lenec ru

← все посты

Result vs panic в Rust: когда что использовать без догматизма

13K

В Rust-коммьюнити есть негласное правило: panic! — зло, всё через Result. Это упрощение, которое приводит к смешным конструкциям: unwrap() внутри тестов, ? везде в main, и обработка ошибок «потому что так принято», даже там, где panic был бы честнее.

Поделюсь подходом, к которому пришёл за год работы с Rust в продакшене. Без идеологии, чисто практика: где Result работает лучше, где panic — норм, и где между ними реальный выбор.

Что говорит официальный гайд

The Rust Programming Language прямо пишет: panic — для нерековерабельных ситуаций, когда программа вошла в состояние, из которого никак не выйти; Result — для всего, что можно обработать вызывающим кодом.

Простая мнемоника:

  • Бизнес-ошибка (валидация ввода, отказ внешнего сервиса, не нашли запись в БД) → Result.
  • Программная ошибка (нарушен инвариант, который гарантирован типом) → panic.
  • Невозможная ситуация, которой по логике быть не должно → unreachable!() или panic.

Звучит просто. На практике границы плывут.

Где panic — нормально

В main и инициализации

В main ловить ошибку через ? — модный паттерн с fn main() -> anyhow::Result<()>. Это работает для CLI, но для сервиса, который при старте не может подключиться к БД, проще:

fn main() {
    let cfg = Config::load().expect("failed to load config");
    let db = connect(&cfg.db_url).expect("failed to connect to db");
    // ...
}

Если конфиг битый или БД недоступна, сервис не должен запуститься. expect с осмысленным сообщением — честный выход. Прятать это в ? и какой-нибудь anyhow::Error — лишняя церемония.

Сценарий, который ты гарантировал инвариантом

fn parse_quote(s: &str) -> Quote {
    let parts: Vec<&str> = s.split(',').collect();
    if parts.len() != 3 {
        panic!("bad quote format: {}", s);
    }
    Quote {
        symbol: parts[0].into(),
        bid: parts[1].parse().unwrap(),
        ask: parts[2].parse().unwrap(),
    }
}

Если эта функция вызывается на сообщениях из биржевого фида, формат гарантируется на стороне провайдера. Если он внезапно поменялся — это критическая ошибка, и логичнее панически упасть, чем тащить Result через все вызовы.

Альтернатива: вернуть Result и обрабатывать в caller. Но если нет осмысленного recovery (а в торговом движке — нет, ты не можешь продолжать работу с битыми котировками), смысл проброса теряется.

Тесты

В тестах unwrap() и panic! — норма. Цель — выявить проблему. Тащить Result через все assertions значит писать в три раза больше кода ради того, чтобы при провале теста увидеть Err вместо panic.

#[test]
fn parses_simple_quote() {
    let q: Quote = "AAPL,150.0,150.5".parse().unwrap();
    assert_eq!(q.symbol, "AAPL");
}

Build-time, не runtime

Если ошибка ловится при компиляции (через invariant, поддерживаемый типом), runtime-ловить её через Result — лишний шум. Например, NonZeroU64::new(0) возвращает Option, и компилятор тебя заставит обработать; внутри типа после конструирования инвариант гарантирован.

Где Result обязателен

Любой ввод снаружи

HTTP-запрос, чтение файла, разбор JSON, вызов БД, вход с консоли. Всё, что приходит из непрозрачного источника — может быть невалидным, и это не повод ронять процесс.

fn handle_create(body: &str) -> Result<Order, ApiError> {
    let req: CreateOrderReq = serde_json::from_str(body)
        .map_err(ApiError::BadJson)?;
    req.validate().map_err(ApiError::Validation)?;
    // ...
}

Любая работа с сетью

Сетевые ошибки — норма жизни. Timeout, connection refused, 5xx от downstream — каждый кейс должен быть обработан. Ronять процесс из-за одного 502 — глупо.

Опубличный API библиотеки

Если пишешь crate для других — Result везде, где есть малейшая возможность ошибки. Пользователь твоего crate должен иметь возможность обработать ошибку, а не ловить panic от чужого кода. Тут как раз место для thiserror.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ParseError {
    #[error("invalid format: {0}")]
    InvalidFormat(String),
    #[error("out of range: {0}")]
    OutOfRange(i64),
}

anyhow vs thiserror

Часто путают, что когда использовать.

thiserror — для библиотек. Каждая ошибка — явный enum с кодами, контекстом, From-конверсиями. Пользователь твоей библиотеки может matcher'ить по типу ошибки и реагировать.

anyhow — для приложений. anyhow::Error — это «упаковка для любой ошибки», с человеко-читаемым контекстом. На уровне main и обработчиков HTTP это работает отлично, у вызывающего нет необходимости разбираться с конкретным типом.

// в библиотеке
pub fn parse(s: &str) -> Result<Quote, ParseError> { /* ... */ }

// в приложении
fn run() -> anyhow::Result<()> {
    let q = parse(input).context("failed to parse quote")?;
    let order = build_order(q).context("failed to build order")?;
    Ok(())
}

Когда unwrap оправдан

Слегка контр-интуитивно, но unwrap() и expect() бывают вполне разумны:

  • В тестах.
  • В прототипах и POC.
  • В местах, где инвариант гарантирован выше по стеку: vec.first().unwrap() сразу после vec.push(...).
  • В компайл-тайм-вычислениях (constants, статика), где паника при ошибке означает баг в коде, не runtime-проблему.

Главное — каждый unwrap() должен быть осознанным, а не «лень обрабатывать». В review я обычно прошу комментарий рядом: // safe: vec is just pushed above. Это снимает вопросы при чтении.

Где сам ошибался

Первые проекты на Rust я делал «как учили»: Result везде. В одном CLI получился такой стек:

fn main() -> anyhow::Result<()> {
    let args = parse_args()?;
    let cfg = load_config(&args)?;
    let runner = Runner::new(cfg)?;
    runner.run()?;
    Ok(())
}

Любая ошибка возвращалась пользователю в виде Error: XXX. Звучит ОК, но в реальности parse_args у меня делал clap::Parser::parse(), который при ошибке сам печатает help и выходит — и моя обёртка через Result только мешала. Убрал — стало чище.

Другой случай: писал библиотеку парсинга формата сообщений из закрытой системы. Делал тщательно: каждое поле через Result. Потом понял, что 99% использования — внутри одного бинарника, формат строго гарантирован проколом. Перевёл часть на panic при нарушении инварианта — код стал на треть короче, читаемее, и не потерял безопасности.

Подход, к которому пришёл

Несколько правил, по которым пишу сейчас:

  • Если ошибка — это «нормальный» путь выполнения (валидация, сеть, IO) → Result.
  • Если ошибка — это «инвариант нарушен» и код в этом состоянии не должен работать → panic.
  • Если функция называется try_* или parse, обычно Result.
  • Если функция гарантирует результат по контракту (Vec::first после push) → unwrap с комментарием.
  • В библиотеках — thiserror и enum-ы. В приложениях — anyhow + context.
  • Никаких map_err(|_| MyError::Generic) без контекста: ошибки без контекста невозможно отлаживать.

И главное: не надо чувствовать себя неправильным разработчиком, если у тебя в коде есть panic!. Иногда это самый правильный выход. Rust — про честную работу с ошибками, а не про их прятание под Result-обёртки.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.