«Как обрабатывать ошибки» в каждом языке выглядит как вопрос синтаксиса: где
try/catch, где if err != nil, где ?. Но синтаксис — это следствие. За ним
стоит одно и то же решение, которое язык навязывает или оставляет на
программиста: считать конкретный сбой частью контракта или поломкой, после
которой продолжать нельзя. Большинство дорогих ошибок в проде — это спутанные
каналы: паника там, где нужно было вернуть ошибку, или проглоченная «ошибка»,
которая на самом деле была сигналом сломанного инварианта.
Эта статья — не справочник по try/catch четырёх языков. Это разбор того, как
Go, Rust, C++ и Java проводят границу между двумя каналами отказов и какие
следствия это даёт для проектирования.
В статье
- Два канала: ожидаемый отказ и сломанный инвариант
- Go: ошибки как значения
- Rust: Result и panic!
- C++: исключения, RAII и noexcept
- Java: checked против unchecked
- «Let it crash»: Erlang и Elixir как контраст
- Сводная таблица
- Сквозные принципы
- Из практики: 404, закешированный на месяц
- Что дальше
Два канала: ожидаемый отказ и сломанный инвариант
У любого языка есть два принципиально разных канала для «что-то пошло не так», и их полезно не путать.
Ожидаемый отказ — часть контракта функции. Файл не открылся, валидация не прошла, соединение оборвалось, пользователь не найден. Это не баг: вызывающий обязан такой исход предусмотреть и обработать. Ожидаемый отказ — нормальный поток управления, просто не «счастливый».
Сломанный инвариант — состояние, которое по замыслу программы невозможно.
Индекс за границей массива, разыменование 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) |
исключения, noexcept→terminate |
ноль (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-группе, какой язык разобрать
подробнее в первую очередь.
Комментарии