MEASUREMENT
bench/gogenerics/shapes.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/go/typing/generics
- Run on
- go1.24.7 linux/amd64, Intel Xeon 2.10GHz, GOMAXPROCS = 2
- How to run it
go run bench/gogenerics/shapes.go # работает и из корня go run bench/gogenerics/inference.go go run bench/gogenerics/tilde.go go run bench/gogenerics/constraints.go go run bench/gogenerics/versions.go cd bench/gogenerics ROUNDS=7 ./cost.sh > runs/cost.txt
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для статьи «Дженерики в Go: одно тело на форму, словарь и цена метода»
| Файл | Что делает |
|---|---|
shapes.go |
считает, сколько тел кода компилятор порождает для дженерика: собирает shapesrc/ и читает таблицу символов через go tool nm. Ни одного замера времени |
shapesrc/main.go |
подопытная программа: три дженерика с разными ограничениями, инстанциированные одиннадцатью типами |
cost_test.go |
цена: вызов метода через ограничение, чтение из []T и []any, построение среза, сортировка, арифметика — блоки 1–5 |
noinline_test.go |
та же тройка вызовов с запретом встраивания на прямом пути: отделяет потерянное встраивание от диспетчеризации — блок 6 |
layout_test.go |
чтение поля без вызова метода через []*user и через []Namer: отделяет цену раскладки данных от диспетчеризации — блок 7 |
cost.sh |
гоняет весь набор чередующимися раундами и печатает диапазоны, а не одно число. Прогон записан в runs/cost.txt |
inference.go |
вывод типов: пять случаев, где он срабатывает, и пять, где нет; неудачные собираются отдельным модулем, печатается дословная ошибка компилятора. Ни одного замера времени |
tilde.go |
тильда в ограничении: ~float64 против float64; почему type Celsius float64 не подходит под второе. Ни одного замера времени |
constraints.go |
проектирование ограничения: набор типов против поведения, что каждое из них даёт телу и что запрещает. Ни одного замера времени |
versions.go |
какие возможности дженериков умеет запущенный toolchain: четыре случая собираются временным модулем, печатается дословный ответ компилятора. Ни одного замера времени |
Каталог — отдельный модуль Go, поэтому тесты запускаются из него:
go run bench/gogenerics/shapes.go # работает и из корня
go run bench/gogenerics/inference.go
go run bench/gogenerics/tilde.go
go run bench/gogenerics/constraints.go
go run bench/gogenerics/versions.go
cd bench/gogenerics
ROUNDS=7 ./cost.sh > runs/cost.txt
Чередующимися раундами, а не -count=N, и это не украшение. -count=N
повторяет каждый бенчмарк N раз ПОДРЯД, и все N попадают в один и тот же
участок времени; на виртуальной машине частота плавает минутами, и такой набор
показывает состояние машины, а не разброс замера. cost.sh гоняет весь набор
по кругу и печатает диапазон lo–hi по раундам: перекрываются — значит разницы
нет.
shapes.go помечен //go:build ignore — он package main, а рядом лежит тест
пакета gogenerics. Подопытная программа вынесена в подкаталог shapesrc/,
потому что её надо собирать отдельным двоичным файлом.
Как здесь появился go1.27, если его нечем скачать
Обычный путь — GOTOOLCHAIN=go1.27.0 go version — в этой среде не работает:
загрузка тулчейнов идёт через proxy.golang.org, а он в белый список сетевых
адресов не входит, и ответ приходит такой:
go: download go1.27.0: golang.org/toolchain@v0.0.1-go1.27.0.linux-amd64:
reading https://proxy.golang.org/... : 403 Forbidden
server response: Host not in allowlist: proxy.golang.org.
go.dev и dl.google.com закрыты так же. Зато открыт github.com, а исходники
Go лежат там же, где и всегда, — и для сборки Go нужен только сам Go, который
уже есть. Отсюда рецепт:
git clone --depth 1 --branch go1.27.0 https://github.com/golang/go.git /tmp/go127
cd /tmp/go127/src
GOROOT_BOOTSTRAP=/usr/local/go1.24.7 GOTOOLCHAIN=local ./make.bash
export PATH=/tmp/go127/bin:$PATH
make.bash, а не all.bash: второй прогоняет ещё и тесты самого Go, а они
здесь не нужны и идут долго. На двух ядрах сборка занимает около двадцати
минут. Проверка — go version.
Зачем GOTOOLCHAIN=local во всех прогонах. Без него go увидит в go.mod
пробника строку go 1.27 и полезет скачивать подходящий тулчейн — то есть
проверять будет не тот, которым запущен. С local он обязан работать тем, что
есть, и отказ на старой версии выглядит именно так, как увидит его читатель.
Что здесь важно прочитать правильно
Главное — не наносекунды. Оно в shapes.go: Go не мономорфизирует по
типам. На одиннадцать типов приходится шесть тел кода, а на три разных
указателя — одно. Это утверждение о том, что делает компилятор, и проверяется
оно таблицей символов, а не секундомером.
По времени сопоставляются только строки внутри одного блока cost_test.go.
В каждом блоке все строки дают одинаковый результат, отличается лишь способ.
Блоки друг с другом не сопоставляются: в блоках 1–4 работа идёт по тысяче
элементов, в блоке 5 — одно сравнение.
Главная ловушка темы. Дженерик стоит принципиально по-разному в
зависимости от того, каково ограничение. Арифметическое (~int64, ordered)
— тело компилируется под конкретную форму, и код выходит тот же, что руками.
Интерфейсное (Namer) — все указатели делят одно тело, адрес метода берётся
из словаря, вызов становится косвенным. Блок 1 и блок 5 меряют разные вещи, и
числа из них несопоставимы, хотя оба «про дженерики».
Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.10GHz, GOMAXPROCS = 2)
Сколько тел кода
Одни и те же одиннадцать типов подставлены и в AnyBox[T any], и в
Cmp[T comparable] — иначе сравнение двух ограничений было бы нечестным. В
Named[T Stringer] подставимы только три указателя: у остальных типов метода
String нет.
| функция | ограничение | подставлено типов | тел кода | словарей |
|---|---|---|---|---|
AnyBox |
any |
11 | 6 | 11 |
Named |
Stringer |
3 | 1 | 3 |
Cmp |
comparable |
11 | 8 | 11 |
Какие типы слились:
| форма | типы, попавшие в неё |
|---|---|
go.shape.*uint8 |
*main.A, *main.B, *main.C |
go.shape.float64 |
float64, main.Celsius, main.Meters |
go.shape.int |
int, main.Count |
go.shape.int64 |
int64 |
go.shape.string |
string |
go.shape.[2]int |
[2]int |
Три правила, и все три видны в этой таблице:
- Указатели сливаются, если ограничение — набор методов.
anyиStringerнаборы методов, поэтому*A,*B,*Cдают одно тело с формой*uint8.comparableнабором методов не является, поэтому у каждого указателя своя форма и своё тело. Большеcomparableне меняет НИЧЕГО: на тех же одиннадцати типах он даёт восемь тел вместо шести, и все два лишних — это распавшаяся группа указателей. - Именованный тип берёт форму базового.
Celsius,Metersиfloat64— одно тело. - Скаляры разных базовых типов НЕ сливаются.
int64иfloat64— по 8 байт, выравнивание одинаковое, указателей внутри нет; формы всё равно разные.
Третье правило легко принять за недоделку, и это ошибка: оно так задумано.
Документ реализации говорит прямо, что int и float64 никогда не
окажутся в одной форме — «fundamentally different built-in types such as int
and float64 are never in the same gcshape», — потому что у них разные
операции, и в словарь пришлось бы класть разные реализации сложения и сдвигов.
Даже 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.
«Or they are both pointer types» — то есть любые два указателя. Компилятор же
сливает их только под ограничением-набором методов: под comparable у каждого
указателя своя форма, и это ровно то, что видно в таблице выше. Здесь
реализация тоньше документа, и в TODO у shapify первым пунктом стоит
именно это: «collapsing all pointer-shaped types into a common type».
Цена, блок 1 — тысяча вызовов метода
| как вызывается метод | ns/op (lo–hi) | к прямому вызову |
|---|---|---|
напрямую, []*user |
321–328 | ×1,00 |
дженерик, [T Namer] |
1 342–1 462 | ×4,18 |
интерфейс, []Namer |
2 185–2 243 | ×6,81 |
Дженерик не даёт скорости прямого вызова. Причина видна в shapes.go: у
Named все три указателя делят одно тело, и внутри тела нельзя вызвать
(*user).Name напрямую — тело не знает, чей это указатель. Адрес метода лежит
в словаре.
А вот отношение дженерика к интерфейсу отсюда брать нельзя. Прежняя версия
этого файла публиковала 1 805 против 1 844 и делала вывод «разница 2 %». Тот
же исходник, тот же go1.24.7, та же строка cpu: — и на другом экземпляре
машины дженерик вышел в полтора раза дешевле интерфейса. Всё остальное в
наборе при этом стало быстрее на 10–40 %, а интерфейсный вызов один подорожал.
Причину замер не устанавливает. Устанавливает он границу: цена косвенного
вызова — самое непереносимое число в этом каталоге.
Блок 6 — то же, но встраивание на прямом пути запрещено
| как вызывается метод | ns/op (lo–hi) | к прямому вызову |
|---|---|---|
напрямую, //go:noinline |
1 143–1 198 | ×1,00 |
дженерик, [T Namer] |
1 585–1 629 | ×1,39 |
интерфейс, []Namer |
2 198–2 253 | ×1,92 |
Блок 1 отвечает сразу на два вопроса: «сколько стоит добраться до метода» и
«встроился ли он». //go:noinline убирает второй. Прямой вызов дорожает с 321
до 1 143 — то есть встраивание стоило около 822 нс из 1 021, на которые
дженерик отставал в блоке 1. На встраивание приходится около четырёх пятых
разницы, а на саму косвенность — оставшаяся пятая.
Контроль блока — интерфейсная строка: запрет у неё ничего не отнимает, и она обязана совпасть с блоком 1. Совпала.
Блок 7 — сколько из отставания интерфейса вообще не диспетчеризация
| как читаем поле | ns/op (lo–hi) | к первой строке |
|---|---|---|
через []*user |
317–348 | ×1,00 |
через []Namer |
539–564 | ×1,70 |
Метод не зовётся ни в одной строке: поле читается напрямую, у интерфейсной стороны через утверждение типа. Различаются только данные: тысяча машинных слов против тысячи пар «дескриптор, указатель», 8 КиБ против 16 КиБ. Раскладка сама по себе стоит около 222 нс — это пятая часть того, на что интерфейс отстаёт от честного прямого вызова (2 198 − 1 143 ≈ 1 055 нс).
Порядок слагаемых по величине: встраивание, диспетчеризация, раскладка данных.
Блок 2 — прочитать тысячу int64
| откуда читаем | ns/op |
|---|---|
[]int64 |
307–312 |
дженерик по []T, T ~int64 |
308–319 |
[]any с приведением |
541–572 |
Дженерик и конкретный тип неотличимы: диапазоны перекрываются. Ограничение арифметическое, тело компилируется под форму
int64, и получается тот же код.
Блок 3 — построить срез из тысячи значений
| что строим | ns/op | B/op | allocs/op |
|---|---|---|---|
[]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 |
Дженерик и конкретный тип снова неотличимы, вплоть до байта. А []any — это
срез из пар «дескриптор типа, указатель на значение», по 16 байт на элемент,
плюс отдельная коробка в куче на каждое значение. Арифметика сходится:
16 384 на сам срез плюс 8 000 на тысячу коробок — это и есть 24 384 байта
вместо 8 192, и 1 001 выделение вместо одного. Ширина в 16 байт — раскладка
этой сборки под linux/amd64, а не контракт any; уедут ли значения в кучу,
решает анализ убегания конкретного кода, и в блоке 5 то же преобразование не
даёт ни одного выделения.
Все три функции написаны одинаково — та же граница, тот же make, тот же
append, то же возвращаемое значение. Иначе сравнивалось бы не «дженерик
против конкретного типа», а «вызов функции против кода на месте».
Блок 4 — отсортировать тысячу чисел
| чем сортируем | ns/op | B/op | allocs/op |
|---|---|---|---|
slices.Sort (дженерик) |
12 804–13 565 | 0 | 0 |
sort.Ints |
12 814–13 259 | 0 | 0 |
sort.Sort(sort.IntSlice(...)) |
62 112–63 593 | 24 | 1 |
sort.Slice(..., func(i, j int) bool) |
50 244–55 476 | 56 | 2 |
Вторая строка не победа и не поражение: с Go 1.22 sort.Ints — это буквально
func Ints(x []int) { slices.Sort(x) }. Совпадение первых двух строк —
проверка повторяемости замера.
Настоящее сравнение — с двумя нижними. slices.Sort быстрее старого
интерфейсного пути в 4,8 раза и быстрее sort.Slice в 3,9. Коэффициента
«дженерики против интерфейсов» отсюда не выходит: сравниваются два разных API
целиком, разной эпохи, а разница включает диспетчеризацию, встраивание,
представление компаратора и сам алгоритм. Настоящее разложение на слагаемые —
в блоках 6 и 7, а не здесь.
И побочный результат, который стоит отдельного взгляда: sort.Sort с
IntSlice МЕДЛЕННЕЕ sort.Slice с замыканием — 62 112 против 50 244.
«Идиоматический» способ до дженериков оказался и самым медленным: на каждое
сравнение приходится три косвенных вызова (Len, Less, Swap) вместо
одного замыкания.
Блок 5 — одно сравнение
| как написано | ns/op |
|---|---|
руками, func minConcrete(a, b int) int |
0,4426–0,4694 |
дженерик, [T ordered] |
0,4523–0,4941 |
встроенный min |
0,4363–0,4672 |
через any с приведением |
0,9188–0,982 |
Три первых строки неотличимы. Для арифметического ограничения дженерик стоит ровно ноль.
Четвёртая строка НЕ показывает цену упаковки: minIface встраивается,
упакованное значение никуда не убегает, и анализ убегания оставляет его на
стеке — ноль выделений здесь настоящий. Цена упаковки появляется, когда
значение переживает вызов; такой случай — в блоке 3.
От прогона к прогону (три прогона по 1 с): блок 1 — 368,3–372,4 / 1 800–1 815 /
1 840–1 868; блок 2 — 369,0–414,8 / 375,6–378,1 / 723,3–809,0; блок 3 —
3 485–3 843 / 2 916–3 786 / 24 002–26 442; блок 4 — 14 479–14 543 /
14 374–14 421 / 68 059–69 633 / 58 843–60 341; блок 5 — 0,7201–0,7501 /
0,7205–0,7322 / 0,7178–0,7225 / 1,033–1,050. Столбцы B/op и allocs/op не
менялись.
Вывод типов (inference.go)
Первоисточник — спецификация Go, раздел Type inference. Три формулировки оттуда объясняют почти все неожиданности.
Что вообще значит «вывелось»:
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.
(пер.: «Вывод типов поддерживает вызовы дженерик-функций и присваивания дженерик-функций переменным (явно объявленного функционального типа)».)
Присваивания РЕЗУЛЬТАТА вызова в этом списке нет — отсюда случай 2 части 2.
И правило приоритета:
Type inference gives precedence to type information obtained from typed operands before considering untyped constants.
(пер.: «Вывод типов отдаёт предпочтение сведениям о типе, полученным от типизированных операндов, прежде чем рассматривать нетипизированные константы».)
Отсюда случай 3: Sum(i, 2.5) при i типа int даёт не float64, а ошибку.
Прогон целиком:
go version: go version go1.24.7 linux/amd64
── Часть 1: что выводится ──
1. Map(p, f) при p типа Point
выведено: S = main.Point, E = int (из ограничения S ~[]E)
значение: [2 4 6]
то же самое с явными параметрами: main.Point
2. частичный список Map[Point](...): main.Point
3. Sum(i, 2) при i типа int → T = int
Sum(1, 2.5) — обе константы нетипизированы → T = float64
Sum(1, 2) — обе целые → T = int
4. var f func([]int, func(int) int) []int = Map → func([]int, func(int) int) []int
f работает: [11 12]
5. Zero[int]() = 0 (без [int] не соберётся — см. случай 1 части 2)
── Часть 2: что НЕ выводится (дословные ошибки компилятора) ──
1. параметр типа только в результате
почему: аргументов нет — уравнений нет — выводить не из чего
| # probe
| ./main.go:9:10: in call to Zero, cannot infer T (declared at ./main.go:3:11)
2. тип выведенного результата не берётся из цели присваивания
почему: вывод работает по аргументам вызова, а не по тому, куда кладут результат
| # probe
| ./main.go:6:20: in call to Make, cannot infer T (declared at ./main.go:3:11)
3. типизированный операнд выигрывает у константы
почему: T выведен как int по переменной i, и 2.5 в int уже не помещается
| # probe
| ./main.go:7:13: cannot use 2.5 (untyped float constant) as int value in argument to Sum (truncated)
4. дженерик-ТИП не выводится никогда
почему: вывод типов определён для вызовов функций; у типов его нет
| # probe
| ./main.go:6:7: cannot use generic type Box[T any] without instantiation
5. константы разного вида в одном параметре типа
почему: два нетипизированных операнда дают несовместимые уравнения
| # probe
| ./main.go:6:14: in call to Pair, mismatched types untyped int and untyped string (cannot infer T)
Что стоит унести из части 2: сообщение компилятора во всех случаях
одинаково честное — cannot infer T. Оно не говорит «добавьте аргумент типа»,
оно говорит, что уравнений не хватило.
Тильда (tilde.go)
Первоисточник — спецификация 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».)
То есть float64 в ограничении — множество из одного типа, ~float64 —
множество всех типов с базовым типом float64. Celsius не РАВЕН float64,
поэтому в первое не попадает; базовый тип у него float64, поэтому во второе
попадает.
Что такое базовый тип — там же, раздел Underlying types:
Each type T has an underlying type: If T is one of the predeclared boolean, numeric, or string types, or a type literal, the corresponding underlying type is T itself. Otherwise, T's underlying type is the underlying type of the type to which T refers in its declaration.
(пер.: «У каждого типа T есть базовый тип: если T — один из предобъявленных булевых, числовых или строковых типов либо литерал типа, соответствующий базовый тип — сам T. Иначе базовый тип T — это базовый тип того типа, на который T ссылается в своём объявлении».)
Определение рекурсивное, поэтому цепочка type Fahrenheit Celsius тоже
сводится к float64 — это в прогоне видно.
И ограничение на саму тильду, там же:
In a term of the form ~T, the underlying type of T must be itself, and T cannot be an interface.
(пер.: «В терме вида ~T базовый тип T должен быть им самим, и T не может быть интерфейсом».)
Прогон целиком:
go version: go version go1.24.7 linux/amd64
── Часть 1: что компилируется ──
ScaleExact(2.5) = 5 (float64)
ScaleUnder(2.5) = 5 (float64)
ScaleUnder(Celsius(20)) = 40 (main.Celsius)
ScaleUnder(Fahrenheit(68)) = 136 (main.Fahrenheit)
Обратите внимание: тип результата — Celsius и Fahrenheit, а не
float64. Тильда расширяет множество допустимых типов, но НЕ
стирает имя: внутри тела T остаётся именованным типом.
── Часть 2: что НЕ компилируется (дословные ошибки) ──
1. именованный тип под ограничением БЕЗ тильды
почему: множество типов у `float64` состоит ровно из float64; Celsius в него не входит
| # probe
| ./main.go:10:16: Celsius does not satisfy Exact (possibly missing ~ for float64 in Exact)
2. тильда на типе, чей базовый тип — не он сам
почему: спецификация требует: в терме ~T базовый тип T должен быть самим T
| # probe
| ./main.go:5:21: invalid use of ~ (underlying type of MyInt is int)
| ./main.go:10:7: cannot satisfy Bad (empty type set)
3. тильда на интерфейсе
почему: спецификация требует: T не может быть интерфейсом
| # probe
| ./main.go:3:21: invalid use of ~ (error is an interface)
Две вещи, которые видно только в прогоне:
- Компилятор сам подсказывает про тильду. Сообщение —
Celsius does not satisfy Exact (possibly missing ~ for float64 in Exact). Это и есть самый частый способ узнать о тильде: из ошибки. - Тильда расширяет множество, но не стирает имя.
ScaleUnder(Celsius(20))возвращаетmain.Celsius, а неfloat64. Внутри телаTостаётся именованным типом со всеми его методами.
Отдельно: ~float64 включает и сам float64 — базовый тип у float64 это он
сам. Тильда ничего не исключает, только добавляет.
Проектирование ограничения (constraints.go)
Правило, по которому определяется, что телу дженерика вообще позволено, —
Type Parameters Proposal (design/43651-type-parameters.md):
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.
(пер.: «Правило таково: дженерик-функция может использовать значение, тип которого — параметр типа, любым способом, который допускается каждым членом множества типов ограничения этого параметра».)
Это объясняет обе стороны сразу: + доступен, только если сложение есть у
КАЖДОГО типа множества; String() — только если он есть у каждого.
Почему одних методов не хватило, оттуда же:
However, method calls are not sufficient for everything we want to express.
(пер.: «Однако вызовов методов недостаточно для всего, что мы хотим выразить».)
Оператор нельзя выразить методом — перегрузки операторов в Go нет, — поэтому для арифметики набор типов не стилистический выбор, а единственный способ.
Правило для обратного случая — go.dev/blog/when-generics, раздел «Don't use type parameters if method implementations differ»:
If the implementation is different for each type, then use an interface type and write different method implementations, don't use a type parameter.
(пер.: «Если реализация различается для каждого типа, используйте интерфейсный тип и пишите разные реализации методов, а не параметр типа».)
И общее правило оттуда же, раздел «One simple guideline»:
If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider whether you can use a type parameter.
(пер.: «Если вы обнаружили, что пишете ровно один и тот же код много раз и единственное различие между копиями в том, что код использует разные типы, подумайте, нельзя ли использовать параметр типа».)
Две цитаты складываются в одно решение: ОДИН И ТОТ ЖЕ код на разные типы — параметр типа; РАЗНЫЙ код на разные типы — интерфейс с методом.
Наконец, запрет, из-за которого «и то, и другое сразу» через объединение не выйдет, — спецификация Go, General interfaces:
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 version: go version go1.24.7 linux/amd64
── Часть 1: что можно ──
(A) набор типов → доступны операторы
Max(3, 7) = 7
Max(2.5, 1.5) = 2.5
(B) ограничение-метод → доступен метод, реализации разные
Greet(&user{...}) = привет, user:аня
Greet(&admin{...}) = привет, admin:боря
(C) пересечение: ~int И метод → доступно и то, и другое
NextName(Port(80)) = порт 81
заметьте: это ПЕРЕСЕЧЕНИЕ (два элемента интерфейса),
а не объединение. Объединение с методом запрещено — см. случай 3.
── Часть 2: чего нельзя (дословные ошибки компилятора) ──
1. метод при ограничении-наборе типов
почему: у int нет метода Name, значит его нет ни у кого в множестве — тело его не получает
| # probe
| ./main.go:6:11: v.Name undefined (type T has no field or method Name)
2. оператор при ограничении-методе
почему: Named — это множество ВСЕХ типов с методом Name, включая структуры; сложение определено не у всех
| # probe
| ./main.go:6:9: invalid operation: operator + not defined on a (variable of type T constrained by Named)
3. объединение типов с интерфейсом-методом
почему: прямой запрет спецификации: union не может содержать интерфейсы с методами
| # probe
| ./main.go:5:28: cannot use fmt.Stringer in union (fmt.Stringer contains methods)
4. comparable в объединении
почему: тот же запрет, вторая его половина
| # probe
| ./main.go:3:28: cannot use comparable in union
── Что из этого следует для проектирования ──
Вопрос не «что красивее», а «какие операции нужны телу»:
нужен оператор → только набор типов, выбора нет;
нужно поведение, разное
у разных типов → интерфейс с методом;
нужно и то, и другое → пересечение элементов (случай C),
но НЕ объединение.
И цена у этих двух путей разная — она замерена в cost_test.go:
набор типов → тело под конкретную форму, накладных расходов нет;
метод в ограничении → общее тело на все указатели, адрес метода из
словаря, вызов косвенный (1 342 нс против 321).
Главное, что показывает прогон: два вида ограничений не взаимозаменяемы.
Набор типов даёт операторы и отнимает методы; ограничение-метод даёт метод и
отнимает операторы. Совместить их можно только ПЕРЕСЕЧЕНИЕМ элементов
(interface { ~int; Name() string }), но не объединением — объединение с
методами запрещено прямо.
Практическое следствие, которое соединяется с блоком 1: выбор ограничения — это одновременно выбор цены. Набор типов компилируется под конкретную форму и не стоит ничего; метод в ограничении сливает все указатели в одно тело, адрес метода уходит в словарь, и вызов становится косвенным — 1 342 нс против 321 у прямого.
Что не подтвердилось
«Дженерики в Go — это шаблоны, компилятор порождает версию под каждый
тип». Не по типам. На одиннадцать типов вышло шесть тел, а на три
указателя — одно. Специализация здесь идёт по форме, и формы часто совпадают
с типами (int, int64, string, [2]int получили по своему телу), а
часто нет. Полная мономорфизация («стенсилинг») была одним из рассмотренных
вариантов; выбран промежуточный.
«Дженерик — это способ убрать интерфейс и получить прямой вызов». Ровно наоборот, если ограничение содержит методы: тело общее на все указатели, адрес метода берётся из словаря, вызов остаётся косвенным. Замерено: 4,18 от прямого вызова.
«Зато четырёхкратная разница — это цена косвенности». Нет: блок 6 её делит. Запрет встраивания на прямом пути поднимает его с 321 до 1 143, и дженерик отстаёт уже в 1,39 раза. Около четырёх пятых разницы — потерянное встраивание. Ещё пятая часть отставания ИНТЕРФЕЙСА (блок 7) — не диспетчеризация вовсе, а вдвое более широкие данные.
«Зато известно, во сколько раз дженерик быстрее интерфейса». Не известно. Прежний прогон давал 1 805 против 1 844 — вровень; нынешний даёт 1 342 против 2 185 — в полтора раза дешевле. Тот же исходник и тот же toolchain. Переносится только то, что оба вызова остаются косвенными.
«Дженерик всегда что-то стоит». В блоках 2, 3 и 5 он не стоил ничего: байты и выделения совпали до единицы, времена легли внутрь разброса прогонов. Замерены три случая на одном типе элемента и одной длине — этого хватает, чтобы опровергнуть «всегда», и не хватает, чтобы утверждать «никогда». Разница между этими блоками и блоком 1 — в том, есть ли в ограничении методы.
Оговорка ко всем числам: одна машина, GOMAXPROCS = 2, тысяча элементов,
один тип данных. Соотношения внутри блока устойчивее, чем сами числа, а
главное утверждение статьи — про число тел кода — от машины не зависит
вообще: его даёт таблица символов.
Перепроверка на go1.27.0
Аудит статьи просил повторить ключевые эксперименты на текущей версии языка. Повторено, и ответ короткий: не изменилось ничего. По частям:
| что | как проверено | результат |
|---|---|---|
| число тел кода и словарей | shapes.go |
runs/shapes.txt и runs/shapes-go127.txt различаются одной строкой — номером версии |
| отказы компилятора по ограничениям | constraints.go |
совпали слово в слово |
| отказы компилятора по выводу типов | inference.go |
совпали слово в слово, все пять |
| возможности 1.24/1.26/1.27 | versions.go |
runs/versions-go124.txt и runs/versions-go127.txt |
| время, байты, выделения | cost.sh |
runs/toolchain-go124.txt и runs/toolchain-go127.txt: диапазоны перекрываются в каждой строке каждого блока |
Почему пара, а не один прогон на 1.27. Сравнивать можно только прогоны, снятые на одной машине. Основные таблицы этого файла сняты на одном экземпляре (Xeon 2.10GHz), а пара — на другом (Xeon 2.80GHz), и её числа с таблицами выше не сопоставляются вовсе. Внутри пары сопоставляется всё: там обе стороны сняты подряд на одном железе, и единственное, чем они отличаются, — версия toolchain.
Что пара сказала сверх заданного вопроса. На той машине блок 6 дал ×1,00 на обоих toolchain: с запретом встраивания дженерик стоил ровно столько же, сколько прямой вызов. То есть чтение адреса из словаря там не стоило ничего измеримого, и всё отставание дженерика из блока 1 оказалось потерянным встраиванием. На машине основных таблиц оставалось ещё 1,39. Складывать эти наблюдения надо так: вклад встраивания — от четырёх пятых до всей разницы, вклад косвенности — от пятой части до нуля, и который случай ваш, решает машина, а не версия Go.
Ещё одно, чего пара касаться не должна. На той машине разброс раундов
заметно шире (в блоке 3 до 66 % против 9 % на машине основных таблиц), и часть
второстепенных отношений там другая — например, sort.Sort и sort.Slice
перестали различаться. Это ещё одна причина не переносить её числа в статью:
пара отвечает на один вопрос — «изменил ли что-нибудь 1.27» — и ни на какой
другой.
Источники
- Generics implementation — GC Shape Stenciling (проектный документ) — https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-gcshape.md
- Generics implementation — Dictionaries (Go 1.18): описание того, что реализовано на самом деле, включая правило группировки форм — https://go.googlesource.com/proposal/+/master/design/generics-implementation-dictionaries-go1.18.md
- Generics implementation — Dictionaries (проектный документ) — https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-dictionaries.md
cmd/compile/internal/noder/reader.go, функцияshapify— правило, по которому тип превращается в формуcmd/compile/internal/noder/writer.go— где решается, считать ли ограничение набором методов (IsMethodSet)- Спецификация Go, разделы Type parameters, Type constraints, General interfaces, Type inference, Underlying types — https://go.dev/ref/spec
- Type Parameters Proposal (
design/43651-type-parameters.md) — правило «operations permitted by every member of the type set» — https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md - When To Use Generics (блог Go) — «Don't use type parameters if method implementations differ» и «One simple guideline» — https://go.dev/blog/when-generics
sort.Intsв Go 1.24 —func Ints(x []int) { slices.Sort(x) }— https://go.dev/src/sort/sort.go
Script
304 lines//go:build ignore
// Сколько тел кода компилятор на самом деле порождает для дженерика.
//
// Здесь нет ни одного замера времени. Вопрос, на который отвечает файл, —
// не «быстро ли», а «сколько»: Go не мономорфизирует по типам, он порождает
// одно тело на GC-ФОРМУ (gcshape), а различать конкретные типы внутри общего
// тела ему помогает словарь, передаваемый скрытым аргументом.
//
// КАК ЭТО ПРОВЕРЯЕТСЯ. Программа собирает shapesrc/main.go и читает таблицу
// символов готового двоичного файла через `go tool nm`. Тела инстанциаций
// видны там под именами вида `main.AnyBox[go.shape.*uint8]`, словари — под
// `main..dict.AnyBox[*main.A]`. Считать их — то же самое, что спросить у
// компилятора, что он сделал, только без чтения его исходников.
//
// ЗАПУСК:
//
// go run bench/gogenerics/shapes.go
//
// Снято на go1.24.7 linux/amd64.
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Символ тела инстанциации: main.Имя[go.shape.ЧТО-ТО].
// Скобка внутри формы бывает своя — например, go.shape.[2]int, — поэтому
// закрывающая ищется жадно, до конца строки, а не первой попавшейся.
var reBody = regexp.MustCompile(`^main\.(AnyBox|Named|Cmp|Two|Union|Both)\[(go\.shape\..+)\]$`)
// Символ словаря: main..dict.Имя[конкретный тип].
var reDict = regexp.MustCompile(`^main\.\.dict\.(AnyBox|Named|Cmp|Two|Union|Both)\[(.+)\]$`)
func main() {
dir, err := os.Getwd()
must(err)
// Файл запускается и из корня репозитория, и из своего каталога.
base := dir
if filepath.Base(dir) != "gogenerics" {
base = filepath.Join(dir, "bench", "gogenerics")
}
src := filepath.Join(base, "shapesrc")
if _, err := os.Stat(src); err != nil {
fmt.Fprintf(os.Stderr, "не нашёл %s: запускайте из корня репозитория или из bench/gogenerics\n", src)
os.Exit(1)
}
bin := filepath.Join(os.TempDir(), "de-shapes-probe")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = src
build.Stderr = os.Stderr
must(build.Run())
defer os.Remove(bin)
nm := exec.Command("go", "tool", "nm", bin)
out, err := nm.Output()
must(err)
bodies := map[string][]string{} // функция -> список форм
dicts := map[string][]string{} // функция -> список конкретных типов
scanner := bufio.NewScanner(strings.NewReader(string(out)))
scanner.Buffer(make([]byte, 1<<20), 1<<20)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
sym := fields[len(fields)-1]
if m := reBody.FindStringSubmatch(sym); m != nil {
bodies[m[1]] = append(bodies[m[1]], m[2])
}
if m := reDict.FindStringSubmatch(sym); m != nil {
dicts[m[1]] = append(dicts[m[1]], m[2])
}
}
// Порядок вывода: сначала базовые интерфейсы, потом небазовые. Так
// видно, что граница проходит именно по этому свойству, а не по числу
// методов и не по алфавиту.
order := []string{"AnyBox", "Named", "Two", "Cmp", "Union", "Both"}
head(1, "Одно тело на форму, один словарь на тип")
fmt.Println(" Слева — сколько РАЗНЫХ конкретных типов подставлено в вызовах")
fmt.Println(" (по числу словарей), справа — сколько тел кода компилятор")
fmt.Println(" на самом деле сгенерировал.")
fmt.Println()
fmt.Printf(" %-10s %-12s %-10s %s\n", "функция", "ограничение", "типов", "тел кода")
for _, fn := range order {
fmt.Printf(" %-10s %-12s %-10d %d\n",
fn, constraintOf(fn), len(uniq(dicts[fn])), len(uniq(bodies[fn])))
}
checkAgainstSymbols(bodies, dicts, order)
head(2, "Какие типы слились в одну форму")
for _, fn := range order {
fmt.Printf("\n %s[T %s]\n", fn, constraintOf(fn))
for _, shape := range uniq(bodies[fn]) {
fmt.Printf(" %-28s <- %s\n", short(shape), strings.Join(typesFor(fn, shape, uniq(dicts[fn])), ", "))
}
}
head(3, "Правило, по которому это происходит")
fmt.Println(" Из cmd/compile/internal/noder/reader.go, функция shapify:")
fmt.Println()
fmt.Println(" When a pointer type is used to instantiate a type parameter")
fmt.Println(" constrained by a basic interface, we know the pointer's element")
fmt.Println(" type can't matter to the generated code. In this case, we can use")
fmt.Println(" an arbitrary pointer type as the shape type. (To match the")
fmt.Println(" non-unified frontend, we use `*byte`.)")
fmt.Println(" Otherwise, we simply use the type's underlying type as its shape.")
fmt.Println()
fmt.Println(" Ключевое слово здесь — BASIC INTERFACE. По спецификации это")
fmt.Println(" интерфейс, множество типов которого задано ТОЛЬКО методами.")
fmt.Println(" Отсюда все шесть строк выше:")
fmt.Println(" * any, Stringer и TwoMethods — базовые интерфейсы, поэтому")
fmt.Println(" все указатели сливаются в *uint8, и различать их приходится")
fmt.Println(" словарю. Число методов роли не играет: ноль, один и два.")
fmt.Println(" * comparable, объединение *A | *B | *C и «Stringer плюс")
fmt.Println(" comparable» базовыми НЕ являются, и у каждого указателя")
fmt.Println(" своя форма и своё тело.")
fmt.Println(" * последний случай и разводит две формулировки: методы в нём")
fmt.Println(" ЕСТЬ, а слияния нет. Значит правило не про наличие методов,")
fmt.Println(" а про то, задано ли ограничение только ими.")
fmt.Println(" * именованный тип берёт форму своего базового типа, поэтому")
fmt.Println(" Celsius, Meters и float64 — одно тело.")
fmt.Println(" * int64 и float64 занимают по 8 байт, но формы разные —")
fmt.Println(" и это НЕ недоделка. Документ реализации говорит прямо:")
fmt.Println(" «fundamentally different built-in types such as int and")
fmt.Println(" float64 are never in the same gcshape», потому что у них")
fmt.Println(" разные операции и в словарь пришлось бы класть разные")
fmt.Println(" реализации сложения и сдвигов.")
fmt.Println()
fmt.Println(" А вот где реализация И ПРАВДА расходится с документом — это")
fmt.Println(" указатели, только в другую сторону. Документ:")
fmt.Println()
fmt.Println(" Two concrete types are in the same gcshape grouping if and")
fmt.Println(" only if they have the same underlying type or they are")
fmt.Println(" both pointer types.")
fmt.Println()
fmt.Println(" «Both pointer types» — без всяких условий. Компилятор же")
fmt.Println(" сливает их только под ограничением-набором методов: под")
fmt.Println(" comparable у каждого указателя своя форма, и это видно в")
fmt.Println(" разделе 2 выше. Реализация здесь ТОНЬШЕ документа, а не грубее,")
fmt.Println(" и в TODO у shapify первым пунктом стоит ровно это:")
fmt.Println(" «collapsing all pointer-shaped types into a common type».")
head(4, "Что это значит для вызова метода")
fmt.Println(" У Named все три указателя делят ОДНО тело. Значит, внутри него")
fmt.Println(" нельзя вызвать (*A).String напрямую: тело не знает, чей это")
fmt.Println(" указатель. Адрес метода лежит в словаре, и вызов идёт через него.")
fmt.Println()
fmt.Println(" Отсюда следствие, которое обычно узнают наоборот: дженерик по")
fmt.Println(" ограничению-интерфейсу НЕ превращается в прямой вызов. Во что он")
fmt.Println(" обходится по сравнению с обычным интерфейсом — в cost_test.go.")
}
// checkAgainstSymbols сверяет правило с таблицей символов в обе стороны:
// каждая вычисленная форма обязана найтись среди символов, и у каждого
// найденного символа обязан быть хотя бы один тип. Любое расхождение значит,
// что shapeOf устарел относительно компилятора или подопытной программы.
func checkAgainstSymbols(bodies, dicts map[string][]string, order []string) {
var problems []string
for _, fn := range order {
found := map[string]bool{}
for _, shape := range uniq(bodies[fn]) {
found[shape] = false
}
for _, t := range uniq(dicts[fn]) {
shape := shapeOf(fn, t)
if _, ok := found[shape]; !ok {
problems = append(problems, fmt.Sprintf(
"%s: правило дало форму %s для типа %s, но такого символа в nm нет", fn, shape, t))
continue
}
found[shape] = true
}
for shape, covered := range found {
if !covered {
problems = append(problems, fmt.Sprintf(
"%s: в nm есть форма %s, но правило не отнесло к ней ни одного типа", fn, shape))
}
}
}
if len(problems) > 0 {
fmt.Fprintln(os.Stderr, "правило разошлось с таблицей символов:")
for _, p := range problems {
fmt.Fprintln(os.Stderr, " "+p)
}
os.Exit(1)
}
}
func constraintOf(fn string) string {
switch fn {
case "AnyBox":
return "any"
case "Named":
return "Stringer"
case "Two":
return "TwoMethods"
case "Cmp":
return "comparable"
case "Union":
return "PtrUnion"
default:
return "StringerComparable"
}
}
// basicConstraint — задаётся ли ограничение ТОЛЬКО методами.
//
// Термин из спецификации: базовый интерфейс — тот, чьё множество типов задано
// одними методами. any (пустой набор), Stringer и TwoMethods базовые;
// comparable, объединение указательных типов и «методы + comparable» — нет.
//
// ПОЧЕМУ ЭТО ВАЖНО. Правило shapify говорит именно про basic interface, а не
// про «есть ли методы». Разница видна только на небазовых ограничениях,
// которые методы всё-таки содержат, — на Both. Пока их в опыте не было, обе
// формулировки объясняли данные одинаково, и выбрать между ними было нельзя.
func basicConstraint(fn string) bool {
switch fn {
case "AnyBox", "Named", "Two":
return true
default:
return false
}
}
// typesFor возвращает конкретные типы, попавшие в данную форму.
//
// ЧЕСТНАЯ ОГОВОРКА. Само соответствие «тип → форма» из таблицы символов НЕ
// читается: там лежат отдельно список форм и отдельно список типов, а связь
// между ними приходится восстанавливать по правилу shapify. Значит, правило
// может разойтись с реальностью и таблица снизу окажется красивой выдумкой
// при верных счётчиках сверху. Поэтому перед печатью стоит проверка
// checkAgainstSymbols: она требует, чтобы множество форм, вычисленных
// правилом, совпало с множеством форм, найденных в nm. Не совпало — программа
// падает, а не печатает.
func typesFor(fn, shape string, concrete []string) []string {
var out []string
for _, t := range concrete {
if shapeOf(fn, t) == shape {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
func shapeOf(fn, concrete string) string {
isPtr := strings.HasPrefix(concrete, "*")
if isPtr && basicConstraint(fn) {
return "go.shape.*uint8"
}
switch concrete {
case "main.Celsius", "main.Meters", "float64":
return "go.shape.float64"
case "main.Count", "int":
return "go.shape.int"
case "int64":
return "go.shape.int64"
case "string":
return "go.shape.string"
case "[2]int":
return "go.shape.[2]int"
}
return "go.shape." + concrete
}
func short(shape string) string { return strings.TrimPrefix(shape, "go.shape.") }
func uniq(xs []string) []string {
seen := map[string]bool{}
var out []string
for _, x := range xs {
if !seen[x] {
seen[x] = true
out = append(out, x)
}
}
sort.Strings(out)
return out
}
func head(n int, title string) {
fmt.Printf("\n%s\n%d. %s\n%s\n", strings.Repeat("=", 72), n, title, strings.Repeat("=", 72))
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}