Дженерики в Go: как устроены, где помогают и где не нужны

Дженерики появились в 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 не получится.

Ниже ограничения-исключения, которые необходимо просто запомнить:

Для упорядоченных типов есть готовое ограничение 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 доступны дженерики?
С Go 1.18, вышедшей в марте 2022 года. В Go 1.21 добавили пакеты 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 компилируется, но паникует?
С Go 1.20 обычные интерфейсы удовлетворяют ограничению comparable, потому что интерфейсные значения формально сравнимы. Но сравнение == двух интерфейсов сравнивает динамические значения внутри. Если там функция, map или слайс, рантайм паникует с comparing uncomparable type. С конкретными типами такой проблемы нет: несравнимый тип отбрасывается ещё компилятором.
Можно ли инстанцировать дженерик типом, известным только в рантайме?
Нет. Все инстанциации фиксируются на этапе компиляции, рантайм-дженериков в Go не существует. Если тип известен только в рантайме, это работа для интерфейсов или пакета reflect. По этой же причине нельзя «положить дженерик-функцию в переменную» без указания конкретных типов: Map[User, string] можно, просто Map нельзя.
Дженерики заменяют кодогенерацию через go generate?
В большинстве случаев да. Контейнеры и хелперы, которые раньше генерировали шаблонами, теперь пишутся дженериком без внешних инструментов. Кодогенерация остаётся для задач, где типов мало, а различий в логике много: сериализация, моки, обвязка API. Там генератор выигрывает, потому что может подстроить код под каждый тип, а дженерик обязан вести себя одинаково для всех.

Итог

Дженерики в Go это инструмент с узкой, но реальной нишей. Короткий чек-лист, который я держу в голове:

Соседние темы кластера разобраны отдельно: интерфейсы в Go, бенчмарки и оптимизация и дорожная карта для начинающих.


Теги: