Дженерики в Go
| Появились | Go 1.18 (март 2022) |
|---|---|
| Предложение | Type Parameters Proposal (Иан Лэнс Тейлор, Роберт Гриземер) |
| Синтаксис | func NameT Constraint |
| Ограничения | интерфейсы, в том числе наборы типов (~int | ~float64) |
| Встроенные ограничения | any, comparable |
| Стандартные пакеты | slices, maps, cmp, iter |
Дженерики в Go (параметры типов, англ. type parameters) — возможность писать функции и типы, которые работают с разными типами данных, сохраняя статическую проверку. Они появились в Go 1.18 в марте 2022 года — после более чем десяти лет обсуждений и нескольких отвергнутых проектов — и стали самым крупным изменением языка с версии 1.0.[1][3]
Зачем
До дженериков обобщённый код в Go писали тремя способами, у каждого из которых есть недостатки:
- копированием —
SumInts,SumFloats,SumInt64s; - через пустой интерфейс
interface{}— с потерей проверки типов и приведением в рантайме; - генерацией кода (
go generate).
С параметрами типов одна функция подходит для всех нужных типов, а ошибки ловит компилятор.
Синтаксис
Параметры типов объявляются в квадратных скобках перед обычными параметрами. Каждый параметр имеет ограничение (constraint) — интерфейс, описывающий допустимые типы:[2]
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, 0, len(s))
for _, v := range s {
out = append(out, f(v))
}
return out
}
lengths := Map([]string{"go", "rust"}, func(s string) int { return len(s) })
// [2 4] — T и U выведены компилятором
В большинстве вызовов аргументы типов выводятся автоматически; явно их пишут, когда вывести нельзя: Map[string, int](...).
Ограничения и наборы типов
Интерфейс в Go 1.18 стал описывать не только набор методов, но и набор типов:[5]
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
|перечисляет допустимые типы;~intозначает «любой тип, чьё базовое представление —int», включая пользовательские вродеtype UserID int;any— синонимinterface{}, любой тип;comparable— типы, поддерживающие==и!=; нужен для ключейmap;- пакет
cmpпредоставляетcmp.Ordered— типы с операторами<,>.
Интерфейсы с наборами типов можно использовать только как ограничения, но не как обычный тип переменной.
Обобщённые типы
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 st Stack[string]
st.Push("a")
Ограничение языка: методы не могут иметь собственных параметров типов — только использовать параметры своего типа. Поэтому цепочки вида stream.Map(...).Filter(...) с меняющимся типом в Go пишут обычными функциями.
Стандартная библиотека
Дженерики позволили добавить в стандартную библиотеку типобезопасные утилиты:[7]
import ("slices"; "maps")
xs := []int{3, 1, 2}
slices.Sort(xs) // [1 2 3]
i, found := slices.BinarySearch(xs, 2)
slices.Contains(xs, 5) // false
keys := slices.Sorted(maps.Keys(m)) // отсортированные ключи map
В Go 1.23 появились итераторы — функции вида iter.Seq[T], по которым можно проходить обычным for range; они тоже обобщённые.[6] sync.Map и atomic.Pointer[T] — другие примеры обобщённых типов в библиотеке.
Реализация
Компилятор Go использует компромисс между полной мономорфизацией (как в Rust и C++, где для каждого типа генерируется отдельная копия кода) и упаковкой в интерфейсы (как в Java): функции компилируются по одной копии на «форму» типа (GC shape) — например, одна копия на все указатели — с передачей словаря с информацией о типе. Это ускоряет компиляцию и уменьшает размер бинарника, но обобщённый код иногда работает чуть медленнее, чем написанный вручную для конкретного типа. В Rust ту же задачу решают трейты с мономорфизацией.
Когда использовать
Авторы Go советуют не превращать дженерики в инструмент по умолчанию:[4]
- Использовать — для функций над срезами, map и каналами любого типа элемента; для обобщённых структур данных (стеки, очереди, деревья, кэши); когда иначе пришлось бы копировать одну и ту же реализацию.
- Не использовать — если достаточно обычного интерфейса с методами (
io.Readerлучше, чем[T io.Reader]); если реализация различается для разных типов; если код становится сложнее читать.
Правило из блога Go: начинайте писать код без параметров типов и добавляйте их, когда обнаружите, что пишете одно и то же несколько раз.