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 — имя типа, которое станет известно в месте вызова. Вызвали с []int — T стал 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 цикл — не позор.
Хороший признак: вы уже написали вторую почти одинаковую функцию и копируете третью. Вот теперь.
В стандартной библиотеке они уже есть
Половину того, что хочется написать, писать не нужно:
slices—slices.Sort,slices.Contains,slices.Index,slices.Max;maps—maps.Keys,maps.Values. Одна тонкость: они возвращают не срез, а последовательность, поэтому срез из них получают так:keys := slices.Collect(maps.Keys(m)). НашKeysиз примера — учебная копия, возвращающая сразу срез;cmp— сравнение иcmp.Or;sync.OnceValue— посчитать один раз и вернуть готовое.
Куда идти дальше
Курс закончился, язык — нет. Семь направлений, в каждом одна строка про то, зачем оно:
slices,maps,cmp— то, что вы теперь умеете читать: они целиком на дженериках.- Postgres вместо SQLite — тот же
database/sql, другой драйвер и настоящий пул соединений. SQLite в блоге был выбором, а не законом. errgroupиsync.WaitGroup— когда горутин не две, а сорок, и все должны закончиться.net/http/pprof— узнать, где программа проводит время, вместо того чтобы гадать.go test -fuzz— тесты, которые сами придумывают вход и находят то, что вы не додумали.- Метрики и трассировка —
expvar, Prometheus, OpenTelemetry: сколько запросов, какие медленные, где ждали. - Сборка по кнопке — GitHub Actions: тесты на каждый коммит и деплой одной командой.
Читать стоит в таком порядке: go.dev/doc/effective_go, потом Go by Example, потом чужой код — начиная со стандартной библиотеки, она читаемая.
Что построить в блоге
Блог из курса — не законченная вещь, а основание. Что просится дальше, по возрастанию сложности:
- картинка для соцсетей —
og:image, чтобы ссылка выглядела ссылкой, а не строкой; - черновики — статья не сразу видна всем; одно поле и одна проверка в запросе;
- комментарии — форма, таблица, модерация и снова CSRF;
- несколько авторов — права у нас уже есть, не хватает страницы автора;
- письма — подтверждение почты и восстановление пароля.
Карта урока
Что вы теперь умеете
Пятьдесят уроков назад мы написали программу из четырёх строк. Сейчас за вами блог, который: хранит статьи в базе с миграциями, ищет по ним, пускает людей по паролю и держит сессии, различает права, принимает картинки, отдаёт JSON, покрыт тестами с гонками, работает по HTTPS на своём домене, останавливается аккуратно и умеет делать резервную копию.
Тридцать четыре шага в репозитории — это тот же блог на каждом уроке, и любой можно запустить одной командой. Возвращайтесь к ним, когда что-то забудется: код в них тот же, что в тексте.
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Зачем дженерику условие на тип, если тип и так подставится при вызове?
- Почему
anyс разбором типа медленнее, чем параметр типа? - Когда дженерик не нужен, хотя и возможен?
Задание
Обязательное. Найдите в своём блоге две функции с почти одинаковым телом и сведите их в одну с параметром типа. Если таких нет — напишите Filter[T any](xs []T, keep func(T) bool) []T и примените его к списку статей.
По желанию.
- Замерьте свою версию против варианта с
any:go test -bench . -benchmem. - Перепишите счётчик просмотров из урока про горутины так, чтобы он считал что угодно, — с мьютексом внутри.
- Замените свои маленькие помощники на
slicesиmapsи посмотрите, сколько строк ушло.
Куда это встанет в блоге
Никуда — и это правильный конец. Обещанное ядро блога готово: он работает, выложен и его можно показать. Дженерики в нём не нужны, и добавлять их ради урока было бы нечестно.
Список улучшений при этом есть — он есть у любой работающей системы. Из названного в уроках: защита входа от перебора, чистка истёкших сессий и кнопка «выйти везде», постраничный список статей, удаление картинок вместе со статьёй, казахская морфология в поиске, готовность базы в проверке. Это не долги курса, а обычная эксплуатационная очередь.
Дальше блог меняете вы, а не курс.
Ответы
Показать ответы
- Затем, что без условия компилятор не знает, что можно делать с
T: сложение, сравнение, вызов метода. Условие — это и разрешение для тела функции, и проверка для того, кто её вызывает. - Потому что в версии с
anyтип разбирается на каждом элементе, во время работы. Дженерик знает тип на этапе сборки — измерено: 534 наносекунды против 1801. - Когда функция нужна для одного типа; когда типам нужно разное поведение — там интерфейс; и когда читаемость от параметра типа страдает больше, чем выигрывает повторное использование.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.