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

Это текстовая версия моего ролика про дженерики:

Данный материал частично основан на официальном туториале от команды языка.

Зачем нужны дженерики

Идея обобщённого программирования заключается в том, чтобы единожды написать код, который может работать с множеством разных типов, соответствующих заданным ограничениям. Например, можно только один раз реализовать алгоритм сортировки чисел, но применять его к разным видам чисел, вместо того чтобы реализовывать его отдельно для int, int64, uint32, float64 и т.д. То, что это должны быть именно числа как раз и является ограничением в данном случае. Дженерики — это также одна из форм полиморфизма.

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

1
2
3
4
5
6
type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 |
		~string
}
  • С помощью оператора | перечисляются типы, подходящие под констрейнт. Таким образом Ordered требует, чтобы тип был таким, что над ним задано отношение порядка.
  • Префикс ~ перед каждым типом означает, что это может быть, например, не только string, но и любой тип, для которого string является базовым типом (т.е. типы, расширяющие string тоже удовлетворяют требованиям).

Имея такое ограничение, мы можем объявить, например, вот такую функцию:

1
2
3
4
5
6
7
8
9
func Min[T constraints.Ordered](s []T) T {
	min := s[0]
	for _, v := range s[1:] {
		if v < min {
			min = v
		}
	}
	return min
}

Конструкция [T Ordered] после имени функции означает, что в качестве типа аргумента можно использовать обозначение T, при этом T обязан удовлетворять ограничению Ordered, т.е. быть одним из типов, перечисленных в Ordered.

При этом в теле функции не важно, какой конкретно тип у элементов слайса s. Важно только то, что возможно определить, какой элемент больше, а какой меньше. С другой стороны, нам не требуется прибегать к механизму type assertion. Посмотрите, как громоздко это выглядело бы, и каким вариант type assertion обладает потенциалом для багов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
	"fmt"
	"log"
)

func main() {
	s := []interface{}{1, 2, 3}
	minNum, err := min(s)
	if err != nil {
		log.Fatal(err)
	}
	log.Println(minNum)
}

func min(s []interface{}) (interface{}, error) {
	min := s[0]
	for _, v := range s {
		switch v.(type) {
		case int:
			vInt := v.(int)
			minInt, ok := min.(int)
			if !ok {
				return nil, fmt.Errorf("type mismatch: expected min to be int, got %T", min)
			}
			if vInt < minInt {
				min = v
			}
		case float64:
			vFloat64 := v.(float64)
			minFloat64, ok := min.(float64)
			if !ok {
				return nil, fmt.Errorf("type mismatch: expected min to be float64, got %T", min)
			}
			if vFloat64 < minFloat64 {
				min = v
			}
		// все остальные кейсы для всех остальных поддерживаемых типов
		default:
			return nil, fmt.Errorf("unsupported type %T", v)
		}
	}

	return min, nil
}

На данном примере видно, что есть случаи, когда использование дженериков удобнее и безопаснее использования interface{}.

Пример. Сумма чисел

Давайте взглянем на пример из официального туториала. Представьте, что у вас есть функция, которая считает сумму значений в мапе:

1
2
3
4
5
6
7
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

Предполагается, что числа в мапе — это int64. В какой-то момент вам захотелось начать считать такую же сумму, но для float64:

1
2
3
4
5
6
7
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}

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

Чтобы это было возможным, в Go 1.18 появились параметры типа. Это похоже на обычные параметры функции, но работают они немного на другом уровне. Каждый параметр типа имеет ограничение (constraint), который определяет допустимый набор типов. Во время компиляции вместо этого множества типов подставляется тот, что используется фактически.

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

1
2
3
4
5
6
7
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}
  • Здесь K и V являются параметрами типа, а m параметром функции.
  • comparable и int64 | float64 — это ограничения на типы.
  • comparable — встроенное в язык (начиная с версии 1.18) ограничение, захватывающее буквально все типы, для которых уместны операторы сравнения.
  • int64 | float64 означает, что тип может быть либо int64, либо float64.

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

Вызываться такая функция может так:

1
2
3
fmt.Printf("Generic Sums: %v and %v\n",
    SumIntsOrFloats[string, int64](ints),
    SumIntsOrFloats[string, float64](floats))

Либо так:

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

Поскольку в данном случае компилятор способен самостоятельно вывести и подставить типы.

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

1
2
3
type Number interface {
    int64 | float64
}

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

1
2
3
4
5
6
7
func SumNumbers[K comparable, V Number](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

Пример. Обобщённый репозиторий

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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
package repository

import (
	"errors"
	"fmt"
	"sync"

	"github.com/google/uuid"
)

// Entity одновременно является абстракцией над хранимыми данными 
// и ограничением для обобщённого репозитория.
type Entity interface {
	ID() uuid.UUID
	SetID(id uuid.UUID)
	Update(updates Updates) (Entity, error)
}

type Updates map[string]any // any — это алиас для interface{}

var ErrEntityNotFound = errors.New("entity not found")

type Repository[E Entity] struct {
	// Мьютекс нужен, чтобы репозиторий можно было использовать
	// из разных горутин. Тут больше подошёл бы RWMutex,
	// однако данная деталь не важна в текущем примере.
	//
	// Другой вариант: использовать вместо обычной мапы
	// sync.Map, и тогда мьютекс вовсе не нужен был бы.
	mu sync.Mutex

	// storage играет роль одновременно простого хранилища
	// и индекса по ID сущности.
	storage map[uuid.UUID]E
}

func New[E Entity]() *Repository[E] {
	return &Repository[E]{
		storage: make(map[uuid.UUID]E),
	}
}

func (d *Repository[E]) Create(e E) (uuid.UUID, error) {
	d.mu.Lock()
	defer d.mu.Unlock()

	id := uuid.New()
	e.SetID(id)

	d.storage[id] = e
	return id, nil
}

func (d *Repository[E]) Read(id uuid.UUID) (E, error) {
	d.mu.Lock()
	defer d.mu.Unlock()

	return d.read(id)
}

func (d *Repository[E]) Update(id uuid.UUID, updates Updates) (E, error) {
	d.mu.Lock()
	defer d.mu.Unlock()

	e, err := d.read(id)
	if err != nil {
		return e, err
	}

	// Предполагается, что каждая сущность содержит метод
	// Update, производящий валидацию и изменения, а репозиторий
	// лишь записывает обновлённую сущность в хранилище.
	updatedEntity, updErr := e.Update(updates)
	if updErr != nil {
		return e, fmt.Errorf("update entity: %w", updErr)
	}

	d.storage[id] = updatedEntity.(E)
	return updatedEntity.(E), nil
}

func (d *Repository[E]) Delete(id uuid.UUID) (E, error) {
	d.mu.Lock()
	defer d.mu.Unlock()

	e, err := d.read(id)
	if err != nil {
		return e, err
	}

	delete(d.storage, id)
	return e, nil
}

func (d *Repository[E]) read(id uuid.UUID) (E, error) {
	e, ok := d.storage[id]
	if !ok {
		return e, ErrEntityNotFound
	}
	return e, nil
}

При этом использование такого репозитория мало чем отличается от «традиционно» написанного:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package main

import "generics-example/repository"

func main() {
	userRepo := repository.New[*User]()

	userID, err := userRepo.Create(&User{Name: "Fedor"})
	if err != nil {
		log.Fatal(err)
	}

	user, err := userRepo.Read(userID)
	if err != nil {
		log.Printf("%v (%v)", err, userID)
	}
	log.Printf("created user: %v", user)

	wrongID := uuid.New()
	if _, err := userRepo.Read(wrongID); err != nil {
		log.Printf("get user: %v (%v)", err, wrongID)
	}

	if _, err := userRepo.Update(userID, repository.Updates{"name": "Ivan"}); err != nil {
		log.Println(err)
	}
	ivan, _ := userRepo.Read(userID)
	log.Printf("updated user: %v", ivan)

	if _, err := userRepo.Update(userID, repository.Updates{"name": 42, "age": 37}); err != nil {
		log.Println(err)
	}

	deleted, err := userRepo.Delete(userID)
	if err != nil {
		log.Println(err)
	}
	log.Printf("deleted user: %v", deleted)

	if _, err := userRepo.Delete(userID); err != nil {
		log.Printf("delete user: %v (%v)", err, userID)
	}
}

type User struct {
	id   uuid.UUID
	Name string
}

func (u *User) ID() uuid.UUID {
	return u.id
}

func (u *User) SetID(id uuid.UUID) {
	u.id = id
}

func (u *User) Update(updates repository.Updates) (repository.Entity, error) {
	for k, v := range updates {
		if k != "name" {
			continue
		}

		newName, ok := v.(string)
		if !ok {
			return nil, errors.New("name must be string")
		}
		u.Name = newName
	}

	return u, nil
}

func (u *User) String() string {
	return fmt.Sprintf("id=%s, name=%q", u.ID().String(), u.Name)
}

Магия

Поговорим немного о магии. Ведь код выше выглядит как настоящая магия! Кстати, в программистском сленге у данного слова есть вполне конкретное значение:

Магия — это код, благодаря которому сокращается рутина, но появляется больше неявного поведения.

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

Взгляните на написанный выше репозиторий. Что если нужно будет сделать join? А если нужно будет завернуть какую-то последовательность операций в транзакцию? Таких «нестандартных» сценариев настолько много, что их просто невозможно предусмотреть все. Да, можно написать обёртку, расширяющую возможность исходного репозитория, но со временем сложность и количество кода обёртки может вырасти настолько, что выяснится, что было бы лучше изначально написать репозиторий в лоб без всякой магии.

К чему я это всё?

Конечно, когда есть дженерики, соблазн написать репозиторий один раз и переиспользовать везде очень велик. Но, как говорится, «когда у тебя в руках молоток, всё вокруг кажется гвоздями». Дженерики, как и любую магию, стоит использовать только тогда, когда других, более простых вариантов не остаётся.

Как понять, когда использовать дженерики

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

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

Библиотека lo

С момента выхода Go 1.18 появился ряд библиотек, которые добавляют всякие маленькие полезные функции с дженериками в стиле функционального программирования. Одной из самых популярных является библиотека samber/lo. Чем же она может быть полезна? На самом деле юзкейсов, которые она покрывает очень много, я покажу только часть из них.

Фильтрация

Представьте, что вам небходимо из слайса пользователей получить другой слайс пользователей, но содержащий только пользователей с правами администратора. Без lo код будет примерно таким:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

func main() {
	users := []user{
		{username: "tomakado", isAdmin: true},
		{username: "johndoe", isAdmin: false},
		{username: "admin", isAdmin: true},
	}

	adminsOnly := make([]user, 0, len(users))
	for _, u := range users {
		if u.isAdmin {
			adminsOnly = append(adminsOnly, u)
		}
	}
	fmt.Println(adminsOnly)
}

type user struct {
	username string
	isAdmin  bool
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
	"fmt"

	"github.com/samber/lo"
)

func main() {
	users := []user{
		{username: "tomakado", isAdmin: true},
		{username: "johndoe", isAdmin: false},
		{username: "admin", isAdmin: true},
	}

	adminsOnly := lo.Filter(users, func(u user, i int) bool { return u.isAdmin })
	fmt.Println(adminsOnly)
}

type user struct {
	username string
	isAdmin  bool
}

Стало компактнее, не правда ли? Кроме этого, код из императивного превратился в декларативный. Декларативный подход заключается в том, что мы описываем что мы делаем, а не как. Представьте, что вы попросили официанта в ресторане бутерброд. Если вы просите его об этом императивно, вы опишете по шагам, что ему нужно делать: подойти к холодильнику, открыть холодильник, взять хлеб, взять сыр и т.д. Но официант скорее всего понимает, что нужно сделать, чтобы получился бутерброд, поэтому вы можете попросить его об это декларативно: «Я хочу бутерброд с хлебом и сыром». Причем официант вряд ли будет его делать сам, но для вас это уже всего лишь детали реализации.

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

1
adminsOnly := lo.Filter(users, isAdmin)

Маппинг

lo.Map возвращает слайс, состоящий из результатов применения функции к элементам исходного слайса:

1
2
3
4
5
var (
	ints        = []int{1, 2, 3}
	intsSquared = lo.Map(ints, func(i int, _ int) int { return i * i })
)
fmt.Println(intsSquared) // [1 4 9]

Перемешивание слайса

1
2
randomOrder := lo.Shuffle([]int{0, 1, 2, 3, 4, 5})
fmt.Println(randomOrder) // [1, 4, 0, 3, 5, 2]

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

Монады с библиотекой mo

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

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

Option

Option позволяет сделать значение опциональным без использования хака с поинтерами:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
some := mo.Some(42)
result := some.OrElse(1234)

fmt.Println(result) // => 42

// или

none := mo.None[int]()
result = none.OrElse(1234)

fmt.Println(result) // => 1234

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

Future

Future — это контейнер для значения, которое может быть недоступно прямо сейчас, но будет доступно когда-то в будущем, либо произойдет ошибка, если значение никогда не будет доступно. Иными словами, Future представляет собой удобную обёртку для выполнения асинхронных задач. Если, например, на «чистом» Go для ожидания единственного значения из горутины вам необходимо объявить канал, передать его в горутину, а затем правильно организовать чтение из канала, то с Future всё становится проще:

1
2
3
fut := NewFuture(func(resolve func(string), reject func(error)) {
	// какая-то долгая операция
})

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

1
value, err := fut.Collect()

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

Причём удобная обработка ошибок для Future также предусмотрена:

1
2
3
4
5
6
7
8
result := NewFuture(func(resolve func(string), reject func(error)) {
	reject(errors.New("failure"))
}).Catch(func(err error) (string, error) {
	return "foobar", nil
}).Result()

fmt.Println(result.OrEmpty()) // => foobar
fmt.Println(result.Error()) // => <nil>

Стоит заметить, что я не рекомендую использовать Future для работы с асинхронными вычислениями, а лишь показываю, что возможно делать с дженериками. Призываю вас всё-таки писать идиоматичный код.

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

***

  • Дженерики — мощный инструмент для написания универсального кода;
  • Они хорошо подходят для абстрактных и универсальных алгоритмов и структур данных, но могут принести проблемы при использовании на уровне предметной области (бизнеса);
  • Благодаря дженерикам у нас теперь есть классные инструменты из мира функционального программирования, которые, тем не менее, стоит использовать осторожно;
  • Если есть возможность не использовать дженерики — не используйте их.

Полезные ссылки

Telegram: https://t.me/deferpanic

Discord: https://discord.gg/4uw7Fpp2QX

Реквизиты, если у вас есть желание поддержать defer panic: