Result vs panic в Rust: когда что использовать без догматизма
В 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-обёртки.