Введение
Долгие годы у Go был знаменитый пробел: нельзя было написать одну функцию,
работающую с разными типами, не жертвуя проверкой типов. Хочешь функцию
Max для int и для float64? Пиши две.
Хочешь стек, хранящий что угодно? Держи []interface{} и приводи
типы в рантайме, рискуя паникой. В Go 1.18 (2022) появились дженерики
(обобщённое программирование) — крупнейшее дополнение языка со времён выхода.
Теперь можно параметризовать функции и типы по типу, сохраняя проверку на этапе
компиляции. В этой главе мы разберём дженерики от синтаксиса до
generic-репозитория, а затем пройдёмся по нескольким жемчужинам современной
стандартной библиотеки, которые вышли примерно в то же время: встраивание файлов
//go:embed, структурное логирование log/slog и
итераторы range-over-func из Go 1.23.
Зачем дженерики: проблема any
До 1.18 «обобщённый» код писали через interface{} (с 1.18 у него
есть короткий псевдоним any). Минусы такого подхода серьёзные:
теряется информация о типе, поэтому нужны приведения в рантайме; приведение может
упасть паникой, и компилятор тут ничем не поможет; значения
«упаковываются» (boxing), что иногда бьёт по производительности. Сравни два
подхода к суммированию.
package main
import "fmt"
// Старый способ: any теряет тип, требует приведения и может паниковать.
func SumAny(s []any) int {
total := 0
for _, v := range s {
total += v.(int) // если не int — ПАНИКА в рантайме
}
return total
}
// Новый способ: тип известен компилятору, приведения не нужны.
func Sum[T int | float64](s []T) T {
var total T
for _, v := range s {
total += v
}
return total
}
func main() {
fmt.Println(SumAny([]any{1, 2, 3})) // 6, пока повезло с типами
// SumAny([]any{1, "два"}) // упадёт в рантайме
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
// Sum([]string{"a"}) // ОШИБКА КОМПИЛЯЦИИ, до запуска
}
Ключевая мысль: any — когда тип действительно произвольный и
неважен (хранение разнородных данных, fmt.Println). Дженерики —
когда одна и та же логика нужна для многих типов и важна типобезопасность на
этапе компиляции.
Параметры типа
Синтаксис параметров типа — в квадратных скобках после имени функции:
func Name[T constraint](arg T) T. Здесь T — параметр
типа (подставится конкретный тип при вызове), а constraint —
ограничение, задающее, какие типы допустимы. Самое общее ограничение —
any («любой тип»).
package main
import "fmt"
// PrintSlice печатает срез элементов ЛЮБОГО типа.
// До дженериков пришлось бы писать PrintIntSlice, PrintStringSlice и т.д.
func PrintSlice[T any](s []T) {
for i, v := range s {
fmt.Printf(" [%d] %v
", i, v)
}
}
// First возвращает первый элемент и флаг «срез не пуст».
func First[T any](s []T) (T, bool) {
if len(s) == 0 {
var zero T // нулевое значение для типа T
return zero, false
}
return s[0], true
}
func main() {
PrintSlice([]int{1, 2, 3}) // T выведен как int
PrintSlice([]string{"go", "rust"}) // T выведен как string
if v, ok := First([]int{10, 20}); ok {
fmt.Println("первый:", v)
}
// Тип можно указать явно, хотя обычно он выводится сам.
PrintSlice[float64]([]float64{1.5, 2.5})
}
Обрати внимание на var zero T — это идиома «нулевое значение
параметра типа». Для int это 0, для
string — пустая строка, для указателя — nil. Когда
заранее не знаешь тип, только так и можно получить «пустое» значение.
Ограничения: comparable, cmp.Ordered и union-типы
Ограничение — это интерфейс. Их три вида. any — любой тип.
comparable — встроенное ограничение для типов, поддерживающих
== и != (нужно, например, для ключей map). И свой
интерфейс с множеством типов, перечисленных через | — это
новая возможность Go 1.18. Для упорядочиваемых типов в стандартной библиотеке с
Go 1.21 есть готовое ограничение cmp.Ordered.
package main
import (
"cmp"
"fmt"
)
// comparable: можно сравнивать значения через ==.
func Index[T comparable](s []T, target T) int {
for i, v := range s {
if v == target {
return i
}
}
return -1
}
// cmp.Ordered: числа и строки, поддерживают < > <= >=.
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
// Своё ограничение: только числовые типы. Тильда ~ означает «а также любой
// тип, чей базовый тип — этот» (например, type Celsius float64).
type Number interface {
~int | ~int64 | ~float64
}
func SumNums[T Number](s []T) T {
var total T
for _, v := range s {
total += v
}
return total
}
func main() {
fmt.Println(Index([]string{"a", "b", "c"}, "b")) // 1
fmt.Println(Max(3, 7)) // 7
fmt.Println(Max("apple", "banana")) // banana
fmt.Println(SumNums([]float64{1.1, 2.2, 3.3})) // 6.6
}
Тильда ~ — тонкий, но важный момент. Без неё
Number принимал бы ровно int/float64, но
не твой пользовательский type Celsius float64. С тильдой
ограничение распространяется на все типы с таким базовым типом.
Map, Filter, Reduce
Это то, ради чего дженерики просили годами. Один раз пишем функцию для среза
любого типа — и не плодим копии. Обрати внимание: Map и
Reduce используют два параметра типа (T и
U), потому что тип входа и тип результата могут различаться.
package main
import (
"fmt"
"strings"
)
// Map применяет f к каждому элементу и возвращает новый срез []U.
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
// Filter оставляет только элементы, для которых keep вернул true.
func Filter[T any](s []T, keep func(T) bool) []T {
var result []T
for _, v := range s {
if keep(v) {
result = append(result, v)
}
}
return result
}
// Reduce сворачивает срез в одно значение, накапливая результат в acc.
func Reduce[T, U any](s []T, init U, f func(acc U, cur T) U) U {
acc := init
for _, v := range s {
acc = f(acc, v)
}
return acc
}
func main() {
nums := []int{1, 2, 3, 4, 5}
labels := Map(nums, func(n int) string { return fmt.Sprintf("#%d", n) })
fmt.Println("Map:", labels)
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println("Filter:", evens)
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Println("Reduce:", sum)
// Map работает и со строками — прямо с функцией из stdlib.
fmt.Println(Map([]string{"go", "fun"}, strings.ToUpper))
}
Обобщённые типы: Stack[T]
Параметры типа могут быть не только у функций, но и у структур. Классический
пример — типобезопасный стек. До дженериков пришлось бы хранить
[]any (и приводить типы в рантайме) либо копировать код под каждый
тип. Синтаксис: type Stack[T any] struct{...}, а методы повторяют
[T] в получателе.
package main
import "fmt"
// Stack — стек элементов типа T (LIFO).
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
func main() {
var ints Stack[int]
ints.Push(10)
ints.Push(20)
fmt.Println("размер:", ints.Len())
v, _ := ints.Pop()
fmt.Println("pop:", v) // 20
// Тот же тип для строк — без единой строчки нового кода.
var strs Stack[string]
strs.Push("a")
s, _ := strs.Pop()
fmt.Println("строковый стек:", s)
}
Важное ограничение: у методов нельзя вводить собственные
новые параметры типа — они работают только с параметрами типа-получателя. То
есть Push и Pop оперируют тем самым T, что
задан у Stack.
Вывод типов: почему [T] обычно не пишут
Ты, наверное, заметил, что вызовы дженериков выше выглядят как обычные вызовы
функций — без квадратных скобок. Это благодаря выводу типов:
компилятор сам определяет параметры типа из типов аргументов. Явно указывать
[T] нужно только тогда, когда выводить не из чего.
package main
import "fmt"
func Map[T, U any](s []T, f func(T) U) []U {
r := make([]U, len(s))
for i, v := range s {
r[i] = f(v)
}
return r
}
// У Zero нет аргументов типа T — выводить не из чего.
func Zero[T any]() T {
var z T
return z
}
func main() {
nums := []int{1, 2, 3}
// 1) Полный автоматический вывод: T=int, U=int.
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled)
// 2) U выводится из типа возврата f (здесь string).
strs := Map(nums, func(n int) string { return fmt.Sprint(n) })
fmt.Println(strs)
// 3) Явное указание ОБЯЗАТЕЛЬНО: у Zero аргументов нет.
fmt.Printf("%v %q %v
", Zero[int](), Zero[string](), Zero[bool]())
}
//go:embed — вшить файлы в бинарник (Go 1.16)
Теперь переходим к стандартной библиотеке. Директива //go:embed
позволяет встроить содержимое файлов прямо в исполняемый файл во время
компиляции. Больше не нужно таскать рядом с бинарником папку с шаблонами,
конфигами или статикой — всё внутри. Три обязательных условия: импорт пакета
embed; директива-комментарий //go:embed путь без
пробела после //; директива стоит прямо над переменной уровня
пакета.
package main
import (
_ "embed" // нужен для работы директивы; сам пакет тут не вызывается
"fmt"
)
// Содержимое version.txt подставится в переменную при компиляции.
//go:embed version.txt
var version string
func main() {
fmt.Print("Версия приложения: ", version)
}
Для нескольких файлов или целых каталогов используют тип
embed.FS — виртуальную файловую систему, которая реализует интерфейс
fs.FS. Поэтому её принимают html/template,
http.FileServer, fs.WalkDir и всё, что работает с
fs.FS.
package main
import (
"embed"
"fmt"
"text/template"
"os"
)
//go:embed templates
var assets embed.FS
func main() {
// Прочитать один вшитый файл.
data, _ := assets.ReadFile("templates/hello.txt")
fmt.Print(string(data))
// Распарсить вшитый шаблон прямо из embed.FS.
tmpl, _ := template.ParseFS(assets, "templates/greeting.tmpl")
person := struct {
Name string
Age int
}{"Анна", 30}
_ = tmpl.ExecuteTemplate(os.Stdout, "greeting.tmpl", person)
}
log/slog — структурное логирование (Go 1.21)
Пакет log писал плоскую строку. Пакет log/slog пишет
сообщение плюс пары «ключ-значение» — это и есть «структурированное»
логирование. Такие записи легко фильтровать, парсить и отправлять в системы сбора
логов. Есть четыре уровня по возрастанию важности: Debug,
Info, Warn, Error.
package main
import "log/slog"
func main() {
// Просто сообщение.
slog.Info("приложение запущено")
// Сообщение + атрибуты (пары ключ-значение).
slog.Info("пользователь вошёл",
"user_id", 42,
"username", "alice",
)
slog.Warn("заканчивается место на диске", "free_mb", 120)
slog.Error("не удалось подключиться к БД", "attempt", 3)
// time=... level=INFO msg="пользователь вошёл" user_id=42 username=alice
}
За формат вывода отвечает Handler. В стандартной библиотеке их два:
TextHandler (человекочитаемые строки key=value) и
JSONHandler (по одному JSON-объекту на запись — для машинной
обработки). Логгер создают через slog.New(handler), а
slog.SetDefault делает его логгером по умолчанию.
package main
import (
"log/slog"
"os"
)
func main() {
// Текстовый обработчик.
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
textLogger.Info("заказ создан", "order_id", 1001, "total", 49.90)
// JSON-обработчик — для продакшена и систем сбора логов.
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
jsonLogger.Info("заказ создан", "order_id", 1001)
// {"time":"...","level":"INFO","msg":"заказ создан","order_id":1001}
// Сделать JSON-логгер логгером по умолчанию.
slog.SetDefault(jsonLogger)
slog.Warn("низкий остаток", "sku", "ABC-123", "left", 2)
}
На практике часто нужны две вещи: понизить порог до Debug,
типизированные атрибуты (быстрее и безопаснее, чем пары через any) и
дочерние логгеры With, которые автоматически добавляют общие поля к
каждой записи.
package main
import (
"log/slog"
"os"
)
func main() {
// Понизить порог до Debug (по умолчанию виден только Info и выше).
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
logger.Debug("теперь Debug виден", "cache_size", 256)
// Типизированные атрибуты — нельзя перепутать ключ и значение.
logger.Info("запрос обработан",
slog.Int("status", 200),
slog.String("method", "GET"),
)
// With: общие поля для ВСЕХ записей дочернего логгера.
reqLogger := logger.With("request_id", "req-7f3a", "user_id", 42)
reqLogger.Info("начало обработки") // + request_id, user_id
reqLogger.Info("запрос к БД", "table", "orders") // те же поля + table
}
Итераторы: range-over-func (Go 1.23)
В Go 1.23 появилась возможность писать собственные итераторы, по которым
работает for range. Чтобы функция стала итератором, она возвращает
функцию сигнатуры func(yield func(V) bool). Для каждого элемента
итератор вызывает yield(элемент); если yield вернул
false — значит потребитель сделал break, и надо
немедленно остановиться. Тип iter.Seq[V] — это и есть
func(yield func(V) bool).
package main
import (
"fmt"
"iter"
)
// Count возвращает итератор по числам 0, 1, ..., n-1.
func Count(n int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
if !yield(i) { // потребитель прервал цикл — выходим
return
}
}
}
}
func main() {
// Свой итератор используется как обычный range.
for i := range Count(5) {
fmt.Print(i, " ") // 0 1 2 3 4
}
fmt.Println()
// break заставляет yield вернуть false → итератор останавливается.
for i := range Count(100) {
if i == 3 {
break
}
fmt.Print(i, " ") // 0 1 2
}
fmt.Println()
}
Главная сила итераторов — композиция: функция принимает
iter.Seq и возвращает новый iter.Seq, не строя
промежуточных срезов. Так из мелких «кирпичиков» собираются ленивые конвейеры.
Для пар «ключ-значение» есть iter.Seq2[K, V].
package main
import (
"fmt"
"iter"
)
func Range(n int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
if !yield(i) {
return
}
}
}
}
// Filter принимает Seq и возвращает Seq — вот она, композиция.
func Filter[V any](seq iter.Seq[V], keep func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if keep(v) && !yield(v) {
return
}
}
}
}
func MapSeq[A, B any](seq iter.Seq[A], f func(A) B) iter.Seq[B] {
return func(yield func(B) bool) {
for v := range seq {
if !yield(f(v)) {
return
}
}
}
}
func main() {
// Конвейер: 0..9 → чётные → квадрат. Данные текут лениво, по одному.
evens := Filter(Range(10), func(n int) bool { return n%2 == 0 })
squares := MapSeq(evens, func(n int) int { return n * n })
for v := range squares {
fmt.Print(v, " ") // 0 4 16 36 64
}
fmt.Println()
}
Кейс из реального проекта
Дженерики особенно хороши для инфраструктурного кода, где логика одинакова для
многих типов. Классический пример — обобщённый репозиторий для хранения
сущностей в памяти. Раньше пришлось бы копировать UserRepo,
OrderRepo, ProductRepo с одинаковым телом. С дженериками
пишем один Repository[T].
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("сущность не найдена")
// Entity — ограничение: у любой сущности должен быть числовой ID.
type Entity interface {
GetID() int
}
// Repository[T] хранит сущности типа T в памяти по их ID.
type Repository[T Entity] struct {
items map[int]T
}
func NewRepository[T Entity]() *Repository[T] {
return &Repository[T]{items: make(map[int]T)}
}
func (r *Repository[T]) Save(e T) {
r.items[e.GetID()] = e
}
func (r *Repository[T]) Get(id int) (T, error) {
e, ok := r.items[id]
if !ok {
var zero T
return zero, fmt.Errorf("Get id=%d: %w", id, ErrNotFound)
}
return e, nil
}
// --- Конкретные сущности ---
type User struct {
ID int
Name string
}
func (u User) GetID() int { return u.ID }
func main() {
// Один и тот же Repository[T] — для любой сущности с методом GetID.
users := NewRepository[User]()
users.Save(User{ID: 1, Name: "Аня"})
u, err := users.Get(1)
fmt.Println(u.Name, err) // Аня <nil>
_, err = users.Get(99)
fmt.Println(errors.Is(err, ErrNotFound)) // true
}
Обрати внимание, как здесь встречаются темы двух глав: репозиторий обобщён по
типу T, а ошибка «не найдено» оборачивается через %w,
чтобы вызывающий мог проверить её через errors.Is. Это и есть
современный идиоматичный Go.
Типичные ошибки
Дженерики там, где хватило бы интерфейса
Дженерики — мощный инструмент, но не серебряная пуля. Если тебе нужно поведение (метод), а не конкретный тип данных, обычный интерфейс проще и понятнее. Правило Go: «пиши код, а не типы». Начинай с конкретной реализации, а обобщай только когда реально появилось два-три почти одинаковых куска кода.
Метод с собственным параметром типа
Попытка объявить func (s *Stack[T]) Map[U any](...) не
скомпилируется: методы не могут вводить новые параметры типа помимо тех, что у
типа-получателя. Если нужна такая гибкость — делай обычную обобщённую функцию, а
не метод.
Забыть про yield-контракт в итераторах
Если в собственном итераторе проигнорировать возвращаемое значение
yield (не выйти при false), то break в
цикле потребителя перестанет работать корректно, а итератор продолжит
«прокачивать» значения впустую. Всегда проверяй if !yield(v) { return }.
Путать ключ и значение в slog
При записи через пары «ключ, значение» легко случайно сдвинуть аргументы —
компилятор это не поймает, ведь оба имеют тип any. В горячих местах
предпочитай типизированные атрибуты slog.Int,
slog.String и метод LogAttrs — там перепутать нельзя.
Практика
- Напиши обобщённую функцию
Keys[K comparable, V any](m map[K]V) []K, возвращающую срез ключей карты. Проверь её наmap[string]intиmap[int]bool. - Реализуй обобщённую функцию
Contains[T comparable](s []T, target T) boolи функциюMinMax[T cmp.Ordered](s []T) (T, T). - Создай обобщённую очередь
Queue[T any](FIFO) с методамиEnqueue,DequeueиLen. - Встрой через
//go:embedфайлconfig.jsonв переменную и распарси его стандартнымencoding/json. - Настрой
slogс JSON-обработчиком и дочерним логгером черезWith("service", "billing"); залогируй событие уровняErrorс атрибутами. - Напиши итератор
Fib(n int) iter.Seq[int], выдающий первыеnчисел Фибоначчи, и пройдись по нему черезfor range.
Итог
Дженерики закрыли давний пробел Go: теперь одна функция или тип работают с
разными типами без потери типобезопасности. Ты освоил параметры типа, три вида
ограничений (any, comparable, union-типы и
cmp.Ordered), тильду ~, обобщённые Map/
Filter/Reduce, generic-типы вроде Stack[T]
и вывод типов, благодаря которому вызовы остаются лаконичными. Во второй половине
главы мы прошлись по современной стандартной библиотеке: встраивание файлов
//go:embed, структурное логирование log/slog с
текстовым и JSON-обработчиками и итераторы range-over-func с ленивой
композицией конвейеров. Всё это — инструменты, которые делают современный
Go-код одновременно выразительным и надёжным.