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

ОСНОВЫ ЯЗЫКА: GO Интерфейсы

Обязательно

Интерфейсы Go: маленькие, неявные, по делу

Go

Как работают интерфейсы в Go: неявная реализация, маленькие контракты, пустой any, утверждение типа и type switch, и почему стоит принимать интерфейсы.

1. Интерфейс требует поведение, а не структуру

1 / 5

Контракт описывает, что объект умеет. Функция принимает любой тип с нужным методом и не меняется при добавлении новых реализаций.

  • Writer требует Write(data []byte) (int, error).
  • Файл, буфер и сетевое соединение подходят под один контракт.
  • Интерфейс объявляют там, где он нужен потребителю.
WriterWrite(data) файл буфер сеть

2. Реализация неявная, проверка при сборке

2 / 5

Никаких implements: если у типа есть все методы, он реализует интерфейс. Забытый метод обнаруживает компилятор, а не пользователь.

  • var _ Greeter = Russian{} - идиома проверки.
  • Pointer receiver реализует интерфейс только у *Russian.
  • Подходит тип даже из чужой библиотеки, которую нельзя править.
RussianGreet() Greeterреализовано автоматически var _ Greeter = Russian{}

3. Маленькие интерфейсы легко реализовать

3 / 5

Один метод - лучший контракт. Так работают io.Reader, io.Writer и fmt.Stringer. Маленькие интерфейсы собирают большие встраиванием.

  • Reader читает файлы, сеть, архивы и буферы одним способом.
  • String() string задаёт собственное отображение типа.
  • io.ReadWriter - просто Reader и Writer вместе.
ReaderRead(p) WriterWrite(p) ReadWriterвстраивание

4. any и безопасное извлечение типа

4 / 5

any подходит под всё, но убирает типобезопасность. Достать конкретный тип помогают x, ok := v.(T) и type switch.

  • Голая v.(T) падает при несовпадении - используйте форму с ok.
  • В каждой ветке type switch переменная уже имеет нужный тип.
  • Обобщения сохраняют тип там, где реализация одна для всех.
any42 v.(string) ok = false type switchint / string / bool Без ok - только при стопроцентной уверенности

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

5 / 5

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

  • countBytes(r io.Reader) просит только Read.
  • newBuffer() *bytes.Buffer возвращает все возможности.
  • Интерфейс равен nil только при пустых типе и значении: *NotFound(nil) внутри error уже не nil.
countBytesio.Reader *bytes.Bufferвсе методы error: (*NotFound, nil)err != nil

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