Deep Engineering
Продвинутый·Опубликовано·60 МИН

Дженерики в Go: одиннадцать типов, шесть тел кода и метод, до которого всё равно идут через словарь

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

Полное техническое изложение

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.

Параметры типа бывают не только у функций:

GO
type Box[T any] struct {
    Value T
}
 
b := Box[string]{Value: "s"}

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

А сначала — вторая половина той строчки, cmp.Ordered.

Ограничение задаёт множество типов, а множество типов задаёт операции

Цепочка, из которой механически следует всё остальное:

  1. ограничение задаёт
  2. множество типов — какие T разрешены, — а оно задаёт
  3. операции, разрешённые телу функции.

Последняя стрелка — не метафора. Тело может делать со значением типа 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.

перевод

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

Type Parameters Proposal

Отсюда механическое следствие, которое видно на двух ошибках компиляции (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 или интерфейсы, задающие методы.

Спецификация Go — General interfaces

Совместить можно пересечением: interface { ~int; Name() string } — и тогда доступны и оператор, и метод. Проверено: работает.

Базовый интерфейс и общий: почему это не одно слово «интерфейс»

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

Проверяется это компилятором мгновенно:

GO
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.

перевод

Множество типов термина, не являющегося интерфейсом, состоит только из этого типа.

Спецификация Go — General interfaces

Тильда расширяет его до всех типов с таким базовым:

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):

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.

перевод

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

Спецификация Go — Type inference

Ключевое слово — «из контекста». Список контекстов в спецификации закрытый:

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.

перевод

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

go.dev/blog — When To Use Generics

Из этого и из цепочки «ограничение → множество типов → операции» получается короткий разбор случаев:

один и тот же АЛГОРИТМ для семейства типов
    (сортировка, 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 не было вовсе:

GO
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.

перевод

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

Go 1.26 Release Notes → Changes to the language

Пример оттуда же — ограничение «тип, умеющий складываться сам с собой»:

GO
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 теперь поддерживает обобщённые методы: объявление метода может объявлять собственные параметры типа.

Go 1.27 Release Notes → Changes to the language

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

Generics implementation — GC Shape Stenciling

Типов много, форм мало. Всё, чего телу не хватает, чтобы работать с конкретным типом, передаётся отдельно:

The implementation of f will have an additional argument which is the pointer to the dictionary structure.

перевод

У реализации f будет дополнительный аргумент — указатель на структуру словаря.

Generics implementation — GC Shape Stenciling

Дальше — что из этого выходит на практике, и всё это можно посчитать.

Сколько тел кода на самом деле

Числа здесь не выведены из документации, а сняты с готового двоичного файла. Программа 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`.) Иначе мы просто берём базовый тип как его форму.

cmd/compile/internal/noder/reader.go, функция shapify

byte — это псевдоним uint8, поэтому в таблице символов форма называется *uint8. Из этих трёх предложений следует всё, что на картинке, — и в них уже стоит то самое слово: basic interface.

Указатели сливаются, если ограничение — базовый интерфейс. Слово «базовый» здесь не эпитет, а термин спецификации: базовый интерфейс — тот, чьё множество типов задано только методами. any — базовый (методов ноль), Stringer — базовый (один), интерфейс из двух методов — тоже. comparable базовым не является: в нём условие на сам тип. Поэтому под comparable те же одиннадцать типов дают не шесть тел, а восемь: группа указателей распадается, всё остальное остаётся как было.

Три ограничения — это три точки, и по трём точкам легко провести неверную линию: «дело в наличии методов» объясняет их ровно так же. Развести две формулировки может только ограничение, в котором методы есть, а базовым оно не является. Поэтому в подопытной программе их шесть:

ограничениебазовый?типовтел кода
anyда, методов ноль116
Stringerда, один метод31
TwoMethodsда, два метода31
comparableнет118
interface{ *A | *B | *C }нет: объединение типов33
interface{ comparable; String() string }нет, хотя метод есть33

Последняя строка и есть ответ: методы в ограничении есть, а слияния нет. Значит, дело не в них, а в том, задано ли ограничение только ими, — ровно как и написано в 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-форме

Generics implementation — Dictionaries (Go 1.18)

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

Generics implementation — Dictionaries (Go 1.18)

or they are both pointer types (или когда оба они — указательные типы) — любые два указателя, без оговорок. А компилятор сливает их только под базовым интерфейсом: под comparable у каждого своя форма. Это ровно то, что видно на картинке, если переключить ограничение, — и то же самое стоит первым пунктом в заметке авторов shapify о том, чего они ещё не сделали:

collapsing all pointer-shaped types into a common type

перевод

слияние всех типов указательной формы в общий тип

cmd/compile/internal/noder/reader.go, TODO в shapify

То есть здесь реализация не грубее документа, а тоньше.

Что это значит для вызова метода

Вот тут интуиция подводит систематически. «Заменю интерфейс на дженерик и получу прямой вызов» — правка, которую делают ради скорости и которая скорости не даёт.

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

Итог трёх блоков — не одно число, а порядок слагаемых: встраивание, диспетчеризация, раскладка данных, именно в этом порядке по величине. И проектный документ обе первые цены называет заранее, среди недостатков выбранного подхода: более осторожный анализ убегания и потеря встраивания там, где вызов метода не разрешается при компиляции.

Где дженерик не стоит ничего

Из предыдущего раздела напрашивается вывод «дженерики медленные». Он неверен, и вот почему.

Переключите вкладки. Первая — та, что выше: четырёхкратное отставание. А «чтение из среза», «построение среза» и «одно сравнение» — ноль разницы там, где ноль можно показать (в скобках диапазон по семи раундам):

что делаемрукамидженерик
прочитать тысячу int64307–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] — методы есть. Тело общее на все указатели, адрес из словаря.

Это и есть правило, которое стоит унести из статьи. Не «дженерики быстрые» и не «дженерики медленные», а: посмотрите на ограничение.

Настоящая цена, которую платят чаще всего

Она не в дженериках. Она в том, чем дженерики заменяют:

что строимнсбайтвыделений
[]int643 303–3 5988 1921
дженерик []T3 290–3 5578 1921
[]any16 114–17 00224 3841 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.Sort12 804–13 56500
sort.Ints12 814–13 25900
sort.Sort(sort.IntSlice(x))62 112–63 593241
sort.Slice(x, func(i, j int) bool)50 244–55 476562

Вторая строка выглядит как сравнение дженерика со специализированной функцией и им не является. С Go 1.22 sort.Ints — это буквально одна строка:

GO
// 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.txtruns/toolchain-go127.txt. Пара снята на другой машине, чем таблицы статьи, и потому сопоставляется только сама с собой: это и есть правило, ради которого она снята парой, а не одним прогоном.

Совпадения ваших чисел с этими ждать не надо — ждать надо совпадения соотношений внутри блока. И даже соотношений не всех: как показано в разделе про вызов метода, отношение «дженерик к интерфейсу» не пережило смены экземпляра машины, хотя обе стороны остались косвенными. Ждать стоит того, что косвенный вызов дороже прямого и что встраивание в этой разнице весит больше диспетчеризации.

Главное утверждение статьи, про число тел кода, секундомером не меряется: его даёт таблица символов. Но и оно не языковая гарантия — оно воспроизводится при том же исходнике, той же версии компилятора и той же целевой конфигурации; на другой паре GOOS/GOARCH или другой сборочной конфигурации проверять надо заново тем же прогоном.

Между версиями — не обязательно, и «не обязательно» здесь не отговорка: между 1.24.7 и 1.27.0 она совпала побайтово, но это проверено, а не обещано. Группировка форм не обещана спецификацией, а статья сама показывает, где текущий компилятор расходится с собственным проектным документом; в TODO у shapify первым пунктом стоит слияние всех указательных форм в одну. Сделают — и число тел на comparable изменится, не изменив в языке ничего. Поэтому число тел кода — не константа, а результат измерения, и на новой версии его перепроверяют тем же прогоном bench/gogenerics/shapes.go.

Расхожие заблуждения

Утверждение

Дженерики в 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 целиком, разной эпохи, а разница включает и диспетчеризацию, и встраивание, и представление компаратора, и сам алгоритм.

Проверьте себя

Вопрос 1 из 5

Одна обобщённая функция вызвана с одиннадцатью разными типами: тремя указателями, int, int64, float64, двумя именованными типами поверх float64, одним поверх int, string и [2]int. Сколько тел кода окажется в двоичном файле?

Источники и что читать дальше

12 ИСТОЧНИКОВ

  1. 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
  2. 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
  3. 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
  4. 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
  5. Спецификация 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
  6. 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
  7. Спецификация 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
  8. Спецификация 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
  9. 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
  10. 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
  11. 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
  12. 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