Туториал по дженерикам в Go

49 minute read

Перевод статьи “A Comprehensive Guide to Generics in Go

Go статически типизированный язык. Это значит, что типы переменных проверяются на этапе компиляции. Встроенные типы в Go, такие как мапы, слайсы каналы, а также встроенные функции, например len и make умеют работать с разными типами. Но до версии 1.18 пользовательские типы так не умели.

Это значит, что в Go, если я реализую бинарное дерево для, например, типа int:

1type IntTree struct {
2 left, right *IntTree
3 value int
4}
5func (t * IntTree) Lookup(x int) *IntTree { 
6    // ...
7}

… и потом понадобится реализовать дерево для strings или float, и мне будет нудна типобезопасность, то придется написать кастомный код для каждого типа отдельно. Такой подход подвержен ошибкам, очень многословен и нарушает принцип DRY(не повторять себя).

С другой стороны, я могу изменить свое бинарное дерево так, чтобы оно работало с типом interface{}. Это равнозначно разрешению на работу с любым типом. Но так я потеряю возможность проверки типов во время компиляции, а проверка типов при компиляции - одно из главных преимуществ Go. Кроме того, в Go нет возможности обработки слайса любого типа - слайс []string или []int нельзя назначить переменной типа interface{}.

В результате, до Go 1.18 и дженериков, общие алгоритмы для слайсов, такие как map, reduce, filter, реализовывались в ручную для каждого типа отдельно. Это расстраивало разработчиков и было причиной критики языка Go.

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

Для примера, возьмем две функции которые суммируют слайсы map[string]int64 и map[string]float64 соответственно:

 1// SumInts складывает вместе значения m.
 2func SumInts(m map[string]int64) int64 {
 3    var s int64
 4    for _, v := range m {
 5        s += v
 6    }
 7    return s
 8}
 9
10// SumFloats складывает вместе значения m.
11func SumFloats(m map[string]float64) float64 {
12    var s float64
13    for _, v := range m {
14        s += v
15    }
16    return s
17}

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

Или еще один пример - две функции, которые умножают значения типа int и int16:

1func DoubleInt(value int) int {
2   return value * 2
3}
4
5func DoubleInt16(value int16) int16 {
6   return value * 2
7}

Возможность написать одну, которая перемножают любые типы, это очень удобно. Неправда ли?

Почему так долго ждали?

Если аргументы в пользу дженериков такая так убедительны, то почему команде разработки Go понадобилось больше 10 лет для их реализации? На самом деле, эту проблему не так просто решить. Go делает упор на быстроте компиляции, понятном и читабельном коде, быстроте исполнения. Различные предполагаемы реализации ставили под угрозу одно ил несколько из этих преимуществ.

Но, как это часто бывает с открытым ПО, коллективное решение проблемы в конечном итоге привело к приемлемой реализации. Решение получилось универсальным, быстрым и эффективным. Но при этом достаточно гибким, чтобы соответствовать требованиям.

Дженерики а Go - резюме

  • Начиная с версии 1.18 язык Go был расширен, чтобы дать возможность добавлять явно определенные структурные ограничения, называемые параметрами типа(type parameters), к объявлениям функций и объявлениям типов.
1func F[T any](p T) { … }
2
3type M[T any] []
  • Список параметров типа [T any] использует квадратные скобки, но в остальном выглядит как обычный список параметров. Эти параметры типа затем могут использоваться обычными параметрами функции или в теле функции.

  • Общий(дженерик) код ожидает, что аргументы типа будут соответствовать определенным требованиям, которые называются ограничениями(constraints). Для каждого параметра типа должно быть определено ограничение:

1func F[T Constraint](p T) {
2    // ...
3 }
  • Эти ограничение не более чем интерфейсные типы.

  • Ограничение параметра типа может могут ограничивать набор аргументов типа одним из трех способов:

  1. произвольный(arbitrary) тип T ограничивается этим типом:
1func F[T MyConstraint](p T) {
2    // ...
3 }
  1. элемент приближения(approximation element) ~T ограничивается всеми типами, базовым типом которых является T
1func F[T ~int] (p T) {
2    // ...
3 }
  1. объединенный элемент T1 | T2 | … ограничивается любым из перечисленных элементов
1func F[T ~int | ~string | ~float64] (p T) {
2    // ...
3 }
  • Когда универсальные функции и типы используют операторы для определенных параметров типа, эти параметры должны удовлетворять интерфейсу, определяемому ограничением параметра.

  • И самое главное, дизайн дженериков полностью обратно совмести с Go 1.

Разбираемся с параметрами типа

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

Также как и обычные параметры имеют типы, параметры типов имеют мета-типы - ограничения(или constraints).

1// Print печатает элементы любых слайсов.
2// Print имеет параметр типа T и имеет один обычный (non-type)
3// параметр s, который является слайсом из элементов 
4// указанного параметра типа.
5func Print[T any](s []T) {
6   for _, v := range s {
7     fmt.Println(v)
8   }
9}

В этом примере в функции Print идентификатор T является параметром типа, типом который в данный момент неизвестен, но который будет известен при вызове функции. Как показано выше, ограничение any допускает любой тип в качестве аргумента типа и разрешает функции использовать только операции, разрешенные для типа any. Интерфейсный тип для any это пустой интерфейс: interface{}. По факту, в дженериках ключевое слово any это просто синтаксический сахар для interface{}. Мы можем переписать функцию Print так:

1func Print[T interface{}](s []T) {
2 // ...
3}

Параметр типа (T) также может использоваться при определении типов параметров функции. Парамер s определяется как слайс элементов T. И это параметр типа может использоваться внутри тела функции как тип переменных.

Считается идеоматически правильным для параметров типа использовать одну большую букву, например T или S.

Так как в определении Print есть параметры типов, то при вызовы функции Print необходимо указывать аргумент типа. Аргументы типа указываются так же, как объявляются параметры типа: в виде списка аргументов с использованием квадратных скобок.

 1// Вызов Print с аргументом []int.
 2// Print имеет параметр типа T, и мы хотим передать []int,
 3// для этого указываем int в аргументах типа написав Print[int].
 4// Функция Print[int] ожидает []int как аргумент.
 5Print[int]([]int{1, 2, 3})
 6
 7// Код выводит:
 8// 1
 9// 2
10// 3

В приведенном выше примере функции Print задан аргумент типа [int]. Это явно указывает, что мы вызываем универсальную(дженерик) функцию со слайсом интов.

Разбираемся с ограничениями параметров типа

Ниже показана функция, которая конвертирует слайс любого типа в слайс []string через вызов метода String у каждого элемента.

1// Эта функция не работает как надо.
2func Stringify[T any](s []T) (ret []string) {
3    for _, v := range s {
4        ret = append(ret, v.String()) // Это не сработает
5    }
6    return ret
7}

На первый взгляд эта функция выглядит правильной: переменная v имеет тип T и тип T может быть типом any.

Но тип T должен иметь метод String(), чтобы работал вызов v.String().

Как было сказано выше, аргумент типа отвечает некоторым требованиям, которые называются ограничениями(constraints). Эти ограничения работают как для аргументов типа, передаваемых во время вызова, так и на код внутри дженерик функции.

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

Вызывающий код должен передавать только такой аргумент типа, который удовлетворяет ограничению. То есть, аргумент типа должен реализовывать все методы, определенные ограничением.

Возвращаясь к нашему примеру, видно что ограничение указано как any. Это значит, что на переданом параметре дженерик функция может совершать только те операции(или методы), которые доступны для типа any

Есть ли у типа any метод String()? Конечно нет. Значит, для нашего примера Stringify мы не можем использовать any как ограничение типа.

Для правильной работы Stringify необходим интерфейсный тип с методом String() без аргументов и возвращающий строку.

 1// Stringer это ограничение типа которое требует, чтобы аргумент типа 
 2// имел метод String и позволяет дженерик функции вызвать String().
 3// Метод String должен возвращать строку.
 4type Stringer interface {
 5    String() string
 6}
 7
 8// Stringify вызывает метод String для каждого элемента s, и возвращает 
 9// результат. После параметра T указано ограничение, которое 
10// применяется к типу T, в нашем случае это Stringer.
11func Stringify[T Stringer](s []T) (ret []string) {
12    for _, v := range s {
13        ret = append(ret, v.String())
14    }
15    return ret
16}

… и пример работы нашей функции для int типа:

 1import (
 2    "fmt"
 3    "strconv"
 4)
 5
 6type myint int
 7
 8func (i myint) String() string {
 9    return strconv.Itoa(int(i))
10}
11
12func main() {
13    x := []myint{myint(1), myint(2), myint(3)}
14    Stringify(x)
15    fmt.Println(x)
16    // Prints "[1 2 3]"
17}

Обратите внимание, что как и каждый обычный параметр может иметь свой тип, так и каждый параметр типа может иметь свое ограничение.

 1// Stringer это ограничение типа которое требует, чтобы аргумент типа 
 2// имел метод String и позволяет дженерик функции вызвать String().
 3// Метод String должен возвращать строку.
 4type Stringer interface {
 5    String() string
 6}
 7
 8// Plusser это ограничение типа, которое требует, чтобы аргумент типа 
 9// имел метод Plus. Ожидается что Plus метод добавить получаемую 
10// строку с внутренней строке и вернет получившийся результат 
11type Plusser interface {
12    Plus(string) string
13}
14
15// ConcatTo принимает слайс елементов, которые имеют метод String
16// и слайс элементов с методом Plus. Слайсы должны быть одинакового размера.
17// Функция конвертирует каждый элемент слайса s в строку и передает его в 
18// метод Plus соответствующего элемента из слайса p и возвращает строк,
19// полученных в результате
20func ConcatTo[S Stringer, P Plusser](s []S, p []P) []string {
21    r := make([]string, len(s))
22    for i, v := range s {
23        r[i] = p[i].Plus(v.String())
24    }
25    return r
26}

Предефайненные ограничения и интерфейсы типов

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

1// SumIntsOrFloats суммирует значения мамы m. 
2// Поддерживает int64 и float64 типы элементов в мапе.
3func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
4    var s V
5    for _, v := range m {
6        s += v
7    }
8    return s
9}

Обратите внимание на ограничение параметра типа K - comparable. Так как множество дженерик функций используют сравнения и циклы по мапам и слайсам. Поэтому в Go делает доступным по умолчанию набор часто используемых ограничений.

Ограничение comparable из стандартной библиотеки позволяет использовать любые типы, где значения можно сравнивать с помощью операторов == and !=. Учитывая что ключи в мапе обязательно должны быть сопоставимы, то необходимо указывать K как comparable для использования его в качестве ключа.

И обратите внимание на список ограничений типов для параметра типа V: int64 | float64. Использование | позволяет объединить два типа и такое ограничение позволяет использовать int64 или float64 в качестве параметра. Любой из типов будет разрешен компилятором в качестве аргумента в вызывающем коде.

В аргументах функции указан тип map[K]V. Как это уже понятно - это валидный тип мапы, так как K это сопоставимый тип. Если мы не укажем как comporable, то компилятор не пропустит выражение map[K]V.

Пример вызова вышеуказанной функции:

 1// Инициализация мапы с интовыми значениями
 2ints := map[string]int64{
 3    "first":  34,
 4    "second": 12,
 5}
 6
 7// Инициализация мапы с float значениями
 8floats := map[string]float64{
 9    "first":  35.98,
10    "second": 26.99,
11}
12
13fmt.Printf("Generic Sums: %v and %v\n",
14    SumIntsOrFloats[string, int64](ints),
15    SumIntsOrFloats[string, float64](floats))

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

Часто, компилятор Go может вывести типы, которые вы хотите использовать, из аргументов функции. Это называется выводом типа и позволяет вам вызывать универсальные функции более упрощенным способом (опуская аргументы типа).

1fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
2    SumIntsOrFloats(ints),
3    SumIntsOrFloats(floats))

В заключении

Дженерики в Go - это простое, элегантное и мощное дополнение к языку Go, позволяющее разработчикам использовать простые абстрактные шаблоны типобезопасным способом. В ближайшие годы, по мере того как дженерики получат массовое распространение, они приведут к еще более эффективному написанию кода на Go. Тем не менее, разработчики Go должны быть благоразумны при выборе дженериков. Процитирую Иэна Лэнса Тейлора:

If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider using a type parameter. Another way to say this, is you should avoid using type parameters until you notice you’re about to write the exact same code multiple times.

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

Примеры дженериков

В завершении приведу несколько полезных примеров дженериков в Go:

Дженерики числовых фкнуций

 1package main
 2
 3import (
 4	"fmt"
 5
 6	"golang.org/x/exp/constraints"
 7)
 8
 9type Number interface {
10	constraints.Float | constraints.Integer | constraints.Complex
11}
12
13func Double[T Number](value T) T {
14	return value * 2
15}
16
17func DotProduct[T Number](s1, s2 []T) T {
18	if len(s1) != len(s2) {
19		panic("DotProduct: slices of unequal length")
20	}
21	var r T
22	for i := range s1 {
23		r += s1[i] * s2[i]
24	}
25	return r
26}
27
28func Sum[K comparable, V Number](m map[K]V) V {
29	var s V
30	for _, v := range m {
31		s += v
32	}
33	return s
34}
35
36func main() {
37	// Invoke Double
38	fmt.Println(Double(23))
39	fmt.Println(Double(23.23))
40	fmt.Println(Double(-2323.3434))
41
42	// Invoke DotProduct
43	i := []int{1, 2, 3}
44	j := []int{4, 5, 6}
45	fmt.Println(DotProduct(i, j))
46
47	// Invoke Sum
48	ints := map[string]int64{
49		"first":   23,
50		"second":  565,
51		"third":   755,
52		"fourth":  766,
53		"fifth":   8977,
54		"sixth":   70433,
55		"seventh": 4339222,
56	}
57	fmt.Println(Sum(ints))
58
59}

Дженерик функций для слайсов

 1package main
 2
 3import (
 4	"fmt"
 5	"sort"
 6
 7	"golang.org/x/exp/constraints"
 8)
 9
10// Map turns a []T1 to a []T2 using a mapping function.
11// This function has two type parameters, T1 and T2.
12// This works with slices of any type.
13func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
14	r := make([]T2, len(s))
15	for i, v := range s {
16		r[i] = f(v)
17	}
18	return r
19}
20
21// Reduce reduces a []T1 to a single value using a reduction function.
22func Reduce[T1, T2 any](s []T1, initializer T2, f func(T2, T1) T2) T2 {
23	r := initializer
24	for _, v := range s {
25		r = f(r, v)
26	}
27	return r
28}
29
30// Filter filters values from a slice using a filter function.
31// It returns a new slice with only the elements of s
32// for which f returned true.
33func Filter[T any](s []T, f func(T) bool) []T {
34	var r []T
35	for _, v := range s {
36		if f(v) {
37			r = append(r, v)
38		}
39	}
40	return r
41}
42
43// Merge - receives slices of type T and merges them into a single slice of type T.
44func Merge[T any](slices ...[]T) (mergedSlice []T) {
45	for _, slice := range slices {
46		mergedSlice = append(mergedSlice, slice...)
47	}
48	return mergedSlice
49}
50
51// Includes - given a slice of type T and a value of type T,
52// determines whether the value is contained by the slice.
53func Includes[T comparable](slice []T, value T) bool {
54	for _, el := range slice {
55		if el == value {
56			return true
57		}
58	}
59	return false
60}
61
62// // Sort - sorts given a slice of any orderable type T
63// The constraints.Ordered constraint in the Sort() function guarantees that 
64// the function can sort values of any type supporting the operators <, <=, >=, >.
65func Sort[T constraints.Ordered](s []T) {
66	sort.Slice(s, func(i, j int) bool {
67		return s[i] < s[j]
68	})
69}
70
71func main() {
72
73	s := []int{1, 2, 3, 7, 5, 22, 18}
74	j := []int{4, 5, 6}
75
76	floats := Map(s, func(i int) float64 { return float64(i) })
77	fmt.Println(floats)
78
79	sum := Reduce(s, 0, func(i, j int) int { return i + j })
80	fmt.Println(sum)
81
82	evens := Filter(s, func(i int) bool { return i%2 == 0 })
83	fmt.Println(evens)
84
85	merged := Merge(s, j)
86	fmt.Println(merged)
87
88	i := Includes(s, 22)
89	fmt.Println(i)
90
91	Sort(s)
92	fmt.Println(s)
93
94}

Дженерик функций для мап

 1package main
 2
 3import (
 4	"fmt"
 5
 6	"golang.org/x/exp/constraints"
 7)
 8
 9// Keys returns the keys of the map m in a slice.
10// The keys will be returned in an unpredictable order.
11// This function has two type parameters, K and V.
12// Map keys must be comparable, so key has the predeclared
13// constraint comparable. Map values can be any type.
14func Keys[K comparable, V any](m map[K]V) []K {
15	r := make([]K, 0, len(m))
16	for k := range m {
17		r = append(r, k)
18	}
19	return r
20}
21
22// Sum sums the values of map containing numeric or float values
23func Sum[K comparable, V constraints.Float | constraints.Integer](m map[K]V) V {
24	var s V
25	for _, v := range m {
26		s += v
27	}
28	return s
29}
30
31func main() {
32	k := Keys(map[int]int{1: 2, 2: 4})
33	fmt.Println(k)
34
35	s := Sum(map[int]int{1: 2, 2: 4})
36	fmt.Println(s)
37
38}

Дженерик реализация структуры Set

 1package main
 2
 3import (
 4	"fmt"
 5
 6	"golang.org/x/exp/constraints"
 7)
 8
 9// Keys returns the keys of the map m in a slice.
10// The keys will be returned in an unpredictable order.
11// This function has two type parameters, K and V.
12// Map keys must be comparable, so key has the predeclared
13// constraint comparable. Map values can be any type.
14func Keys[K comparable, V any](m map[K]V) []K {
15	r := make([]K, 0, len(m))
16	for k := range m {
17		r = append(r, k)
18	}
19	return r
20}
21
22// Sum sums the values of map containing numeric or float values
23func Sum[K comparable, V constraints.Float | constraints.Integer](m map[K]V) V {
24	var s V
25	for _, v := range m {
26		s += v
27	}
28	return s
29}
30
31func main() {
32	k := Keys(map[int]int{1: 2, 2: 4})
33	fmt.Println(k)
34
35	s := Sum(map[int]int{1: 2, 2: 4})
36	fmt.Println(s)
37
38}

Что почитать

comments powered by Disqus