Глава 11. Дженерики и современная стандартная библиотека

84 просмотров
0 лайков
0 в избранном

Введение

Долгие годы у 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 — там перепутать нельзя.

Практика

  1. Напиши обобщённую функцию Keys[K comparable, V any](m map[K]V) []K, возвращающую срез ключей карты. Проверь её на map[string]int и map[int]bool.
  2. Реализуй обобщённую функцию Contains[T comparable](s []T, target T) bool и функцию MinMax[T cmp.Ordered](s []T) (T, T).
  3. Создай обобщённую очередь Queue[T any] (FIFO) с методами Enqueue, Dequeue и Len.
  4. Встрой через //go:embed файл config.json в переменную и распарси его стандартным encoding/json.
  5. Настрой slog с JSON-обработчиком и дочерним логгером через With("service", "billing"); залогируй событие уровня Error с атрибутами.
  6. Напиши итератор 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-код одновременно выразительным и надёжным.

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

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!