понедельник, 2 мая 2022 г.

Введение в использование дженериков в Golang

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

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

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

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

Параметры типа

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

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

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

Мы можем сделать эту функцию универсальной, чтобы она работала для разных типов, добавив список параметров типа. В этом примере мы добавляем список параметров типа с одним параметром типа T и заменяем использование 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]

Здесь дерево универсального типа хранит значения параметра типа T. Универсальные типы могут иметь методы, такие как Lookup в этом примере. Чтобы использовать универсальный тип, необходимо создать его экземпляр; Tree[string] — это пример создания экземпляра Tree с типом аргумента string.

Наборы типов

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

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

Точно так же наборы параметров типа имеют тип для каждого параметра типа. Поскольку параметр типа сам по себе является типом, типы параметров типа определяют наборы типов. Этот метатип называется ограничением типа (type constraint).

В универсальном GMin ограничение типа импортируется из пакета constraints. Ограничение 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 — это типы интерфейса, которые аналогичным образом определены в пакете contraints. Обратите внимание, что нет методов, определенных интерфейсом Ordered.

Для ограничений типа мы обычно не заботимся о конкретном типе, таком как string; нас интересуют все типы строк. Именно для этого используется токен ~. Выражение ~string означает набор всех типов, базовым типом которых является string. Это включает в себя сам тип string, а также все типы, объявленные с такими определениями, как type MyString string.

Конечно, мы по-прежнему хотим указывать методы в интерфейсах, и мы хотим быть обратно совместимыми. В Go 1.18 интерфейс может содержать методы и встроенные интерфейсы, как и раньше, но он также может включать неинтерфейсные типы, объединения (union) и наборы базовых типов.

При использовании в качестве ограничения типа набор типов, определенный интерфейсом, указывает именно те типы, которые разрешены в качестве аргументов типа для соответствующего параметра типа. В теле универсальной функции, если тип операнда является параметром типа 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) // явный аргумент типа

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

var a, b, m float64

m = GMin(a, b) // нет аргумента типа

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

Этот тип вывода, который выводит аргументы типа из типов аргументов функции, называется выводом типа аргумента функции.

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

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

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

// Scale возвращает копию s с каждым элементом, умноженным на c.
// У этой реализации есть проблема, как мы увидим.
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 {
    // Детали не важны.
}

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

// ScaleAndPrint удваивает Point и печатает ее.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // НЕ КОМПИЛИРУЕТСЯ
}

К сожалению, это не компилируется, выдавая ошибку типа r.String undefined (тип []int32 не имеет поля или метода String).

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

Чтобы исправить это, мы должны изменить функцию Scale, чтобы использовать параметр типа для типа среза.

// Scale возвращает копию s с каждым элементом, умноженным на 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 ограничено целым числом, эффект тот же, что и раньше: первый аргумент должен быть срез некоторого целочисленного типа. Единственное изменение в теле функции заключается в том, что теперь мы передаем S, а не []E, когда вызываем make.

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

Но справедливо спросить: почему можно написать вызов Scale без передачи явных аргументов типа? То есть, почему мы можем писать Scale(p, 2) без аргументов типа вместо того, чтобы писать Scale[Point, int32](p, 2)? Наша новая функция Scale имеет два параметра типа, S и E. При вызове Scale без передачи каких-либо аргументов типа вывод типа аргумента функции, описанный выше, позволяет компилятору сделать вывод, что аргумент типа для S — это Point. Но функция также имеет параметр типа E, который является типом коэффициента умножения c. Соответствующий аргумент функции равен 2, и поскольку 2 является нетипизированной константой, вывод типа аргумента функции не может вывести правильный тип для E (в лучшем случае он может вывести тип по умолчанию для 2, который является int и который будет неправильным). Вместо этого процесс, посредством которого компилятор делает вывод о том, что аргумент типа для E является типом элемента среза, называется выводом типа ограничения.

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

Обычно это применяется, когда одно ограничение использует форму ~type для некоторого типа, где этот тип записывается с использованием других параметров типа. Мы видим это в примере Scale. S — это ~[]E, за которым следует тип []E, записанный в терминах другого параметра типа. Если мы знаем аргумент типа для S, мы можем вывести аргумент типа для E. S — это тип среза, а E — тип элемента этого среза.

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

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

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

Заключение

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


Читайте также:


Комментариев нет:

Отправить комментарий