В парадигме объектно-ориентированного программирования понятие интерфейса играет важную роль и тесно связано с одной из трех основополагающих концепций — с инкапсуляцией.
Если говорить простыми словами, то интерфейс — это некий контракт, согласно которому компоненты системы ожидают друг от друга определенного поведения, например в части обмена информацией. В качестве примера применения такого соглашения можно привести идею «Everything is a file» («Всё есть файл»), которая изначально появилась в Unix. Эта идея заключается в том, что доступ к ресурсам, таким как документы, периферия, некоторые внутренние процессы и даже сетевая коммуникация, представляется в виде потока байтов с использованием пространства имен файловой системы. Неоспоримым преимуществом такого подхода является то, что для доступа к огромному количеству разнообразных ресурсов можно использовать один и тот же набор инструментов, утилит или программных библиотек. В объектно-ориентированном программировании (ООП) интерфейс — это описание структуры объекта, но без конкретных деталей реализации.
Go, в отличие, например, от Java, С++ или PHP, не является объектно-ориентированным языком в его классической интерпретации. Отвечая на вопрос, является ли Go объектно-ориентированным языком, авторы не дают однозначного ответа: «И да, и нет.» Несмотря на то, что в Go есть типы и методы и он позволяет использовать объектно-ориентированный стиль программирования, в языке отсутствует иерархия классов (и вообще классы как таковые), а взаимосвязь между конкретными и абстрактными (интерфейсными) типами является неявной, в отличие от тех же Java, C++ или PHP.
В «классических» ООП-языках реализация классом интерфейса заключается как в описании самого класса (например public class MyClass implements MyInterface
), так и в требовании к коду класса реализовать все описанные в интерфейсе методы, точно соответствуя заявленным сигнатурам из описания этого интерфейса.
В языке Go нет необходимости в явном указании на то, что конкретный тип реализует какой-то интерфейс, достаточно лишь реализовать все методы, описанные в интерфейсе, и это уже будет считаться реализацией интерфейса.
Так, например, в следующем примере на языке Java, класс Circle
не будет являться реализацией интерфейса Shape
, потому что в описании класса отсутствует упоминание о реализации интерфейса, даже несмотря на то, что он содержит методы, соответствующие методам, указанным в интерфейсе. А вот класс Square
, напротив, будет являться реализацией интерфейса Shape
.
// Shape.java
interface Shape {
public double area();
public double perimeter();
}
// Circle.java
public class Circle {
private double radius;
// constructor
public Circle(double radius) {
this.radius = radius;
}
public double area() {
return this.radius * this.radius * Math.PI;
}
public double perimeter() {
return 2 * this.radius * Math.PI;
}
}
// Square.java
public class Square implements Shape {
private double x;
// constructor
public Square(double x) {
this.x = x;
}
public double area() {
return this.x * this.x;
}
public double perimeter() {
return 4 * this.x;
}
}
Мы можем легко в этом убедиться, если создадим функцию calculate
, которая будет принимать в качестве аргумента объект-реализацию интерфейса Shape
:
// Calculator.java
public class Calculator {
public static void calculate(Shape shape) {
double area = shape.area();
double perimeter = shape.area();
System.out.printf("Area: %f,%nPerimeter: %f.");
}
public static void main() {
Square s = new Square(20);
Circle c = new Circle(10);
calculate(s);
calculate(c);
}
}
Если попытаться скомпилировать такой код, мы получим ошибку:
javac Calculator.java
Calculator.java:16: error: incompatible types: Circle cannot be converted to Shape
calculate(c);
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
В языке Go отсутствует требование к типу на указание реализуемых интерфейсов. Достаточно лишь реализовать те, методы, которые описаны в интерфейсе (приведенный ниже код адаптирован из книги Михалиса Цукалоса «Golang для профи»):
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
Perimeter() float64
}
type Square struct {
X float64
}
func (s Square) Area() float64 {
return s.X * s.X
}
func (s Square) Perimeter() float64 {
return 4 * s.X
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return c.Radius * c.Radius * math.Pi
}
func (c Circle) Perimeter() float64 {
return 2 * c.Radius * math.Pi
}
func Calculate(x Shape) {
fmt.Printf("Area: %f,\nPerimeter: %f\n\n", x.Area(), x.Perimeter())
}
func main() {
s := Square{X: 20}
c := Circle{Radius: 10}
Calculate(s)
Calculate(c)
}
Area: 400.000000,
Perimeter: 80.000000
Area: 314.159265,
Perimeter: 62.831853
Если же мы попытаемся использовать в качестве аргумента для функции Calculate
тип, который не реализует интерфейс Shape
, то мы получим ошибку на этапе компиляции, как в следующем примере, где тип Rectangle
не реализует интерфейс Shape
(отсутствует метод Perimeter
):
package main
import "fmt"
type Shape interface {
Area() float64
Perimeter() float64
}
type Rectangle struct {
W, H float64
}
func (r Rectangle) Area() float64 {
return r.W * r.H
}
func Calculate(x Shape) {
fmt.Printf("Area: %f,\nPerimeter: %f\n\n", x.Area(), x.Perimeter())
}
func main() {
r := Rectangle{W: 10, H: 20}
Calculate(r)
}
./main.go:25:12: cannot use r (variable of type Rectangle) as type Shape in argument to Calculate:
Rectangle does not implement Shape (missing Perimeter method)
Обратите внимание на то, как компилятор языка Go предоставляет более информативное сообщение об ошибке, в отличие от компилятора языка Java.
cloud
С одной стороны такой подход к реализации интерфейсов ведёт упрощению написания программ, но с другой стороны, он может стать источником ошибок, которые порой бывает трудно отловить.
Поясню на примере. Во время работы над клиентской библиотекой для одного популярного API, нам потребовалось реализовать механизм кэширования, то есть сохранения уже полученных данных локально «на клиенте» для того, чтобы избежать повторных запросов к удалённому серверу API. Доступ к API предоставлялся в рамках пакетов с лимитированным количеством обращений в месяц, так что использование механизма кэширования было экономически выгодным для пользователей. Но поскольку варианты использования этой библиотеки не ограничиваются лишь веб-приложениями (хотя, это и самый распространённый случай), мы не могли реализовать один единственный способ кэширования, который бы удовлетворял всех. Даже в случае с приложениями, выполняющимися в рамках веб-сервера, вариантов кэширования как минимум два (а то и все три!) — кэширование в памяти сервера и использование, например, Memcached или Redis. Но есть же ещё и CLI-приложения — приложения с интерфейсом в виде командной строки. И те варианты, которые отлично работают для веб-приложений, совсем не годятся для консольных. В итоге, мы не стали реализовывать один единственный способ кэширования данных, а написали свой интерфейс, перечислив в нем методы для получения данных из кэша и занесения данных в кэш. Так же, мы написали реализации этого интерфейса для различных вариантов кэширования. Таким образом, пользователи нашей библиотеки (другие программисты) могли, для решения своих задач, либо воспользоваться одной из реализаций, поставляемых в комплекте с библиотекой, либо написать свою реализацию интерфейса кэширования под свои нужды.
Таким образом, сложилась ситуация, что реализация интерфейса и применение этой реализации были как бы разнесены по разным кодовым базам: реализации в «нашей» библиотеке, а применение — в приложениях других программистов. Перед нами встала задача проверить, что наши собственные реализации действительно являются корректными реализациями нашего же интерфейса.
Представим, что у нас есть интерфейс cache.Interface
и типы cache.InMemory
и cache.OnDisk
:
package cache
import (
"encoding/json"
"fmt"
"os"
"sync"
)
type Interface interface {
Get(key string) (value []byte, ok bool)
Set(key string, value []byte)
Delete(key string)
}
type InMemory struct {
mu sync.Mutex
items map[string][]byte
}
func NewInMemory() *InMemory {
return &InMemory{
items: make(map[string][]byte),
}
}
func (c *InMemory) Get(key string) (value []byte, ok bool) {
c.mu.Lock()
value, ok = c.items[key]
c.mu.Unlock()
return value, ok
}
func (c *InMemory) Set(key string, value []byte) {
c.mu.Lock()
c.items[key] = value
c.mu.Unlock()
}
func (c *InMemory) Delete(key string) {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
}
type OnDisk struct {
mu sync.Mutex
items map[string][]byte
filename string
}
func NewOnDisk(filename string) *OnDisk {
return &OnDisk{
items: make(map[string][]byte),
filename: filename,
}
}
func (c *OnDisk) Get(key string) (value []byte, err error) {
c.mu.Lock()
defer c.mu.Unlock()
f, err := os.Open(c.filename)
if err != nil {
return nil, err
}
defer f.Close()
dec := json.NewDecoder(f)
if err := dec.Decode(&c.items); err != nil {
return nil, err
}
value, ok := c.items[key]
if !ok {
return nil, fmt.Errorf("no value for key: %s", key)
}
return value, nil
}
func (c *OnDisk) Set(key string, value []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
f, err := os.Create(c.filename)
if err != nil {
return err
}
enc := json.NewEncoder(f)
if err := enc.Encode(c.items); err != nil {
return err
}
return nil
}
func (c *OnDisk) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
f, err := os.Create(c.filename)
if err != nil {
return err
}
enc := json.NewEncoder(f)
if err := enc.Encode(c.items); err != nil {
return err
}
return nil
}
Теперь нам надо удостовериться, что оба наших типа — и cache.InMemory
и cache.OnDisk
реализуют cache.Interface
. Как этого можно достичь? Ответ, который первым приходит в голову, — написать тест.
Напишем два небольших теста, чтобы проверить, что наши типы cache.InMemory
и cache.OnDisk
реализуют интерфейс cache.Interface
:
package cache
import "testing"
func TestInMemoryImplementsInterface(t *testing.T) {
var v interface{} = NewInMemory()
_, ok := v.(Interface)
if !ok {
t.Error("InMemory does not implement Interface")
}
}
func TestOnDiskImplementsInterface(t *testing.T) {
var v interface{} = NewOnDisk("cache.json")
_, ok := v.(Interface)
if !ok {
t.Error("OnDisk does not implement Interface")
}
}
Запустим эти тесты:
go test -v ./cache
=== RUN TestInMemoryImplementsInterface
--- PASS: TestInMemoryImplementsInterface (0.00s)
=== RUN TestOnDiskImplementsInterface
cache_test.go:17: OnDisk does not implement Interface
--- FAIL: TestOnDiskImplementsInterface (0.00s)
FAIL
FAIL cache 0.002s
FAIL
Как видно из результатов выполнения тестов, тип cache.InMemory
реализует интерфейс cache.Interface
, а вот тип cache.OnDisk
— нет.
Хотя вариант с использованием тестов для проверки реализации интерфейса рабочий, он всё же требует от разработчика определённой дисциплины. Надо не забыть написать тесты, а также, что не менее важно, необходимо не забыть эти тесты время от времени запускать.
К счастью, есть более простой способ проверки того, реализует ли конкретный тип требуемый интерфейс. Для этого надо написать всего одну строчку кода (у нас два типа, поэтому две строчки) и запустить go build
.
package cache
// ...
var _ Interface = (*InMemory)(nil)
var _ Interface = (*OnDisk)(nil)
go build ./cache
cache/cache.go:6:19: cannot use (*OnDisk)(nil) (value of type *OnDisk) as type Interface in variable declaration:
*OnDisk does not implement Interface (wrong type for Delete method)
have Delete(key string) error
want Delete(key string)
Как видите, компилятор языка Go не только сообщает нам о том, что тип не реализует интерфейс, но и подсказывает что послужило тому причиной. В нашем случае — это разные сигнатуры методов.
Никакой магии в этом нет. Символ нижнего подчеркивания (_
) это специальное имя переменной, когда нам надо присвоить какое-то значение, но не использовать его в дальнейшем. Один из самых распространенных примеров использования таких переменных — это игнорирование ошибок, например:
f, _ := os.Open("/path/to/file")
В приведённом выше примере мы открываем файл, но никак не проверяем на наличие возможных ошибок.
Таким образом мы создаём неиспользуемую переменную типа cache.Interface
, а далее присваиваем ей нулевой указатель (nil pointer
) на тип реализации (cache.InMemory
или cache.OnDisk
).
Разворачивайте свои проекты на Go<br>в облаке Timeweb Cloud
В этом материале мы познакомились с понятием «интерфейс» в различных языках программирования. Выяснили, является ли язык программирования Go объектно-ориентированным языком. Узнали о способах определения, является ли тип реализацией интерфейса, при помощи тестов и на этапе компиляции кода.