Generics в Go: где реально помогают и как устроены внутри
Дженерики в Go 1.18+ — это не шаблоны C++ и не стирание типов как в Java. Разбираем механизм GC-shape stenciling, цену интерфейсов внутри дженериков и то, где обобщения реально экономят, а где ломают производительность.

С Go 1.18 (март 2022) в язык добавили параметрический полиморфизм. За два с лишним года эйфория сменилась вопросами: почему func Map[T, U any] иногда медленнее рукописной версии, откуда лишние аллокации и почему компилятор не всегда инлайнит обобщённый код. Ответ — в том, как Go компилирует дженерики: это ни полное мономорфизное разворачивание в стиле C++, ни стирание типов в стиле Java, а гибрид под названием GC-shape stenciling. Разберём механизм и посмотрим, где обобщения дают выигрыш, а где — скрытую цену.
Что происходит при компиляции обобщённой функции
Наивное ожидание: компилятор для каждого набора типовых аргументов генерирует отдельную копию функции. Так делает C++ с шаблонами — на выходе десятки специализаций, каждая максимально оптимизирована под свой тип. Плата — раздутый бинарник и долгая сборка.
Go пошёл другим путём. Вместо копии на каждый тип компилятор группирует типы по их GC-shape — форме с точки зрения сборщика мусора и раскладки в памяти. Все указательные типы (*Foo, *Bar, string-как-указатель на данные и т.д.) имеют одну GC-shape и делят одну сгенерированную копию функции. Целочисленные типы одного размера — другую.
Одна инстанциация обобщённой функции обслуживает все типы с одинаковой GC-shape. Для указателей это почти всегда одна копия на всё — экономия размера бинарника, но источник неочевидных накладных расходов.
Откуда берётся dictionary
Раз одна копия функции работает с разными конкретными типами, ей нужно во время выполнения знать, что это за тип: какой у него размер, как копировать, где искать методы. Эту информацию компилятор передаёт через скрытый аргумент — dictionary¹. Каждый вызов обобщённой функции неявно получает указатель на словарь, описывающий фактические типовые параметры.
Когда внутри дженерика вызывается метод, объявленный в ограничении (constraint), вызов идёт не напрямую, а через словарь — по сути, через таблицу, похожую на интерфейсную. Это и есть точка, где обобщённый код может проиграть специализированному: прямой вызов заменяется на косвенный, а инлайн через словарь компилятор в общем случае не делает.
Где generics реально помогают
Обобщения оправданы там, где раньше приходилось выбирать между тремя плохими вариантами: копипаста на каждый тип, interface{} с приведением, или кодогенерация через go generate. Дженерики закрывают все три случая.
1. Контейнеры и структуры данных
Типобезопасная очередь, стек, множество, LRU-кеш. До 1.18 их писали либо на interface{} (потеря типов, боксинг, паника при неверном приведении), либо генерировали код. Теперь:
// Go 1.18+
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
Для Stack[int] элементы лежат в срезе как значения, без боксинга — в отличие от []interface{}, где каждый int превратился бы в отдельную аллокацию с заголовком интерфейса.
2. Алгоритмы над упорядоченными и сравнимыми типами
Стандартная библиотека получила пакеты slices и maps (стабилизированы в Go 1.21). Вместо десяти версий сортировки — одна обобщённая:
import (
"cmp"
"slices"
)
func main() {
nums := []int{5, 2, 8, 1}
slices.Sort(nums) // работает для любого cmp.Ordered
idx, found := slices.BinarySearchFunc(nums, 8, cmp.Compare)
_ = idx
_ = found
}
Ограничение cmp.Ordered из пакета cmp перечисляет типы, поддерживающие операторы сравнения. Компилятор проверяет применимость на этапе сборки, а не паникует в рантайме.
3. Утилиты обработки данных
Map, Filter, Reduce, Keys, Values — функции, которые раньше дублировались под каждый тип или писались через рефлексию. Обобщённые версии дают статическую типизацию без рефлексии.
Где обобщения не нужны или вредны
- Когда есть ровно один тип. Если
Stackнужен только для*Order, дженерик добавляет словарь и косвенность, ничего не давая взамен. Пишите конкретный тип. - Когда достаточно интерфейса поведения. Если функция вызывает у аргумента один-два метода и не зависит от конкретного типа, обычный интерфейс проще и не тянет за собой инстанциацию.
- В горячем цикле с указательными типами. Здесь косвенный вызов метода через словарь и отсутствие инлайна могут стоить заметно дороже специализированной функции. Это надо мерить, а не предполагать.
| Ситуация | Дженерики | interface{} | Конкретный тип |
|---|---|---|---|
| Контейнер под несколько типов значений | Да, без боксинга | Боксинг + приведения | Копипаста на каждый тип |
| Функция зависит от поведения, не от типа | Избыточно | Идеально | — |
| Ровно один рабочий тип | Лишняя косвенность | — | Проще всего |
| Горячий цикл, значимые типы (int, float) | Обычно ок | Дорого (аллокации) | Быстрее всего |
Как измерить цену дженериков в своём коде
Не верьте абстрактным рассуждениям — включая эту статью. Замеряйте на своих типах и данных. Инструменты уже в стандартной поставке.
- Напишите бенчмарк для обобщённой и специализированной версии:
func BenchmarkGeneric(b *testing.B)иfunc BenchmarkConcrete(b *testing.B). - Запустите с учётом аллокаций:
go test -bench=. -benchmem. Смотрите на столбцыns/opиallocs/op— лишние аллокации выдают боксинг. - Проверьте, инлайнится ли вызов и уходят ли значения в кучу:
go build -gcflags='-m' ./.... В выводе ищите строкиinlining call toиescapes to heap. - Прогоните каждую версию несколько раз и сравните через
benchstat(пакетgolang.org/x/perf/cmd/benchstat) — он покажет, статистически значима ли разница или это шум.
Условия замера фиксируйте: версия Go, архитектура (amd64/arm64 дают разный результат), размер данных. Разница в 3% на 1000 элементов может стать 30% на десяти миллионах.
Граничные случаи, о которых спотыкаются
Методы не могут иметь собственных типовых параметров
Типовые параметры объявляются только у функций и типов, но не у методов сверх параметров самого типа. func (s *Stack[T]) MapTo[U any](...) не скомпилируется. Обходится вынесением в обычную функцию с двумя параметрами.
Вывод типов не всесилен
Компилятор часто выводит типовые аргументы из значений, но не всегда — особенно когда тип фигурирует только в возвращаемом значении. Тогда придётся указывать явно: Map[int, string](nums, conv). Это не баг, а ограничение алгоритма вывода.
Нельзя переключаться по типовому параметру через type switch напрямую
Внутри обобщённой функции T — это один тип для всей инстанциации, а не «любой из». Чтобы разветвить логику по фактическому типу, придётся привести к any и делать type switch по нему — что возвращает часть накладных расходов интерфейса.
Что делать дальше
Если пишете библиотеку с контейнерами или утилитами над коллекциями — начните с пакетов slices, maps и cmp из стандартной библиотеки (Go 1.21+): значительную часть задач они закрывают без своего кода. Для собственных обобщений держите правило: сначала конкретный тип или интерфейс, дженерик — только когда дублирование типов становится реальным, а не гипотетическим. Перед вливанием в горячий путь прогоните -benchmem и -gcflags='-m' и сравните с рукописной версией через benchstat. Детали механизма compiler описаны в design-документе Go по имплементации дженериков — читать стоит, если хотите понимать, почему словарь появляется именно там.
¹ Dictionary (словарь) — скрытая структура, которую компилятор Go передаёт в обобщённую функцию во время выполнения. Содержит информацию о фактических типовых параметрах: их размер, метаданные для сборщика мусора и таблицы методов для вызовов, объявленных в ограничениях. ↩
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go: Tutorial — Getting started with generics, Go blog — An Introduction To Generics
Частые вопросы
У меня Go 1.20, а не 1.21 — пакеты slices и maps доступны?
Дженерики всегда быстрее interface{}?
Почему компилятор не инлайнит мою обобщённую функцию?
Можно ли сделать type switch по типовому параметру T?
Почему методу нельзя добавить свой типовой параметр?
Раздувают ли дженерики бинарник, как шаблоны C++?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.