Введение: Почему Generics в Go — это прорыв?
Долгое время язык Go (Golang) оставался одним из немногих современных языков без поддержки обобщённого программирования (generics). Разработчики вынуждены были использовать interface{} и приведение типов, что приводило к потере безопасности типов на этапе компиляции и загромождало код. С выходом версии Go 1.18 (февраль 2022 года) ситуация кардинально изменилась.
Generics (дженерики) — это механизм, позволяющий писать функции и структуры данных, которые могут работать с любыми типами, сохраняя при этом строгую типизацию. В этой статье мы разберём, зачем они нужны, и покажем на практических примерах, как их использовать в повседневной разработке на Go.
Зачем нужны Generics в Go?
До появления дженериков для создания универсальных функций разработчики использовали interface{} (пустой интерфейс). Рассмотрим классический пример — функция, которая находит минимальный элемент в слайсе:
// Без дженериков (Go < 1.18)
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}
Для каждого типа приходилось писать отдельную функцию. С interface{} можно было сделать так:
func MinInterface(a, b interface{}) interface{} {
// Требуется приведение типов и проверка на этапе выполнения
// Это небезопасно и неэффективно
}
Проблемы такого подхода очевидны:
- Потеря типовой безопасности — ошибка может проявиться только во время выполнения.
- Необходимость приведения типов — код становится громоздким.
- Снижение производительности — работа с интерфейсами требует дополнительных операций.
Generics решают все эти проблемы. Они позволяют:
- Писать универсальные алгоритмы один раз.
- Сохранять строгую проверку типов на этапе компиляции.
- Избавиться от приведения типов и повысить производительность.
Основы синтаксиса Generics в Go
Дженерики в Go основаны на параметрах типа. Синтаксис прост и интуитивно понятен. Параметры типа указываются в квадратных скобках после имени функции или типа.
1. Простая функция с дженериками
Напишем ту же функцию минимума, но теперь с использованием дженериков:
package main
import "fmt"
// Min возвращает минимальное значение из двух
func Min[T comparable](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
fmt.Println(Min[int](10, 20)) // 10
fmt.Println(Min[float64](3.14, 2.71)) // 2.71
fmt.Println(Min[string]("abc", "xyz")) // abc
}
Здесь [T comparable] — это параметр типа. comparable — это встроенное ограничение (constraint), которое гарантирует, что тип поддерживает операторы сравнения == и !=. Обратите внимание: для оператора < нужно использовать ограничение constraints.Ordered (из пакета golang.org/x/exp/constraints) или написать своё.
2. Ограничения (Constraints)
Ограничения — это интерфейсы, которые определяют, какие методы и операции должны поддерживать типы. В Go 1.18+ появился пакет constraints (пока находится в golang.org/x/exp), но вы можете создавать свои ограничения.
import "golang.org/x/exp/constraints"
// MinOrdered работает только с упорядоченными типами
func MinOrdered[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
Собственное ограничение для типов, поддерживающих метод String():
type Stringer interface {
String() string
}
// PrintStrings печатает строковое представление
func PrintStrings[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}
Практические примеры использования Generics
Теперь перейдём к реальным задачам, которые часто встречаются в Go-разработке.
1. Обобщённая структура данных: стек
Создадим типобезопасный стек, который может работать с любым типом:
package main
import "fmt"
// Stack — обобщённая структура стека
type Stack[T any] struct {
items []T
}
// Push добавляет элемент в стек
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop удаляет и возвращает верхний элемент
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(1)
intStack.Push(2)
fmt.Println(intStack.Pop()) // 2 true
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println(stringStack.Pop()) // world true
}
Обратите внимание на [T any] — any это псевдоним для interface{}, который позволяет использовать любой тип без ограничений.
2. Утилитарные функции: Map и Filter
Функции высшего порядка, знакомые по другим языкам, теперь можно реализовать типобезопасно:
// Map применяет функцию f к каждому элементу слайса
func Map[T, U any](items []T, f func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = f(item)
}
return result
}
// Filter оставляет элементы, удовлетворяющие предикату
func Filter[T any](items []T, pred func(T) bool) []T {
var result []T
for _, item := range items {
if pred(item) {
result = append(result, item)
}
}
return result
}
func main() {
numbers := []int