Дженерики: параметры типа, ограничения, инстанцирование
Кратко о теме
Заголовок раздела «Кратко о теме»Дженерики (параметрический полиморфизм) появились в Go 1.18 — это самое крупное изменение языка со времён Go 1. Функция или тип получают список параметров типа в квадратных скобках: func Map[T, U any](xs []T, f func(T) U) []U, type Stack[T any] struct { items []T }. Каждый параметр типа сопровождается ограничением (constraint) — интерфейсом, который описывает, что с этим типом разрешено делать. Ограничение может быть обычным набором методов (fmt.Stringer), а может быть новой конструкцией — множеством типов (type set): ~int | ~string. Тильда ~int означает «любой тип, у которого базовый тип int», то есть в том числе type UserID int. Интерфейс с элементами-типами разрешено использовать только как ограничение, переменной такого типа объявить нельзя.
Модель в голове должна быть такая: до 1.18 в Go было ровно два способа написать «одинаковый код для разных типов» — интерфейсы (динамическая диспетчеризация, потеря конкретного типа, зачастую аллокация при упаковке значения в interface{}) и кодогенерация через go generate + шаблоны (text/template, genny, gotemplate), плюс совсем тяжёлая артиллерия — reflect. Дженерики закрывают ровно ту нишу, где раньше приходилось выбирать между «потерять типы» и «сгенерировать пять почти одинаковых файлов»: контейнеры и алгоритмы над коллекциями, где тип элемента не важен для логики. Они не заменяют интерфейсы: интерфейс по-прежнему правильный инструмент, когда поведение у реализаций разное, а дженерик — когда поведение одно, а типы разные.
Компилируются дженерики не так, как в C++ (полная мономорфизация) и не так, как в Java (стирание типов). Go использует GC Shape stenciling with dictionaries: компилятор порождает по одной копии кода на «gcshape» — грубо говоря, на форму типа с точки зрения сборщика мусора и размера. Все указательные типы (*T, map, chan, func, интерфейсы, срезы — то, что представлено одним указателем) имеют одну и ту же форму и делят одну инстанциацию; int64 и float64 получат свои. Информация, зависящая от конкретного типа (дескриптор типа, таблицы методов, размеры), передаётся в скрытом аргументе — словаре (dictionary). Отсюда практические следствия: код не раздувается так, как при полной мономорфизации, но и не всегда так же быстр, как рукописный конкретный код — обращения через словарь мешают части оптимизаций и инлайнингу. Дженерик-код обычно быстрее варианта на interface{} (нет боксинга и рефлексии), но может проиграть специализированному коду; если производительность критична — надо мерить.
Границы возможностей стоит помнить наизусть, их спрашивают: у методов не бывает собственных параметров типа (func (s Stack[T]) Map[U any](...) — ошибка компиляции); нельзя выражать «структура с полем X» без core type; нельзя специализировать реализацию для конкретного T; нет ковариантности ([]Dog не подходит там, где ждут []Animal); нет перегрузки операторов сверх того, что разрешает ограничение. Из свежего: Go 1.21 заметно улучшил вывод типов и принёс в стандартную библиотеку slices, maps и cmp; Go 1.23 добавил итераторы (iter.Seq[V], iter.Seq2[K,V]) и range-over-func, из-за чего maps.Keys теперь возвращает итератор, а не срез; Go 1.24 разрешил обобщённые псевдонимы типов (type Set[T comparable] = map[T]struct{}).
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Generics. What are they? How do they work in Go? a) Go 1.18: syntax, purpose. How did you manage without them?
Заголовок раздела «Generics. What are they? How do they work in Go? a) Go 1.18: syntax, purpose. How did you manage without them?»Коротко. Дженерики — параметрический полиморфизм: функция или тип параметризуются типом, а не только значением. В Go они появились в 1.18: параметры типа объявляются в квадратных скобках с ограничением-интерфейсом (func Max[T cmp.Ordered](a, b T) T), компилятор проверяет всё статически и генерирует код по схеме «GC shape stenciling + словари». До 1.18 обходились интерфейсами и interface{} с приведением типа, кодогенерацией через go generate и, в крайнем случае, рефлексией.
Глубже. Синтаксис состоит из трёх частей. Первая — объявление параметров типа: func F[T any, K comparable](...), type Pair[A, B any] struct { First A; Second B }. Вторая — ограничения: любой интерфейс годится как ограничение, но у ограничений есть дополнительные возможности — элементы-типы и объединения, ~T для «типов с базовым типом T», встроенное any (алиас interface{}) и встроенное comparable (типы, для которых определены ==/!= и которые можно класть в ключ мапы). Третья — инстанцирование: Max[int](1, 2) явно либо Max(1, 2) с выводом типа. Вывод типов работает по аргументам функции и по уже известным параметрам типа; в Go 1.21 он расширен (вывод для generic-функций, передаваемых как значения, и для методов при присваивании интерфейсу), но полного вывода для типов-структур нет: Stack[int]{} писать обязательно, Stack{} не скомпилируется.
package main
import ( "cmp" "fmt" "slices")
// Своё ограничение через объединение и тильду:// ~int подходит и для `type UserID int`.type Number interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64}
func Sum[T Number](xs []T) T { var acc T // нулевое значение параметра типа — только так, литерал 0 не всегда валиден for _, x := range xs { acc += x } return acc}
func MapSlice[T, U any](xs []T, f func(T) U) []U { out := make([]U, 0, len(xs)) for _, x := range xs { out = append(out, f(x)) } return out}
// Обобщённый тип с методами: параметры типа объявляет тип, не метод.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 } v := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return v, true}
func main() { fmt.Println(Sum([]float64{1.5, 2.5})) // 4 fmt.Println(MapSlice([]int{1, 2}, func(i int) string { return fmt.Sprint(i) })) fmt.Println(max(3, 7), cmp.Compare(3, 7)) // встроенный max (Go 1.21) и cmp fmt.Println(slices.Contains([]string{"a"}, "a")) // дженерик из stdlib
var s Stack[int] // инстанцирование типа — явное, вывода тут нет s.Push(42) fmt.Println(s.Pop())}Как жили без них: (1) interface{} + type assertion/type switch — работает, но типобезопасность уезжает в рантайм, значения боксятся, а API становится нечестным (func Min(a, b interface{}) interface{}); (2) интерфейс с методом-компаратором в стиле sort.Interface — приходилось для каждого типа писать Len/Less/Swap; (3) go generate + text/template или сторонние генераторы — типобезопасно и быстро, но растёт репозиторий, нужен шаг сборки, а IDE и линтеры видят сгенерированный код; (4) reflect — универсально и медленно, ошибки только в рантайме. На собеседовании полезно назвать конкретный след этой эпохи в стандартной библиотеке: sort.Slice на рефлексии, sync.Map с any в сигнатуре, container/list и container/heap на interface{}, atomic.Value. И тут же сказать, что сегодня им есть типизированные замены: slices.SortFunc, atomic.Pointer[T], sync.OnceValue[T].
Что такое дженерики?
Заголовок раздела «Что такое дженерики?»Коротко. Дженерики — это возможность написать одну реализацию функции или типа, работающую для множества типов, с проверкой корректности на этапе компиляции. Тип становится параметром: func Filter[T any](xs []T, pred func(T) bool) []T работает и для int, и для User, и при этом на выходе честный []User, а не []any.
Глубже. Ключевое отличие от интерфейса — где и что теряется. Интерфейс стирает конкретный тип: положив User в any, вы обязаны сделать assertion, чтобы получить его обратно, и компилятор не проверит, что вы сделали это правильно. Дженерик конкретный тип сохраняет: T внутри функции — это ровно тот тип, с которым её инстанцировали, и связь между входом и выходом выражается в сигнатуре. Второе отличие — что разрешено с T: только то, что гарантирует ограничение. С any нельзя даже сравнить на равенство (нужен comparable) и нельзя сложить (нужен констрейнт с числовыми типами). Отсюда типичная ошибка новичка: var zero T вместо 0 или nil — единственный корректный способ получить нулевое значение параметра типа.
Правило выбора: интерфейс — когда реализации ведут себя по-разному и вызывающему нужна динамическая диспетчеризация; дженерик — когда алгоритм один, а типы разные. Официальная рекомендация из блога Go: не начинайте с дженериков, пишите конкретный код, и обобщайте только когда увидели повторение; если внутри тела функции стоит type switch по параметру типа — почти наверняка нужен был интерфейс, а не дженерик.
Нашел ли применение дженерикам в Go?
Заголовок раздела «Нашел ли применение дженерикам в Go?»Коротко. Это вопрос про опыт: интервьюер хочет услышать не «да», а конкретный случай, где дженерик реально убрал дублирование или боксинг, и понимание, где вы их сознательно не применили. Хороший ответ — один-два примера из практики плюс упоминание, что большая часть пользы приходит бесплатно, через stdlib (slices, maps, cmp, sync.OnceValue, atomic.Pointer[T], errgroup/singleflight в типизированных обёртках).
Глубже. Каркас ответа: (1) задача — что дублировалось; (2) что сделали — какую сигнатуру ввели; (3) что получили — сколько кода/аллокаций ушло; (4) где не стали применять и почему. Типичные честные примеры, которые звучат убедительно:
- обобщённый кэш/пул:
type Cache[K comparable, V any] struct { ... }вместоmap[string]anyс приведением на каждом чтении; - хелперы над срезами и мапами до появления
slices/maps:Map,Filter,GroupBy[K comparable, T any](xs []T, key func(T) K) map[K][]T,Keys,Chunk; Ptr[T](v T) *Tдля указателей на литералы (в API/DTO с опциональными полями) иDeref[T](p *T, def T) T;- типизированная обёртка над каналом/воркер-пулом:
func Pool[In, Out any](in <-chan In, n int, f func(In) Out) <-chan Out; Result[T]/Option[T]— можно упомянуть, но стоит сразу оговориться, что в Go это чужеродно и в командном коде обычно проигрывает обычному(T, error);- декодирование ответов HTTP/БД:
func DecodeJSON[T any](r io.Reader) (T, error)— вместоinterface{}и приведения на стороне вызова.
Где сознательно не применять: доменная логика, где обобщать нечего; «обобщение ради обобщения» с тремя параметрами типа и нечитаемой сигнатурой; случаи, где нужен интерфейс с разным поведением; горячие участки, где мономорфный код измеримо быстрее.
// Живой пример: группировка чего угодно по любому сравнимому ключу.func GroupBy[T any, K comparable](xs []T, key func(T) K) map[K][]T { out := make(map[K][]T) for _, x := range xs { k := key(x) out[k] = append(out[k], x) } return out}Пользовался ли make generate? А дженериками? Сравнение с рефлексией.
Заголовок раздела «Пользовался ли make generate? А дженериками? Сравнение с рефлексией.»Коротко. Скорее всего имелось в виду go generate (часто завёрнутый в цель make generate) — это не сборочный инструмент, а способ запускать кодогенераторы по директивам //go:generate ... в исходниках: mockgen, stringer, protoc, sqlc, easyjson. Три подхода решают одну задачу «код для разных типов», но по-разному: кодогенерация — до компиляции, дженерики — во время компиляции, рефлексия — в рантайме. Чем раньше по этой шкале, тем быстрее и типобезопаснее, но тем менее динамично.
Глубже. go generate сам ничего не генерирует: он сканирует файлы пакета, находит строки вида //go:generate command args (комментарий обязан начинаться в начале строки, без пробела после //) и выполняет команды. Он не запускается автоматически при go build — его вызывают руками или из Makefile/CI, а результат коммитят в репозиторий. Подстановки: $GOFILE, $GOPACKAGE, $GOLINE, $DOLLAR. Начиная с Go 1.24 версии инструментов кодогенерации принято фиксировать директивой tool в go.mod (go get -tool, go tool <name>) — раньше для этого держали файл tools.go с пустым импортом.
Сравнение по осям:
go generate | Дженерики | reflect | |
|---|---|---|---|
| Когда работает | до компиляции | при компиляции | в рантайме |
| Типобезопасность | полная | полная | ошибки только в рантайме |
| Скорость | как рукописный код | близко к рукописному (словари мешают части оптимизаций) | заметно медленнее, аллокации на боксинг |
| Что умеет | всё, включая методы и код по внешней схеме (proto, SQL, моки) | только то, что выразимо через параметры типа | всё, что известно только в рантайме: поля, теги, произвольные структуры |
| Цена | лишний шаг сборки, сгенерированные файлы в репозитории | сложность сигнатур, читаемость | сложность, отсутствие проверок, риск паник |
Что вытеснило что: дженерики забрали у кодогенерации нишу «контейнеры и алгоритмы над коллекциями» (Set[T], Queue[T], Map/Filter), а у рефлексии — нишу «универсальные хелперы над данными известной формы» (sort.Slice → slices.SortFunc, ручные конвертеры срезов). Что осталось за кодогенерацией: моки, stringer, ORM/SQL по схеме, protobuf/gRPC, быстрые JSON-кодеки — всё, что порождает методы и опирается на внешнее описание, а не на параметр типа. Что осталось за рефлексией: encoding/json, database/sql, валидаторы по тегам структур, DI-контейнеры — там форма данных неизвестна на этапе компиляции, и дженерики её не заменят. Важная деталь для собеседования: дженерики не дают доступа к полям и тегам T, у параметра типа нет «интроспекции» — поэтому func Unmarshal[T any]([]byte) (T, error) всё равно внутри вызывает encoding/json, то есть рефлексию; дженерик тут только убирает приведение типа на стороне вызывающего.
Использовали ли у себя дженерики?
Заголовок раздела «Использовали ли у себя дженерики?»Коротко. См. выше про применение дженериков — вопрос тот же по сути, но формулировка «у себя» смещает акцент на командный контекст: что приняли как соглашение в проекте, а не что вы попробовали лично. Отвечать стоит парой «где применили и почему это оправдалось» + «где договорились не применять».
Глубже. Отличие от предыдущего вопроса в том, что здесь уместно рассказать про процесс и последствия: версия Go в проекте (дженерики требуют минимум go 1.18 в go.mod, slices/maps/cmp — go 1.21, итераторы — go 1.23), было ли обсуждение в code review, вынесли ли хелперы в общий internal-пакет или тащили samber/lo, не расползлись ли по кодовой базе три конкурирующих Map. Хорошо звучит признание границ: «пробовали обобщить слой репозиториев Repo[T] — получилось хуже, потому что у сущностей всё равно разные запросы, откатились к конкретным типам». Типичные ошибки в ответе: «не использовали, у нас всё на интерфейсах» без объяснения; перечисление возможностей языка вместо своего кейса; заявление «переписали всё на дженерики, стало быстрее» без цифр — за этим сразу последует вопрос про бенчмарки и про словари.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Говорят, что Go «мономорфизирует как C++» или, наоборот, «стирает типы как Java». В Go — промежуточная схема: GC shape stenciling со словарями, все указательные типы делят одну инстанциацию.
- Уверенно обещают, что дженерик-код всегда быстрее интерфейсного и не медленнее рукописного. Быстрее
interface{}— обычно да (нет боксинга), но словарь и худший инлайнинг могут проиграть конкретному коду. - Забывают про тильду: пишут ограничение
int | stringи удивляются, чтоtype UserID intне подходит. Нужно~int | ~string. - Путают
anyиcomparable: пытаются сравнить два значенияT anyчерез==или сделатьmap[T]V. - Возвращают
nilили0вместоvar zero Tпри раннем выходе из обобщённой функции. - Пробуют объявить параметры типа у метода (
func (s *Stack[T]) Map[U any](...)) — в Go это запрещено, обходится свободной функцией. - Пробуют использовать интерфейс с элементами-типами (
~int | ~string) как обычный тип переменной — он годится только как ограничение. - Считают, что дженерики отменяют рефлексию:
encoding/json,database/sql, валидация по тегам всё так же наreflect, потому что у параметра типа нет интроспекции полей. - Не знают актуального состояния stdlib: продолжают писать свои
Contains/Keysвместоslices/maps/cmp(Go 1.21) и не в курсе, что с Go 1.23maps.Keysвозвращает итератор (iter.Seq[K]), а не срез — нуженslices.Collect(maps.Keys(m)). - Путают
go generateс частью сборки: он не запускается приgo build, его вызывают явно, а результат коммитят.
Что почитать
Заголовок раздела «Что почитать»- Спецификация: разделы Type parameters, Type constraints, General interfaces — https://go.dev/ref/spec#Type_parameter_declarations
- Официальный туториал и FAQ по дженерикам: https://go.dev/doc/tutorial/generics и https://go.dev/doc/faq#generics
- Go blog, «When To Use Generics» — критерии применимости от авторов языка: https://go.dev/blog/when-generics
- Дизайн реализации: «Generics implementation — GC Shape Stenciling» — https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md
- Пакеты
slices,maps,cmp: https://pkg.go.dev/slices, https://pkg.go.dev/maps, https://pkg.go.dev/cmp go generate: https://go.dev/blog/generate иgo help generate- Go 1.24 Release Notes (обобщённые псевдонимы типов, директива
toolв go.mod): https://go.dev/doc/go1.24