Введение: что такое Generics и почему они появились в Go?
Долгое время сообщество Go (Golang) обходилось без дженериков (обобщённого программирования). Разработчики использовали интерфейсы, пустые interface{} и кодогенерацию, чтобы обойти ограничения строгой типизации. В версии Go 1.18 (февраль 2022 года) в язык наконец-то были добавлены дженерики — мощный инструмент, позволяющий писать функции и структуры, которые работают с разными типами данных без потери безопасности типов.
В этой статье мы разберём, зачем нужны Generics в Go, как они работают, и научимся применять их на практике. Материал рассчитан на начинающих разработчиков, которые уже знакомы с основами Go: переменными, функциями и срезами.
Зачем нужны дженерики в Go?
Представьте, что вам нужно написать функцию, которая находит минимальное значение в срезе чисел. Без дженериков вам пришлось бы писать отдельные функции для int, float64 и других типов:
// Без дженериков — три одинаковые функции
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat64(a, b float64) float64 {
if a < b {
return a
}
return b
}
Это приводит к дублированию кода. Раньше эту проблему решали через interface{} и приведение типов, но такой подход терял безопасность на этапе компиляции:
func Min(a, b interface{}) interface{} {
// Сравнение и приведение — легко ошибиться
return a
}
Дженерики решают три ключевые задачи:
- Типобезопасность — ошибки типов отлавливаются на этапе компиляции.
- Устранение дублирования — одна функция работает с любыми типами.
- Читаемость кода — явное указание того, какие типы поддерживаются.
Основы синтаксиса: type parameters и constraints
Дженерики в Go строятся на двух концепциях: параметры типа (type parameters) и ограничения (constraints).
Параметры типа
Параметры типа указываются в квадратных скобках после имени функции или типа. По соглашению, чаще всего используют букву T (от Type), но можно использовать любые имена:
// Функция с одним параметром типа T
func Print[T any](value T) {
fmt.Println(value)
}
// Использование
Print[int](42) // явно указываем тип
Print("Hello") // компилятор выводит тип автоматически
Ключевое слово any — это псевдоним для пустого интерфейса interface{}. Оно означает, что функция принимает любой тип.
Ограничения (constraints)
Ограничения определяют, какие типы могут быть использованы с дженериком. Например, для сравнения чисел нужно, чтобы тип поддерживал операторы < и >. Встроенные ограничения для числовых типов находятся в пакете golang.org/x/exp/constraints:
import "golang.org/x/exp/constraints"
// Ограничение — только целые числа
func Sum[T constraints.Integer](numbers []T) T {
var total T
for _, n := range numbers {
total += n
}
return total
}
Вы также можете создавать свои ограничения через интерфейсы:
// Собственное ограничение: тип должен иметь метод String()
type Stringer interface {
String() string
}
func Describe[T Stringer](value T) string {
return "Value: " + value.String()
}
Практические примеры использования Generics
Рассмотрим несколько реальных сценариев, где дженерики значительно упрощают код.
1. Универсальная функция поиска в срезе
// Находит индекс элемента в срезе. -1 если не найден.
func Index[T comparable](slice []T, target T) int {
for i, v := range slice {
if v == target {
return i
}
}
return -1
}
// Использование
func main() {
nums := []int{10, 20, 30, 40}
fmt.Println(Index(nums, 30)) // 2
words := []string{"go", "rust", "python"}
fmt.Println(Index(words, "rust")) // 1
}
Обратите внимание на ограничение comparable — оно требует, чтобы тип поддерживал оператор ==. Это все базовые типы (строки, числа, булевы) и структуры, состоящие только из comparable-полей.
2. Generic-структура: стек
// Обобщённый стек для любого типа
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, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
// Использование
func main() {
intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)
val, ok := intStack.Pop()
fmt.Println(val, ok) // 20 true
}
3. Работа с картами: копирование и преобразование
// Копирует значения из одной карты в другую
func CopyMap[K comparable, V any](src map[K]V) map[K]V {
dst := make(map[K]V, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
// Преобразует все значения через функцию
func MapValues[K comparable, V, R any](m map[K]V, fn func(V) R) map[K]R {
result := make(map[K]R, len(m))
for k, v := range m {
result[k] = fn(v)
}
return result
}
// Использование
func main() {
original := map[string]int{"a": 1, "b": 2}
copy := CopyMap(original)
doubled := MapValues(original, func(v int) int {
return v * 2
})
fmt.Println(doubled) // map[a:2 b:4]
}
Ограничения и подводные камни
Дженерики в Go — мощный, но