Дженерики в Go: как обобщённое программирование меняет правила игры

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

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

Начать курс

Всё началось с Go 1.18: почему дженерики — это не просто модная фича?

Долгое время сообщество Go гордилось простотой языка. Отсутствие обобщённого программирования (generics) считалось осознанным выбором: «нам не нужны сложные шаблоны, мы пишем понятный код». Но реальность взяла своё. Разработчики устали копипастить функции для int, string и float64 — или использовать interface{} с последующим приведением типов, теряя безопасность на этапе компиляции.

Выход Go 1.18 (2022 год) стал революцией. Дженерики (generics) добавили в язык параметры типов (type parameters) и ограничения (constraints). Теперь Go-программисты могут писать обобщённые структуры данных и алгоритмы, не жертвуя производительностью и строгой типизацией.

В этой статье на pythonlib.ru мы разберёмся, как работают generics в Go, увидим реальные примеры кода и поймём, когда их стоит использовать, а когда — нет. Поехали!

Что такое generics в Go простыми словами?

Представьте, что вы пишете функцию, которая находит минимальный элемент в срезе. Без дженериков вам пришлось бы создавать три версии: для int, float64 и string. Или использовать interface{} с приведением типов — медленно и небезопасно.

Generics позволяют написать одну функцию, которая работает с любым типом, удовлетворяющим определённым условиям. Синтаксис простой: после имени функции в квадратных скобках указываются параметры типов.

Синтаксис: квадратные скобки и слово any

package main

import "fmt"

// Min — обобщённая функция для поиска минимума
func Min[T any](a, b T) T {
    // Ошибка: оператор < не определён для any
    // if a < b {
    //     return a
    // }
    return b
}

Код выше не скомпилируется, потому что any — это пустой интерфейс (синоним interface{}). Компилятор не знает, поддерживает ли тип оператор <. Нужно добавить ограничение (constraint).

Ограничения типов: constraints

В Go ограничения задаются через интерфейсы. Можно использовать встроенные из пакета golang.org/x/exp/constraints или написать свои.

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

// Min работает с любым упорядоченным типом
func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Min(10, 20))       // 10
    fmt.Println(Min(3.14, 2.71))   // 2.71
    fmt.Println(Min("apple", "banana")) // "apple"
}

Обратите внимание: constraints.Ordered включает все типы, для которых определены операторы <, <=, >, >= — это int, float, string.

Практические примеры: от простого к сложному

Давайте посмотрим, как generics в Go упрощают написание реального кода.

1. Обобщённый стек (Stack)

Без дженериков пришлось бы хранить []interface{} и делать type assertion. С generics — элегантно и безопасно.

package main

import "fmt"

// Stack — обобщённая структура данных
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(1)
    intStack.Push(2)
    val, ok := intStack.Pop()
    fmt.Println(val, ok) // 2 true

    strStack := Stack[string]{}
    strStack.Push("hello")
    strStack.Push("world")
    fmt.Println(strStack.Pop()) // world true
}

2. Обобщённая функция для фильтрации среза

Очень полезно для обработки данных: один раз написали, используем везде.

// Filter возвращает новый срез, содержащий только элементы, 
// для которых предикат вернул true
func Filter[T any](slice []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func main() {
    numbers := []int{1, 2, 3, 4, 5, 6}
    evens := Filter(numbers, func(n int) bool {
        return n%2 == 0
    })
    fmt.Println(evens) // [2 4 6]

    words := []string{"cat", "dog", "elephant", "ant"}
    longWords := Filter(words, func(s string) bool {
        return len(s) > 3
    })
    fmt.Println(longWords) // [elephant]
}

3. Дженерики с несколькими параметрами типов

Можно объявлять два и более параметра. Например, функция для создания пары.

func MakePair[K comparable, V any](key K, value V) Pair[K, V] {
    return Pair[K, V]{Key: key, Value: value}
}

type Pair[K comparable, V any] struct {
    Key   K
    Value V
}

func main() {
    p := MakePair("id", 42)
    fmt.Println(p.Key, p.Value) // id 42
}

Обратите внимание: comparable — это встроенное ограничение, которое разрешает сравнение с помощью == и !=. Идеально для ключей map.

Ограничения: что можно и что нельзя?

Дженерики в Go — мощный инструмент, но у них есть свои границы. Важно понимать, чтобы не попасть в ловушку.

  • Нельзя использовать операторы (кроме сравнения через == и != для comparable). Для арифметики нужны custom constraints.
  • Нельзя использовать дженерики с методами — параметр типа можно задать только для типа или функции, но не для отдельного метода.
  • Производительность: дженерики Go компилируются в мономорфный код (как в C++), поэтому накладных расходов на runtime нет. Но увеличивается размер бинарника.
  • Читаемость: если в коде слишком много [T any, U comparable], код может стать сложным. Используйте дженерики осмысленно.

Создание собственного

Блоги

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