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 дают реальную практическую пользу, когда их применяют обдуманно: там, где есть дублирование, повторяющиеся структуры или операции, которые отличаются только типом данных. Небольшая дисциплина в выборе ограничений и тестировании разных инстанцирований обеспечивает безопасный и поддерживаемый код, который легче развивать в будущем.

