← назад к разделу

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

Зачем нужны интерфейсы

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

Короткая формула: интерфейс — это набор методов, которые тип обязан иметь. Кто эти методы имеет — тот и подходит.

type Writer interface {
    Write(data []byte) (int, error)
}

Любой тип с методом Write(data []byte) (int, error) считается Writer. Файл, буфер в памяти, сетевое соединение — все подходят под один контракт.

Неявная реализация (duck typing на этапе компиляции)

В большинстве языков нужно прямо написать «этот класс реализует этот интерфейс» (implements, : IWriter и так далее). В Go этого нет. Если у типа есть все методы интерфейса — он его реализует. Автоматически.

type Greeter interface {
    Greet() string
}

type Russian struct{}

// Метод Greet делает Russian пригодным как Greeter — без объявлений.
func (r Russian) Greet() string {
    return "Привет"
}

func sayHello(g Greeter) {
    println(g.Greet())
}

func main() {
    sayHello(Russian{}) // работает: у Russian есть метод Greet
}

Это называют duck typing («если крякает как утка — значит, утка»), но с важной оговоркой: проверка происходит на этапе компиляции, а не в момент запуска. Забыл метод — программа просто не соберётся, а не упадёт у пользователя.

Плюс такого подхода: интерфейс можно объявить там, где он нужен (в пакете-потребителе), а тип, который под него подходит, — где угодно, даже в чужой библиотеке, которую ты не можешь править. Тип и контракт не обязаны знать друг о друге.

Если хочешь явно убедиться, что тип реализует интерфейс, есть идиома проверки на этапе компиляции:

var _ Greeter = Russian{} // не соберётся, если Russian перестанет подходить

Маленькие интерфейсы

Главная привычка в Go — держать интерфейсы маленькими. Чем меньше методов, тем больше типов под него подходит и тем проще его реализовать. Идеал — один метод.

В стандартной библиотеке это видно невооружённым глазом: io.Reader и io.Writer — по одному методу каждый, и на них держится вся работа с потоками данных.

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

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

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

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

Интерфейс без методов подходит под любой тип — ведь требований ноль. Раньше его писали как interface{}, с Go 1.18 есть понятный синоним — any.

func describe(v any) {
    println(v) // сюда можно передать что угодно
}

describe(42)
describe("текст")
describe([]int{1, 2, 3})

any удобен, но это отказ от типобезопасности: компилятор больше не знает, что внутри. Чтобы что-то сделать со значением, придётся доставать его конкретный тип обратно (об этом ниже). Поэтому any — крайнее средство: для по-настоящему разнородных данных (например, разбор JSON неизвестной структуры), а не замена нормальным типам.

Утверждение типа (type assertion)

Когда значение лежит в интерфейсе (особенно в any), достать из него конкретный тип помогает утверждение типа: v.(T).

var v any = "привет"

s := v.(string)  // достаём строку
println(s)       // привет

Если тип не совпал, такая форма вызовет панику и уронит программу. Поэтому почти всегда используют безопасную форму с двумя значениями — второе говорит, удалось ли:

var v any = 42

s, ok := v.(string)
if !ok {
    println("это не строка") // ok == false, паники нет
}

Короткая формула: 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 "строка длиной " + itoa(len(x)) // x здесь уже string
    case bool:
        return "логическое значение"
    default:
        return "неизвестный тип"
    }
}

В каждой ветке переменная x уже имеет нужный конкретный тип — приводить ничего не надо. Это основной инструмент, когда в any может прийти несколько разных типов.

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

Распространённый совет в Go: функция принимает интерфейс, а возвращает конкретный тип (структуру).

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

// Хорошо: нужен только Read — просим минимум.
func countBytes(r io.Reader) (int, error) { /* ... */ }

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

// Хорошо: вернули конкретный *bytes.Buffer, у вызывающего все его возможности.
func newBuffer() *bytes.Buffer { /* ... */ }

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

Связь с backend-Go

Здесь мы говорим про интерфейсы как возможность языка. Как они применяются в реальной серверной разработке — подмена зависимостей в HTTP-обработчиках, port-интерфейсы для базы данных, мок-реализации в тестах — это раздел /go/. Там те же интерфейсы работают как швы между слоями приложения.

Коротко

  • Интерфейс — набор методов; тип подходит, если имеет их все.
  • Реализация неявная: никаких implements, проверка на этапе компиляции (duck typing, но безопасный).
  • Держи интерфейсы маленькими — идеал один метод (io.Reader, io.Writer); много мелких лучше одного большого.
  • any (бывший interface{}) подходит под всё, но отключает типобезопасность — крайнее средство.
  • Достать конкретный тип: утверждение типа x, ok := v.(T) (безопасная форма) или type switch для нескольких вариантов.
  • Принимай интерфейсы, возвращай структуры; интерфейс вводи по факту нужды, а не заранее.

Что почитать дальше

  • Структуры и методы — как объявляются типы и методы, на которых держатся интерфейсы.
  • Ошибки в Go — error это интерфейс; type switch и утверждение типа постоянно встречаются при работе с ошибками.
  • Слайсы и map — базовые структуры данных, которые часто прячут за интерфейсами.