Дженерики появились в Go 1.18 15 марта 2022 года. Их ждали дольше любой другой возможности языка. Расс Кокс сформулировал «дилемму дженериков» ещё в 2009 году. Финальный дизайн, Type Parameters Proposal, написали Иэн Лэнс Тейлор и Роберт Гризмер, и в 2021 году его приняли. В этой статье разберу, как дженерики устроены, какие ограничения у них есть, что дают пакеты slices и cmp и как я решаю, писать дженерик или обойтись интерфейсом.
Если вы только начинаете работать с Go, то сначала пройдите базовый маршрут из статьи «Go для начинающих: дорожная карта». Здесь я считаю, что вы уже уверенно пишете функции, структуры и интерфейсы.
Какую проблему решают дженерики
До Go 1.18 для реализации «одной функции для разных типов» было три возможных решения.
Первое: дубли функций. MinInt, MinFloat64, MinString. Код дублируется, при необходимости правки вносятся в три места.
Второе: пустой интерфейс. Функция принимает interface{}, внутри проводим приведение типов. Компилятор больше ничего не проверяет, ошибки можно увидеть только рантайме. Значения упаковываются в интерфейс, что приводит к дополнительным аллокациям памяти.
Третье: кодогенерация. go generate плюс шаблоны. Работает, но это отдельный инструмент, который нужно поддерживать.
Дженерики позволяют закрывать эту проблему проще. Одна функция умеет работать с разными типами без потери статической типизации и проверок на этапе компиляции:
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
Min(2, 3) вернёт int, Min(2.5, 3.1) вернёт float64, а Min(2, "три") не скомпилируется. Никаких скрытых приведений типов и паник в рантайме не будет.
Синтаксис: параметры типа
Параметры типа объявляются в квадратных скобках между именем функции и обычными аргументами:
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
Здесь T и U это параметры типа, а any это их ограничение. Ограничение говорит компилятору, что можно делать со значениями этого типа. С any можно поводить только основные операции: присваивать, передавать и класть в слайс.
Дженерик-типы объявляются так же:
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
}
Обратите внимание на var zero T. Это идиома для нулевого значения параметра типа. Написать return nil нельзя: компилятор не знает, есть ли у типа T вообще понятие nil.
Ограничения: что разрешено делать с T
Ограничение это фактически интерфейс. Обычный интерфейс с методами тоже подходит:
func PrintAll[T fmt.Stringer](items []T) {
for _, v := range items {
fmt.Println(v.String())
}
}
Но операторы +/-/* и другие методами выразить нельзя. Так, например, сравнение через < не выражается методом. Поэтому в ограничениях появились две новые конструкции: объединения типов и тильда.
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](items []T) T {
var total T
for _, v := range items {
total += v
}
return total
}
Вертикальная черта перечисляет допустимые типы. Тильда означает «любой тип, у которого такой базовый тип» (в спецификации это underlying type). Без тильды type Celsius float64 не прошёл бы ограничение float64, потому что это другой именованный тип. С ~float64 проходит.
Такие интерфейсы с объединениями можно использовать только как ограничения. Объявить переменную типа Number не получится.
Ниже ограничения-исключения, которые необходимо просто запомнить:
anyэто псевдонимinterface{}. Подходит любой тип.comparableтребует, чтобы тип поддерживал==и!=. Нужен для ключей map и функций поиска.
Для упорядоченных типов есть готовое ограничение cmp.Ordered, включённое в стандартную библиотеку с Go 1.21. Оно покрывает все целочисленные, вещественные и строковые типы. До него использовали constraints.Ordered из golang.org/x/exp/constraints, и в старом коде вы его ещё встретите.
Тонкость comparable: компилируется, но паникует
С Go 1.20 ограничению comparable удовлетворяют и обычные интерфейсы, включая any. Это удобно. Например, slices.Contains работает со слайсом []any. Но у удобства есть цена. Сам интерфейс сравним всегда, а значение внутри него может оказаться несравнимым: функция, map, слайс.
func has[T comparable](s []T, v T) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
vals := []any{func() {}}
has(vals, vals[0]) // компилируется, но паникует
Компилятор этот код пропустит, а рантайм ответит паникой runtime error: comparing uncomparable type func(). То есть comparable с конкретными типами безопасен. С интерфейсами держите в голове, что лежит внутри (но лучше конечно к такому паттерну вообще не прибегать). Мотивацию этого изменения Роберт Гризмер разобрал в посте All your comparable types.
Вывод типов
В большинстве вызовов параметры типа указывать не нужно. Компилятор выводит их из аргументов:
names := Map(users, func(u User) string { return u.Name })
Компилятор видит []User и функцию func(User) string, поэтому сам понимает, что T = User, U = string. Явное декларирование типа нужна редко. Например, когда аргументов недостаточно для вывода или когда вы берёте ссылку на дженерик-функцию:
mapper := Map[User, string] // без аргументов вывести типы не из чего
Решается это тоже просто. Начинайте без явных параметров. Если компилятор не справился, он скажет об этом, и тогда допишете типы руками.
Частые ошибки компилятора и что они значат
Сообщения об ошибках у дженериков свои, и поначалу читаются тяжело. Четыре самых частых, дословно из компилятора.
string does not satisfy Number (string missing in ~int | ~int64 | ~float64). Вы передали тип, которого нет в объединении ограничения. Либо тип не подходит по смыслу, либо вы забыли добавить его в ограничение. Третий вариант: нет тильды, и пользовательский тип не прошёл проверку.
cannot infer T. Компилятору не из чего вывести параметр типа. Классический случай: функция вида func Zero[T any]() T. Аргументов с типом T у неё нет, вывести его неоткуда. Укажите тип явно: Zero[int]().
X does not satisfy comparable. Тип содержит несравнимое поле: функцию, слайс или map. Такому типу нельзя быть ключом map и нельзя проходить ограничение comparable. Уберите поле или смените подход.
cannot use type Number outside a type constraint: interface contains type constraints. Вы объявили переменную или аргумент типа интерфейса с объединением. Такие интерфейсы живут только в квадратных скобках, в роли ограничения.
Что дженерики дали стандартной библиотеке
Практическая польза дженериков для большинства проектов это даже не свои дженерик-функции, а новые пакеты стандартной библиотеки. С Go 1.21 в ней появились slices, maps и cmp.
// поиск и проверка вхождения
idx := slices.Index(names, "alice")
ok := slices.Contains(ports, 8080)
// сортировка без sort.Slice и рефлексии
slices.Sort(nums)
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age)
})
// сравнение слайсов по элементам
equal := slices.Equal(a, b)
До дженериков то же самое писалось через sort.Slice с функцией-замыканием и упаковкой в интерфейс или цикл руками. slices.Sort типобезопасен и работает быстрее sort.Slice потому что не гоняет значения через интерфейс. Это подтверждает и документация sort.Slice: она прямо советует новые функции из slices как более быстрые.
Туда же относятся встроенные min и max, добавленные в Go 1.21. Больше не нужно писать трёхстрочный if ради минимума-максимума двух чисел.
Пакет maps даёт maps.Clone и maps.Equal. А в Go 1.23 дженерики стали фундаментом итераторов: iter.Seq[T] это обычный дженерик-тип, и maps.Keys возвращает именно его:
m := map[string]int{"b": 2, "a": 1}
keys := slices.Collect(maps.Keys(m)) // []string
slices.Sort(keys)
Дженерики проникают и в старые пакеты. atomic.Pointer[T] с Go 1.19 заменяет atomic.Value там, где тип известен заранее: Load возвращает *T без type assertion. sync.OnceValue с Go 1.21 сворачивает ленивую инициализацию в одну строку и возвращает функцию нужного типа.
Если вы регулярно пишете хелперы над слайсами, посмотрите разбор фильтрации слайса без лишних аллокаций. Можно реализовать дженерик-версию один раз и использовать для любого типа элементов.
Что значит S ~[]E в сигнатурах
Откройте документацию любой функции из slices:
func Clone[S ~[]E, E any](s S) S
Два параметра типа выглядят избыточно. Зачем S, если можно написать []E? Затем, чтобы именованные слайсы не теряли тип. Допустим, у вас type Ports []int. Версия с сигнатурой func Clone[E any](s []E) []E приняла бы Ports, но вернула бы голый []int. Версия с S ~[]E возвращает Ports. Тильда здесь та же, что в ограничениях чисел: подходит любой тип с базовым типом []E.
Когда встретите такую сигнатуру в документации, читайте её так: функция принимает слайс любого именованного типа и не портит этот тип на выходе.
Где дженерики уместны
За несколько лет с дженериками у меня сложился короткий список ситуаций, где они окупаются.
Функции над слайсами и картами. Map, Filter, Keys, Chunk, Dedup. Логика не зависит от типа элемента вообще. Это идеальный кейс, и стандартная библиотека с пакетом slices показывает ровно его.
Контейнеры и структуры данных. Стек, очередь, кэш, множество, пул объектов. До 1.18 такой код писали на interface{} и теряли типы на границе. Теперь Stack[Order] и Stack[User] это разные типы, и перепутать их нельзя.
type Set[T comparable] struct {
m map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{m: make(map[T]struct{})}
}
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
Числовые алгоритмы. Суммы, агрегации, статистика, которые должны работать и для int64, и для float64. Ограничения с объединениями типов закрывают это без дублирования.
Типобезопасные обёртки. Классика жанра это Result[T] для передачи значения вместе с ошибкой через канал:
type Result[T any] struct {
Value T
Err error
}
func fetchAll(urls []string) <-chan Result[*http.Response] { /* ... */ }
До дженериков такой канал объявляли через interface{} или заводили отдельную структуру на каждый тип. Сюда же относятся обёртки над sync.Map с конкретными типами ключа и значения. Про сами паттерны работы с каналами подробнее в статье про конкурентность в Go.
Общий признак у всех кейсов один: код одинаковый для всех типов, а типы важны на границах. Внутри функции вы не спрашиваете, что за тип пришёл. Если рука тянется сделать type switch по T, дженерик здесь не нужен.
Где дженерики не нужны
Ошибок с дженериками я вижу больше, чем удачных применений. Основная причина: люди приносят привычки из Java и C#, где дженерики применяются повсеместно. В Go применение дженериков должно быть более узкое и точечное.
Не заменяйте интерфейсы дженериками. Если у типов разное поведение и вы вызываете их методы, то используйте интерфейсы:
// лишний параметр типа
func Handle[T io.Reader](r T) error { /* ... */ }
// то же самое, проще и без инстанциаций
func Handle(r io.Reader) error { /* ... */ }
Обе версии делают одно и то же. Вторая короче, понятнее и не тянет за собой механику дженериков. Параметр типа с единственным ограничением-интерфейсом почти всегда избыточен. Он оправдан только когда тип нужен ещё где-то в сигнатуре: например, функция принимает []T и возвращает T. Про то, когда интерфейс уместен и как его готовить, я подробно писал в разборе интерфейсов в Go.
Не пишите дженерик авансом. У авторов Go есть рабочее правило. Иэн Лэнс Тейлор, соавтор дизайна дженериков, сформулировал его в посте When To Use Generics: сначала напишите код с конкретными типами, а обобщайте, когда дублирование реально появилось. Дженерик ради «вдруг пригодится» это та же пустая абстракция, что и интерфейс с одной реализацией.
Не обобщайте бизнес-логику. ProcessOrder не должен превращаться в Process[T Processable]. Доменный код почти всегда работает с конкретными типами, и его читаемость важнее мнимой универсальности.
Смысл различия удобно держать в одной строке. Интерфейс отвечает на вопрос «разные типы ведут себя по-разному, а мне нужно общее поведение». Дженерик отвечает на вопрос «алгоритм один и тот же, типы подставляются снаружи».
| Ситуация | Что брать |
|---|---|
| Вызываете методы значений, поведение у типов разное | Интерфейс |
| Алгоритм не зависит от типа: контейнер, хелпер над слайсом | Дженерик |
Нужны операторы: <, +, == | Дженерик с ограничением |
| Тип встречается в сигнатуре несколько раз и должен совпадать | Дженерик |
| Одна реализация, обобщать нечего | Конкретный тип |
Ограничения дизайна, о которые все спотыкаются
У дженериков в Go есть осознанные ограничения. Их полезно знать заранее, чтобы не проектировать API, который не скомпилируется.
У методов нет своих параметров типа. Параметры типа объявляются только у функции или у типа целиком. Написать так нельзя:
type Collection[T any] struct{ items []T }
// не скомпилируется: у метода не может быть своих параметров типа
func (c Collection[T]) Map[U any](f func(T) U) Collection[U] { ... }
Это ограничение регулярно ломает попытки перенести в Go цепочки вида collection.Map(...).Filter(...).Reduce(...). Выход: свободная функция Map(c, f) вместо метода.
Нет специализации. Нельзя написать отдельную реализацию Min для строк и отдельную для чисел. Одна функция, одно тело, одно поведение для всех типов из ограничения.
Type switch по параметру типа напрямую не работает. Значение типа T сначала нужно упаковать в any:
func describe[T any](v T) string {
switch any(v).(type) {
case int:
return "целое"
default:
return "что-то другое"
}
}
Но если вы это пишете, остановитесь. Ветвление по типу внутри дженерика означает, что алгоритм не общий и обобщение было ошибкой.
Нулевое значение достаётся через var. Универсального литерала нулевого значения нет, идиома var zero T уже встречалась в примере со стеком выше.
Рефлексия не видит параметров типа. Внутри дженерик-функции reflect работает уже с конкретным типом, который подставили при инстанциации. Никакого «списка типов» в рантайме не существует. Если нужен reflect.Type параметра без значения на руках, с Go 1.22 есть reflect.TypeFor[T]().
Рекурсивные ограничения: T Cloner[T]
Параметр типа может ссылаться сам на себя в собственном ограничении. Выглядит страшно, применяется просто:
type Cloner[T any] interface {
Clone() T
}
func CloneAll[T Cloner[T]](in []T) []T {
out := make([]T, 0, len(in))
for _, v := range in {
out = append(out, v.Clone())
}
return out
}
Читается так: T это тип, чей метод Clone возвращает тот же самый T. Обычный интерфейс этого выразить не может. В interface{ Clone() Cloner } метод возвращал бы интерфейс, и вызывающий получал бы не свой тип, а абстракцию с последующим type assertion. С рекурсивным ограничением CloneAll для []Config возвращает ровно []Config.
Тот же приём встречается в сортировках и деревьях: ограничение [T interface{ Less(T) bool }] требует, чтобы тип умел сравнивать себя с собой, а не с чем попало. Приём редкий, но когда нужен, заменить его нечем.
Производительность: словари и формы типов
Дженерики в Go не бесплатная абстракция, но и не всегда дорогая. Компилятор использует подход, который называется GCShape stenciling. Он описан в design-документе реализации дженериков в Go 1.18. Смысл такой: отдельная копия функции генерируется не для каждого типа, а для каждой «формы» типа. У int и float64 формы разные, будут две копии. А вот все указатели имеют одну форму, поэтому *User и *Order разделяют одну копию кода, и внутрь неё передаётся скрытый аргумент, словарь с информацией о конкретном типе.
Для практики из этого следуют три вещи.
Первое: для базовых типов вроде int дженерик-код обычно компилируется в то же, что и написанный руками. slices.Sort для []int быстрее sort.Slice именно поэтому: нет упаковки в интерфейс, сравнение инлайнится.
Второе: когда параметр типа инстанцируется указателем или интерфейсом, вызовы методов идут через словарь. Такой вызов сложнее инлайнить, и на горячем пути он может оказаться медленнее прямого вызова или даже вызова через обычный интерфейс. Отсюда практический совет: не передавайте интерфейс в параметр типа. Функция с обычным интерфейсным аргументом в этом случае как минимум не хуже.
Третье: выводы о скорости делаются только по замерам. Про методику я писал в статье про бенчмарки и оптимизацию в Go. Разница между дженериком и интерфейсом видна в микробенчмарках, но в типичном сервисе тонет на фоне сети и базы данных.
FAQ
С какой версии Go доступны дженерики?
slices, maps, cmp и встроенные min и max, а в Go 1.23 на дженериках построили итераторы iter.Seq. Если ваш проект на Go 1.21 или новее, вам доступно всё, о чём написано в этой статье.Чем ограничение отличается от обычного интерфейса?
| и использовать тильду для базового типа. Интерфейс с объединением типов нельзя использовать как тип переменной, только как ограничение в квадратных скобках. Обычный интерфейс с методами работает в обеих ролях.Что означает тильда в ограничении, например ~int?
~int означает «любой тип, у которого базовый тип это int». Без тильды ограничению int удовлетворяет только сам int. С тильдой подходит и type Port int, и любой другой именованный тип на его основе. В ограничениях для чисел тильду стоит ставить почти всегда, иначе пользовательские типы вроде type Celsius float64 не пройдут.Дженерики или интерфейсы: что выбрать?
Замедляют ли дженерики программу?
int или структур обычно нет: компилятор генерирует специализированный код, и он не отличается от написанного руками. Замедление возможно, когда параметр типа инстанцируется интерфейсом или указателем: вызовы уходят через словарь типов и хуже инлайнятся. На практике разница заметна только на горячих путях и проверяется бенчмарками.Почему у метода нельзя объявить свои параметры типа?
Map(c, f) вместо c.Map(f).Почему comparable с any компилируется, но паникует?
comparable, потому что интерфейсные значения формально сравнимы. Но сравнение == двух интерфейсов сравнивает динамические значения внутри. Если там функция, map или слайс, рантайм паникует с comparing uncomparable type. С конкретными типами такой проблемы нет: несравнимый тип отбрасывается ещё компилятором.Можно ли инстанцировать дженерик типом, известным только в рантайме?
reflect. По этой же причине нельзя «положить дженерик-функцию в переменную» без указания конкретных типов: Map[User, string] можно, просто Map нельзя.Дженерики заменяют кодогенерацию через go generate?
Итог
Дженерики в Go это инструмент с узкой, но реальной нишей. Короткий чек-лист, который я держу в голове:
- Дженерик уместен, когда алгоритм один для всех типов: контейнеры, хелперы над слайсами и картами, числовые агрегации.
- Если вы вызываете методы значений, это интерфейс, а не дженерик. Не заменяйте
io.Readerна[T io.Reader]. - Сначала конкретный код, потом обобщение. Дженерик авансом это пустая абстракция.
- В ограничениях для чисел используйте тильду и готовый
cmp.Ordered. comparableс конкретными типами безопасен, с интерфейсами может паниковать в рантайме.- Сигнатура
S ~[]Eв документации означает «принимаю именованный слайс и не порчу его тип». - Пакеты
slices,mapsиcmpпокрывают большинство бытовых задач. СвойMapиFilterпишите, только если их реально не хватает. - Type switch внутри дженерика это сигнал, что обобщение было ошибкой.
- Не передавайте интерфейсы в параметры типа и проверяйте горячие пути бенчмарками.
Соседние темы кластера разобраны отдельно: интерфейсы в Go, бенчмарки и оптимизация и дорожная карта для начинающих.
Теги: