Дженерики в Go

Материал из llmdocs — справочник разработчика
Портал: Go и Rust
Дженерики в Go
ПоявилисьGo 1.18 (март 2022)
ПредложениеType Parameters Proposal (Иан Лэнс Тейлор, Роберт Гриземер)
Синтаксисfunc NameT Constraint
Ограниченияинтерфейсы, в том числе наборы типов (~int | ~float64)
Встроенные ограниченияany, comparable
Стандартные пакетыslices, maps, cmp, iter

Дженерики в Go (параметры типов, англ. type parameters) — возможность писать функции и типы, которые работают с разными типами данных, сохраняя статическую проверку. Они появились в Go 1.18 в марте 2022 года — после более чем десяти лет обсуждений и нескольких отвергнутых проектов — и стали самым крупным изменением языка с версии 1.0.[1][3]

Зачем

До дженериков обобщённый код в Go писали тремя способами, у каждого из которых есть недостатки:

  • копированием — SumInts, SumFloats, SumInt64s;
  • через пустой интерфейс interface{} — с потерей проверки типов и приведением в рантайме;
  • генерацией кода (go generate).

С параметрами типов одна функция подходит для всех нужных типов, а ошибки ловит компилятор.

Синтаксис

Параметры типов объявляются в квадратных скобках перед обычными параметрами. Каждый параметр имеет ограничение (constraint) — интерфейс, описывающий допустимые типы:[2]

func Map[T, U any](s []T, f func(T) U) []U {
    out := make([]U, 0, len(s))
    for _, v := range s {
        out = append(out, f(v))
    }
    return out
}

lengths := Map([]string{"go", "rust"}, func(s string) int { return len(s) })
// [2 4] — T и U выведены компилятором

В большинстве вызовов аргументы типов выводятся автоматически; явно их пишут, когда вывести нельзя: Map[string, int](...).

Ограничения и наборы типов

Интерфейс в Go 1.18 стал описывать не только набор методов, но и набор типов:[5]

type Number interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Sum[T Number](xs []T) T {
    var total T
    for _, x := range xs {
        total += x
    }
    return total
}
  • | перечисляет допустимые типы;
  • ~int означает «любой тип, чьё базовое представление — int», включая пользовательские вроде type UserID int;
  • any — синоним interface{}, любой тип;
  • comparable — типы, поддерживающие == и !=; нужен для ключей map;
  • пакет cmp предоставляет cmp.Ordered — типы с операторами <, >.

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

Обобщённые типы

type Stack[T any] struct {
    items []T
}

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]
st.Push("a")

Ограничение языка: методы не могут иметь собственных параметров типов — только использовать параметры своего типа. Поэтому цепочки вида stream.Map(...).Filter(...) с меняющимся типом в Go пишут обычными функциями.

Стандартная библиотека

Дженерики позволили добавить в стандартную библиотеку типобезопасные утилиты:[7]

import ("slices"; "maps")

xs := []int{3, 1, 2}
slices.Sort(xs)                       // [1 2 3]
i, found := slices.BinarySearch(xs, 2)
slices.Contains(xs, 5)                // false
keys := slices.Sorted(maps.Keys(m))   // отсортированные ключи map

В Go 1.23 появились итераторы — функции вида iter.Seq[T], по которым можно проходить обычным for range; они тоже обобщённые.[6] sync.Map и atomic.Pointer[T] — другие примеры обобщённых типов в библиотеке.

Реализация

Компилятор Go использует компромисс между полной мономорфизацией (как в Rust и C++, где для каждого типа генерируется отдельная копия кода) и упаковкой в интерфейсы (как в Java): функции компилируются по одной копии на «форму» типа (GC shape) — например, одна копия на все указатели — с передачей словаря с информацией о типе. Это ускоряет компиляцию и уменьшает размер бинарника, но обобщённый код иногда работает чуть медленнее, чем написанный вручную для конкретного типа. В Rust ту же задачу решают трейты с мономорфизацией.

Когда использовать

Авторы Go советуют не превращать дженерики в инструмент по умолчанию:[4]

  • Использовать — для функций над срезами, map и каналами любого типа элемента; для обобщённых структур данных (стеки, очереди, деревья, кэши); когда иначе пришлось бы копировать одну и ту же реализацию.
  • Не использовать — если достаточно обычного интерфейса с методами (io.Reader лучше, чем [T io.Reader]); если реализация различается для разных типов; если код становится сложнее читать.

Правило из блога Go: начинайте писать код без параметров типов и добавляйте их, когда обнаружите, что пишете одно и то же несколько раз.

См. также

Источники

  1. Go — Type Parameters Proposal
  2. Go Tutorial — Getting started with generics
  3. Go Blog — An Introduction To Generics
  4. Go Blog — When To Use Generics
  5. The Go Programming Language Specification — Type parameter declarations
  6. Go Blog — Range Over Function Types
  7. Go — package slices