Давайте дружить в Телеграме: рассказываем про новые фичи, общаемся в комментах, прислушиваемся к вашим идеям Подписаться

Использование интерфейсов в Go

Пётр Разумов
Пётр Разумов
Ведущий разработчик
31 мая 2023 г.
787
12 минут чтения
Средний рейтинг статьи: 5

В парадигме объектно-ориентированного программирования понятие интерфейса играет важную роль и тесно связано с одной из трех основополагающих концепций — с инкапсуляцией.

Image2

Интерфейс как соглашение

Если говорить простыми словами, то интерфейс — это некий контракт, согласно которому компоненты системы ожидают друг от друга определенного поведения, например в части обмена информацией. В качестве примера применения такого соглашения можно привести идею «Everything is a file» («Всё есть файл»), которая изначально появилась в Unix. Эта идея заключается в том, что доступ к ресурсам, таким как документы, периферия, некоторые внутренние процессы и даже сетевая коммуникация, представляется в виде потока байтов с использованием пространства имен файловой системы. Неоспоримым преимуществом такого подхода является то, что для доступа к огромному количеству разнообразных ресурсов можно использовать один и тот же набор инструментов, утилит или программных библиотек. В объектно-ориентированном программировании (ООП) интерфейс — это описание структуры объекта, но без конкретных деталей реализации.

Интерфейсы в ООП-языках и в Go

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.

Проблемы и решения

С одной стороны такой подход к реализации интерфейсов ведёт упрощению написания программ, но с другой стороны, он может стать источником ошибок, которые порой бывает трудно отловить.

Поясню на примере. Во время работы над клиентской библиотекой для одного популярного 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 не только сообщает нам о том, что тип не реализует интерфейс, но и подсказывает что послужило тому причиной. В нашем случае — это разные сигнатуры методов.

Что это за магия?

Image1

Никакой магии в этом нет. Символ нижнего подчеркивания (_) это специальное имя переменной, когда нам надо присвоить какое-то значение, но не использовать его в дальнейшем. Один из самых распространенных примеров использования таких переменных — это игнорирование ошибок, например:

f, _ := os.Open("/path/to/file")

В приведённом выше примере мы открываем файл, но никак не проверяем на наличие возможных ошибок.

Таким образом мы создаём неиспользуемую переменную типа cache.Interface, а далее присваиваем ей нулевой указатель (nil pointer) на тип реализации (cache.InMemory или cache.OnDisk).

Заключение

В этом материале мы познакомились с понятием «интерфейс» в различных языках программирования. Выяснили, является ли язык программирования Go объектно-ориентированным языком. Узнали о способах определения, является ли тип реализацией интерфейса, при помощи тестов и на этапе компиляции кода.

Зарегистрируйтесь и начните пользоваться
сервисами Timeweb Cloud прямо сейчас

15 лет опыта
Сосредоточьтесь на своей работе: об остальном позаботимся мы
165 000 клиентов
Нам доверяют частные лица и компании, от небольших фирм до корпораций
Поддержка 24/7
100+ специалистов поддержки, готовых помочь в чате, тикете и по телефону