Дженерики и итераторы
Go прожил без параметрического полиморфизма двенадцать лет. Дженерики появились в версии 1.18 (2022) — это самое крупное изменение языка за всю его историю и, пожалуй, самое спорное. Ещё через год стандартная библиотека получила пакеты slices, maps и cmp, а в 1.23 подъехала вторая половина истории — итераторы и цикл for range по функции.
В основах мы взглянули на синтаксис дженериков мельком. Здесь разберёмся по-настоящему: как устроены ограничения, во что всё это компилируется, где выигрыш реален, а где вы просто усложнили код — и как писать итераторы, которые сообщество приняло за новый способ обходить что угодно.
Что делали до дженериков
Задача простая: функция «максимум из двух» для int, float64 и string. До 1.18 было три пути, и все плохие.
// 1. Копипаста. Работает, но три копии одного алгоритма.
func MaxInt(a, b int) int { if a > b { return a }; return b }
func MaxFloat(a, b float64) float64 { /* ... */ }
// 2. interface{} + утверждение типа. Компилятор больше не помогает,
// ошибка типа переезжает из компиляции в рантайм, плюс упаковка в кучу.
func Max(a, b interface{}) interface{} {
switch av := a.(type) {
case int:
if av > b.(int) { return a } // паника, если b — не int
return b
}
panic("неподдерживаемый тип")
}
// 3. Кодогенерация: шаблон + go generate. Инструмент вместо языка.
Именно поэтому sort.Interface требовал реализовать три метода на каждый тип, а половина утилитарных пакетов в экосистеме была набором почти одинаковых функций. Дженерики закрыли ровно эту дыру — и, что важно, только её.
Параметры типа: синтаксис
import "cmp"
// T — параметр типа, cmp.Ordered — ограничение на него.
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
fmt.Println(Max(3, 7)) // 7, T выведен как int
fmt.Println(Max("a", "b")) // "b", T выведен как string
fmt.Println(Max[float64](1, 2)) // явная инстанциация, когда вывод не справляется
Параметризовать можно и типы:
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) {
if len(s.items) == 0 {
var zero T // идиома: нулевое значение параметра типа
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
s := &Stack[string]{} // инстанциация: Stack именно строк
Две вещи, о которые спотыкаются все:
var zero T— единственный способ получить нулевое значение параметра типа. Написатьreturn nilнельзя:Tможет бытьint.- У методов не может быть собственных параметров типа.
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U]не скомпилируется. Ограничение сознательное: иначе метод нельзя было бы вызвать через интерфейс, не сгенерировав код для всех возможныхU. Обходной путь — обычная функция:func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U]. Именно поэтому в Go нет «текучих» цепочек.Map().Filter()— и, судя по обсуждениям, не появится.
Ограничения — это множества типов
Здесь Go делает поворот, который стоит осознать: интерфейс теперь означает две разные вещи.
- Классический интерфейс — множество методов: «любой тип, умеющий
Read». - Интерфейс-ограничение — множество типов: «любой тип из вот этого списка».
// Множество типов через объединение (union)
type Number interface {
~int | ~int64 | ~float32 | ~float64
}
// Тильда ~ значит «и любой тип, у которого этот базовый тип»
type Celsius float64 // ~float64 покрывает Celsius, а float64 — нет
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x // допустимо: у всех типов множества есть +
}
return total
}
// Можно смешивать методы и типы
type Stringish interface {
~string
fmt.Stringer
}
Готовые ограничения, которые покрывают почти всё:
any— псевдонимinterface{}, никаких операций, кроме присваивания.comparable— типы, поддерживающие==и!=. Нужен, чтобы использоватьTкак ключ карты. Внимание:comparableне означает «упорядочиваемый»,<для него недоступен.cmp.Ordered(из стандартной библиотеки с 1.21) — всё, что поддерживает<: целые, вещественные, строки.- Своё объединение — когда нужен конкретный набор.
Интерфейсы с объединениями нельзя использовать как обычные типы переменных — только как ограничения. Это не баг, а прямое следствие того, что множество типов не описывает поведения, которое можно вызвать динамически.
Стрелки здесь — сужение множества: каждое следующее ограничение допускает меньше типов, но разрешает больше операций. Stringer стоит особняком: это множество поведений, а не типов, и в качестве ограничения он тоже годится.
Во что это компилируется и сколько стоит
Три известные стратегии реализации дженериков:
- Мономорфизация (C++, Rust): для каждой комбинации типов компилятор генерирует отдельный машинный код. Максимум скорости, взрыв размера бинарника и времени компиляции.
- Стирание (Java): типы существуют только для компилятора, в рантайме всё —
Object. Компактно, но требует упаковки примитивов. - Словари и GC shape stenciling — путь Go, компромисс между первыми двумя.
Go группирует типы по форме с точки зрения сборщика мусора (GC shape). Все указательные типы имеют одну форму, поэтому Stack[*User] и Stack[*Order] разделяют один экземпляр скомпилированного кода. Различия — размеры, методы, операции — передаются в скрытом аргументе-словаре.
у аргумента типа?"} SHAPE -->|"int, int64 — целые"| ST1["Экземпляр кода для целых"] SHAPE -->|"float64"| ST2["Экземпляр кода для вещественных"] SHAPE -->|"*User, *Order, любой указатель"| ST3["ОДИН общий экземпляр
для всех указателей"] ST3 --> DICT["Словарь: реальный тип,
размеры, адреса методов"] DICT --> CALL["Косвенный вызов через словарь"] ST1 --> FAST["Прямой код, инлайн возможен"] ST2 --> FAST CALL --> SLOW["Косвенность мешает инлайну"]
Практический вывод, который часто удивляет: дженерик-версия функции может оказаться медленнее написанной руками под конкретный тип. Косвенные вызовы через словарь хуже инлайнятся, а инлайнинг в Go — главный источник ускорения мелких функций. Разница обычно в единицы процентов и не имеет значения, но в горячем цикле её надо померить, а не предполагать — методика в главе про бенчмаркинг и через -benchmem из главы про тестирование.
Когда дженерики уместны, а когда нет
Официальный совет команды Go, сформулированный Иэном Лэнсом Тейлором, звучит так: пишите код, а не типы. Разворачивая:
Берите дженерики, когда:
- Пишете контейнер: стек, очередь, множество, LRU-кеш, типизированный пул.
- Пишете алгоритм над срезами, картами и каналами, где тело одинаково для всех типов: сортировка, поиск, слияние,
fan-in. - Пишете утилиту, где иначе неизбежен
any:Ptr[T](v T) *T,Coalesce[T comparable](vals ...T) T. - Вы уже трижды скопировали одну и ту же функцию, поменяв только тип.
Не берите дженерики, когда:
- Достаточно интерфейса. Если реализация для разных типов разная — это полиморфизм поведения, и его выражает интерфейс. Дженерик нужен, когда код одинаковый, а типы разные.
- В ограничении один тип. Значит, параметр типа не нужен вовсе.
- Вы абстрагируете «на будущее». Го-культура прямолинейна: конкретный тип сегодня лучше абстрактного на всякий случай.
- Хочется алгебраических типов,
Option/Resultи цепочекmap().filter(). Дженерики их не дают: нет сумм-типов, нет сопоставления с образцом, у методов нет параметров типа. Попытка притащить в Go функциональный стиль другого языка даёт код, который не читает никто в команде. Про честный функциональный стиль — трек по функциональному программированию.
Полезный пример уместного дженерика — типобезопасное множество, которого в стандартной библиотеке нет:
// Set — множество на карте. T comparable, потому что это ключ карты.
type Set[T comparable] map[T]struct{}
func NewSet[T comparable](items ...T) Set[T] {
s := make(Set[T], len(items))
for _, it := range items {
s[it] = struct{}{}
}
return s
}
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }
func (s Set[T]) Len() int { return len(s) }
func (s Set[T]) Union(other Set[T]) Set[T] {
out := make(Set[T], len(s)+len(other))
for v := range s {
out[v] = struct{}{}
}
for v := range other {
out[v] = struct{}{}
}
return out
}
И пример из практики, экономящий сотни строк, — параллельная обработка с ограничением конкурентности поверх errgroup из главы про конкурентность:
// MapConcurrent применяет f ко всем элементам не более чем в limit горутин.
// Результат сохраняет порядок входа; первая ошибка отменяет остальные.
func MapConcurrent[T, R any](ctx context.Context, in []T, limit int, f func(context.Context, T) (R, error)) ([]R, error) {
out := make([]R, len(in))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for i, v := range in {
g.Go(func() error { // с Go 1.22 переменные цикла свои на каждой итерации
r, err := f(ctx, v)
if err != nil {
return fmt.Errorf("элемент %d: %w", i, err)
}
out[i] = r // каждая горутина пишет в свой индекс — гонки нет
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}
Обратите внимание: каждая горутина пишет в собственный индекс заранее выделенного среза, поэтому синхронизация не нужна. Это стандартный приём, который избавляет от мьютекса вокруг результата.
slices, maps и cmp: дженерики в стандартной библиотеке
С Go 1.21 три пакета закрыли большую часть повседневных нужд. Знать их наизусть выгоднее, чем писать свои циклы.
import (
"cmp"
"maps"
"slices"
)
xs := []int{5, 2, 9, 2}
slices.Sort(xs) // [2 2 5 9], быстрее старого sort.Ints
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Name, b.Name) // компаратор возвращает -1, 0, 1
})
slices.SortStableFunc(users, byAge) // стабильная сортировка, когда важен порядок равных
i, found := slices.BinarySearch(xs, 5) // только для отсортированного среза
slices.Contains(xs, 9) // true
slices.Index(xs, 2) // 0
slices.Reverse(xs)
ys := slices.Clone(xs) // копия — лечит «срез держит чужой массив»
slices.Equal(xs, ys) // поэлементное сравнение
xs = slices.Compact(xs) // убирает подряд идущие дубликаты
xs = slices.Insert(xs, 1, 42) // вставка со сдвигом
xs = slices.Delete(xs, 0, 2) // удаление диапазона
max := slices.Max(xs)
m := map[string]int{"a": 1, "b": 2}
m2 := maps.Clone(m)
maps.Equal(m, m2)
maps.DeleteFunc(m, func(k string, v int) bool { return v == 0 })
// cmp.Or — первое ненулевое значение, идеально для конфигов
addr := cmp.Or(flagAddr, envAddr, ":8080")
Тонкость, о которой стоит знать: slices.Delete и slices.Insert изменяют исходный массив и возвращают новый срез — результат обязательно присваивайте обратно. С Go 1.22 Delete дополнительно обнуляет освободившийся хвост, чтобы не удерживать ссылки на объекты и не мешать сборщику мусора.
Итераторы: вторая половина истории
Проблема, которую решали
До Go 1.23 обход «чего-то, что не срез и не карта» выглядел каждый раз по-новому:
// Вариант 1: коллбэк. Нельзя выйти по break, нельзя вернуть ошибку наверх обычным способом.
tree.ForEach(func(v int) { fmt.Println(v) })
// Вариант 2: собственный тип итератора. Многословно, у каждой библиотеки свой.
it := tree.Iterator()
for it.Next() { fmt.Println(it.Value()) }
// Вариант 3: генератор на канале. Красиво выглядит и ТЕЧЁТ,
// если получатель вышел из цикла раньше времени.
for v := range tree.Chan() { ... }
Третий вариант — прямая дорога к утечке горутин из главы про конкурентность: отправитель навсегда виснет на записи в канал, который больше никто не читает.
Как устроен range-over-func
С 1.23 for range умеет принимать функцию специального вида. Стандартный пакет iter даёт два псевдонима:
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
Читается так: последовательность — это функция, которая умеет отдавать элементы в переданный ей yield. Если yield вернул false, потребитель больше не хочет данных и надо немедленно прекратить.
// Итератор по строкам файла: читает лениво, не грузит файл в память.
func Lines(r io.Reader) iter.Seq[string] {
return func(yield func(string) bool) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if !yield(sc.Text()) { // потребитель сделал break — уходим
return
}
}
}
}
// Использование неотличимо от обхода среза
for line := range Lines(f) {
if strings.HasPrefix(line, "#") {
continue
}
if line == "STOP" {
break // yield вернёт false, итератор корректно свернётся
}
process(line)
}
Компилятор превращает тело цикла в функцию и передаёт её как yield; break, return и continue работают ровно так, как ожидает читатель. Никаких горутин, никаких каналов, никаких утечек.
закрыв ресурсы через defer Seq-->>Loop: возврат из seq, цикл окончен
Итератор по страницам API — пример, ради которого всё затевалось
// Users отдаёт пользователей постранично, скрывая пагинацию от вызывающего.
// Ошибку несём вторым элементом Seq2 — идиома для итераторов с вводом-выводом.
func (c *Client) Users(ctx context.Context) iter.Seq2[User, error] {
return func(yield func(User, error) bool) {
cursor := ""
for {
page, next, err := c.fetchPage(ctx, cursor)
if err != nil {
yield(User{}, fmt.Errorf("страница %q: %w", cursor, err))
return // после ошибки не продолжаем
}
for _, u := range page {
if !yield(u, nil) {
return
}
}
if next == "" {
return // страницы кончились
}
cursor = next
}
}
}
// Вызывающий не знает ни про курсоры, ни про HTTP
for u, err := range client.Users(ctx) {
if err != nil {
return err
}
fmt.Println(u.Name)
}
Это та же выгода, что даёт sql.Rows, но для любого источника — и без ручного Next()/Err()/Close().
Что появилось в стандартной библиотеке
// maps.Keys и maps.Values теперь возвращают итераторы, а не срезы
for k := range maps.Keys(m) { ... }
// Каноническая идиома «карта в отсортированном порядке» — одна строка
for _, k := range slices.Sorted(maps.Keys(m)) {
fmt.Println(k, m[k])
}
slices.Collect(seq) // итератор -> срез
slices.Values(xs) // срез -> итератор значений
slices.All(xs) // срез -> Seq2 индекс/значение
strings.SplitSeq(s, ",") // Go 1.24: разбиение без промежуточного среза
strings.Lines(s) // Go 1.24: строки без аллокаций на каждую
Идиома slices.Sorted(maps.Keys(m)) заменяет пять строк ручного сбора и сортировки ключей — и решает вечную проблему случайного порядка обхода карты, о которой предупреждала глава про основы.
iter.Pull: когда push-модель не подходит
iter.Seq — push-итератор: последовательность сама проталкивает элементы. Для слияния двух последовательностей это неудобно — надо тянуть по одному из каждой. Для этого есть iter.Pull:
next, stop := iter.Pull(seq) // превращает push в pull
defer stop() // ОБЯЗАТЕЛЬНО: освобождает ресурсы итератора
for {
v, ok := next()
if !ok {
break
}
use(v)
}
iter.Pull внутри использует сопрограмму (coro) — механизм рантайма, а не горутину с каналом, так что накладные расходы невелики. Но правило жёсткое: всегда вызывайте stop, иначе получите зависший ресурс.
Правила приличия для итераторов
- Уважайте
falseотyield. Вернулfalse— немедленно возвращайтесь, отработавdefer. - Не вызывайте
yieldпослеfalseи не вызывайте его из другой горутины — это нарушение контракта, рантайм отвечает паникой. - Ошибки — через
Seq2[T, error], а не через панику и не через поле в структуре. - Освобождайте ресурсы через
deferвнутри итератора — приbreakони отработают. - Не заменяйте итератором срез. Если данные уже в памяти и их немного,
[]Tпроще, быстрее и понятнее. Итератор нужен для ленивости: большие файлы, страницы API, бесконечные последовательности.
Типичные ошибки
- Путать
comparableиcmp.Ordered. Первое — про==, второе — про<. Структура сравнима, но не упорядочиваема. return nilвместоvar zero Tв дженерик-функции — не компилируется, и это хорошо.- Параметр типа ради одного типа. Признак преждевременной абстракции: удалите его.
- Дженерик вместо интерфейса. Если тела реализаций разные — нужен интерфейс, а не параметр типа.
- Забыть присвоить результат
slices.Delete/Insert— они возвращают новый срез, а не меняют длину на месте. - Итератор на канале «по привычке» — теперь это лишняя горутина и потенциальная утечка; используйте
iter.Seq. - Забыть
stop()уiter.Pull.
Мини-итог
- Дженерики закрывают ровно одну задачу: одинаковый код для разных типов. Всё остальное по-прежнему делают интерфейсы.
- Ограничение — это множество типов;
~включает пользовательские типы с тем же базовым. - Реализация — словари и общий код для одинаковых GC shape; в горячем пути возможна небольшая потеря против ручного кода.
slices,maps,cmpпокрывают почти все повседневные операции — не пишите циклы руками.- Итератор — это функция, отдающая элементы в
yield;for rangeпо ней работает как по срезу, включаяbreak. Seq2[T, error]— стандартный способ отдавать ошибки из ленивых источников.
Источники
- Type Parameters Proposal — исходный документ дизайна, читается тяжело, но отвечает на все «почему».
- An Introduction To Generics и When To Use Generics — официальный блог, обязательно оба.
- Range Over Function Types — введение в итераторы от команды Go.
- Документация пакетов
iter,slices,maps,cmp. - Generics can make your Go code slower — честный разбор цены словарей с бенчмарками.
Что дальше
Дженерики и итераторы — свежий слой языка. Но повседневная сила Go в другом: в стандартной библиотеке, где на паре крошечных интерфейсов держится вся работа с данными. Разберём её вглубь.