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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.