В Go 1.18 добавлена поддержка дженериков. Это самое большое нововведение с момента первого Open Source выпуска Go. Не будем пытаться охватить все детали, затронем все важные моменты. Подробное описание со множеством примеров смотрите в документе с предложением. Материалом делимся к старту курса по Backend-разработке на Go.


Введение в дженерики

За основу этого поста взято наше выступление на GopherCon 2021:

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

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

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

  1. Типы как параметры для функций и типов.

  2. Определение интерфейсных типов как наборов типов, в том числе типов без методов.

  3. Выведение типа, когда во многих случаях типы аргументов при вызове функции опускаются.

Типы как параметры

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

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

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

Параметризуем эту функцию для работы с разными типами, вместо типа float64 добавив список с одним параметром типа T:

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

И вызовем её с типом в качестве аргумента:

x := GMin[int](2, 3)

Указание в GMin типа int как аргумента называется инстанцированием. В компиляторе инстанцирование происходит в два этапа:

  1. Замена всех аргументов-типов на соответствующие типам параметры.

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

После инстанцирования оставшаяся без параметров функция вызывается так же, как и любая другая. Например, в этом коде:

fmin := GMin[float64]
m := fmin(2.71, 3.14)

при инстанцировании GMin[float64] фактически получается исходная функция Min для значений с плавающей точкой. Эту функцию можно вызывать.

У типов теперь тоже могут быть параметры:

type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

Здесь в дженерик-типе Tree хранятся значения параметра типа T. В дженерик-типах могут быть и методы, такие как Lookup выше. Чтобы использовать дженерик-тип, его нужно инстанцировать. Tree[string] — пример инстанцирования Tree с типом-аргументом string.

Наборы типов

Рассмотрим подробнее аргументы-типы, применяемые для инстанцирования типа как параметра.

У обычной функции для каждого значения параметра есть тип, определяющий возможный набор значений. Так, в нашей функции Min с типом float64 для аргумента допустим набор значений с плавающей точкой, которые могут быть представлены этим типом.

Аналогично, в списках типов как параметров тип есть у каждого параметра. Но тип-параметр — сам по себе тип, а значит, типы-параметры определяют наборы типов. Такой набор (метатип) также называется ограничением типа.

В параметризованной функции GMin ограничение типа импортируется из пакета constrains. В ограничении Ordered описывается набор всех типов со значениями, которые можно упорядочить или, другими словами, сравнить через операторы < (или <= , > и т. д.).

Это ограничение гарантирует передачу в GMin только типов с упорядоченными значениями. Кроме того, значения параметра этого типа могут использоваться в теле функции GMin с оператором сравнения <.

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

Но constraints.Ordered — это также интерфейсный тип, а оператор < не является методом. Здесь нужно взглянуть на интерфейсы по-новому.

До недавнего времени в спецификации Go было заявлено, что интерфейс определяет набор методов, примерно соответствующий перечисленному в интерфейсе набору. Любой тип, реализующий все методы набора, реализует соответствующий интерфейс:

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

Посмотрите ниже:

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

Но для наших целей подход с набором типов предпочтительнее: можно явно добавлять типы в набор и, таким образом, по-новому управлять набором типов. Для этого мы расширили синтаксис интерфейсных типов. Например, interface{ int|string|bool } определяет набор типов int, string и bool:

По новому подходу этому интерфейсу соответствуют только int, string или bool.

Рассмотрим фактическое определение contraints.Ordered:

type Ordered interface {
    Integer|Float|~string
}

Здесь интерфейс Ordered — это набор всех целочисленных типов, типов числа с плавающей точкой и строковых типов. Вертикальная полоса обозначает объединение типов (или наборов типов в данном случае).

Integer и Float — это интерфейсные типы, аналогично определённых в пакете constraints. Обратите внимание: нет методов, определяемых интерфейсом Ordered.

Что касается ограничений типа, конкретный тип (например, string) нас обычно не так интересует, как все строковые типы. Вот для чего нужен токен ~: выражение ~string означает набор всех типов с базовым типом string. Это сам тип string и все типы, объявленные с такими определениями, как type MyString string.

Конечно, методы всё равно нужно указывать в интерфейсах и с сохранением обратной совместимости. В Go 1.18, как и прежде, в интерфейсе могут иметь место методы и встроенные интерфейсы, а ещё — встроенные неинтерфейсные типы, объединения и наборы базовых типов.

Набор определяемых интерфейсом типов, когда интерфейс используется в качестве ограничения типа,  точно указывает типы, разрешённые как типы-аргументы для соответствующего параметра типа.

В теле параметризованной функции, тип операнда которой — тип-параметр P с ограничением C, операции разрешены, если они разрешены всеми типами в наборе типов C. Сейчас здесь есть ряд ограничений реализации, но в обычном коде встреча с ними маловероятна.

Используемым как ограничения интерфейсам можно присваивать имена (например, Ordered). Или они могут быть литеральными интерфейсами, встроенными в список типов-параметров. Например:

[S interface{~[]E}, E interface{}]

Здесь S — это тип среза, тип конкретного элемента среза может быть любым.

Это типичный случай, поэтому внешний interface{} для интерфейсов в позиции ограничения можно опустить и просто написать:

[S ~[]E, E interface{}]

Пустой интерфейс часто встречается в списках типов как параметров, да и в обычном коде на Go тоже. Поэтому в качестве псевдонима для пустого интерфейсного типа в Go 1.18 появился новый предварительно объявляемый идентификатор any. С ним получаем идиоматический код:

[S ~[]E, E any]

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

Выведение типов

Новая значительная возможность языка — выведение типов. Это самое сложное, но и важное нововведение, допускающее при написании кода применение естественного стиля, где вызываются параметризованные функции.

Выведение типа-аргумента функции

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

Вернёмся к параметризованной функции GMin:

func GMin[T constraints.Ordered](x, y T) T { ... }

Тип-параметр T нужен, чтобы не указывать обычные типы x и y. Как мы видели ранее, эта функция может вызываться с помощью аргумента с явно заданным типом:

var a, b, m float64

m = GMin[float64](a, b) // explicit type argument

В компиляторе во многих случаях тип-аргумент для T может выводиться из обычных аргументов. Результат — столь же чёткий код, но короче:

var a, b, m float64

m = GMin(a, b) // no type argument

Эффект достигается сопоставлением типов аргументов a и b с типами параметров x и y.

Такое выведение аргументов-типов из типов аргументов в функцию называется выведением типа аргумента функции. Оно происходит только для параметров типа, которые используются в параметрах функции, а не исключительно в результатах функции или в её теле.

Например, выведение типа не применяется к таким функциям, как MakeT[T any]() T, в которых T используется только для результата.

Выведение типа ограничения

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

// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

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

Рассмотрим многомерный тип Point, где каждый Point — это список целых чисел, определяющих координаты точки. Конечно, у этого типа есть методы:

type Point []int32

func (p Point) String() string {
    // Details not important.
}

Чтобы масштабировать Point, пригодится функция Scale:

// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // DOES NOT COMPILE
}

Но она не компилируется и завершается ошибкой r.String undefined (type []int32 has no field or method String).

Проблема заключается в том, что функция Scale возвращает значение типа []E, где E — это тип элемента среза аргумента. Когда мы вызываем Scale со значением типа Point, базовый тип которого — []int32, то получаем значение типа []int32, а не Point. Это обусловлено самим способом написания кода (дженериком). Но это не то, что нам здесь нужно.

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

// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

Мы ввели новый тип-параметр среза S и ограничили его так, чтобы базовым типом стал S, а не []E, и типом результата также был S. Но E может быть только целым числом, поэтому эффект тот же, что и раньше: первый аргумент должен быть срезом целочисленного типа. Единственное изменение в теле функции: когда мы вызываем make — передаём S, а не []E.

Поведение новой функции — такое же, как и у прежней, если вызывать её с помощью обычного среза. Если же использовать тип Point, то получим значение типа Point. Это то, что нам нужно. В этой версии Scale более ранняя функция ScaleAndPrint будет компилироваться и запускаться, как мы ожидаем.

Но почему можно писать вызов к Scale без передачи аргументов с явно заданным типом? То есть почему, вместо того чтобы писать Scale[Point, int32](p, 2), мы можем написать Scale(p, 2) без типов-аргументов?

В новой функции Scale теперь два типа-параметра: S и E. Поскольку при вызове к Scale никаких типов-аргументов не передаётся, то описанный выше механизм выведения типа-аргумента функции позволяет компилятору в качестве типа-аргумента для S вывести Point.

Но у функции есть ещё тип-параметр E, — это тип множителя с. Соответствующий аргумент функции равен 2, а поскольку 2 — это нетипизированная константа, вывод типа аргумента функции не может вывести правильный тип для E: в лучшем случае он может вывести тип по умолчанию для 2, — int, что неверно.

Вместо этого происходит процесс, с помощью которого в компиляторе выводится, что тип-аргумент для E — это тип элемента среза. Этот процесс называется выведением типа ограничения. Типы-аргументы выводятся из ограничений параметров типа.

Выведение типа ограничения применяется, когда у одного типа-параметра есть ограничение, определяемое как другой тип-параметр. Когда тип-параметр одного из таких парметров известен, ограничение используется для выведения типа-аргумента другого типа-аргумента.

Обычный случай применения такого вывода — когда в одном ограничении используется форма ~type для типа, в свою очередь записываемого с помощью других типов-параметров. Мы видим это в примере со Scale.

Здесь S — это ~[]E, то есть ~, за которым идёт тип []E, написанный как другой тип-параметр. Если мы знаем тип-аргумент для S, то можем вывести и тип-аргумент для E. S — это тип среза, а E — тип элемента этого среза.

Мы рассмотрели лишь основы выведения типа ограничения. Подробности смотрите в документе с предложением или в спецификации языка.

Выведение типа на практике

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

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

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

Заключение

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

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

А мы поможем вам прокачать навыки или освоить профессию в IT, востребованную в любое время:

Выбрать другую востребованную профессию.

Краткий каталог профессий и курсов

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


  1. Druj
    29.03.2022 16:47
    +25

    func Scale[S ~[]E, E constraints.Integer](s S, c E) S

    А кто-то потом говорит что у раста синтаскис токсичный


    1. orcy
      30.03.2022 07:45

      Не зная Go, мне тут в принципе всю понятно (или мне кажется что все понятно), кроме ~. Два generic типа у функции, у типов ограничения - вроде все ок.


    1. JTG
      30.03.2022 14:29
      +2

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


  1. xmcuz
    30.03.2022 09:12

    Жаль что нельзя использовать структуры в качестве типов.


    1. Devoter
      30.03.2022 21:45

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