Generics в Go: зачем нужны и как использовать

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: что такое 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
    }
}

                
                

Блоги

Рекомендаций по книгам