С выходом Go 1.18 язык получил одну из самых ожидаемых возможностей за всю свою историю - дженерики. Теперь разработчики могут писать обобщённый код без копирования функций для разных типов и без потери типобезопасности. Это упрощает архитектуру библиотек, уменьшает дублирование и делает API более универсальным.
Разберёмся, как устроены дженерики в Go, как их применять на практике и какие ограничения стоит учитывать.
Что такое дженерики и зачем они нужны
Дженерики позволяют писать функции и типы, которые работают с разными типами данных. Вместо того чтобы создавать отдельную реализацию для int, float64 или string, можно определить универсальный шаблон и указать ограничения для допустимых типов.
До появления дженериков в Go было два основных подхода:
Использовать интерфейсы, например interface{}.
Дублировать код под каждый тип.
Первый способ лишал строгой типизации и требовал приведения типов. Второй приводил к повторению кода и усложнял поддержку.
Дженерики решают обе проблемы одновременно: сохраняют типобезопасность и позволяют писать переиспользуемые функции.
Базовый синтаксис
Обобщённые параметры типа объявляются в квадратных скобках сразу после имени функции или типа.
Простейший пример:
func Print[T any](value T) {
fmt.Println(value)
}Здесь:
T - параметр типа
any - ограничение, означающее "любой тип"
Функцию можно вызвать так:
Print
Print[string]("hello")Во многих случаях компилятор сам выводит тип, поэтому можно писать короче:
Print(42)
Print("hello")Ограничения типов
Параметры типа редко оставляют полностью свободными. Обычно задают ограничения, которые определяют, какие операции допустимы.
Например, если нам нужна функция для сравнения чисел:
func Max[T int | float64](a, b T) T {
if a > b {
return a
}
return b
}Здесь T может быть либо int, либо float64.
Однако перечислять все возможные числовые типы неудобно. Для этого используются интерфейсы-ограничения.
Интерфейсы как ограничения
В Go дженерики тесно связаны с интерфейсами. Можно определить собственное ограничение:
type Number interface {
int | int64 | float64
}И использовать его:
func Sum[T Number](a, b T) T {
return a + b
}Теперь функция работает с любым типом, который входит в Number.
Предопределённый тип any
Тип any - это псевдоним для interface{}. Он означает отсутствие ограничений.
func Identity[T any](v T) T {
return v
}Ограничения с методами
Можно требовать от типа наличие определённых методов:
type Stringer interface {
String() string
}
func PrintString[T Stringer](v T) {
fmt.Println(v.String())
}Теперь T должен реализовывать метод String().
Использование дженериков со структурами
Дженерики можно применять не только к функциям, но и к типам.
Например, реализуем простой стек:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
n := len(s.items)
item := s.items[n-1]
s.items = s.items[:n-1]
return item
}Использование:
intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)
fmt.Println(intStack.Pop())Теперь один и тот же стек можно использовать для любого типа.
Объединение типов через | (union types)
В ограничениях можно перечислять допустимые типы через символ |:
type Integer interface {
int | int8 | int16 | int32 | int64
}Это удобно для создания категорий типов.
Важно помнить: такие ограничения работают только в контексте дженериков, это не полноценные объединённые типы как в других языках.
Использование встроенных пакетов
После появления дженериков стандартная библиотека начала получать обобщённые функции. Например, пакет slices содержит универсальные функции для работы со срезами.
Пример:
import "slices"
nums := []int{3, 1, 2}
slices.Sort(nums)Функция Sort работает с разными типами, если они поддерживают сравнение.
Тип ~ и базовые типы
В ограничениях можно использовать символ ~, чтобы разрешить пользовательские типы на основе базового.
Например:
type MyInt int
type Integer interface {
~int | ~int64
}Теперь тип MyInt тоже будет удовлетворять ограничению.
Это особенно важно при создании библиотек, которые должны работать с пользовательскими типами.
Практический пример: универсальная функция Map
Реализуем функцию, которая преобразует срез одного типа в срез другого:
func Map[T any, R any](input []T, fn func(T) R) []R {
result := make([]R, len(input))
for i, v := range input {
result[i] = fn(v)
}
return result
}Использование:
nums := []int{1, 2, 3}
strings := Map(nums, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})Функция универсальна и типобезопасна.
Когда стоит использовать дженерики
Дженерики особенно полезны в следующих случаях:
создание коллекций и контейнеров
написание утилитарных функций
разработка библиотек
устранение повторяющегося кода
Но не стоит применять их везде. Если функция используется только с одним типом, обобщение может усложнить читаемость без реальной пользы.
Ограничения и особенности
Несколько важных моментов:
Дженерики не являются шаблонами в стиле C++. Они компилируются иначе и не поддерживают метапрограммирование.
Ограничения не позволяют выполнять произвольные операции а только те, которые гарантированы типом.
Код становится сложнее для понимания, если ограничения слишком запутанные.
Рефлексия и дженерики решают разные задачи и не заменяют друг друга полностью.
Дженерики сделали Go более гибким, не нарушая его философию простоты и явности. Они позволяют писать универсальный, переиспользуемый и типобезопасный код без избыточных интерфейсов и приведения типов.
При разумном использовании дженерики упрощают архитектуру и уменьшают дублирование. Главное применять их там, где это действительно оправдано.