Generics в Go: что это и зачем нужны

С выходом Go 1.18 язык получил одну из самых ожидаемых возможностей за всю свою историю - дженерики. Теперь разработчики могут писать обобщённый код без копирования функций для разных типов и без потери типобезопасности. Это упрощает архитектуру библиотек, уменьшает дублирование и делает API более универсальным.

Разберёмся, как устроены дженерики в Go, как их применять на практике и какие ограничения стоит учитывать.

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

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

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

  1. Использовать интерфейсы, например interface{}.

  2. Дублировать код под каждый тип.

Первый способ лишал строгой типизации и требовал приведения типов. Второй приводил к повторению кода и усложнял поддержку.

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

Базовый синтаксис

Обобщённые параметры типа объявляются в квадратных скобках сразу после имени функции или типа.

Простейший пример:

func Print[T any](value T) {
    fmt.Println(value)
}

Здесь:

  • T - параметр типа

  • any - ограничение, означающее "любой тип"

Функцию можно вызвать так:

Print
Print[string]("hello")

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

Print(42)
Print("hello")

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

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

Например, если нам нужна функция для сравнения чисел:

func Max[T int | float64](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Здесь T может быть либо int, либо float64.

Однако перечислять все возможные числовые типы неудобно. Для этого используются интерфейсы-ограничения.

Интерфейсы как ограничения

В Go дженерики тесно связаны с интерфейсами. Можно определить собственное ограничение:

type Number interface {
    int | int64 | float64
}

И использовать его:

func Sum[T Number](a, b T) T {
    return a + b
}

Теперь функция работает с любым типом, который входит в Number.

Предопределённый тип any

Тип any - это псевдоним для interface{}. Он означает отсутствие ограничений.

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

Ограничения с методами

Можно требовать от типа наличие определённых методов:

type Stringer interface {
    String() string
}

func PrintString[T Stringer](v T) {
    fmt.Println(v.String())
}

Теперь T должен реализовывать метод String().

Использование дженериков со структурами

Дженерики можно применять не только к функциям, но и к типам.

Например, реализуем простой стек:

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 {
    n := len(s.items)
    item := s.items[n-1]
    s.items = s.items[:n-1]
    return item
}

Использование:

intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)

fmt.Println(intStack.Pop())

Теперь один и тот же стек можно использовать для любого типа.

Объединение типов через | (union types)

В ограничениях можно перечислять допустимые типы через символ |:

type Integer interface {
    int | int8 | int16 | int32 | int64
}

Это удобно для создания категорий типов.

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

Использование встроенных пакетов

После появления дженериков стандартная библиотека начала получать обобщённые функции. Например, пакет slices содержит универсальные функции для работы со срезами.

Пример:

import "slices"

nums := []int{3, 1, 2}
slices.Sort(nums)

Функция Sort работает с разными типами, если они поддерживают сравнение.

Тип ~ и базовые типы

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

Например:

type MyInt int

type Integer interface {
    ~int | ~int64
}

Теперь тип MyInt тоже будет удовлетворять ограничению.

Это особенно важно при создании библиотек, которые должны работать с пользовательскими типами.

Практический пример: универсальная функция Map

Реализуем функцию, которая преобразует срез одного типа в срез другого:

func Map[T any, R any](input []T, fn func(T) R) []R {
    result := make([]R, len(input))
    for i, v := range input {
        result[i] = fn(v)
    }
    return result
}

Использование:

nums := []int{1, 2, 3}
strings := Map(nums, func(n int) string {
    return fmt.Sprintf("Number: %d", n)
})

Функция универсальна и типобезопасна.

Когда стоит использовать дженерики

Дженерики особенно полезны в следующих случаях:

  • создание коллекций и контейнеров

  • написание утилитарных функций

  • разработка библиотек

  • устранение повторяющегося кода

Но не стоит применять их везде. Если функция используется только с одним типом, обобщение может усложнить читаемость без реальной пользы.

Ограничения и особенности

Несколько важных моментов:

  1. Дженерики не являются шаблонами в стиле C++. Они компилируются иначе и не поддерживают метапрограммирование.

  2. Ограничения не позволяют выполнять произвольные операции а только те, которые гарантированы типом.

  3. Код становится сложнее для понимания, если ограничения слишком запутанные.

  4. Рефлексия и дженерики решают разные задачи и не заменяют друг друга полностью.

Дженерики сделали Go более гибким, не нарушая его философию простоты и явности. Они позволяют писать универсальный, переиспользуемый и типобезопасный код без избыточных интерфейсов и приведения типов.

При разумном использовании дженерики упрощают архитектуру и уменьшают дублирование. Главное применять их там, где это действительно оправдано.

Комментарии (0)

Войдите, чтобы оставить комментарий

Похожие статьи

Кастомные директивы Blade в Laravel

Руководство по созданию и использованию пользовательских Blade-директив в Laravel. Объясняем, какие бывают директивы, где их регистрировать и как применять для улучшения шаблонов.

Вышла версия OpenVPN 2.7 с поддержкой модуля DCO для Linux-ядра и mbedTLS 4

OpenVPN 2.7 вышла с поддержкой ускоренного модуля DCO для Linux-ядра, улучшенной серверной архитектурой, обновлённой работой с DNS и расширенной поддержкой TLS через mbedTLS 4.