Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11

Generics в Go: почему их ждали, а массово не используют?

Сообщество получило то, о чём просило, и ... проигнорировало. Как же так — дженерики в Go ломают привычные паттерны разработки?

554 открытий3К показов
Generics в Go: почему их ждали, а массово не используют?

Создателей Go 13 лет атаковали просьбами добавить дженерики. Мечта сбылась: новая функция должна была избавить от дублирования кода, упростить работу с алгоритмами и сделать типы более безопасными.

Заметили, что массового внедрения не произошло? Разработчики не спешат переписывать рабочий код на дженериках.

По каким причинам обобщённые типы оказались невостребованными? — ответим на этот вопрос вместе с участниками тг-канала Golang GO.

Почему дженерики не вытеснили интерфейсы?

Интерфейсы задают структуру программы: реализуют паттерны декоратора, роутера, зависимости. Дженерики лишь усиливают существующие интерфейсы, делают их безопаснее.

Разработчики, ожидавшие революцию в проектировании, обнаружили, что дженерики годятся только для узкого пула задач.

Тащить их везде может быть чревато не только потерей производительности, но и рефакторингом больших объёмов кода. Далеко не каждая компания сейчас потянет такие затраты. Тем более, если неясны перспективы.

Наиболее оправданный случай использования дженериков — замена interface{} в местах, где тип известен на этапе компиляции:

			// До дженериков
func processJob(job interface{}) error {
    actualJob, ok := job.(MyJobType)
    if !ok {
        return errors.New("wrong job type")
    }
    // обработка...
}

// С дженериками
func processJob[T JobInterface](job T) error {
    // обработка без type assertion
}
		

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

Практика показывает, что обобщённые типы нашли применение в конкретных областях.

У нас они часто используются: много дженерик-функций для кеширования, маппингов, сохранения в базы данных, передачи по gRPC, работы с брокерами, почти вся CRUD-админка на дженериках. Из плюсов — меньше кода, из минусов — иногда сложно понять, что происходит с конкретной сущностью

1. Пулы соединений и кэширование

Наиболее очевидное применение дженериков — создание типизированных контейнеров.

Например, этот пул соединений будет работать с любым типом ресурсов, поддерживающих закрытие:

			type Pool[T io.Closer] struct {
    resources chan T
}

func (p *Pool[T]) Get() T {
    return <-p.resources
}

func (p *Pool[T]) Put(resource T) {
    p.resources <- resource
}
		

Аналогично с кэшированием. Можно создавать строго типизированный кэш, который возвращает значения правильного типа на этапе компиляции:

			type Cache[K comparable, V any] struct {
    data map[K]V
    mu   sync.RWMutex
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}
		

2. Математические операции

Дженерики избавляют от дублирования кода для разных числовых типов:

			// Вместо отдельных функций для int, float64
func Sum[T constraints.Integer | constraints.Float](numbers []T) T {
    var sum T
    for _, n := range numbers {
        sum += n
    }
    return sum
}

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

3. Функции высшего порядка

С появлением дженериков появилась возможность создавать типобезопасные функции высшего порядка для работы с коллекциями:

			func Filter[T any](slice []T, predicate func(T) bool) []T {
    result := make([]T, 0, len(slice))
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
    result := initial
    for _, v := range slice {
        result = fn(result, v)
    }
    return result
}

func Find[T any](slice []T, predicate func(T) bool) (T, bool) {
    for _, v := range slice {
        if predicate(v) {
            return v, true
        }
    }
    var zero T
    return zero, false
}
		
Я не представляю, как можно обойтись без дженериков, если идёт речь про обработку больших массивов разнообразных данных, где нужны собственные структуры и алгоритмы.

4. Обёртки для API и протоколов

Так выглядит обёртка ответа с типизированным полем результата на дженериках:

			type Response[T any] struct {
    Status string `json:"status"`
    Data   T      `json:"data"`
    Error  string `json:"error,omitempty"`
}

// Вместо множества структур для каждого типа ответа
func handleUserResponse() Response[User] { /* ... */ }
func handleOrderResponse() Response[Order] { /* ... */ }
		

Компилятор гарантирует соответствие типов на этапе компиляции. До дженериков приходилось использовать json.RawMessage и распаковывать на уровне роутинга.

5. Репозитории и CRUD-операции

Типизированные интерфейсы для работы с данными:

			type Repository[T any, ID comparable] interface {
    GetByID(id ID) (T, error)
    Save(entity T) error
    Delete(id ID) error
    FindAll() ([]T, error)
}
		

Как дженерики повлияли на тесты?

Однозначно стало проще создавать универсальных хелперов для тестирования:

			func AssertEqual[T comparable](t *testing.T, got, want T) {
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

func Contains[T comparable](slice []T, item T) bool {
    for _, v := range slice {
        if v == item {
            return true
        }
    }
    return false
}
		

Почему дженерики в Go не такие, как в других языках?

Отсутствие обобщённых типов болезненно воспринимали разработчики, перешедшие в Go из Java, C#, C++ и других языков с дженериками.

Добавление функционального программирования — это тренд Rust, Kotlin и других современных языков. Без дженериков в Go было бы сложно переписывать код в функциональной парадигме с других языков.

Реализация фичи подвела: гошные методы не имеют собственные параметры типа. Методы наследуют дженерик-параметры самой структуры, и нельзя добавить новые.

Например, это работает:

			type Box[T any] struct {
    value T
}

func (b Box[T]) Get() T {
    return b.value
}
		

А такая функция уже не скомпилируется:

			func (b Box[T]) Convert[U any]() U {
    // ...
}
		

Привычные паттерны из Rust, Java или C# в Go просто не реализуемы. Вместо методов приходится использовать функции:

			func Convert[T, U any](b Box[T]) U {
    // ...
}
		

Как дженерики изменили разработку на Go?

Типовые задачи в Го до появления дженериков решали иначе. Приходилось использовать пустой interface{}, генерировать типобезопасный код с помощью go:generate, stringer.

Несмотря на недостатки, дженерики поменяли подход к разработке.

Если у вас всё строится на дженериках, то можно считать, что нужен совершенно другой инструментарий и образ мышления для работы с таким кодом.

Обобщённые типы активно используются в библиотеках. Взять тот же samber/lo и функции Map, Filter, Reduce на дженериках. Ещё стандартная библиотека пополнилась пакетами slices и maps.

Что касается производительности — ожидания не оправдались. Думали, что избавились от преобразований типов и получили прирост скорости? На деле разница минимальна. В реальных приложениях узкое место не в типах, а в работе БД, сетевых запросах или бизнес-логике.

Что в итоге?

  • Интерфейсы остаются основой архитектуры Go-приложений, дженерики их дополняют, но не заменяют.
  • Обобщённые типы полезны только для конкретных задач. Применяйте по необходимости, когда дженерики устраняют дублирование кода или повышают типобезопасность.
  • Упрощайте тестирование — универсальные хелперы для проверок и утверждений.
  • Избегайте преждевременного обобщения — не пишите код через дженерики «на всякий случай».
  • Параметры типов лучше всего подходят для контейнеров, кэшей, математических функций.
  • Привычные паттерны из других языков не реализуемы — приходится адаптировать подходы под Go.
  • Усложнение понимания кода — иногда непросто разобраться, что происходит с конкретной сущностью.

Поделитесь опытом использования дженериков — где они реально помогли, а где оказались излишними? Удивите примерами удачного и неудачного применения!

Следите за новыми постами
Следите за новыми постами по любимым темам
554 открытий3К показов