Модуль: 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 в heap

nil-интерфейс 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):

  • У типа T method set — методы с receiver T.
  • У типа *T method set — методы с receiver T И *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{} // OK

Type 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 при сравнении интерфейсов и где паника?».