Модуль: Core Go · Уровень: Senior
TL;DR#
Интерфейс в Go — это «толстый указатель» из двух слов: дескриптор типа и указатель на данные. Непустой интерфейс представлен структурой iface (с таблицей методов itab), пустой — eface. Главная ловушка senior-уровня: интерфейс с конкретным типом, но nil-значением внутри — НЕ равен nil. Удовлетворение интерфейса проверяется статически и структурно (duck typing).
Теория#
Внутреннее устройство: eface и iface#
Любая переменная интерфейсного типа в рантайме — это структура из двух машинных слов (на amd64 — 16 байт).
Пустой интерфейс interface{} / any представлен eface:
// runtime/runtime2.go
type eface struct {
_type *_type // дескриптор динамического типа
data unsafe.Pointer // указатель на данные (или сами данные, если влезают в слово)
}Непустой интерфейс (с методами) — iface:
type iface struct {
tab *itab // таблица: тип интерфейса + конкретный тип + методы
data unsafe.Pointer
}itab — itable#
itab — это кэшируемая структура, связывающая конкретный тип с интерфейсом:
type itab struct {
inter *interfacetype // тип интерфейса
_type *_type // конкретный (динамический) тип
hash uint32 // копия _type.hash для быстрых type switch
_ [4]byte
fun [1]uintptr // массив указателей на методы (переменной длины)
}fun[0] == 0означает, что тип НЕ реализует интерфейс.- itab строятся лениво при первом присваивании конкретного типа в интерфейс и кэшируются в глобальной hash-таблице (
itabTable). Поэтому повторные присваивания дешёвы. funсодержит указатели на конкретные реализации методов в нужном порядке — вызов метода через интерфейс это разыменованиеtab.fun[i]+ indirect call. Это мешает инлайнингу и предсказанию переходов.
Хранение данных и escape#
Поле data — указатель. Если в интерфейс кладут значение, не помещающееся в указатель (или адресуемое), компилятор размещает его в куче (escape) и кладёт указатель. Даже маленькое значение int при упаковке в интерфейс уходит в кучу (есть оптимизация для маленьких целых через staticuint64s для значений 0–255).
var x interface{} = 42 // 42 копируется, обычно escape в heapnil-интерфейс vs nil-значение (КЛЮЧЕВОЕ)#
Интерфейс равен nil ТОЛЬКО когда оба слова nil: и тип, и данные.
func doer() error {
var p *MyError = nil
return p // возвращаем nil-указатель, но в типизированном виде
}
err := doer()
fmt.Println(err == nil) // false! tab != nil (тип *MyError), data == nilЭто классический баг. Объявив var err error, мы получаем (nil, nil). Положив туда (*MyError)(nil), мы получаем (*MyError, nil) — интерфейс не nil.
// АНТИПАТТЕРН
func do() error {
var err *MyError
if somethingBad() {
err = &MyError{}
}
return err // если err==nil, всё равно вернётся не-nil интерфейс
}
// ПРАВИЛЬНО: возвращать nil-литерал явно либо использовать error-переменнуюInterface satisfaction (структурная типизация)#
Тип реализует интерфейс, если имеет все его методы — без явного объявления (implements не нужно). Проверка статическая, на этапе компиляции, кроме случаев присваивания через интерфейс/assertion (тогда в рантайме строится itab).
Важно про набор методов (method set):
- У типа
Tmethod set — методы с receiverT. - У типа
*Tmethod set — методы с receiverTИ*T.
Поэтому если метод объявлен на *T, то значение T не удовлетворяет интерфейсу (только *T):
type Stringer interface{ String() string }
type Foo struct{}
func (f *Foo) String() string { return "foo" }
var s Stringer = Foo{} // ОШИБКА компиляции
var s Stringer = &Foo{} // OKType assertion и type switch#
v, ok := i.(T) // безопасная форма
v := i.(T) // паникует, если тип не совпалПод капотом: для конкретного типа сравнивается eface._type / iface.tab._type. Для интерфейсного целевого типа — строится/ищется itab. Form , ok не паникует.
switch v := i.(type) {
case int: // v имеет тип int
case string: // v имеет тип string
case nil: // i == nil (eface пустой)
default:
}Type switch использует itab.hash для быстрой первичной фильтрации.
Пустой интерфейс и any#
any (Go 1.18+) — алиас для interface{}. Используется для гетерогенных данных, но теряет статическую типизацию. На senior-уровне предпочитают дженерики там, где раньше был interface{}.
Accept interfaces, return structs#
Идиома: функции принимают интерфейсы (гибкость для вызывающего), но возвращают конкретные типы (вызывающий получает полный API, не теряет методы, проще тестировать).
func New() *Server { ... } // возвращаем конкретный тип
func Process(r io.Reader) error // принимаем интерфейсИсключение — фабрики с несколькими реализациями или когда возврат интерфейса намеренно скрывает реализацию.
Подводные камни / gotchas#
- Nil-интерфейс != typed nil. См. выше — самый частый баг при возврате ошибок.
- Сравнение интерфейсов паникует, если динамический тип не comparable (например, slice/map/func внутри).
i1 == i2для двух интерфейсов со слайсами внутри → паника в рантайме. - Method set и адресуемость.
Foo{}.String()работает (компилятор берёт адрес), ноFoo{}нельзя положить в интерфейс, если метод на*Foo— значение в интерфейсе не адресуемо. - Аллокации при упаковке. Перевод значения в интерфейс часто вызывает escape в кучу — горячие пути с
interface{}могут неожиданно аллоцировать. fmt.Stringerрекурсия. ЕслиString()вызываетfmt.Sprintf("%v", t)на том же типе — бесконечная рекурсия/переполнение стека.- Изменение значения через интерфейс невозможно — внутри копия (если value receiver). Нужен указатель.
Вопросы на собеседовании#
В: Из чего состоит интерфейсное значение в рантайме?
О: Из двух слов. Для непустого интерфейса — iface{tab *itab, data unsafe.Pointer}, где itab содержит тип интерфейса, конкретный тип, хэш и таблицу указателей на методы. Для пустого — eface{_type *_type, data unsafe.Pointer}. data — указатель на данные (значение копируется в кучу при упаковке).
В: Почему err != nil, хотя я вернул nil-указатель?
О: Интерфейс nil только когда оба слова nil. Возвращая типизированный nil-указатель (*T)(nil), мы заполняем поле типа (tab/_type), поэтому интерфейс не nil, хотя data == nil. Сравнение == nil сравнивает оба слова.
В: Что такое itab и когда он создаётся? О: itab связывает конкретный тип с интерфейсом и хранит таблицу методов. Создаётся лениво при первом присваивании конкретного типа в данный интерфейс, кэшируется в глобальной itabTable, поэтому повторные операции дешёвы.
В: Может ли сравнение интерфейсов вызвать панику?
О: Да. Если динамические типы совпадают, но не comparable (слайс, map, функция внутри структуры) — рантайм-паника при ==.
В: В чём разница method set для T и *T применительно к интерфейсам?
О: Method set *T включает методы с обоими receiver, method set T — только value-receiver. Если метод на *T, то T не удовлетворяет интерфейсу — нужен указатель.
В: Что значит «accept interfaces, return structs» и почему? О: Принимай интерфейсы для гибкости (вызывающий подставит любую реализацию, легче мокать), возвращай конкретные типы, чтобы не урезать API и не плодить лишние абстракции. Возврат интерфейса оправдан, когда нужно скрыть реализацию.
В: Что происходит при i.(T) без , ok и с ним?
О: Без ok — паника при несовпадении. С ok — возвращается zero value и false. Для конкретного T сравниваются дескрипторы типов, для интерфейсного — ищется/строится itab.
В: Почему interface{} иногда вызывает аллокацию?
О: Значение должно лежать по указателю в data. Если оно не адресуемо/не помещается оптимизированно, компилятор делает escape в кучу. Поэтому горячие пути на any стоит измерять.
На что копают на senior+#
- Точное знание layout: 2 слова, поля itab, ленивое построение и кэш.
- Понимание escape-анализа при упаковке в интерфейс и влияния на GC/аллокации.
- Объяснение, почему вызов через интерфейс мешает инлайнингу (devirtualization компилятором с Go 1.18+ для известных типов).
- Follow-up: «Как компилятор девиртуализирует вызовы интерфейсов?», «Почему type switch быстрее цепочки assertion?» (используется hash), «Как реализован
comparableпри сравнении интерфейсов и где паника?».