1.7Дженерики
Разберём параметры типов через множества допустимых типов и сравним дженерик с интерфейсом поведения.
Зачем они вообще появились
До 1.18 у Go было ровно три способа написать «одно и то же для разных типов», и каждый чем-то плох. Дженерики закрывают конкретный, довольно узкий пробел: статически типизированный код, одинаковый по структуре, но разный по типу элементов. Не «ООП» и не «шаблоны на все случаи жизни». Именно это.
| Способ до 1.18 | Что плохо | Пример из stdlib |
|---|---|---|
| Копипаста на каждый тип | N копий кода, N мест для багов, правки не синхронизированы |
strings и bytes - почти
зеркальные пакеты
|
interface{} + type assertion |
Проверки уехали в рантайм, боксинг и аллокации, API теряет типы | sort.Slice, sync.Map |
Кодогенерация (go generate) |
Отдельный шаг сборки, сгенерированный код в репозитории, IDE плывёт | исторический genny, шаблоны в проектах |
| Рефлексия | Медленно, не проверяется компилятором, нечитаемо | reflect.DeepEqual, кодеки |
Дженерики придуманы не ради того, чтобы кода стало меньше. Они
переносят проверку типов из рантайма в компайл-тайм там, где раньше
приходилось использовать
interface{}. Если в вашем варианте
interface{} нет и копипасты нет, дженерики вам вряд ли
нужны.
Синтаксис: параметры типа
Параметры типа объявляются в квадратных скобках сразу после
имени функции или типа. Каждый параметр состоит из имени и констрейнта.
Констрейнт обязателен: даже «любой тип» пишется явно как any (это алиас для interface{},
добавленный в 1.18 ради читаемости).
// Дженерик-функция: два параметра типа, оба используются в обычной сигнатуре
func Map[E any, R any](s []E, f func(E) R) []R {
r := make([]R, 0, len(s)) // R можно использовать как обычный тип
for _, v := range s {
r = append(r, f(v))
}
return r
}
// Вызов: типы выводятся из аргументов
names := Map([]int{1, 2, 3}, strconv.Itoa) // []string
// Инстанцируем явно, когда вывести не из чего
parse := Map[string, int] // получили обычную func([]string, func(string) int) []int
// Дженерик-тип: параметры типа у объявления типа
type Stack[T any] struct {
items []T
}
// Методы дженерик-типа перечисляют параметры получателя.
// До Go 1.27 добавить к ним свои параметры типа было нельзя; с 1.27 можно (см. ниже).
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T // нулевое значение параметра типа - только так
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
// Дженерик-тип всегда используют с аргументами типа
var st Stack[string] // ок
// var bad Stack // ошибка: cannot use generic type Stack without instantiation
-
var zero Tостаётся единственным переносимым способом получить нулевое значение параметра типа. Написатьreturn nilилиreturn 0нельзя:Tможет быть чем угодно. С Go 1.21 есть ещё*new(T)и выражениеzero-значения черезvar; читаемее всего именованный результат:func Pop() (v T, ok bool), тогдаreturnсам вернёт нулевое. -
Собственные параметры типа у методов до Go 1.27 объявлять было
нельзя вообще, и это ограничение годами называли главным недостатком
дженериков в Go.
С Go 1.27 можно:
func (s *Stack[T]) MapTo[R any](f func(T) R) Stack[R]компилируется. Запрет остался только для методов интерфейса. Подробности ниже, отдельным разделом. - Дженерик-тип без аргументов типа ещё не тип, а «шаблон». Его нельзя положить в поле, объявить переменной или использовать в сигнатуре без инстанцирования.
Констрейнты: интерфейс как множество типов
Формулировка, которую стоит держать наготове: в Go 1.18
изменилось само определение интерфейса. Раньше интерфейс задавал
множество методов. Теперь он задаёт
множество типов, а список методов просто один
из способов это множество задать.
interface{ Read([]byte) (int, error) } задаёт множество
всех типов, у которых есть такой метод.
interface{ int | string } перечисляет ровно два типа.
Констрейнтом называют интерфейс, поставленный в позицию параметра типа.
| Запись | Множество типов | Можно ли использовать как обычный тип переменной |
|---|---|---|
any |
все типы | Да (это interface{}) |
comparable |
типы, у которых == не паникует |
Нет - только как констрейнт |
interface{ String() string } |
все типы с методом String |
Да |
interface{ int | int64 } |
ровно int и int64 |
Нет - интерфейс с элементами типа |
interface{ ~int } |
все типы с underlying int |
Нет |
interface{ ~string; String() string } |
пересечение: underlying string И метод |
Нет |
Интерфейс, содержащий элементы типа (|,
~) или comparable, называется
non-basic и может использоваться только как
констрейнт. Объявить var x Ordered нельзя: получите
ошибку компиляции «interface contains type constraints». Обычный
интерфейс с одними методами (basic) работает и как тип, и как
констрейнт.
Тильда ~: почему без неё всё ломается
int в констрейнте означает
ровно тип int, ни строчкой больше. А в реальном коде сплошь и рядом
встречаются именованные типы: type UserID int,
type Weekday int,
type Kilometers float64. Такой тип
не удовлетворяет констрейнту int, потому что
это другой тип. ~int читается как «любой тип, у
которого underlying type совпадает с int» и включает и
сам int, и все производные от него именованные типы.
type Strict interface{ int } // множество = {int}
type Loose interface{ ~int } // множество = {int, UserID, Weekday, ...}
type UserID int
func StrictSum[T Strict](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
func LooseSum[T Loose](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
ids := []UserID{1, 2, 3}
// StrictSum(ids) // ошибка: UserID does not satisfy Strict (possibly missing ~ for int)
LooseSum(ids) // ок → UserID(6)
// Ограничение тильды: справа от ~ должен стоять тип, чей underlying - он сам.
// type MyInt = ~int // нельзя: ~ вне интерфейса
// interface{ ~UserID } // ошибка: invalid use of ~ (underlying type of UserID is int)
possibly missing ~ for int in constraint переводится
буквально как «ты забыл тильду». Практическое правило: в собственных
числовых/строковых констрейнтах
почти всегда нужна тильда. Исключение одно: вы сознательно
хотите отсечь именованные типы (например, чтобы
Duration случайно не попал в арифметику для «сырых»
наносекунд).
T ровно те операции, которые допустимы для
каждого типа из множества. Отсюда прямое следствие: чем
шире констрейнт, тем меньше можно сделать внутри функции.
comparable и constraints.Ordered
comparable остаётся единственным встроенным констрейнтом,
который не выразить синтаксисом объединения: множество «типов, у
которых == определён и не паникует» включает все числа,
строки, булевы, указатели, каналы, интерфейсы, а ещё массивы и
структуры из таких типов. Набор выходит бесконечный, его задаёт
рекурсивное правило. Поэтому он зашит в компилятор.
// comparable нужен там, где значение идёт ключом мапы или сравнивается
func Unique[T comparable](s []T) []T {
seen := make(map[T]struct{}, len(s)) // ключ мапы обязан быть comparable
out := make([]T, 0, len(s))
for _, v := range s {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
}
return out
}
Unique([]int{1, 1, 2}) // ок
Unique([]struct {
A int
}{{1}, {1}}) // ок - структура из comparable полей
// Unique([][]byte{{1}}) // ошибка: []byte does not satisfy comparable
До 1.20 any не удовлетворял
comparable: интерфейсные типы сравнимы
синтаксически, но == на них может
паниковать в рантайме («comparing uncomparable type
[]int»), а comparable обещает именно строгую,
непаникующую сравнимость. В Go 1.20 правило смягчили: теперь
интерфейсные типы удовлетворяют
comparable как констрейнту, хотя формально под «strictly
comparable» не подходят. Практический вывод:
Unique[any](...) компилируется, но упадёт в
рантайме, если внутрь положить слайс. Это, пожалуй, единственное
место, где дженерик-код может паниковать «как
interface{}».
constraints.Ordered живёт в
golang.org/x/exp/constraints, то есть
вне стандартной библиотеки, и это осознанное решение.
Команда Go решила не фиксировать в stdlib набор «удобных»
констрейнтов, пока не станет ясно, какие из них действительно
нужны: однажды попав в std, они попадают под гарантию
совместимости навсегда. Практический ответ на собесе: «в проде я
не тяну x/exp ради трёх строк, объявляю свой
Ordered локально, это ровно один интерфейс».
// Это ровно x/exp/constraints, можно скопировать к себе
type Signed interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type Unsigned interface{ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr }
type Integer interface{ Signed | Unsigned } // объединение констрейнтов
type Float interface{ ~float32 | ~float64 }
type Ordered interface{ Integer | Float | ~string } // всё, где работают < и >
// Complex в Ordered не входит: комплексные числа не упорядочены.
func Max[T Ordered](a, b T) T { if a > b { return a }; return b }
func Clamp[T Ordered](v, lo, hi T) T {
if v < lo { return lo }
if v > hi { return hi }
return v
}
min, max и clear стали
встроенными функциями
(не дженериками, а билтинами: работают даже на нетипизированных
константах).
min(a, b) вместо своего Max,
clear(m) вместо цикла с delete. Плюс
появились slices.Max/slices.Min для
срезов. Если кандидат на вопрос «напиши Max» пишет дженерик и не
упоминает встроенный max, значит, за языком он не следит.
Собственные констрейнты через интерфейсы
Констрейнт остаётся обычным интерфейсом, поэтому собирать его можно из чего угодно: методов, объединений типов, других констрейнтов. Три рабочих приёма, которые реально встречаются в проде.
// 1. Метод + тип одновременно (пересечение множеств)
type Stringish interface {
~string
Len() int // тип обязан иметь underlying string и метод Len
}
// 2. Констрейнт «указатель на T»: иначе это не выразить.
// Классика: функция должна создать значение и вызвать на нём pointer-метод.
type Unmarshaler[T any] interface {
*T // один терм: указатель на T
json.Unmarshaler
}
func DecodeAll[T any, PT Unmarshaler[T]](raws [][]byte) ([]T, error) {
out := make([]T, 0, len(raws))
for _, raw := range raws {
var v T
if err := PT(&v).UnmarshalJSON(raw); err != nil { // &v имеет тип *T = PT
return nil, err
}
out = append(out, v)
}
return out, nil
}
// Вызов: DecodeAll[User](raws) - PT выводится из T автоматически
// 3. Констрейнт на «слайс своего типа» сохраняет именованный тип результата
type Slice[E any] interface{ ~[]E }
func Filter[S Slice[E], E any](s S, keep func(E) bool) S {
out := make(S, 0, len(s)) // тип результата S, а не []E
for _, v := range s {
if keep(v) { out = append(out, v) }
}
return out
}
type IDs []int
var ids IDs = []int{1, 2, 3}
r := Filter(ids, func(i int) bool { return i > 1 }) // r имеет тип IDs, а не []int
Если объявить func Filter[E any](s []E, ...) []E,
то на входе IDs примется (присваивание совместимо),
но вернётся уже голый []int: именованный тип
потерян, вызывающий код лишился методов IDs.
Именно так устроены сигнатуры в пакете slices:
func Sort[S ~[]E, E cmp.Ordered](x S). Это первое,
что стоит показать, когда просят «напиши generic-хелпер
по-взрослому».
Вывод типов: когда работает и когда нет
Вывод типов в Go намеренно ограничен. Он идёт только «снизу вверх» от типов аргументов (function argument type inference) и дополняется выводом из самих констрейнтов (constraint type inference). Из типа результата, из контекста присваивания и из тела функции Go не выводит ничего, в отличие от Rust или Haskell. Выбор сознательный: так проще, и сообщения об ошибках предсказуемее.
| Ситуация | Выводится? | Что делать |
|---|---|---|
Map(xs, f) - все параметры типа встречаются в
аргументах
|
Да | - |
S ~[]E, известен S → нужен
E
|
Да (constraint inference) | - |
Параметр типа только в результате:
func Zero[T any]() T
|
Нет | Zero[int]() |
Передаём функцию как значение: var f = Map
|
Нет | var f = Map[int, string] |
Аргумент - нетипизированная константа:
Max(1, 2.5)
|
Частично |
Go 1.21+ выводит float64; раньше - ошибка
|
| Аргумент - untyped nil | Нет | Указать явно |
| Литерал анонимной функции без типов параметров | Нет | Написать типы в литерале или указать явно |
// Классический промах вывода
func New[T any]() *T { return new(T) }
// p := New() // ошибка: cannot infer T
p := New[User]() // ок
// Ещё один: reduce, где аккумулятор другого типа
func Reduce[E, A any](s []E, init A, f func(A, E) A) A { ... }
sum := Reduce([]int{1,2,3}, 0, func(a, e int) int { return a + e }) // ок: A выведен из init
// А так не выведется, потому что тип литерала неизвестен до вывода A:
// Reduce([]int{1,2,3}, 0, func(a, e) int { return a + e }) // так нельзя: у литерала функции типы параметров обязательны
// Go 1.21 заметно усилил вывод: теперь работает передача дженерик-функции
// в дженерик-функцию без явных аргументов типа.
res := Map([]int{1,2,3}, strconv.Itoa) // 1.18+
srt := slices.SortedFunc(maps.Keys(m), cmp.Compare) // 1.23, вывод через несколько уровней
Ставьте «выводимые» параметры типа первыми и делайте так, чтобы
каждый параметр типа встречался хотя бы в одном обычном
аргументе. Параметр типа, который встречается только в результате,
сигналит: API неудобен, пользователю придётся писать скобки на каждом
вызове. Тогда передайте «образец» аргументом или верните значение
через указатель:
func Decode[T any](data []byte, dst *T) error
выводится. А вот
func Decode[T any](data []byte) (T, error) не выводится.
Дженерики или интерфейсы: аргументы обеих сторон
Правильный ответ здесь не «дженерики лучше». Инструменты решают разные задачи: интерфейс абстрагирует поведение (что объект умеет), дженерик абстрагирует тип данных (с чем работает алгоритм). Путаница начинается там, где обе абстракции формально применимы.
| Критерий | Дженерики | Интерфейсы |
|---|---|---|
| Что абстрагируем | Тип данных, форму контейнера | Поведение, реализацию |
| Где проверка | Компиляция | Компиляция (метод) + рантайм (assertion) |
| Типы на выходе | Сохраняются точно | Стираются до интерфейса |
| Боксинг/аллокации | Нет для non-pointer shape | Есть при укладке значения в интерфейс |
| Диспетчеризация | Прямой вызов или через словарь | Косвенный через itab |
| Расширяемость сторонним кодом | Плохая: список типов в union закрыт | Отличная: любой тип с методами подходит |
| Мокирование в тестах | Практически невозможно | Штатный способ |
| Размер бинаря | Растёт (стенсилы под каждую форму) | Не растёт |
| Читаемость сигнатур | Страдает при 3+ параметрах типа | Обычно лучше |
| Гетерогенная коллекция | Невозможна | []Shape - штатно |
Задайте вопрос:
меняется ли алгоритм в зависимости от типа? Если
нет, код один и тот же, отличается только тип элемента, берите
дженерик (Map, Filter,
Set[T], Cache[K,V], пул). Если
да, для каждого типа своя реализация, берите интерфейс (Storage, Payer,
io.Writer). Третий случай: тип нужен только внутри
функции и наружу не течёт. Тогда абстрагировать, скорее всего, нечего.
Когда дженерики - плохой выбор
-
Слой репозитория / storage.
Repository[T]выглядит красиво ровно до первого методаFindByEmail. Дальше начинается либо union-констрейнт из всех сущностей, либо рефлексия для имени таблицы, либо интерфейс сбоку. Выходит хуже, чем три честных репозитория. -
Там, где нужен мок. Подменить
Tв тесте нельзя: тип фиксируется на этапе компиляции. Границы с внешним миром должны быть интерфейсами. - Одна-две инстанциации. Дженерик ради двух типов крадёт читаемость, добавляет к размеру бинаря и ничего не даёт. Две функции честнее.
- Union из «всех типов, которые я знаю». Такой констрейнт нельзя расширить снаружи пакета: каждый новый тип означает правку библиотеки. Анти-паттерн, если только множество действительно не замкнуто (например, числовые типы).
-
Кодогенерация вместо дизайна. Если сигнатура выглядит как
func Do[A, B, C any, F ~func(A) (B, error)](...), читать это будет невозможно, и через полгода перепишут в первую очередь именно её.
Когда дженерики - явно правильный выбор
-
Контейнеры и структуры данных:
Set[T],Queue[T],OrderedMap[K,V], LRU-кэш, лок-фри очередь. -
Алгоритмы над коллекциями:
slices,maps, group-by, chunk, partition. -
Инфраструктурные обёртки:
Pool[T](типобезопасныйsync.Pool),Result[T],Optional[T],singleflightс типом. -
Конкурентные примитивы:
Fanout[T], типизированныйerrgroup-воркер, батчер. -
Устранение зеркальных пакетов: там, где раньше писали
stringsиbytesотдельно.
// Пример, где дженерик даёт настоящую пользу: типобезопасный sync.Pool.
// Без него на каждом Get стоит .(*Buffer), а такой assertion может паниковать.
type Pool[T any] struct {
p sync.Pool
new func() T
}
func NewPool[T any](new func() T) *Pool[T] {
return &Pool[T]{p: sync.Pool{New: func() any { return new() }}, new: new}
}
func (p *Pool[T]) Get() T { return p.p.Get().(T) } // assertion спрятана внутри, наружу - T
func (p *Pool[T]) Put(v T) { p.p.Put(v) }
// Пример, где дженерик - ошибка: «универсальный репозиторий»
type Repo[T any] interface {
Get(ctx context.Context, id int64) (T, error)
Save(ctx context.Context, v T) error
}
// Дальше понадобится ListActive, FindByEmail, UpdateBalance - и абстракция рассыпается.
// Плюс имя таблицы всё равно берут рефлексией или из отдельного метода, так что
// типобезопасность, ради которой всё затевалось, теряется в самом важном месте.
Как это работает под капотом: GC shape stenciling
Классических способов реализовать дженерики ровно два, и оба чего-то
стоят.
Мономорфизация (C++ templates, Rust): компилятор генерирует
отдельную копию машинного кода под каждый набор типов. Скорость
максимальная, зато бинарь и время сборки растут взрывом. Стирание типов (Java generics, Go до 1.18 через
interface{}): один код, все значения приводятся к общему
представлению. Бинарь маленький, зато боксинг, аллокации и косвенные
вызовы.
Go выбрал гибрид, «GC shape stenciling». Слово stencil переводится как трафарет: компилятор берёт тело дженерик-функции и «печатает» по нему готовую копию машинного кода; такую копию и называют стенсилом. Печатает он их не по одной на каждый тип, а по одной на каждую GC-форму, то есть на каждую группу типов, которые по размеру и раскладке указателей внутри выглядят для сборщика мусора одинаково. Всё, что у типов из одной группы всё-таки разное (описание типа, адреса методов, itab), передаётся отдельно, скрытым первым аргументом, который называют словарём. Правило группировки из design-документа звучит буквально так:
Два конкретных типа попадают в одну gcshape-группу тогда
и только тогда, когда у них одинаковый underlying type
или оба оказываются указательными типами.
Следствия: *User и *Order дают один
стенсил; type UserID int и int тоже один; а
вот int и int64 дадут
разные стенсилы, несмотря на одинаковый размер.
*T в горячем цикле может проигрывать и
мономорфному коду, и даже обычному интерфейсу.
Что лежит в словаре
Словарём зовут статическую структуру, которую компилятор кладёт в read-only секцию бинаря (по одной на каждую конкретную инстанциацию, не на форму) и передаёт скрытым первым аргументом. Внутри лежит всё, чего стенсил знать не может: он один на много типов.
| Содержимое словаря | Зачем нужно |
|---|---|
*runtime._type для каждого параметра типа
|
Аллокация (make, new), конверсия
в any, работа GC
|
Дескрипторы производных типов ([]T,
map[K]V…)
|
make([]T, n) внутри дженерика |
itab для методов констрейнта |
Вызов метода, объявленного в констрейнте |
| Ссылки на вложенные инстанциации |
Дженерик вызывает другой дженерик с теми же T
|
| Словари для замыканий внутри тела |
Замыкание тоже должно уметь работать с T
|
int,
string, структур дженерик даёт код, почти
неотличимый от рукописного. Для *T с методами он теряет
инлайнинг и девиртуализацию и может проиграть интерфейсу.
-
Нет рантайм-специализации. Инстанциация происходит на
компиляции, поэтому создать
Stack[T]с типом, известным только в рантайме, нельзя. - Время компиляции растёт. Go 1.18 заметно замедлил сборку (~15% на типичных проектах) из-за переделки компилятора под дженерики: нового тайпчекера и инстанциаций; к 1.20–1.21 это в основном отыграли.
- Инлайнинг дженериков ограничен. Компилятор умеет инлайнить простые дженерик-функции, но со словарём это работает хуже, чем с обычными.
-
Профилировать надо инстанциации. В pprof вы увидите
символы вида
pkg.Index[go.shape.int_0]: не баг, а имя стенсила.
Пакеты slices и maps
Главный продукт дженериков для повседневного кода. Оба пакета
переехали из
golang.org/x/exp в стандартную библиотеку в
Go 1.21, вместе с пакетом cmp (там живёт
cmp.Ordered, cmp.Compare,
cmp.Or). В Go 1.23 оба пакета получили вторую волну
функций, уже на итераторах.
import ("slices"; "maps"; "cmp")
// ── slices: поиск и сортировка ──────────────────────────────────────
xs := []int{5, 2, 9, 2}
slices.Sort(xs) // [2 2 5 9] - pdqsort, без рефлексии
slices.SortStableFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age) // компаратор возвращает -1/0/+1, а не bool
})
i, found := slices.BinarySearch(xs, 5) // 2, true - только на отсортированном
idx := slices.Index(xs, 9) // 3, или -1
ok := slices.Contains(xs, 2) // true
j := slices.IndexFunc(users, func(u User) bool { return u.Admin })
// ── slices: сравнение и модификация ─────────────────────────────────
slices.Equal([]int{1,2}, []int{1,2}) // true - поэлементно, без reflect
slices.Compare(a, b) // лексикографически: -1/0/+1
xs = slices.Compact(xs) // убирает подряд идущие дубли: [2 5 9]
xs = slices.Insert(xs, 1, 7, 8) // вставляет несколько по индексу
xs = slices.Delete(xs, 1, 3) // удаляет полуинтервал [1:3)
slices.Reverse(xs)
c := slices.Clone(xs) // поверхностная копия
m := slices.Max(xs) // паникует на пустом слайсе
g := slices.Grow(xs, 100) // гарантирует cap для 100 доп. элементов
xs = slices.Clip(xs) // cap = len: append больше не пишет в общий хвост; массив тот же
// ── maps ────────────────────────────────────────────────────────────
mm := map[string]int{"a": 1, "b": 2}
maps.Clone(mm) // поверхностная копия
maps.Equal(mm, other) // сравнивает по значениям
maps.Copy(dst, src) // src поверх dst
maps.DeleteFunc(mm, func(k string, v int) bool { return v == 0 })
// ── Go 1.23: итераторы (iter.Seq) ───────────────────────────────────
for k := range maps.Keys(mm) { _ = k } // maps.Keys теперь возвращает итератор
keys := slices.Sorted(maps.Keys(mm)) // []string{"a","b"} - идиома «ключи по порядку»
for i, v := range slices.All(xs) { _, _ = i, v }
for v := range slices.Values(xs) { _ = v }
back := slices.Backward(xs) // итератор в обратном порядке
xs2 := slices.Collect(maps.Values(mm)) // итератор → слайс
-
slices.Compactудаляет только соседние дубли. Без предварительной сортировки это не «уникализация». -
slices.Max/Minпаникуют на пустом слайсе, в отличие от встроенныхmax/min, которые работают с аргументами. -
Delete,Compact,Insertмодифицируют исходный массив и возвращают новый заголовок. Старый слайс после них использовать нельзя. С Go 1.22Deleteещё и обнуляет освободившийся хвост, чтобы не держать ссылки для GC. -
Cloneв обоих пакетах поверхностный.slices.Cloneот[][]intдаст новый внешний слайс с теми же внутренними.
slices.Sort лучше
sort.Slice
sort.Slice работает через рефлексию: он
получает any, строит reflect.Value,
вызывает reflect.Swapper и на каждое сравнение
дёргает замыкание через интерфейс. slices.Sort работает
как инстанцированный pdqsort с прямыми сравнениями и без
единой аллокации. На срезах чисел разница обычно составляет
2–4×, а slices.SortFunc против
sort.Slice выигрывает раза в полтора. Плюс
типобезопасность: sort.Slice(42, ...) компилируется
и паникует в рантайме, slices.Sort(42) не
компилируется.
Go 1.26 и 1.27: что в дженериках изменилось
Go 1.27: у методов появились собственные параметры типа
Годами правильным ответом на собесе было «параметр типа у метода объявить нельзя, выноси в свободную функцию». С Go 1.27 это неверно. Метод обычного или дженерик-типа теперь объявляет свои параметры типа ровно так же, как функция:
type Box struct {
v []int
}
// Go 1.27: метод объявляет собственный параметр типа T
func (b *Box) MapTo[T any](f func(int) T) []T {
out := make([]T, 0, len(b.v))
for _, x := range b.v { out = append(out, f(x)) }
return out
}
b := &Box{v: []int{1, 2, 3}}
fmt.Println(b.MapTo(func(i int) string { return strconv.Itoa(i * 2) })) // [2 4 6]
// Работает и для дженерик-типа: получатель перечисляет свои параметры, метод добавляет свои
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) MapTo[R any](f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
Пример из стандартной библиотеки есть в math/rand/v2:
метод Rand.N() стал дженерик-методом, поэтому
r.N(100) возвращает int, а
r.N(time.Second) уже time.Duration, без
конверсий на месте вызова.
Метод интерфейса по-прежнему не может иметь своих параметров типа:
type Mapper interface {
MapTo[T any](f func(int) T) []T // ошибка: interface method must have no type parameters
}
Причина ровно та, что раньше приводили как аргумент против
дженерик-методов вообще. Интерфейс в Go проверяется
статически и структурно: чтобы решить, реализует ли тип
интерфейс, компилятор сверяет конечный список методов и
складывает их адреса в itab. У дженерик-метода конечного
списка нет: инстанциаций бесконечно много, и часть из них появится в
чужом пакете, которого компилятор в этот момент
даже не видит. Собрать под это таблицу методов невозможно. У
конкретного типа этой проблемы нет: вызов
b.MapTo(f) компилятор видит целиком и инстанцирует именно
то, что нужно, как обычный вызов дженерик-функции.
Отсюда и итоговая формулировка:
«ограничение снято там, где инстанциация известна на
компиляции, и осталось там, где она принципиально не
известна».
Go 1.26: дженерик-тип может ссылаться на себя в собственном констрейнте
Раньше компилятор отвергал «self-referential» констрейнт, когда параметр типа ограничен интерфейсом, параметризованным этим же параметром. С Go 1.26 это легально, и привычный по другим языкам паттерн «тип, который умеет складываться сам с собой» наконец выражается прямо:
// Go 1.26+
type Adder[A Adder[A]] interface{ Add(A) A }
type Num int
func (n Num) Add(o Num) Num { return n + o }
func Sum[A Adder[A]](xs []A) A {
var acc A
for _, x := range xs { acc = acc.Add(x) }
return acc
}
fmt.Println(Sum([]Num{1, 2, 3})) // 6
Читается это так: «A может быть любым типом, у которого
есть метод Add, принимающий и возвращающий
этот же самый A». Без самоссылки такой
контракт выражался только через any и
рантайм-проверки.
Go 1.26: new принимает выражение
Не про дженерики, но про тот же класс неудобств. Раньше
new принимал только тип, и чтобы получить указатель
на результат выражения, приходилось заводить временную переменную
ради &tmp. Теперь можно передать само выражение:
// До 1.26
tmp := compute()
p := &tmp
// Go 1.26+
p := new(compute()) // *T, где T - тип результата compute()
// Особенно заметно на литералах и константах, у которых нельзя взять адрес напрямую:
q := new(42) // *int
r := new(len(s) * 2) // *int
Применяют это в опциональных полях структур (Field *int) и в параметрах клиентов облачных API, где половина кода раньше
состояла из вызовов самописного помощника
func ptr[T any](v T) *T. Такой помощник теперь не
нужен.
Go 1.27: вывод типов стал общее
Раньше вывод типов работал главным образом в точке вызова: из аргументов. В 1.27 он обобщён на все контексты, где дженерик-функция присваивается переменной или конвертируется в совместимый тип функции. Практически это значит, что в таких местах реже приходится писать аргументы типа руками: компилятор берёт их из типа приёмника.
Чего дженерики в Go не умеют - и почему
Список ограничений сознательный: каждое из них выкинуто, чтобы не получить систему типов, которую невозможно ни быстро скомпилировать, ни объяснить.
| Чего нет | Как выглядела бы попытка | Почему запрещено | Обходной путь |
|---|---|---|---|
|
Параметры типа у методов интерфейса (у методов конкретных типов - можно с Go 1.27) |
interface{ MapTo[R any](...) } |
Ломает статическую проверку интерфейсов: чтобы узнать, реализует ли тип интерфейс, компилятору пришлось бы инстанцировать бесконечно много методов и сложить их в itab |
Обычная дженерик-функция:
func MapStack[T, R any](s *Stack[T], f func(T) R)
*Stack[R], либо дженерик-метод на конкретном типе (Go 1.27+)
|
| Специализация |
«для T = string взять другую реализацию»
|
Нарушает принцип «одно тело - одно поведение»; в C++ это источник трудноуловимых багов и раздувания кода |
switch any(v).(type) внутри тела, либо
отдельная функция
|
| Ковариантность |
Передать []*Dog туда, где ждут
[]*Animal
|
В Go типы инвариантны: List[Dog] и
List[Animal] - разные несовместимые типы.
Ковариантность контейнеров небезопасна при записи
(классическая дыра массивов в Java)
|
Сделать функцию дженериком по элементу или конвертировать слайс явно |
| Поля в констрейнтах | interface{ ID int64 } |
Type set задаётся типами и методами; «структурные» констрейнты обсуждались и отложены | Констрейнт с геттером GetID() int64 |
| Перегрузка операторов | Свой + для Money |
Явный отказ от «магии» в дизайне Go | Методы Add/Sub |
| Дженерик-параметры у типа в интерфейсе | Динамический выбор T в рантайме |
Инстанциация - событие компиляции | Интерфейс + any на границе |
// Обход №1 (нужен только до Go 1.27 либо когда метод обязан жить в интерфейсе)
type Stack[T any] struct {
items []T
}
// func (s *Stack[T]) Map[R any](f func(T) R) *Stack[R] // до 1.27 не компилировалось,
// // с 1.27 вполне легально
func MapStack[T, R any](s *Stack[T], f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
// Обход №2: «специализация» руками, через type switch по any(v)
func Format[T any](v T) string {
switch x := any(v).(type) { // конверсия any(v) обязательна
case time.Time: return x.Format(time.RFC3339)
case error: return x.Error()
case fmt.Stringer: return x.String()
default: return fmt.Sprint(v)
}
}
// Цена: это рантайм-проверка. Если такой switch занимает всё тело, дженерик тут лишний:
// достаточно обычного func Format(v any) string.
// Обход №3: инвариантность - почему это не баг
type List[T any] struct {
xs []T
}
var dogs List[*Dog]
// var animals List[*Animal] = dogs // ошибка, и правильно:
// иначе через animals.Append(&Cat{}) в dogs попал бы кот.
В Go 1.24 появились generic type aliases:
type Set[T comparable] = map[T]struct{}. Раньше алиас не
мог иметь параметров типа. В 1.26 разрешили самоссылку дженерик-типа в
собственном констрейнте, в 1.27 добавили параметры типа у методов
конкретных типов и более общий вывод типов при присваивании
дженерик-функции. Обсуждают, но не приняли: sum types, структурные
констрейнты по полям. Логика
команды Go простая: фичу добавляют, только если она
не усложняет чтение чужого кода. Это же объясняет, почему
в языке нет Result[T] в качестве встроенного типа.
Вопросы
11func F[T Constraint](...));
констрейнт - это интерфейс, задающий
множество допустимых типов; инстанцирование
происходит на компиляции - либо явно, либо выводом из
аргументов.
Три части синтаксиса
-
Список параметров типа выглядит как
[T any, K comparable]. Каждый элемент: имя + констрейнт. Констрейнт обязателен, «любой» пишется какany. -
Использование. Внутри сигнатуры и тела
Tведёт себя как обычный тип:[]T,map[K]T,var zero T,make([]T, n). -
Инстанцирование записывается как
F[int](x)явно илиF(x)с выводом. Для дженерик-типов аргументы типа обязательны всегда:Stack[string], никогда простоStack.
// Функция
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m { out = append(out, k) }
return out
}
// Тип + методы: получатель перечисляет параметры типа, но не вводит новые
type Set[T comparable] struct{ m map[T]struct{} }
func NewSet[T comparable](vs ...T) *Set[T] {
s := &Set[T]{m: make(map[T]struct{}, len(vs))}
for _, v := range vs { s.m[v] = struct{}{} }
return s
}
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Len() int { return len(s.m) }
// Нулевое значение параметра типа - через var, *new(T) или именованный результат
func (s *Set[T]) Any() (T, bool) {
for v := range s.m { return v, true }
var zero T // не nil и не 0, а var zero T
return zero, false
}
Что чаще всего спрашивают следом
-
Можно ли параметр типа у метода? Начиная с Go 1.27 да, у
метода конкретного типа (
func (b *Box) MapTo[T any](...) []T). До 1.27 было нельзя. У метода интерфейса по-прежнему нет. -
Что такое
any? Алиасinterface{}, добавленный в 1.18 (type any = interface{}). Именно алиас, а не новый тип. -
Можно ли дженерик-метод у интерфейса? Нет, и это
единственное место, где запрет сохранился после 1.27.
Интерфейс может быть дженерик-типом (
type Repo[T any] interface{...}), но его методы своих параметров типа не объявляют.
func (s *Set[T]) Add(v T): в нём
[T] у получателя не объявляет новый
параметр типа, а связывает имя с уже объявленным у типа.
Поэтому нельзя добавить сюда констрейнт:
func (s *Set[T comparable]) ... даст ошибку
компиляции. Констрейнт пишется один раз, у объявления типа.
| и
~ - явно. ~int = «любой тип, у
которого underlying - int»,
int | string = объединение множеств.
Почему понадобились элементы типа
Операции +, <,
range, индексация, конверсия через методы не
выражаются: в Go нет перегрузки операторов. Разрешить их для T можно
одним способом: сказать компилятору, из каких конкретно типов
состоит множество.
Компилятор разрешает операцию тогда и только тогда, когда
она допустима для каждого типа множества.
type Number interface{ ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T {
var s T
for _, x := range xs { s += x } // + разрешён: он есть у всех типов множества
return s
}
// А вот так уже нельзя:
type Weird interface{ ~int | ~string }
func Bad[T Weird](a, b T) T {
return a - b // ошибка: оператор - не определён для string
// при этом a + b сработало бы: + есть и у чисел, и у строк
}
Тильда: суть в одном примере
type Celsius float64
func SumStrict[T interface{ float64 }](xs []T) T { ... }
func SumLoose [T interface{ ~float64 }](xs []T) T { ... }
temps := []Celsius{36.6, 37.1}
// SumStrict(temps) // ошибка: Celsius does not satisfy interface{float64}
// // (possibly missing ~ for float64)
SumLoose(temps) // ок
// Ограничение самой тильды: справа обязан стоять тип, чей underlying - он сам
// interface{ ~Celsius } // ошибка: invalid use of ~ (underlying type of Celsius is float64)
// Поэтому нельзя написать «любой тип, производный от Celsius».
| Запись | Читается как | Пример типа внутри |
|---|---|---|
int |
ровно int | int |
~int |
underlying = int |
int, time.Weekday,
UserID
|
~[]byte |
underlying = []byte |
[]byte, json.RawMessage
|
int | ~string |
объединение |
int, string,
MyStr
|
~int; Stringer |
пересечение (перевод строки = И) |
type Code int с методом
String()
|
Интерфейс с |, ~ или
comparable зовётся non-basic: его можно
использовать только как констрейнт.
var x Number не скомпилируется («cannot use
type Number outside a type constraint: interface contains
type constraints»). Обычный интерфейс с одними методами
по-прежнему работает и как тип, и как констрейнт, и это
единственный вид интерфейса, который годится и туда, и туда.
comparable - встроенный констрейнт
«типы, у которых == определён и
не может паниковать» (строгая сравнимость). Он нужен
для ключей мап. С Go 1.20 интерфейсные типы (включая
any) стали удовлетворять ему, хотя
строго сравнимыми не являются - из-за чего
== может упасть в рантайме.
Кто входит и кто нет
| Тип | comparable? |
Комментарий |
|---|---|---|
| Числа, строки, bool | Да | - |
Указатели, каналы, unsafe.Pointer
|
Да | Сравниваются как адреса |
| Массивы из comparable | Да |
[3]int - да, [3][]int -
нет
|
| Структуры со всеми comparable полями | Да | Одно поле-слайс убивает сравнимость всей структуры |
| Слайсы, мапы, функции | Нет | Сравнимы только с nil |
Интерфейсы, any |
С 1.20 - да как констрейнт |
Но == паникует, если внутри лежит
несравнимое
|
func Count[T comparable](s []T) map[T]int {
m := make(map[T]int, len(s))
for _, v := range s { m[v]++ }
return m
}
Count([]string{"a", "a"}) // ок
Count([][3]int{{1,2,3}}) // ок - массив из int
// Count([][]int{{1}}) // ошибка компиляции: []int does not satisfy comparable
// Go 1.20+: это компилируется...
vals := []any{1, "x", []int{1, 2}}
Count(vals) // ...и паникует в рантайме:
// panic: runtime error: hash of unhashable type []int
Почему comparable зашит в компилятор
Его нельзя выразить объединением: множество строго сравнимых
типов задаётся
рекурсивным правилом (структура сравнима, если
сравнимы все её поля; массив сравним, если сравним элемент), то есть
бесконечно. Поэтому comparable остался предопределённым
идентификатором, а не библиотечным типом. По
той же причине его нельзя использовать как тип переменной.
До 1.20 существовал раздражающий разрыв:
map[any]int написать можно (мапа принимает
любой сравнимый на вид ключ), а Count[any] уже нельзя,
потому что any не был строго сравним.
Получалось, что дженерик-версия собственного кода была
строже, чем встроенная мапа. В 1.20 правило
удовлетворения ослабили: теперь «satisfies comparable»
шире, чем «is strictly comparable». Цена: возможная паника в
рантайме, ровно такая же, как у обычной мапы с ключом any. Если
нужна гарантия без паник, принимайте не any, а конкретный тип или
собственный узкий констрейнт.
Ordered - объединение всех типов,
для которых определены < <= > >=:
целые, вещественные и строки. Живёт в
golang.org/x/exp/constraints; в stdlib с Go
1.21 попал только cmp.Ordered - ровно то же
множество, но под другим именем и в пакете cmp.
// x/exp/constraints целиком, тут нечего скрывать
type Signed interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type Unsigned interface{ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr }
type Integer interface{ Signed | Unsigned }
type Float interface{ ~float32 | ~float64 }
type Complex interface{ ~complex64 | ~complex128 }
type Ordered interface{ Integer | Float | ~string }
// В stdlib (Go 1.21) есть пакет cmp:
// type cmp.Ordered - то же множество
// func cmp.Compare[T Ordered](x, y T) int // -1 / 0 / +1
// func cmp.Less[T Ordered](x, y T) bool
// func cmp.Or[T comparable](vals ...T) T // Go 1.22: первое ненулевое
Почему не в stdlib
-
Гарантия совместимости навсегда. Всё, что попадает
в
std, нельзя потом изменить. Команда Go осознанно ждала, какие констрейнты реально окажутся нужны на практике, а не какие кажутся красивыми. -
Спорная семантика.
Orderedвключаетfloat64, гдеNaNломает все ожидания:NaN < x,NaN > xиNaN == xвсе три дают false. Из-за этогоcmp.Compareпришлось специально определить так, чтобыNaNсчитался меньше любого числа, иначеslices.Sortвёл бы себя недетерминированно. -
Констрейнты дёшево писать самому. Это буквально
одна строка, а тянуть
x/expв go.mod ради неё многим не хочется: пакет экспериментальный и не даёт гарантий совместимости.
«Для сравнения беру cmp.Ordered из stdlib, он есть с
1.21. Для min/max беру встроенные
min/max, тоже с 1.21, они даже
работают с нетипизированными константами. Свои
Number/Integer объявляю локально
одной строкой, x/exp в зависимости не
добавляю.»
[T any, PT interface{ *T; SomeMethod() }]
нужен, когда функция должна создать значение
T и вызвать на нём метод с указательным
получателем.
Четыре формы констрейнта
// 1) только методы: обычный интерфейс, годится и как тип
type Named interface{ Name() string }
// 2) только типы
type Numeric interface{ ~int | ~int64 | ~float64 }
// 3) пересечение: и underlying, и метод
type Code interface {
~int // underlying обязан быть int
String() string // и обязан быть метод String
}
type HTTPStatus int
func (s HTTPStatus) String() string { return http.StatusText(int(s)) }
// HTTPStatus удовлетворяет Code
// 4) композиция констрейнтов
type SignedNumeric interface{ constraints.Signed | Float }
Приём «указатель на параметр типа»
Задача: функция должна сама создать T и вызвать
на нём метод, объявленный с получателем *T.
Написать [T Unmarshaler] нельзя: тогда
T будет указателем, и var v T даст
nil. Решает второй параметр типа, который констрейнтом
привязан к первому.
type Setter[T any] interface {
*T // PT обязан быть именно *T
SetDefaults() // и иметь этот метод (он с pointer receiver)
}
func Build[T any, PT Setter[T]](n int) []T {
out := make([]T, n)
for i := range out {
PT(&out[i]).SetDefaults() // &out[i] имеет тип *T, конвертируем в PT
}
return out
}
type Config struct {
Timeout time.Duration
}
func (c *Config) SetDefaults() { c.Timeout = 30 * time.Second }
cfgs := Build[Config](3) // PT = *Config выводится из T автоматически
Работает благодаря constraint type inference: зная
T = Config и видя констрейнт *T,
компилятор выводит PT = *Config, так что писать оба
аргумента типа не нужно. Приём стал рабочей лошадкой типизированных
декодеров, ORM-обёрток, билдеров и фабрик.
В стандартной библиотеке аналогично устроен констрейнт
S ~[]E в slices: он привязывает
тип слайса к типу элемента и позволяет вернуть
именованный тип, а не голый []E.
Два механизма
-
Function argument type inference. Сопоставляет типы
фактических аргументов с типами параметров:
Map([]int{...}, strconv.Itoa)→E=int,R=string. -
Constraint type inference. Доводит остальное из
вида констрейнта: зная
S = IDsи констрейнтS ~[]E, выводитE = int. Именно поэтомуslices.Sort(myIDs)работает без скобок.
// ── Работает ─────────────────────────────────────────────
slices.Sort(ids) // S из аргумента, E из констрейнта
Keys(m) // K, V из типа мапы
Build[Config](3) // PT выведен из T
// ── Не работает ──────────────────────────────────────────
func Zero[T any]() T { var z T; return z }
// z := Zero() // cannot infer T - параметр только в результате
z := Zero[int]()
func New[T any]() *T { return new(T) }
// p := New() // то же самое
p := New[Repo]()
var f = Map // cannot use generic function Map without instantiation
var g = Map[int, string] // так - ок
// Литерал функции без типов параметров:
// Reduce(xs, 0, func(a, e) int { return a + e }) // не скомпилируется: у литерала функции типы параметров обязательны
Reduce(xs, 0, func(a, e int) int { return a + e }) // ок
// untyped nil:
// Ptr(nil) // cannot infer T
Ptr[*User](nil)
Что улучшилось в 1.21
-
Вывод, когда дженерик-функцию без скобок передают
аргументом или присваивают переменной с явным
функциональным типом:
var f func([]int, func(int) string) []string = Map(раньше требовались явные скобки). - Вывод из методов: если у аргумента есть метод, подходящий под констрейнт, типы выводятся точнее.
-
Аккуратнее работа с нетипизированными константами:
Max(1, 2.5)теперь выводитfloat64.
Каждый параметр типа должен встречаться
хотя бы в одном обычном аргументе. Если это не так,
поменяйте сигнатуру: не
func Decode[T any](b []byte) (T, error), а
func Decode[T any](b []byte, dst *T) error. Второй
вариант выводится и не заставляет пользователя писать скобки на
каждой строчке.
Аргументы за дженерики
-
Типы не теряются.
Filter([]User, pred)вернёт[]User, а не[]anyс assertion на каждом элементе. -
Нет боксинга. Уложить
intвinterface{}означает, как правило, аллокацию (кроме мелких значений из кэша рантайма). В дженерикеintостаётсяint. -
Ошибки на компиляции.
sort.Slice(42, less)компилируется и падает;slices.Sort(42)не компилируется. -
Убирает зеркальные пакеты. Классика тут
stringsvsbytes: два почти одинаковых пакета, которые нужно править синхронно.
Аргументы за интерфейсы
- Открытость. Любой чужой тип может реализовать ваш интерфейс. Union-констрейнт закрыт: чтобы добавить тип, нужно править саму библиотеку.
-
Тестируемость. Мок подставляется через интерфейс.
Подменить
Tв тесте нельзя: он фиксирован на компиляции. -
Гетерогенность.
[]Shapeс кругами и квадратами работает штатно.[]Tвсегда один тип. -
Читаемость.
func Save(ctx, s Storage)понятнее, чемfunc Save[S ~*T, T Entity](ctx, s S). - Размер бинаря. Интерфейс даёт одну копию кода, дженерик по стенсилу на форму.
| Задача | Правильный инструмент | Почему |
|---|---|---|
Set[T], LRU[K,V], очередь
|
Дженерик | Алгоритм один, тип элемента меняется |
Map/Filter/GroupBy
|
Дженерик | То же |
Storage, PaymentProvider
|
Интерфейс | У каждой реализации своя логика, нужен мок |
io.Reader-подобные абстракции |
Интерфейс | Расширяемость чужим кодом |
Типобезопасный sync.Pool |
Дженерик | Прячет assertion внутрь |
«Универсальный» Repository[T] |
Ни то, ни другое | Конкретные репозитории честнее - специфичные запросы всё равно появятся |
| Плагины, стратегии | Интерфейс | Выбор реализации в рантайме |
| Числовые утилиты | Дженерик | Множество типов замкнуто |
// Показательная пара. Дженерик - там, где меняется тип:
func GroupBy[T any, K comparable](s []T, key func(T) K) map[K][]T {
m := make(map[K][]T)
for _, v := range s { m[key(v)] = append(m[key(v)], v) }
return m
}
// Интерфейс - там, где меняется поведение:
type Notifier interface{ Notify(ctx context.Context, msg string) error }
type Email struct {
...
} // своя реализация
type SMS struct {
...
} // своя реализация
func Broadcast(ctx context.Context, ns []Notifier, msg string) { ... }
// Сделать это дженериком нельзя: []Notifier гетерогенен по определению.
«Дженерики не заменяют интерфейсы, это ортогональный инструмент.
Интерфейс отвечает на вопрос
что объект умеет, а дженерик на вопрос
с чем работает алгоритм. Ошибка, которую я стараюсь не
делать: тянуть дженерик в доменный слой. Там почти всегда меняется
поведение, а не тип, и через полгода абстракция трещит по швам. И отдельный сигнал: если вы
пишете дженерик и внутри у вас type switch по
any(v), дженерик тут не нужен, достаточно обычной
функции от any.»
Три возможных подхода и выбор Go
| Подход | Где | Скорость | Размер бинаря | Компиляция |
|---|---|---|---|---|
| Мономорфизация | C++, Rust | Максимум | Растёт с числом типов | Медленно |
| Стирание типов | Java, Go до 1.18 | Боксинг, косвенные вызовы | Минимум | Быстро |
| GC shape stenciling | Go 1.18+ | Между ними | Растёт с числом форм | Средне |
Правило группировки
Два конкретных типа получают один стенсил тогда и только тогда, когда у них одинаковый underlying type либо оба они указательные.
type UserID int
type Age int
// Index[int], Index[UserID], Index[Age] → один стенсил (underlying = int)
// Index[int64] → отдельный стенсил (другой underlying)
// Index[*User], Index[*Order], Index[*T] → один стенсил (все указатели)
// Index[string] → отдельный
// Index[struct{ A int }] → отдельный
// В pprof и в дизассемблере это выглядит так:
// main.Index[go.shape.int_0]
// main.Index[go.shape.*uint8_0]
Что попадает в словарь
Словарь лежит в бинаре read-only структурой, по одной на каждую конкретную инстанциацию (а не на форму), передаётся скрытым аргументом. Внутри:
-
*runtime._typeкаждого параметра типа: нужен дляmake,new, конверсии вanyи для GC; -
дескрипторы производных типов (
[]T,map[K]V,chan T), которые встречаются в теле; -
itabдля каждого метода, объявленного в констрейнте; - указатели на вложенные инстанциации, если дженерик вызывает другой дженерик;
- словари для замыканий, созданных в теле.
// Концептуально компилятор превращает
func Index[T comparable](s []T, v T) int { ... }
// в нечто вроде
func Index_shape_int(dict *dictionary, s []shapeInt, v shapeInt) int { ... }
// и на каждом вызове подставляет нужный словарь:
// Index_shape_int(&dict_Index_int, ints, 5)
// Index_shape_int(&dict_Index_UserID, ids, UserID(5))
-
Инстанциация только на компиляции. Создать
Stack[T]с типом, известным только в рантайме, невозможно. -
Бинарь растёт от форм, а не от типов. Тысяча
дженериков над
*Tдаёт одну копию кода, и это принципиальное отличие от C++. -
Символы в профиле выглядят странно.
go.shape.int_0в pprof это норма: имя стенсила, а не мусор. - Дженерики удлинили сборку. 1.18 замедлил компиляцию примерно на 15% из-за переделки компилятора под дженерики; к 1.20–1.21 регрессию в основном закрыли.
Откуда берётся замедление
- Чтение словаря. В прологе стенсила добавляется работа с ещё одним скрытым аргументом; для коротких функций это заметная доля.
- Потеря инлайнинга. Метод, объявленный в констрейнте, вызывается через itab из словаря. Компилятор не знает, какой именно код там окажется, и не инлайнит его. Для обычного интерфейса с единственной реализацией в модуле компилятор часто девиртуализирует вызов и инлайнит тело.
- Работа через указатели на pointer-форме. Один стенсил обслуживает все указательные типы, поэтому он не знает конкретную раскладку и вынужден быть максимально общим.
- Меньше оптимизаций «по месту». Границы слайса, размеры элементов, специфика типа: всё, что мономорфный код знает статически, стенсил знает не всегда.
Где дженерики, наоборот, выигрывают
-
Значимые типы (
int,float64,string, структуры): нет боксинга и нет аллокаций, которые обязательны дляinterface{}. -
Сортировка/поиск:
slices.Sortпротивsort.Sliceобычно выигрывает кратно, потому что уходит рефлексия. - Горячие циклы над числами: код почти неотличим от рукописного.
// Бенчмарк, который стоит уметь написать на доске
type Num interface{ ~int64 }
func SumConcrete(xs []int64) int64 { var s int64; for _, x := range xs { s += x }; return s }
func SumGeneric[T Num](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
func SumIface(xs []any) int64 {
var s int64
for _, x := range xs { s += x.(int64) } // плюс аллокации при создании []any
return s
}
func BenchmarkSum(b *testing.B) {
xs := make([]int64, 1_000)
b.Run("concrete", func(b *testing.B) { for b.Loop() { _ = SumConcrete(xs) } })
b.Run("generic", func(b *testing.B) { for b.Loop() { _ = SumGeneric(xs) } })
ys := make([]any, len(xs))
for i, x := range xs { ys[i] = x }
b.Run("iface", func(b *testing.B) { for b.Loop() { _ = SumIface(ys) } })
}
// Типичный результат: concrete ≈ generic (разница в пределах шума),
// iface в разы хуже, и это ещё без учёта аллокаций на упаковку.
Плохой ответ: «дженерики быстрее интерфейсов, потому что
нет боксинга». Это верно только для значимых типов.
Хороший ответ: «на числах и строках дженерик практически
равен рукописному коду и заметно быстрее
interface{}. На указателях и типах с методами
из констрейнта он теряет инлайнинг и девиртуализацию и
может проиграть даже интерфейсу. Поэтому в горячем пути я
не выбираю по интуиции, а пишу бенчмарк с
-benchmem.»
cmp) и расширенные
итераторами в 1.23. slices.Sort -
инстанцированный pdqsort без рефлексии и аллокаций, обычно в
2–4 раза быстрее sort.Slice и проверяется на
компиляции.
Что чем заменяется
| Было | Стало | Что выиграли |
|---|---|---|
sort.Ints(x),
sort.Strings(x)
|
slices.Sort(x) |
Одна функция вместо трёх, работает на любых
cmp.Ordered
|
sort.Slice(x, less) |
slices.SortFunc(x, cmp) |
Нет рефлексии; компаратор возвращает
int, а не bool
|
sort.SearchInts |
slices.BinarySearch |
Возвращает (idx, found), а не «место
вставки»
|
reflect.DeepEqual(a, b) на слайсах
|
slices.Equal |
Без рефлексии, на порядок быстрее |
цикл по мапе + sort |
slices.Sorted(maps.Keys(m)) |
Одна строка, Go 1.23 |
ручной цикл с delete |
maps.DeleteFunc, clear(m)
|
Читаемость |
// Компаратор сменил форму: частая ошибка при миграции
sort.Slice(us, func(i, j int) bool { return us[i].Age < us[j].Age }) // bool, по индексам
slices.SortFunc(us, func(a, b User) int { return cmp.Compare(a.Age, b.Age) }) // int, по значениям
// Сортировка по нескольким полям читается лучше через cmp.Or (Go 1.22)
slices.SortFunc(us, func(a, b User) int {
return cmp.Or(
cmp.Compare(a.LastName, b.LastName),
cmp.Compare(a.FirstName, b.FirstName),
cmp.Compare(a.Age, b.Age),
)
})
// slices.Sort не стабилен (pdqsort). Для стабильности есть SortStableFunc.
// Детерминированный обход мапы:
for _, k := range slices.Sorted(maps.Keys(m)) { // 1.23: maps.Keys - итератор
fmt.Println(k, m[k])
}
// ключи идут по возрастанию при любом запуске, в отличие от голого range по мапе
Почему sort.Slice медленный
Он принимает any, строит
reflect.Value, получает
reflect.Swapper и вызывает переданное замыкание
через интерфейс на каждое сравнение. Плюс проверка «а слайс
ли это?» происходит в рантайме, отсюда классическая паника
sort.Slice(x) на неслайсе.
slices.Sort инстанцируется под конкретный тип:
сравнение сводится к машинной инструкции, свопы к прямым
присваиваниям, аллокаций ноль.
-
slices.Compactубирает только соседние дубли, так что сначала сортируйте. -
slices.Max/Minпаникуют на пустом слайсе. -
Insert/Delete/Compactпортят исходный массив и возвращают новый заголовок: старую переменную использовать нельзя. -
Cloneв обоих пакетах поверхностный. -
slices.Containsищет линейно. Для частых проверок нужна мапа илиSet[T], а не «универсальный хелпер».
Параметры типа у методов: что изменилось в 1.27
До Go 1.27 метод не мог объявить собственные параметры типа вообще, и это ограничение называли главным недостатком дженериков в Go. В 1.27 его сняли для методов конкретных типов:
type Stack[T any] struct {
items []T
}
// Go 1.27: легально
func (s *Stack[T]) MapTo[R any](f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
// А вот это по-прежнему ошибка: interface method must have no type parameters
type Mapper[T any] interface {
MapTo[R any](f func(T) R) *Stack[R]
}
Почему разделение именно такое. Реализация интерфейсов в Go
структурная и проверяется статически: чтобы понять,
реализует ли тип интерфейс, компилятор сверяет конечный
список методов и складывает их адреса в itab.
Для дженерик-метода такого конечного списка не существует:
инстанциаций бесконечно много, и часть появится в чужих пакетах, которых компилятор сейчас не видит. Для
конкретного типа этой проблемы нет: место вызова
s.MapTo(f) компилятор видит целиком и
инстанцирует ровно нужный вариант, как обычную
дженерик-функцию. Формулировка на собес: «сняли там, где
инстанциация известна на компиляции; оставили там, где она
принципиально не известна».
Пример в стандартной библиотеке: дженерик-метод
Rand.N() в math/rand/v2. Старый
обход через свободную функцию (func MapStack[T, R any](s *Stack[T], f func(T) R)
*Stack[R]) остаётся актуальным для кода, который должен собираться
на Go до 1.27, и для случаев, когда метод обязан попасть в
интерфейс.
Специализация
// Так нельзя (C++ template specialization):
// func Print[T any](v T) { fmt.Println(v) }
// func Print[T string](v T) { fmt.Println("str:", v) }
// ошибка компиляции: Print redeclared in this block
// other declaration of Print
// Так можно, но это рантайм-проверка:
func Print[T any](v T) {
if s, ok := any(v).(fmt.Stringer); ok { fmt.Println(s.String()); return }
fmt.Println(v)
}
Print(42) // 42
Print("hi") // hi
Print(90 * time.Second) // 1m30s - у time.Duration есть String(), сработала первая ветка
Причина в читаемости. В C++ специализация означает, что по вызову f(x)
невозможно понять, какой код выполнится, не зная всех
специализаций в программе. Go сознательно требует: одно объявление,
одно поведение.
Ковариантность
type Animal interface{ Sound() string }
type Dog struct{}
func (Dog) Sound() string { return "woof" }
var dogs []Dog
// var animals []Animal = dogs // ошибка: cannot use dogs ([]Dog) as []Animal
type Box[T any] struct {
v T
}
var bd Box[Dog]
// var ba Box[Animal] = bd // ошибка: Box[Dog] и Box[Animal] - разные типы
Причина в типобезопасности. Если бы []Dog можно
было передать как []Animal, то в него можно
было бы записать Cat{}, и исходный слайс
перестал бы быть []Dog. Java разрешила это для
массивов и получила ArrayStoreException в
рантайме. Go выбрал строгую инвариантность:
List[Dog] и List[Animal] просто разные
типы, между ними нет никакого отношения. Плюс у
[]Dog и []Animal физически разная
раскладка в памяти: значения против пар (itab, data).
| Чего нет | Обход |
|---|---|
|
Параметр типа у метода интерфейса у метода конкретного типа - можно с Go 1.27 |
Свободная дженерик-функция |
| Специализация |
switch any(v).(type) либо отдельная
функция
|
| Ковариантность | Дженерик по элементу или явная конверсия слайса в цикле |
| Поля в констрейнтах | Констрейнт с методом-геттером |
| Перегрузка операторов |
Методы Add/Equal/Compare
|
| Динамическая инстанциация |
Интерфейс + any на границе, рефлексия
|
| Sum types (размеченные объединения) | Интерфейс с приватным методом + type switch |
Go 1.24 принёс generic type aliases:
type Set[T comparable] = map[T]struct{}
(раньше алиасы не могли иметь параметров типа). Go 1.26
разрешил дженерик-типу
ссылаться на себя в собственном констрейнте (type Adder[A Adder[A]] interface{ Add(A) A }) и научил new принимать выражение, а не один лишь
тип (p := new(compute())). Go 1.27
снял запрет на параметры типа у методов конкретных типов и
обобщил вывод типов на присваивание дженерик-функции в
переменную. Обсуждаются, но не приняты: sum types,
структурные констрейнты по полям. Общий принцип, который
стоит проговорить: команда Go добавляет фичу, только если
она не усложняет чтение чужого кода. Именно поэтому большая
часть списка выше так и останется в статусе «нельзя».
Что стоит запомнить
- Constraint задаёт множество допустимых типов.
- Дженерик сохраняет связь типов входа и результата.
- Интерфейс задаёт runtime-поведение.
Частые ошибки
- Не добавляй параметр типа без типовой пользы.
- Вывод типа ограничен контекстом.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи значения
Что напечатает фрагмент? Объясни, почему получился такой результат.
func same[T comparable](a, b T) bool { return a == b }
fmt.Println(same(2, 2), same("a", "b"))
Ответ и разбор
Вызовы печатают true false: оба типа удовлетворяют comparable, а функция применяет оператор равенства.
Код-ревью
2. Найди ошибку и исправь
Найди compile error и укажи constraint, который нужен для чисел с упорядочением.
func min[T any](a, b T) T {
if a < b { return a }; return b
}
Ответ и разбор
Сравнение < запрещено для любого T, ограниченного только any. Используй cmp.Ordered из стандартного пакета cmp (Go 1.21+) либо собственный constraint с нужным type set и ~ для именованных типов. Не подменяй constraint на comparable: сравнимые типы не обязательно упорядочены.
Применение
3. Реши задачу
Функция сортировки принимает []T и должна вернуть []T того же конкретного типа элемента. Почему generic API удобнее интерфейса any?
Ответ и разбор
Параметр T связывает тип элементов входного и выходного значений и сохраняет статическую проверку без преобразования из any. Если задача требует вызвать разное поведение объектов во время выполнения, интерфейс выражает это лучше; выбор зависит от того, нужна ли связь типов или динамическая диспетчеризация.
Применение
4. Реши задачу
Есть func Pair[T any](a, b T), вызов Pair(1, "x") не компилируется. Как вызвать функцию корректно и почему?
Ответ и разбор
Оба аргумента должны разрешаться в один T, но int и string разные типы. Передай два значения одного типа, например Pair(1, 2). Явное Pair[int](1, 2) может задать T явно, но не превращает string в int.