Введение: что такое Generics и зачем они появились в Go
Долгое время сообщество Go обходилось без дженериков (обобщенных типов). Разработчики использовали interface{} и ручное приведение типов, что приводило к громоздкому коду и ошибкам во время выполнения. С выходом версии Go 1.18 (февраль 2022) в язык были официально добавлены дженерики — одна из самых ожидаемых функций.
Generics (или параметрический полиморфизм) позволяют писать функции и структуры данных, которые работают с любыми типами, сохраняя при этом строгую типизацию на этапе компиляции. Это означает, что вы можете написать один алгоритм (например, сортировку или поиск) и использовать его для int, string, пользовательских структур — без дублирования кода и без потери безопасности типов.
В этой статье мы разберем, зачем нужны дженерики в Go, как их правильно использовать и какие подводные камни стоит учитывать.
Основная часть: как работают Generics в Go
1. Синтаксис параметров типов
Базовый синтаксис дженериков в Go выглядит так: после имени функции или типа в квадратных скобках указываются параметры типов. Рассмотрим простейший пример — функция, которая возвращает переданное значение (аналог identity):
package main
import "fmt"
// Обобщенная функция: T — параметр типа
func Print[T any](value T) {
fmt.Println(value)
}
func main() {
Print[int](42) // явное указание типа
Print[string]("Go") // явное указание типа
Print("Go 1.18") // неявный вывод типа (type inference)
}
Ключевое слово any — это псевдоним для interface{}. Оно означает, что функция принимает значение любого типа. Однако настоящая сила дженериков раскрывается при использовании ограничений (constraints).
2. Ограничения (constraints) и интерфейсы
Ограничения позволяют указать, какие операции можно выполнять с параметром типа. Например, чтобы сложить два числа, нужно гарантировать, что тип поддерживает операторы +, -, * и т.д. В Go для этого используются специальные интерфейсы из пакета golang.org/x/exp/constraints или пользовательские интерфейсы:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Ограничение: T должен поддерживать сравнение
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(10, 20)) // 20
fmt.Println(Max("apple", "banana")) // banana
// fmt.Println(Max([]int{1}, []int{2})) // Ошибка компиляции: слайсы не упорядочены
}
Встроенный интерфейс constraints.Ordered включает типы, поддерживающие операторы <, <=, >, >=: все числовые типы и string.
Вы также можете создавать собственные ограничения:
// Пользовательское ограничение: тип должен иметь метод String()
type Stringer interface {
String() string
}
func Describe[T Stringer](v T) string {
return "Value: " + v.String()
}
3. Обобщенные структуры и методы
Дженерики в Go работают не только с функциями, но и с пользовательскими типами. Рассмотрим классический пример — обобщенный стек:
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
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func main() {
intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)
val, ok := intStack.Pop()
fmt.Println(val, ok) // 20 true
stringStack := Stack[string]{}
stringStack.Push("Go")
stringStack.Push("Generics")
fmt.Println(stringStack.Pop()) // Generics true
}
Обратите внимание: при объявлении методов для обобщенной структуры параметр типа указывается и в определении метода (func (s *Stack[T]) ...).
4. Практические примеры и лучшие практики
Рассмотрим еще несколько полезных примеров использования дженериков в реальных проектах.
Пример 1: Функция фильтрации для слайсов
package main
import "fmt"
// Filter возвращает новый слайс, содержащий элементы, удовлетворяющие предикату
func Filter[T any](s []T, predicate func(T) bool) []T {
var result []T
for _, v := range s {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
even := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(even) // [2 4 6]
words := []string{"Go", "Rust", "Python", "Java"}
longWords := Filter(words, func(s string) bool { return len(s) > 3 })
fmt.Println(longWords) // [Python Java]
}
Пример 2: Обобщенный кеш (in-memory cache)
package main
import (
"fmt"
"sync"
)
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{items: make(map[K]V)}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
func main() {
cache := NewCache[string, int]()
cache.Set("one", 1)
cache.Set("two", 2)
if val, ok := cache.Get("one"); ok {
fmt.Println(val) // 1
}
}