3. errors. Создание, оборачивание и сравнение ошибок
«Ошибки — это обычные значения. Программы работают со значениями, и решения принимаются именно на их основе».
— Роб Пайк
Философия обработки ошибок в Go
В отличие от языков с исключениями (Java, C#, Python, PHP), где нормальный контроль потока выполнения может быть внезапно разорван скрытым выбросом throw в любой точке стека вызовов, Go использует фундаментально иную, предельно честную модель: ошибки — это обычные значения. Они возвращаются функциями как штатный результат работы, передаются как аргументы и проверяются явным if err != nil.
Это не просто эстетическое предпочтение создателей языка, а глубокий архитектурный выбор:
- Предсказуемость: Каждый потенциальный сбой обязан быть обработан прямо на уровне вызывающего кода. Нет скрытых прыжков через 20 уровней стека в неизвестный блок
catch. - Mechanical Sympathy: Компилятор и процессор могут агрессивно оптимизировать машинный код. Отсутствие механизма раскрутки стека (Stack Unwinding) и громоздких таблиц исключений (DWARF Exception Tables) делает счастливым предсказатель ветвлений процессора (Branch Predictor).
- Контроль: Разработчик полностью контролирует контекст ошибки, трассировку и стратегию восстановления на каждом этапе.
Однако написание надежного кода требует четкого понимания внутренней механики интерфейсов рантайма, цены аллокаций памяти и строгих правил работы с цепочками ошибок.
1. Интерфейс error и его устройство в памяти
В оперативной памяти интерфейс error физически представлен структурой iface (размером 16 байт на 64-битной архитектуре):
type iface struct {
tab *itab // Указатель на таблицу методов и тип
data unsafe.Pointer // Указатель на конкретные данные в памяти
}
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph iface["Интерфейс error (16 байт)"]
Tab["tab *itab<br>(Тип + метод Error())"]
Data["data unsafe.Pointer<br>(Данные ошибки)"]
end
Tab --> ItabObj["Структура itab<br>(Метаданные типа)"]
Data --> ConcreteVal["Экземпляр ошибки в куче<br>(errorString или кастомная структура)"]
Когда функция завершается успешно и не возвращает ошибку, она возвращает nil. Для интерфейса это означает, что оба указателя (tab и data) равны nil.
Именно здесь кроется самая коварная и знаменитая ловушка языка Go, на которой регулярно спотыкаются даже опытные инженеры.
Проблема: nil != nil
Рассмотрим классический баг, ломающий production-системы:
type MyError struct {
Code int
}
func (e *MyError) Error() string {
return fmt.Sprintf("error code: %d", e.Code)
}
func doWork() error {
var p *MyError = nil // Типизированный указатель со значением nil
// ...какая-то логика без ошибок...
return p // ВНИМАНИЕ: возвращаем *MyError в сигнатуру error!
}
func main() {
err := doWork()
if err != nil {
fmt.Println("Ошибка есть!", err) // ЭТОТ КОД СРАБОТАЕТ!
}
}
Почему это происходит?
Интерфейс в Go считается равным nil только тогда, когда и tab, и data равны nil.
Когда мы вернули переменную p, рантайм сконструировал iface, где:
tabуказывает на валидную таблицу методов типа*MyError(tab != nil).dataравнаnil.
Поскольку поле tab заполнено, выражение err != nil возвращает true! Программа считает, что произошла ошибка, хотя внутри лежит пустой указатель. Попытка вызвать err.Error() может привести к панике разыменования нулевого указателя (nil pointer dereference).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph NonNilIface["Ловушка nil != nil"]
T["tab: *itab (*MyError) != nil"]
D["data: nil"]:::process
end
NonNilIface --> Check{"if err != nil"}
Check -->|"Результат: TRUE!"| Panic["Ложное срабатывание ошибки!"]
2. Три стратегии работы с ошибками
В зависимости от архитектурных требований к обработке сбоев, в Go выделяют три основные стратегии:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
E["Стратегии ошибок"]:::process --> S1["1. Sentinel Errors<br>(Глобальные маркеры: io.EOF, sql.ErrNoRows)"]
E --> S2["2. Custom Types<br>(Структуры с контекстом: ValidationError)"]
E --> S3["3. Opaque Errors<br>(Черный ящик: просто error, без деталей)"]
Стратегия 1. Sentinel Errors (Маркерные ошибки)
Глобальные переменные-синглтоны, представляющие конкретное, ожидаемое состояние системы:
var ErrNotFound = errors.New("пользователь не найден")
// Использование
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound, nil
}
- Плюсы: Предельная простота, мгновенное сравнение по адресу памяти (pointer comparison), ноль дополнительных аллокаций.
- Минусы: Создают жесткую связь между пакетами (coupling), не позволяют передать динамический контекст (например, какой именно
IDне был найден).
Стратегия 2. Custom Error Types (Пользовательские типы)
Структуры, реализующие интерфейс error и несущие в себе богатый набор метаданных:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message)
}
// Использование
var valErr *ValidationError
if errors.As(err, &valErr) {
log.Printf("Invalid field: %s", valErr.Field)
}
- Плюсы: Передача структурированного контекста (поля валидации, HTTP-статусы, таймстемпы), типобезопасное извлечение через
errors.As. - Минусы: Требуют больше кода, выставляют внутренние типы пакета наружу.
Стратегия 3. Opaque Errors (Непрозрачные ошибки)
Функция возвращает просто error. Вызывающий код не интересуется деталями: он проверяет только err != nil и реагирует одинаково (логирует ошибку и отдает клиенту HTTP 500).
- Плюсы: Идеальная инкапсуляция, нулевая связанность модулей.
- Минусы: Невозможно дифференцировать обработку сбоев на уровне API.
3. Цепочки ошибок и механизмы Go 1.13+
Исторически для оборачивания ошибок с сохранением контекста сообщество Go использовало внешнюю библиотеку github.com/pkg/errors Дейва Чейни. Начиная с версии Go 1.13, этот механизм был официально стандартизирован: функция fmt.Errorf получила поддержку глагола %w (wrap).
Механика Unwrap
Обертка над ошибкой — это любая структура, реализующая метод Unwrap() error.
Функции errors.Is и errors.As умеют рекурсивно обходить цепочку оберток (матрешку), спускаясь до первопричины (Root Cause).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Вызывающий код<br>errors.As(err, &dbErr)"]:::error -->|"Unwrap()"| B["Пакет репозитория<br>fmt.Errorf('GetUser: %w', err)"]
B -->|"Unwrap()"| C["Драйвер базы данных<br>*pq.Error (lib/pq error)"]
B -.->|"Проверка типа/значения"| Check["Сравнение на каждом уровне"]
C -.->|"Совпадение найдено!"| Check
Пример идиоматичного оборачивания
func GetUser(ctx context.Context, id int64) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = ?", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
// Оборачиваем, добавляя контекст, но сохраняя оригинал для errors.Is/As
return nil, fmt.Errorf("repository.GetUser id=%d: %w", id, err)
}
return &u, nil
}
Go 1.20: errors.Join и множественная распаковка
В Go 1.20 появился метод errors.Join, решающий давнюю проблему объединения нескольких независимых ошибок в одну (например, при валидации сложных форм или закрытии нескольких ресурсов через Close() в блоке defer):
var errs []error
// ... накопление ошибок в цикле ...
if len(errs) > 0 {
return errors.Join(errs...)
}
Внутри errors.Join создает структуру, реализующую метод Unwrap() []error. Функции errors.Is и errors.As умеют обходить дерево ошибок, проверяя каждую ветку.
4. Mechanical Sympathy и цена аллокаций
Каждая созданная ошибка — это выделение памяти. В высоконагруженных бэкенд-сервисах (10k–100k RPS) неосторожное создание ошибок в горячих путях приводит к взрывному росту нагрузки на Garbage Collector и деградации задержек (GC Assist Time).
Внутренние аллокации
errors.New("текст"): Возвращает указатель на структуру*errors.errorString. Строковый литерал лежит в read-only сегменте бинарника, аллоцируется лишь крошечная оболочка (16–24 байта в куче).fmt.Errorf("текст %w", err): Парсит строку формата, выделяет память под результирующую строку и создает новую структуруwrapError. Это в разы дороже, чемerrors.New.
Оптимизация для Hot Paths
Если ошибка в цикле является ожидаемым сигналом (например, признак окончания данных), никогда не используйте fmt.Errorf:
// ❌ Плохо: создает новую аллокацию и рефлексию на каждой итерации
for {
if err := read(); err != nil {
return fmt.Errorf("read chunk %d: %w", i, err)
}
}
// ✅ Хорошо: используем готовый sentinel-синглтон
if errors.Is(err, io.EOF) {
break // io.EOF — глобальный синглетон, проверка за доли наносекунды
}
5. Ловушки и хардкорные вопросы с собеседований
| Ловушка | В чем опасность | Как исправить |
|---|---|---|
errors.As без указателя |
errors.As(err, e) успешно скомпилируется, но вызовет панику в рантайме. |
Второй аргумент всегда обязан быть указателем: errors.As(err, &target). |
Сравнение через == |
Работает только для простых sentinel-ошибок. Если ошибка была обернута через %w, оператор == вернет false. |
Всегда используйте errors.Is(err, target). |
| Потеря контекста | Вызов err.Error() и возврат новой ошибки errors.New(err.Error()). |
Цепочка разрывается, errors.Is и errors.As больше не смогут найти исходную причину. Передавайте error как есть. |
| Дублирование сообщений | fmt.Errorf("failed: %w", err) + логирование того же текста в двух местах. |
Логируйте на границе обработки. Возвращайте ошибки чистыми: ровно один раз на границе системы (в middleware HTTP-хэндлера или фоновом воркере). |
6. Сравнение с экосистемами других языков
| Аспект | Java / C# / Python | Go |
|---|---|---|
| Механизм | try-catch-finally, исключения как объекты классов |
Явный возврат значений, error как интерфейс |
| Стоимость | Высокая: раскрутка стека, аллокация объекта исключения, сброс branch prediction | Минимальная: проверка указателя в регистре CPU, линейный поток инструкций |
| Стек вызовов (Stack Trace) | Генерируется автоматически JVM/CLR при создании исключения | Отсутствует в stdlib. Для продакшена используют runtime/debug.Stack(), runtime.Callers или внешние либы, но это дорого. |
| Игнорирование | Легко «заглушить» пустым блоком catch (Exception e) {} |
Компилятор запрещает неиспользуемые переменные; игнорирование через _ сразу видно на код-ревью и подсвечивается линтерами. |
Итог
- Ошибки в Go — это данные. С ними работают так же, как с числами или строками: передают через
returnи проверяют явно. - Помните про
iface != nil. Возвращайтеnil, а не&MyError{}со значениемnil. - Используйте стандартные инструменты Go 1.13+: Оборачивайте ошибки через
fmt.Errorf("%w", err), сравнивайте черезerrors.Is, извлекайте типы черезerrors.As. - Объединяйте ошибки через
errors.Join(Go 1.20+): Идеально подходит для batch-операций и обработки ошибок закрытия ресурсов вdefer. - Помните о цене аллокаций: В горячих циклах не генерируйте строки через
fmt.Errorf, а используйте готовые sentinel-ошибки (io.EOF).
Понимание работы с ошибками закладывает прочный фундамент для потоковой обработки данных. В следующей статье мы перейдем к сердцу всей подсистемы ввода-вывода в Go и разберем два величайших интерфейса стандартной библиотеки: 4. io. Reader, Writer и философия потоков в Go.