Ошибки, паники и исключения: как языки обращаются с отказами

Как Go, Rust, C++ и Java проводят границу между ожидаемым отказом и сломанным инвариантом, и как осознанно выбирать между возвратом ошибки, паникой и исключением

«Как обрабатывать ошибки» в каждом языке выглядит как вопрос синтаксиса: где try/catch, где if err != nil, где ?. Но синтаксис — это следствие. За ним стоит одно и то же решение, которое язык навязывает или оставляет на программиста: считать конкретный сбой частью контракта или поломкой, после которой продолжать нельзя. Большинство дорогих ошибок в проде — это спутанные каналы: паника там, где нужно было вернуть ошибку, или проглоченная «ошибка», которая на самом деле была сигналом сломанного инварианта.

Эта статья — не справочник по try/catch четырёх языков. Это разбор того, как Go, Rust, C++ и Java проводят границу между двумя каналами отказов и какие следствия это даёт для проектирования.

В статье

Два канала: ожидаемый отказ и сломанный инвариант

У любого языка есть два принципиально разных канала для «что-то пошло не так», и их полезно не путать.

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

Сломанный инвариант — состояние, которое по замыслу программы невозможно. Индекс за границей массива, разыменование nil, нарушенное предусловие, повреждённые внутренние данные. Восстанавливаться на месте здесь нельзя: мы уже не знаем, в каком состоянии система. Цель — упасть быстро и шумно, как можно ближе к причине, а не тащить порчу дальше.

Языки различаются тем, какими механизмами они выражают эти два канала и насколько жёстко их разделяют. Go даёт значения-ошибки для первого канала и panic для второго. Rust — Result и panic!. C++ объединяет оба канала в один механизм исключений и оставляет разделение на дисциплину программиста; Java попыталась развести их через checked-исключения, но на практике во многом откатилась к той же дисциплине (об этом ниже) — отсюда большая часть их проблем. Дальше — по языкам, а в конце сведём это в таблицу и общие принципы.

Go: ошибки как значения

Go отдаёт первый канал программисту явно: ошибка — это обычное значение типа error, возвращаемое последним. Никакого скрытого потока управления — каждый вызов, который может сбоить, виден в сигнатуре и в месте использования.

var ErrNotFound = errors.New("user not found")

func loadUser(id string) (*User, error) {
    u, err := db.Query(id)
    if err != nil {
        // обогащаем контекстом, сохраняя исходную ошибку через %w
        return nil, fmt.Errorf("loadUser %s: %w", id, err)
    }
    if u == nil {
        return nil, ErrNotFound
    }
    return u, nil
}

// вызывающий разбирает причину, не сравнивая строки
if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

fmt.Errorf("...: %w", err) оборачивает ошибку, добавляя контекст и сохраняя цепочку; errors.Is сравнивает с sentinel-значением сквозь обёртки, а errors.As извлекает конкретный тип. Это и есть идиоматичный Go: ошибки — данные, с которыми работают как с данными.

Второй канал — panic. Он предназначен для сломанных инвариантов: обращение по nil-указателю, выход за границы среза, недостижимая ветка switch. recover существует не для того, чтобы превращать панику в поток управления, а чтобы поставить границу процесса — например, не дать одному сбойному HTTP-запросу уронить весь сервер:

func handle(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if v := recover(); v != nil {
            log.Error("panic in handler", "value", v, "stack", debug.Stack())
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
    }()
    serve(w, r)
}

Антипаттерны Go. panic в библиотечном коде ради «удобства» — он навязывает вызывающему чужое решение о фатальности. Проглоченная ошибка (v, _ := f()) — молчаливая потеря первого канала. И наоборот, if err != nil { panic(err) } в бизнес-логике превращает ожидаемый отказ в поломку процесса.

Rust: Result и panic!

Rust разделяет два канала на уровне типов. Ожидаемый отказ — это значение Result<T, E>, и просто так его не проигнорируешь: тип помечен #[must_use], поэтому неиспользованный Result компилятор по умолчанию подсвечивает предупреждением (а проекты часто поднимают такие warning до ошибок сборки). Оператор ? пробрасывает ошибку вверх, при необходимости приводя её тип через From.

use thiserror::Error;

#[derive(Error, Debug)]
enum LoadError {
    #[error("user {0} not found")]
    NotFound(String),
    #[error("db error")]
    Db(#[from] sqlx::Error), // ? сам сконвертирует sqlx::Error в LoadError
}

fn load_user(id: &str) -> Result<User, LoadError> {
    let row = db::query(id)?;                       // ранний возврат при ошибке
    row.ok_or_else(|| LoadError::NotFound(id.into()))
}

thiserror удобен для библиотек, где тип ошибки — часть API; anyhow — для приложений, где достаточно «любой ошибки с контекстом». Разница та же, что между sentinel-ошибками и fmt.Errorf в Go, только проверяется компилятором.

Второй канал — panic!. Он для сломанных инвариантов: unwrap() на None, выход за границы, явный unreachable!(). Важно, что unwrap()/expect() — это утверждение инварианта, а не лень: «здесь по построению всегда Some, и если нет — это баг». Хороший expect("config must be validated at startup") читается как документированное предусловие.

Поведение паники настраивается профилем сборки: panic = "unwind" раскручивает стек (запуская деструкторы и позволяя поймать панику на границе потока через catch_unwind), panic = "abort" немедленно завершает процесс — меньше бинарь, нет раскрутки. Для сервиса под оркестратором abort + рестарт часто честнее попытки восстановиться. Подробнее о production-дисциплине ошибок в Rust — в статье про production-паттерны.

C++: исключения, RAII и noexcept

C++ исторически объединяет оба канала в одном механизме исключений и оставляет разделение на дисциплину программиста. Брошенное throw раскручивает стек, вызывая деструкторы локальных объектов, — и здесь центральную роль играет RAII: ресурс, захваченный в конструкторе и освобождаемый в деструкторе, корректно освободится при любой раскрутке. RAII — это то, что вообще делает исключения в C++ безопасными.

void process(const std::string& path) {
    std::lock_guard<std::mutex> lock(mtx);   // освободится даже при throw ниже
    auto file = std::ifstream(path);
    if (!file) {
        throw std::runtime_error("cannot open " + path);
    }
    parse(file);                             // бросит — lock и file всё равно закроются
}

Дисциплина исключений описывается гарантиями безопасности: nothrow (операция не бросает вообще), strong (бросит — состояние откатится как до вызова), basic (инварианты сохранены, но состояние могло измениться), no-guarantee (после броска объект непригоден). Проектируя API, выбираешь и документируешь уровень — это прямой аналог вопроса «ожидаемый отказ или поломка».

noexcept помечает функцию как не бросающую — и это не только документация: например, std::vector при росте предпочитает move-конструктор, если тот noexcept, а иначе ради strong-гарантии копирует (и лишь когда копирование недоступно — двигает потенциально бросающим move). Нарушенный noexcept вызывает std::terminate — это и есть «сломанный инвариант, продолжать нельзя».

Модель «zero-cost»: на счастливом пути исключения не стоят ничего (нет проверок кодов возврата), но сам бросок дорог. Поэтому исключения — для редких отказов, не для штатных развилок. До C++23 ожидаемые ошибки без исключений выражали через std::error_code (например, перегрузки <filesystem>, принимающие error_code&) — рабочий, но многословный паттерн. C++23 добавил std::expected<T, E> — явный Result-подобный канал для ожидаемых ошибок, сигнал общего сдвига индустрии от «исключения на всё» к разделению двух каналов на уровне типа.

Java: checked против unchecked

Java — единственный из мейнстрима, кто попытался зашить разделение каналов в систему типов через checked-исключения. Иерархия: Throwable делится на Error (сломанные инварианты уровня JVM — OutOfMemoryError, ловить не полагается) и Exception. Внутри Exception особняком стоит RuntimeException и его потомки (unchecked — баги в логике: NullPointerException, IllegalArgumentException); остальные Exception — checked, компилятор требует объявить их в throws или поймать.

Замысел был верным: checked-исключение = ожидаемый отказ, который вызывающий обязан учесть. На практике checked плохо масштабируются. Сигнатуры протекают через слои (throws SQLException тащится в бизнес-логику), и под давлением появляется catch (Exception e) {} — проглатывание, которое хуже отсутствия проверки. Поэтому современные фреймворки (Spring) почти везде используют unchecked-исключения, а checked оставляют для узких случаев.

// AutoCloseable освобождается автоматически, как RAII в C++
try (var conn = pool.getConnection();
     var stmt = conn.prepareStatement(SQL)) {
    stmt.setString(1, id);
    return map(stmt.executeQuery());
} catch (SQLException e) {
    // не проглатываем: оборачиваем в доменную unchecked-ошибку с контекстом
    throw new UserLookupException("loadUser " + id, e);
}

try-with-resources закрывает любой AutoCloseable при выходе из блока, в том числе при исключении, — тот же приём детерминированного освобождения, что RAII в C++, только синтаксически явный. Ключевая дисциплина та же, что везде: не проглатывать, оборачивать с контекстом, сохранять причину (cause).

«Let it crash»: Erlang и Elixir как контраст

Все четыре языка выше пытаются обработать отказ внутри процесса — поймать, обернуть, восстановиться. Erlang и Elixir (BEAM) предлагают противоположную философию: не лечить сбойный процесс, а дать ему упасть. Процессы дёшевы и изолированы (ничего не разделяют), а supervisor по дереву надзора перезапускает упавшего потомка из заведомо хорошего начального состояния.

defmodule Worker do
  use GenServer
  # никаких try/rescue вокруг бизнес-логики: пусть падает
  def handle_call({:load, id}, _from, state) do
    user = Db.fetch!(id)   # бросит — процесс умрёт, supervisor перезапустит
    {:reply, user, state}
  end
end

Это доводит идею «сломанный инвариант → быстрый отказ» до архитектурного принципа: граница восстановления вынесена за процесс, а изоляция гарантирует, что поломка локальна. Тот же мотив виден в panic = "abort" + рестарт пода под Kubernetes — просто BEAM делает это гранулярно и встроенно.

Для полноты картины — два языка одним абзацем. Python идёт по пути EAFP («проще попросить прощения, чем разрешения»): try/except как штатный приём, а не крайняя мера — исключения глубоко встроены в идиомы языка (StopIteration, KeyError), хотя сам бросок не бесплатен. C# близок к Java, но без checked-исключений вовсе: все исключения unchecked, ресурсы освобождаются через using/IDisposable — тот же RAII-приём, что try-with-resources.

Сводная таблица

Язык Канал ожидаемых отказов Канал сломанного инварианта Стоимость на happy path Проверяет ли компилятор
Go error как возвращаемое значение panic / recover на границе низкая (явный if err) нет (можно проигнорировать _)
Rust Result<T, E> + ? panic! (unwind/abort) низкая да, предупреждением (#[must_use])
C++ исключения или std::expected (C++23) исключения, noexceptterminate ноль (zero-cost), дорого при throw нет
Java checked-исключения (по замыслу) RuntimeException / Error ноль на happy path, дорого при throw (fillInStackTrace) частично (только checked)
Elixir/BEAM {:ok, _} / {:error, _} падение процесса + supervisor низкая нет

Главный водораздел — разделены ли каналы на уровне типов (Rust, отчасти Java и C++23) или оставлены на дисциплину (Go-panic, классический C++, Java-runtime).

Сквозные принципы

Независимо от языка работают одни и те же правила.

  • Ставь границу перехвата на периметре, не в глубине. recover/catch уместны там, где есть осмысленная единица восстановления: HTTP-запрос, задача, поток, процесс. Перехват в середине бизнес-логики прячет проблему.
  • Не глотай отказ. Пустой catch, v, _ := f(), catch (Exception e) {} — потеря информации, которая всплывёт позже и дороже.
  • Обогащай контекстом при проброске. На каждом уровне добавляй, что делал (fmt.Errorf("loadUser %s: %w", ...), обёртка с cause), сохраняя исходную причину.
  • fail-fast на старте, устойчивость в рантайме. Невалидная конфигурация при запуске — повод немедленно упасть (см. graceful shutdown и старт сервиса); сбой одного запроса в рантайме — не повод ронять процесс.
  • Не делай исключение/панику потоком управления. Ожидаемая развилка — это значение (error, Result, std::expected), а не throw.

Из практики: 404, закешированный на месяц

Случай не про панику в коде, а про ту же путаницу каналов этажом выше — на инфраструктуре; тем он и показателен, что граница «ожидаемый отказ или результат» одна и та же. В проекте превью изображений отдаёт imgproxy, тянущий исходники из S3. Однажды статья ушла в публикацию раньше, чем картинка была залита в бакет: imgproxy получил от origin 404, отдал его клиенту — и закешировал отрицательный ответ на 30 дней. Файл залили через час, но битые превью продолжали показываться: система запомнила временный отказ как окончательный ответ.

Корень — ровно тот, о котором вся статья. 404 здесь был ожидаемым, временным отказом («ещё не готово»), а кеш обошёлся с ним как с достоверным, долговечным результатом, достойным запоминания. Транзиентный сбой и settled-результат — разные каналы, и склеивать их нельзя: первый недопустимо мемоизировать на тех же условиях, что второй. Дисциплина, которая закрыла проблему: заливать медиа в бакет до публикации (чтобы первый же запрос был настоящим успехом) и держать короткий TTL негативного кеша для 404. Тот же принцип «не глотай отказ» и «не путай поломку с результатом» — просто граница здесь проходит не по try/catch, а по слою кеширования.

Что дальше

Каждый из языков заслуживает отдельного погружения: идиомы errors.Is/As и дизайн error-типов в Go; thiserror vs anyhow и границы panic в Rust; гарантии безопасности исключений и std::expected в C++. Если тема интересна — напишите в Telegram-группе, какой язык разобрать подробнее в первую очередь.

Обсуждение в Telegram

Присоединиться →

Комментарии