Rust Обработка ошибок: Result, Option, оператор ?, свои типы ошибок
0%

Обработка ошибок: Result, Option, оператор ?, свои типы ошибок

Обработка ошибок: 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; если её вызвал ваш собственный код, нарушивший собственное обещание, — это паника. Плохой ввод от пользователя не должен ронять сервис. Пустой массив там, где инвариант структуры обещает непустой, — должен, потому что дальше вы будете портить данные.

Правая нижняя ветка — самая недооценённая. Лучший способ обработать ошибку — сделать её невыразимой: если порт хранится в 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_elseResult 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)
}

Три вывода из этого разворачивания:

  1. ? — это ранний return. Не «продолжить с пустым значением», не «залогировать». Только выход.
  2. ? вызывает From::from для ошибки. Именно поэтому одна функция может пробрасывать io::Error и ParseIntError в один и тот же тип: нужна лишь реализация From. Это ключ ко всему проектированию своих типов ошибок ниже.
  3. ? не добавляет контекста. Из 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 остаётся её причина; три способа напечатать одно и то же значение

Цепочка 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 — если хочется контекста в стиле «каждая точка отказа объявляет свой вариант».

Ошибка в живом сервисе: кто добавляет контекст и кто решает

Три правила читаются прямо с диаграммы. Контекст добавляет тот, кто знает детали (репозиторий знает таблицу, хендлер — нет). Решение о категории принимает слой, который понимает домен. А превращение ошибки в 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> — не указатель, а значение по месту: он занимает столько же, сколько больший из вариантов, и копируется при каждом ?.

Раскладка Result в байтах: тег плюс самый толстый вариант, ниша вместо тега, уменьшение через Box

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 с контекстом».
  • Цена подхода — плюмбинг, отсутствие бесплатного стек-трейса и ручной контекст. Выигрыш — отсутствие невидимого потока управления и контракт, который проверяет машина.

Источники

Что дальше

Коллекции и итераторы: ленивость, адаптеры, эффективность — мы уже видели, как collect собирает Result<Vec<_>, E> из итератора результатов, и как filter_map(Result::ok) тихо съедает ошибки. Следующая глава показывает механику, из которой эти трюки следуют: почему итераторы ленивые, во что они компилируются, чем Vec отличается от VecDeque и HashMap от BTreeMap, и как не заплатить лишнюю аллокацию в цепочке из шести адаптеров.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов