В языке Go интерфейс — это набор методов, которые могут быть реализованы типом. Иными словами, интерфейс — описание того, что может сделать тип.

Если тип имеет методы, описанные в интерфейсе, то этот тип удовлетворяет интерфейсу.

Синтаксис интерфейса очень простой. Вот пример некоторого интерфейса. Обратите внимание, что интерфейс описываем как тип:

type MyInterface interface {
    Method1(int) int
    Method2(a string) string 
    //.... может быть ещё много методов
} 

В фигурных скобках указывается имя метода, список его аргументов и возвращаемых значений. Названия аргументов могут быть опущены, поэтому достаточно указать тип. Но для лучшего понимания кода их лучше писать: описание метода Create(id int, name string, email string) bool более понятно, чем Create(int, string, string) bool, хотя формально эти методы идентичны.

Интерфейс описывает контракт между различными частями программы. Передавая некоторую переменную в разные части программы, описываем, какие методы ожидаем от этой переменной.

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

Приведём простой пример. Представим, что у нас есть некоторая структура Person, описывающая человека. В различных областях жизни (и нашей программы) человек может выступать в разных ролях: например, быть студентом, работником, родителем и кем-нибудь ещё. Для конкретной области не имеет значения, кем он является в других. На работе от него ждут работы, в обучении — сдачи домашних работ. Как родитель он может сообщить информацию о детях. А ещё он может сообщать информацию о себе.

Опишем эти отношения в коде:

package person
 
// Person - структура, описывающая человека
type Person struct {
    name string
    homework string
    children []*Person
}
 
// DoHomework — делает домашнюю работу 
func (p Person) DoHomework() string {
    return p.homework
}
 
// Children — сообщает информацию о детях
func (p Person) Children() []*Person {
    return p.children
}
 
// Work — выполняет поручения на работе
func (p Person) Work( tasks []string ) string {
    s := p.name + " work:"
    for _,task :=  range tasks {
        s += "\n I do " + task    
    }   
    return s
}
 
// String — сообщает информацию о себе
func (p Person) String() string {
    return p.name
} 

Естественно, не требуется одновременное наличие этих методов в разных участках программы. Более того, их вызов может нарушить нормальную работу программы.

Для примера опишем другой пакет, представляющий собой место работы:

package company
 
// Worker — интерфейс работника компании 
type Worker interface {
    // всё, что он должен уметь делать, — это работать
    Work(tasks []string) string
}
 
// Company — структура компании 
type Company struct {
    // personal — сотрудники компании
    // обратите внимание, мы создали слайс сотрудников компании, то есть слайс переменных интерфейсного типа Worker
    personal [] Worker
}
 
// Hire — наём нового сотрудника
// Сотрудник может быть любого типа: человек, робот или сторожевая собака. Главное, чтобы он умел работать, то есть удовлетворял интерфейсу Worker
// Go ещё на этапе компиляции проверяет, соответствует ли интерфейсу переданная переменная
func* Company) Hire( newbie Worker ) {
    с.personal = append(с.personal, newbie) 
}
 
// Process — работа конкретного сотрудника 
func (с Company) Process ( id int,  tasks []string) (res string) {
    return c.personal[id].Work(tasks)
}

Tip

Обратите внимание, что для Person явно не указывается, что он реализует интерфейс Worker. Снова вспоминаем утиную типизацию: если что-то выглядит как утка, плавает как утка и крякает как утка, то это утка.

В этом и есть суть полиморфизма. company может работать с разными сущностями, единственное требование к которым — уметь работать. Это требование и описывается через интерфейс.

Теперь соединим эти пакеты вместе.

package main
 
import (
    "company"
    "person"
)
 
func main() {
    pers := person.Person{}
    comp := company.Company{}
    
    comp.Hire(pers) // мы передаём переменную типа Person в функцию, аргументом которой является переменная Worker! 
} 

На этапе компиляции компилятор проверяет, можно ли person присвоить переменной типа Worker. Для этого проверяется, что тип Person имеет все методы интерфейса Worker. В нашем случае они есть, всё работает.

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

Интерфейсы добавляют гибкости и снижают связность кода. Пакеты person и company ничего не знают друг о друге, но могут успешно взаимодействовать.

Продолжим рассматривать наш пример.

Предположим, что мы решили добавить в нашу программу роботов, которые могут работать так же, как и люди:

package robot
 
import "fmt"
 
// Robot — тип робота
type Robot struct {
    model       string
    serialId    int
    workCounter int
}
 
func (r Robot) String() string {
    return fmt.Sprintf("Robot %s serialID %d", r.model, r.serialId)
}
 
// Work — робот выполняет работы и запоминает количество выполненных задач. Поэтому получатель метода — по указателю
func (r *Robot) Work(tasks []string) string {
    res := fmt.Sprintf("%s work:", r)
    for _, task := range tasks {
        res += "\n I do " + task
    }
    r.workCounter += len(tasks)
    return res
} 

Так как тип *Robot реализует интерфейс Worker, то можно устроить робота на работу в компанию.

Tip

С точки зрения Go типы Robot и *Robot (указатель) — разные. В примере метод Work привязан именно к *Robot. Так как формально тип Robot не реализует интерфейс Worker, такой код не скомпилируется:

comp := company.Company{}
 
robo := Robot{};
comp.Hire(robo); 

Поэтому будем использовать указатель на робота. Действительно, в этом есть логика. Раз работа в компании изменяет внутреннее состояние робота, то нужно передать указатель именно на неё.

comp := company.Company{}
 
robo := &Robot{};
comp.Hire(robo); 

Такой код скомпилируется нормально. Обратите внимание, что в самой компании ничего не пришлось менять. Мы просто создали роботов, которые удовлетворяют всем её требованиям к сотрудникам. Если бы компания работала со структурами, то нам бы пришлось создавать отдельные методы работы в ней с роботами и с людьми.

Интерфейсы и код внешних библиотек

Теперь, когда вы знаете синтаксис описания и реализации интерфейсов, рассмотрим практики их использования.

Раз в Go не нужно явно указывать, что тип реализует интерфейс, можно писать свои интерфейсы и к библиотечному коду.

Допустим, вы пользуетесь готовой библиотекой, которая посылает сетевые запросы к API:

type BigAPIClient struct {
    // пропустим код
}
 
func (c *BigAPIClient) Connect() error {
    // ...
}
 
func (c *BigAPIClient) Close() error {
    // ...
}
 
func (c *BigAPIClient) FetchMessages() ([]Message, error) {
    // ...
}
 
func (c *BigAPIClient) SendMessage(email string, message string) error {
    // ...
}
 
func (c *BigAPIClient) SendStatus(status string) error {
    // ...
} 

Допустим, вашему коду хватит двух методов. Если вы хотите протестировать интеграцию с этой библиотекой, вам в ней ничего не нужно исправлять. В Go можно легко заменить вызовы методов типа на вызовы методов интерфейса:

type Client interface {
    FetchMessages() ([]Message, error)
    SendMessage(email string, message string) error
}
 
func MyFunc(client Client) {
    // в параметрах вместо типа *BigAPIClient принимаем интерфейс Client
    // код функции и вызывающий код остаются без изменений
 
    messages, err := client.FetchMessages() 
    if err != nil {
        // ...
    }
 
    // ...
}
 
func main() {
    client := &BigAPIClient{}
    MyFunc(client)
} 

Напишем тестовую заглушку, чтобы протестировать интеграцию.

type MockClient struct {
}
 
func (c *MockClient) FetchMessages() ([]Message, error) {
    return []Message{{Text: "привет"}, {Text: "тестовый пример"}}, nil
}
 
func (c *MockClient) SendMessage(email string, message string) error {
    // ...
} 

Теперь функция одинаково работает как c библиотечными методами, так и с подменёнными тестовыми методами:

func main() {
    realClient := &BigAPIClient{}
    MyFunc(realClient)
 
    mockClient := &MockClient{}
    MyFunc(mockClient)
} 

Note

В коде библиотек можно встретить такую конструкцию:

var _ Client = (*MockClient)(nil) 

Эта строчка добавляет явную проверку — реализует ли тип MockClient интерфейс Client. Если данный тип не соответствует спецификации интерфейса, код не скомпилируется. Такая конструкция позволяет сделать проверку до того, как появится код, использующий этот тип.

Интерфейсы должны быть компактными

В Go принято делать интерфейсы по возможности маленькими. Чем проще интерфейс, тем легче воспринимать код. Если в интерфейсе больше 5–10 методов, значит, пора его делить.

Хорошая практика — объявлять интерфейс даже с одним методом. Часто такие интерфейсы называют по имени метода и добавляют суффикс -er.

type Stringer interface {
    String() string
} 

Композиция интерфейсов

В описании интерфейса можно не только перечислять методы, но и встраивать уже существующие интерфейсы — их можно комбинировать:

type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}
 
type FileHandle interface {
    Reader
    Writer
    Close() error
} 

В итоге интерфейс FileHandle будет содержать три метода: Read, Write и Close.

Композиция интерфейсов — очень важная и удобная в применении вещь. Она позволяет встроить в интерфейс требования другого интерфейса, включая интерфейсы из других пакетов. Например, если компания планирует отправлять всех своих сотрудников на обучение, то она может просто встроить интерфейс Student в интерфейс Worker. Компания может не знать, какие требования задаёт интерфейс Student, но перекладывает ответственность за это на сотрудников.


Ключевые мысли

  • Интерфейс — это тип языка Go, который описывает не структуру переменной, а её поведение.
  • Реализация интерфейса — это создание такого типа, который реализует поведение, описанное интерфейсом.
  • Переменной типа интерфейс может быть присвоен объект любого типа, если он удовлетворяет этому интерфейсу.
  • С точки зрения языка типы T и *T — разные.
  • Интерфейс описывается в том же пакете, в котором применяется, и является частью его контракта для внешних пакетов, которые его реализуют.
  • Интерфейсы позволяют снизить связность кода.
  • Интерфейсы позволяют реализовать полиморфизм и сокрытие в парадигме ООП.
  • Интерфейсы можно комбинировать.
  • Можно писать свои интерфейсы и к библиотечному коду.

📂 Go | Последнее изменение: 26.08.2024 19:59