voidlearn
 

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] компилируется. Запрет остался только для методов интерфейса. Подробности ниже, отдельным разделом.
  • Дженерик-тип без аргументов типа ещё не тип, а «шаблон». Его нельзя положить в поле, объявить переменной или использовать в сигнатуре без инстанцирования.
Анатомия объявления func Map [E any, R any] (s []E, f func(E) R) []R имя список параметров типа E, R - имена · any - констрейнт обычная сигнатура E и R уже можно использовать как типы Инстанцирование: компилятор подставляет конкретные типы Map(ints, strconv.Itoa) Map(users, User.Name) Map[string, int](ss, atoi) Map[int, string] Map[User, string] Map[string, int] выведено из аргументов выведено из аргументов задано явно Вывод идёт только от типов АРГУМЕНТОВ вниз; тип результата в выводе не участвует - из него ничего вывести нельзя.
Объявление и инстанцирование. Квадратные скобки образуют «второй уровень» сигнатуры: сначала подставляются типы, потом функция становится обычной. Инстанцирование случается на компиляции, в рантайме никакого «дженерика» уже нет.

Констрейнты: интерфейс как множество типов

Формулировка, которую стоит держать наготове: в 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 случайно не попал в арифметику для «сырых» наносекунд).

Констрейнт = множество типов (type set) any - все типы Go comparable - есть строгий == constraints.Ordered есть < <= > >= ~int | ~int64 и все производные string float64 struct{A int} [4]byte *User []byte map[k]v func() any == паникует или запрещён Как читать запись int - ровно тип int ~int - int и все type X int int | string - объединение ~int; Str() string - пересечение: и underlying, и метод Интерфейс с | или ~ или comparable - только констрейнт, не тип переменной. Почему множества, а не «список методов» Операции +, <, range, индексация не выражаются через методы. Единственный способ разрешить их для параметра типа - перечислить типы, где они есть.
Type set. Компилятор разрешает над значением типа 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
Тонкость Go 1.20, о которой знают немногие

До 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
}
С Go 1.21 половина этого не нужна

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
Зачем вообще приём «~[]E»

Если объявить 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 - штатно
Критерий, по которому решать за 5 секунд

Задайте вопрос: меняется ли алгоритм в зависимости от типа? Если нет, код один и тот же, отличается только тип элемента, берите дженерик (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-документа звучит буквально так:

Правило GC shape

Два конкретных типа попадают в одну gcshape-группу тогда и только тогда, когда у них одинаковый underlying type или оба оказываются указательными типами. Следствия: *User и *Order дают один стенсил; type UserID int и int тоже один; а вот int и int64 дадут разные стенсилы, несмотря на одинаковый размер.

Один исходник func Index[T comparable](s []T, v T) int Мономорфизация (C++/Rust) - для сравнения 6 инстанциаций → 6 копий машинного кода + максимум скорости: всё известно, всё инлайнится − размер бинаря и время компиляции растут линейно Инстанциации в программе int UserID (=int) int64 string *User *Order 4 стенсила - по одному на GC shape Index[shape int] int + UserID Index[shape int64] только int64 Index[shape string] только string Index[shape *uint8] ВСЕ указательные типы словарь на инстанциацию Что это даёт и чего стоит + Бинарь растёт не от числа типов, а от числа ФОРМ: тысяча *T-инстанциаций даёт одну копию кода. + Для non-pointer форм (int, string, struct) значения лежат по значению - без боксинга и аллокаций. − Для pointer-формы код не знает конкретный тип: размеры, методы и типовые дескрипторы берутся из словаря - лишняя косвенность.
GC shape stenciling. Компромисс между размером бинаря и скоростью. Именно из-за объединения всех указателей в одну форму дженерик над *T в горячем цикле может проигрывать и мономорфному коду, и даже обычному интерфейсу.

Что лежит в словаре

Словарём зовут статическую структуру, которую компилятор кладёт в read-only секцию бинаря (по одной на каждую конкретную инстанциацию, не на форму) и передаёт скрытым первым аргументом. Внутри лежит всё, чего стенсил знать не может: он один на много типов.

Содержимое словаря Зачем нужно
*runtime._type для каждого параметра типа Аллокация (make, new), конверсия в any, работа GC
Дескрипторы производных типов ([]T, map[K]V…) make([]T, n) внутри дженерика
itab для методов констрейнта Вызов метода, объявленного в констрейнте
Ссылки на вложенные инстанциации Дженерик вызывает другой дженерик с теми же T
Словари для замыканий внутри тела Замыкание тоже должно уметь работать с T
Три способа сделать «одну и ту же» работу - и что происходит на вызове Конкретный тип SumInts(xs []int) · аргумент в регистре · размер элемента известен · инлайн и вектор-оптимизации · 0 аллокаций эталон скорости N копий кода при N типах Дженерик Sum[T Number](xs []T) · скрытый арг: *dict · non-ptr форма - как эталон · ptr-форма - всё через *T · метод констрейнта → dict → itab быстро на числах/строках двойная косвенность на указателях Интерфейс Sum(xs []Number) · значение упаковано: (itab, data) · укладка int в интерфейс - аллокация · вызов метода - косвенный · девиртуализация только иногда аллокации на каждом элементе 1 копия кода на все типы Почему дженерик иногда МЕДЛЕННЕЕ обоих Стенсил для pointer-формы не знает конкретный тип, поэтому: (1) не может заинлайнить метод констрейнта - только вызов через словарь; (2) теряет devirtualization, которую компилятор умеет делать для мономорфного интерфейса; (3) добавляет чтение словаря в прологе. Вывод для собеса: «дженерики всегда быстрее интерфейсов» - неверно. Быстрее на значимых типах, спорно на указателях, всегда - надо мерить.
Цена абстракции. Для 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.22 Delete ещё и обнуляет освободившийся хвост, чтобы не держать ссылки для 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] в качестве встроенного типа.

Вопросы

11
Суть: параметры типа объявляются в квадратных скобках после имени (func 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]) ... даст ошибку компиляции. Констрейнт пишется один раз, у объявления типа.

Суть: начиная с 1.18 интерфейс - это множество типов; методы задают множество неявно («все типы с такими методами»), а | и ~ - явно. ~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()
Basic vs non-basic интерфейс

Интерфейс с |, ~ или 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 - хороший ответ «а зачем это поменяли»

До 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.

Суть: Go выводит типы от типов аргументов и из констрейнтов, а с 1.21 ещё из функционального типа, которому присваивают саму дженерик-функцию. Из типа результата вызова и из тела вывод не идёт. Нет параметра типа среди аргументов - пишем скобки руками.

Два механизма

  • 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.
Правило проектирования API

Каждый параметр типа должен встречаться хотя бы в одном обычном аргументе. Если это не так, поменяйте сигнатуру: не 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) не компилируется.
  • Убирает зеркальные пакеты. Классика тут strings vs bytes: два почти одинаковых пакета, которые нужно править синхронно.

Аргументы за интерфейсы

  • Открытость. Любой чужой тип может реализовать ваш интерфейс. 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 не делает ни полной мономорфизации, ни полного стирания. Компилятор генерирует одну копию кода на GC-форму типа (все указатели - одна форма) и передаёт скрытым первым аргументом словарь с типовыми дескрипторами и itab-ами для того, что зависит от конкретного типа.

Три возможных подхода и выбор 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 регрессию в основном закрыли.
Суть: да, оба варианта возможны. Медленнее мономорфного кода - почти всегда на pointer-формах (лишняя косвенность через словарь, хуже инлайнинг). Медленнее интерфейса - реже, но бывает: компилятор умеет девиртуализировать вызов через интерфейс, когда тип известен, а вызов через словарь девиртуализовать сложнее.

Откуда берётся замедление

  • Чтение словаря. В прологе стенсила добавляется работа с ещё одним скрытым аргументом; для коротких функций это заметная доля.
  • Потеря инлайнинга. Метод, объявленный в констрейнте, вызывается через 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.»

Суть: два дженерик-пакета, переехавшие в stdlib в Go 1.21 (вместе с 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], а не «универсальный хелпер».
Суть: параметры типа у методов появились в Go 1.27 - но только у методов конкретных типов; у методов интерфейса их по-прежнему нет (это ломало бы статическую проверку интерфейсов). Нет специализации (нарушает «одно тело - одно поведение»), нет ковариантности (типы инвариантны - иначе через контейнер можно нарушить типобезопасность), нет полей в констрейнтах и перегрузки операторов.

Параметры типа у методов: что изменилось в 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.