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

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

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

Начать курс

Введение: что такое Generics и почему они появились в Go?

Долгое время язык Go (Golang) оставался одним из немногих современных языков без поддержки обобщённого программирования (generics). Разработчикам приходилось либо дублировать код для разных типов данных, либо использовать интерфейсы и пустые interface{}, что приводило к потере типобезопасности и необходимости выполнять приведение типов в рантайме.

С выходом версии Go 1.18 (февраль 2022 года) в языке появились долгожданные дженерики. Generics — это механизм, позволяющий писать функции, типы и структуры данных, которые работают с произвольными типами, сохраняя при этом строгую типизацию на этапе компиляции.

В этой статье мы разберём:

  • Зачем нужны дженерики в Go;
  • Как объявлять обобщённые функции и типы;
  • Что такое constraints и как их использовать;
  • Практические примеры применения.

Зачем нужны Generics в Go?

До появления дженериков типичная проблема выглядела так: нужно написать функцию, которая работает со срезами разных типов (например, int, string, float64).

Без дженериков приходилось либо писать три отдельные функции:

func SumInts(nums []int) int {
    total := 0
    for _, v := range nums {
        total += v
    }
    return total
}

func SumFloats(nums []float64) float64 {
    total := 0.0
    for _, v := range nums {
        total += v
    }
    return total
}

Либо использовать interface{} с приведением типов — небезопасно и неудобно.

С дженериками достаточно одной функции:

func Sum[T int | float64](nums []T) T {
    var total T
    for _, v := range nums {
        total += v
    }
    return total
}

Преимущества очевидны:

  • Типобезопасность — ошибки типов отлавливаются на этапе компиляции;
  • Уменьшение дублирования кода — одна функция для многих типов;
  • Читаемость — код становится более лаконичным и понятным.

Основы синтаксиса Generics в Go

Объявление обобщённой функции

Базовый синтаксис использует квадратные скобки [] для указания параметров типа:

func ИмяФункции[ИмяТипа constraint](параметры) результат {
    // тело функции
}

Пример простой функции, которая возвращает переданное значение:

func Identity[T any](value T) T {
    return value
}

func main() {
    fmt.Println(Identity[int](42))       // 42
    fmt.Println(Identity[string]("Go"))  // Go
}

Go может автоматически выводить тип, поэтому явное указание [int] необязательно:

fmt.Println(Identity(42))       // Go сам поймёт, что T = int
fmt.Println(Identity("Go"))    // T = string

Обобщённые типы (структуры)

Дженерики работают не только с функциями, но и с пользовательскими типами:

type Stack[T any] struct {
    elements []T
}

func (s *Stack[T]) Push(value T) {
    s.elements = append(s.elements, value)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.elements) == 0 {
        var zero T
        return zero, false
    }
    value := s.elements[len(s.elements)-1]
    s.elements = s.elements[:len(s.elements)-1]
    return value, true
}

func main() {
    intStack := Stack[int]{}
    intStack.Push(10)
    intStack.Push(20)
    val, ok := intStack.Pop()
    fmt.Println(val, ok) // 20 true
}

Constraints: ограничения для типов

В примерах выше мы использовали any — это встроенный constraint, означающий "любой тип". Однако часто требуется наложить ограничения: например, чтобы тип поддерживал сравнение или арифметические операции.

Встроенные constraints

  • any — синоним interface{}, любой тип;
  • comparable — типы, поддерживающие операторы == и != (все сравнимые типы: числа, строки, указатели, каналы, структуры из сравнимых полей).

Пример с comparable — функция поиска элемента в срезе:

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func main() {
    fmt.Println(Contains([]string{"a", "b", "c"}, "b")) // true
    fmt.Println(Contains([]int{1, 2, 3}, 4))            // false
}

Пользовательские constraints

Вы можете объединять типы в собственные ограничения. Это делается через интерфейс с перечислением типов (type union):

type Number interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

func Min[T Number](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
}

Constraint с методами

Можно комбинировать type union с методами:

type Stringer interface {
    ~string | ~[]byte
    String() string
}

Символ ~ означает, что constraint включает не только сам тип, но и любые типы, основанные на нём (например, type MyString string).

Практические примеры использования Generics

1. Универсальная сортировка

func SortSlice[T constraints.Ordered](slice []T) {
    sort.Slice(slice, func(i, j int) bool {
        return slice[i] < slice[j]
    })
}

Здесь constraints.Ordered — пакет golang

Блоги

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