Интерфейсы в 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 "Гав-гав"
}goDog нигде не упоминает Sayer, но его значение можно присвоить переменной типа Sayer:
var s Sayer = Dog{Name: "Шарик"}
fmt.Println(s.Say())goГав-гавЭто называется неявной реализацией. Ее сила проявляется, когда реализаций становится больше одной. Напишем функцию, которая принимает интерфейс:
package declare
import "fmt"
func announce(s Sayer) {
fmt.Println(s.Say())
}gopackage declare
type Robot struct {
Model string
}
func (r Robot) Say() string {
return "Бип-буп"
}goannounce(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 sgoНиже – значение s в памяти. Из tab можно дойти до itab, а через нее – до метаданных Sayer и Dog и до кода Dog.Say. Из data – до копии Dog, которая лежит в интерфейсе. На вкладке var s any – то же значение в пустом интерфейсе.
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())} Переменная d – структура Dog во фрейме callExample на стеке. В статических данных – то, что компилятор подготовил заранее: itab пары (Sayer, Dog) и код Dog.Say.
Посмотрим на два машинных слова интерфейса вживую. Приведем интерфейсное значение к структуре с той же схемой памяти, что у 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()
}govar s Sayer = Dog{Name: "Шарик"}
inspectIface(s)gotab: 0x3e6dd48
data: 0x3e6d7c8tab указывает на 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()
}goinspectEface(42)
inspectEface(Dog{Name: "Шарик"})gotyp: 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)} Пустой интерфейс – тоже два слова, но в первом не itab, а _type: указатель прямо на метаданные типа. 42 – константа, поэтому ее упакованную копию компилятор заранее положил в статические данные, и a.data указывает туда.
Набор методов и pointer receiver
Заменим у Dog value receiver на pointer receiver и попробуем передать значение в интерфейс:
type Dog struct {
Name string
}
func (d *Dog) Say() string {
return "Гав-гав"
}
d := Dog{Name: "Шарик"}
var s Sayer = dgocannot 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())} Переменная d сразу лежит на хипе, а не на стеке. Компилятор заранее увидел, что адрес d уходит в интерфейс и может пережить фрейм, и перенес d на хип – в выводе -gcflags=-m это moved to heap: d.
Копирование при приведении к интерфейсу
Мы уже видели, что в момент присваивания интерфейсной переменной значение копируется. Посмотрим, что из этого следует:
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())} Переменная d – структура Dog во фрейме copyExample на стеке.
Куда копируется значение? Поле 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))go1А теперь упакуем число поменьше:
fmt.Println(boxAllocs(42))go0Ноль аллокаций. Для маленьких целых чисел в рантайме есть статический массив из 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]uint64goЕсли упаковываемое значение лежит в диапазоне от 0 до 255, рантайм не аллоцирует память, а направляет data на готовый элемент этого массива. Сравним по шагам два вызова с маленьким и большим числом:
//go:noinlinefunc box(n int) any { return n}func boxingExample() { small := box(7) big := box(1000) fmt.Println(small, big)} У вызова box(7) свой фрейм на стеке с параметром n. Директива //go:noinline не дает компилятору встроить box и упаковать константу заранее: значение n станет известно только в рантайме.
Упаковка указателя аллокаций не требует – указатель кладется в data как есть:
d := &Dog{Name: "Шарик"}
allocs := testing.AllocsPerRun(100, func() {
sink = d
})
fmt.Println(allocs)go0nil интерфейс и typed nil
Нулевое значение интерфейсного типа – nil: оба машинных слова пустые. Вызов метода на nil интерфейсе приведет к панике:
defer func() {
fmt.Println(recover())
}()
var s Sayer
s.Say()goruntime error: invalid memory address or nil pointer dereferenceПроисходит это, потому что tab равен nil, соответственно вызывать нечего. Проверим через уже знакомый inspectIface:
var s Sayer
inspectIface(s)gotab: <nil>
data: <nil>А теперь другой случай. Положим в интерфейс nil указатель на Dog:
var d *Dog
var s Sayer = d
inspectIface(s)gotab: 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} Переменная p – указатель на Dog, равный nil. Никакого Dog в памяти нет.
Вызов метода через typed nil интерфейс сам по себе не паникует: в таблице методов есть Say(), и он вызывается с receiver, равным nil. Дальше все зависит от метода. Этот объявлен с pointer receiver и проверяет nil:
package typednil
func (d *Dog) Say() string {
if d == nil {
return "Тишина"
}
return "Гав-гав"
}govar 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)
}gopackage 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)gofalse
(<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} trapExample вызывает process, а та – handle(false). У каждого вызова свой фрейм на стеке.
Программа с такой ошибкой уходит в ветку обработки ошибки при полностью успешном выполнении. Ловушка настолько известна, что попала в официальный FAQ ↗.
Решение – не объявлять конкретные типы ошибок в сигнатурах. Функция, возвращающая ошибку, должна возвращать error:
package typednil
func handleFixed(fail bool) error {
if fail {
return &AppError{Code: 500}
}
return nil
}goerr := handleFixed(false)
fmt.Println(err == nil)gotrueЗдесь 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)gotrue
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)gofalseСравнивать интерфейсы разных типов нельзя, если ни один из них не присваиваем другому – это ошибка компиляции:
var sayer Sayer
var sleeper Sleeper
fmt.Println(sayer == sleeper)goinvalid 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)goruntime 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)gointerface 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)} Переменная d – структура Dog во фрейме assertExample на стеке.
В примере с «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 "кто-то"
}gofmt.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("неизвестный тип")
}
}goclassify(Robot{Model: "R2-D2"})
classify(Dog{Name: "Шарик"})
classify(42)
classify(3.14)goStringer: Робот 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)gotab: 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())} Dog собирается из параметра first и копируется на хип. s1.tab указывает на itab пары (Sayer, Dog), который компилятор подготовил заранее.
Цена динамического диспатча
Вызов метода через интерфейс – динамический: компилятор не знает, какая функция будет вызвана, поэтому генерирует загрузку указателя из 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
}gopackage 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)
}
}goBenchmarkDirectCall-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
}gopackage 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)
}govar 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
}gopackage 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А логику без внешних эффектов – расчет скидки, валидацию, парсер – проще тестировать как есть: заглушка ей не нужна, а значит, не нужен и интерфейс. Понадобится позже – конкретный тип менять не придется: благодаря неявной реализации достаточно объявить интерфейс у потребителя.