Обработка ошибок: Result, Option, оператор ?, свои типы ошибок
В предыдущих главах мы собрали арсенал: перечисления с данными, обобщения, трейты, владение. Эта глава показывает задачу, ради которой всё это собиралось вместе. Обработка ошибок в Rust — не отдельная подсистема языка, а обычные типы данных плюс немного синтаксического сахара. Никаких throw, никакого невидимого потока управления, никакого «а вдруг здесь выскочит исключение».
Звучит скромно. На практике это решение меняет то, как выглядит код: список того, что может пойти не так, становится частью сигнатуры функции — то есть частью того, что компилятор проверяет, а рецензент читает.
Задача: три ответа индустрии и почему Rust выбрал значения
Функция «прочитать конфиг» может не сработать по десятку причин: файла нет, нет прав, диск отвалился, содержимое — не тот формат, обязательного ключа не хватает. Языки отвечают на это по-разному.
Коды возврата (C). Ошибка — это особое значение результата плюс глобальный errno. Дёшево, прозрачно и абсолютно необязательно к исполнению:
// Компилируется без единого предупреждения. Ломается в проде.
FILE *f = fopen("/etc/app.toml", "r");
char buf[256];
fread(buf, 1, sizeof buf, f); // f может быть NULL — разыменование NULL, UB
Компилятор C не обязан вас останавливать: игнорирование результата — легальная операция. Как это устроено на уровне системных вызовов и почему errno — глобальная переменная потока, разбирается в «Системные вызовы и ввод-вывод».
Исключения (Java, C#, Python, C++). Ошибка едет вверх по стеку сама, «бесплатно» на счастливом пути. Цена — невидимость: по сигнатуре Config load(String path) нельзя понять, что вылетит и откуда. Любая строка становится потенциальной точкой выхода, а значит, все инварианты между двумя строками кода надо держать в голове. Джо Даффи, архитектор Midori в Microsoft, разобрал этот компромисс в классической статье «The Error Model» — она и сегодня лучшее чтение по теме.
Ошибки как значения (Rust, Go, отчасти Elixir). Возможная неудача записана в типе результата, и не обработать её нельзя молча. Rust усиливает этот подход двумя вещами, которых нет в Go: перечисления с данными (можно перечислить все случаи и получить проверку исчерпывающести) и оператор ? (проброс вверх без ручного if err != nil на каждой строке). Сравнение с идиомами Go — в «Идиомы и ошибки» трека golang, с моделью «пусть падает» — в аналогичной главе трека elixir, с исключениями — в главе трека csharp.
Две категории: восстановимое и невосстановимое
Первое, что нужно уложить в голове: в Rust две принципиально разные ситуации, и путать их — источник большинства плохого кода.
| Восстановимая ошибка | Невосстановимая ошибка (баг) | |
|---|---|---|
| Что это | ожидаемый исход операции: файла нет, ввод пользователя мусорный, сеть отвалилась | нарушен инвариант самой программы: индекс вне границ, unwrap на None, «этого не может быть» |
| Инструмент | Result<T, E>, Option<T> |
panic!, assert!, unwrap, expect |
| Кто решает, что делать | вызывающий код | никто: продолжать бессмысленно, состояние уже неверное |
| Видно в сигнатуре | да | нет (только в документации, раздел # Panics) |
Формулировка из главы 9 The Rust Book остаётся лучшей: паника — это «программа обнаружила, что находится в состоянии, которое она не умеет описывать». Не «что-то плохое случилось», а «моё представление о мире оказалось ложным».
Отсюда рабочее правило: если ошибку вызвал внешний мир — это Result; если её вызвал ваш собственный код, нарушивший собственное обещание, — это паника. Плохой ввод от пользователя не должен ронять сервис. Пустой массив там, где инвариант структуры обещает непустой, — должен, потому что дальше вы будете портить данные.
Что писать в сигнатуре?"] --> B{"Отсутствие результата —
это нормальный исход?"} B -->|"да, и причина не нужна:
ключа нет в мапе,
строка пустая"| C["Option<T>"] B -->|"операция провалилась,
причина важна"| D{"Вызывающий может
что-то с этим сделать?"} D -->|"да: повторить, спросить
пользователя, взять дефолт,
вернуть 4xx"| E["Result<T, E>
E перечисляет случаи"] D -->|"нет: нарушен инвариант
самой программы"| F["panic! / assert! / expect
это баг, а не исход"] C --> G{"Нужно объяснить,
почему пусто?"} G -->|"да, вариантов несколько"| E G -->|"нет, вариант один"| H["оставить Option"] E --> I{"Ошибка доказуемо
невозможна?"} I -->|"да"| J["Infallible или тип,
в котором невалидного
состояния не существует"]
Правая нижняя ветка — самая недооценённая. Лучший способ обработать ошибку — сделать её невыразимой: если порт хранится в u16, проверка «не больше 65535» не нужна нигде, кроме границы парсинга. Это подход «parse, don’t validate», о типах-доказательствах — в «Типы и трейты».
Option<T>: отсутствие — не ошибка
Option — обычное перечисление из двух вариантов, объявленное в std:
pub enum Option<T> { None, Some(T) }
Никакой магии: None — это значение, а не «пустой указатель». Компилятор не даст обратиться к содержимому, не разобрав вариант, и именно поэтому в Rust нет разыменования null — той самой «ошибки на миллиард долларов» Тони Хоара. Благодаря niche-оптимизации (см. «Основы Rust») Option<&T> и Option<Box<T>> не занимают ни одного лишнего байта по сравнению с самим указателем.
Работать с Option идиоматично — значит почти никогда не писать match на четыре строки:
#[derive(Debug)]
struct Sensor { id: u32, celsius: f64 }
fn find(sensors: &[Sensor], id: u32) -> Option<&Sensor> {
sensors.iter().find(|s| s.id == id)
}
fn main() {
let sensors = vec![
Sensor { id: 7, celsius: 21.5 },
Sensor { id: 9, celsius: -3.0 },
];
// 1. let-else: выйти рано, дальше работать с обычным значением
let Some(s) = find(&sensors, 9) else {
println!("датчик 9 не подключён");
return;
};
println!("{:.1} °C", s.celsius); // -3.0 °C
// 2. Дефолт, вычисляемый лениво
let t = find(&sensors, 42).map_or(0.0, |s| s.celsius);
println!("{t}"); // 0
// 3. Цепочка без единого if
let warm = find(&sensors, 7)
.filter(|s| s.celsius > 18.0)
.map(|s| s.id);
println!("{warm:?}"); // Some(7)
// 4. matches! — когда нужен только да/нет
println!("{}", matches!(find(&sensors, 1), None)); // true
}
Option — про «значения нет», без объяснений. Как только вариантов отсутствия становится больше одного («нет в кеше» против «нет в базе» против «есть, но истёк»), Option начинает врать: вызывающий не может различить случаи. Это сигнал переходить к Result или к своему перечислению.
Result<T, E>: обычное перечисление, которое нельзя проигнорировать
#[must_use]
pub enum Result<T, E> { Ok(T), Err(E) }
Единственное отличие от вашего собственного enum — атрибут #[must_use]. Он превращает молчаливое игнорирование ошибки в предупреждение компилятора, а с #![deny(warnings)] в CI — в ошибку сборки:
fn main() {
std::fs::write("out.txt", b"data"); // забыли обработать Result
}
warning: unused `Result` that must be used
--> src/main.rs:2:5
|
2 | std::fs::write("out.txt", b"data");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
2 | let _ = std::fs::write("out.txt", b"data");
| +++++++
Обратите внимание на help: let _ = — это осознанное игнорирование, и оно видно в коде и в дифе. Если вы пишете let _ =, будьте готовы ответить на ревью «почему здесь можно потерять ошибку». Иногда ответ есть (пишем в закрывающийся сокет на пути завершения), но по умолчанию это красный флаг.
Комбинаторы: когда они читаются лучше match
Result и Option несут десятки методов. Учить их списком бесполезно, полезно понимать оси:
| Что нужно | Option |
Result |
|---|---|---|
| Преобразовать значение внутри | map |
map |
| Преобразовать ошибку | — | map_err |
| Цепочка, которая сама может провалиться | and_then |
and_then |
| Дефолт | unwrap_or, unwrap_or_else, unwrap_or_default |
те же |
| Перейти в другой мир | ok_or, ok_or_else → Result |
ok(), err() → Option |
| Проверить условие | filter |
— |
| Достать или упасть | unwrap, expect |
unwrap, expect, unwrap_err |
| Только проверить | is_some, is_none |
is_ok, is_err |
Практическое правило: комбинаторы хороши для линейных преобразований, match — для ветвления по вариантам ошибки. Как только внутри map_err появляется своя логика на пять строк, это уже match.
Два метода стоит выделить отдельно, потому что на них ошибаются постоянно:
fn main() {
let cached: Option<String> = None;
// ПЛОХО: аргумент вычисляется ВСЕГДА, даже когда значение есть
let a = cached.clone().unwrap_or(expensive_default());
// ХОРОШО: замыкание вызовется только на пути None
let b = cached.unwrap_or_else(expensive_default);
println!("{a} {b}");
}
fn expensive_default() -> String {
println!("считаю дефолт..."); // печатается один раз, а не два
"default".to_owned()
}
То же различие у пары ok_or / ok_or_else и map_or / map_or_else. Правило: если аргумент — не константа, берите _else-версию. Clippy это ловит (or_fun_call), но не во всех случаях.
Оператор ?: что именно он делает
Без ? код на Result выглядел бы как каскад match — примерно как if err != nil в Go, только хуже, потому что вложенный. ? убирает шум, не убирая явности: символ виден в каждой точке, откуда функция может выйти.
use std::path::Path;
// То, что вы пишете
fn read_port(path: &Path) -> Result<u16, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let port: u16 = text.trim().parse()?;
Ok(port)
}
// То, во что это разворачивается (упрощённо)
fn read_port_desugared(path: &Path) -> Result<u16, Box<dyn std::error::Error>> {
let text = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(e) => return Err(From::from(e)), // ← вот здесь вся соль
};
let port = match text.trim().parse::<u16>() {
Ok(value) => value,
Err(e) => return Err(From::from(e)),
};
Ok(port)
}
Три вывода из этого разворачивания:
?— это раннийreturn. Не «продолжить с пустым значением», не «залогировать». Только выход.?вызываетFrom::fromдля ошибки. Именно поэтому одна функция может пробрасыватьio::ErrorиParseIntErrorв один и тот же тип: нужна лишь реализацияFrom. Это ключ ко всему проектированию своих типов ошибок ниже.?не добавляет контекста. Изio::Errorневозможно узнать, какой файл не открылся: стандартная библиотека не кладёт путь внутрь. Ошибка «No such file or directory (os error 2)» без имени файла — самый частый способ сделать лог бесполезным.
Формально ? работает через трейты Try и FromResidual (RFC 3058), но их реализация для своих типов пока нестабильна, так что практически ? — это Result, Option и ControlFlow.
? на Option
В функции, возвращающей Option, ? работает так же: None — ранний выход.
/// Домен из адреса: любая неудача — просто None, объяснять нечего.
fn domain_of(email: &str) -> Option<&str> {
let at = email.find('@')?; // нет '@' → None
let rest = email.get(at + 1..)?; // границы не по символу → None
if rest.is_empty() { None } else { Some(rest) }
}
fn main() {
println!("{:?}", domain_of("me@example.com")); // Some("example.com")
println!("{:?}", domain_of("сломано")); // None
}
Смешивать миры ? не умеет — и это правильно, потому что переход Option → Result требует решения, какую ошибку подставить. Компилятор об этом прямо говорит:
error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s,
in a function that returns `Result`
--> src/lib.rs:5:26
|
4 | fn first(v: &[u8]) -> Result<u8, MyError> {
| ----------------------------------------- this function returns a `Result`
5 | let x = v.first()?;
| ^ use `.ok_or(...)?` to provide an error compatible with `Result<u8, MyError>`
Ответ на это — ok_or_else(|| MyError::Empty)?. В обратную сторону — .ok()?, но помните: .ok() выбрасывает ошибку в мусор.
? в main
main может возвращать Result — так работает трейт Termination:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("нет-такого.toml")?;
println!("{}", text.len());
Ok(())
}
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Код выхода — 1. И тут первая ловушка: main печатает ошибку через Debug, а не через Display. Отсюда Os { code: 2, ... } вместо человеческой фразы. Для прототипа нормально, для CLI — нет: пользователю нужен текст, а вам — контроль над кодом выхода. Рабочий вариант ниже, в разделе про практику: fn main() -> ExitCode плюс явная печать в stderr.
Свои типы ошибок: контракт хорошего типа
Box<dyn Error> годится для скриптов и main. В библиотеке он проваливает главный тест: вызывающий не может программно отреагировать на конкретный случай — только напечатать строку. Публичный тип ошибки — это API, и к нему есть требования, зафиксированные в Rust API Guidelines:
- реализует
Debug(дляunwrap, тестов,{:?}в логе) иDisplay(для человека); - реализует
std::error::Error— это делает его совместимым со всей экосистемой и даётsource(); Send + Sync + 'static— иначе ошибку нельзя передать между потоками, положить вanyhowили вернуть изtokio::spawn;- хранит причину через
source(), а не заклеивает её строкой; - достаточно мал, чтобы
Result<T, E>не раздувал каждый вызов (см. раздел про размер); - перечисляет случаи вариантами, а не одним
Other(String); - помечен
#[non_exhaustive], если вы собираетесь добавлять варианты без мажорной версии.
Руками, чтобы понимать, что генерируют макросы
use std::fmt;
use std::path::PathBuf;
#[derive(Debug)]
pub enum ConfigError {
Read { path: PathBuf, source: std::io::Error },
Syntax { line: usize },
Missing(&'static str),
BadPort { raw: String, source: std::num::ParseIntError },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// ВАЖНО: описываем только СВОЙ слой и не пересказываем source —
// иначе в цепочке появятся дубли текста
Self::Read { path, .. } => write!(f, "не удалось прочитать {}", path.display()),
Self::Syntax { line } => write!(f, "строка {line}: ожидался формат `ключ = значение`"),
Self::Missing(key) => write!(f, "нет обязательного ключа `{key}`"),
Self::BadPort { raw, .. } => write!(f, "{raw:?} не похоже на номер порта"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::BadPort { source, .. } => Some(source),
Self::Syntax { .. } | Self::Missing(_) => None,
}
}
}
// Позволяет писать `?` над функциями, возвращающими io::Error, —
// но ТОЛЬКО там, где путь неважен: иначе контекст потеряется.
impl From<std::io::Error> for ConfigError {
fn from(source: std::io::Error) -> Self {
Self::Read { path: PathBuf::from("<неизвестно>"), source }
}
}
Последний блок — намеренно неудачный пример. Он показывает конфликт, с которым вы столкнётесь в первый же день: From не принимает дополнительных аргументов, поэтому «бесплатный» ? не может добавить путь. Выбор такой: либо map_err с явным контекстом (дольше писать, полезнее в логе), либо From без контекста (короче, но потом вы будете гадать, какой файл не открылся). Правильный ответ в 90 % случаев — map_err.
Цепочка source() — это то, что превращает «ошибка при запуске» в диагностируемый инцидент. Обходить её нужно вручную (итератор Error::sources пока нестабилен):
/// Собирает всю цепочку причин в одну строку: то, что нужно человеку.
fn report(err: &(dyn std::error::Error + 'static)) -> String {
let mut out = err.to_string();
let mut cursor = err.source();
while let Some(cause) = cursor {
out.push_str(": ");
out.push_str(&cause.to_string());
cursor = cause.source();
}
out
}
Сообщения компилятора, которые вы увидите на этом пути
Умение читать диагностику rustc — половина обучения Rust. Все примеры ниже приведены с сокращениями, но с сохранением структуры.
? в функции, которая не возвращает Result. Самая частая ошибка первой недели:
error[E0277]: the `?` operator can only be used in a function that returns
`Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:51
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = std::fs::read_to_string("cfg.toml")?;
| ^ cannot use the `?` operator
| in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
Читать так: «? требует, чтобы тип возврата умел принимать остаток». Лечится сменой сигнатуры на Result, а не unwrap-ом.
Нет From для конверсии. Второе по частоте — и подсказка здесь ровно та, что нужна:
error[E0277]: `?` couldn't convert the error to `ConfigError`
--> src/config.rs:12:49
|
11 | pub fn load(path: &Path) -> Result<Config, ConfigError> {
| --------------------------- expected `ConfigError` because of this
12 | let text = std::fs::read_to_string(path)?;
| ^ the trait `From<std::io::Error>`
| is not implemented for `ConfigError`
|
= note: the question mark operation (`?`) implicitly performs a conversion
on the error value using the `From` trait
help: consider implementing `From<std::io::Error>` for `ConfigError`
Не спешите механически добавлять From: сначала спросите, не нужен ли здесь контекст. Часто правильная правка — map_err(...), а не новая реализация трейта.
Забыли Ok. Классика после рефакторинга:
error[E0308]: mismatched types
--> src/lib.rs:3:5
|
2 | fn parse(s: &str) -> Result<u32, std::num::ParseIntError> {
| ------------------------------------ expected because of return type
3 | s.trim().parse::<u32>()?
| ^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<u32, ParseIntError>`, found `u32`
|
help: try wrapping the expression in `Ok`
|
3 | Ok(s.trim().parse::<u32>()?)
| +++ +
Здесь ? вообще лишний: s.trim().parse() уже возвращает нужный Result.
Ошибка не Send + Sync. Всплывает, когда Box<dyn Error> пытаются протащить в anyhow, в tokio::spawn или в поток:
error[E0277]: `(dyn std::error::Error + 'static)` cannot be sent between threads safely
= help: the trait `Send` is not implemented for `(dyn std::error::Error + 'static)`
= note: required for `Box<dyn Error>` to implement `Into<anyhow::Error>`
Лечится дисциплиной: во всём приложении пишите Box<dyn Error + Send + Sync + 'static> либо не пишите Box<dyn Error> вовсе. Подробнее про Send/Sync — в «Бесстрашной конкурентности».
Clippy про толстую ошибку:
warning: the `Err`-variant returned from this function is very large
--> src/config.rs:20:29
|
20 | pub fn load(path: &Path) -> Result<Config, ConfigError> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ the `Err`-variant is at least 136 bytes
|
= help: try reducing the size of `ConfigError`, for example by boxing large elements
thiserror: то же самое, но без ручной работы
Всё, что мы написали руками выше, генерируется derive-макросом thiserror. Это де-факто стандарт для библиотек: макрос не добавляет ни рантайма, ни новых типов в публичный API — только impl-блоки, которые вы бы написали сами.
[dependencies]
thiserror = "2"
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive] // сможем добавлять варианты без мажорной версии
pub enum ConfigError {
#[error("не удалось прочитать {path}")]
Read {
path: PathBuf,
#[source] // поле с именем `source` подхватывается и без атрибута
source: std::io::Error,
},
#[error("строка {line}: ожидался формат `ключ = значение`")]
Syntax { line: usize },
#[error("нет обязательного ключа `{0}`")]
Missing(&'static str),
#[error("ключ `port`: {raw:?} не похоже на номер порта")]
BadPort {
raw: String,
#[source]
source: std::num::ParseIntError,
},
/// `transparent` — «я просто пробрасываю чужую ошибку»:
/// Display и source берутся у вложенного значения, своего текста нет.
#[error(transparent)]
Toml(#[from] toml::de::Error),
}
Что дают атрибуты:
#[error("...")]— генерируетDisplay; внутри работает обычная интерполяция полей и{0}для кортежных вариантов;#[source]— связывает вариант с причиной, то есть заполняетsource();#[from]— то же, плюс генерируетimpl From<...>, чтобы?конвертировал автоматически (используйте только там, где контекст не нужен);#[error(transparent)]— вариант-переходник без собственного текста;#[backtrace]— на nightly подхватываетstd::backtrace::Backtrace.
anyhow: ошибка для приложения, а не для библиотеки
anyhow — противоположный полюс: один тип anyhow::Error, который хранит любую ошибку, добавляет контекст и печатает цепочку причин. Он не реализует std::error::Error (специально, чтобы не конфликтовать с blanket-реализациями), зато принимает любую E: Error + Send + Sync + 'static.
use anyhow::{bail, ensure, Context, Result};
fn start(path: &std::path::Path) -> Result<()> {
let cfg = config::load(path)
.with_context(|| format!("конфигурация {}", path.display()))?; // + слой контекста
ensure!(cfg.port != 0, "порт 0 недопустим"); // проверка → ошибка
if cfg.host.is_empty() {
bail!("пустой host"); // ранний выход
}
Ok(())
}
Разница между context и with_context: первый принимает готовое значение (вычисляется всегда), второй — замыкание (только на пути ошибки). В горячем коде это не мелочь — форматирование строки на счастливом пути бессмысленно.
Печать anyhow::Error через Debug (то есть то, что показывает main) выглядит так:
Error: конфигурация /etc/app.toml
Caused by:
0: не удалось прочитать /etc/app.toml
1: No such file or directory (os error 2)
А {err:#} (Display в альтернативном режиме) даёт одну строку: конфигурация /etc/app.toml: не удалось прочитать /etc/app.toml: No such file or directory (os error 2). Если выставлен RUST_BACKTRACE=1, anyhow дополнительно приложит backtrace с точки создания ошибки — то, чего у голых enum-ошибок нет.
Когда типизация всё-таки нужна, из anyhow можно достать конкретный тип обратно:
match err.downcast_ref::<ConfigError>() {
Some(ConfigError::Missing(key)) => println!("подскажем пользователю про ключ {key}"),
_ => println!("общий случай"),
}
Но это признак того, что слой был спроектирован неверно: если вызывающему нужно ветвиться, тип должен быть виден в сигнатуре.
Правило, которое стоит запомнить дословно: библиотека возвращает точный enum (thiserror), приложение — anyhow::Result. Причина не в стиле, а в асимметрии: у библиотеки неизвестный вызывающий, которому может понадобиться отличить «нет ключа» от «сломан синтаксис»; у приложения вызывающий один — main, — и ему нужно напечатать понятный отчёт и вернуть код выхода.
Отдельно про соседей: eyre и color-eyre — тот же anyhow с настраиваемыми отчётами и цветом, miette — диагностика в стиле rustc с подсветкой фрагмента исходника (идеально для парсеров и компиляторов, ср. трек compilers), snafu — если хочется контекста в стиле «каждая точка отказа объявляет свой вариант».
Ошибка в живом сервисе: кто добавляет контекст и кто решает
операция, таблица, ключ R-->>S: Err(RepoError::Db) Note over S: match по коду: 23505 — доменный случай,
таймаут — инфраструктурный S-->>H: Err(OrderError::Duplicate) Note over H: единственное место, где ошибка
превращается в контракт API H->>H: Duplicate → 409, Infra → 500 + trace_id H-->>C: 409 Conflict, тело error=order_exists Note over H: warn! для 409, error! для 500;
тело ответа не содержит внутренностей
Три правила читаются прямо с диаграммы. Контекст добавляет тот, кто знает детали (репозиторий знает таблицу, хендлер — нет). Решение о категории принимает слой, который понимает домен. А превращение ошибки в HTTP-статус живёт ровно в одном месте — иначе половина ошибок утечёт наружу как 500 с текстом sqlx::Error, показывающим схему базы. Подробнее про таксономию ошибок в проде — в «Прод на Rust».
Паника: когда она правильный ответ
Паника — не «плохая практика». Это отдельный инструмент с честным назначением: остановиться, пока не сделано хуже.
fn main() {
let v: Vec<i32> = vec![1, 2, 3];
let x = v[10]; // индекс вне границ: паника, а не чтение чужой памяти
println!("{x}");
}
thread 'main' panicked at src/main.rs:3:14:
index out of bounds: the len is 3 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Сравните с C, где v[10] молча прочитает соседние байты — и это уже неопределённое поведение с потенциальной уязвимостью. Паника здесь — не деградация, а гарантия.
Что из этого важно на практике:
panic = "abort"в[profile.release]убирает таблицы разматывания: бинарь меньше, кода меньше. Ценой этого перестают работатьcatch_unwindи#[should_panic]-тесты, а поток больше не умирает в одиночку — падает весь процесс. Для CLI это часто хороший выбор, для многопоточного сервера — почти никогда.catch_unwind— неtry/catch. Он для границ: FFI, пул потоков, HTTP-слой (tower_http::catch_panic). Использовать его как управление потоком нельзя: он не ловитabort, а состояние после перехвата может быть логически испорчено.- Паника через
extern "C"— UB. Оборачивайте экспортируемые функции вcatch_unwindили объявляйтеextern "C-unwind"; подробности — в «unsafe и FFI». unwrapв библиотеке допустим только там, где вы можете доказать инвариант, и обязателен раздел# Panicsв докстроке.
Хорошее expect объясняет, почему вы уверены, а не жалуется:
// ПЛОХО: сообщение не помогает ни пользователю, ни дежурному
let re = Regex::new(PATTERN).expect("не должно случиться");
// ХОРОШО: сообщение — это утверждение об инварианте
let re = Regex::new(PATTERN).expect("PATTERN — литерал, проверен тестом regex_compiles");
Размер ошибки и горячий путь
Result<T, E> — не указатель, а значение по месту: он занимает столько же, сколько больший из вариантов, и копируется при каждом ?.
use std::mem::size_of;
#[derive(Debug)]
struct Detail { key: String, raw: String, expected: &'static str } // 24 + 24 + 16 = 64
#[derive(Debug)]
enum Fat { Io(std::io::Error), Bad(Detail) }
#[derive(Debug)]
enum Slim { Io(std::io::Error), Bad(Box<Detail>) }
fn main() {
println!("{}", size_of::<Result<u64, u64>>()); // 16
println!("{}", size_of::<Result<(), Box<dyn std::error::Error>>>()); // 16
println!("{}", size_of::<Detail>()); // 64
println!("{}", size_of::<Fat>()); // 72
println!("{}", size_of::<Slim>()); // 16
}
Точные числа зависят от версии компилятора и платформы (проверено на стабильном x86-64), но соотношение устойчиво: толстый вариант ошибки делает толстыми все Result в крейте, даже те вызовы, что всегда возвращают Ok. На горячем пути это лишние memcpy и давление на регистры.
Лечится боксированием крупных вариантов — ровно то, что предлагает clippy::result_large_err. Но: оптимизировать это стоит после профилирования, а не превентивно. Методика — в «Честном бенчмаркинге» и «CPU-профилировании», про цену копирований и локальность — в «Памяти».
Типичные грабли
1. unwrap на данных из внешнего мира. Самая дорогая привычка: пользователь прислал мусор — сервис упал. Простое правило для ревью: unwrap/expect допустимы в тестах, в примерах, в main прототипа и там, где рядом доказательство инварианта. На пути пользовательских данных — никогда.
2. Потеря причины в map_err.
// ПЛОХО: source стёрт, в логе останется только «плохой конфиг»
let cfg = load(path).map_err(|_| AppError::BadConfig)?;
// ХОРОШО: причина сохранена и всплывёт в цепочке
let cfg = load(path).map_err(|source| AppError::BadConfig { source })?;
3. String вместо типа. Result<T, String> пишется за секунду и живёт годами. Проблемы: нельзя match, нельзя source(), нельзя отличить «повторяемую» ошибку от постоянной, а форматирование строк тратит аллокации на пути ошибки. Если лень объявлять enum — берите anyhow, он хотя бы сохраняет цепочку и backtrace.
4. anyhow в публичной библиотеке. Пользователь получает «что-то сломалось» без возможности отреагировать. Наружу — enum, внутрь — что угодно.
5. Ошибка без контекста. std::fs не кладёт путь в io::Error. Строка No such file or directory (os error 2) в логе прод-сервиса стоит часа расследования. Всегда добавляйте «что делали»: .with_context(|| format!("читаю {}", path.display()))?.
6. Дубли текста в цепочке. Если Display варианта пересказывает source, отчёт превращается в «не удалось прочитать конфиг: не удалось прочитать /etc/app.toml: No such file or directory: No such file or directory». Каждый слой говорит только про свой слой.
7. Молчаливое отбрасывание ошибок в итераторах.
let raw = "1 2 x 4";
// ПЛОХО: 'x' исчез бесследно, сумма неверна, никто не узнает
let good: Vec<u32> = raw.split_whitespace().filter_map(|s| s.parse().ok()).collect();
println!("{good:?}"); // [1, 2, 4]
// ХОРОШО: первая же ошибка останавливает сбор и возвращается наружу
let all: Result<Vec<u32>, _> = raw.split_whitespace().map(str::parse::<u32>).collect();
println!("{all:?}"); // Err(ParseIntError { kind: InvalidDigit })
// Либо явно разделяем — когда частичный успех допустим
let (ok, bad): (Vec<_>, Vec<_>) =
raw.split_whitespace().map(str::parse::<u32>).partition(Result::is_ok);
println!("успешно {}, отказов {}", ok.len(), bad.len()); // успешно 3, отказов 1
То, что collect умеет собирать Result<Vec<_>, E> из итератора Result — одна из самых полезных мелочей стандартной библиотеки; подробнее про такие трюки — в «Коллекциях и итераторах».
8. ? внутри замыкания. Замыкание — отдельная функция, ? выходит из неё, а не из внешней. Тип возврата придётся указать явно:
let parse_all = |lines: &[&str]| -> Result<Vec<u32>, std::num::ParseIntError> {
lines.iter().map(|l| l.parse()).collect()
};
Блоки try {} пока нестабильны, так что закрытое замыкание — рабочий обходной путь. То же касается async — см. «Асинхронный Rust».
9. Ошибки в Drop. drop не может вернуть Result, поэтому BufWriter при уничтожении пытается сбросить буфер и молча игнорирует неудачу. Данные могут не попасть на диск, а программа отчитается об успехе:
use std::io::{BufWriter, Write};
fn save(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
let mut w = BufWriter::new(std::fs::File::create(path)?);
w.write_all(data)?;
w.flush()?; // ОБЯЗАТЕЛЬНО: иначе ошибка записи утонет в Drop
Ok(()) // альтернатива: w.into_inner()? — тоже отдаёт Result
}
Общий паттерн: если у ресурса есть «закрытие, которое может провалиться», давайте явный close()/finish(), возвращающий Result, а Drop оставляйте как страховку.
10. std::process::exit пропускает деструкторы. Буферы не сброшены, файлы не закрыты, tracing не успел записать. Из main возвращайте ExitCode, а не вызывайте exit.
11. SIGPIPE и падение в конвейере. Rust игнорирует SIGPIPE, поэтому mytool | head -3 заканчивается паникой:
thread 'main' panicked at library/std/src/io/stdio.rs:1117:9:
failed printing to stdout: Broken pipe (os error 32)
Для CLI это надо обрабатывать явно (проверять ErrorKind::BrokenPipe и завершаться тихо). Почему сигнал приходит именно так — в «Процессах и сигналах» и «Системных вызовах и IPC».
12. Добавление варианта в публичный enum — ломающее изменение. Чужой исчерпывающий match перестанет компилироваться. #[non_exhaustive] заставляет пользователей писать _ => ... заранее и сохраняет вам свободу развивать тип.
13. Ошибка как средство управления потоком. Err для «не нашлось, это нормально» превращает лог в шум и мешает отличить настоящие сбои. Нормальный исход — это Option или отдельный вариант успеха, а не ошибка.
Практика: маленький CLI целиком
Соберём всё вместе: библиотечный модуль с точным типом ошибки, приложение на anyhow, честный код выхода и понятные сообщения.
// src/config.rs — «библиотека»: перечислимый тип, никакого anyhow
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("не удалось прочитать {path}")]
Read { path: PathBuf, #[source] source: std::io::Error },
#[error("строка {line}: ожидался формат `ключ = значение`")]
Syntax { line: usize },
#[error("нет обязательного ключа `{0}`")]
Missing(&'static str),
#[error("ключ `port`: {raw:?} не похоже на номер порта")]
BadPort { raw: String, #[source] source: std::num::ParseIntError },
}
impl ConfigError {
/// Часть контракта: вызывающий может решить, стоит ли повторять попытку.
pub fn is_transient(&self) -> bool {
matches!(
self,
Self::Read { source, .. }
if matches!(source.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::TimedOut)
)
}
}
#[derive(Debug)]
pub struct Config { pub host: String, pub port: u16 }
pub fn load(path: &Path) -> Result<Config, ConfigError> {
// map_err, а не ?: только здесь мы знаем путь и обязаны его сохранить
let text = std::fs::read_to_string(path)
.map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
let mut host = None;
let mut port = None;
for (index, raw_line) in text.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line
.split_once('=')
.ok_or(ConfigError::Syntax { line: index + 1 })?;
match key.trim() {
"host" => host = Some(value.trim().to_owned()),
"port" => {
let raw = value.trim();
port = Some(raw.parse::<u16>().map_err(|source| ConfigError::BadPort {
raw: raw.to_owned(),
source,
})?);
}
// Неизвестные ключи игнорируем осознанно: конфиг должен быть
// совместим вперёд. Это решение, а не забывчивость.
_ => continue,
}
}
Ok(Config {
host: host.ok_or(ConfigError::Missing("host"))?,
port: port.ok_or(ConfigError::Missing("port"))?,
})
}
// src/main.rs — «приложение»: anyhow, контекст, код выхода
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::Context;
mod config;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
// {err:#} — вся цепочка причин в одну строку, то, что читает человек
eprintln!("srv: ошибка: {err:#}");
ExitCode::from(2)
}
}
}
fn run() -> anyhow::Result<()> {
let path: PathBuf = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/etc/srv.conf"));
// with_context, а не context: строка форматируется только на пути ошибки
let cfg = config::load(&path)
.with_context(|| format!("конфигурация {}", path.display()))?;
println!("слушаю {}:{}", cfg.host, cfg.port);
Ok(())
}
$ printf 'host = 0.0.0.0\nport = 8080\n' > srv.conf
$ cargo run -q -- srv.conf
слушаю 0.0.0.0:8080
$ cargo run -q -- нет-такого.conf; echo "код выхода: $?"
srv: ошибка: конфигурация нет-такого.conf: не удалось прочитать нет-такого.conf: No such file or directory (os error 2)
код выхода: 2
$ printf 'host = 0.0.0.0\nport = 99999\n' > bad.conf
$ cargo run -q -- bad.conf
srv: ошибка: конфигурация bad.conf: ключ `port`: "99999" не похоже на номер порта: number too large to fit in target type
Три строки сообщения — три слоя: что делали, что не получилось, почему именно. Ровно то, что нужно и пользователю, и дежурному в три ночи.
Ошибки надо тестировать так же, как счастливый путь — по варианту, а не по тексту сообщения (текст меняется, вариант — контракт):
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn отсутствие_файла_даёт_read() {
let err = load(Path::new("/нет/такого")).unwrap_err();
assert!(matches!(err, ConfigError::Read { .. }));
// и причина не потеряна
assert!(std::error::Error::source(&err).is_some());
}
#[test]
fn плохой_порт_сообщает_значение() {
let dir = std::env::temp_dir().join("cfg-test.conf");
std::fs::write(&dir, "host = h\nport = 99999\n").unwrap();
let err = load(&dir).unwrap_err();
assert!(matches!(err, ConfigError::BadPort { .. }));
}
}
Тест может и сам возвращать Result — тогда внутри работает ?. Подробнее про это, #[should_panic] и property-based проверки — в «Тестировании».
Цена и место: честно
Где подход выигрывает. Список отказов виден в сигнатуре — значит, его проверяет компилятор и читает рецензент. Нет невидимых точек выхода: между двумя строками кода не может «выстрелить» исключение из глубины стека, поэтому инварианты держатся локально. Нет неожиданных остановок на паузу GC и нет двух разных механизмов (исключения плюс коды) в одной кодовой базе. Для сервисов, где важны хвостовые задержки, и для встраиваемых систем без раскрутки стека это существенно.
Где подход проигрывает. Плата реальная, и делать вид, что её нет, бессмысленно:
- Плюмбинг. Каждый новый вид ошибки трогает несколько сигнатур и, возможно, несколько enum-ов. В языке с исключениями новый тип отказа не требует править промежуточные слои вообще.
- Нет бесплатного стек-трейса. У
Errнет истории.anyhowсRUST_BACKTRACE=1закрывает дыру для приложений, но у типизированных ошибок в библиотеке трейса нет и не будет, пока не стабилизируетсяError::provide. - Ручной контекст. «Что делали» никто не добавит за вас. Забыли — получили
os error 2без имени файла. - Semver-трение. Публичный enum ошибок — часть API.
#[non_exhaustive]помогает, но и он не бесплатен для пользователей. - Компиляция.
thiserrorиanyhowдёшевы (единицы секунд холодной сборки наsyn), но сама модель добавляет мономорфизацию и код разматывания. Про полную стоимость сборки — в «Прод на Rust».
Куда не стоит тащить. Одноразовый скрипт, разведочный анализ данных, склейка трёх API «на выброс» — там питоновский traceback и try/except дают результат быстрее, а цена ошибки близка к нулю. Rust оправдан, когда цена отказа высока или когда предсказуемость важнее скорости написания. Это тот же вывод, что и в обзоре трека, просто применённый к обработке ошибок.
Сравнение подходов (без оценок «лучше/хуже» — у каждого своя ниша):
| Язык | Механизм | Что хорошо | Чем платят |
|---|---|---|---|
| Rust | Result + ? + enum |
контракт в типе, исчерпывающий match, ноль скрытого потока |
плюмбинг, нет трейса из коробки |
| Go | error + if err != nil |
предельная простота, errors.Is/As |
нет ?, нет проверки исчерпывающести, легко потерять err |
| Elixir | {:ok, _} / {:error, _} + супервизоры |
«пусть падает»: восстановление на уровне процессов | нет статических гарантий, нужна дисциплина |
| C#/Java | исключения | нулевой плюмбинг, полный стек-трейс | невидимые точки выхода, catch (Exception) как антипаттерн |
| C | коды + errno |
нулевая стоимость | игнорирование ошибки легально и распространено |
Связь с соседними треками
Обработка ошибок в Rust — прикладная надстройка над тем, что происходит уровнем ниже. Коды возврата системных вызовов и errno, из которых рождается io::Error, разобраны в «Системных вызовах и вводе-выводе»; почему пропущенная проверка в C превращается в уязвимость — в «Неопределённом поведении». Коды выхода процесса, сигналы и SIGABRT от panic = "abort" — в «Системных вызовах и IPC» и «Процессах и сигналах». Как ошибки и паники превращаются в метрики и алерты — в «Наблюдаемости и производительности». А вопрос «сколько стоит Result на горячем пути» решается только измерением: «Честный бенчмаркинг».
Для no_std есть хорошая новость: с Rust 1.81 трейт Error живёт в core::error::Error, так что встраиваемым крейтам больше не нужно изобретать свой аналог. Про среду без стандартной библиотеки — в «unsafe и FFI» и «Экосистеме».
Мини-итог
- В Rust ошибка — значение, а список отказов — часть сигнатуры. Компилятор проверяет, что вы его не проигнорировали, а
#[must_use]не даёт выброситьResultмолча. Option— «значения нет, объяснять нечего».Result— «не получилось, и вот почему». Как только вариантов отсутствия больше одного,Optionначинает врать.?— это раннийreturnплюсFrom::fromдля ошибки. Он не добавляет контекст: «что делали» дописываете вы, черезmap_errилиwith_context.- Хороший тип ошибки:
Debug + Display + Error + Send + Sync + 'static, сsource(), перечислимый, компактный,#[non_exhaustive]. Руками это десятки строк, сthiserror— атрибуты. - Библиотека — точный enum (
thiserror), приложение —anyhow. Причина в асимметрии знания о вызывающем, а не в стиле. - Паника — для нарушенных инвариантов, а не для плохого ввода.
catch_unwind— инструмент границ (FFI, пул потоков, HTTP-слой), неtry/catch. - Читайте диагностику целиком:
E0277проFromResidualозначает «смени тип возврата»,?couldn’t convert — «добавьFromили, лучше,map_errс контекстом». - Цена подхода — плюмбинг, отсутствие бесплатного стек-трейса и ручной контекст. Выигрыш — отсутствие невидимого потока управления и контракт, который проверяет машина.
Источники
- The Rust Programming Language, глава 9 «Error Handling» — канонический вход, читать первым.
std::result,std::option,std::error::Error— документация с полным списком комбинаторов и правиламиsource().- The Rust Reference: оператор
?и RFC 3058 «try_trait_v2» — точная семантика разворачивания. - Rust API Guidelines: error types — чек-лист для публичного типа ошибки.
- Andrew Gallant, «Error Handling in Rust» — длинный разбор от автора
ripgrep; местами исторический, но объяснениеFromиBox<dyn Error>лучшее в интернете. - Nick Cameron, «Rust error handling» — современный систематический обзор подходов и крейтов.
- Jane Lusby, «Error handling Isn’t All About Errors», RustConf 2020 — доклад участницы рабочей группы по ошибкам: определения, роли,
source. - Что делает Error Handling Project Group — почему
Errorустроен именно так и куда движется. - Luca Palmieri, «Error Handling in Rust: A Deep Dive» — прод-ориентированный разбор из практики автора «Zero To Production In Rust».
- Joe Duffy, «The Error Model» — разделение «восстановимое / abandonment» на опыте Midori; фундамент, на котором стоит модель Rust.
- Документация крейтов:
thiserror,anyhow,eyre,color-eyre,miette,snafu. - Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — глава 7 целиком про ошибки.
- Jon Gjengset, «Rust for Rustaceans» — глава 4 «Error Handling»: проектирование типов ошибок для библиотек.
- Rust Compiler Error Index — и то же локально через
rustc --explain E0277.
Что дальше
Коллекции и итераторы: ленивость, адаптеры, эффективность — мы уже видели, как collect собирает Result<Vec<_>, E> из итератора результатов, и как filter_map(Result::ok) тихо съедает ошибки. Следующая глава показывает механику, из которой эти трюки следуют: почему итераторы ленивые, во что они компилируются, чем Vec отличается от VecDeque и HashMap от BTreeMap, и как не заплатить лишнюю аллокацию в цепочке из шести адаптеров.