open atlas
↑ К треку
Go с нуля до senior GO · 05 · 01

Параметры типов и constraints: множества типов, пределы вывода и GCShape-стенсилинг

Параметры типов и constraints как множества типов: аппроксимация ~T, объединения, comparable. Где вывод типов работает и где останавливается. Инстанцирование через GCShape-стенсилинг: типы-значения получают свой машинный код, все указатели делят один инстанс и словарь.

GO Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Когда вышел Go 1.18, команда хранилища переписала свой LRU-кеш с interface{} на Cache[K comparable, V any]. Type assertions исчезли с горячего пути, а в design-доке обещали то же ускорение, которое команда помнила по шаблонам C++: полная мономорфизация, всё инлайнится. Бенчмарки вернулись расколотыми пополам. Cache[string, int64] оказался на 28% быстрее старого кода. А инстанцирование, которое реально жило в проде — Cache[string, *Session], — измерилось в пределах шума от версии на interface{}. Дизассемблер объяснил почему: каждое инстанцирование с указателями скомпилировалось в одну общую функцию со скрытым аргументом-словарём, и вызовы внутри неё остались косвенными. Дженерики Go — не шаблоны C++; это осознанный гибрид, и шов этого гибрида проходит ровно между типами-значениями и указателями. Команда оставила дженерик-API — он убрал целый класс паник на assertion, — но слайд про производительность тихо исчез из презентации.

За следующие десять минут ты узнаешь, где именно проходит этот шов — и почему указательный случай молча поглотил ожидаемое ускорение.

Constraints — множества типов, а не иерархии классов

Список параметров типа — func Sum[T Number](xs []T) T — вводит T как плейсхолдер, который должен удовлетворять своему constraint. Constraint — это обычный интерфейс, но в новой роли: вместо того чтобы описывать, что значение умеет делать, он описывает множество типов, которые разрешены. Внутри такого интерфейса можно перечислять типы напрямую, объединять их через | и ставить префикс ~, означающий «любой тип, чей underlying type — вот этот»:

// Constraint — это интерфейс, используемый как множество типов.
type Number interface {
	~int | ~int64 | ~float64 // ~T: любой тип с underlying-типом T
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x // легально: каждый тип множества поддерживает +
	}
	return total
}

type Celsius float64 // именованный тип, underlying — float64

var _ = Sum([]Celsius{20.5, 21.0}) // компилируется только благодаря ~

Тильда важнее, чем кажется. Код на Go полон именованных типов — type UserID int64, type Port int, — а constraint, записанный как голый int64, допускает только int64, но не UserID. Без тильды ваша «числовая» библиотека отвергает половину доменных типов кодовой базы, причём провал — это ошибка компиляции у вызывающего, далеко от constraint, который её вызвал. Эмпирическое правило: в переиспользуемых constraints всегда пишите ~T, если нет конкретной причины исключить именованные типы. Словарь дополняют ещё две детали: comparable — предобъявленный constraint со смыслом «поддерживает == и !=» (именно он нужен ключам map, и его нельзя выразить объединением — множество бесконечно), а ещё constraints могут содержать методы, и тогда тип должен одновременно входить в множество и иметь эти методы. Чего constraint не может никогда — выступать обычным типом: нельзя объявить var n Number; множества типов существуют только в момент инстанцирования.

Вывод типов: мощный в точке вызова, отсутствующий везде ещё

Прежде чем рассчитывать на вывод типов для чистоты вызовов, стоит знать, где он просто останавливается, — чтобы поймать запах дизайна до того, как о него споткнётся коллега.

Вывод типов — то, что делает дженерики читаемыми: Sum(prices) вместо Sum[Celsius](prices). Компилятор выводит аргументы типов из типов аргументов функции и затем проверяет их по constraints. Но у вывода есть жёсткие края, и senior-ревью означает знать, где они:

func Map[T, U any](xs []T, f func(T) U) []U { /* ... */ }

ys := Map(ids, strconv.Itoa) // T=int, U=string — выведены из аргументов

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

v := Zero[int]() // обязательное явное инстанцирование:
                 // T встречается только в результате — выводу не на что смотреть

Параметр типа, который встречается только в позиции результата, вывести нельзя никогда — нет аргумента, несущего информацию. Нетипизированные константы — вторая ловушка: передача 1 в параметр [T Number] выводит int (тип константы по умолчанию), а не int64, и если constraint исключает int, вызов не компилируется, хотя 1 — совершенно валидный int64. Третий предел структурный: вывод работает только в точках вызова. Нет вывода из контекста присваивания, нет частичной специализации, нет перегрузки по constraints. Когда вывод не срабатывает, фикс механический — написать аргумент типа явно, — но API, который регулярно заставляет вызывающих писать F[конкретный](...), — это запах дизайна, лечащийся переносом параметра типа в аргумент.

Викторина

Библиотека объявляет constraint type ID interface { int64 } — без тильды. Коллега пишет type UserID int64 и вызывает Lookup[UserID](...). Что произойдёт?

Инстанцирование: GCShape-стенсилинг, честно

Что происходит, когда вы реально вызываете Sum[Celsius]? C++ бы мономорфизировал: сгенерировал свежий машинный код под каждый аргумент типа — быстрый код, медленная компиляция, толстые бинарники. Java стирает типы: одна копия кода, всё в боксах. Go 1.18 выбрал середину — GCShape-стенсилинг со словарями. Компилятор группирует аргументы типов по GC-шейпу: два типа делят шейп, если для аллокатора и сборщика мусора они выглядят одинаково — тот же размер, та же раскладка указателей. Но главный коллапс вот в чём: все типы-указатели — один шейп. *User, *Order, *bytes.Buffer — одно общее инстанцирование. Чтобы различать их в рантайме, компилятор передаёт скрытый аргумент-словарь с метаданными конкретного типа: его runtime._type, адреса методов, производные типы. Код, вызывающий метод параметра типа, загружает адрес из словаря и вызывает косвенно — а значит, через этот вызов нет инлайнинга.

Честные последствия для производительности: инстанцирования с типами-значениями получают выделенный код, прямые вызовы, полный инлайнинг — реальный выигрыш над версиями с interface{}, которым приходилось боксить и делать assertions. Инстанцирования с указателями получают общий код и косвенные вызовы через словарь — примерно профиль стоимости интерфейсного вызова метода, иногда чуть хуже из-за лишней загрузки словаря. Ровно в этот шов и врезалась команда из Hook: Cache[string, int64] мономорфизировался; Cache[string, *Session] пошёл через словарь. Стенсилинг — деталь реализации, которую спецификация не обещает (стратегия gc остаётся шейповой с 1.18), но проектировать API так, будто дженерики — бесплатная мономорфизация, — верный способ занести бенчмарк-сюрпризы в продакшен-ревью.

Викторина

Вы инстанцируете Cache[string, *User] и Cache[string, *Order]. Сколько копий машинного кода кеша сгенерирует gc для типа-значения и как диспетчеризуются вызовы на нём?

Вспомните перед уходом
  1. 01
    Объясни constraints как множества типов: что дают ~T, объединения и comparable, и почему constraint, записанный как голый int64, отвергает type UserID int64?
  2. 02
    Разбери GCShape-стенсилинг: что получает свой машинный код, что делится, что делает словарь, и в какое следствие для производительности упёрлась команда из Hook.
Итог

Дженерики в Go — это две идеи, скрученные вместе, и у обеих есть края. Первая идея — constraint как множество типов: интерфейс, перечисляющий типы вместо методов, где ~T допускает любой тип с underlying-типом T (забудьте тильду — и каждый type UserID int64 в кодовой базе отскочит от вашей библиотеки), объединения перечисляют альтернативы, comparable называет бесконечное множество типов с ==, а тело дженерик-функции может использовать только операции, которые поддерживает каждый член множества. Constraints — граждане исключительно компайл-тайма: они не могут быть типом переменной. Вторая идея — стратегия компиляции: вывод типов заполняет аргументы типов из аргументов в точке вызова и больше ниоткуда, поэтому параметр, встречающийся только в результатах, требует явного инстанцирования, а нетипизированные константы выводят свои типы по умолчанию. Само инстанцирование идёт по GC-шейпам, а не по типам — GCShape-стенсилинг. Различные шейпы-значения получают выделенный машинный код с прямыми инлайнируемыми вызовами; все указатели схлопываются в одно общее инстанцирование, параметризованное скрытым словарём метаданных типа, и вызовы на параметре типа становятся косвенными. Этот шов — главная история про производительность: дженерик-код над типами-значениями может всерьёз обогнать код на interface{}, а дженерик-код над указателями обычно играет с ним вничью, как и показал расколотый бенчмарк команды кеша. Используйте дженерики ради типобезопасности и ясности API везде, где они уместны, — но обещайте мономорфизационные ускорения только там, где аргументы типов — значения. Теперь, когда встретишь дженерик-API в бенчмарке, который не обгоняет версию на interface{}, — сразу проверь аргумент типа: если это указатель, причина уже известна.

Практика

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

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.