Дженерики в Go: type parameters, constraints и цена под капотом

Дженерики в Go (1.18+) — не мономорфизация как в C++ и не стирание как в Java, а GC-shape stenciling со словарями. Разбираем type parameters и constraints (~, comparable, интерфейсы-ограничения), инференс типов, как это работает под капотом и что стоит — на живом стенде с бенчмарками: дженерик vs interface{} vs рукопись, и почему на указательных типах дженерик не быстрее интерфейса

Дженерики пришли в Go в версии 1.18 — позже, чем в большинство мейнстрим-языков, и осознанно скромнее. Их часто продают как «типобезопасную замену interface{} без потери скорости», и первая половина этого тезиса верна. Вторая — только иногда. Реализация дженериков в Go — это не мономорфизация как в C++ (копия кода на каждый тип) и не стирание типов как в Java (один код, приведения в рантайме), а гибрид: GC-shape stenciling со словарями. У этого гибрида есть цена, и она проявляется ровно там, где её меньше всего ждут — на указательных типах.

Эта статья — про дженерики вглубь: синтаксис type parameters и constraints (включая ~-approximation и comparable), инференс типов и его границы, устройство под капотом и — главное — что это стоит, заземлённое на живой стенд с бенчмарками. Спойлер из стенда: на сумме []int дженерик и рукопись идут ноздря в ноздрю при нуле аллокаций, а interface{}-версия дороже в десятки раз и боксит тысячи раз; но на вызове метода через constraint по указательному типу дженерик не быстрее обычного интерфейса. Про то, почему так, и весь разговор.

Языко-агностичный взгляд на ту же тему — в компаньоне «Дженерики across languages»: там сравнение подходов C++/Java/Rust/Go бок о бок. Здесь — только Go и только вглубь.

Схема «как Go компилирует обобщённую функцию через GC-формы и словари»: классификатор сортирует типы по форме памяти — значимые типы (int/float64) идут в быструю линию со специализацией и нулём аллокаций, все указательные типы делят один экземпляр и вызывают методы через словарь (не быстрее интерфейса); панели: generic ≈ handwritten, interface{} боксирование в десятки раз медленнее, размер бинаря generic = interface без раздувания

В статье

Зачем дженерики и когда они не нужны

До 1.18 в Go было два способа написать код, работающий с разными типами, и оба неприятны. Первый — interface{} (с 1.18 — синоним any): универсально, но типобезопасность теряется на входе и восстанавливается приведением на выходе, а компилятор ничего не проверяет. Второй — кодогенерация (go generate, text/template): типобезопасно и быстро, но требует внешнего шага сборки, засоряет репозиторий и плохо переживает рефакторинг.

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

Stack[int] и Stack[string] — разные типы, каждый проверен компилятором; Pop возвращает T, а не any, приведений на стороне вызова нет. Обратите внимание на var zero T — это идиома «нулевое значение параметра типа», единственный способ получить нейтральный элемент, не зная T.

Но дженерики — не серебряная пуля, и авторы Go это прямо декларируют. Правило из официального блога: если реализации метода для разных типов различаются — это интерфейс, а не дженерик. Дженерики уместны, когда код над разными типами буквально одинаков (алгоритмы над срезами/картами, контейнеры, утилиты). Если же для каждого типа своя логика — нужен полиморфизм через интерфейсы, а не параметр типа. И почти всегда: не начинайте с дженерика — сначала конкретный тип, обобщение потом, когда дублирование стало реальным, а не гипотетическим.

Type parameters и constraints

Синтаксис параметра типа — квадратные скобки после имени: func F[T Constraint](...), type C[T Constraint] struct{...}. T — имя параметра, Constraint — ограничение, задающее, что с T можно делать. В отличие от any в старом коде, constraint — это контракт, проверяемый компилятором.

Constraint в Go — это интерфейс, но расширенный. Классический интерфейс ограничивает по методам; constraint дополнительно может ограничивать по множеству допустимых типов. Оба механизма комбинируются в одном объявлении.

Constraint по методам

Если внутри дженерика вызывается метод — constraint перечисляет этот метод, ровно как обычный интерфейс:

type Stringer interface {
	String() string
}

func Join[T Stringer](xs []T) string {
	var b strings.Builder
	for i, x := range xs {
		if i > 0 {
			b.WriteString(", ")
		}
		b.WriteString(x.String()) // x.String() разрешён: T ограничен Stringer
	}
	return b.String()
}

Отдельный, но частый на практике вид — self-referential (F-bounded) констрейнт, где интерфейс ссылается на сам тип-параметр. Так описывают «тип, который умеет сравнивать себя с себе подобным»: метод принимает T, а не any, и компилятор проверяет это статически.

// Ordered[T]: тип T умеет сравнивать себя с другим T.
type Ordered[T any] interface {
	Less(T) bool
}

// Min работает с любым типом, реализующим Less(себя): T ограничен Ordered[T].
func Min[T Ordered[T]](a, b T) T {
	if b.Less(a) {
		return b
	}
	return a
}

T Ordered[T] читается «T таков, что реализует Ordered[T]» — рекурсия в объявлении легальна и типобезопасна: Less принимает именно T, а не «какой-нибудь Ordered». Это идиома для обобщённых компараторов, fluent-builder’ов (WithX(...) T, возвращающих свой конкретный тип) и подобных API, где метод должен оперировать точным типом-получателем, а не общим интерфейсом.

Constraint по множеству типов и ~-approximation

Более интересная часть — ограничение по типам. Внутри интерфейса-constraint можно перечислить конкретные типы через union |. Тогда T обязан быть одним из них, и компилятор разрешает операции, определённые для всех членов union (например, +, <):

type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

func Max[T Ordered](a, b T) T {
	if a > b { // оператор > определён для всех членов union
		return a
	}
	return b
}

Ключевая деталь — тильда. int в union означает «ровно тип int». ~int означает «int и любой тип, чей базовый тип (underlying type) — int». Разница критична, потому что в Go повсеместно объявляют именованные типы поверх встроенных:

type Celsius float64 // базовый тип — float64

// Constraint без тильды: только сам float64.
type StrictFloat interface{ float64 }

// Constraint с тильдой: float64 и всё, что на нём построено.
type AnyFloat interface{ ~float64 }

func f1[T StrictFloat](x T) {}
func f2[T AnyFloat](x T) {}

func demo() {
	var c Celsius = 36.6
	// f1(c) // НЕ компилируется: Celsius не является буквально float64
	f2(c)    // OK: базовый тип Celsius — float64, подходит под ~float64
}

Практический вывод: в constraint по числовым/строковым типам почти всегда нужна тильда — иначе ограничение отсечёт все пользовательские типы поверх встроенных. Именно поэтому constraint Number в стенде записан как ~int | ~int8 | ... | ~float64 — чтобы type Money int64 тоже проходил.

comparable

Отдельный встроенный constraint — comparable. Он разрешает операции == и != и нужен, например, чтобы использовать T как ключ карты:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	out := xs[:0]
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

Тонкость, о которой спотыкаются: comparable — это не «любой тип, поддерживающий ==». Интерфейсные значения формально сравнимы, но их сравнение может паниковать в рантайме (если под интерфейсом лежит несравнимый тип, вроде среза). Начиная с Go 1.20 правила смягчили: обычные интерфейсы стали удовлетворять comparable, но с оговоркой, что сравнение всё ещё может паниковать. Для строгой типобезопасности предпочитайте конкретные comparable-типы, а не интерфейсы под comparable.

Пакет constraints и переезд в slices/maps/cmp

На заре дженериков (1.18–1.20) базовые constraint’ы жили в экспериментальном golang.org/x/exp/constraints — оттуда брали constraints.Ordered, constraints.Integer и прочее. С Go 1.21 бо́льшая часть применений этих constraint’ов уехала в стандартную библиотеку: пакеты slices, maps и cmp дают готовые дженерик-функции, и напрямую писать Max/Min/Sort по Ordered больше не нужно.

import (
	"cmp"
	"slices"
)

func demo() {
	xs := []int{3, 1, 2}
	slices.Sort(xs)                     // сортировка по возрастанию
	m := slices.Max(xs)                 // максимум
	i, found := slices.BinarySearch(xs, 2)
	_ = m
	_ = i
	_ = found

	// cmp.Ordered — constraint из stdlib (аналог constraints.Ordered)
	_ = clamp(5, 0, 10)
}

func clamp[T cmp.Ordered](v, lo, hi T) T {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

Ключевой сдвиг: cmp.Ordered из стандартного пакета cmp заменил constraints.Ordered как «тот самый» constraint для упорядочиваемых типов. Пакет x/exp/constraints всё ещё существует и полезен для более редких ограничений (Integer, Float, Signed), но повседневный код 1.21+ чаще опирается на cmp + slices + maps и вообще не объявляет собственных числовых constraint’ов.

Инференс типов

Хорошая новость: в большинстве случаев параметры типа писать руками не надо — компилятор выводит их из аргументов (type inference). Плохая — инференс не всесилен, и понимать его границы полезно.

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, len(xs))
	for i, x := range xs {
		out[i] = f(x)
	}
	return out
}

func demo() {
	xs := []int{1, 2, 3}

	// T выводится из xs ([]int → T=int),
	// U выводится из возвращаемого типа f (int → U=string):
	ss := Map(xs, func(x int) string { return strconv.Itoa(x) })
	_ = ss

	// Явные параметры типа — когда вывести не из чего:
	empty := Map[int, string](nil, func(x int) string { return "" })
	_ = empty
}

Правило приблизительно такое: T выводится, если он встречается в типе хотя бы одного обычного (не-типового) аргумента функции. Map(xs, f) выводит T из xs, а U — из типа результата функции f (function argument type inference). А вот если параметр типа фигурирует только в возвращаемом значении и нигде в аргументах — вывести неоткуда:

func Zero[T any]() T {
	var z T
	return z
}

func demo() {
	// z := Zero()   // НЕ компилируется: из чего выводить T?
	z := Zero[int]() // нужен явный параметр типа
	_ = z
}

Другие частые границы инференса:

  • Константы без явного типа. Max(3, 5) при Max[T Ordered] выведет T=int (default type нетипизированной константы), но Max(3, 5.0) — ошибка: 3 хочет быть int, 5.0float64, единого T нет. Лечится приведением одного из аргументов или явным Max[float64](3, 5.0).
  • Композитные литералы и nil. nil не несёт типа — из него ничего не выведешь.
  • Инференс не проходит через несколько «слоёв» так глубоко, как в Hindley-Milner-языках. Go сознательно ограничивает вывод локальными правилами ради предсказуемости и скорости компиляции; при сомнении компилятор требует явности, а не гадает.

Практика: полагайтесь на инференс в вызовах, но не удивляйтесь, когда придётся дописать [T] — это не баг, а осознанный компромисс дизайна.

Под капотом: GC-shape stenciling и словари

Это ядро статьи. Вопрос, на который отвечает реализация: как один дженерик-код исполняется для разных T? У индустрии два классических ответа, и Go не выбрал ни один из них целиком.

  • Мономорфизация (C++, Rust). Компилятор генерирует отдельную специализированную копию кода на каждый конкретный T. Максимальная скорость (прямые вызовы, инлайн), но раздувание бинаря и времени компиляции — copy на каждый тип.
  • Стирание типов (Java). Один общий код, все параметры типа стёрты до Object, типобезопасность — только на этапе компиляции, в рантайме — приведения и боксинг. Компактно, но с накладными расходами и без специализации.

Go выбрал середину — GC-shape stenciling со словарями. Идея в двух частях.

Часть 1 — стенсилинг по форме (shape), а не по типу. Компилятор генерирует копию кода не на каждый T, а на каждую GC-форму (GC shape) — грубо, на каждый уникальный layout памяти с точки зрения сборщика мусора и вызовов. Ключевой факт: все указательные типы имеют одну и ту же форму. *Circle, *Rect, *bytes.Buffer, *os.File — с точки зрения GC это всё «одно машинное слово, являющееся указателем». Значит, дженерик, инстанцированный по любому из них, использует одну общую копию кода. А вот int, float64, string, структуры разных layout’ов — это разные формы, у каждой своя копия.

Часть 2 — словарь (dictionary). Раз одна копия обслуживает много типов одной формы, ей нужно как-то узнавать про конкретный тип в рантайме: какой у него размер (для value-форм это уже зашито в форму, но для методов — нет), где лежат его методы, как его сравнивать/хешировать. Эту информацию компилятор упаковывает в скрытый параметр — словарь, который передаётся в дженерик-функцию неявно, как дополнительный аргумент. Словарь содержит указатели на нужные методы конкретного типа, метаданные типа (*runtime._type), под-словари для вложенных вызовов.

Отсюда — центральное следствие для производительности. Когда дженерик над указательным типом вызывает метод из constraint (x.Area()), этот вызов идёт не напрямую и не через инлайн, а косвенно, через словарь: код одной формы не знает статически, чей именно Area звать, — он берёт указатель на метод из словаря и прыгает по нему. Это ровно та же косвенность, что у обычного интерфейсного вызова через itab. Девиртуализации (превращения в прямой вызов) на этом пути нет. Именно поэтому, как мы увидим в бенчмарках, дженерик по указателям не быстрее интерфейса: под капотом это тот же самый indirect call, просто через словарь вместо itab.

Оговорка важная: всё это — как реализовано в текущем компиляторе Go (на момент go1.26.3) для описанных паттернов, а не гарантия языка. Спецификация не обещает ни shape-стенсилинга, ни словарей, ни отсутствия девиртуализации; это детали реализации gc, и они могут меняться от релиза к релизу (компилятор вправе девиртуализировать конкретный call-site, если докажет единственный тип). Поэтому механизм ниже описывает поведение, которое стоит перепроверять бенчем на своей сборке, а не константу, на которую можно закладываться навсегда.

Для value-форм картина другая и приятная: SumGeneric[int] — это специализация именно под форму int, тело цикла складывает машинные слова напрямую, acc += x компилируется в обычный ADD, без всякого словаря на горячем пути и без боксинга. Поэтому на value-типах дженерик близок к рукописи.

Резюме механизма одной таблицей:

Аспект C++ (мономорфизация) Java (стирание) Go (shape stenciling + dict)
Копий кода на каждый тип одна одна на форму
Value-типы прямой код, инлайн боксинг специализация по форме, без боксинга
Вызов метода прямой/инлайн virtual косвенно через словарь
Размер бинаря раздувается компактно умеренно (копии шарятся по форме)
Рантайм-инфо нет приведения словарь

Цена: бенчмарки на живом стенде

Теория выше проверяема. Всё, что дальше, заземлено на живой стенд digital-cookbook/generics/ — чистый Go без docker и внешних зависимостей, три сюжета в трёх пакетах. Числа сверены прогоном на go1.26.3 windows/amd64 (go test -bench=. -benchmem -count=3 ./...). Они ориентировочные — зависят от машины, версии Go и флагов; снимайте у себя. Робастны здесь не абсолютные наносекунды, а порядки и аллокации. Про то, как правильно снимать такие числа и не обмануться, — отдельная статья про профилирование и бенчмарки.

Сюжет 1 — value-типы: дженерики выигрывают

Три версии суммы числового среза: дженерик SumGeneric[T Number], рукописные SumInt/SumFloat64 и SumIface([]any) с боксингом. Важная оговорка про последнюю: её бенчмарк меряет стоимость интерфейсного подхода целиком — включая конвертацию []int/[]float64 в []any (boxInts/boxFloats) прямо в теле цикла. Это честный сквозной сценарий «у меня на руках typed slice, а API хочет []any», но именно поэтому цифры такие большие: платим и за упаковку каждого элемента, и за сам dispatch. Не читайте это как «любой interface-вызов на два порядка медленнее» — так дорого выходит конкретно массовый боксинг value-типов. Результаты:

Бенчмарк ns/op B/op allocs/op Что показывает
SumInt_Generic ~1400 0 0 дженерик по форме int
SumInt_Handwritten ~3200 0 0 рукописный эталон int
SumInt_Iface ~100000 96256 3841 боксинг int → аллокации
SumFloat64_Generic ~3300 0 0 дженерик по форме float64
SumFloat64_Handwritten ~3700 0 0 рукописный эталон float64
SumFloat64_Iface ~95000 98296 4096 боксинг float64 → аллокации

Читаем аккуратно. Дженерик и рукопись — один порядок и ноль аллокаций в обоих случаях: специализация по форме работает, боксинга нет. Разница дженерик/рукопись на int (≈1400 против ≈3200) выглядит так, будто дженерик быстрее рукописи — но это артефакт инлайна и кодогена на конкретной сборке, а не общее правило. Не стройте на этом выводов: корректная формулировка — «дженерик и рукопись сопоставимы по скорости и оба не аллоцируют».

А вот interface{}-версия — в десятки раз дороже (≈100µs против ≈1–3µs) и боксит: ~3841 аллокаций на int (~96 КБ/op) и ~4096 на float64. Каждый элемент упаковывается в интерфейсное значение; для целых ≥256 упаковка идёт с аллокацией на куче (значения <256 рантайм кэширует, потому и не ровно 4096). Вот она, реальная цена any на горячем пути над value-типами — и вот где дженерики её убирают.

Сюжет 2 — указательные типы: дженерик НЕ быстрее интерфейса

Ключевой сюрприз. Дженерик SumAreasGeneric[T Shape] вызывает x.Area() через constraint по указательным *Circle/*Rect; интерфейсная SumAreasIface([]Shape) делает то же через обычный dispatch.

Бенчмарк ns/op B/op allocs/op Что показывает
Area_Generic ~12300 0 0 вызов Area() через словарь специализации
Area_Iface ~12900 0 0 вызов Area() через интерфейсную таблицу (itab)

≈12.3µs против ≈12.9µs, оба — ноль аллокаций: в этом Windows-прогоне дженерик близок к интерфейсу и точно не быстрее. Но «близость» машинозависима — на других системах дженерик тут бывает и заметно медленнее интерфейса; устойчив лишь вывод, что ускорения ждать нельзя. Причина ровно та, что разобрана под капотом: все указатели — одна форма, делят одну копию кода, вызов Area() идёт косвенно через словарь, девиртуализации нет. Если вы тянете дженерик в такой код в надежде на ускорение — надежда не оправдается. Брать его тут стоит ради типобезопасного API, а не ради скорости.

Сюжет 3 — размер бинаря: никакого раздувания

Два бинаря: generic инстанцирует дженерик-функции для дюжины типов (int, int8/16/32/64, uint*, float32/64, string), iface делает то же через одну SumTwoIface(any, any) any.

Бинарь Размер Комментарий
bin/generic 2 467 328 Б инстанцирование дженерика по многим формам
bin/iface 2 467 328 Б одна interface{}-функция
разница 0 байт в этом прогоне (Windows) нет раздувания; на др. ОС/версиях возможна малая разница

Совпадение байт-в-байт получилось в этом конкретном прогоне (Windows, go1.26.3); на других ОС/версиях бинари могут разойтись на сотни байт из ~2.4 МБ — важен не точный ноль, а порядок: раздувания нет. В C++ аналогичный шаблон, инстанцированный по дюжине типов, дал бы дюжину копий кода и заметный рост. Go так не делает — это осознанный размен: чуть медленнее (словари, косвенность) в обмен на компактность и быструю компиляцию.

Когда дженерики выигрывают, когда нет

Соберём выводы в решающее правило.

Дженерики дают выигрыш — берите:

  • Контейнеры и алгоритмы над value-типами. Сумма/сортировка/поиск по []int, []float64, множества, стеки, очереди — специализация по форме убирает боксинг any и даёт скорость рукописи. Это сюжет 1.
  • Type-safe API вместо interface{}. Даже там, где скорость не меняется, дженерик убирает приведения и ловит ошибки типов на компиляции. Stack[T] лучше Stack на any не потому, что быстрее, а потому, что безопаснее.
  • Утилиты stdlib. slices, maps, cmp — используйте их вместо самописных any-хелперов и вместо кодогена.

Выигрыша по скорости нет — берите только ради типобезопасности или не берите:

  • Код с интерфейсным dispatch по указателям. Если внутри всё равно вызов метода через constraint по указательному типу — это сюжет 2: дженерик = интерфейс по стоимости. Берите дженерик, если он делает API чище/безопаснее; но не ждите ускорения, и если он усложняет сигнатуры — честнее обычный interface{}.
  • Единичное место, где any читается проще. Дженерик с тремя параметрами типа и вложенными constraint’ами бывает менее читаем, чем прямолинейный any + type switch. Читаемость — тоже инженерный критерий.
  • Массовая специализация под перформанс-критичный числовой код. Иногда честнее кодоген или ручная специализация под конкретные типы, если вы точно знаете набор типов и выжимаете последние проценты, — но это редкий случай, начинать надо с дженерика.

Одной фразой: дженерики берут ради типобезопасности — когда обобщение действительно упрощает API, — а ради скорости только на value-типах. Там, где конкретная функция или обычный интерфейс дают более читаемый код, они и уместнее: обобщение ради обобщения — не цель. Смежная тема выбора модели исполнения — «Модели конкурентности across languages»; а идиоматичные способы структурировать сам код на Go — в «Паттернах конкурентности Go».

Границы механизма

Дженерики Go сознательно ограничены — часть того, что есть в C++/Java/Rust, здесь просто нельзя. Знать границы важно, чтобы не биться о них в дизайне.

  • Нет дженерик-методов у не-дженерик типов. Метод не может ввести собственный параметр типа. func (r Repo) Get[T any](id int) Tне компилируется. Параметризовать можно только тип целиком (type Repo[T any]) или свободную функцию, но не отдельный метод существующего типа. Причин несколько (сложность инстанцирования методов, method sets, интерфейсы и линковка), и словари — одна из них: метод пришлось бы диспетчеризовать по типу, неизвестному при объявлении. Полный разбор — в type-parameters proposal.
type Cache struct{ /* ... */ }

// func (c *Cache) Get[T any](key string) T { ... } // ОШИБКА компиляции

// Обходной путь — свободная дженерик-функция:
func CacheGet[T any](c *Cache, key string) T {
	var z T
	// ... достать из c и привести ...
	return z
}
  • Нельзя параметризовать метод отдельно от типа. Из предыдущего: набор параметров типа фиксируется на объявлении типа/функции, «доп. T» на вызове метода не добавить.
  • Нет ковариантности. []T не является []Shape, даже если каждый T реализует Shape. Stack[*Circle]не Stack[Shape]. Дженерик-типы инвариантны по параметру. Хотите гетерогенную коллекцию — используйте []Shape (интерфейс), а не дженерик.
  • Constraints не дают доступа к полям. Union типов в constraint разрешает операции (+, <, ==) и методы, но не поля структур. Нельзя написать constraint «любой тип с полем .Name string» и обратиться к x.Name внутри дженерика. Общий доступ к данным — только через методы-геттеры в интерфейсе-constraint. Это регулярно всплывает у тех, кто ждёт «structural typing по полям», как в TypeScript, — в Go его нет.

Эти границы — не недоработка, а следствие выбранной реализации и приоритета простоты. Полное сравнение того, что каждый язык позволяет и запрещает, — в компаньоне «Дженерики across languages».

Демо и версии

  • Код: живой стенд digital-cookbook/generics/ — чистый Go, без docker и внешних зависимостей, один модуль, три подкаталога по сюжетам: numeric/ (value-типы — дженерик vs рукопись vs interface{}), dispatch/ (указательные типы — дженерик vs интерфейс через constraint), binsize/ (размер бинаря). Бенчмарки — go test -bench=. -benchmem ./...; размеры бинарей — сперва mkdir -p bin (каталог в чистом checkout не создан), затем go build -o bin/generic ./binsize/cmd/generic и то же для iface, после чего сравнить. Код в тексте — выжимки из стенда.
  • Версии: дженерики доступны с Go 1.18 (type parameters, constraints, ~, comparable). С Go 1.20 обычные интерфейсы стали удовлетворять comparable (сравнение может паниковать). С Go 1.21 появились стандартные slices, maps, cmp — предпочитайте их самописным any-хелперам и x/exp/constraints.
  • Числа сверены на go1.26.3 windows/amd64 и ориентировочны (машина/версия/флаги влияют). Робастные выводы: дженерик ≈ рукопись на value-типах (один порядок, 0 аллокаций), interface{} дороже в десятки раз с тысячами аллокаций от боксинга, на указательных типах дженерик не быстрее интерфейса, а размер бинаря — без раздувания в порядке величины (в Windows-прогоне байт-в-байт, на Linux — разница ~500 Б из ~2.4 МБ). Снимайте у себя — не переносите абсолютные ns из статьи в свои решения.
  • Смежное: «Дженерики across languages» (языко-агностичное сравнение подходов), профилирование и бенчмарки в Go (как честно измерять), паттерны конкурентности Go, модели конкурентности across languages.

Документация и первоисточники

  • Go blog: An Introduction to Generics — вводная от команды Go: type parameters, constraints, инференс, и правило «если реализация метода различается по типам — это интерфейс, а не дженерик».
  • Go blog: When To Use Generics — когда дженерики уместны, а когда лучше интерфейс или обычный код; практические критерии выбора.
  • Type Parameters Proposal — исходный design doc дженериков: полная семантика constraint’ов, ~-approximation, union-элементов, инференса и границ механизма.
  • PlanetScale blog: Generics can make your Go code slower — практический разбор того, почему на указательных типах дженерик идёт через словарь и не девиртуализуется (тот самый сюрприз из сюжета 2).
  • Go 1.18 Release Notes — релиз дженериков; раздел о реализации через GC-shape stenciling и словари (dictionaries).
  • Исходники компилятора: пакет cmd/compile/internal/noder (reader.go/writer.go — генерация копий по форме и сборка словарей) и cmd/compile/internal/devirtualize — где девиртуализация всё же происходит.

Обсуждение в Telegram

Присоединиться →

Комментарии