12. Обработка ошибок через error

Когда разработчики, привыкшие к Java, C# или Python, впервые открывают проект на Go, их первая эмоциональная реакция предсказуема: «Почему я должен писать if err != nil каждые три строчки? Верните мне привычный try-catch!».

Создатели Go отказались от механизма исключений (Exceptions) совершенно осознанно. Исключения разрушают линейность и предсказуемость исполнения программы (Control Flow). Вызывая функцию в Java или C++, вы никогда не можете быть уверены, вернет ли она управление в следующую строчку или невидимый механизм раскрутки стека (Stack Unwinding) катапультирует выполнение на пять уровней вверх в случайный блок catch. В распределенных сетевых сервисах такая скрытая «невидимая магия» оборачивается утечками ресурсов, незакрытыми соединениями и трудноуловимыми багами.

В Go провозглашен ясный инженерный принцип: ошибка — это просто значение (Error is a value). Ошибка не прерывает работу приложения сама по себе, не требует дорогостоящих таблиц раскрутки стека и передается через регистры процессора как самые обычные данные. Язык обязывает программиста рассуждать об ошибках честно и хладнокровно прямо в месте их возникновения.

В этой главе мы разберем внутреннее устройство ошибок в памяти, паттерны их упаковки и распутывания, а также исследуем знаменитую ловушку Typed Nil, на которой проверяют глубину понимания рантайма на технических собеседованиях.


1. Интерфейс error под капотом

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

Go
type error interface {
    Error() string
}

Любая структура данных или пользовательский тип, имеющий метод Error() string, автоматически удовлетворяет этому контракту благодаря утиной типизации (Structural Duck Typing) и может возвращаться в качестве ошибки.


2. Создание ошибок и фокус с указателем

Простейший способ сконструировать ошибку в коде — обратиться к стандартным пакетам errors или fmt:

Go
err1 := errors.New("file not found")
err2 := fmt.Errorf("user %d not found", userID)

Давайте заглянем в исходный код пакета errors из состава Go toolchain:

Go
package errors

// New возвращает ошибку, форматированную как заданный текст.
func New(text string) error {
    return &errorString{text} // Обратите внимание на амперсанд!
}

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

3. Классификация ошибок в Go

В профессиональной бэкенд-разработке ошибки принято разделять на три четкие категории.

Категория 1: Сигнальные ошибки (Sentinel Errors)

Сигнальные ошибки — это глобальные переменные уровня пакета (технически изменяемые, но по соглашению их никто не переприсваивает), обозначающие ожидаемые штатные исходы: например, достижение конца потока данных (io.EOF) или отсутствие искомой записи в реляционной базе данных (sql.ErrNoRows).

Go
var ErrUserNotFound = errors.New("user not found")

func getUser() error {
    return ErrUserNotFound
}

// Проверка (errors.Is работает и с обёрнутыми ошибками, см. ниже):
if errors.Is(err, ErrUserNotFound) {
    // Пользователь не найден, создаем нового...
}

Категория 2: Пользовательские типы ошибок (Custom Error Types)

Когда текстового сообщения недостаточно и вызывающей стороне требуются детальные структурированные данные (HTTP-код ответа, идентификатор запроса, число повторных попыток), мы создаем собственную структуру:

Go
type HTTPError struct {
    StatusCode int
    URL        string
    Msg        string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s at %s", e.StatusCode, e.Msg, e.URL)
}

Категория 3: Обернутые ошибки (Wrapped Errors)

В реальных сервисах вызов проходит через цепочку архитектурных слоев: Транспорт (HTTP-контроллер) -> Бизнес-логика (Сервис) -> Слой данных (Репозиторий). Если база данных разорвала сетевой сокет (connection refused), а промежуточные слои просто передавали ошибку наверх, то на уровне логов мы получим «голую» техническую строку connection refused без малейшего понимания, какой пользовательский запрос ее вызвал.

Исторически инженеры форматировали ошибки вручную: fmt.Errorf("db fetch: %v", err). Но спецификатор %v безвозвратно уничтожал исходную ошибку, превращая ее в плоский текст.

Начиная с Go 1.13, в стандартную библиотеку был добавлен механизм оборачивания (Wrapping) с помощью специального глагола %w:

Go
// Используем %w (wrap) вместо %v (value)
return fmt.Errorf("не удалось загрузить пользователя %d: %w", userID, err)

Спецификатор %w оборачивает ошибку, формируя в памяти цепочку ошибок: исходная «корневая» ошибка сохраняется, а к ней добавляется контекст каждого пройденного слоя. С Go 1.20 цепочка может ветвиться в дерево: fmt.Errorf принимает несколько %w, функция errors.Join объединяет ошибки, а тип ошибки может реализовать Unwrap() []error:

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
    Wrap1["Контроллер<br>fmt.Errorf с %w"]:::process -->|"содержит ссылку на"| Wrap2["Сервис<br>fmt.Errorf с %w"]
    Wrap2 -->|"содержит ссылку на"| Base["Репозиторий<br>sql.ErrNoRows"]
  

Распаковка: errors.Is и errors.As

Поскольку ошибка оказывается «упакованной» внутрь цепочки контекстов, простое сравнение if err == sql.ErrNoRows перестает работать. На замену пришли две функции пакета errors:

1. errors.Is — заменяет оператор ==. Функция рекурсивно обходит цепочку (а с Go 1.20 — дерево, в глубину), вызывая метод Unwrap(), и проверяет, совпадает ли какая-либо из вложенных ошибок с целевой сигнальной ошибкой:

Go
if errors.Is(err, sql.ErrNoRows) {
    // Сработает, даже если sql.ErrNoRows была обернута через %w десять раз!
}

2. errors.As — заменяет приведение типа (Type Assertion). Функция ищет в цепочке ошибку заданного конкретного типа и извлекает ее:

Go
var httpErr *HTTPError // Создаем пустой указатель на целевую структуру
if errors.As(err, &httpErr) {
    // Если в цепочке нашелся *HTTPError, переменная httpErr будет заполнена
    fmt.Println(httpErr.StatusCode)
}

С Go 1.26 есть и обобщённый вариант errors.AsType[E]: он возвращает найденную ошибку сразу, без заранее объявленной переменной.


4. Главная ловушка: Typed Nil (Неявный Nil интерфейс)

Это, пожалуй, самый коварный и знаменитый баг в Go, связанный с интерфейсами. Его безошибочное понимание — верный признак зрелого инженера.

Взгляните на следующий листинг:

Go
package main

import "fmt"

type MyError struct {
    Msg string
}

func (e *MyError) Error() string { return e.Msg }

func doWork() error {
    // Внимание! Мы объявляем переменную конкретного типа указателя
    var err *MyError = nil 

    // ...какая-то успешная логика...

    return err // Возвращаем nil?
}

func main() {
    err := doWork()
    if err != nil {
        fmt.Println("ПРОИЗОШЛА ОШИБКА!", err)
    } else {
        fmt.Println("УСПЕХ!")
    }
}

Результат работы программы:

Text
ПРОИЗОШЛА ОШИБКА! <nil>

Произошло нечто парадоксальное: функция doWork вернула nil, но условие err != nil вычислилось в true!

Mechanical Sympathy: Разбор полета

Вспомним структуру интерфейса iface из начала статьи: он представляет собой пару указателей (itab, data) или логически (Type, Data).
Интерфейс считается равным nil строго тогда, когда оба его указателя пусты: (nil, nil).

Что делает компилятор в строке return err функции doWork?
Функция возвращает интерфейс error. Переменная err имеет тип *MyError со значением nil. Компилятор производит упаковку конкретного типа в интерфейс:

  • В поле данных (data) записывается значение nil.
  • Но в поле таблицы типа (itab) записывается реальный указатель на тип *MyError!

В вызывающую функцию main возвращается интерфейс вида (*MyError, nil).
При выполнении проверки if err != nil процессор сравнивает (*MyError, nil) с эталонным (nil, nil). Поскольку указатель на тип не пуст, результат сравнения — true! Ветка проверки срабатывает, и программа радостно сообщает об «ошибке», а любая попытка прочитать ее поля вызовет panic: nil pointer dereference.

Детальное погружение во внутренности интерфейсов, таблицы виртуальных методов itab и структуры iface/eface мы проведем в главе 24. Интерфейсы под капотом. iface и eface.


Итог

  1. Ошибки — это обычные значения. Они не ломают конвейер инструкций CPU, передаются в регистрах общего назначения и обрабатываются строго по месту возникновения.
  2. Под капотом error — это 16-байтный интерфейс iface с одним методом Error() string.
  3. Функция errors.New возвращает указатель на структуру, обеспечивая корректное сравнение сигнальных ошибок (Sentinel Errors) по адресам в памяти.
  4. Оборачивание ошибок через %w формирует цепочку контекста, которую можно безопасно инспектировать с помощью errors.Is и errors.As.
  5. Typed Nil — опасная ловушка: интерфейс, содержащий типизированный указатель nil, сам не равен nil. Возвращайте литерал nil при успехе.

Штатные ошибки — естественная часть жизни любого надежного бэкенда. Но что происходит, когда в системе случается непредвиденная катастрофа: повреждение памяти, выход за границы массива или разыменование нулевого указателя?

Для таких исключительных аварийных ситуаций в рантайме Go существует механизм паник. В следующей лекции — 13. Panic, Recover и stack trace — мы изучим, как работает механизм раскрутки стека при панике, как печатается трассировка и как защитить сервис от аварийного падения с помощью recover.