Go Дженерики и итераторы в Go: параметры типа, ограничения и range-over-func
0%

Дженерики и итераторы в Go: параметры типа, ограничения и range-over-func

Дженерики и итераторы

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] разделяют один экземпляр скомпилированного кода. Различия — размеры, методы, операции — передаются в скрытом аргументе-словаре.

Практический вывод, который часто удивляет: дженерик-версия функции может оказаться медленнее написанной руками под конкретный тип. Косвенные вызовы через словарь хуже инлайнятся, а инлайнинг в 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 работают ровно так, как ожидает читатель. Никаких горутин, никаких каналов, никаких утечек.

Итератор по страницам 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.Seqpush-итератор: последовательность сама проталкивает элементы. Для слияния двух последовательностей это неудобно — надо тянуть по одному из каждой. Для этого есть 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] — стандартный способ отдавать ошибки из ленивых источников.

Источники

Что дальше

Дженерики и итераторы — свежий слой языка. Но повседневная сила Go в другом: в стандартной библиотеке, где на паре крошечных интерфейсов держится вся работа с данными. Разберём её вглубь.

11. Стандартная библиотека вглубь

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов