Интерфейсы в Go: паттерны и антипаттерны из продакшена

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

Если вы только начинаете с Go, сначала имеет смысл пройти базовый маршрут из статьи «Go для начинающих: дорожная карта». Здесь я считаю, что вы уже писали структуры и методы.

Как устроен интерфейс

Интерфейс объявляет методы, но не данные:

type Stringer interface {
    String() string
}

Любой тип, у которого есть метод String() string, автоматически удовлетворяет Stringer. Не нужно явно писать implements, не нужно импортировать пакет с интерфейсом. Если методы совпали по сигнатуре, тип подходит. Это называется структурной типизацией. В этом главное отличие Go от Java или C#.

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

Интерфейс внутри это значение из пары двух указателей. Первый указывает на информацию о типе (какой конкретный тип лежит внутри и таблица его методов), второй на сами данные. Пустой интерфейс без данных это пара (nil, nil). Запомните её: через несколько абзацев поговорим про коварную ошибку с интерфейсами.

Маленькие интерфейсы лучше больших

Идиоматичный интерфейс в Go узкий. От одного до трёх методов. Посмотрите на стандартную библиотеку: io.Reader, io.Writer, io.Closer это по одному методу каждый.

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

Чем меньше интерфейс, тем больше типов под него подходят и тем проще писать тесты. Функция, которая принимает io.Reader, работает с файлом, сетевым соединением, буфером в памяти, ответом HTTP потоком распакованных данных gzip. Ей всё равно, откуда байты.

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

Есть народная мудрость по этому поводу:

The bigger the interface, the weaker the abstraction.

Чем больше интерфейс, тем слабее абстракция. Это слова Роба Пайка, одного из авторов языка.

Определяйте интерфейс у потребителя, а не у реализации

Это правило ломает привычку тех, кто пришёл из Java. Там интерфейс обычно лежит рядом с реализацией: UserService и его интерфейс IUserService в одном пакете. В Go так делать не нужно.

Интерфейс принадлежит потребляющему коду, а не коду с реализацией. Пакет, который отдаёт данные, возвращает конкретную структуру. Пакет, который данные потребляет, сам объявляет узкий интерфейс ровно под те методы, что ему нужны.

Плохо:

// пакет storage навязывает большой интерфейс всем
package storage

type Storage interface {
    GetUser(id int) (*User, error)
    SaveUser(u *User) error
    DeleteUser(id int) error
    ListUsers() ([]*User, error)
    // ...ещё десяток методов
}

type Postgres struct{ /* ... */ }

Хорошо:

// пакет storage просто отдаёт конкретный тип
package storage

type Postgres struct{ /* ... */ }

func (p *Postgres) GetUser(id int) (*User, error) { /* ... */ }
func (p *Postgres) SaveUser(u *User) error        { /* ... */ }
// пакет report объявляет ровно тот контракт, который ему нужен
package report

type userGetter interface {
    GetUser(id int) (*User, error)
}

func Build(g userGetter, id int) (*Report, error) {
    u, err := g.GetUser(id)
    // ...
}

report не зависит от storage. Он зависит от одного метода. В тест вы подкладываете заглушку с единственным GetUser, а не мок на десять методов. И report не тащит за собой драйвер базы данных, когда вы прогоняете его модульные тесты.

Заметьте: имя интерфейса начинается со строчной буквы. Он не экспортируется. Это нормально и даже хорошо. Контракт нужен внутри пакета, наружу его выставлять незачем.

Принимайте интерфейсы, возвращайте структуры

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

Это общий подход не только для стандартной библиотеки, а для всего сообщества в целом.

// Logger это узкий интерфейс с одним методом, например Log(...)

// принимаем интерфейс: гибкость на входе
func NewServer(log Logger) *Server { /* ... */ }

// возвращаем структуру: вся мощь типа на выходе
func New() *Client { /* ... */ }

Если вернуть интерфейс, вы заранее ограничиваете пользователя теми методами, которые придумали сегодня. Захочет он завтра вызвать новый метод вашей структуры, а его нет в интерфейсе. Конкретный тип такого ограничения не накладывает.

Из правила есть исключения. error это интерфейс, и его возвращают постоянно. Так задумано: вызывающему обычно нужен только метод Error() плюс проверки через errors.Is и errors.As. Подробно про это в разборе обработки ошибок в Go.

Ловушка typed nil

Это самая коварная ошибка с интерфейсами. Она, в том числе, регулярно всплывает на собеседованиях.

Вспомните: интерфейс это пара (тип, значение). Интерфейс равен nil, только когда обе части пустые. Если в интерфейс положить нулевой указатель конкретного типа, пара станет (*T, nil). Тип есть, значит, интерфейс уже не nil.

type MyError struct{}

func (e *MyError) Error() string { return "boom" }

func doWork() error {
    var e *MyError // e == nil
    // ...что-то пошло не так, но мы забыли присвоить e
    return e // возвращаем nil-указатель, но завёрнутый в интерфейс
}

func main() {
    err := doWork()
    if err != nil {
        fmt.Println("ошибка!", err) // сработает, хотя ошибки по сути нет
    }
}

doWork возвращает нулевой *MyError. На выходе он упаковывается в интерфейс error и превращается в пару (*MyError, nil). Сравнение err != nil истинно, потому что тип внутри не пустой. Программа объявляет ошибку, которой нет.

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

type APIError struct{ Code int }

func (e *APIError) Error() string { return fmt.Sprintf("api: код %d", e.Code) }

// возвращает конкретный тип, это первая ошибка
func fetch() (*APIError, error) {
    // ...запрос прошёл успешно
    return nil, nil
}

func handle() error {
    apiErr, _ := fetch()
    return apiErr // *APIError(nil) уезжает в интерфейс error
}

handle объявлен как func() error, и при возврате нулевой *APIError оборачивается в интерфейс. На стороне вызывающего if err := handle(); err != nil снова истинно. Хуже того, errors.Is и errors.As тут не спасают: значение внутри действительно есть, проверка типа проходит, и обёртка вокруг ошибок не отличает «настоящую» ошибку от nil-указателя в интерфейсе. Подробнее про сравнение и развёртывание ошибок в разборе обработки ошибок в Go. Эту же ловушку любят давать на собеседованиях уровня senior: разбор частых вопросов собрал в статье про senior-собеседование по Go.

Исправление простое. Не объявляйте переменную конкретного типа под возврат ошибки. Возвращайте явный nil:

func doWork() error {
    if somethingFailed {
        return &MyError{}
    }
    return nil // ровно nil-интерфейс, без обёртки
}

Функции, отдающие ошибку, должны возвращать тип error, а не конкретный тип ошибки. И возвращать nil явно, когда всё хорошо.

Проверка реализации на этапе компиляции

Из-за неявного удовлетворения интерфейса легко не заметить, что тип перестал ему соответствовать. Например, вы поменяли сигнатуру метода, а тип используется только через рефлексию или через фабрику. Компилятор промолчит, упадёт во время выполнения.

Есть идиома, которая ловит это на этапе сборки:

var _ io.Writer = (*MyWriter)(nil)

Строка ничего не делает во время выполнения. Она присваивает нулевой указатель *MyWriter пустой переменной типа io.Writer. Если *MyWriter не реализует io.Writer, проект не соберётся, и вы узнаете об этом сразу. Я ставлю такую строку рядом с объявлением типа, который обязан соответствовать важному интерфейсу.

Интерфейсы ради тестов: где граница

Одна из основных причин заводить интерфейс в Go это не «архитектура», а необходимость подменить зависимость в тесте. Базу, HTTP-клиент, очередь, часы. Это законно и полезно. Узкий интерфейс у потребителя позволяет подложить заглушку без поднятия реальной инфраструктуры.

type clock interface {
    Now() time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

// в тесте
type fakeClock struct{ t time.Time }

func (f fakeClock) Now() time.Time { return f.t }

Теперь логику, зависящую от времени, можно протестировать с предсказуемым результатом. Подробнее про подходы к тестам в Go в отдельной статье про тестирование.

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

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

Интерфейс или конкретный тип: что выбрать

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

СитуацияЧто брать
Аргумент функции, который хочется подменить в тестеУзкий интерфейс у потребителя
Зависимость с несколькими реализациями (разные БД, транспорты)Интерфейс
Возвращаемое значение конструктора (New)Конкретная структура
Одна реализация навсегда, мокать нечегоКонкретный тип
Поле структуры, которое всегда одного типаКонкретный тип
Возврат ошибкиТип error (исключение из «возвращайте структуры»)

Главное отличие от Java и C# в том, что там интерфейс часто заводят авансом, чтобы «была абстракция». В Go это лишнее. Структура и так гибкая, а интерфейс из-за неявного удовлетворения добавляется в любой момент без правки реализации. Поэтому конкретный тип это разумное значение по умолчанию, а интерфейс вводится под конкретную потребность: тест, вторая реализация, разрыв зависимости между пакетами.

Антипаттерн: пакет interfaces и интерфейс на каждый сервис

Частый паттерн из мира корпоративной разработки, который в Go вреден. Команда заводит пакет interfaces или contracts и складывает туда интерфейсы для всех сервисов. Или к каждой структуре FooService заранее пишет интерфейс FooServiceInterface с теми же методами один в один.

Чем это плохо:

Признак здорового кода на Go: интерфейсов мало, они узкие, они рядом с тем, кто их использует. Признак переноса чужих привычек: интерфейс на каждый класс, отдельный пакет под контракты, суффикс Interface в именах.

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

Интерфейсы складываются из интерфейсов. Это позволяет собирать контракт из готовых кирпичей, не повторяя сигнатуры:

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

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

io.ReadCloser в стандартной библиотеке устроен ровно так. Тело HTTP-ответа (resp.Body) это io.ReadCloser: его читают, а потом закрывают. Композиция позволяет функции просить ровно нужный набор. Нужно только читать, просите io.Reader. Нужно ещё закрывать, просите io.ReadCloser.

Пустой интерфейс и any

Пустой интерфейс interface{} не требует ни одного метода, поэтому ему удовлетворяет любой тип. С Go 1.18 у него появился псевдоним any. Это буквально одно и то же, any просто читается лучше.

Пустой интерфейс это возможность частично игнорировать систему типов. Иногда это бывает оправдано: контейнеры до дженериков, разбор произвольного JSON, форматирование в fmt. Но при этом вы теряете проверки компилятора. Чтобы достать значение обратно, нужен type assertion или type switch:

func describe(v any) string {
    switch x := v.(type) {
    case int:
        return fmt.Sprintf("целое %d", x)
    case string:
        return fmt.Sprintf("строка %q", x)
    default:
        return "неизвестный тип"
    }
}

С появлением дженериков в Go 1.18 многие старые применения any или interface{} стали лишними. Раньше контейнер «слайс чего угодно» писали через []interface{}. Теперь то же самое выражается параметром типа и сохраняет статическую проверку. Если интуитивно написали any, то сначала спросите себя, не решается ли задача дженериком.

Цена интерфейса: производительность

Интерфейс не бесплатен, хотя в большинстве кода об этом можно не думать.

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

Второе, и более важное на практике. Когда вы кладёте значение в интерфейс, оно может уехать в кучу. Интерфейс хранит указатель на данные, поэтому небольшое значение, которое жило на стеке, при упаковке в интерфейс нередко аллоцируется в куче. Классический пример: fmt.Println(x) упаковывает x в any, и из-за этого значение убегает в кучу. На редком вызове это незаметно. В цикле на горячем пути такие аллокации видно в профиле.

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

FAQ

Чем интерфейс отличается от структуры в Go?
Структура описывает данные и методы конкретного типа. Интерфейс описывает только набор методов, без данных, и не привязан к одной реализации. Любой тип, у которого есть нужные методы, удовлетворяет интерфейсу автоматически. Структуру берут по умолчанию, а интерфейс вводят, когда нужно подменить реализацию: в тесте, при второй реализации или для разрыва зависимости между пакетами.
Что такое typed nil и почему err != nil срабатывает на nil?
Интерфейсное значение это пара (тип, данные). Интерфейс равен nil, только когда пусты обе части. Если вернуть нулевой указатель конкретного типа (например *MyError) из функции, объявленной как error, пара станет (*MyError, nil). Тип внутри не пустой, поэтому err != nil истинно, хотя ошибки по сути нет. Лечится тем, что функция возвращает тип error, а nil возвращается буквально, без обёртки в конкретный тип.
any и interface{} это одно и то же?
Да. С Go 1.18 any это псевдоним для interface{}, поведение полностью идентично, any просто читается лучше. И тот, и другой не требуют ни одного метода, поэтому им удовлетворяет любой тип. Прежде чем тянуться к any, проверьте, не решается ли задача дженериком: он сохраняет статическую проверку типов, которую пустой интерфейс теряет.
Где объявлять интерфейс: рядом с реализацией или у потребителя?
У потребителя. Пакет с реализацией возвращает конкретную структуру, а пакет, который её использует, сам объявляет узкий интерфейс ровно под нужные методы. Так потребитель зависит от одного или двух методов, а не от всего сервиса, и не тащит за собой лишние зависимости в тестах. Интерфейс при этом обычно не экспортируется: контракт нужен внутри пакета.
Нужен ли интерфейс, если реализация одна?
Обычно нет. Интерфейс с единственной реализацией, которую вы не мокаете, это пустая абстракция: он усложняет навигацию по коду и ничего не даёт взамен. Работайте с конкретным типом. Интерфейс легко ввести позже, когда появится вторая реализация или потребность в моке, и благодаря неявному удовлетворению реализацию править не придётся.
Замедляют ли интерфейсы программу на Go?
В большинстве кода разница незаметна. Вызов метода через интерфейс это динамическая диспетчеризация, её сложнее инлайнить, чем прямой вызов у структуры. Важнее то, что упаковка значения в интерфейс нередко отправляет его в кучу: интерфейс хранит указатель на данные. На горячем пути в миллионы вызовов это видно в профиле. Сначала измерьте, потом оптимизируйте точечно.

Итог

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

Отдельные темы кластера разобраны подробнее: конкурентность в Go, обработка ошибок и тестирование. Открывайте по мере надобности.


Теги: