9. Errors Are Values. Почему в Go нет исключений

Для разработчика, приходящего в Go из мира Java, C#, Python или современного C++, ничто не вызывает столь сильного отторжения в первые недели, как работа с ошибками. Полное отсутствие синтаксических конструкций try/catch/finally и бесконечная череда проверок if err != nil поначалу кажутся не просто архаизмом, а шагом назад на полвека — прямиком в семидесятые годы к языку C.

Новички часто пытаются обмануть систему: городят самодельные макросы, пишут глобальные перехватчики аварийных паник через recover, чтобы эмулировать catch, плодят фабричные функции Must(), или пытаются встроить в Go функциональные монады.

Однако с годами эксплуатации высоконагруженных систем приходит кристальное понимание: отказ от исключений — это одно из самых глубоких, дальновидных и удачных архитектурных решений авторов Go.

Чтобы осознать его гениальность, нам нужно опуститься на уровень регистров микропроцессора и конвейера команд, а затем посмотреть на дизайн надежного распределенного бэкенда.


Цена исключений: Mechanical Sympathy

В классических объектно-ориентированных средах исключение (Exception) — это фундаментальный механизм нелинейного изменения потока управления (Control Flow). Когда вы вызываете конструкцию вроде throw new UserNotFoundException(), вы не просто возвращаете статус завершения. Вы запускаете тяжелейший системный механизм раскрутки.

В Go ошибка — это обычное типизированное значение (Value).

Возврат ошибки return nil, err на уровне инструкций ничем не отличается от возврата пары чисел return 0, 1. Начиная с Go 1.17, благодаря переходу на register-based ABIInternal, значения передаются напрямую через регистры центрального процессора (RAX, RBX и др.) без единого обращения к оперативной памяти!

Сама проверка if err != nil компилируется в одну элементарную инструкцию сравнения (TESTQ) и условный переход JNZ (Jump if Not Zero). Предсказатель ветвлений процессора мгновенно обучается тому, что ошибки случаются редко: конвейер ядра продолжает непрерывно исполнять основной счастливый путь (happy path) без единого такта простоя.


Неявный поток управления vs Явный

Исключения создают в программе невидимый, неявный канал передачи сигналов, превращая код в эквивалент хаотичного оператора GOTO.

Взгляните на типичный код метода на Java:

JAVA
public void processTransaction(Transaction tx) {
    db.save(tx);
    billing.charge(tx);
    email.sendReceipt(tx);
}

Глядя на этот фрагмент, невозможно предсказать поведение системы:

  • Что произойдет, если метод billing.charge() завершится ошибкой NetworkException?
  • Откатит ли db.save(tx) изменения в базе?
  • Перехватит ли ошибку вызывающий код, или приложение упадет?
  • Кто вызовет закрытие сокета?

Чтобы ответить на эти вопросы, инженер обязан погрузиться в реализацию каждого вызываемого метода на всю глубину стека вызовов, изучая, какие исключения они декларируют или неявно выбрасывают в рантайме.

В Go поток управления кристально прозрачен и линеен:

Go
func (s *Service) ProcessTransaction(tx *Transaction) error {
    if err := s.db.Save(tx); err != nil {
        return fmt.Errorf("failed to save tx: %w", err)
    }

    if err := s.billing.Charge(tx); err != nil {
        // Явный, видимый глазами откат состояния
        s.db.Rollback(tx)
        return fmt.Errorf("billing failed: %w", err)
    }

    // Отправка чека по email может завершиться ошибкой, 
    // но мы явно решаем залогировать ее и не прерывать успешную транзакцию
    if err := s.email.SendReceipt(tx); err != nil {
        log.Printf("non-critical error sending receipt: %v", err)
    }

    return nil
}

Читая этот код сверху вниз, разработчик видит все возможные точки отказа и осознанные инженерные решения прямо в точке вызова. Код документирует сам себя.

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 ExceptionModel["Java / Python / C#: Неявный скрытый поток (GOTO)"]
        direction TB
        A1["Вызов метода"]:::error --> B1["throw Exception"]
        B1 -.->|"Невидимый прыжок через 5 фреймов стека"| C1["Где-то наверху ловит catch"]
        C1 -.->|"Неопределенное состояние ресурсов"| D1["Утечки / Блокировки"]
    end

    subgraph GoModel["Go: Явный локальный поток управления"]
        direction TB
        A2["Вызов функции"]:::error --> B2["return nil, err"]
        B2 -->|"Локальный возврат в вызывающую функцию"| C2["Проверка if err != nil"]
        C2 -->|"Осознанное решение: rollback, retry или return"| D2["Предсказуемое состояние системы"]
    end
  

"Ошибки" против "Багов"

Один из важнейших концептуальных водоразделов философии Go — разграничение понятий Ошибки (Errors) и Бага (Bugs).

В распределенной системе большинство сбоев — это нормальное, ожидаемое состояние внешней среды:

  • Пользователь ввел неверный пароль — это штатная ситуация.
  • Сетевой запрос завершился по таймауту — в распределенной сети это неизбежно.
  • Файл на диске временно недоступен — это штатный сценарий.

Все эти ситуации являются элементами бизнес-логики. Они моделируются обычным интерфейсом error, возвращаются как значения и обрабатываются на месте.

Но что происходит, если:

  • Инженер пытается прочитать 10-й элемент массива из 3 элементов?
  • Разыменовывается указатель, равный nil?
  • Происходит целочисленное деление на ноль?

Это не ожидаемые события бизнес-логики. Это грубые ошибки программиста (баги). В такой ситуации память процесса может быть скомпрометирована, и продолжать выполнение опасно.

Именно для таких аварийных инцидентов в Go существует механизм panic. Паника немедленно завершает текущую горутину и печатает подробный стек вызовов. Подробнее об этом мы поговорим в статье 11. Panic и Recover. Когда они нужны, а когда вредны.


"Errors are values" означает, что их можно программировать

В 2015 году Роб Пайк опубликовал классическую статью «Errors are values», смысл которой многие истолковали превратно. Начинающим gopher-ам показалось, будто автор призывает слепо копировать if err != nil после каждого чиха.

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

Вместо монотонного повторения проверок:

Go
_, err = w.Write(p0)
if err != nil {
    return err
}
_, err = w.Write(p1)
if err != nil {
    return err
}
// ... и так 10 раз подряд

Идиоматичный подход (широко применяемый, например, в стандартном пакете bufio) заключается в том, чтобы инкапсулировать состояние ошибки прямо внутри структуры-обертки:

Go
type errWriter struct {
    w   io.Writer
    err error // Ошибка накапливается во внутреннем состоянии
}

// Write не возвращает ошибку каждый раз — он сохраняет её внутри
func (ew *errWriter) Write(buf []byte) {
    if ew.err != nil {
        return // Если ошибка уже зафиксирована, последующие вызовы превращаются в no-op
    }
    _, ew.err = ew.w.Write(buf)
}

// Теперь прикладная логика форматирования выглядит элегантно:
func WriteResponse(w io.Writer) error {
    ew := &errWriter{w: w}

    ew.Write([]byte("Header\n"))
    ew.Write([]byte("Body\n"))
    ew.Write([]byte("Footer\n"))

    // Проверяем ошибку ровно один раз в самом конце цепочки!
    return ew.err
}

Код остается абсолютно безопасным, полностью линейным и избавлен от визуального шума.


Итог

Архитектурный выбор Go в пользу ошибок-значений — это осознанная ставка на предсказуемость, безопасность и аппаратную эффективность:

  1. Mechanical Sympathy: Простые условные переходы не разрушают конвейер инструкций CPU и не требуют дорогой раскрутки стека (Stack Unwinding).
  2. Линейная читаемость: Поток управления читается сверху вниз без скрытых аварийных прыжков.
  3. Алгоритмическая гибкость: Ошибки можно накапливать, оборачивать, фильтровать и программировать как любые другие данные.

В следующей лекции мы разберем практический инструментарий работы с цепочками ошибок: оборачивание через %w, функции errors.Is/errors.As и архитектурные паттерны: 10. Обработка ошибок в Go. if err != nil как часть дизайна.