Generics в Go изменили подход к написанию повторяемого кода. В этой статье я шаг за шагом покажу, как правильно применять обобщения на практике и дам рабочие примеры, которые можно сразу вставить в проект.

Коротко о том, что это и зачем

Обобщения позволяют писать функции и типы, универсальные для разных типов данных, без дублирования кода. Вместо нескольких однотипных реализаций вы создаёте одну универсальную и используете её с разными параметрами типов.

За счёт параметров типов код становится компактнее, а интерфейсы — выразительнее. В то же время важно правильно задать ограничения на типы, чтобы компилятор мог гарантировать безопасность операций.

Синтаксис и базовые примеры

Типовой шаблон функции с параметром типа выглядит так: func Name[T any](…) … — где T обозначает параметр типа, а any — разрешает любой тип. Типовые параметры можно указывать несколько: func F[A, B any](…).

Ниже несколько практичных примеров, которые покрывают часто встречающиеся задачи: поиск, карта, фильтрация и стек.

Функция поиска индекса для сравнимых типов

Если нужна простая проверка на равенство, удобнее использовать встроенное ограничение comparable. Оно позволяет применять оператор == к значениям типа.

func IndexOf[T comparable](slice []T, v T) int {
    for i, x := range slice {
        if x == v {
            return i
        }
    }
    return -1
}

Этот код одинаково хорошо работает для []int, []string и других типов, которые поддерживают сравнение.

Вызов не требует явного указания типа: idx := IndexOf(nums, 42).

Map и Filter для срезов

Типичный функциональный приём — преобразование и фильтрация коллекций. В Go это удобно выразить через универсальные функции, принимающие callback.

func Map[T any, U any](in []T, f func(T) U) []U {
    out := make([]U, 0, len(in))
    for _, v := range in {
        out = append(out, f(v))
    }
    return out
}

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

Map часто используют для конвертации типов, Filter — для отбрасывания лишнего. Эти функции универсальны и не зависят от конкретного элемента.

Пример структуры — стек с универсальным элементом

Обобщённые типы дают возможность создавать коллекции одного кода, но для разных типов элементов. Ниже простой стек с методами Push и Pop.

type Stack[T any] struct {
    elems []T
}

func (s *Stack[T]) Push(v T) {
    s.elems = append(s.elems, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.elems) == 0 {
        var zero T
        return zero, false
    }
    last := s.elems[len(s.elems)-1]
    s.elems = s.elems[:len(s.elems)-1]
    return last, true
}

Такой стек легко использовать для строк, структур или указателей, при этом не теряя типовой безопасности.

Ограничения типов: понятие и примеры

Ограничения описывают, какие операции допустимы для параметра типа. Это интерфейсы, перечисляющие набор допустимых типов или методов. Правильно подобранные ограничения делают код безопасным и гибким.

Ниже приведу простой пример ограничения для чисел, чтобы можно было сравнивать и выполнять арифметику.

Определение числового ограничения

В стандартных задачах удобно объявить собственный набор числовых типов, используя синтаксис приближений к типам. Такой интерфейс поможет писать функции сравнения или арифметики.

type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64
}

func Max[T Number](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Оператор > гарантированно доступен потому, что в ограничении указаны конкретные числовые представления.

Типизация в вызовах и вывод типов

Компилятор Go умеет выводить параметры типов из аргументов. В большинстве случаев тип указывать не требуется. Это сокращает громоздкие объявления и делает вызовы лаконичными.

Если вывод невозможен, тип можно указать явно: Map[int, string](… ). Часто же достаточно просто Map(nums, strconv.Itoa).

Практические советы и ловушки

Generics упрощают код, но порождают свои подводные камни. Я перечислю основные, с которыми сталкивался при переносе реального кода на обобщения.

  • Излишне широкие ограничения усложняют код и не дают компилятору оптимизировать операции.
  • Методы с указателями и методы со значениями меняют набор допустимых типов; проверяйте методные наборы при комбинировании типов и ограничений.
  • Использование in[:0] для оптимизации фильтрации экономит память, но мутирует исходный срез; учтите это, если срез ещё нужен в исходном виде.
  • Для сравнения структур используйте comparable, но помните, что не все структуры сравнимы по == (например, содержащие срезы).

В своём опыте я пересмотрел несколько API: заменил три варианта Filter для int, string и struct на одну обобщённую функцию. Это упростило тесты, но потребовало пересмотра контрактов методов и явной работы с копиями срезов в местах, где важна неизменяемость.

Производительность и генерация кода

Go реализует обобщения так, чтобы каждое инстанцирование типа давало специализированный код для конкретного набора параметров типов. Это похоже на мономорфизацию — компилятор создаёт версии функций под конкретные типы.

На практике это значит: скорость операций сопоставима с ручными версиями без generics. При этом бинарник может увеличиться, если большое число инстанцирований создаёт много одинаковых по содержанию реализаций.

Небольшая таблица ограничений

Ограничение Описание Применение
any Любой тип General utilities, контейнеры
comparable Поддерживает == и != Поиск, множества, карты ключей
Number (польз.) Набор числовых типов Арифметика, сравнение чисел

Когда не стоит использовать generics

Обобщения хороши для повторяющегося кода, но не являются панацеей. Для простой логики с единичным использованием они добавляют лишнюю абстракцию. Поддержка и читаемость важнее гипотетической экономии строк.

Также не стоит пытаться через generics решать задачи, требующие динамической типизации или отражения типов в рантайме; для таких случаев лучше использовать явную реализацию с reflect или интерфейсами.

Мой короткий опыт

В одном проекте я заменил три отдельные реализации сборки ключей для кэша на одну обобщённую функцию. Сначала это казалось громоздким, но результаты оправдали усилия: меньше тестового кода и единая логика. При этом пришлось уделить внимание документации и именованию типов, чтобы новые функции оставались понятными коллегам.

Ещё один урок — тесты должны покрывать разные инстанцирования типов. Ошибки в ограничениях часто проявляются только при специфических комбинациях типов.

Ещё пара шаблонов для практики

Полезные шаблоны быстро становятся частью библиотеки утилит: создание безопасного nil-значения типа, объединение нескольких срезов и функция Unique для comparable типов.

func Zero[T any]() T {
    var z T
    return z
}

func Concat[T any](slices ...[]T) []T {
    var total int
    for _, s := range slices {
        total += len(s)
    }
    out := make([]T, 0, total)
    for _, s := range slices {
        out = append(out, s...)
    }
    return out
}

Эти примеры просты, но часто встречаются в повседневной работе и экономят время.

Generics в Go дают реальную практическую пользу, когда их применяют обдуманно: там, где есть дублирование, повторяющиеся структуры или операции, которые отличаются только типом данных. Небольшая дисциплина в выборе ограничений и тестировании разных инстанцирований обеспечивает безопасный и поддерживаемый код, который легче развивать в будущем.