BackendBit

К статьям о Go

В Go нет классов и наследования. Главный инструмент полиморфизма – интерфейсы, и устроены они не так, как в большинстве языков. В Java мы используем ключевое слово implements, чтобы указать, что класс реализует интерфейс. В Go этого нет – тип реализует интерфейс, если у этого типа есть все методы, которые объявлены в интерфейсе. Это одновременно как большая сила, так и большая ответственность. Но обо всем по порядку.

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

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

package declare

type Sayer interface {
	Say() string
}
go

Возьмем тип Dog с методом Say():

package declare

type Dog struct {
	Name string
}

func (d Dog) Say() string {
	return "Гав-гав"
}
go

Dog нигде не упоминает Sayer, но его значение можно присвоить переменной типа Sayer:

var s Sayer = Dog{Name: "Шарик"}

fmt.Println(s.Say())
go
Гав-гав

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

package declare

import "fmt"

func announce(s Sayer) {
	fmt.Println(s.Say())
}
go
package declare

type Robot struct {
	Model string
}

func (r Robot) Say() string {
	return "Бип-буп"
}
go
announce(Dog{Name: "Шарик"})
announce(Robot{Model: "R2-D2"})
go
Гав-гав
Бип-буп

Функция announce ничего не знает ни про Dog, ни про Robot. Новый тип с методом Say() заработает с ней без единой правки в ее пакете. Работает это и в обратную сторону: интерфейсу могут удовлетворять типы из чужих пакетов и стандартной библиотеки, авторы которых никогда не видели ваш интерфейс. Именно поэтому в Go интерфейс обычно объявляет потребитель, а не реализация – к этому мы вернемся в конце статьи.

Образцовые интерфейсы живут в стандартной библиотеке: error, fmt.Stringer, io.Reader:

// io/io.go

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

В каждом из них один метод, но на них держится половина стандартной библиотеки: gzip, шифрование, JSON-декодер и HTTP-клиент работают с одним и тем же io.Reader.

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

У неявной реализации есть обратная сторона: если тип перестанет удовлетворять интерфейсу – например, кто-то переименует метод – ошибка компиляции возникнет не рядом с типом, а в местах использования. Иногда это далеко: в другом пакете или вообще в другом репозитории.

Чтобы зафиксировать контракт рядом с типом, используют идиому с переменной-пустышкой:

package declare

// Не скомпилируется, если Dog перестанет реализовывать Sayer
var _ Sayer = Dog{}
go

Присваивание нулевого значения не генерирует кода – компилятор просто проверяет, что Dog реализует Sayer. Для типов, реализующих интерфейс через pointer receiver, та же идиома записывается как var _ Sayer = (*Dog)(nil) – почему, разберем в разделе про набор методов.

Встраивание интерфейсов

Интерфейсы можно собирать из других интерфейсов – это называется встраиванием:

package embed

type Sleeper interface {
	Sleep() string
}

type Pet interface {
	Sayer
	Sleeper
}
go

Набор методов Pet – объединение методов Sayer и Sleeper. Добавим Dog метод Sleep():

package embed

func (d Dog) Sleep() string {
	return "Хр-р-р"
}
go

Теперь у Dog есть оба метода, и он реализует интерфейсы Sayer, Sleeper и Pet:

var p Pet = Dog{Name: "Шарик"}

fmt.Println(p.Say())
fmt.Println(p.Sleep())
go
Гав-гав
Хр-р-р

Так собраны композитные интерфейсы пакета io:

// io/io.go

type ReadWriter interface {
	Reader
	Writer
}
go

Интерфейс под капотом

Интерфейсное значение – структура iface из двух машинных слов: data указывает на значение конкретного типа, а tab – на структуру itab с метаданными типов и таблицей методов.

var s Sayer = Dog{Name: "Шарик"}
return s
go

Ниже – значение s в памяти. Из tab можно дойти до itab, а через нее – до метаданных Sayer и Dog и до кода Dog.Say. Из data – до копии Dog, которая лежит в интерфейсе. На вкладке var s any – то же значение в пустом интерфейсе.

Схема памяти: интерфейсное значение sОткрыто: s Sayers Sayertab→ itab(Sayer, Dog)data→ Dog (копия)

s Sayer

Интерфейсное значение s – два машинных слова, tab и data. Нажмите на поле: указатель откроет блок, на который указывает, а здесь появится пояснение к полю.

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

func callExample() {	d := Dog{Name: "Шарик"}	var s Sayer = d	announce(s)}func announce(s Sayer) {	fmt.Println(s.Say())}
1 / 4

Переменная d – структура Dog во фрейме callExample на стеке. В статических данных – то, что компилятор подготовил заранее: itab пары (Sayer, Dog) и код Dog.Say.

Память на шаге 1 из 4Хип: пусто. Стек: фрейм callExample – d (Name = "Шарик"). Статические данные: itab(Sayer, Dog) (пропущенные поля); Dog.Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET). itab(Sayer, Dog).Fun[0] указывает на блок «Dog.Say». Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеcallExampleitab(Sayer, Dog)…Fun[0]Dog.SayLEAQ "Гав-гав", AXMOVL $13, BXRETd DogName"Шарик"
Память на шаге 1 из 4Хип: пусто. Стек: фрейм callExample – d (Name = "Шарик"). Статические данные: itab(Sayer, Dog) (пропущенные поля); Dog.Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET). itab(Sayer, Dog).Fun[0] указывает на блок «Dog.Say». Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеcallExampleitab(Sayer, Dog)…Fun[0]Dog.SayLEAQ "Гав-гав", AXMOVL $13, BXRETd DogName"Шарик"

Посмотрим на два машинных слова интерфейса вживую. Приведем интерфейсное значение к структуре с той же схемой памяти, что у iface:

package main

import (
	"fmt"
	"unsafe"
)

type ifaceHeader struct {
	tab  unsafe.Pointer
	data unsafe.Pointer
}

func inspectIface(s Sayer) {
	h := (*ifaceHeader)(unsafe.Pointer(&s))

	fmt.Println("tab:", h.tab)
	fmt.Println("data:", h.data)
	fmt.Println()
}
go
var s Sayer = Dog{Name: "Шарик"}

inspectIface(s)
go
tab: 0x3e6dd48
data: 0x3e6d7c8

tab указывает на itab пары (Sayer, Dog), data – на копию структуры Dog{Name: "Шарик"}.

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

Пустому интерфейсу interface{} удовлетворяет любой тип – у него нет методов, поэтому и таблица методов ему не нужна. Рантайм использует для него отдельную, более простую структуру eface: вместо указателя на itab в первом слове лежит указатель сразу на метаданные типа. Переключите схему выше на var s any: тот же Dog в пустом интерфейсе – это _type и data, без itab.

Убедимся тем же способом:

package main

import (
	"fmt"
	"unsafe"
)

type efaceHeader struct {
	typ  unsafe.Pointer
	data unsafe.Pointer
}

func inspectEface(v any) {
	h := (*efaceHeader)(unsafe.Pointer(&v))

	fmt.Println("typ:", h.typ)
	fmt.Println("data:", h.data)
	fmt.Println()
}
go
inspectEface(42)
inspectEface(Dog{Name: "Шарик"})
go
typ: 0x3e50ee0
data: 0x3d94588

typ: 0x3e5a620
data: 0x3e6d7c8

Указатели typ разные – метаданные int и Dog. А data второго вызова совпадает с data из примера про iface: там лежит тот же литерал Dog{Name: "Шарик"}.

Положить значение в интерфейс – значит скопировать его и направить data на копию. Это называют упаковкой, или боксингом (boxing). Значения, известные при компиляции, – константу 42 или литерал Dog{Name: "Шарик"} – компилятор упаковывает заранее и кладет копию в статические данные. С переменными предсказать сложнее: n := 1000 он тоже упакует заранее, если n нигде не меняется, а d := Dog{Name: "Шарик"} – уже в рантайме, хотя значение d не менее очевидно. Это детали реализации компилятора, и полагаться на них не стоит. Проследим, куда попадают константа и переменная:

func emptyExample() {	var a any = 42	d := Dog{Name: "Шарик"}	var b any = d	fmt.Println(a, b)}
1 / 4

Пустой интерфейс – тоже два слова, но в первом не itab, а _type: указатель прямо на метаданные типа. 42 – константа, поэтому ее упакованную копию компилятор заранее положил в статические данные, и a.data указывает туда.

Память на шаге 1 из 4Хип: пусто. Стек: фрейм emptyExample – a. Статические данные: метаданные int; метаданные Dog; упакованная 42. a._type указывает на блок «метаданные int». a.data указывает на блок «упакованная 42». Изменения на этом шаге: появился блок «a»; новый указатель a._type; новый указатель a.data.ХипСтекСтатические данныеemptyExampleметаданные intметаданные Dog42 (int)a any_typedata
Память на шаге 1 из 4Хип: пусто. Стек: фрейм emptyExample – a. Статические данные: метаданные int; метаданные Dog; упакованная 42. a._type указывает на блок «метаданные int». a.data указывает на блок «упакованная 42». Изменения на этом шаге: появился блок «a»; новый указатель a._type; новый указатель a.data.ХипСтекСтатические данныеemptyExampleметаданные intметаданные Dog42 (int)a any_typedata

Набор методов и pointer receiver

Заменим у Dog value receiver на pointer receiver и попробуем передать значение в интерфейс:

type Dog struct {
	Name string
}

func (d *Dog) Say() string {
	return "Гав-гав"
}

d := Dog{Name: "Шарик"}
var s Sayer = d
go
cannot use d (variable of struct type Dog) as Sayer value in variable declaration: Dog does not implement Sayer (method Say has pointer receiver)

Компилятор считает, что Dog не реализует Sayer, хотя метод Say() у него очевидно есть. Дело в наборе методов (method set) – так спецификация называет методы, которые есть у типа. Для Dog и *Dog он разный:

  • В набор методов Dog входят методы с value receiver.
  • В набор методов *Dog входят методы и с value receiver, и с pointer receiver.
Receiver методаВходит в набор методов DogВходит в набор методов *Dog
func (d Dog)дада
func (d *Dog)нетда

Тип реализует интерфейс, если в его наборе методов есть все методы интерфейса. Say() с pointer receiver входит в набор методов *Dog, но не Dog.

Правило выглядит формальным, но за ним стоит механика: по указателю всегда можно получить значение, а у значения не всегда есть адрес. Value-receiver методу через *Dog достаточно разыменовать указатель и получить копию. Pointer-receiver методу через Dog нужен адрес значения. Прямой вызов d.Say() на значении при этом компилируется без проблем:

d := Dog{Name: "Шарик"}

fmt.Println(d.Say())
go
Гав-гав

Здесь d – переменная, у нее есть адрес, и компилятор молча переписывает вызов в (&d).Say(). С интерфейсом такой трюк не пройдет: как мы уже видели, при помещении в интерфейс значение копируется. Адрес у копии есть – на нее указывает data, – но pointer-receiver метод изменил бы копию, а не d, и изменения молча пропали бы. А если значение было упаковано заранее, data и вовсе указывает в статические данные, которые доступны только для чтения. Вместо тихой потери данных Go запрещает такой код на этапе компиляции.

Поэтому в Sayer можно положить *Dog, но не Dog:

d := Dog{Name: "Шарик"}
var s Sayer = &d

fmt.Println(s.Say())
go
Гав-гав

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

func pointerExample() {	d := Dog{Name: "Шарик"}	var s Sayer = &d	announce(s)}func announce(s Sayer) {	fmt.Println(s.Say())}
1 / 4

Переменная d сразу лежит на хипе, а не на стеке. Компилятор заранее увидел, что адрес d уходит в интерфейс и может пережить фрейм, и перенес d на хип – в выводе -gcflags=-m это moved to heap: d.

Память на шаге 1 из 4Хип: d (Name = "Шарик"). Стек: фрейм pointerExample – пусто. Статические данные: itab(Sayer, *Dog) (пропущенные поля); (*Dog).Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET). itab(Sayer, *Dog).Fun[0] указывает на блок «(*Dog).Say». Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеpointerExampleitab(Sayer, *Dog)…Fun[0](*Dog).SayLEAQ "Гав-гав", AXMOVL $13, BXRETd DogName"Шарик"
Память на шаге 1 из 4Хип: d (Name = "Шарик"). Стек: фрейм pointerExample – пусто. Статические данные: itab(Sayer, *Dog) (пропущенные поля); (*Dog).Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET). itab(Sayer, *Dog).Fun[0] указывает на блок «(*Dog).Say». Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеpointerExampleitab(Sayer, *Dog)…Fun[0](*Dog).SayLEAQ "Гав-гав", AXMOVL $13, BXRETd DogName"Шарик"

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

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

d := Dog{Name: "Шарик"}
var s Sayer = d

d.Name = "Бобик"

fmt.Println(s.Say())
fmt.Println(d.Say())
go
Шарик говорит: гав-гав
Бобик говорит: гав-гав

Интерфейс s продолжает лаять от имени Шарика: в момент var s Sayer = d он получил собственную копию структуры, и переименование оригинала в Бобика его не касается.

func copyExample() {	d := Dog{Name: "Шарик"}	var s Sayer = d	d.Name = "Бобик"	announce(s)}func announce(s Sayer) {	fmt.Println(s.Say())}
1 / 4

Переменная d – структура Dog во фрейме copyExample на стеке.

Память на шаге 1 из 4Хип: пусто. Стек: фрейм copyExample – d (Name = "Шарик"). Статические данные: itab(Sayer, Dog). Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеcopyExampleitab(Sayer, Dog)d DogName"Шарик"
Память на шаге 1 из 4Хип: пусто. Стек: фрейм copyExample – d (Name = "Шарик"). Статические данные: itab(Sayer, Dog). Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеcopyExampleitab(Sayer, Dog)d DogName"Шарик"

Куда копируется значение? Поле data в iface – указатель, и начиная с Go 1.4 там всегда указатель. Если упаковываемое значение само устроено как указатель – это указатели, мапы, каналы, функции и структуры из одного такого поля, – оно кладется в data как есть. Любое другое значение хранится отдельно, а data указывает на копию – даже int, который поместился бы в машинное слово. Поэтому, если интерфейс покидает функцию, упаковке обычно нужна аллокация на хипе.

Цена упаковки

Посчитаем аллокации с помощью testing.AllocsPerRun() – эта функция запускает колбэк много раз и возвращает среднее количество аллокаций за запуск. Колбэк присваивает значение переменной sink на уровне пакета – так значение переживает вызов колбэка, и копия не может остаться на стеке:

package conv

import (
	"fmt"
	"testing"
)

var sink any

func Example_boxingAllocates() {
	d := Dog{Name: "Шарик"}

	allocs := testing.AllocsPerRun(100, func() {
		sink = d
	})

	fmt.Println(allocs)
	// Output:
	// 1
}
go

Одна аллокация: структура копируется на хип, data указывает на копию. То же самое с int. Напишем хелпер:

package conv

import "testing"

// boxAllocs возвращает среднее количество аллокаций
// при упаковке значения n в интерфейс.
//
//go:noinline
func boxAllocs(n int) float64 {
	return testing.AllocsPerRun(100, func() {
		sink = n
	})
}
go

Директива //go:noinline здесь не случайна: без нее компилятор заинлайнит вызов, увидит константу и создаст упакованное значение еще на этапе компиляции – и тест измерит оптимизацию компилятора, а не упаковку. С директивой компилятор не видит значение заранее, и упаковка происходит в рантайме:

fmt.Println(boxAllocs(1000))
go
1

А теперь упакуем число поменьше:

fmt.Println(boxAllocs(42))
go
0

Ноль аллокаций. Для маленьких целых чисел в рантайме есть статический массив из 256 элементов:

// runtime/iface.go

// staticuint64s is used to avoid allocating in convTx for small integer values.
// staticuint64s[0] == 0, staticuint64s[1] == 1, and so forth.
// It is defined in assembler code so that it is read-only.
var staticuint64s [256]uint64
go

Если упаковываемое значение лежит в диапазоне от 0 до 255, рантайм не аллоцирует память, а направляет data на готовый элемент этого массива. Сравним по шагам два вызова с маленьким и большим числом:

//go:noinlinefunc box(n int) any {	return n}func boxingExample() {	small := box(7)	big := box(1000)	fmt.Println(small, big)}
1 / 4

У вызова box(7) свой фрейм на стеке с параметром n. Директива //go:noinline не дает компилятору встроить box и упаковать константу заранее: значение n станет известно только в рантайме.

Память на шаге 1 из 4Хип: пусто. Стек: фрейм boxingExample – пусто; фрейм box – n (значение = 7). Статические данные: staticuint64s ([0] = 0, пропущенные поля, [7] = 7, пропущенные поля, [255] = 255); метаданные int. Изменения на этом шаге: появился фрейм box; появился блок «n».ХипСтекСтатические данныеboxingExampleboxstaticuint64s[0]0…[7]7…[255]255метаданные intn intзначение7
Память на шаге 1 из 4Хип: пусто. Стек: фрейм boxingExample – пусто; фрейм box – n (значение = 7). Статические данные: staticuint64s ([0] = 0, пропущенные поля, [7] = 7, пропущенные поля, [255] = 255); метаданные int. Изменения на этом шаге: появился фрейм box; появился блок «n».ХипСтекСтатические данныеboxingExampleboxstaticuint64s[0]0…[7]7…[255]255метаданные intn intзначение7

Упаковка указателя аллокаций не требует – указатель кладется в data как есть:

d := &Dog{Name: "Шарик"}

allocs := testing.AllocsPerRun(100, func() {
	sink = d
})

fmt.Println(allocs)
go
0

nil интерфейс и typed nil

Нулевое значение интерфейсного типа – nil: оба машинных слова пустые. Вызов метода на nil интерфейсе приведет к панике:

defer func() {
	fmt.Println(recover())
}()

var s Sayer

s.Say()
go
runtime error: invalid memory address or nil pointer dereference

Происходит это, потому что tab равен nil, соответственно вызывать нечего. Проверим через уже знакомый inspectIface:

var s Sayer

inspectIface(s)
go
tab: <nil>
data: <nil>

А теперь другой случай. Положим в интерфейс nil указатель на Dog:

var d *Dog
var s Sayer = d

inspectIface(s)
go
tab: 0x3e6dd28
data: <nil>

data пустой, но tab – нет: рантайм знает, что внутри интерфейса лежит *Dog, просто сам указатель равен nil. Такое значение называют typed nil. Это уже не nil интерфейс. Проследим по шагам, откуда в typed nil берется заполненный tab:

func typedNilExample() {	var p *Dog	var s Sayer = p	fmt.Println(s != nil) // true}
1 / 3

Переменная p – указатель на Dog, равный nil. Никакого Dog в памяти нет.

Память на шаге 1 из 3Стек: фрейм typedNilExample – p (значение = nil). Статические данные: itab(Sayer, *Dog). Изменения на этом шаге: появился блок «p».СтекСтатические данныеtypedNilExampleitab(Sayer, *Dog)p *Dogзначениеnil
Память на шаге 1 из 3Стек: фрейм typedNilExample – p (значение = nil). Статические данные: itab(Sayer, *Dog). Изменения на этом шаге: появился блок «p».СтекСтатические данныеtypedNilExampleitab(Sayer, *Dog)p *Dogзначениеnil

Вызов метода через typed nil интерфейс сам по себе не паникует: в таблице методов есть Say(), и он вызывается с receiver, равным nil. Дальше все зависит от метода. Этот объявлен с pointer receiver и проверяет nil:

package typednil

func (d *Dog) Say() string {
	if d == nil {
		return "Тишина"
	}

	return "Гав-гав"
}
go
var d *Dog
var s Sayer = d

fmt.Println(s.Say())
fmt.Printf("(%v, %T)\n", s, s)
go
Тишина
(<nil>, *typednil.Dog)

Будь у Say() value receiver, вызов бы запаниковал: чтобы получить значение Dog, нужно разыменовать nil.

Ловушка с err != nil

В Go есть встроенный интерфейс error с единственным методом Error() string:

// builtin/builtin.go

type error interface {
	Error() string
}
go

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

package typednil

import "fmt"

type AppError struct {
	Code int
}

func (e *AppError) Error() string {
	return fmt.Sprintf("ошибка приложения, код %d", e.Code)
}
go
package typednil

func handle(fail bool) *AppError {
	if fail {
		return &AppError{Code: 500}
	}

	return nil
}
go

Вызовем handle() из функции с сигнатурой, возвращающей стандартный error:

package typednil

func process() error {
	return handle(false)
}
go

Ошибки нет, handle() вернул nil – значит, и process() вернет nil… или нет?

err := process()

fmt.Println(err == nil)
fmt.Printf("(%v, %T)\n", err, err)
go
false
(<nil>, *typednil.AppError)

Проверка err == nil вернула false. В момент return handle(false) происходит неявное приведение *AppError к интерфейсу error. Получается typed nil: tab заполнен парой (error, *AppError), data равен nil. А typed nil, как мы только что видели, не равен nil.

Проследим по шагам, где именно появляется typed nil:

func trapExample() {	err := process()	fmt.Println(err != nil) // true}func process() error {	return handle(false)}func handle(fail bool) *AppError {	if fail {		return &AppError{Code: 500}	}	return nil}
1 / 5

trapExample вызывает process, а та – handle(false). У каждого вызова свой фрейм на стеке.

Память на шаге 1 из 5Стек: фрейм trapExample – пусто; фрейм process – пусто; фрейм handle – fail (значение = false). Статические данные: itab(error, *AppError). Изменения на этом шаге: появился фрейм process; появился фрейм handle; появился блок «fail».СтекСтатические данныеtrapExampleprocesshandleitab(error, *AppError)fail boolзначениеfalse
Память на шаге 1 из 5Стек: фрейм trapExample – пусто; фрейм process – пусто; фрейм handle – fail (значение = false). Статические данные: itab(error, *AppError). Изменения на этом шаге: появился фрейм process; появился фрейм handle; появился блок «fail».СтекСтатические данныеtrapExampleprocesshandleitab(error, *AppError)fail boolзначениеfalse

Программа с такой ошибкой уходит в ветку обработки ошибки при полностью успешном выполнении. Ловушка настолько известна, что попала в официальный FAQ ↗.

Решение – не объявлять конкретные типы ошибок в сигнатурах. Функция, возвращающая ошибку, должна возвращать error:

package typednil

func handleFixed(fail bool) error {
	if fail {
		return &AppError{Code: 500}
	}

	return nil
}
go
err := handleFixed(false)

fmt.Println(err == nil)
go
true

Здесь return nil кладет в интерфейс честный nil – без типа, оба слова пустые.

Сравнение интерфейсов

Значения одного интерфейсного типа можно сравнивать. Два интерфейсных значения равны, если внутри них лежат значения одного конкретного типа и эти значения равны – или если оба интерфейса равны nil (спецификация ↗):

var a Sayer = Dog{Name: "Шарик"}
var b Sayer = Dog{Name: "Шарик"}

fmt.Println(a == b)

b = Dog{Name: "Бобик"}
fmt.Println(a == b)
go
true
false

Если конкретные типы разные, то значения не равны – даже когда поля выглядят одинаково. Чтобы это проверить, добавим еще один тип, который реализует Sayer:

package compare

type Cat struct {
	Name string
}

func (c Cat) Say() string {
	return "Мяу"
}
go

Сравним собаку и кота с одинаковыми именами:

var a Sayer = Dog{Name: "Шарик"}
var b Sayer = Cat{Name: "Шарик"}

fmt.Println(a == b)
go
false

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

var sayer Sayer
var sleeper Sleeper

fmt.Println(sayer == sleeper)
go
invalid operation: sayer == sleeper (mismatched types Sayer and Sleeper)

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

Слайсы, мапы и функции несравнимы: код []int{1} == []int{2} просто не скомпилируется. Но стоит спрятать те же слайсы в интерфейс – и компилятор пропустит сравнение, ведь тип any сравним. Проверка отложится до рантайма:

defer func() {
	fmt.Println(recover())
}()

var a any = []int{1, 2, 3}
var b any = []int{1, 2, 3}

fmt.Println(a == b)
go
runtime error: comparing uncomparable type []int

Механика такая: сравнение интерфейсов сначала сверяет указатели на метаданные типов, а затем вызывает функцию сравнения из метаданных – поле Equal в структуре abi.Type. У несравнимых типов это поле равно nil – рантайм видит nil и паникует.

Мапы с интерфейсными ключами паникуют так же: map[any]int компилируется, но любая операция с ключом-слайсом – вставка, чтение или удаление – приведет к панике hash of unhashable type.

Type assertion и type switch

Внутри интерфейса всегда лежит значение конкретного типа – и иногда его нужно достать. Для этого есть type assertion:

value := i.(T)
go

Выражение возвращает значение типа T из интерфейса i. Форма с одним возвращаемым значением опасна: если внутри лежит не T, будет паника. Пример ниже объявлен в пакете assert, а в тексте паники тип указывается вместе с пакетом – отсюда assert.Dog:

defer func() {
	fmt.Println(recover())
}()

var i any = Dog{Name: "Шарик"}

num := i.(int)
fmt.Println(num)
go
interface conversion: interface {} is assert.Dog, not int

Поэтому в подавляющем большинстве случаев используется форма «comma, ok» – она вместо паники возвращает флаг успеха:

var i any = Dog{Name: "Шарик"}

d, ok := i.(Dog)
fmt.Println(d, ok)

s, ok := i.(Sayer)
fmt.Println(s, ok)

num, ok := i.(int)
fmt.Println(num, ok)
go
{Шарик} true
{Шарик} true
0 false

При неудаче первое возвращаемое значение – нулевое значение типа T (для int это 0).

Как рантайм понимает, что внутри не T? Для конкретного типа T assertion сравнивает один указатель – первое слово интерфейса – с адресом, который компилятор знает заранее. У пустого интерфейса первое слово – _type, и оно сравнивается с адресом метаданных T. У непустого – указатель на itab, и он сравнивается с адресом itab пары «интерфейс – T». Обходить таблицу методов не нужно:

func assertExample() {	d := Dog{Name: "Шарик"}	var i any = d	describe(i)}func describe(i any) {	c, ok := i.(Cat)	fmt.Println(c, ok)	dog, ok := i.(Dog)	fmt.Println(dog, ok)	fmt.Printf("%T\n", i)}
1 / 5

Переменная d – структура Dog во фрейме assertExample на стеке.

Память на шаге 1 из 5Хип: пусто. Стек: фрейм assertExample – d (Name = "Шарик"). Статические данные: метаданные Dog; метаданные Cat. Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеassertExampleметаданные Dogметаданные Catd DogName"Шарик"
Память на шаге 1 из 5Хип: пусто. Стек: фрейм assertExample – d (Name = "Шарик"). Статические данные: метаданные Dog; метаданные Cat. Изменения на этом шаге: появился блок «d».ХипСтекСтатические данныеassertExampleметаданные Dogметаданные Catd DogName"Шарик"

В примере с «comma, ok» была строка s, ok := i.(Sayer) – там в T стоит не конкретный тип, а интерфейс. Такой assertion проверяет, реализует ли значение внутри интерфейса еще один интерфейс. Пригодится это, например, для необязательного поведения:

package assert

import "fmt"

func name(s Sayer) string {
	if str, ok := s.(fmt.Stringer); ok {
		return str.String()
	}

	return "кто-то"
}
go
fmt.Println(name(Robot{Model: "R2-D2"}))
fmt.Println(name(Dog{Name: "Шарик"}))
go
Робот R2-D2
кто-то

Robot реализует fmt.Stringer – его имя взято из String(). У Dog такого метода нет, поэтому сработал фоллбек "кто-то". На этом приеме построены многие механизмы стандартной библиотеки: например, errors.As() и проверка io.WriterTo внутри io.Copy().

Когда вариантов больше двух, вместо цепочки assertions используется type switch:

package assert

import "fmt"

func classify(i any) {
	switch v := i.(type) {
	case fmt.Stringer:
		fmt.Println("Stringer:", v.String())
	case Sayer:
		fmt.Println("Sayer:", v.Say())
	case int:
		fmt.Println("int:", v)
	default:
		fmt.Println("неизвестный тип")
	}
}
go
classify(Robot{Model: "R2-D2"})
classify(Dog{Name: "Шарик"})
classify(42)
classify(3.14)
go
Stringer: Робот R2-D2
Sayer: Гав-гав
int: 42
неизвестный тип

Внутри каждого case переменная v имеет тип этого case – никаких дополнительных приведений не нужно. Если в case перечислено несколько типов, как и в default, v остается интерфейсом. Обратите внимание на первый вызов: Robot подходит и под fmt.Stringer, и под Sayer, но выполнился первый подходящий case. Порядок веток в type switch имеет значение.

При этом ветки с конкретными типами type switch не перебирает по очереди: компилятор сортирует их по хешу типа – числу из метаданных типа, копия которого лежит и в itab, – и в рантайме сначала находит ветку по хешу, а уже потом сравнивает сами типы.

itab: создание, кэширование и диспатч

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

Если приведение типа к интерфейсу видно на этапе компиляции – как var s Sayer = Dog{...} – компилятор создает itab статически и кладет его в бинарник. А когда пара становится известна только в рантайме – например, при assertion одного интерфейса к другому – itab строит функция getitab() из рантайма. Построенный itab сохраняется в глобальной хеш-таблице itabTable, так что для каждой пары эта работа выполняется один раз. Подробнее об этом – в классической статье Расса Кокса Go Data Structures: Interfaces ↗. Учтите, что она написана в 2009 году, до Go 1.4: значения размером в машинное слово там хранятся прямо в data, а сейчас data всегда указатель.

Пара (Sayer, Dog) в программе одна, и статический itab для нее тоже один. Поэтому tab у двух интерфейсов с одинаковой парой типов – один и тот же указатель:

var s1 Sayer = Dog{Name: "Шарик"}
var s2 Sayer = Dog{Name: "Бобик"}
var s3 Sayer = Cat{Name: "Мурзик"}

inspectIface(s1)
inspectIface(s2)
inspectIface(s3)
go
tab: 0x3e6dd48
data: 0x3e6d7c8

tab: 0x3e6dd48
data: 0x3e6d7d8

tab: 0x3e6dd68
data: 0x3e6d7e8

У Шарика и Бобика общий itab пары (Sayer, Dog): его подготовил компилятор, один на всю программу, а различаются только данные. У кота другой конкретный тип, поэтому и itab другой.

Проследим по шагам вызов sharedItabExample("Шарик", "Бобик", "Мурзик"). Имена приходят из параметров функции, поэтому значения упаковываются в рантайме, а не заранее:

func sharedItabExample(first, second, cat string) {	var s1 Sayer = Dog{Name: first}	var s2 Sayer = Dog{Name: second}	var s3 Sayer = Cat{Name: cat}	chorus(s1, s2, s3)}func chorus(s1, s2, s3 Sayer) {	fmt.Println(s1.Say(), s2.Say(), s3.Say())}
1 / 3

Dog собирается из параметра first и копируется на хип. s1.tab указывает на itab пары (Sayer, Dog), который компилятор подготовил заранее.

Память на шаге 1 из 3Хип: данные s1 (Name = "Шарик"). Стек: фрейм sharedItabExample – s1. Статические данные: itab(Sayer, Dog) (пропущенные поля, Hash = хеш Dog); itab(Sayer, Cat) (пропущенные поля, Hash = хеш Cat); метаданные Dog; Dog.Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET); метаданные Cat; Cat.Say (код: LEAQ "Мяу", AX; MOVL $6, BX; RET). itab(Sayer, Dog).Type указывает на блок «метаданные Dog». itab(Sayer, Dog).Fun[0] указывает на блок «Dog.Say». itab(Sayer, Cat).Type указывает на блок «метаданные Cat». itab(Sayer, Cat).Fun[0] указывает на блок «Cat.Say». s1.tab указывает на блок «itab(Sayer, Dog)». s1.data указывает на блок «данные s1». Изменения на этом шаге: появился блок «данные s1»; появился блок «s1»; новый указатель s1.tab; новый указатель s1.data.ХипСтекСтатические данныеsharedItabExampleitab(Sayer, Dog)…TypeHashхеш DogFun[0]itab(Sayer, Cat)…TypeHashхеш CatFun[0]метаданные DogDog.SayLEAQ "Гав-гав", AXMOVL $13, BXRETметаданные CatCat.SayLEAQ "Мяу", AXMOVL $6, BXRETDogName"Шарик"s1 Sayertabdata
Память на шаге 1 из 3Хип: данные s1 (Name = "Шарик"). Стек: фрейм sharedItabExample – s1. Статические данные: itab(Sayer, Dog) (пропущенные поля, Hash = хеш Dog); itab(Sayer, Cat) (пропущенные поля, Hash = хеш Cat); метаданные Dog; Dog.Say (код: LEAQ "Гав-гав", AX; MOVL $13, BX; RET); метаданные Cat; Cat.Say (код: LEAQ "Мяу", AX; MOVL $6, BX; RET). itab(Sayer, Dog).Type указывает на блок «метаданные Dog». itab(Sayer, Dog).Fun[0] указывает на блок «Dog.Say». itab(Sayer, Cat).Type указывает на блок «метаданные Cat». itab(Sayer, Cat).Fun[0] указывает на блок «Cat.Say». s1.tab указывает на блок «itab(Sayer, Dog)». s1.data указывает на блок «данные s1». Изменения на этом шаге: появился блок «данные s1»; появился блок «s1»; новый указатель s1.tab; новый указатель s1.data.ХипСтекСтатические данныеsharedItabExampleitab(Sayer, Dog)…TypeHashхеш DogFun[0]itab(Sayer, Cat)…TypeHashхеш CatFun[0]метаданные DogDog.SayLEAQ "Гав-гав", AXMOVL $13, BXRETметаданные CatCat.SayLEAQ "Мяу", AXMOVL $6, BXRETDogName"Шарик"s1 Sayertabdata

Цена динамического диспатча

Вызов метода через интерфейс – динамический: компилятор не знает, какая функция будет вызвана, поэтому генерирует загрузку указателя из tab.Fun и вызов по этому указателю. Сравним с прямым вызовом бенчмарком:

package dispatch

type Adder interface {
	Add(a, b int32) int32
}

type Int32Adder struct{}

//go:noinline
func (Int32Adder) Add(a, b int32) int32 {
	return a + b
}
go
package dispatch

import "testing"

var (
	sink int32

	adder Adder = Int32Adder{}
)

func BenchmarkDirectCall(b *testing.B) {
	concrete := Int32Adder{}

	for b.Loop() {
		sink = concrete.Add(10, 32)
	}
}

func BenchmarkInterfaceCall(b *testing.B) {
	for b.Loop() {
		sink = adder.Add(10, 32)
	}
}
go
BenchmarkDirectCall-10  	594822565	  2.018 ns/op
BenchmarkInterfaceCall-10	584418312	  2.056 ns/op

Разница – в пределах погрешности (Go 1.27, Apple M1 Pro). Адрес в tab.Fun от вызова к вызову не меняется, процессор его предсказывает, и косвенный вызов обходится почти так же дешево, как прямой. Дороже то, что компилятор не знает, какой метод вызовется. Он не может заинлайнить его тело и не видит, что метод делает с данными, поэтому значение, переданное через интерфейс, часто уходит на хип. В функции, которую вызывают миллионы раз в секунду, эти аллокации и упущенные оптимизации заметны. В остальном коде – нет.

Компилятор умеет девиртуализировать вызовы: если он может доказать, какой конкретный тип лежит в интерфейсе – например, приведение и вызов происходят в одной функции – динамический вызов заменяется прямым, и инлайнинг снова работает. С включенной profile-guided optimization ↗ компилятор делает это даже спекулятивно, опираясь на профиль реальной нагрузки.

Именно поэтому бенчмарк выше держит интерфейсную переменную на уровне пакета – локальную переменную компилятор девиртуализировал бы – и запрещает инлайнинг метода директивой //go:noinline, чтобы оба бенчмарка измеряли настоящий вызов.

Интерфейсы и хорошие практики

Бывает, что рядом с каждой структурой сразу пишут интерфейс с теми же методами – на случай, если понадобится вторая реализация. Обычно она так и не появляется. У этого антипаттерна есть имя – interface pollution, загрязнение интерфейсами. Код обрастает абстракциями, которые ничего не абстрагируют: у интерфейса одна реализация, но переход к определению в IDE ведет к интерфейсу, а не к ней.

Из Go proverbs ↗: The bigger the interface, the weaker the abstraction – чем больше интерфейс, тем слабее абстракция. io.Reader с одним методом реализуют сотни типов; интерфейс на двадцать методов – ровно один, тот, ради которого его написали.

Несколько практических правил.

Объявляйте интерфейс на стороне потребителя

Часто интерфейс объявляют рядом с репозиторием и перечисляют в нем все его методы:

package pollution

type UserRepository interface {
	Save(u User) error
	Delete(id int) error
	Find(id int) (User, error)
	List() ([]User, error)
}
go

Вместо этого каждый потребитель объявляет ровно то, что использует:

package pollution

type UserSaver interface {
	Save(u User) error
}
go
package pollution

func RegisterUser(s UserSaver, name string) error {
	return s.Save(User{Name: name})
}
go

Благодаря неявной реализации конкретный тип хранилища удовлетворит UserSaver автоматически – и по сигнатуре RegisterUser() сразу видно, что функция только сохраняет. А заглушку для такого интерфейса написать проще, чем для UserRepository.

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

Функция, принимающая io.Reader, читает и из файла, и из сетевого соединения, а в тестах ей можно передать strings.NewReader("...").

Но возвращать интерфейс обычно не стоит: вызывающий код теряет доступ к остальным методам типа, а по сигнатуре непонятно, что реально возвращается. Представим обертку над bufio.NewWriter(), которая возвращает io.Writer:

package returns

import (
	"bufio"
	"io"
)

func newWriter(w io.Writer) io.Writer {
	return bufio.NewWriter(w)
}
go
var buf bytes.Buffer

w := newWriter(&buf)
fmt.Fprintln(w, "hello")
// w.Flush() не скомпилируется: у io.Writer нет такого метода

fmt.Printf("%q\n", buf.String())
go
""

buf остался пустым: строка застряла в буфере bufio.Writer, а Flush(), который записал бы ее в buf, через io.Writer не вызвать. Поэтому bufio.NewWriter() из стандартной библиотеки принимает io.Writer, а возвращает конкретный *bufio.Writer:

var buf bytes.Buffer

w := bufio.NewWriter(&buf)
fmt.Fprintln(w, "hello")
w.Flush()

fmt.Printf("%q\n", buf.String())
go
"hello\n"

Исключение – error: как мы видели в разделе про typed nil, конкретный тип ошибки в сигнатуре опасен.

Заводите интерфейс, когда он нужен потребителю

Тестовая заглушка – тоже реализация. Если RegisterUser() нужно проверить без базы данных, интерфейс оправдан с первого дня, даже когда в продакшене за ним один тип. Благодаря узкому UserSaver заглушка – это структура с одним методом:

package pollution

type fakeSaver struct {
	saved []User
}

func (f *fakeSaver) Save(u User) error {
	f.saved = append(f.saved, u)
	return nil
}
go
package pollution

import "testing"

func TestRegisterUser(t *testing.T) {
	var s fakeSaver

	if err := RegisterUser(&s, "Анна"); err != nil {
		t.Fatal(err)
	}

	if len(s.saved) != 1 || s.saved[0].Name != "Анна" {
		t.Fatalf("saved = %v, want one user named Анна", s.saved)
	}
}
go

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