Модуль: 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имеет underlyingint; нельзя~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— множество всех типов с underlyingT.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 erasure | Java (erasure), C# (частично) | компактный код, одна копия | боксинг, динамические диспетчи, медленнее |
Go выбрал гибрид — GC Shape Stenciling. Идея:
- Компилятор генерирует не по копии на каждый конкретный тип, а по копии на каждую уникальную GC-форму (gcshape) аргумента типа.
- GC shape типа — это, грубо, то, как тип выглядит для аллокатора и сборщика мусора: его размер, выравнивание и расположение указателей внутри (pointer/scalar map). Главное правило: все типы-указатели имеют одну и ту же GC shape (
*Tдля любогоT— это просто слово-указатель). ПоэтомуStack[*Foo],Stack[*Bar],Stack[*Baz]используют одну инстанцированную копию кода. - Типы с разной формой получают разные копии:
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 это не так.
- Указательные типы делят код и работают через словарь. Для
Stack[*Foo]обращение к информации о типе идёт через индирекцию в словарь, а не зашито константой. Вызовы методов из constraint’а становятся косвенными (загрузка itab из словаря, потом вызов) — фактически как у интерфейсов, devirtualization теряется. - Потеря инлайнинга. Поскольку одна копия обслуживает много типов, компилятору труднее инлайнить и специализировать арифметику/вызовы; часть оптимизаций, доступных для конкретного типа, недоступна.
- Накладные на словарь. Дополнительный аргумент-словарь, лишние загрузки из памяти.
- Результат: дженерик-код над указательными типами и с вызовами методов может быть не быстрее, а иногда медленнее, чем эквивалент с интерфейсами — и заметно медленнее, чем рукописная мономорфная версия. Зато над скалярными типами (числа) дженерики обычно выигрывают у интерфейсов, потому что избегают боксинга (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 над
[]intvs интерфейс-версия — что быстрее и почему?» Сильный ответ: дженерик быстрее на скалярах (нет боксинга), но над указательными типами с вызовами методов разница может исчезнуть из-за индирекции через словарь. Ещё лучше — упомянуть, что надо мерить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).