- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Когда в феврале 2022-го вышел Go 1.18 с дженериками, сообщество разделилось на два лагеря. Первые кричали «наконец-то!» и бросились переписывать всё подряд. Вторые — «не нужны были, не нужны и сейчас». Прошло три года. Пыль улеглась. И я хочу поговорить не о том, как написать
До 1.18 у нас было два пути: дублировать код под каждый тип или использовать
Дженерики предложили писать код один раз так, чтобы он работал с разными типами, и при этом компилятор проверял корректность на этапе сборки.
Самое интересное в Go-дженериках — это система ограничений. Ограничение — это интерфейс, который описывает множество допустимых типов. И тут Go делает кое-что, чего нет ни в Java, ни в C#: ограничения могут содержать конкретные типы.
Тильда перед типом означает «этот тип и все типы, у которых underlying type совпадает». Без тильды только сам тип.
В коде вы почти всегда хотите тильду. Без неё ваши дженерик-функции не примут пользовательские типы, определённые через
Самый очевидный и самый полезный кейс для дженериков — операции над слайсами. До 1.18 в Go не было даже
А вот
Это тот случай, где дженерики дали максимум пользы при минимуме сложности.
Кеши, пулы, очереди — всё это раньше работало через
Использование:
Без дженериков у вас было бы
Go-шное
Полезно ли это в Go? Многие считают, что
Map, Filter, Reduce — классические функции, которые в Go были невозможны в generic-виде до 1.18:
Использование:
Вполне работает и даже читаемо, но тут есть нюанс.
Я видел немало попыток втащить в Go функциональный стиль через дженерики, и большинство из них выглядят неидиоматично. Код из предыдущего примера вполне ок. Но когда начинаются цепочки вида:
...это уже попытка превратить Go в Haskell. Go — не Haskell. Его сила в том, что
Ещё один антипаттерн — дженерики ради дженериков:
Если у вашей generic-функции только один допустимый тип — дженерик не нужен.
Третий случай — generic-обёртки над тем, что прекрасно работает без них:
Тут
Четвёртый — попытка сделать «универсальный сервис»:
Три type-параметра ради CRUD-а. Каждый потребитель этого сервиса будет писать
Интерфейсы Go и так дают полиморфизм. Дженерики нужны, когда интерфейсов недостаточно — когда вы хотите сохранить конкретный тип на выходе, а не потерять его за
Используйте, когда пишете утилиту или контейнер, который работает с произвольным типом: кеш, очередь, слайс-хелперы, результат-типы.
Не используйте, когда конкретный тип известен, когда хотите сделать красиво и функционально вместо простого
Если хотите системно прокачать Go, на курсе «Разработчик на Golang. Уровень Про» разбираем идиоматику и внутреннее устройство языка, практику серверной разработки: сервисы, API, базы данных, типовые ошибки в команде. Отдельно смотрим миграции высоконагруженных систем на Go. Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.
Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные уроки:
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». Записаться