В Go 1.18 добавлена поддержка дженериков. Это самое большое нововведение с момента первого Open Source выпуска Go. Не будем пытаться охватить все детали, затронем все важные моменты. Подробное описание со множеством примеров смотрите в документе с предложением. Материалом делимся к старту курса по Backend-разработке на Go.
Введение в дженерики
За основу этого поста взято наше выступление на GopherCon 2021:
Точное описание изменений в Go см. в обновлённой спецификации языка. (Внимание: в фактической реализации 1.18 на то, что разрешено в предложении-документе, наложены ограничения. Спецификация должна быть точной. В будущих выпусках некоторые ограничения могут быть сняты.)
Дженерики — это способ написания кода, который не зависит от конкретных применяемых типов. Функции и типы теперь могут быть написаны для любого набора типов.
С дженериками в язык добавляются три важные функциональные возможности:
Типы как параметры для функций и типов.
Определение интерфейсных типов как наборов типов, в том числе типов без методов.
Выведение типа, когда во многих случаях типы аргументов при вызове функции опускаются.
Типы как параметры
В функциях и типах теперь параметром может быть тип. Список таких параметров выглядит как список обычных параметров, но вместо круглых скобок используются квадратные.
Чтобы показать принцип работы, начнём с простой функции 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 как аргумента называется инстанцированием. В компиляторе инстанцирование происходит в два этапа:
Замена всех аргументов-типов на соответствующие типам параметры.
Проверка, что каждый тип соответствует своим ограничениям. Подробности позже. Если второй этап не пройден, инстанцирование не происходит и программа не будет работать.
После инстанцирования оставшаяся без параметров функция вызывается так же, как и любая другая. Например, в этом коде:
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, востребованную в любое время:
Выбрать другую востребованную профессию.
Краткий каталог профессий и курсов
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также
Комментарии (5)
xmcuz
30.03.2022 09:12Жаль что нельзя использовать структуры в качестве типов.
Devoter
30.03.2022 21:45Можно, проблема в том, как Go интерпретирует ограничения, ограничения - это интерфейсы, а у интерфейсов не может быть полей, только тип и методы. Таким образом, можно ввести ограничение, которое будет задано структурными типами, но нельзя будет обращаться к ним, так как constraint type - это интерфейсный тип, а не структурный. В некотором смысле это даже хорошо, но почему это может быть хорошо нужно писать отдельную статью.
Druj
func Scale[S ~[]E, E constraints.Integer](s S, c E) S
А кто-то потом говорит что у раста синтаскис токсичный
orcy
Не зная Go, мне тут в принципе всю понятно (или мне кажется что все понятно), кроме ~. Два generic типа у функции, у типов ограничения - вроде все ок.
JTG
Писать код на перле можно на любом языке программирования, как говорится.