Назад к блогу

Дженерики в Go: от идеи до Go 1.27

Дженерики в Go: от идеи до Go 1.27

Введение: почему дженерики стали необходимостью

Возможно, вы сталкивались с ситуацией: нужно написать функцию Reverse для среза. В Go 1.18 это выглядело бы так:

func ReverseInts(s []int) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Работает отлично. Но что если понадобится перевернуть срез строк? Вы скопируете функцию, замените int на string, и получите практически идентичный код. Отличие — только в типе параметра. Именно эту проблему и решают дженерики, возможность которых в Go обсуждается уже более десяти лет. Отсутствие этой возможности — один из самых частых запросов сообщества: в опросах за последние три года она стабильно входит в тройку ключевых проблем языка.

В этой статье мы разберём, почему дженерики так важны для Go, какие задачи они позволяют решить и чего стоит ожидать от их реализации. Мы рассмотрим конкретные примеры — от простых функций до структур данных вроде бинарных деревьев, а также обсудим, как разработчики языка планируют сохранить простоту и скорость компиляции, которые являются отличительными чертами Go.

История: как Go обходился без дженериков и к чему это привело

История дженериков в Go началась почти сразу после релиза языка. Go был выпущен 10 ноября 2009 года, и менее чем через 24 часа на форуме появился первый комментарий о необходимости дженериков. Эта тема не утихала все последующие годы.

Три ежегодных опроса сообщества подряд показывали один и тот же результат: отсутствие дженериков стабильно входило в тройку главных проблем языка. Сообщество не просто просило — оно требовало решения.

Почему такой ажиотаж? Дело в том, что без дженериков разработчикам приходилось выбирать между несколькими неидеальными стратегиями. Можно было использовать интерфейсы, как это делает стандартная библиотека в sort.Sort, но тогда приходится вручную писать методы для каждого типа — дублирование кода остаётся, просто переносится в другое место. Можно было задействовать пакет reflect, но это медленно и не даёт статической проверки типов. Или использовать генераторы кода, которые плодят копии функций и усложняют сборку.

Все эти обходные пути требовали сложной настройки и не решали проблему по сути. Сообществу нужен был полноценный механизм, и в июле 2019 года на Gophercon был представлен первый дизайн-проект с контрактами. Его встретили с энтузиазмом, но и с критикой — первоначальная версия контрактов оказалась слишком сложной. Этот ранний проект стал отправной точкой для дальнейших итераций, которые в итоге привели к появлению дженериков в Go 1.18.

Альтернативы дженерикам: интерфейсы, рефлексия и генерация кода

Обойтись без дженериков можно было тремя путями, и каждый из них прятал свою цену. Стоит их разобрать — именно они показывают, почему сообщество так долго требовало языковой поддержки.

Интерфейсы: дублирование просто переезжает

Использование интерфейсов — самый «нативный» путь для Go. Так устроен sort.Sort из стандартной библиотеки: вы определяете методы для своего типа и передаёте его в функцию. Звучит разумно, но на практике это означает, что для реверса среза вам придётся вручную объявить именованный тип и написать для него методы. Код дублируется не в теле функции, а в описании типа. По сути, вы просто переместили копирование в другое место, а не избавились от него.

Если попытаться пойти дальше и «научить» интерфейсы работать без ручных методов, вы столкнётесь с непреодолимым ограничением. Допустим, язык сам определит метод Index для всех срезов. Тогда он должен возвращать пустой интерфейс — и статическая типизация теряет смысл. А если вы захотите написать функцию, принимающую два среза одного типа или возвращающую срез по типу из map, — это станет невозможно в принципе.

Reflect: цена выше выгоды

Технически вы можете написать универсальный Reverse с пакетом reflect. Практически — этот код настолько неудобен и медленен, что мало кто доводит такую затею до продакшена. Вдобавок при каждом обращении к значению нужны явные проверки типов, а компилятор не сможет вас защитить: ошибка проявится только в рантайме, а не на этапе сборки. Статическая проверка — главное преимущество Go — попросту исчезает.

Кодогенерация: каскад проблем

Наконец, можно сгенерировать Reverse для каждого типа отдельно. Пакетов-генераторов существует несколько, и они работают. Но добавьте сюда новый шаг сборки для каждого пакета, которому нужна функция. Умножьте это на необходимость компилировать все копии кода. И главное: когда вы найдёте и исправите ошибку в исходной версии, придётся перегенерировать каждую копию — а она может лежать в совершенно другом проекте. Один баг превращается в проблему синхронизации всей экосистемы.

Итог прост: все три способа настолько неудобны, что большинство разработчиков просто пишут функцию для конкретного типа — и заодно тесты к ней, чтобы не пропустить элементарную ошибку. Кстати, именно так в своё время появился баг в самой первой версии ReverseInts: автор забыл вычесть единицу из длины среза. Каждая такая копия кода — потенциальный новый баг и новое дублирование, на которое уходят часы работы.

Финальный синтаксис Go 1.18: параметры типов и ограничения

Рассмотрим две функции, которые чаще всего приводят в качестве примера при обсуждении дженериков в Go: Reverse и Min. Они показывают разные уровни сложности синтаксиса — от простого до требующего явного ограничения.

Reverse: параметр типа без ограничений

В черновом варианте дизайна 2019 года функция Reverse выглядела так:

func Reverse (type Element) (s []Element) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Тело функции идентично версиям для []int и []string. Изменилась только сигнатура: тип элемента вынесен в отдельный параметр Element. Обратите внимание на синтаксис — ключевое слово type перед именем параметра отличает тип-параметр от обычного аргумента.

При вызове вы можете явно указать тип: Reverse(int)(s). Но в большинстве случаев компилятор выведет его сам из типа аргумента — вызов Reverse(s) не отличается от вызова обычной функции. Сложность ложится на автора функции, а не на её пользователя.

Для Reverse не нужно никаких ограничений на Element: единственная операция — присваивание, которое работает с любым типом в Go.

Min: контракты как ограничения

С Min ситуация иная. Чтобы сравнить два значения оператором <, тип должен поддерживать это сравнение. Здесь появляется понятие контракта — описания того, какие операции допустимы с типом-параметром.

func Min (type T Ordered) (a, b T) T {
    if a < b {
        return a
    }
    return b
}

contract Ordered(T) {
    T int, int8, int16, int32, int64,
        uint, uint8, uint16, uint32, uint64, uintptr,
        float32, float64,
        string
}

Контракт Ordered — это просто список встроенных типов, поддерживающих оператор <. Он также принимает именованные типы, чей базовый тип входит в этот список. Перечислять типы оказалось проще, чем придумывать новую нотацию для операторов: в Go операторы работают только со встроенными типами.

В реальном коде контракт Ordered скорее всего оказался бы в стандартной библиотеке, и функция выглядела бы так:

func Min (type T contracts.Ordered) (a, b T) T {
    if a < b {
        return a
    }
    return b
}

Контракты могут описывать не только список типов, но и требования к методам. Для функции ToStrings, которая вызывает метод String() на каждом элементе, контракт выглядит так:

contract Stringer(T) {
    T String() string
}

Здесь важно понимать разницу: аргумент ToStrings — не []fmt.Stringer, а срез некоторого типа, реализующего fmt.Stringer. Представление в памяти у этих двух видов срезов обычно различается, и Go не поддерживает прямое преобразование между ними.

Практические соображения

Черновая версия синтаксиса с ключевым словом type и контрактами была предложена в 2019 году. Окончательная версия, вышедшая в Go 1.18, использует квадратные скобки и интерфейсные ограничения вместо контрактов — но концепция осталась той же. Имейте в виду: примеры с contract не компилируются в современных версиях Go, они показывают лишь эволюцию дизайна. Сегодняшний синтаксис выглядит как func Min[T constraints.Ordered](a, b T) T, а ограничения задаются через интерфейсы. Если вы встретите обучающие материалы с contract и (type Element) — это исторический черновик, а не актуальный синтаксис.

Практика: универсальные структуры данных и функции

Когда речь заходит о дженериках, большинство примеров ограничивается функциями вроде Reverse. Но не менее важны обобщённые структуры данных. Сравните: встроенные slice и map умеют хранить значения любого типа — но что, если вам нужен упорядоченный набор элементов или дерево? Без дженериков вы пишете новую реализацию для каждого типа данных.

Рассмотрим бинарное дерево, которое принимает функцию сравнения. Это изящный приём: он снимает с элемента требования реализовывать какие-либо интерфейсы — дерево работает с любым типом, для которого вы можете задать правило порядка.

// Tree — обобщённая структура: узел хранит значение типа E.
// compare задаёт порядок: отрицательное, ноль или положительное
// при сравнении двух значений.
type Tree(type E) struct {
    root    *node(E)
    compare func(E, E) int
}

type node(type E) struct {
    val         E
    left, right *node(E)
}

Обратите внимание: node тоже параметризован типом E. Здесь проявляется первое отличие обобщённых структур от функций — тип приходится писать явно. Создание нового дерева выглядит так:

func New(type E)(cmp func(E, E) int) *Tree(E) {
    return &Tree(E){compare: cmp}
}

А вот метод поиска — он возвращает не само значение, а указатель на «слот» в дереве. Если элемента нет, указатель указывает на место, куда его следует вставить:

func (t *Tree(E)) find(v E) **node(E) {
    pn := &t.root
    for *pn != nil {
        switch cmp := t.compare(v, (*pn).val); {
        case cmp < 0:
            pn = &(*pn).left
        case cmp > 0:
            pn = &(*pn).right
        default:
            return pn
        }
    }
    return pn
}

Этот приём с двойным указателем избавляет от дублирования логики поиска между Contains и Insert. Contains лишь проверяет, не пуст ли найденный слот:

func (t *Tree(E)) Contains(v E) bool {
    return *t.find(v) != nil
}

func (t *Tree(E)) Insert(v E) bool {
    pn := t.find(v)
    if *pn != nil {
        return false // элемент уже существует
    }
    *pn = &node(E){val: v}
    return true
}

Синтаксис **node(E) пугает на первый взгляд, но логика проста: find ищет место, где должен находиться элемент, и возвращает адрес указателя на это место. Так одна функция обслуживает и проверку наличия, и вставку, и саму операцию вставки — без дополнительного прохода по дереву.

Пользователь такой структуры не задумывается о синтаксических тонкостях:

var intTree = tree.New(func(a, b int) int { return a - b })

func InsertAndCheck(v int) {
    intTree.Insert(v)
    if !intTree.Contains(v) {
        log.Fatalf("%d not found after insertion", v)
    }
}

Почему бы не написать то же самое на интерфейсах? Формально можно: определить Tree с полем compare func(interface{}, interface{}) int. Но тогда вы теряете статическую типизацию — компилятор не проверит, что в дерево вставляются значения одного типа. Придётся добавлять утверждения типа при каждой вставке и при каждом сравнении. Дженерики решают эту задачу на уровне типов: дерево хранит значения конкретного типа E, и попытка вставить что-то несоответствующее не скомпилируется.

Что нового в Go 1.27: generic методы и улучшенный вывод типов

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

Первое: теперь поддерживаются методы с собственными параметрами типа. До этого приходилось дублировать код для каждого типа. Взгляните на пример из стандартной библиотеки math/rand/v2: раньше для генерации случайного числа каждого целочисленного типа требовался отдельный метод Int32N, Int64N, IntN. Теперь один метод N[Int intType] покрывает все целочисленные типы разом. Это не просто сокращение кода — это устранение источника ошибок при копировании реализации для очередного типа.

Второе изменение касается контекстов присваивания. Раньше, вызывая generic-функцию, вы должны были явно указывать параметр типа, либо компилятор выводил его из аргументов функции. Теперь вывод работает и там, где нет прямого вызова. Представьте, что у вас есть функция GenericFormatter[T any], которая форматирует значение любого типа, и вы хотите использовать её как значение типа IntFormatter — функции, принимающей int. В Go 1.27 компилятор сам выведет T = int в трёх случаях: при инициализации среза литералом []IntFormatter{GenericFormatter}, при преобразовании типа IntFormatter(GenericFormatter) и при отправке в канал ch <- GenericFormatter. Раньше каждый такой сценарий требовал ручного указания типа — лишний шум и барьер для использования дженериков в составе более крупных выражений.

Практический эффект этих изменений: код становится чище, а generic-функции наконец можно использовать почти так же свободно, как обычные. Выполнив пример с GenericFormatter, вы увидите в консоли value: 42 — функция отформатирует целое число, хотя её тип был выведен из контекста, а не задан явно.

Прочие улучшения Go 1.27: производительность и новые пакеты

Ограничиваться только generic-методами и выводом типов было бы расточительно. В Go 1.27 появилось несколько изменений, которые заметно упростят повседневную работу.

Одно из них касается инициализации структур с вложенными полями. Раньше, если структура содержала встроенную (embedded) структуру, вы не могли напрямую указать ключ для поля этой вложенной структуры в литерале. Приходилось сначала создавать вложенную структуру отдельно, затем присваивать её. Теперь компилятор разрешает использовать любой валидный селектор поля как ключ. Пример из блога Go: структура Gopher содержит встроенную Habitat, и теперь допустима запись g := Gopher{Name: "Gopher", Burrow: "Burrow #42"}. Мелочь, но код становится плоским и читается легче — не нужно мысленно разворачивать уровни вложенности.

Второе улучшение — в рантайме. Для небольших объектов (менее 80 байт) аллокация стала быстрее до 30% за счёт специализированных пулов памяти. Для allocation-heavy приложений это даёт около 1% прироста к общей производительности. Цифра скромная, но она бесплатная — вы просто обновляете компилятор.

И наконец, encoding/json/v2 с новым низкоуровневым пакетом jsontext — это ответ на давние жалобы на строгость и скорость стандартного encoding/json. Старый пакет теперь работает поверх новой реализации, что ускоряет unmarshaling без ломки обратной совместимости. Есть небольшой штраф: новый API чуть сложнее для освоения, но он компенсируется более строгими дефолтами и конфигурируемостью, которых так не хватало.

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

Generic-программирование в Go прошло путь от пожелания до встроенной поддержки — сначала функции с параметризованными типами были доступны в экспериментальной форме, а затем стали стабильным языковым механизмом. Сегодня, начиная с Go 1.27, эта возможность распространилась даже на методы, так что одна реализация покрывает сразу все целочисленные типы. В итоге паттерн «копировать-и-вставить» с поправкой на тип уходит в прошлое: общий код пишется один раз, тестируется один раз и переиспользуется в любом месте, а все сложности остаются тому, кто этот общий код создаёт, а не тому, кто его вызывает.

Ещё один важный итог: время экспериментов прошло. Современный Go 1.27 — не полигон для проверки обобщённого программирования, а инструмент, который уже содержит готовые решения для повседневных сценариев: сокращение аллокаций, поддержка постквантовой криптографии и новый пакет для работы с JSON. Если раньше за производительность и безопасность приходилось доплачивать сторонними библиотеками или собственными ухищрениями, то теперь значительная часть этого богатства доступна из коробки.

Наш практический совет: не спешите переписывать существующий код на свежих возможностях. Сначала опробуйте release candidate Go 1.27 в одном экспериментальном модуле, где используете generic-методы для типизированного доступа к math/rand/v2 и новый пакет encoding/json/v2. На этом небольшом проекте вы проверите, как механизмы работают в реальных условиях, познакомитесь с ограничениями и только потом решите, какие части вашего основного кода стоит мигрировать.

Источники

Похожее