Дженерики в Go: одиннадцать типов, шесть тел кода и метод, до которого всё равно идут через словарь
Сначала язык: ограничение задаёт множество типов, множество типов задаёт операции, а сохранить связь между типом входа и типом выхода не умеет ни один интерфейс. Потом компилятор: Go порождает версию не под каждый тип, а под форму — одиннадцать типов дали шесть тел кода, три разных указателя одно. И только потом замер: дженерик по ограничению с методами прямого вызова не даёт и отстаёт вчетверо, но четвёрка эта на четыре пятых состоит из потерянного встраивания, а не из косвенности, и это здесь измерено, а не предположено.
Полное техническое изложение
TL;DR
- Go мономорфизирует не по типам, а по GC-формам: одно тело на форму. Одиннадцать типов дали шесть тел, три указателя — одно.
- На go1.24.7 слияние указателей решил один вопрос: набор ли методов это
ограничение.
anyиStringer— да, указатели сливаются.comparable— нет, у каждого своё тело (те же одиннадцать типов дают восемь). Это наблюдение за компилятором, а не правило спецификации. - Дженерик с методами в ограничении не даёт прямого вызова: 1 342 нс против 321 у прямого. Но «стоит как интерфейс» — сказать нельзя: здесь интерфейс вышел 2 185, а на прежнем прогоне статьи шёл вровень с дженериком.
- Из четырёхкратной разницы четыре пятых — потерянное встраивание. Запретить его на прямом пути — и тот дорожает с 321 до 1 143, а дженерик отстаёт уже не в 4,18 раза, а в 1,39.
- Там, где методов в ограничении нет, дженерик не стоил ничего ни в одном из трёх замеров: те же байты, те же выделения, что руками.
[]any— вот где платят: 1 001 выделение против одного.
Одно тело на форму, а не на тип
Компилятор порождает код не под каждый подставленный тип, а под форму — размер, выравнивание и расположение указателей внутри. Всё, чего телу не хватает, чтобы знать конкретный тип, передаётся скрытым аргументом — словарём.
Считать это не надо на глаз: bench/gogenerics/shapes.go собирает подопытный
пакет и читает таблицу символов через go tool nm. Там видно и тела
(main.AnyBox[go.shape.*uint8]), и словари (main..dict.AnyBox[*main.A]).
Три правила, и все три на картинке:
- указатели сливаются, если ограничение — набор методов;
- именованный тип берёт форму базового (
Celsius,Meters,float64— одно тело); int64иfloat64не сливаются, хотя оба по восемь байт, — и так задумано: у них разные операции.
Почему метод всё равно вызывается косвенно
Раз три указателя делят одно тело, это тело не знает, чей метод звать. Подставить адрес при сборке нельзя — он берётся из словаря.
| как вызывается метод | нс | к прямому |
|---|---|---|
| напрямую | 321 | ×1,00 |
дженерик, [T Namer] | 1 342 | ×4,18 |
| интерфейс | 2 185 | ×6,81 |
Дженерик не даёт скорости прямого вызова — это главное и это не меняется. А вот отношение дженерика к интерфейсу меняется: на прежнем прогоне статьи они шли вровень, здесь дженерик заметно дешевле. Выбирать по этому отношению нельзя.
Зато четырёхкратную разницу можно разложить. Поставим методу //go:noinline,
чтобы прямой путь тоже делал настоящий вызов:
| как вызывается метод | нс | к прямому |
|---|---|---|
напрямую, //go:noinline | 1 143 | ×1,00 |
дженерик, [T Namer] | 1 585 | ×1,39 |
| интерфейс | 2 198 | ×1,92 |
Прямой вызов без встраивания подорожал с 321 до 1 143 — значит, встраивание стоило около 822 нс из 1 021, на которые дженерик отставал. Примерно четыре пятых разницы — это потерянное встраивание, а не косвенность.
Но только если в ограничении есть методы
| что делаем | руками | дженерик |
|---|---|---|
прочитать тысячу int64 | 307 нс | 308 нс |
| построить срез | 3 303 нс, 8 192 б, 1 выд. | 3 290 нс, 8 192 б, 1 выд. |
| одно сравнение | 0,4426 нс | 0,4523 нс |
Байты и выделения совпадают точно, а времена лежат внутри разброса раундов. Для [T ~int64] или [T ordered] тело
компилируется под конкретную форму, и от дженерика в машинном коде не остаётся
ничего.
Правило, которое стоит унести: не «дженерики быстрые» и не «дженерики медленные», а посмотрите на ограничение.
Настоящая цена — не дженерик, а any
| что строим | нс | байт | выделений |
|---|---|---|---|
[]int64 | 3 303 | 8 192 | 1 |
дженерик []T | 3 290 | 8 192 | 1 |
[]any | 16 114 | 24 384 | 1 001 |
Срез из пар «дескриптор типа, указатель на значение» — по 16 байт на элемент — плюс коробка на каждое значение: 16 384 плюс 8 000 и есть 24 384. Вот от чего дженерик спасает в разы.
Оговорка: само преобразование в any кучи не требует. Уедет значение в кучу
или нет, решает анализ убегания конкретного кода и конкретной версии
компилятора. Здесь значения складываются в срез и живут дальше — уезжают. В
замере одного сравнения то же преобразование не даёт ни одного выделения.
И ещё одна: 16 байт на элемент — это раскладка данной сборки под linux/amd64,
а не контракт any.
Чем измерено
Все числа выше сняты на go1.24.7 linux/amd64, Intel Xeon 2,10 ГГц,
GOMAXPROCS = 2, семью чередующимися раундами; прогон целиком лежит в
bench/gogenerics/runs/cost.txt. Число тел кода и словарей —
bench/gogenerics/shapes.go.
Язык при этом ушёл дальше: в Go 1.26 сняли запрет на самоссылку в ограничении, в Go 1.27 появились обобщённые методы — и то и другое здесь проверено компилятором, а не взято из примечаний к выпуску. Замеры на 1.27 повторены тоже: таблица форм совпала побайтово, а в паре прогонов на одной машине диапазоны двух toolchain перекрылись в каждой строке. То есть 1.27 в этих нагрузках не изменил ничего.
Совпадения ваших времён с этими ждать не надо: ждать надо совпадения соотношений внутри одной таблицы — и то не всех, как показывает пара «дженерик и интерфейс». Число тел кода секундомером не меряется, его даёт таблица символов, и при том же исходнике, той же версии компилятора и той же целевой конфигурации оно будет тем же.
TL;DR
Три слоя, и статья идёт по ним в этом порядке: язык (что дженерик выражает), компилятор (как это сделано в go1.24.7), замер (сколько это стоило на одной машине). Смешивать их — главная ошибка разговоров про дженерики.
Язык. Ограничение задаёт множество типов, множество типов задаёт операции, разрешённые телу. Дженерик нужен там, где алгоритм один на семейство типов, где контейнеру всё равно, что внутри, и — главное — где надо сохранить связь между типом входа и типом выхода: этого интерфейсом не выразить вовсе. Там, где у каждого типа своё поведение и код зовёт один метод, интерфейс честнее. Состояние языка на сегодня — Go 1.27 с обобщёнными методами: проверено компилятором, а не по примечаниям к выпуску. Все таблицы статьи сняты на go1.24.7; что из них меняется на 1.27 — тоже проверено, и ответ «ничего».
Компилятор. Go мономорфизирует не по типам, а по GC-формам: одно тело кода на форму, и типов в форме бывает несколько. Различать их внутри общего тела помогает словарь, передаваемый скрытым аргументом.
- Одиннадцать типов — шесть тел. Снято с таблицы символов: три разных
указателя дают одно тело,
Celsius,Metersиfloat64— тоже одно. int64иfloat64не сливаются, хотя оба по восемь байт, — и так задумано. А вот указатели документ реализации сливает безусловно, компилятор же только под базовым интерфейсом — ограничением, заданным одними методами: здесь реализация тоньше документа.- Слияние указателей на go1.24.7 решило одно свойство ограничения: базовый
ли это интерфейс, то есть задано ли его множество типов только методами.
any,Stringerи интерфейс из двух методов — базовые, и указатели сливаются.comparable, объединение*A | *B | *Cи «Stringerплюсcomparable» — нет, и тогда у каждого указателя своё тело: те же одиннадцать типов подcomparableдают восемь. Проверено на шести ограничениях, а не на трёх: последнее из них методы содержит, но слияния не даёт. Спецификация ни форм, ни словарей, ни числа тел не определяет — это наблюдение за компилятором.
Замер. go1.24.7 linux/amd64, семь чередующихся раундов, прогон целиком в
bench/gogenerics/runs/cost.txt.
- С ограничением-методом дженерик не убрал косвенный вызов. 1 342 нс против 321 у прямого — ×4,18; у интерфейса 2 185, то есть ×6,81. Вывод отсюда — не «дженерик равен интерфейсу» и не «дженерик быстрее интерфейса», а «замена интерфейса на дженерик сама по себе прямого вызова не даёт».
- Четырёхкратное отставание разложено на слагаемые, а не оставлено догадкой. Запретить встраивание на прямом пути — и он дорожает с 321 до 1 143: около четырёх пятых разницы приходится на потерянное встраивание, а не на косвенность. Дженерик отстаёт от честного прямого вызова всего в 1,39 раза. У интерфейса отставание 1,92, и пятая часть его — вообще не диспетчеризация, а вдвое более широкие данные: 8 КиБ указателей против 16 КиБ интерфейсных значений. На другом экземпляре машины вклад косвенности вышел нулевым, а встраивания — стопроцентным: порядок слагаемых переносится, сами доли — нет.
- 1.27 в этих нагрузках не изменил ничего. Таблица символов совпала побайтово, отказы компилятора — слово в слово, а в паре прогонов на одной машине диапазоны двух toolchain перекрылись в каждой строке каждого блока.
- Там, где методов в ограничении нет, дженерик не стоил ничего ни в одном из трёх замеров — чтение, построение среза, одно сравнение: те же байты, те же выделения, что руками.
[]any— вот где платят: 1 001 выделение против одного и 24 384 байта против 8 192.
Три слоя, которые в разговорах про дженерики слипаются
Почти весь спор «дженерики в Go быстрые или медленные» держится на том, что три разных вопроса задаются как один. Развести их надо с самого начала, потому что ответы у них разной прочности:
| слой | что к нему относится | насколько это прочно |
|---|---|---|
| язык | параметр типа, ограничение, множество типов, вывод, обобщённый метод | контракт: записан в спецификации и обязателен для любой реализации Go |
| компилятор | gcshape, словарь, сколько тел кода порождено | решение конкретной реализации: спецификация о нём молчит, и смена версии его не ломает — она его меняет |
| замер | наносекунды, байты, выделения | наблюдение за одной программой на одной машине. Самое хрупкое из трёх |
Статья идёт ровно в этом порядке: сначала что дженерик выражает, потом какое множество типов задаёт ограничение и что оно разрешает телу, потом нужен ли дженерик вообще, потом как всё это сделано в go1.24.7 — и только в конце, сколько оно стоило на нашей машине.
Порядок не косметический. Прочитанное в обратную сторону — «замерили 4,18, значит дженерики вчетверо дороже» — это вывод о языке из наблюдения за машиной, и неверен он ровно настолько, насколько велика разница между слоями. Насколько она велика, видно в конце: одно и то же отношение между дженериком и интерфейсом на двух экземплярах одной машины вышло разным.
Из чего состоит дженерик
Начать стоит не с компилятора, а с того, что вообще написано на бумаге. Пять слов, которые дальше используются постоянно, — и все пять видны в одной строчке:
Два вызова одной функции — Max(10, 20) и Max(1.5, 2.5) — дают int и
float64. Никакого приведения, никакого any, конкретный тип сохранён и на
входе, и на выходе. Это и есть то, ради чего параметры типа появились, и это
утверждение о языке: оно верно на любой реализации Go.
Параметры типа бывают не только у функций:
type Box[T any] struct {
Value T
}
b := Box[string]{Value: "s"}Вот тут и возникает вопрос, с которого обычно начинают, — и хорошо, что он возникает вторым, а не первым: сколько машинных версий этого кода должен породить компилятор? Одну на каждый подставленный тип? Одну на всех? Ответ лежит не в языке, а в реализации, и разбирается в своей части статьи, ниже.
А сначала — вторая половина той строчки, cmp.Ordered.
Ограничение задаёт множество типов, а множество типов задаёт операции
Цепочка, из которой механически следует всё остальное:
- ограничение задаёт
- множество типов — какие
Tразрешены, — а оно задаёт - операции, разрешённые телу функции.
Последняя стрелка — не метафора. Тело может делать со значением типа T ровно
то, что разрешено каждому типу из множества, и ни на одну операцию больше.
Четыре ограничения, которые встречаются чаще всех, стоит прочитать сначала как язык, а не как форму кода:
| ограничение | какие типы разрешены | что получает тело |
|---|---|---|
any | любой аргумент типа | почти ничего: операций, общих у всех типов Go, нет |
comparable | типы, значения которых можно сравнивать == и != | == и != |
interface{ Name() string } | типы, у которых есть такой метод | вызов Name() |
interface{ ~int | ~float64 } | только int, float64 и именованные типы поверх них | арифметику и сравнение |
Заметьте, что comparable и объединение — это не «интерфейсы» в привычном
смысле: значением такого типа быть нельзя. Различение разбирается прямо ниже, и
оно же потом объяснит самое неожиданное наблюдение статьи.
Что ограничение разрешает телу, а что запрещает
Это два разных инструмента, и путать их дорого. Правило, по которому компилятор решает, что вам можно:
The rule is that a generic function may use a value whose type is a type
parameter in any way that is permitted by every member of the type set of the
parameter's constraint.
Правило таково: дженерик-функция может использовать значение, тип которого является параметром типа, любым способом, который разрешён каждым членом множества типов ограничения этого параметра.
Отсюда механическое следствие, которое видно на двух ошибках компиляции
(bench/gogenerics/constraints.go):
v.Name undefined (type T has no field or method Name)
invalid operation: operator + not defined on a (variable of type T constrained by Named)
Ограничение-множество (~int | ~float64) даёт операторы, но не даёт методов.
Ограничение-поведение (interface { Name() string }) даёт метод, но не даёт
операторов. И совместить их объединением нельзя — спецификация запрещает:
A union (with more than one term) cannot contain the predeclared identifier
comparable or interfaces that specify methods, or embed comparable or
interfaces that specify methods.
Объединение (более чем из одного термина) не может содержать предобъявленный идентификатор comparable или интерфейсы, задающие методы, а также встраивать comparable или интерфейсы, задающие методы.
Совместить можно пересечением: interface { ~int; Name() string } — и тогда
доступны и оператор, и метод. Проверено: работает.
Базовый интерфейс и общий: почему это не одно слово «интерфейс»
Здесь же лежит различение, из-за которого путаются в самом начале, — и то самое, которым ниже будет объясняться слияние указателей. Спецификация делит интерфейсы надвое:
Проверяется это компилятором мгновенно:
type Number interface{ ~int | ~float64 }
var x Number // не компилируется
func Sum[T Number](a, b T) T // компилируетсяСообщение компилятора дословно (bench/gogenerics/constraints.go, случай 4):
cannot use type Number outside a type constraint: interface contains type constraints
Отсюда две вещи сразу. Первая: ограничение — не значение. Когда T
ограничен Stringer, внутри тела нет никакой интерфейсной переменной; есть
конкретный тип, о котором известно, что у него есть метод. Вторая: именно
принадлежность к базовым и решает форму, а число методов — нет. Но это уже
про компилятор, и проверяется это ниже, таблицей из шести ограничений.
Тильда: почему ~int64, а не int64
~ встречалась выше как часть синтаксиса, и пора сказать, что она означает.
Ограничение из одного типа задаёт множество из одного элемента:
The type set of a non-interface type term is the set consisting of just that
type.
Множество типов термина, не являющегося интерфейсом, состоит только из этого типа.
Тильда расширяет его до всех типов с таким базовым:
The type set of a term of the form ~T is the set of all types whose underlying
type is T.
Множество типов термина вида ~T — это множество всех типов, базовый тип которых есть T.
Практическая разница — вот эта (bench/gogenerics/tilde.go):
type Exact interface { float64 }
type Under interface { ~float64 }
type Celsius float64
ScaleExact(Celsius(20)) // не компилируется
ScaleUnder(Celsius(20)) // работаетКомпилятор при этом сам подсказывает, чего не хватает:
Celsius does not satisfy Exact (possibly missing ~ for float64 in Exact)
То есть без тильды ограничение отсекает весь ваш доменный код: type UserID int64 не подойдёт под [T int64], type Celsius float64 — под [T float64],
type Seconds int — под [T int]. Почти всегда нужна тильда.
У самой тильды два ограничения, и оба спецификация называет прямо: в терме ~T
базовый тип T должен быть самим T (то есть ~MyInt при type MyInt int не
бывает) и не может быть интерфейсом. Проверено — оба случая дают ошибку
компиляции:
invalid use of ~ (underlying type of MyInt is int)
cannot satisfy Bad (empty type set)
invalid use of ~ (error is an interface)
Первый случай даёт две строки: ограничение с запрещённым термом становится пустым множеством, и следом падает всякая попытка его удовлетворить.
И то же, что и у вывода типов выше: тильда расширяет множество, но не стирает
имя. ScaleUnder(Celsius(20)) возвращает main.Celsius, а не float64.
Что выводится, а что придётся написать руками
Вывод в первой строчке статьи назван и на этом оставлен. Именно из-за него дженерики на письме выглядят как обычные функции, и у него есть закрытый список случаев, где он работает. Спецификация описывает его так:
A use of a generic function may omit some or all type arguments if they can be
inferred from the context within which the function is used, including the
constraints of the function's type parameters.
Использование дженерик-функции может опускать часть или все аргументы типа, если они выводимы из контекста, в котором функция используется, включая ограничения её параметров типа.
Ключевое слово — «из контекста». Список контекстов в спецификации закрытый:
Type inference supports calls of generic functions and assignments of generic
functions to (explicitly function-typed) variables.
Вывод типов поддерживает вызовы дженерик-функций и присваивания дженерик-функций переменным (с явно указанным функциональным типом).
Присваивания результата в этом списке нет — и отсюда самый частый случай,
когда компилятор просит написать параметр явно. Проверено на go1.24.7
(bench/gogenerics/inference.go); ниже отказы компилятора дословно, снята
только позиция в файле перед сообщением:
in call to Zero, cannot infer T (declared at ./main.go:3:11)
in call to Make, cannot infer T (declared at ./main.go:3:11)
cannot use generic type Box[T any] without instantiation
in call to Pair, mismatched types untyped int and untyped string (cannot infer T)
Правило, из которого всё это следует, простое: выводится то, что видно в
аргументах. У Zero[T]() T аргументов нет — выводить не из чего. У обобщённого
типа (не функции) вывода нет вовсе: Box[T] надо инстанцировать.
Отдельно стоит нетипизированная константа. Спецификация ставит приоритет прямо:
Type inference gives precedence to type information obtained from typed operands
before considering untyped constants.
Вывод типов отдаёт предпочтение информации о типе, полученной из типизированных операндов, прежде чем рассматривать нетипизированные константы.
Разница видна на двух вызовах одной функции Sum[T ~int | ~float64](a, b T) T.
Пока оба операнда — нетипизированные константы, расширению ничто не мешает:
Sum(1, 2.5) даёт float64. Но стоит одному операнду стать типизированным
(var i int, вызов Sum(i, 2.5)) — и T связан с int раньше, чем очередь
дойдёт до константы:
cannot use 2.5 (untyped float constant) as int value in argument to Sum (truncated)
Падает здесь не вывод, а приведение константы к уже выведенному типу.
И приятная деталь, которую видно только запуском: вывод сохраняет именной
тип. Для type Point []int вызов Map(p, f) даёт S = main.Point, а не
[]int — имя не теряется по дороге.
Нужен ли здесь дженерик вообще
До сих пор речь шла о том, что дженерик умеет. Вопрос, который на практике задают раньше всех остальных, — надо ли его тут применять. У авторов языка ответ сформулирован через противопоставление интерфейсу:
Inversely, if the implementation is different for each type, then use an
interface type and write different method implementations, don't use a type
parameter.
Наоборот, если реализация различается для каждого типа, используйте интерфейсный тип и напишите разные реализации методов, а не параметр типа.
Из этого и из цепочки «ограничение → множество типов → операции» получается короткий разбор случаев:
один и тот же АЛГОРИТМ для семейства типов
(сортировка, min/max, Map/Filter, суммирование)
→ дженерик
КОНТЕЙНЕР, которому всё равно, что внутри
(стек, множество, кэш, очередь)
→ дженерик
надо СОХРАНИТЬ связь между типом входа и типом выхода
(Keys(map[K]V) []K; Map([]T, func(T) U) []U)
→ дженерик: единственный из четырёх случаев,
который интерфейсом не выражается вообще
у каждого типа своё ПОВЕДЕНИЕ, а код зовёт один метод
(Read, String, Serve)
→ интерфейс
Третью строку чаще всего пропускают, а она и есть настоящая причина
существования дженериков. func Keys(m map[any]any) []any компилируется и
работает; чего в ней нет — так это связи между тем, что положили, и тем, что
достали. Связь восстанавливается приведением у вызывающего, то есть проверкой в
рантайме вместо проверки при сборке. Дженерик её сохраняет — и это свойство
языка, не зависящее ни от версии компилятора, ни от машины.
Четвёртая строка — там, где дженерик не нужен, и совет авторов языка ровно про неё. Если реализация у каждого типа своя, ограничение с методами не даёт ничего, чего не даёт интерфейс: тогда интерфейс честнее и по смыслу.
А по цене вывод надо брать ровно в тех границах, в каких он измерен, и не шире;
сам замер — в конце статьи. Измерено вот что: на одной нагрузке — тысяча
объектов указательной формы, ограничение Namer, вызов Name() — дженерик не
превратился в прямой вызов. Не измерено и потому не утверждается, что дженерик
«стоит как интерфейс»: на прогоне, который лежит в
bench/gogenerics/runs/cost.txt, он вышел заметно дешевле интерфейса, а на
прежнем прогоне той же статьи — вровень с ним. Значит из замера следует
одно: замена интерфейса на дженерик сама по себе не даёт ни прямого вызова,
ни встраивания, ни девиртуализации, и рассчитывать на них при выборе API
нельзя.
Множество типов без методов — то, ради чего дженерики и сделаны, и в трёх замерах оно не стоило ничего.
Что менялось в самом языке
Здесь проходит граница, которую в статьях про дженерики чаще всего стирают, а стирать её нельзя: состояние языка и toolchain замера — не одно и то же.
- Язык — Go 1.27. Цитаты ниже дословны, но ни одна возможность здесь не утверждается со слов документа: каждая проверена компилятором.
- Все таблицы статьи — go1.24.7. Опыт с формами и все замеры сняты на нём. Что из этого меняется на 1.27, разобрано в конце раздела: коротко — ничего.
| версия | что изменилось | проверено здесь |
|---|---|---|
| 1.18 | параметры типов, дженерик-функции и типы, gcshape + словари | да, вся статья |
| 1.21 | заметно расширенный вывод типов | частично: раздел про вывод |
| 1.24 | обобщённые псевдонимы типов | да, компилятором |
| 1.26 | ограничение может ссылаться на сам ограничиваемый тип | да, компилятором |
| 1.27 | обобщённые методы; вывод типов во всех контекстах присваивания | да, компилятором |
Столбец «проверено» здесь означает буквально проверку: versions.go собирает
каждый случай временным модулем тем же toolchain, которым запущен, и печатает
ответ компилятора. Вот тот же файл, прогнанный дважды. На go1.24.7:
1. ВОЗМОЖНОСТИ ЯЗЫКА НА GO1.24.7, ПРОВЕРЕННЫЕ КОМПИЛЯТОРОМ
----------------------------------------------------------------------
1. обобщённый псевдоним типа
обещано: Go 1.24
go.mod пробника: go 1.24
| собралось
2. ограничение ссылается на сам ограничиваемый тип
обещано: Go 1.26
go.mod пробника: go 1.26
| go: go.mod requires go >= 1.26 (running go 1.24.7; GOTOOLCHAIN=local)
3. у метода свои параметры типа
обещано: Go 1.27
go.mod пробника: go 1.27
| go: go.mod requires go >= 1.27 (running go 1.24.7; GOTOOLCHAIN=local)
4. у МЕТОДА ИНТЕРФЕЙСА параметров типа нет и в 1.27
обещано: нигде: это граница, которую 1.27 не сдвинул
go.mod пробника: go 1.27
| go: go.mod requires go >= 1.27 (running go 1.24.7; GOTOOLCHAIN=local)
И тот же файл на go1.27.0:
1. ВОЗМОЖНОСТИ ЯЗЫКА НА GO1.27.0, ПРОВЕРЕННЫЕ КОМПИЛЯТОРОМ
----------------------------------------------------------------------
1. обобщённый псевдоним типа
обещано: Go 1.24
go.mod пробника: go 1.24
| собралось
2. ограничение ссылается на сам ограничиваемый тип
обещано: Go 1.26
go.mod пробника: go 1.26
| собралось
3. у метода свои параметры типа
обещано: Go 1.27
go.mod пробника: go 1.27
| собралось
4. у МЕТОДА ИНТЕРФЕЙСА параметров типа нет и в 1.27
обещано: нигде: это граница, которую 1.27 не сдвинул
go.mod пробника: go 1.27
| # probe
| ./main.go:4:7: interface method must have no type parameters
| ./main.go:4:27: undefined: F
Обратите внимание на случай 4: он собирается тем же toolchain, что и случай 3, и всё равно отказан. Про эту границу — ниже.
Псевдоним с параметрами — то, чего до 1.24 не было вовсе:
type Pair[A, B any] = struct {
First A
Second B
}
p := Pair[int, string]{First: 1, Second: "s"} // собирается на go1.24.7В 1.26 сняли запрет, из-за которого ограничение нельзя было замкнуть на себя:
The restriction that a generic type may not refer to itself in its type
parameter list has been lifted. It is now possible to specify type constraints
that refer to the generic type being constrained.
Ограничение, по которому обобщённый тип не может ссылаться на самого себя в списке своих параметров типа, снято. Теперь можно задавать ограничения типов, ссылающиеся на сам ограничиваемый обобщённый тип.
Пример оттуда же — ограничение «тип, умеющий складываться сам с собой»:
type Adder[A Adder[A]] interface {
Add(A) A
}
func algo[A Adder[A]](x, y A) A {
return x.Add(y)
}А в 1.27 появилось то, чего у дженериков в Go не было с самого начала, — собственные параметры типа у метода:
Go 1.27 now supports generic methods: a method declaration may declare its own
type parameters.
Go 1.27 теперь поддерживает обобщённые методы: объявление метода может объявлять собственные параметры типа.
Важна и вторая половина того же абзаца — граница, которую 1.27 не сдвинул:
Note that methods of interfaces may not declare type parameters nor can
interface methods be implemented by generic methods.
Заметьте, что методы интерфейсов не могут объявлять параметры типа, равно как и методы интерфейсов не могут быть реализованы обобщёнными методами.
Это не оговорка, а как раз тот случай 4 из прогона выше: метод интерфейса с собственным параметром типа компилятор 1.27 отказывается принимать, и отказ дословно такой —
./main.go:4:7: interface method must have no type parameters
То есть на выбор «дженерик или интерфейс», о котором вся вторая половина статьи, 1.27 влияет не так, как кажется на первый взгляд: он расширяет то, что можно выразить у конкретного типа, и ровно ничего не меняет в интерфейсах. Значит, и противопоставление, ради которого делался замер цены вызова, осталось тем же самым.
Там же расширен и вывод типов, которому посвящён отдельный раздел выше:
Function type inference has been generalized to apply in all contexts where a
generic function is assigned to a variable of (or converted to) a matching
function type.
Вывод типов функций обобщён и применяется теперь во всех контекстах, где обобщённая функция присваивается переменной подходящего функционального типа (или приводится к нему).
Тут напрашивается вывод, что раздел про вывод типов устарел, — и он неверен.
Расширение 1.27 касается присваивания обобщённой ФУНКЦИИ переменной
функционального типа; это случай, который и на 1.24.7 работал. Ни один из пяти
отказов, приведённых в том разделе, к нему не относится: прогон inference.go
на 1.27 даёт их дословно теми же, включая тот, где тип не выводится из цели
присваивания результата.
Что из проверенного на go1.24.7 изменилось на 1.27
Ничего. Это стоит показать по частям, потому что «ничего» — тоже результат, и проверяется он по-разному для каждого слоя.
Опыт с формами кода — совпал побайтово. shapes.go на 1.27 даёт ту же
таблицу символов: одиннадцать типов под any — шесть тел, под comparable —
восемь, три указателя под Stringer — одно, объединение — три. Файлы прогонов
лежат рядом (runs/shapes.txt и runs/shapes-go127.txt) и различаются одной
строкой с номером версии. То есть группировка форм, которую спецификация не
обещает, пережила три выпуска подряд.
Отказы компилятора в разделе про ограничения — тоже совпали побайтово.
constraints.go на 1.27 печатает те же сообщения слово в слово.
Замеры времени — перекрываются во всех строках. Здесь нужна оговорка,
которая важнее самих чисел: сравнивать между собой можно только два прогона,
снятые на ОДНОЙ машине. Основные таблицы статьи сняты на одном экземпляре
машины, а пара «1.24.7 против 1.27» — на другом, и её числа с таблицами статьи
не сопоставляются вовсе. Внутри пары (runs/toolchain-go124.txt и
runs/toolchain-go127.txt) сопоставляется всё, и результат такой: в каждой
строке каждого блока диапазоны двух toolchain перекрываются. На этих нагрузках
1.27 не изменил ни времени, ни байтов, ни выделений.
А вот что эта пара сказала сверх заданного вопроса. На той машине блок 6 дал ×1,00: с запретом встраивания дженерик стоил РОВНО столько же, сколько прямой вызов, — и так на обоих toolchain. То есть там чтение адреса из словаря не стоило ничего измеримого, и всё отставание дженерика из блока 1 оказалось потерянным встраиванием, до последней наносекунды. На машине основных таблиц оставалось ещё 1,39. Складывать эти два наблюдения надо так: вклад встраивания — от четырёх пятых до всей разницы, вклад самой косвенности — от пятой части до нуля, и который из двух случаев ваш, решает машина, а не версия Go.
Три способа их сделать, и почему выбран третий
Отсюда и до конца статьи — второй слой: не язык, а решения одной конкретной реализации. Всё, что сказано ниже про формы, словари и число тел кода, спецификацией не обещано и в следующей версии Go может стать другим, не изменив в языке ничего.
Вопрос, оставленный в начале, звучал так: сколько машинных версий тела должен
породить компилятор из func Min[T Ordered](a, b T) T? Способов три, и у
каждого своя беда.
Специализировать под каждый тип (как шаблоны в C++). Код получается
идеальным: Min[int] — это две инструкции. Беда в объёме: тел столько,
сколько разных подстановок, и в большой программе это заметно и в размере
файла, и во времени сборки.
Не специализировать вовсе — сложить всё в один код, работающий со значениями через указатели и дескрипторы типов. Тело одно, зато каждое действие идёт через рантайм.
Промежуточный вариант, который и выбран: специализировать не под тип, а под форму. Форма — это то, чем тип выглядит для сборщика мусора:
The GC shape of a type means how that type appears to the allocator / garbage
collector. It is determined by its size, its required alignment, and which
parts of the type contain a pointer.
GC-форма типа — это то, каким тип выглядит для распределителя памяти и сборщика мусора. Она определяется размером, требуемым выравниванием и тем, какие части типа содержат указатель.
Типов много, форм мало. Всё, чего телу не хватает, чтобы работать с конкретным типом, передаётся отдельно:
The implementation of f will have an additional argument which is the pointer
to the dictionary structure.
У реализации f будет дополнительный аргумент — указатель на структуру словаря.
Дальше — что из этого выходит на практике, и всё это можно посчитать.
Сколько тел кода на самом деле
Числа здесь не выведены из документации, а сняты с готового двоичного файла.
Программа bench/gogenerics/shapes.go собирает подопытный пакет и читает
таблицу символов через go tool nm. Тела инстанциаций видны там прямо:
main.AnyBox[go.shape.*uint8]
main.AnyBox[go.shape.float64]
main.AnyBox[go.shape.int]
main.AnyBox[go.shape.int64]
main.AnyBox[go.shape.string]
main.AnyBox[go.shape.[2]int]
Шесть строк. А словарей рядом одиннадцать — по одному на каждый конкретный тип:
main..dict.AnyBox[*main.A]
main..dict.AnyBox[*main.B]
main..dict.AnyBox[*main.C]
main..dict.AnyBox[float64]
main..dict.AnyBox[main.Celsius]
...
Три указателя в одном теле — не совпадение и не оптимизация «на всякий случай». В компиляторе это одна функция и одно правило:
When a pointer type is used to instantiate a type parameter constrained by a
basic interface, we know the pointer's element type can't matter to the
generated code. In this case, we can use an arbitrary pointer type as the
shape type. (To match the non-unified frontend, we use *byte.)
Otherwise, we simply use the type's underlying type as its shape.
Когда указательный тип подставляется в параметр типа, ограниченный базовым интерфейсом, мы знаем, что тип элемента указателя не может влиять на порождаемый код. В этом случае в качестве формы можно взять произвольный указательный тип. (Чтобы совпадало с не-унифицированным фронтендом, берём `*byte`.) Иначе мы просто берём базовый тип как его форму.
byte — это псевдоним uint8, поэтому в таблице символов форма называется
*uint8. Из этих трёх предложений следует всё, что на картинке, — и в них уже
стоит то самое слово: basic interface.
Указатели сливаются, если ограничение — базовый интерфейс. Слово
«базовый» здесь не эпитет, а термин спецификации: базовый интерфейс — тот, чьё
множество типов задано только методами. any — базовый (методов ноль),
Stringer — базовый (один), интерфейс из двух методов — тоже. comparable
базовым не является: в нём условие на сам тип. Поэтому под comparable те же
одиннадцать типов дают не шесть тел, а восемь: группа указателей распадается,
всё остальное остаётся как было.
Три ограничения — это три точки, и по трём точкам легко провести неверную линию: «дело в наличии методов» объясняет их ровно так же. Развести две формулировки может только ограничение, в котором методы есть, а базовым оно не является. Поэтому в подопытной программе их шесть:
| ограничение | базовый? | типов | тел кода |
|---|---|---|---|
any | да, методов ноль | 11 | 6 |
Stringer | да, один метод | 3 | 1 |
TwoMethods | да, два метода | 3 | 1 |
comparable | нет | 11 | 8 |
interface{ *A | *B | *C } | нет: объединение типов | 3 | 3 |
interface{ comparable; String() string } | нет, хотя метод есть | 3 | 3 |
Последняя строка и есть ответ: методы в ограничении есть, а слияния нет.
Значит, дело не в них, а в том, задано ли ограничение только ими, — ровно как
и написано в shapify. Числа сняты прогоном bench/gogenerics/shapes.go,
который не просто печатает таблицу, а падает, если правило разойдётся с
таблицей символов хотя бы на одном типе.
Именованный тип берёт форму базового. type Celsius float64 не даёт
нового тела: его форма — float64. Отсюда практическое: заводить именованные
типы поверх одного базового можно сколько угодно, на объём кода это не влияет.
Скаляры разных базовых типов не сливаются. int64 и float64 — по восемь
байт, выравнивание одинаковое, указателей внутри нет. Для сборщика мусора это
одна форма. Для компилятора — две.
Легко принять это за недоделку, и это была бы ошибка: разведены они намеренно. Документ, описывающий, что реализовано на самом деле, говорит прямо:
fundamentally different built-in types such as int and float64 are never in
the same gcshape
принципиально разные встроенные типы, такие как int и float64, никогда не оказываются в одной GC-форме
Причина в словаре: у int и float64 разные операции, и в словарь пришлось бы
класть разные реализации сложения. Даже int16 и int32 разведены — у них
по-разному работают сдвиги.
А вот где реализация действительно расходится с документом — это указатели, и в другую сторону, чем можно подумать. Правило там записано без всяких условий:
Two concrete types are in the same gcshape grouping if and only if they have
the same underlying type or they are both pointer types.
Два конкретных типа находятся в одной группе GC-форм тогда и только тогда, когда у них одинаковый базовый тип или когда оба они — указательные типы.
or they are both pointer types
(или когда оба они — указательные типы) — любые два указателя, без оговорок. А
компилятор сливает их только под базовым интерфейсом: под
comparable у каждого своя форма. Это ровно то, что видно на картинке, если
переключить ограничение, — и то же самое стоит первым пунктом в заметке
авторов shapify о том, чего они ещё не сделали:
collapsing all pointer-shaped types into a common type
слияние всех типов указательной формы в общий тип
То есть здесь реализация не грубее документа, а тоньше.
Что это значит для вызова метода
Вот тут интуиция подводит систематически. «Заменю интерфейс на дженерик и получу прямой вызов» — правка, которую делают ради скорости и которая скорости не даёт.
Логика простая, и она вся уже на предыдущей картинке. Раз у Named[T Stringer]
все указатели делят одно тело, это тело не знает, чей метод звать. Значит,
подставить адрес (*user).Name в код при сборке нельзя. Адрес лежит в словаре,
и вызов идёт через него — косвенно.
Замерено на тысяче вызовов, семь чередующихся раундов, в столбце — диапазон по раундам:
1. ВЫЗОВ МЕТОДА У ТЫСЯЧИ ОБЪЕКТОВ
----------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
напрямую, []*user 321-328 0 0 x1.00
дженерик, [T Namer] 1342-1462 0 0 x4.18
интерфейс, []Namer 2185-2243 0 0 x6.81
Дженерик не даёт скорости прямого вызова — это главное, и это не меняется от
машины к машине. А вот дальше начинается место, где легко сказать лишнее, и
статья его однажды уже сказала. Прежняя версия публиковала здесь 1 805 против
1 844 и делала вывод «дженерик даёт скорость интерфейса, разница 2 %». На
экземпляре машины, где снят прогон выше, этой двухпроцентной близости нет
вовсе: дженерик оказался в полтора раза быстрее интерфейса. Тот же исходник,
тот же go1.24.7, та же модель процессора в cpu: — и всё остальное в статье
при этом стало быстрее на 10–40 %, а интерфейсный вызов один подорожал.
Почему — этот замер не устанавливает; устанавливает он другое, и это стоит
унести с собой: цена косвенного вызова — самое непереносимое число во всей
статье, и строить на отношении «дженерик к интерфейсу» выбор в коде нельзя.
Зато пятикратное отставание от прямого вызова разложить на слагаемые можно, и теперь оно разложено. Прежде здесь стояло предположение: «скорее всего, дело не только в косвенности — прямой вызов компилятор ещё и встраивает». Проверка предположения стоит одной директивы.
Сколько из четырёхкратной разницы — потерянное встраивание
//go:noinline на методе убирает у прямого пути ровно одно преимущество:
теперь он тоже делает настоящий вызов. Всё прочее — тип, обход, объём работы —
то же самое:
6. ТО ЖЕ, НО ВСТРАИВАНИЕ НА ПРЯМОМ ПУТИ ЗАПРЕЩЕНО
----------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
напрямую, //go:noinline 1143-1198 0 0 x1.00
дженерик, [T Namer] 1585-1629 0 0 x1.39
интерфейс, []Namer 2198-2253 0 0 x1.92
Читается это так. Прямой вызов, лишённый встраивания, подорожал с 321 до 1 143 — то есть встраивание стоило около 822 нс на тысячу вызовов. Всё отставание дженерика от прямого вызова в блоке 1 — это 1 342 − 321 ≈ 1 021 нс. Значит, на встраивание приходится около четырёх пятых разницы, а на саму косвенность — оставшаяся пятая: как только прямой вызов действительно делается, дженерик отстаёт от него не в четыре раза, а в 1,39.
Контроль, по которому этот блок проверяется: интерфейсная строка. Интерфейсный вызов и так не встраивался, запрет у него ничего не отнимает, и его число обязано совпасть с блоком 1. Совпало — 2 185–2 243 против 2 198–2 253.
И сколько из отставания интерфейса — вообще не диспетчеризация
Есть ещё одно молчаливое допущение, которое блоки 1 и 6 делают вместе: будто у
трёх строк всё, кроме способа вызова, одинаково. Не одинаково. []*user — это
тысяча машинных слов, []Namer — тысяча пар «дескриптор типа, указатель», то
есть вдвое больше данных на тот же обход. Отделяется это тем, что метод не
зовётся вовсе: обе строки читают поле напрямую, у интерфейсной стороны через
утверждение типа.
7. СКОЛЬКО ИЗ ОТСТАВАНИЯ ИНТЕРФЕЙСА — ВООБЩЕ НЕ ДИСПЕТЧЕРИЗАЦИЯ
----------------------------------------------------------------------
нс/оп Б/оп выдел. кратно
чтение поля через []*user 317-348 0 0 x1.00
чтение поля через []Namer 539-564 0 0 x1.70
Раскладка данных сама по себе стоит около 222 нс. Интерфейс отстаёт от честного прямого вызова на 2 198 − 1 143 ≈ 1 055 нс, и пятая часть этого — не диспетчеризация, а лишние восемь килобайт, которые процессор тащит через кэш.
Итог трёх блоков — не одно число, а порядок слагаемых: встраивание, диспетчеризация, раскладка данных, именно в этом порядке по величине. И проектный документ обе первые цены называет заранее, среди недостатков выбранного подхода: более осторожный анализ убегания и потеря встраивания там, где вызов метода не разрешается при компиляции.
Где дженерик не стоит ничего
Из предыдущего раздела напрашивается вывод «дженерики медленные». Он неверен, и вот почему.
Переключите вкладки. Первая — та, что выше: четырёхкратное отставание. А «чтение из среза», «построение среза» и «одно сравнение» — ноль разницы там, где ноль можно показать (в скобках диапазон по семи раундам):
| что делаем | руками | дженерик |
|---|---|---|
прочитать тысячу int64 | 307–312 нс | 308–319 нс |
| построить срез из тысячи | 3 303–3 598 нс, 8 192 б, 1 выделение | 3 290–3 557 нс, 8 192 б, 1 выделение |
| одно сравнение | 0,4426–0,4694 нс | 0,4523–0,4941 нс |
Байты и выделения совпадают до единицы, а диапазоны времён перекрываются — значит разницы нет. Различает эти случаи не размер данных и не сложность кода. Различает одно: есть ли в ограничении методы.
[T ~int64],[T ordered]— методов нет. Тело компилируется под формуint64, и в машинном коде от дженерика не остаётся ничего.[T Namer]— методы есть. Тело общее на все указатели, адрес из словаря.
Это и есть правило, которое стоит унести из статьи. Не «дженерики быстрые» и не «дженерики медленные», а: посмотрите на ограничение.
Настоящая цена, которую платят чаще всего
Она не в дженериках. Она в том, чем дженерики заменяют:
| что строим | нс | байт | выделений |
|---|---|---|---|
[]int64 | 3 303–3 598 | 8 192 | 1 |
дженерик []T | 3 290–3 557 | 8 192 | 1 |
[]any | 16 114–17 002 | 24 384 | 1 001 |
В этом замере (go1.24.7, linux/amd64) интерфейсное значение занимает два
машинных слова — дескриптор типа и указатель, — а сохранённые int64
дополнительно уезжают в кучу по одному на элемент. Арифметика сходится: 16 384
на сам срез плюс 8 000 на тысячу коробок — это и есть 24 384 байта и 1 001
выделение. Вот от чего дженерик действительно спасает, и спасает в разы, а не
на проценты.
Обе цифры — наблюдение этой сборки и этой нагрузки, а не контракт any.
Ширина интерфейсного значения зависит от архитектуры, а уедет ли значение в
кучу, решает не сам факт упаковки, а анализ убегания конкретного кода и
конкретной версии компилятора. Здесь значения хранятся в срезе и переживают
вызов — поэтому уезжают. В блоке про одно сравнение то же преобразование в
any не даёт ни одного выделения: значение не покидает функцию, и вариант
через any стоит 0,9188–0,982 нс против 0,4426–0,4694 — вдвое дороже, но без
единого выделения.
Так что «any всегда выделяет память» — неправда, и правило «выделяет, когда
переживает вызов» тоже слишком коротко: переживание — самый частый повод для
убегания, но не единственный.
Сортировка: случай, где спор уже закончен
| чем сортируем | нс | байт | выделений |
|---|---|---|---|
slices.Sort | 12 804–13 565 | 0 | 0 |
sort.Ints | 12 814–13 259 | 0 | 0 |
sort.Sort(sort.IntSlice(x)) | 62 112–63 593 | 24 | 1 |
sort.Slice(x, func(i, j int) bool) | 50 244–55 476 | 56 | 2 |
Вторая строка выглядит как сравнение дженерика со специализированной функцией
и им не является. С Go 1.22 sort.Ints — это буквально одна строка:
// Note: as of Go 1.22, this function simply calls [slices.Sort].
func Ints(x []int) { slices.Sort(x) }Совпадение первых двух строк проверяет повторяемость замера, а не превосходство дженерика. Но сам факт, что спецверсию заменили вызовом дженерика, говорит больше любого замера.
Настоящее сравнение — с двумя нижними строками, и там slices.Sort быстрее
старого интерфейсного пути в 4,8 раза на этой нагрузке, а пути с
замыканием — в 3,9. Из этих чисел нельзя сделать коэффициент «дженерики
быстрее интерфейсов во столько-то раз», и вот почему: сравниваются не две
языковые конструкции, а два разных API целиком. У slices.Sort ограничение
арифметическое (cmp.Ordered), методов в нём нет, элемент известен конкретным
типом — и сравнение компилируется в инструкцию. У старого пути на каждое
сравнение приходится косвенный вызов, а у sort.Sort их три (Len, Less,
Swap). Плюс это разные реализации алгоритма, написанные с разницей в
десятилетие. Разница в 4,8 раза — сумма всего этого разом, и разложить её на
слагаемые этот блок не пытается: как выглядит настоящее разложение, показано
выше, в блоках 1, 6 и 7.
И побочный результат, ради которого стоит посмотреть на две нижние строки
вместе: sort.Sort с IntSlice оказался медленнее sort.Slice с
замыканием — 62 112–63 593 против 50 244–55 476, диапазоны не перекрываются.
То есть «идиоматический» способ до дженериков был и самым медленным: на каждое
сравнение приходится три косвенных вызова (Len, Less, Swap) вместо
одного замыкания.
Что должно повториться, а что нет
Числа сняты на go1.24.7 linux/amd64, Intel Xeon 2,10 ГГц, GOMAXPROCS = 2.
Прогон, из которого взята каждая таблица выше, лежит целиком в
bench/gogenerics/runs/cost.txt; собирает его bench/gogenerics/cost.sh из
cost_test.go (блоки 1–5), noinline_test.go (блок 6) и layout_test.go
(блок 7). Таблицы символов — bench/gogenerics/shapes.go.
В разделах про вывод типов, тильду, ограничения и версии языка нет ни одного
замера времени: их скрипты собирают код и печатают ответ компилятора —
bench/gogenerics/inference.go, bench/gogenerics/tilde.go,
bench/gogenerics/constraints.go, bench/gogenerics/versions.go. Эти четыре
воспроизводятся слово в слово на той же версии Go — позиции в файле будут свои,
а текст сообщений тот же: он часть поведения компилятора, а не измерение.
Перепроверка на go1.27.0 лежит рядом, отдельными файлами:
runs/shapes-go127.txt, runs/versions-go127.txt и пара
runs/toolchain-go124.txt — runs/toolchain-go127.txt. Пара снята на другой
машине, чем таблицы статьи, и потому сопоставляется только сама с собой: это
и есть правило, ради которого она снята парой, а не одним прогоном.
Совпадения ваших чисел с этими ждать не надо — ждать надо совпадения соотношений внутри блока. И даже соотношений не всех: как показано в разделе про вызов метода, отношение «дженерик к интерфейсу» не пережило смены экземпляра машины, хотя обе стороны остались косвенными. Ждать стоит того, что косвенный вызов дороже прямого и что встраивание в этой разнице весит больше диспетчеризации.
Главное утверждение статьи, про число тел кода, секундомером не меряется: его даёт таблица символов. Но и оно не языковая гарантия — оно воспроизводится при том же исходнике, той же версии компилятора и той же целевой конфигурации; на другой паре GOOS/GOARCH или другой сборочной конфигурации проверять надо заново тем же прогоном.
Между версиями — не обязательно, и «не обязательно» здесь не отговорка: между
1.24.7 и 1.27.0 она совпала побайтово, но это проверено, а не обещано.
Группировка форм не обещана спецификацией, а
статья сама показывает, где текущий компилятор расходится с собственным
проектным документом; в TODO у shapify первым пунктом стоит слияние всех
указательных форм в одну. Сделают — и число тел на comparable изменится, не
изменив в языке ничего. Поэтому число тел кода — не константа, а результат
измерения, и на новой версии его перепроверяют тем же прогоном
bench/gogenerics/shapes.go.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Три слоя, и статья идёт по ним в этом порядке: язык (что дженерик выражает), компилятор (как это сделано в go1.24.7), замер (сколько это стоило на одной машине). Смешивать их — главная ошибка разговоров про дженерики.
- Язык. Ограничение задаёт множество типов, множество типов задаёт операции, разрешённые телу. Дженерик нужен там, где алгоритм один на семейство типов, где контейнеру всё равно, что внутри, и — главное — где надо сохранить связь между типом входа и типом выхода: этого интерфейсом не выразить вовсе. Там, где у каждого типа своё поведение и код зовёт один метод, интерфейс честнее. Состояние языка на сегодня — Go 1.27 с обобщёнными методами: проверено компилятором, а не по примечаниям к выпуску. Все таблицы статьи сняты на go1.24.7; что из них меняется на 1.27 — тоже проверено, и ответ «ничего».
- Компилятор. Go мономорфизирует не по типам, а по GC-формам: одно тело кода на форму, и типов в форме бывает несколько. Различать их внутри общего тела помогает словарь, передаваемый скрытым аргументом.
- Одиннадцать типов — шесть тел. Снято с таблицы символов: три разных указателя дают одно тело,
Celsius,Metersиfloat64— тоже одно. int64иfloat64не сливаются, хотя оба по восемь байт, — и так задумано. А вот указатели документ реализации сливает безусловно, компилятор же только под базовым интерфейсом — ограничением, заданным одними методами: здесь реализация тоньше документа.- Слияние указателей на go1.24.7 решило одно свойство ограничения: базовый ли это интерфейс, то есть задано ли его множество типов только методами.
any,Stringerи интерфейс из двух методов — базовые, и указатели сливаются.comparable, объединение*A | *B | *Cи «Stringerплюсcomparable» — нет, и тогда у каждого указателя своё тело: те же одиннадцать типов подcomparableдают восемь. Проверено на шести ограничениях, а не на трёх: последнее из них методы содержит, но слияния не даёт. Спецификация ни форм, ни словарей, ни числа тел не определяет — это наблюдение за компилятором. - Замер. go1.24.7 linux/amd64, семь чередующихся раундов, прогон целиком в
bench/gogenerics/runs/cost.txt. - С ограничением-методом дженерик не убрал косвенный вызов. 1 342 нс против 321 у прямого — ×4,18; у интерфейса 2 185, то есть ×6,81. Вывод отсюда — не «дженерик равен интерфейсу» и не «дженерик быстрее интерфейса», а «замена интерфейса на дженерик сама по себе прямого вызова не даёт».
- Четырёхкратное отставание разложено на слагаемые, а не оставлено догадкой. Запретить встраивание на прямом пути — и он дорожает с 321 до 1 143: около четырёх пятых разницы приходится на потерянное встраивание, а не на косвенность. Дженерик отстаёт от честного прямого вызова всего в 1,39 раза. У интерфейса отставание 1,92, и пятая часть его — вообще не диспетчеризация, а вдвое более широкие данные: 8 КиБ указателей против 16 КиБ интерфейсных значений. На другом экземпляре машины вклад косвенности вышел нулевым, а встраивания — стопроцентным: порядок слагаемых переносится, сами доли — нет.
- 1.27 в этих нагрузках не изменил ничего. Таблица символов совпала побайтово, отказы компилятора — слово в слово, а в паре прогонов на одной машине диапазоны двух toolchain перекрылись в каждой строке каждого блока.
- Там, где методов в ограничении нет, дженерик не стоил ничего ни в одном из трёх замеров — чтение, построение среза, одно сравнение: те же байты, те же выделения, что руками.
[]any— вот где платят: 1 001 выделение против одного и 24 384 байта против 8 192.
На самом деле
- Не порождает. Замерено по таблице символов готового двоичного файла: одиннадцать разных типов, подставленных в одну функцию, дали шесть тел кода, а три разных указателя — одно. Компилятор специализирует не под тип, а под GC-форму: The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer. Полная специализация («стенсилинг») была одним из рассмотренных вариантов; выбран промежуточный.
- В спецификации об этом нет ни слова. Ни «GC-формы», ни словари, ни число порождённых тел языком не определяются вовсе — это решение одной реализации, и статья показывает, где текущий компилятор расходится даже с собственным проектным документом: тот сливает любые два указателя безусловно, компилятор — только под ограничением из одних методов. Проверять такое надо прогоном, а не чтением: между go1.24.7 и go1.27.0 таблица символов совпала побайтово, но это проверено, а не обещано. В TODO у
shapifyпервым пунктом стоит слияние всех указательных форм в одну; сделают — и число тел подcomparableизменится, не изменив в языке ничего. - Перепроверено, и не устарели. Опыт с формами кода на 1.27 дал ту же таблицу символов побайтово; отказы компилятора в разделе про ограничения — те же слово в слово; пять отказов в разделе про вывод типов — тоже, включая тот, который расширение вывода в 1.27, казалось бы, должно было отменить (оно про присваивание обобщённой функции переменной функционального типа, а это работало и в 1.24.7). Замеры времени повторены парой прогонов на одной машине: диапазоны двух toolchain перекрылись в каждой строке каждого блока. И само по себе появление обобщённых методов противопоставление «дженерик или интерфейс» не сдвигает: у методов ИНТЕРФЕЙСА параметров типа нет и в 1.27 — компилятор отвечает
interface method must have no type parameters. - Ровно наоборот, если в ограничении есть методы. Раз все указатели делят одно тело, оно не знает, чей метод звать: адрес берётся из словаря, переданного скрытым аргументом, и вызов остаётся косвенным. Замерено на тысяче вызовов: прямой 321 нс, дженерик 1 342, интерфейс 2 185. Проектный документ эту цену называет заранее, среди недостатков подхода: более осторожный анализ убегания и потеря встраивания там, где вызов метода не разрешается при компиляции. Причём из четырёхкратного отставания дженерика бо́льшая часть — именно встраивание: запретите его на прямом пути, и тот подорожает с 321 до 1 143, а дженерик будет отставать уже в 1,39 раза, а не в 4,18.
- Не известно, и это самый непереносимый результат статьи. На прогоне, который лежит в репозитории сейчас, дженерик вышел 1 342 нс против 2 185 у интерфейса — в полтора раза дешевле. На прежнем прогоне той же статьи, том же исходнике и том же go1.24.7 они шли вровень: 1 805 против 1 844. Отличаются эти два пути ровно тем, ОТКУДА читается адрес метода: у дженерика из словаря вызова, у интерфейса из таблицы методов самого значения; плюс интерфейсный срез вдвое шире — 16 КиБ пар против 8 КиБ указателей, и одно это стоит около 222 нс. Переносится отсюда одно: оба вызова остаются косвенными. Отношение между ними — не переносится.
- Не всегда, и разница огромна. Если в ограничении нет методов (
~int64,cmp.Ordered), тело компилируется под конкретную форму, и в машинном коде от дженерика не остаётся ничего. Замерено: прочитать тысячу int64 — 307 нс руками против 308 дженериком; построить срез — 3 303 нс / 8 192 б / 1 выделение против 3 290 / 8 192 / 1; одно сравнение — 0,4426 против 0,4523 нс. Байты и выделения совпадают точно. Различает случаи не размер данных и не сложность кода, а одно: есть ли в ограничении методы. Оговорка честности ради: замерены три случая на одном типе элемента и одной длине — этого хватает, чтобы опровергнуть «всегда», и не хватает, чтобы утверждать «никогда». - Для программиста да, а на go1.24.7 от него зависит ещё и то, сколько тел кода породит компилятор, — и в проверенных случаях зависело по одному признаку: является ли ограничение НАБОРОМ МЕТОДОВ. Спецификация ни форм, ни словарей, ни числа тел не определяет вовсе, так что это наблюдение за компилятором, а не правило языка.
anyиStringerявляются, поэтому указатели сливаются в одну форму.comparableне является, поэтому у каждого указателя своё тело: те же одиннадцать типов дают восемь тел вместо шести. Большеcomparableне меняет ничего — вся разница в распавшейся группе указателей. - Не добавит: форма именованного типа — это форма его базового типа.
type Celsius float64иtype Meters float64попадают в то же тело, что иfloat64, — замерено, все три в одной строке таблицы символов. В этом опыте именованные типы с одним и тем же базовым отдельных тел для исследованной функции не породили; переносить это на произвольную функцию и произвольную версию компилятора без проверки нельзя. А вот что НЕ сливается — так этоint64иfloat64, хотя оба по восемь байт. И это не недоделка: документ реализации разводит их намеренно — fundamentally different built-in types such as int and float64 are never in the same gcshape, потому что у них разные операции и в словарь пришлось бы класть разные реализации сложения. Расходится с документом другое: там любые два указателя объявлены одной формой безусловно, а компилятор сливает их только под ограничением-набором методов. - Она в том, чем дженерики заменяют. Построить срез из тысячи значений:
[]int64— 8 192 байта и одно выделение, дженерик[]T— ровно столько же,[]any— 24 384 байта и 1 001 выделение. Срез из пар «дескриптор типа, указатель на значение», по 16 байт на элемент, плюс отдельная коробка в куче на каждое значение: 16 384 плюс 8 000 и есть 24 384. Дженерик спасает от этого в разы, а не на проценты. - Само преобразование в
anyкучи не требует. Куда ляжет значение, решает анализ убегания конкретного кода и конкретной версии компилятора: в замере одного сравнения вариант черезanyстоит 0,9188 нс против 0,4426 у написанного руками — дороже, но без единого выделения. Тысяча выделений появляется в другом замере, где значения складываются в срез и живут дальше. «Переживает вызов» — самый частый повод для убегания, но правило не в нём, а в анализе. - Они дают одинаковые числа, потому что это одна и та же функция. С Go 1.22 в стандартной библиотеке стоит
func Ints(x []int) { slices.Sort(x) }с пометкой авторов Note: as of Go 1.22, this function simply calls [slices.Sort]. Совпадение их чисел — проверка повторяемости замера. Настоящее сравнение — со старым интерфейсным путём:sort.Sort(sort.IntSlice(x))— 62 112 нс против 12 804, то есть на этой нагрузке в 4,8 раза. Коэффициента «дженерики против интерфейсов» отсюда не выходит: сравниваются два разных API целиком, разной эпохи, а разница включает и диспетчеризацию, и встраивание, и представление компаратора, и сам алгоритм.
Что разобрано
- Три слоя, которые в разговорах про дженерики слипаются
- Из чего состоит дженерик
- Что ограничение разрешает телу, а что запрещает
- Тильда: почему `~int64`, а не `int64`
- Что выводится, а что придётся написать руками
- Нужен ли здесь дженерик вообще
- Что менялось в самом языке
- Три способа их сделать, и почему выбран третий
- Сколько тел кода на самом деле
- Что это значит для вызова метода
- Где дженерик не стоит ничего
- Настоящая цена, которую платят чаще всего
- Сортировка: случай, где спор уже закончен
- Что должно повториться, а что нет
Расхожие заблуждения
Дженерики в Go — это шаблоны: компилятор порождает версию под каждый тип
Не порождает. Замерено по таблице символов готового двоичного файла: одиннадцать разных типов, подставленных в одну функцию, дали шесть тел кода, а три разных указателя — одно. Компилятор специализирует не под тип, а под GC-форму: The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer
(GC-форма типа — это то, каким тип выглядит для распределителя памяти и сборщика мусора. Она определяется размером, требуемым выравниванием и тем, какие части типа содержат указатель). Полная специализация («стенсилинг») была одним из рассмотренных вариантов; выбран промежуточный.
Число тел кода — свойство языка: раз Go так делает, так написано в спецификации
В спецификации об этом нет ни слова. Ни «GC-формы», ни словари, ни число порождённых тел языком не определяются вовсе — это решение одной реализации, и статья показывает, где текущий компилятор расходится даже с собственным проектным документом: тот сливает любые два указателя безусловно, компилятор — только под ограничением из одних методов. Проверять такое надо прогоном, а не чтением: между go1.24.7 и go1.27.0 таблица символов совпала побайтово, но это проверено, а не обещано. В TODO у shapify первым пунктом стоит слияние всех указательных форм в одну; сделают — и число тел под comparable изменится, не изменив в языке ничего.
После Go 1.27 с его обобщёнными методами замеры на 1.24.7 устарели
Перепроверено, и не устарели. Опыт с формами кода на 1.27 дал ту же таблицу символов побайтово; отказы компилятора в разделе про ограничения — те же слово в слово; пять отказов в разделе про вывод типов — тоже, включая тот, который расширение вывода в 1.27, казалось бы, должно было отменить (оно про присваивание обобщённой функции переменной функционального типа, а это работало и в 1.24.7). Замеры времени повторены парой прогонов на одной машине: диапазоны двух toolchain перекрылись в каждой строке каждого блока. И само по себе появление обобщённых методов противопоставление «дженерик или интерфейс» не сдвигает: у методов ИНТЕРФЕЙСА параметров типа нет и в 1.27 — компилятор отвечает interface method must have no type parameters.
Дженерик — это способ убрать интерфейс и получить прямой вызов
Ровно наоборот, если в ограничении есть методы. Раз все указатели делят одно тело, оно не знает, чей метод звать: адрес берётся из словаря, переданного скрытым аргументом, и вызов остаётся косвенным. Замерено на тысяче вызовов: прямой 321 нс, дженерик 1 342, интерфейс 2 185. Проектный документ эту цену называет заранее, среди недостатков подхода: более осторожный анализ убегания и потеря встраивания там, где вызов метода не разрешается при компиляции. Причём из четырёхкратного отставания дженерика бо́льшая часть — именно встраивание: запретите его на прямом пути, и тот подорожает с 321 до 1 143, а дженерик будет отставать уже в 1,39 раза, а не в 4,18.
Зато известно, насколько дженерик быстрее интерфейса
Не известно, и это самый непереносимый результат статьи. На прогоне, который лежит в репозитории сейчас, дженерик вышел 1 342 нс против 2 185 у интерфейса — в полтора раза дешевле. На прежнем прогоне той же статьи, том же исходнике и том же go1.24.7 они шли вровень: 1 805 против 1 844. Отличаются эти два пути ровно тем, ОТКУДА читается адрес метода: у дженерика из словаря вызова, у интерфейса из таблицы методов самого значения; плюс интерфейсный срез вдвое шире — 16 КиБ пар против 8 КиБ указателей, и одно это стоит около 222 нс. Переносится отсюда одно: оба вызова остаются косвенными. Отношение между ними — не переносится.
Дженерик всегда что-то стоит
Не всегда, и разница огромна. Если в ограничении нет методов (~int64, cmp.Ordered), тело компилируется под конкретную форму, и в машинном коде от дженерика не остаётся ничего. Замерено: прочитать тысячу int64 — 307 нс руками против 308 дженериком; построить срез — 3 303 нс / 8 192 б / 1 выделение против 3 290 / 8 192 / 1; одно сравнение — 0,4426 против 0,4523 нс. Байты и выделения совпадают точно. Различает случаи не размер данных и не сложность кода, а одно: есть ли в ограничении методы. Оговорка честности ради: замерены три случая на одном типе элемента и одной длине — этого хватает, чтобы опровергнуть «всегда», и не хватает, чтобы утверждать «никогда».
Ограничение — это про то, какие типы разрешены
Для программиста да, а на go1.24.7 от него зависит ещё и то, сколько тел кода породит компилятор, — и в проверенных случаях зависело по одному признаку: является ли ограничение НАБОРОМ МЕТОДОВ. Спецификация ни форм, ни словарей, ни числа тел не определяет вовсе, так что это наблюдение за компилятором, а не правило языка. any и Stringer являются, поэтому указатели сливаются в одну форму. comparable не является, поэтому у каждого указателя своё тело: те же одиннадцать типов дают восемь тел вместо шести. Больше comparable не меняет ничего — вся разница в распавшейся группе указателей.
Именованный тип поверх базового добавит компилятору работы
Не добавит: форма именованного типа — это форма его базового типа. type Celsius float64 и type Meters float64 попадают в то же тело, что и float64, — замерено, все три в одной строке таблицы символов. В этом опыте именованные типы с одним и тем же базовым отдельных тел для исследованной функции не породили; переносить это на произвольную функцию и произвольную версию компилятора без проверки нельзя. А вот что НЕ сливается — так это int64 и float64, хотя оба по восемь байт. И это не недоделка: документ реализации разводит их намеренно — fundamentally different built-in types such as int and float64 are never in the same gcshape
(принципиально разные встроенные типы, такие как int и float64, никогда не оказываются в одной GC-форме), потому что у них разные операции и в словарь пришлось бы класть разные реализации сложения. Расходится с документом другое: там любые два указателя объявлены одной формой безусловно, а компилятор сливает их только под ограничением-набором методов.
Настоящая цена обобщённого кода — в дженериках
Она в том, чем дженерики заменяют. Построить срез из тысячи значений: []int64 — 8 192 байта и одно выделение, дженерик []T — ровно столько же, []any — 24 384 байта и 1 001 выделение. Срез из пар «дескриптор типа, указатель на значение», по 16 байт на элемент, плюс отдельная коробка в куче на каждое значение: 16 384 плюс 8 000 и есть 24 384. Дженерик спасает от этого в разы, а не на проценты.
Упаковка в any всегда выделяет память
Само преобразование в any кучи не требует. Куда ляжет значение, решает анализ убегания конкретного кода и конкретной версии компилятора: в замере одного сравнения вариант через any стоит 0,9188 нс против 0,4426 у написанного руками — дороже, но без единого выделения. Тысяча выделений появляется в другом замере, где значения складываются в срез и живут дальше. «Переживает вызов» — самый частый повод для убегания, но правило не в нём, а в анализе.
slices.Sort быстрее sort.Ints, потому что дженерик
Они дают одинаковые числа, потому что это одна и та же функция. С Go 1.22 в стандартной библиотеке стоит func Ints(x []int) { slices.Sort(x) } с пометкой авторов Note: as of Go 1.22, this function simply calls [slices.Sort]
(Примечание: начиная с Go 1.22 эта функция просто вызывает slices.Sort). Совпадение их чисел — проверка повторяемости замера. Настоящее сравнение — со старым интерфейсным путём: sort.Sort(sort.IntSlice(x)) — 62 112 нс против 12 804, то есть на этой нагрузке в 4,8 раза. Коэффициента «дженерики против интерфейсов» отсюда не выходит: сравниваются два разных API целиком, разной эпохи, а разница включает и диспетчеризацию, и встраивание, и представление компаратора, и сам алгоритм.
Проверьте себя
Одна обобщённая функция вызвана с одиннадцатью разными типами: тремя указателями, int, int64, float64, двумя именованными типами поверх float64, одним поверх int, string и [2]int. Сколько тел кода окажется в двоичном файле?
Источники и что читать дальше
12 ИСТОЧНИКОВ
- Generics implementation — GC Shape Stenciling (проектный документ)Официальная документация. Документ, где выбран способ реализации. Определение формы: «The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer» (GC-форма типа — это то, каким тип выглядит для распределителя памяти и сборщика мусора. Она определяется размером, требуемым выравниванием и тем, какие части типа содержат указатель). Про скрытый аргумент прямо: «The implementation of f will have an additional argument which is the pointer to the dictionary structure» (У реализации f будет дополнительный аргумент — указатель на структуру словаря). Там же названа и цена подхода: он может оказаться медленнее полной специализации из-за более осторожного анализа убегания и из-за того, что вызовы методов не разрешаются на этапе компиляции и потому не встраиваются.https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-gcshape.md
- Generics implementation — Dictionaries (проектный документ)Официальная документация. Что лежит в словаре: дескрипторы подставленных типов, дескрипторы производных типов, подсловари для вызовов других дженериков, вспомогательные методы для операций над обобщёнными типами, разметка кадра стека и карты указателей. Формулировка про методы: «the dictionary should contain methods that operate on the generic types» (словарь должен содержать методы, работающие с обобщёнными типами). Из этого и растёт косвенность вызова, измеренная в статье.https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-dictionaries.md
- cmd/compile/internal/noder/reader.go — функция shapifyИсходный код Go. Место, где тип превращается в форму, и единственное правило, которое для этого нужно: «When a pointer type is used to instantiate a type parameter constrained by a basic interface, we know the pointer's element type can't matter to the generated code. In this case, we can use an arbitrary pointer type as the shape type. (To match the non-unified frontend, we use `*byte`.)» (Когда указательный тип подставляется в параметр типа, ограниченный базовым интерфейсом, мы знаем, что тип элемента указателя не может влиять на порождаемый код. В этом случае в качестве формы можно взять произвольный указательный тип. (Чтобы совпадало с не-унифицированным фронтендом, берём `*byte`.)), и следом: «Otherwise, we simply use the type's underlying type as its shape» (Иначе мы просто берём базовый тип как его форму). Там же стоит TODO о том, чего реализация ещё не делает, и первым пунктом в нём — ровно то, что документ реализации считает уже сделанным: «collapsing all pointer-shaped types into a common type» (слияние всех типов указательной формы в общий тип).https://go.dev/src/cmd/compile/internal/noder/reader.go
- Generics implementation — Dictionaries (Go 1.18): что реализовано на самом делеОфициальная документация. Документ, на который сам gcshape-документ ссылается как на более подробное и актуальное описание реализации. Правило группировки записано там одной фразой: «Two concrete types are in the same gcshape grouping if and only if they have the same underlying type or they are both pointer types» (Два конкретных типа находятся в одной группе GC-форм тогда и только тогда, когда у них одинаковый базовый тип или когда оба они — указательные типы). И там же объяснено, почему скаляры разведены намеренно: «fundamentally different built-in types such as `int` and `float64` are never in the same gcshape» (принципиально разные встроенные типы, такие как int и float64, никогда не оказываются в одной GC-форме) — у них разные операции, и в словарь пришлось бы класть разные реализации. Даже `int16` и `int32` разведены из-за сдвигов. Сравнение этого правила с замером и даёт единственное найденное расхождение: указатели документ сливает безусловно, компилятор — только под базовым интерфейсом, то есть ограничением, заданным одними методами.https://go.googlesource.com/proposal/+/master/design/generics-implementation-dictionaries-go1.18.md
- Спецификация Go — Type parameters, Type constraints, General interfacesОфициальная документация. Определение параметра типа: «Within a type parameter list of a generic declaration, each name declares a type parameter» (Внутри списка параметров типа обобщённого объявления каждое имя объявляет параметр типа). И различение, от которого в реализации зависит форма: интерфейс, содержащий только методы, называется базовым — «Interfaces that are not basic may only be used as type constraints, or as elements of other interfaces used as constraints» (Интерфейсы, не являющиеся базовыми, могут использоваться только как ограничения типов или как элементы других интерфейсов, используемых в качестве ограничений).https://go.dev/ref/spec
- sort.Ints в Go 1.24Исходный код Go. Одна строка, объясняющая, почему сравнение «дженерик против специализированной функции» в блоке про сортировку ничего не сравнивает: `func Ints(x []int) { slices.Sort(x) }`. Рядом стоит пометка авторов: «Note: as of Go 1.22, this function simply calls [slices.Sort]» (Примечание: начиная с Go 1.22 эта функция просто вызывает [slices.Sort]). То есть стандартная библиотека уже заменила спецверсию вызовом дженерика.https://go.dev/src/sort/sort.go
- Спецификация Go — Type inferenceОфициальная документация. Правило, из-за которого дженерики на письме выглядят как обычные функции, и закрытый список случаев, где оно работает: «Type inference supports calls of generic functions and assignments of generic functions to (explicitly function-typed) variables» (Вывод типов поддерживает вызовы дженерик-функций и присваивания дженерик-функций переменным с явно указанным функциональным типом). Присваивания результата вызова в списке нет — отсюда самый частый из отказов компилятора, приведённых в статье. Там же приоритет, объясняющий, почему `Sum(i, 2.5)` при `var i int` падает, а `Sum(1, 2.5)` спокойно расширяется до `float64`: «Type inference gives precedence to type information obtained from typed operands before considering untyped constants» (Вывод типов отдаёт предпочтение информации о типе из типизированных операндов, прежде чем рассматривать нетипизированные константы).https://go.dev/ref/spec#Type_inference
- Спецификация Go — General interfaces (множества типов и тильда)Официальная документация. Откуда берётся смысл `~`: «The type set of a non-interface type term is the set consisting of just that type» (Множество типов термина, не являющегося интерфейсом, состоит только из этого типа) против «The type set of a term of the form ~T is the set of all types whose underlying type is T» (Множество типов термина вида ~T — это множество всех типов, базовый тип которых есть T). И запрет, из-за которого множество типов и набор методов нельзя сложить объединением: «A union (with more than one term) cannot contain the predeclared identifier comparable or interfaces that specify methods, or embed comparable or interfaces that specify methods» (Объединение более чем из одного термина не может содержать предобъявленный идентификатор comparable или интерфейсы, задающие методы, а также встраивать comparable или интерфейсы, задающие методы).https://go.dev/ref/spec#General_interfaces
- Type Parameters Proposal (design/43651)Официальная документация. Правило, по которому компилятор решает, какие операции разрешены над значением параметра типа: «The rule is that a generic function may use a value whose type is a type parameter in any way that is permitted by every member of the type set of the parameter's constraint» (Дженерик-функция может использовать значение, тип которого является параметром типа, любым способом, который разрешён каждым членом множества типов ограничения этого параметра). Из этой одной фразы механически следуют обе ошибки компиляции, приведённые в разделе про ограничения.https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
- Go 1.27 Release Notes — Changes to the languageОфициальная документация. Граница между состоянием языка и toolchain замера. Обобщённые методы: «Go 1.27 now supports generic methods: a method declaration may declare its own type parameters» (Go 1.27 теперь поддерживает обобщённые методы: объявление метода может объявлять собственные параметры типа). И то, чего эта возможность НЕ сдвинула, — из того же абзаца: «Note that methods of interfaces may not declare type parameters nor can interface methods be implemented by generic methods» (Заметьте, что методы интерфейсов не могут объявлять параметры типа, равно как и методы интерфейсов не могут быть реализованы обобщёнными методами). Там же про вывод типов: «Function type inference has been generalized to apply in all contexts where a generic function is assigned to a variable of (or converted to) a matching function type» (Вывод типов функций обобщён и применяется теперь во всех контекстах, где обобщённая функция присваивается переменной подходящего функционального типа или приводится к нему). Каждая из этих возможностей здесь не переписана из документа, а проверена компилятором: `
bench/gogenerics/versions.go` собирает четыре случая временным модулем и печатает ответ. Прогоны на обеих версиях — в `runs/versions-go124.txt` и `runs/versions-go127.txt`.https://go.dev/doc/go1.27 - Go 1.26 Release Notes — Changes to the languageОфициальная документация. Снятый запрет на самоссылку в ограничении: «The restriction that a generic type may not refer to itself in its type parameter list has been lifted. It is now possible to specify type constraints that refer to the generic type being constrained» (Ограничение, по которому обобщённый тип не может ссылаться на самого себя в списке своих параметров типа, снято. Теперь можно задавать ограничения типов, ссылающиеся на сам ограничиваемый обобщённый тип). Пример из примечаний — `type Adder[A Adder[A]] interface { Add(A) A }`. На go1.24.7, где сняты все числа статьи, такое объявление ещё не собирается.https://go.dev/doc/go1.26
- go.dev/blog — When To Use GenericsОфициальная документация. Совет авторов языка о том, когда дженерик не нужен: «Inversely, if the implementation is different for each type, then use an interface type and write different method implementations, don't use a type parameter» (Наоборот, если реализация различается для каждого типа, используйте интерфейсный тип и напишите разные реализации методов, а не параметр типа). В статье он соединён с замером цены: методы в ограничении — это интерфейс и по смыслу, и по цене.https://go.dev/blog/when-generics