Интерфейс описывает, что объект умеет делать, не говоря, как он устроен внутри. В Go это сделано необычно: тип реализует интерфейс автоматически, без единого слова об этом в коде. Разберём, как это работает и почему такой подход меняет привычки.
Зачем нужны интерфейсы
Представь функцию, которая что-то логирует. Сегодня она пишет в консоль, завтра — в файл, послезавтра — в сетевой сервис. Если функция жёстко завязана на конкретный тип, каждый новый получатель — переписывание. Если она принимает интерфейс «что-то, во что можно писать», то любой подходящий тип подойдёт без изменений в самой функции.
Короткая формула: интерфейс — это набор методов, которые тип обязан иметь. Кто эти методы имеет — тот и подходит.
type Writer interface {
Write(data []byte) (int, error)
}
Любой тип с методом Write(data []byte) (int, error) считается Writer. Файл, буфер в памяти, сетевое соединение — все подходят под один контракт.
Неявная реализация (duck typing на этапе компиляции)
В большинстве языков нужно прямо написать «этот класс реализует этот интерфейс» (implements, : IWriter и так далее). В Go этого нет. Если у типа есть все методы интерфейса — он его реализует. Автоматически.
live example
package main
import "fmt"
type Greeter interface {
Greet() string
}
type Russian struct{}
// Метод Greet делает Russian пригодным как Greeter — без объявлений.
func (r Russian) Greet() string {
return "Привет"
}
func sayHello(g Greeter) {
fmt.Println(g.Greet())
}
func main() {
sayHello(Russian{}) // работает: у Russian есть метод Greet
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Это называют duck typing («если крякает как утка — значит, утка»), но с важной оговоркой: проверка происходит на этапе компиляции, а не в момент запуска. Забыл метод — программа просто не соберётся, а не упадёт у пользователя.
Плюс такого подхода: интерфейс можно объявить там, где он нужен (в пакете-потребителе), а тип, который под него подходит, — где угодно, даже в чужой библиотеке, которую ты не можешь править. Тип и контракт не обязаны знать друг о друге.
Если хочешь явно убедиться, что тип реализует интерфейс, есть идиома проверки на этапе компиляции:
var _ Greeter = Russian{} // не соберётся, если Russian перестанет подходить
Одна тонкость связана с receiver-ами. Если метод объявлен на указателе (func (r *Russian) Greet()), интерфейс реализует только *Russian, а не Russian: в набор методов значения методы с pointer receiver не входят. Проверка тогда пишется как var _ Greeter = (*Russian)(nil), а в функцию передают &Russian{} — иначе компилятор скажет, что Russian does not implement Greeter (method Greet has pointer receiver).
Маленькие интерфейсы
Главная привычка в Go — держать интерфейсы маленькими. Чем меньше методов, тем больше типов под него подходит и тем проще его реализовать. Идеал — один метод.
В стандартной библиотеке это видно невооружённым глазом: io.Reader и io.Writer — по одному методу каждый, и на них держится вся работа с потоками данных.
type Reader interface {
Read(p []byte) (n int, err error)
}
Один метод — а под него подходят файлы, сетевые соединения, архивы, буферы. Функция, принимающая io.Reader, работает со всеми сразу. Ещё один такой интерфейс — fmt.Stringer с единственным методом String() string: реализуй его у своего типа, и fmt.Println станет печатать значение так, как решил ты. Маленькие интерфейсы складываются в большие встраиванием: io.ReadWriter — это Reader и Writer, перечисленные внутри нового интерфейса, без повторения методов.
Короткая формула: много маленьких интерфейсов лучше, чем один большой. Большой интерфейс с десятком методов трудно реализовать и неудобно подменять в тестах.
Пустой интерфейс: any
Интерфейс без методов подходит под любой тип — ведь требований ноль. Раньше его писали как interface{}, с Go 1.18 есть понятный синоним — any.
func describe(v any) {
fmt.Println(v) // сюда можно передать что угодно
}
describe(42)
describe("текст")
describe([]int{1, 2, 3})
any удобен, но это отказ от типобезопасности: компилятор больше не знает, что внутри. Чтобы что-то сделать со значением, придётся доставать его конкретный тип обратно (об этом ниже). Поэтому any — крайнее средство: для по-настоящему разнородных данных (например, разбор JSON неизвестной структуры), а не замена нормальным типам.
Утверждение типа (type assertion)
Когда значение лежит в интерфейсе (особенно в any), достать из него конкретный тип помогает утверждение типа: v.(T).
var v any = "привет"
s := v.(string) // достаём строку
fmt.Println(s) // привет
Если тип не совпал, такая форма вызовет panic и уронит программу. Поэтому почти всегда используют безопасную форму с двумя значениями — второе говорит, удалось ли:
var v any = 42
s, ok := v.(string)
if !ok {
fmt.Println("это не строка") // ok == false, `panic` нет
}
Короткая формула: x, ok := v.(T) — всегда, голую v.(T) — только когда уверен на 100%.
type switch
Когда вариантов несколько, проверять по одному утомительно. Для этого есть type switch — switch по типу значения:
func describe(v any) string {
switch x := v.(type) {
case int:
return "целое число"
case string:
return "строка длиной " + strconv.Itoa(len(x)) // x здесь уже string
case bool:
return "логическое значение"
default:
return "неизвестный тип"
}
}
В каждой ветке переменная x уже имеет нужный конкретный тип — приводить ничего не надо (strconv.Itoa из стандартной библиотеки превращает число в строку). Это основной инструмент, когда в any может прийти несколько разных типов.
«Принимай интерфейсы, возвращай структуры»
Распространённый совет в Go: функция принимает интерфейс, а возвращает конкретный тип (структуру).
Зачем принимать интерфейс: функция требует от вызывающего только то, что реально использует. Передать можно любой подходящий тип, в том числе подделку в тестах. Гибкость на входе.
// Хорошо: нужен только Read — просим минимум.
func countBytes(r io.Reader) (int, error) { /* ... */ }
Зачем возвращать структуру: вызывающий получает полноценный конкретный тип со всеми полями и методами, ничего не теряя. Если бы функция вернула интерфейс, она бы заранее урезала то, что доступно вызывающему.
// Хорошо: вернули конкретный *bytes.Buffer, у вызывающего все его возможности.
func newBuffer() *bytes.Buffer { /* ... */ }
Ещё одно следствие: не вводи интерфейс заранее, «на всякий случай». В Go его можно объявить в любой момент, когда реально появится второй вариант реализации. Поэтому интерфейсы добавляют по факту нужды, а не авансом.
Связь с backend-Go
Здесь мы говорим про интерфейсы как возможность языка. Как они применяются в реальной серверной разработке — подмена зависимостей в HTTP-обработчиках, port-интерфейсы для базы данных, мок-реализации в тестах — это раздел /go/. Там те же интерфейсы работают как швы между слоями приложения.
Глубже: интерфейс, внутри которого nilрасширенное
Значение интерфейсного типа — это пара: конкретный тип и указатель на данные. Интерфейс равен nil, только когда пусты оба. Отсюда ловушка, на которую наступает почти каждый, и чаще всего с error:
live example
package main
import "fmt"
type NotFound struct{ ID int }
func (e *NotFound) Error() string { return fmt.Sprintf("нет записи %d", e.ID) }
func find(id int) error {
var err *NotFound // nil-указатель
if id < 0 {
err = &NotFound{ID: id}
}
return err // в интерфейс попадает пара (*NotFound, nil)
}
func main() {
err := find(1)
fmt.Println(err == nil) // false!
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Функция вернула «пустой» указатель, но интерфейс error получил тип *NotFound и потому уже не nil: вызывающий код зайдёт в ветку ошибки. Лечится тем, что при успехе возвращают литерал nil, а не переменную конкретного типа, и тем, что переменную для ошибки объявляют типом error, а не *NotFound.
Глубже: обобщённые функции: параметры типов вместо anyрасширенное
Функция «вернуть большее из двух» на any теряет тип: внутри нельзя сравнивать, снаружи результат приходится приводить обратно. С Go 1.18 для этого есть параметры типов:
live example
package main
import (
"cmp"
"fmt"
)
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(3, 7), Max("a", "b")) // 7 b
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
[T cmp.Ordered] объявляет параметр типа T с ограничением: подойдёт любой тип, для которого определён < — числа и строки. Ограничение — это тоже интерфейс, только описывает он не методы, а допустимые типы; any разрешает всё, comparable — то, что можно сравнивать через == (например, для ключей map). Компилятор выводит T из аргументов, поэтому вызов выглядит как обычный. Обобщения берут для контейнеров и функций над «любым типом с таким свойством»; там, где нужна разная реализация у разных типов, по-прежнему работает обычный интерфейс с методами.
Коротко
- Интерфейс — набор методов; тип подходит, если имеет их все.
- Реализация неявная: никаких
implements, проверка на этапе компиляции (duck typing, но безопасный). - Держи интерфейсы маленькими — идеал один метод (
io.Reader,io.Writer); много мелких лучше одного большого. any(бывшийinterface{}) подходит под всё, но отключает типобезопасность — крайнее средство.- Достать конкретный тип: утверждение типа
x, ok := v.(T)(безопасная форма) или type switch для нескольких вариантов. - Принимай интерфейсы, возвращай структуры; интерфейс вводи по факту нужды, а не заранее.
- Интерфейс равен
nil, только если пусты и тип, и значение:nil-указатель, вернувшийся какerror, уже неnil— при успехе возвращай литералnil. - Параметры типов (
func Max[T cmp.Ordered](a, b T) T) заменяютanyтам, где нужна одна реализация для разных типов; ограничение — интерфейс над типами.
Что почитать дальше
- Структуры и методы — как объявляются типы и методы, на которых держатся интерфейсы.
- Ошибки в Go —
errorэто интерфейс; type switch и утверждение типа постоянно встречаются при работе с ошибками. - Слайсы и map — базовые структуры данных, которые часто прячут за интерфейсами.