Март 2022 года принёс Go 1.18, а вместе с ней параметризованные типы, ради которых сообщество спорило больше десяти лет. До этой версии слово "дженерики" в разговорах про Go звучало как обещание, которое дают каждый год и откладывают на потом. Проект предложения согласовали к концу 2020 года, авторы языка Йен Лэнс Тейлор и Роберт Грисемер довели синтаксис до рабочей формы, а спустя полтора года компилятор уже собирал функции с параметрами типов. Вопрос теперь стоит иначе: где новая механика правда экономит время, а где она превращает понятную программу в головоломку из квадратных скобок.

Новый синтаксис параметров типов и имена any и comparable

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

func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
	var s V
	for _, v := range m {
		s += v
	}
	return s
}

Вызов может указывать типы явно, SumIntsOrFloats[string, int64](ints), а может положиться на вывод типов и обойтись без скобок вовсе: SumIntsOrFloats(ints). Компилятор извлечёт типы из фактических аргументов, и вызывающий код останется чистым.

Вертикальная черта задаёт объединение типов: запись int64 | float64 допускает либо один тип, либо другой. Тильда расширяет множество до всех типов с тем же базовым представлением, поэтому конструкция ~int64 принимает не только сам int64, но и любой пользовательский тип, построенный на его основе. Рядом появились два предопределённых имени: any, псевдоним пустого интерфейса interface{}, и comparable, ограничение для типов, чьи значения сравниваются через == и !=. Без comparable нельзя объявить ключ словаря, поэтому он встречается почти в каждой обобщённой функции.

Сценарии в которых обобщённые функции окупаются с первой недели

Общая закономерность проста: чем больше кода крутится вокруг структур данных, тем выше выигрыш. Утилиты для срезов и словарей, сортировка и поиск, кэши и пулы, обёртки результатов с типизированной полезной нагрузкой. До версии 1.18 каждый такой инструмент писали либо несколько раз для разных типов, либо через interface{} с потерей статической проверки и лишними аллокациями при упаковке значений.

Частый пример: поиск максимума в срезе. Раньше требовались отдельные функции для int64 и float64, а единая версия через пустой интерфейс тратила время на упаковку и распаковку каждого элемента. Теперь одна функция закрывает все упорядоченные типы:

import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](s []T) T {
	if len(s) == 0 {
		panic("Max: пустой срез")
	}
	m := s[0]
	for _, v := range s[1:] {
		if v > m {
			m = v
		}
	}
	return m
}

Ограничение Ordered из пакета constraints собирает все упорядочиваемые типы одним объединением:

type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 | ~string
}

Типизированный кэш выручает во втором по частоте сценарии. Написанный один раз, он принимает и строковые ключи, и числовые идентификаторы, и хеши, сохраняя точный тип значений:

type Cache[K comparable, V any] struct {
	mu    sync.RWMutex
	items map[K]V
}

func NewCache[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{items: make(map[K]V)}
}

func (c *Cache[K, V]) Set(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.items[key]
	return v, ok
}

Множества в стандартной библиотеке Go нет, и до версии 1.18 каждый проект держал собственную копию под нужные типы ключей. Обобщённое определение закрывает вопрос один раз:

type Set[T comparable] map[T]struct{}

func NewSet[T comparable]() Set[T] { return make(Set[T]) }

func (s Set[T]) Add(v T) { s[v] = struct{}{} }

func (s Set[T]) Has(v T) bool {
	_, ok := s[v]
	return ok
}

Третья категория: конвейеры преобразований и обёртки. Обобщённые Map и Filter собирают цепочки, которые раньше требовали по копии на каждый тип элемента:

func Map[T, U any](s []T, f func(T) U) []U {
	r := make([]U, 0, len(s))
	for _, v := range s {
		r = append(r, f(v))
	}
	return r
}

func Filter[T any](s []T, pred func(T) bool) []T {
	r := make([]T, 0, len(s))
	for _, v := range s {
		if pred(v) {
			r = append(r, v)
		}
	}
	return r
}

Применение выглядит коротко и без приведений типов:

big := Filter(orders, func(o Order) bool { return o.Amount > 10000 })
names := Map(big, func(o Order) string { return o.Customer })

Обёртка результата с типизированным значением избавляет обработчики от двойных проверок:

type Result[T any] struct {
	Value T
	Err   error
}

func Ok[T any](v T) Result[T] { return Result[T]{Value: v} }

func Fail[T any](err error) Result[T] { return Result[T]{Err: err} }

С такой обёрткой обобщённый парсер собирается без единого приведения типов:

func Parse[T any](raw string, f func(string) (T, error)) Result[T] {
	v, err := f(raw)
	if err != nil {
		return Fail[T](err)
	}
	return Ok(v)
}

Сравнение кода до и после на конкретных правках

Знакомая многим картина: в проекте лежат две почти одинаковые функции поиска максимума, MaxInt64 и MaxFloat64. В одной исправили граничную проверку, во второй забыли. Тест падает на отрицательных числах, вечер уходит на поиск расхождения. Обобщённая версия убирает саму возможность такой правки: код существует в одном экземпляре, и правка автоматически достаётся обоим случаям.

Второй пример касается сортировки. Классический вариант через пакет sort опирается на индексы:

sort.Slice(people, func(i, j int) bool {
	return people[i].Age < people[j].Age
})

Замыкание ловит ошибку индексации только во время выполнения. Обобщённая функция SortFunc из пакета slices, вошедшего в стандартную библиотеку с Go 1.21, принимает значения нужного типа напрямую:

slices.SortFunc(people, func(a, b Person) int {
	return cmp.Compare(a.Age, b.Age)
})

Компилятор сам проследит, что замыкание получает и возвращает правильные типы. Ошибку в сигнатуре он поймает ещё до запуска тестов, а не на третьем прогоне сборки.

Где дженерики делают код хуже а не лучше

Обобщения не бесплатны в чтении, и у них есть зоны, где они только мешают. Если функция хранит разнородные значения и ничего не знает об их устройстве, interface{} честнее: он прямо говорит читателю, что тип не важен. Если коду нужно поведение, маленький интерфейс с парой методов описывает требование точнее, чем ограничение из шести объединений.

Преждевременная абстракция обходится дороже копипасты. Команда пишет обобщённый фреймворк репозиториев "на вырост", с пятью параметрами типов и тремя ограничениями, а применяется он к одному конкретному типу. Читаемость падает, выигрыша нет. Правило внутренней проверки простое: параметр типов появляется на второе реальное применение, а не на первое предполагаемое.

Сигнатура Cache[string, *Cache[int, []byte]] читается без энтузиазма даже у опытного разработчика. Три уровня вложенности параметров в объявлениях обычно означают, что проектирование ушло дальше, чем задача. Репозиторий с конкретной сигнатурой SaveUser найдётся поиском по файлу быстрее, чем обобщённый Save[T] с тремя ограничениями, за которым приходится идти в определение ограничения и разбираться с каждым членом объединения.

Ограничения реализации компилятора в выпуске 1.18

Заметки к выпуску прямо перечислили известные ограничения первой реализации:

  1. внутри обобщённых функций и методов нельзя объявлять новые типы, поддержку обещали в будущих выпусках;
  2. предопределённые функции real, imag и complex не принимают аргументы, тип которых задан параметром типа;
  3. вызов метода у значения типа параметра компилируется только когда метод прямо объявлен в ограничении;
  4. обращение к полю структуры у значения типа параметра запрещено даже если поле есть у каждого типа множества;
  5. параметр типа нельзя встроить в структуру или интерфейс как анонимное поле;
  6. объединение из нескольких членов не может содержать интерфейс с непустым набором методов.

Ограничение про поля выглядит безобидно, но ломает очевидные замыслы. Такой код не соберётся:

func Scale[T interface{ ~struct{ X, Y int } }](p T, k int) T {
	p.X *= k // компилятор отклоняет обращение к полю у параметра типа
	return p
}

Отдельный запрет прописан в самой спецификации: методы не могут объявлять собственные параметры типов. Обобщённый метод Sort у типа List[T] невозможен, зато работает функция верхнего уровня slices.SortFunc. Практический вывод: обобщённые операции в Go живут на уровне функций и пакетов, а не методов структур, и проектировать API стоит с оглядкой на это правило.

Время сборки проекта и цена обобщённого кода в итоговом бинарнике

За новые возможности платит компилятор. Из-за изменений, необходимых для поддержки параметров типов, Go 1.18 собирала проекты примерно на 15% медленнее, чем Go 1.17. Скорость работы готовых программ не изменилась, замедление касалось именно фронтенда компилятора.

Под капотом нет разворачивания шаблонов, как в C++. Работает схема с названием GC shape stenciling: для всех типов с одинаковой машинной формой, то есть одинаковым размером и расположением указателей, компилятор создаёт один экземпляр машинного кода. Различия между типами прячутся в словарь, скрытый аргумент, который несёт адреса методов и служебную информацию. Отсюда небольшие расходы на косвенные вызовы там, где мономорфизация дала бы прямой код, зато размер бинарника почти не растёт даже при десятках применений одной функции.

Заметки к выпуску советовали осторожность: команда языка считала реализацию качественной, но признавала, что обобщённый код ещё не проверен массовыми продакшенами. Позиция честная для релиза, в котором появились и параметризованные типы, и фаззинг, и режим рабочих пространств go work. Гигиена с тех пор не менялась: новые обобщённые интерфейсы покрываются тестами и замерами через go test -bench так же внимательно, как любой новый код. Разница в десятки наносекунд на вызов становится заметной только в горячем цикле, и замер помогает отличить реальный регресс от ощущений. Команда go test -bench с флагом -benchmem покажет и время операции, и число аллокаций на вызов, по которым сразу видно, когда словарь стал дороже прямого кода.

Путь дженериков из эксперимента в стандартную библиотеку

Вместе с Go 1.18 появились три экспериментальных пакета в модуле golang.org/x/exp: constraints с готовыми ограничениями, slices с функциями для срезов и maps с функциями для словарей. Их API не покрывался обещанием совместимости Go 1, о чём команда предупреждала прямо. Установка привычная:

go get golang.org/x/exp

Предупреждение оказалось не формальным. При переезде slices в стандартную библиотеку Go 1.21, вышедшей в августе 2023 года, сигнатура SortFunc сменилась: сравнение стало возвращать int по образцу cmp.Compare, а не bool, как в экспериментальной версии. Код, который полагался на старую сигнатуру, пришлось переписывать. В стандартную библиотеку вошли пакеты slices, maps и cmp с ограничением Ordered.

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

s := []int{5, 2, 6, 1}
slices.Sort(s)
i, found := slices.BinarySearch(s, 6)
if slices.Contains(s, 2) {
	fmt.Println("двойка на месте")
}

В Go 1.23 функции maps.Keys и maps.Values стали возвращать итераторы вместо срезов, вслед за новым пакетом iter. Практический совет для новых проектов: подключайте slices, maps и cmp из стандартной библиотеки, а экспериментальные копии оставьте старому коду.

Параметры типов закрыли давнюю брешь языка, но закрыли её узко и аккуратно. Утилиты для срезов и словарей, кэши, алгоритмы и обёртки стали короче, безопаснее по типам и свободнее от копипасты. Поведенческие интерфейсы, конкретные сущности и маленькие функции остались прежними, и код на Go по-прежнему читается за один проход. Команда, которая применяет обобщения по мерке задачи, получает лучшее из двух миров: статическую строгость там, где она нужна, и простоту там, где она дороже.