Shanraq.org Shanraq.org
Куда дальше: дженерики и дорога после курса
IT

Go: с нуля до своего блога Урок 50 из 50

Куда дальше: дженерики и дорога после курса

Пятидесятый, последний урок курса по Go. Дженерики — одна функция вместо трёх, и условие на тип проверяет компилятор: измерено, что `string` не проходит до запуска. Измерено и чем они лучше `any`: 534 наносекунды против 1801. А ещё — куда идти дальше и что теперь умеет ваш блог.

Зачем это нужно

Курс кончается. Блог работает, лежит на своём домене под HTTPS, останавливается аккуратно и переживает перезагрузку. Осталось два разговора: про то, чего мы не проходили, и про то, куда идти дальше.

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

Сразу целиком

Новая папка, go mod init sabaq47:

package main

import (
	"fmt"
	"sort"
)

// Number — не тип, а условие: сюда годится всё, что складывается плюсом и
// ведёт себя как число. Тильда значит «и свои типы, у которых внутри это же».
type Number interface {
	~int | ~int64 | ~float64
}

// Sum складывает срез любых чисел. До дженериков таких функций писали три:
// для int, для int64 и для float64 — с одинаковым телом.
func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

// Keys возвращает ключи словаря. K comparable — потому что ключом словаря
// может быть только то, что сравнивается; V any — значение нам безразлично.
func Keys[K comparable, V any](m map[K]V) []K {
	out := make([]K, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

// Views — наш счётчик просмотров из урока про горутины, только теперь он не
// знает, чем считает: статьями, тегами или читателями.
type Views[K comparable] struct {
	counts map[K]int
}

func NewViews[K comparable]() *Views[K] {
	return &Views[K]{counts: make(map[K]int)}
}

func (v *Views[K]) Add(key K)       { v.counts[key]++ }
func (v *Views[K]) Count(key K) int { return v.counts[key] }

// Оценка в баллах — свой тип, но внутри int. Тильда в Number разрешает и его.
type Score int

func main() {
	fmt.Println("== одна функция вместо трёх")
	fmt.Println("целые:      ", Sum([]int{2, 3, 4}))
	fmt.Println("дробные:    ", Sum([]float64{0.5, 0.25}))
	fmt.Println("свой тип:   ", Sum([]Score{10, 20}))

	fmt.Println()
	fmt.Println("== ключи любого словаря")
	ages := map[string]int{"Пушкин": 37, "Толстой": 82}
	names := Keys(ages)
	sort.Strings(names)
	fmt.Println("строки:     ", names)
	years := Keys(map[int]string{1799: "Пушкин", 1828: "Толстой"})
	sort.Ints(years)
	fmt.Println("числа:      ", years)

	fmt.Println()
	fmt.Println("== счётчик, которому всё равно, что считать")
	byArticle := NewViews[string]()
	byArticle.Add("pervaya-zapis")
	byArticle.Add("pervaya-zapis")
	fmt.Println("по статьям: ", byArticle.Count("pervaya-zapis"))

	byYear := NewViews[int]()
	byYear.Add(2026)
	fmt.Println("по годам:   ", byYear.Count(2026))
}

Вывод:

== одна функция вместо трёх
целые:       9
дробные:     0.75
свой тип:    30

== ключи любого словаря
строки:      [Пушкин Толстой]
числа:       [1799 1828]

== счётчик, которому всё равно, что считать
по статьям:  2
по годам:    1

Разбор

Параметр типа — это ещё один аргумент, только тип

Обычная функция принимает значения. Дженерик принимает ещё и тип — в квадратных скобках перед обычными скобками:

func Sum[T Number](xs []T) T

T — имя типа, которое станет известно в месте вызова. Вызвали с []intT стал int; вызвали с []float64 — стал float64. Компилятор подставляет тип сам, поэтому в примере нигде не написано Sum[int](...): он виден из аргумента.

Без дженериков это три функции с одинаковым телом: SumInt, SumInt64, SumFloat. Первая ошибка в теле — три исправления, и одно из них однажды забудут.

Условие — главное в дженерике

Number — не тип и не интерфейс в старом смысле. Это условие: список того, чем T разрешено быть.

type Number interface {
	~int | ~int64 | ~float64
}

Тильда значит «и любой свой тип, у которого внутри это же». Поэтому в примере складывается и Score, объявленный как type Score int.

Условие — не украшение. Без него тело функции не собралось бы: компилятор не знает, что к T можно применить +. И он же проверяет вызов:

$ go build ./...
./main.go:20:17: string does not satisfy Number (string missing in ~int | ~int64 | ~float64)

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

Чем это лучше, чем any

До дженериков разнородное складывали через any и разбор типа на месте. Работает, но за это платят. Измерено на срезе в тысячу чисел:

BenchmarkSumGeneric-8    2238902     533.7 ns/op     0 B/op   0 allocs/op
BenchmarkSumAny-8         663471    1801   ns/op     0 B/op   0 allocs/op

534 наносекунды против 1801 — в три с половиной раза. Разница не в памяти (аллокаций нет в обоих случаях), а в работе: в версии с any на каждом элементе выполняется разбор типа, а дженерик знает тип заранее и складывает числа напрямую.

Мерить такое стоит самому: go test -bench . -benchmem — та же команда, что в уроке про тесты, только про скорость.

Когда дженерики не нужны

Соблазн новичка — сделать дженериком всё. Три правила, которые от этого удерживают:

  • Одна функция для одного типа — не повод. func words(s string) int останется собой; параметр типа тут добавит скобок и ничего больше.
  • Интерфейс часто лучше. Если разным типам нужно разное поведение — это интерфейс, как blog.Store в уроке про интерфейсы. Дженерик — про одинаковое поведение над разными типами данных.
  • Читаемость дороже. func Map[T, U any](xs []T, f func(T) U) []U — красиво, но три вложенных вызова такого читаются хуже обычного цикла. В Go цикл — не позор.

Хороший признак: вы уже написали вторую почти одинаковую функцию и копируете третью. Вот теперь.

В стандартной библиотеке они уже есть

Половину того, что хочется написать, писать не нужно:

  • slicesslices.Sort, slices.Contains, slices.Index, slices.Max;
  • mapsmaps.Keys, maps.Values. Одна тонкость: они возвращают не срез, а последовательность, поэтому срез из них получают так: keys := slices.Collect(maps.Keys(m)). Наш Keys из примера — учебная копия, возвращающая сразу срез;
  • cmp — сравнение и cmp.Or;
  • sync.OnceValue — посчитать один раз и вернуть готовое.

Куда идти дальше

Курс закончился, язык — нет. Семь направлений, в каждом одна строка про то, зачем оно:

  1. slices, maps, cmp — то, что вы теперь умеете читать: они целиком на дженериках.
  2. Postgres вместо SQLite — тот же database/sql, другой драйвер и настоящий пул соединений. SQLite в блоге был выбором, а не законом.
  3. errgroup и sync.WaitGroup — когда горутин не две, а сорок, и все должны закончиться.
  4. net/http/pprof — узнать, где программа проводит время, вместо того чтобы гадать.
  5. go test -fuzz — тесты, которые сами придумывают вход и находят то, что вы не додумали.
  6. Метрики и трассировкаexpvar, Prometheus, OpenTelemetry: сколько запросов, какие медленные, где ждали.
  7. Сборка по кнопке — GitHub Actions: тесты на каждый коммит и деплой одной командой.

Читать стоит в таком порядке: go.dev/doc/effective_go, потом Go by Example, потом чужой код — начиная со стандартной библиотеки, она читаемая.

Что построить в блоге

Блог из курса — не законченная вещь, а основание. Что просится дальше, по возрастанию сложности:

  • картинка для соцсетейog:image, чтобы ссылка выглядела ссылкой, а не строкой;
  • черновики — статья не сразу видна всем; одно поле и одна проверка в запросе;
  • комментарии — форма, таблица, модерация и снова CSRF;
  • несколько авторов — права у нас уже есть, не хватает страницы автора;
  • письма — подтверждение почты и восстановление пароля.

Карта урока

Карта урока: параметр типа, условие и дорога дальше

Что вы теперь умеете

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

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

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Зачем дженерику условие на тип, если тип и так подставится при вызове?
  2. Почему any с разбором типа медленнее, чем параметр типа?
  3. Когда дженерик не нужен, хотя и возможен?

Задание

Обязательное. Найдите в своём блоге две функции с почти одинаковым телом и сведите их в одну с параметром типа. Если таких нет — напишите Filter[T any](xs []T, keep func(T) bool) []T и примените его к списку статей.

По желанию.

  • Замерьте свою версию против варианта с any: go test -bench . -benchmem.
  • Перепишите счётчик просмотров из урока про горутины так, чтобы он считал что угодно, — с мьютексом внутри.
  • Замените свои маленькие помощники на slices и maps и посмотрите, сколько строк ушло.

Куда это встанет в блоге

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

Список улучшений при этом есть — он есть у любой работающей системы. Из названного в уроках: защита входа от перебора, чистка истёкших сессий и кнопка «выйти везде», постраничный список статей, удаление картинок вместе со статьёй, казахская морфология в поиске, готовность базы в проверке. Это не долги курса, а обычная эксплуатационная очередь.

Дальше блог меняете вы, а не курс.

Ответы

Показать ответы
  1. Затем, что без условия компилятор не знает, что можно делать с T: сложение, сравнение, вызов метода. Условие — это и разрешение для тела функции, и проверка для того, кто её вызывает.
  2. Потому что в версии с any тип разбирается на каждом элементе, во время работы. Дженерик знает тип на этапе сборки — измерено: 534 наносекунды против 1801.
  3. Когда функция нужна для одного типа; когда типам нужно разное поведение — там интерфейс; и когда читаемость от параметра типа страдает больше, чем выигрывает повторное использование.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.