Generics в Go: полное руководство по дженерикам

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

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

Начать курс

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

С выходом версии Go 1.18 в языке появилась одна из самых ожидаемых возможностей — дженерики (обобщённое программирование). До этого момента разработчикам приходилось либо дублировать код для разных типов данных, либо использовать пустой интерфейс interface{} с последующим приведением типов. Generics решают эту проблему кардинально. В этой статье мы разберём, зачем они нужны, как их использовать на практике и какие подводные камни вас ждут.

Что такое дженерики и зачем они нужны в Go?

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

До появления дженериков разработчики Go использовали два основных подхода:

  • Дублирование кода — написание отдельных функций для int, string, float64 и т.д.
  • Пустой интерфейс — использование interface{} с последующим type assertion или рефлексией, что было небезопасно и медленно.

Дженерики решают эти проблемы, позволяя писать универсальный, но при этом типобезопасный код. Компилятор проверяет типы на этапе компиляции, что исключает ошибки времени выполнения.

Синтаксис Generics: типы-параметры

Основная идея дженериков в Go — это типы-параметры (type parameters). Они объявляются в квадратных скобках после имени функции или типа.

Рассмотрим простейший пример — функция, которая возвращает минимальное из двух значений:

package main

import "fmt"

// Обобщённая функция с типом-параметром T
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[string]("a", "b"))  // "a"
    fmt.Println(Min(3.14, 2.71))        // 2.71 (тип выводится автоматически)
}

Здесь T — это тип-параметр. comparable — это ограничение (constraint), которое говорит, что тип T должен поддерживать операторы сравнения. Без этого ограничения компилятор не позволил бы использовать <.

Важно: Go может автоматически выводить типы-параметры из аргументов, поэтому в большинстве случаев можно не указывать их явно.

Ограничения (Constraints) в дженериках Go

Ограничения — это ключевой элемент дженериков. Они определяют, какие операции допустимы для типов-параметров. Без ограничений компилятор не знает, какие методы или операторы можно применять к T.

В Go есть несколько встроенных ограничений:

  • any — синоним interface{}, разрешает любой тип.
  • comparable — разрешает типы, поддерживающие == и !=.
  • constraints.Ordered — из пакета golang.org/x/exp/constraints, разрешает все упорядоченные типы (числа и строки).

Вы также можете создавать свои ограничения с помощью интерфейсов:

package main

import "fmt"

// Собственное ограничение: тип должен иметь метод String()
type Stringer interface {
    String() string
}

// Функция, работающая с любым Stringer
func Print[T Stringer](value T) {
    fmt.Println(value.String())
}

type User struct {
    Name string
}

func (u User) String() string {
    return "User: " + u.Name
}

func main() {
    user := User{Name: "Alice"}
    Print(user) // User: Alice
}

Обратите внимание: в Go 1.18+ интерфейсы можно использовать как ограничения, и они могут содержать не только методы, но и конкретные типы. Например:

type Numeric interface {
    ~int | ~float64 | ~float32
}

func Sum[T Numeric](values []T) T {
    var sum T
    for _, v := range values {
        sum += v
    }
    return sum
}

Символ ~ означает, что ограничение распространяется и на пользовательские типы, основанные на этих базовых типах.

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
    }
    last := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return last, true
}

func main() {
    stack := Stack[int]{}
    stack.Push(1)
    stack.Push(2)
    
    val, ok := stack.Pop()
    fmt.Println(val, ok) // 2 true
}

Обратите внимание на конструкцию var zero T — это стандартный способ получить нулевое значение для любого типа.

Важное правило: методы обобщённых типов не могут вводить новые типы-параметры. То есть вы не можете написать func (s *Stack[T]) Convert[U any]() Stack[U] — это вызовет ошибку компиляции. Типы-параметры можно добавлять только к функциям.

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

Рассмотрим несколько реальных сценариев, где дженерики действительно полезны.

1. Утилиты для работы со слайсами

package main

import "fmt"

// Фильтрация слайса
func Filter[T any](items []T, predicate func(T) bool) []T {
    result := make([]T, 0)
    for _, item := range items {
        if predicate(item) {
            result = append(result, item)
        }
    }
    return result
}

// Трансформация                

Блоги

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