Модуль: Core Go · Уровень: Senior+

TL;DR#

Дженерики появились в Go 1.18 и позволяют параметризовать функции и типы по типам (type parameters) с ограничениями (constraints), которые задаются интерфейсами, описывающими множество типов (type set), а не множество методов. Под капотом Go использует не чистую мономорфизацию (как C++/Rust) и не чисто словари (как Java erasure), а гибрид — GC Shape Stenciling: компилятор генерирует по одной инстанцированной копии кода на каждую уникальную «GC-форму» (gcshape) аргумента, а различия в пределах одной формы разрешаются через скрытый словарь (dictionary). Это даёт компактный бинарник, но и неочевидные накладные расходы: дженерики далеко не всегда быстрее интерфейсов и часто теряют девиртуализацию и инлайнинг.

Теория#

Базовый синтаксис: type parameters#

Параметр типа объявляется в квадратных скобках после имени функции/типа, перед обычным списком параметров:

func Map[T, U any](s []T, f func(T) U) []U {
    r := make([]U, len(s))
    for i, v := range s {
        r[i] = f(v)
    }
    return r
}

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }

Важные правила синтаксиса:

  • [T any]T это параметр типа, any — его ограничение (constraint). any это псевдоним для interface{}.
  • Параметр типа имеет область видимости всей сигнатуры и тела, а у методов — берётся из объявления типа-ресивера. Метод не может вводить собственные параметры типа (см. ограничения).
  • Инстанцирование (instantiation) — подстановка конкретных типов: Map[int, string](...). Часто аргументы типов выводятся (type inference), и скобки опускаются.

Constraints как интерфейсы — но это интерфейсы про множества типов#

Constraint — это интерфейс. Но в Go 1.18 интерфейсы получили расширенный смысл. Теперь интерфейс описывает type set — множество типов, которые ему удовлетворяют. Обычный интерфейс с методами — это множество всех типов, реализующих эти методы. Новые элементы (union, approximation) расширяют, что можно записать в интерфейс.

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64 | ~string
}

Ключевое разграничение, которое любят спрашивать:

  • Basic interface — содержит только методы (и встроенные basic-интерфейсы). Может использоваться и как тип значения, и как constraint.
  • General interface (с union/approximation/comparable) — может использоваться только как constraint, его нельзя применять как тип переменной. Попытка var x Ordered — ошибка компиляции: «interface contains type constraints».

Approximation: элемент ~#

~T означает «все типы, у которых underlying type равен T». Это критично для пользовательских типов:

type Celsius float64

func Sum[T ~float64](xs []T) T { /* ... */ } // принимает и Celsius
func Sum2[T float64](xs []T) T { /* ... */ } // принимает ТОЛЬКО float64, Celsius — нет
  • ~int = int + любой type MyInt int.
  • Тип в ~T обязан сам быть своим underlying (нельзя ~MyInt, если MyInt имеет underlying int; нельзя ~error — интерфейс не может стоять под ~).
  • Без ~ constraint требует точного совпадения типа, что почти всегда не то, что нужно для числовых дженериков.

Union элементы#

Union перечисляет альтернативы через |. Type set union — это объединение type set’ов слагаемых:

type Number interface {
    ~int | ~int64 | ~float64
}

Ограничения union:

  • Терм union не может содержать методов (нельзя int | io.Reader), кроме одиночного интерфейса без методов как единственного элемента в особых случаях. Практически: union — это про конкретные/approximated типы.
  • Term’ы не должны пересекаться по type set, если используется ~ некорректно (компилятор это проверяет в части случаев).
  • В теле функции с union-constraint можно использовать только те операции, что поддерживаются всеми членами множества. Если Number содержит и string, и int, оператор + разрешён (оба поддерживают), а & (битовый) — уже нет, т.к. string его не поддерживает.

Type sets — модель, лежащая в основе#

Понятие type set — это формальная семантика constraint’ов. Правила:

  • Метод m() в интерфейсе сужает множество до типов с этим методом.
  • T (тип) — множество {T}.
  • ~T — множество всех типов с underlying T.
  • A | B — объединение.
  • Несколько элементов на разных строках интерфейса — пересечение (intersection).
type SignedInt interface {
    ~int | ~int32 | ~int64
}
type Stringer interface { String() string }

// Пересечение: типы с underlying int* И методом String()
type Both interface {
    SignedInt
    Stringer
}

Операция в теле дженерика легальна, только если её поддерживает каждый тип из type set’а. Это даёт статическую проверку без рантайм-сюрпризов.

comparable и его особенности (важно для 1.20+)#

comparable — предопределённый constraint: множество всех типов, которые можно сравнивать через == и != без паники в рантайме на уровне типа. Используется прежде всего для ключей map:

func Keys[K comparable, V any](m map[K]V) []K { /* ... */ }

Тонкость, которую обязательно копают на senior:

  • До Go 1.20 comparable означал строго сравнимые типы. Интерфейсные типы (например any) не удовлетворяли comparable, потому что сравнение интерфейсов может паниковать в рантайме, если внутри лежит несравнимый тип (slice, map, func).
  • В Go 1.20 правила смягчили: теперь comparable удовлетворяют и «строго сравнимые», и обычные интерфейсные типы (так называемые «spec-comparable», которые сравнимы синтаксически, но могут паниковать в рантайме). То есть теперь any удовлетворяет comparable.
// До 1.20 — ошибка компиляции, начиная с 1.20 — компилируется:
func f[T comparable]() {}
var _ = f[any]
  • Следствие/риск: код, который раньше был защищён компилятором от рантайм-паники сравнения, теперь может скомпилироваться и паниковать в рантайме, если в any-ключ положить slice/map/func:
m := map[any]int{}
m[[]int{1}] = 1 // паника: runtime error: hash of unhashable type []int

То есть comparable после 1.20 гарантирует синтаксическую сравнимость, но не гарантирует отсутствие рантайм-паники для интерфейсных типов.

Инстанцирование под капотом: GC Shape Stenciling#

Это центральная senior-тема. Существует два классических подхода к реализации дженериков:

ПодходКто используетПлюсыМинусы
Мономорфизация (stenciling)C++ templates, Rustмаксимум скорости, инлайнинг, специализацияраздувание кода (code bloat), долгая компиляция
Словари / type erasureJava (erasure), C# (частично)компактный код, одна копиябоксинг, динамические диспетчи, медленнее

Go выбрал гибридGC Shape Stenciling. Идея:

  1. Компилятор генерирует не по копии на каждый конкретный тип, а по копии на каждую уникальную GC-форму (gcshape) аргумента типа.
  2. GC shape типа — это, грубо, то, как тип выглядит для аллокатора и сборщика мусора: его размер, выравнивание и расположение указателей внутри (pointer/scalar map). Главное правило: все типы-указатели имеют одну и ту же GC shape (*T для любого T — это просто слово-указатель). Поэтому Stack[*Foo], Stack[*Bar], Stack[*Baz] используют одну инстанцированную копию кода.
  3. Типы с разной формой получают разные копии: int (скаляр, 8 байт), string (16 байт, содержит указатель), [3]int и т.д. — у каждого своя стенцилированная копия.

Но одной формы недостаточно: внутри функции могут понадобиться сведения, специфичные для конкретного типа, а не его формы — например:

  • какой именно *runtime._type (дескриптор типа) использовать при make, конверсиях, рефлексии;
  • адреса методов конкретного типа, если constraint содержит методы (нужно вызвать T.String());
  • метаданные для itab при упаковке в интерфейс.

Эту специфику компилятор передаёт через скрытый dictionary (словарь) — дополнительный неявный аргумент, который добавляется к каждой инстанцированной функции. Словарь содержит:

  • указатели на runtime type descriptors для каждого параметра типа и производных типов;
  • itab’ы (таблицы методов) для вызовов методов из constraint’ов и для конверсий в интерфейсы;
  • информацию о суб-словарях для вложенных вызовов других дженериков.
Вызов:  Sum[Celsius](xs)
Линкуется в копию для gcshape "float64-подобный скаляр 8 байт"
Передаётся скрытый dict с *_type для Celsius и т.п.

Таким образом:

  • Stenciling работает по форме (мало копий) → компактный бинарник, разумное время компиляции.
  • Dictionaries закрывают разницу между типами одной формы → корректность без отдельной копии на каждый тип.

Почему это влияет на производительность#

Это самая частая ошибка интуиции: «дженерики = мономорфизация = быстро». В Go это не так.

  1. Указательные типы делят код и работают через словарь. Для Stack[*Foo] обращение к информации о типе идёт через индирекцию в словарь, а не зашито константой. Вызовы методов из constraint’а становятся косвенными (загрузка itab из словаря, потом вызов) — фактически как у интерфейсов, devirtualization теряется.
  2. Потеря инлайнинга. Поскольку одна копия обслуживает много типов, компилятору труднее инлайнить и специализировать арифметику/вызовы; часть оптимизаций, доступных для конкретного типа, недоступна.
  3. Накладные на словарь. Дополнительный аргумент-словарь, лишние загрузки из памяти.
  4. Результат: дженерик-код над указательными типами и с вызовами методов может быть не быстрее, а иногда медленнее, чем эквивалент с интерфейсами — и заметно медленнее, чем рукописная мономорфная версия. Зато над скалярными типами (числа) дженерики обычно выигрывают у интерфейсов, потому что избегают боксинга (heap-аллокаций при упаковке значения в интерфейс).

Практический вывод: для скаляров (числа) дженерики дают выигрыш за счёт отсутствия боксинга; для указательных типов с вызовами методов выигрыша по сравнению с интерфейсами может не быть.

Когда дженерики уместны#

  • Контейнеры/структуры данных: типобезопасные стеки, очереди, деревья, sets, lru-кэши — раньше делались через interface{} с приведениями.
  • Алгоритмы над коллекциями: Map, Filter, Reduce, сортировки, бинпоиск.
  • Стандартные пакеты: slices и maps (стабилизированы в Go 1.21), cmp, частично sync (sync.OnceValue и т.п.). golang.org/x/exp/constraints даёт constraints.Ordered, Integer, Signed, Float.
  • Функции, где раньше копипастили версии под каждый числовой тип.

Антипаттерны: не стоит «дженерифицировать» ради дженериков. Если у вас один-два конкретных типа — обычные функции/интерфейсы проще и часто быстрее. Дженерики для слоёв с поведением (обычные методы, полиморфизм через поведение) — интерфейсы лучше.

Ограничения языка (важно знать наизусть)#

  • Нет параметризованных методов. Метод не может объявлять собственные type-параметры: func (r Repo) Get[T any]() T — запрещено. Type-параметры методов могут приходить только от типа-ресивера.
  • Нет специализации (template specialization). Нельзя написать особую реализацию Max для string и другую для int — одна реализация на всё множество.
  • Нет методов в union с другими типами — нельзя смешивать method-элементы и type-элементы в одном union произвольно.
  • Ограничения type inference. Вывод типов не всемогущ: не выводит из возвращаемого значения, плохо работает через несколько уровней, не всегда выводит из constraint’ов. Иногда приходится указывать аргументы типа явно.
  • Нет дженериков на уровне пакетов/переменных в смысле «дженерик-переменная».
  • Нельзя использовать general interface как тип значения (var x comparable, var x Ordered — ошибка).
  • Нет ковариантности/контравариантности: []Stack[Animal] не совместим с []Stack[Dog].
  • Type switch по параметру типа T напрямую не разрешён (switch T {} нельзя); приходится свитчить по значению any(v).

Подводные камни / gotchas#

  • comparable теперь пускает any (1.20+) → рантайм-паника. Компилятор больше не страхует: map[any]V с несравнимым ключом упадёт в рантайме. Если нужна строгая гарантия — придётся проектировать API иначе или валидировать.

  • Забыли ~ в constraint. func F[T int] не примет ваш type ID int. Почти всегда нужно ~int. Очень частая ошибка.

  • General interface как обычный тип.

    type Ordered interface { ~int | ~string }
    var x Ordered // ошибка компиляции: interface contains type constraints
  • Ожидание мономорфизации и скорости. Бенчат «дженерик vs интерфейс» на указательных типах и удивляются, что дженерик не быстрее — это следствие GC Shape Stenciling + словарей + потери девиртуализации.

  • Операции, не поддерживаемые всем type set’ом. Если constraint включает string, нельзя применять побитовые операции, даже если на практике вызывают только с int. Компилятор смотрит на всё множество, а не на конкретный вызов.

  • Type inference и nil/литералы. Map(s, func(x) {...}) иногда не выводит тип результата; нужно Map[int, string](...). Литерал nil без типа нередко ломает вывод.

  • Метод не может стать дженериком. Желание сделать «дженерик-метод» на не-дженерик типе упирается в стену; решается выносом в свободную функцию.

  • Zero value параметра типа. Чтобы получить нулевое значение T, используют var zero T (нельзя T{} в общем случае, нельзя nil, т.к. T может быть не-nullable).

  • Сравнение значений T требует comparable. Внутри [T any] нельзя писать a == b; нужно [T comparable].

  • Конверсия в интерфейс внутри дженерика аллоцирует. any(v) для скалярного T боксит значение — теряется часть выигрыша.

Вопросы на собеседовании#

В: Что такое type set и почему интерфейс в Go 1.18 — это «множество типов»? О: Интерфейс теперь формально определяет множество типов, которые ему удовлетворяют. Для basic-интерфейса это все типы с нужными методами. Union (A | B) даёт объединение множеств, approximation (~T) — все типы с underlying T, перечисление элементов на разных строках — пересечение. Операция в теле дженерика легальна, только если её поддерживает каждый тип из множества. Это позволяет статически проверять корректность без рантайм-проверок.

В: Чем ~int отличается от int в constraint? О: int требует точного совпадения типа — пользовательский type ID int не подойдёт. ~int означает «все типы с underlying int», включая ID. Под ~ можно ставить только тип, который сам является своим underlying, и нельзя ставить интерфейс.

В: Как реализованы дженерики в Go под капотом? Это мономорфизация? О: Нет, это гибрид — GC Shape Stenciling. Компилятор генерирует по копии кода не на каждый конкретный тип, а на каждую уникальную GC-форму (gcshape): размер, выравнивание, расположение указателей. Все указательные типы имеют одну форму, поэтому делят одну копию. Различия конкретных типов внутри одной формы передаются через скрытый словарь (dictionary): runtime type descriptors, itab’ы для вызовов методов, суб-словари. Это компромисс между размером бинарника/скоростью компиляции (как у словарей) и производительностью (как у мономорфизации).

В: Почему дженерики не всегда быстрее интерфейсов? О: Из-за словарей. Для типов одной GC-формы (особенно указателей) вызовы методов из constraint’а идут косвенно через itab в словаре — это та же динамическая диспетчеризация, что у интерфейсов, девиртуализация теряется. Плюс затрудняется инлайнинг и специализация, есть накладные на сам словарь-аргумент. Выигрыш дженерики дают в основном на скалярных типах за счёт устранения боксинга, которого требует интерфейс.

В: Что изменилось в comparable в Go 1.20 и какие риски это создаёт? О: До 1.20 comparable означал строго сравнимые типы и интерфейсы (включая any) ему не удовлетворяли, т.к. сравнение интерфейсов может паниковать в рантайме. С 1.20 правила смягчены: comparable удовлетворяют и обычные интерфейсные типы. Теперь f[any] компилируется. Риск — компилятор больше не защищает от рантайм-паники: map[any]int с ключом-slice упадёт в рантайме hash of unhashable type.

В: Почему Stack[*A] и Stack[*B] используют один экземпляр кода, а Stack[int] и Stack[string] — разные? О: Потому что *A и *B имеют идентичную GC shape (одно слово-указатель, одинаковый pointer map), а int (скаляр 8 байт без указателей) и string (16 байт с указателем внутри) — разные формы. Stenciling идёт по форме, а различие между *A и *B (их type descriptors, методы) закрывается словарём.

В: Какие принципиальные ограничения есть у дженериков Go? О: Нельзя объявлять параметризованные методы (type-параметры метода берутся только от ресивера); нет специализации под конкретный тип; general-интерфейсы (с union/comparable) нельзя использовать как тип значения; type inference ограничен (не выводит из возвращаемого значения, не всегда через несколько уровней); нет ковариантности; нельзя делать type switch напрямую по T.

В: Когда стоит использовать дженерики, а когда интерфейсы? О: Дженерики — для типобезопасных контейнеров и алгоритмов над коллекциями (slices, maps, Map/Filter/Reduce), где раньше был interface{} с приведениями, особенно для скаляров (избегаем боксинга). Интерфейсы — когда нужен полиморфизм по поведению, разные реализации одного контракта, плагинная архитектура. Если типов один-два — обычные функции проще и часто быстрее, чем дженерики.

В: Что такое словарь (dictionary) и что в нём лежит? О: Это скрытый дополнительный аргумент инстанцированной дженерик-функции. Содержит runtime type descriptors (*_type) для параметров типа и производных типов, itab’ы для вызовов методов из constraint’ов и для конверсий в интерфейсы, а также суб-словари для вложенных дженерик-вызовов. Через него код, общий для одной GC-формы, получает доступ к специфике конкретного типа.

На что копают на senior+#

  • Глубина по GC Shape Stenciling. Senior должен не просто знать слово «stenciling», а объяснить: что такое gcshape (размер/выравнивание/pointer map), почему все указатели имеют одну форму, что именно лежит в словаре, и как это связано с потерей девиртуализации. Follow-up: «почему Go не сделал полную мономорфизацию?» — ответ про code bloat и время компиляции, осознанный инженерный компромисс.

  • Бенчмарк-интуиция. Спросят: «дженерик-Max над []int vs интерфейс-версия — что быстрее и почему?» Сильный ответ: дженерик быстрее на скалярах (нет боксинга), но над указательными типами с вызовами методов разница может исчезнуть из-за индирекции через словарь. Ещё лучше — упомянуть, что надо мерить go test -bench и смотреть escape analysis / аллокации.

  • Эволюция comparable. Различение «strictly comparable» vs «spec-comparable» и понимание, что 1.20 убрал статическую защиту и переложил риск в рантайм — это маркер senior. Follow-up: как спроектировать API так, чтобы не допустить несравнимый ключ.

  • Границы type inference. Покажут код, который не компилируется без явных аргументов типа, и спросят почему. Нужно знать, что вывод не идёт от возвращаемого типа и ограниченно работает через цепочки вызовов.

  • Понимание, чего дженерики НЕ решают. Нет параметризованных методов → нельзя сделать дженерик-интерфейс с дженерик-методом; нет ковариантности → проблемы с коллекциями параметризованных типов. Senior называет это сразу и предлагает обходные пути (свободные функции, type-параметры на уровне типа).

  • Связь с runtime и escape analysis. Понимание, что конверсия any(v) внутри дженерика боксит и аллоцирует, что словарь — это память и индирекция, и как это видно в профиле/дизассемблере (go build -gcflags=-m, go tool objdump).