Раздел 22 · Go

Generics: ограничения, обобщённые структуры, стек

middle~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перед этим уроком

Generics: ограничения, обобщённые структуры, стек

Generics в Go появились в марте 2022 (версия 1.18) после 12 лет дискуссий. Вокруг этого факта живёт два популярных заблуждения, и оба надо сразу обнулить.

Заблуждение 1: “авторы Go против дженериков, потому что Роб Пайк не любит сложные системы типов”. Нет. Дженерики выкатили, когда смогли уложить их в дизайн без потери главного приоритета языка.

Заблуждение 2: “дженерики получились скромными, потому что команде хотелось минимализма”. Тоже нет. Они скромные, потому что во главе угла Go стоит скорость компиляции, а каждый дополнительный механизм системы типов (вариативные параметры, ассоциированные типы, специализация, generic-методы) её замедляет.

Чтобы это уложилось, держи в голове: Go это язык Google. Google пишет тесты, Google гоняет тесты, Google компилирует тесты, и счёт там идёт на миллионы запусков в день. Если компиляция занимает больше пары секунд, разработчики просто перестают запускать тесты локально, и весь язык можно выкидывать. Поэтому скорость компиляции для Go это не “приятная фича”, а условие выживания. Дженерики сделали ровно настолько богатыми, чтобы можно было написать Min, Max, Sort, Set, Stack без обвала компайл-тайма, и ни шагом дальше.

Этот урок собирает всё, что нужно знать junior-уровню. После него ты сможешь читать дженериковый код в slices, maps, cmp, sync.OnceValue, и писать свой типобезопасный контейнер, понимая, почему API именно такой, а не богаче.

1. Проблема, которую решают generics

До 1.18 такая функция была невозможна:

// хотим написать так, не работало в 1.17 и ранее
func Min[T Ordered](a, b T) T {
    if a < b { return a }
    return b
}

Что приходилось делать:

// Способ 1: писать одну функцию на каждый тип, дублирование
func MinInt(a, b int) int { ... }
func MinFloat(a, b float64) float64 { ... }
func MinString(a, b string) string { ... }

// Способ 2: через interface{} и потерять типобезопасность
func Min(a, b interface{}) interface{} { ... }
result := Min(1, 2).(int) // type assertion на каждом вызове

// Способ 3: кодогенерация через go:generate, как делал sort до 1.21
//go:generate go run gen_min.go

Все три способа: либо много копипаста, либо потеря типов, либо лишняя машинерия. Generics закрывают вопрос.

2. Type parameters, базовый синтаксис

Тип параметра объявляется в квадратных скобках перед списком обычных параметров:

func Min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

T это имя параметра-типа (то же самое что T в Java/C++ generics), cmp.Ordered это ограничение, описывающее, какой тип можно подставить.

Вызов:

Min(1, 2)         // T выводится = int
Min(1.5, 2.5)     // T = float64
Min("a", "b")     // T = string

// явный (редко нужен)
Min[int](1, 2)

Go сам выводит тип параметра по аргументам. Явное указание нужно только когда вывести нельзя (например, generic-фабрика без аргументов).

Как параметр-тип попадает в функцию

func Map[T, U any](xs []T, f func(T) U) []U {
    out := make([]U, len(xs))
    for i, x := range xs {
        out[i] = f(x)
    }
    return out
}

doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
// [2 4 6], тип []int

asStr := Map([]int{1, 2, 3}, strconv.Itoa)
// ["1" "2" "3"], тип []string

Два параметра-типа [T, U any]. any это самое широкое ограничение, “любой тип”.

3. Готовые ограничения

Stdlib даёт два главных constraint-а:

comparable

func Find[T comparable](xs []T, target T) int {
    for i, x := range xs {
        if x == target {
            return i
        }
    }
    return -1
}

comparable это все типы, поддерживающие == и !=:

  • числа, строки, bool, указатели,
  • channel, interface,
  • struct, если все поля comparable,
  • массивы из comparable.

НЕ comparable:

  • slice ([]int, []string),
  • map,
  • func.

Если ты пишешь Set[T], нужен comparable, иначе == не сработает для проверки уникальности.

cmp.Ordered

С Go 1.21 в stdlib пакет cmp со constraint-ом для упорядоченных типов:

import "cmp"

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64 |
    ~string
}

Это все типы, на которых работают <, >, <=, >=. Используется в Min, Max, Sort.

~ означает “и все типы, основанные на этом”. То есть type MyInt int тоже проходит через ~int.

Свои constraint-ы

Constraint, это интерфейс. Ты можешь объявить свой:

type Numeric interface {
    ~int | ~int64 | ~float64
}

func Sum[T Numeric](xs []T) T {
    var s T
    for _, x := range xs {
        s += x
    }
    return s
}

Sum([]int{1, 2, 3})       // 6
Sum([]float64{1.5, 2.5})  // 4

Это type set, явное перечисление через |. Используется только в constraint-интерфейсах, не в обычных интерфейсах.

4. Обобщённые структуры

Generics работают и в struct, и в method receiver:

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

func (s *Stack[T]) Push(x T) {
    s.data = append(s.data, x)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.data) == 0 {
        return zero, false
    }
    x := s.data[len(s.data)-1]
    s.data = s.data[:len(s.data)-1]
    return x, true
}

func (s *Stack[T]) Peek() (T, bool) {
    var zero T
    if len(s.data) == 0 {
        return zero, false
    }
    return s.data[len(s.data)-1], true
}

func (s *Stack[T]) Len() int {
    return len(s.data)
}

Использование:

ints := &Stack[int]{}
ints.Push(1); ints.Push(2); ints.Push(3)
v, ok := ints.Pop() // 3, true

names := &Stack[string]{}
names.Push("a"); names.Push("b")

Заметь:

  • var zero T это идиома для “zero value параметра-типа”. Ты не знаешь конкретный T, но знаешь что у него есть zero value.
  • Pop возвращает (T, bool), идиома Go: “может не быть, не ошибка”.
  • Все методы на pointer receiver, потому что Push/Pop мутируют.

Set[T comparable]

type Set[T comparable] struct {
    m map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{m: make(map[T]struct{})}
}

func (s *Set[T]) Add(x T)        { s.m[x] = struct{}{} }
func (s *Set[T]) Remove(x T)     { delete(s.m, x) }
func (s *Set[T]) Has(x T) bool   { _, ok := s.m[x]; return ok }
func (s *Set[T]) Len() int       { return len(s.m) }
func (s *Set[T]) Slice() []T {
    out := make([]T, 0, len(s.m))
    for k := range s.m {
        out = append(out, k)
    }
    return out
}

Это типичный пример где comparable обязателен: ключ map[T]struct{}, ключи map должны быть comparable.

struct{} пустая структура, занимает 0 байт. Идиома “use map as set”: ключ важен, значение не нужно.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.