Дженерики в Go: три года спустя
- вторник, 3 марта 2026 г. в 00:00:06
Когда в феврале 2022-го вышел Go 1.18 с дженериками, сообщество разделилось на два лагеря. Первые кричали «наконец-то!» и бросились переписывать всё подряд. Вторые — «не нужны были, не нужны и сейчас». Прошло три года. Пыль улеглась. И я хочу поговорить не о том, как написать func Max[T constraints.Ordered](a, b T) T — этому посвящены тысячи туториалов. Я хочу поговорить о том, что реально прижилось, какие паттерны оказались полезными, а где дженерики только мешают.
До 1.18 у нас было два пути: дублировать код под каждый тип или использовать interface{} (а теперь any) с приведением типов в рантайме. Оба такой вот компромисс.
Дженерики предложили писать код один раз так, чтобы он работал с разными типами, и при этом компилятор проверял корректность на этапе сборки.
Самое интересное в Go-дженериках — это система ограничений. Ограничение — это интерфейс, который описывает множество допустимых типов. И тут Go делает кое-что, чего нет ни в Java, ни в C#: ограничения могут содержать конкретные типы.
// Классический интерфейс: описывает поведение (методы) type Stringer interface { String() string } // Type constraint: описывает множество типов type Numeric interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 }
Тильда перед типом означает «этот тип и все типы, у которых underlying type совпадает». Без тильды только сам тип.
type UserID int64 // Без тильды: UserID НЕ подходит под constraint `int64` // С тильдой: UserID подходит под constraint `~int64`
В коде вы почти всегда хотите тильду. Без неё ваши дженерик-функции не примут пользовательские типы, определённые через type X int, а таких типов в любом проекте полно.
Самый очевидный и самый полезный кейс для дженериков — операции над слайсами. До 1.18 в Go не было даже Contains для слайса. Приходилось писать цикл каждый раз или тащить зависимость. Сейчас в стандартной библиотеке есть пакет slices:
package main import ( "fmt" "slices" ) func main() { names := []string{"Artem", "Ivan", "Nikolay"} fmt.Println(slices.Contains(names, "Bob")) // true fmt.Println(slices.Index(names, "Charlie")) // 2 sorted := slices.Clone(names) slices.Sort(sorted) fmt.Println(sorted) // [Artem Ivan Nikolay] // Удаление элемента (без аллокации нового слайса) names = slices.Delete(names, 1, 2) // удаляем Ivan fmt.Println(names) // [Artem Nikolay] }
А вот maps — для операций с map:
package main import ( "fmt" "maps" ) func main() { m1 := map[string]int{"a": 1, "b": 2} m2 := map[string]int{"b": 2, "a": 1} fmt.Println(maps.Equal(m1, m2)) // true // Собрать ключи в слайс keys := slices.Collect(maps.Keys(m1)) fmt.Println(keys) }
Это тот случай, где дженерики дали максимум пользы при минимуме сложности. slices и maps — пакеты, которыми я пользуюсь каждый день.
Кеши, пулы, очереди — всё это раньше работало через interface{} и type assertion. Теперь можно сделать нормально:
package cache import ( "sync" "time" ) type entry[V any] struct { value V expiresAt time.Time } type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]entry[V] ttl time.Duration } func New[K comparable, V any](ttl time.Duration) *Cache[K, V] { return &Cache[K, V]{ items: make(map[K]entry[V]), ttl: ttl, } } func (c *Cache[K, V]) Set(key K, value V) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = entry[V]{ value: value, expiresAt: time.Now().Add(c.ttl), } } func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.RLock() defer c.mu.RUnlock() e, ok := c.items[key] if !ok || time.Now().After(e.expiresAt) { var zero V return zero, false } return e.value, true }
Использование:
// Типобезопасно. Компилятор не даст положить int туда, где ждут string. userCache := cache.New[int64, *User](5 * time.Minute) userCache.Set(42, &User{Name: "Alice"}) sessionCache := cache.New[string, *Session](30 * time.Minute) sessionCache.Set("abc123", &Session{UserID: 42})
Без дженериков у вас было бы map[interface{}]interface{} и каждый Get начинался бы с val, ok := result.(*User). С дженериками компилятор гарантирует типы.
Go-шное (value, error) — хорошо, но иногда хочется чуть больше выразительности. Дженерики позволяют описать Result-тип:
type Result[T any] struct { value T err error } func Ok[T any](v T) Result[T] { return Result[T]{value: v} } func Err[T any](e error) Result[T] { return Result[T]{err: e} } func (r Result[T]) Unwrap() (T, error) { return r.value, r.err } func (r Result[T]) Map(fn func(T) T) Result[T] { if r.err != nil { return r } return Ok(fn(r.value)) } func (r Result[T]) IsOk() bool { return r.err == nil }
Полезно ли это в Go? Многие считают, что (T, error) идиоматичнее, и я с ними согласен. Но в пайплайнах обработки данных, где ошибки нужно протаскивать через цепочку трансформаций, Result[T] бывает удобен.
Map, Filter, Reduce — классические функции, которые в Go были невозможны в generic-виде до 1.18:
func Map[T, U any](s []T, fn func(T) U) []U { result := make([]U, len(s)) for i, v := range s { result[i] = fn(v) } return result } func Filter[T any](s []T, fn func(T) bool) []T { var result []T for _, v := range s { if fn(v) { result = append(result, v) } } return result } func Reduce[T, U any](s []T, initial U, fn func(U, T) U) U { acc := initial for _, v := range s { acc = fn(acc, v) } return acc }
Использование:
orders := []Order{...} // Достать суммы из заказов amounts := Map(orders, func(o Order) float64 { return o.Amount }) // Только оплаченные paid := Filter(orders, func(o Order) bool { return o.Status == "paid" }) // Суммарная выручка total := Reduce(paid, 0.0, func(acc float64, o Order) float64 { return acc + o.Amount })
Вполне работает и даже читаемо, но тут есть нюанс.
Я видел немало попыток втащить в Go функциональный стиль через дженерики, и большинство из них выглядят неидиоматично. Код из предыдущего примера вполне ок. Но когда начинаются цепочки вида:
// Пожалуйста, не делайте так в Go result := Pipe( orders, Filter(isPaid), Map(toAmount), Reduce(sum, 0.0), )
...это уже попытка превратить Go в Haskell. Go — не Haskell. Его сила в том, что for и if читаются мгновенно, и новый человек в команде может понять код за минуту. Цепочки из generic-функций эту читаемость убивают.
Ещё один антипаттерн — дженерики ради дженериков:
// Зачем?? Функция работает только с одним типом. func ProcessUser[T User](u T) error { ... } // Просто напишите: func ProcessUser(u User) error { ... }
Если у вашей generic-функции только один допустимый тип — дженерик не нужен.
Третий случай — generic-обёртки над тем, что прекрасно работает без них:
// Оверинжиниринг: generic логгер type Logger[T any] struct{} func (l Logger[T]) Log(item T) { fmt.Printf("%v\n", item) } // Вы изобрели fmt.Println с лишним шагом. // Просто используйте: func Log(item any) { fmt.Printf("%v\n", item) }
Тут any в обычном параметре делает ровно то же самое. Дженерик-параметр ничего не добавил — ни типобезопасности (внутри всё равно %v), ни удобства. Просто лишний синтаксис.
Четвёртый — попытка сделать «универсальный сервис»:
// Выглядит умно, работает мучительно type Service[T Entity, R Repository[T], V Validator[T]] struct { repo R val V } func (s Service[T, R, V]) Create(ctx context.Context, item T) error { if err := s.val.Validate(item); err != nil { return err } return s.repo.Save(ctx, item) }
Три type-параметра ради CRUD-а. Каждый потребитель этого сервиса будет писать Service[User, UserRepo, UserValidator] — и проклинать вас. Сравните с обычными интерфейсами:
type Service struct { repo Repository val Validator }
Интерфейсы Go и так дают полиморфизм. Дженерики нужны, когда интерфейсов недостаточно — когда вы хотите сохранить конкретный тип на выходе, а не потерять его за interface{}. Если вам достаточно поведения (методов) — интерфейс предпочтительнее.
Используйте, когда пишете утилиту или контейнер, который работает с произвольным типом: кеш, очередь, слайс-хелперы, результат-типы.
Не используйте, когда конкретный тип известен, когда хотите сделать красиво и функционально вместо простого for, когда ваш constraint описывает ровно один тип.

Если хотите системно прокачать Go, на курсе «Разработчик на Golang. Уровень Про» разбираем идиоматику и внутреннее устройство языка, практику серверной разработки: сервисы, API, базы данных, типовые ошибки в команде. Отдельно смотрим миграции высоконагруженных систем на Go. Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.
Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные уроки:
10 марта, 20:00. «Дженерики в Go: от синтаксиса до смысла». Записаться
25 марта, 20:00. «Инструменты профилирования и оптимизации в Go». Записаться