golang

Дженерики в Go: три года спустя

  • вторник, 3 марта 2026 г. в 00:00:06
https://habr.com/ru/companies/otus/articles/1003110/

Когда в феврале 2022-го вышел Go 1.18 с дженериками, сообщество разделилось на два лагеря. Первые кричали «наконец-то!» и бросились переписывать всё подряд. Вторые — «не нужны были, не нужны и сейчас». Прошло три года. Пыль улеглась. И я хочу поговорить не о том, как написать func Max[T constraints.Ordered](a, b T) T — этому посвящены тысячи туториалов. Я хочу поговорить о том, что реально прижилось, какие паттерны оказались полезными, а где дженерики только мешают.

До 1.18 у нас было два пути: дублировать код под каждый тип или использовать interface{} (а теперь any) с приведением типов в рантайме. Оба такой вот компромисс.

Дженерики предложили писать код один раз так, чтобы он работал с разными типами, и при этом компилятор проверял корректность на этапе сборки.

Type constraints: не просто 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). С дженериками компилятор гарантирует типы.

Result/Option-паттерны

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{}. Если вам достаточно поведения (методов) — интерфейс предпочтительнее.


Когда использовать дженерики в Go

Используйте, когда пишете утилиту или контейнер, который работает с произвольным типом: кеш, очередь, слайс-хелперы, результат-типы.

Не используйте, когда конкретный тип известен, когда хотите сделать красиво и функционально вместо простого for, когда ваш constraint описывает ровно один тип.

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

Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные уроки:

  • 10 марта, 20:00. «Дженерики в Go: от синтаксиса до смысла». Записаться

  • 25 марта, 20:00. «Инструменты профилирования и оптимизации в Go». Записаться