← Назад к списку тем

06. Обработка ошибок

Option, Result, оператор ?, panic! vs Result, кастомные ошибки, thiserror, anyhow, From/Into для ошибок.

Option<T> — отсутствие значения без null

В Rust нет null. Отсутствие значения выражается типом Option<T> — enum с двумя вариантами. Это заставляет компилятор проверять оба случая на этапе компиляции, а не в рантайме, как NullPointerException в Java или None в Python.

enum Option<T> {
    Some(T),
    None,
}

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

let user = find_user(1);
match user {
    Some(name) => println!("Found: {name}"),
    None => println!("Not found"),
}

Комбинаторы Option

Вместо явного match в цепочках трансформаций часто удобнее использовать комбинаторы — они делают код декларативным и избегают вложенных проверок.

let age: Option<u32> = Some(30);

// map — трансформирует Some, не трогает None
let doubled = age.map(|a| a * 2); // Some(60)

// and_then — как map, но замыкание само возвращает Option (flat_map)
let checked = age.and_then(|a| if a >= 18 { Some(a) } else { None });

// unwrap_or / unwrap_or_else / unwrap_or_default — значение по умолчанию
let value = age.unwrap_or(0);
let lazy = age.unwrap_or_else(|| expensive_default());
let def: u32 = None.unwrap_or_default(); // 0

// ok_or — превращает Option в Result
let res: Result<u32, &str> = age.ok_or("age missing");

// as_ref — Option<T> -> Option<&T> без потребления значения
let name: Option<String> = Some(String::from("x"));
let len: Option<usize> = name.as_ref().map(|s| s.len());

// if let — короткая форма match для одного варианта
if let Some(a) = age {
    println!("Age is {a}");
}
🔑 Ключевая идея: Option<T> и Result<T, E> — это обычные enum'ы без магии рантайма. Компилятор через #[must_use] и exhaustive matching не даёт «забыть» обработать отсутствие значения или ошибку — в отличие от null-ссылок в Java/C# или неявных None в Python, где забытая проверка всплывает только в рантайме.

Result<T, E> — ошибки как значения

Result — второй краеугольный enum для обработки ошибок. В отличие от исключений (Java throws, Python try/except, C++ throw), ошибка в Rust — часть сигнатуры функции и часть типа возвращаемого значения. Это называется error handling as values, похожий подход — в Go (if err != nil), но с типобезопасностью enum и невозможностью случайно проигнорировать ошибку без явного действия.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        return Err(String::from("division by zero"));
    }
    Ok(a / b)
}

match divide(10.0, 0.0) {
    Ok(v) => println!("Result: {v}"),
    Err(e) => eprintln!("Error: {e}"),
}

Комбинаторы Result

let r: Result<i32, String> = Ok(5);

// map / map_err — трансформация Ok и Err отдельно
let doubled = r.map(|v| v * 2);
let wrapped: Result<i32, std::io::Error> =
    r.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));

// and_then — цепочка операций, каждая может завершиться ошибкой
fn parse_and_double(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse::<i32>().and_then(|n| Ok(n * 2))
}

// unwrap_or / unwrap_or_else — значение по умолчанию вместо паники
let v = r.unwrap_or(-1);

// ok() / err() — конвертация в Option, отбрасывая другую сторону
let maybe: Option<i32> = r.ok();

// is_ok / is_err — проверка варианта без деструктуризации
if r.is_err() {
    eprintln!("something failed");
}
⚠️ #[must_use]: Тип Result помечен атрибутом #[must_use]: если результат вызова функции, возвращающей Result, никак не используется — компилятор выдаёт предупреждение. Игнорировать ошибку всё же можно (let _ = ...), но это осознанное, видимое в коде решение — а не молчаливо забытый catch.

Оператор ? — пропагирование ошибок

Оператор ? — синтаксический сахар для ранних возвратов при ошибке. Он раскрывается примерно в такой match: если значение Err(e) — функция немедленно возвращает Err(From::from(e)), если Ok(v) — выражение вычисляется в v. Аналогично работает и для Option (тогда пробрасывается None).

use std::fs::File;
use std::io::{self, Read};

fn read_file(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?;   // вернёт Err досрочно, если open провалился
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

// Псевдо-раскрытие оператора ?:
fn read_file_desugared(path: &str) -> Result<String, io::Error> {
    let mut file = match File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(From::from(e)),
    };
    // ...
    Ok(String::new())
}

? вызывает From::from для конвертации типа ошибки, поэтому функция может пропагировать ошибки разных источников (I/O, парсинг, сеть) в единый тип, если для него реализован impl From<SourceError> for MyError — подробнее в разделе про From/Into ниже.

✅ Практика: ? работает только внутри функции, возвращающей Result или Option (либо тип, реализующий std::ops::Try — начиная с nightly-фич). В fn main() тоже можно использовать ?, если поменять сигнатуру на fn main() -> Result<(), Box<dyn std::error::Error>>.

panic! vs Result — когда паниковать, а когда возвращать ошибку

panic! — это не «исключение» в духе Java/Python. Паника означает, что программа обнаружила состояние, при котором дальнейшее выполнение небезопасно или бессмысленно с точки зрения инвариантов кода. По умолчанию паника разворачивает стек (unwind), вызывая деструкторы, но может быть настроена на abortCargo.toml: panic = "abort") — тогда процесс завершается немедленно, без unwind, что даёт меньший размер бинарника и чуть более быстрый path ошибки, но убирает возможность поймать панику через catch_unwind.

fn get_element(v: &Vec<i32>, idx: usize) -> i32 {
    v[idx] // panic при выходе за границы — bug программиста, не "ожидаемая" ошибка
}

fn get_element_safe(v: &Vec<i32>, idx: usize) -> Option<&i32> {
    v.get(idx) // ожидаемый "не найдено" случай -> Option
}

// unwrap / expect — явная паника при Err/None
let n: i32 = "42".parse().unwrap();
let m: i32 = "42".parse().expect("config value must be a valid integer");

Правило большого пальца: Result — для ожидаемых, восстановимых сбоев (файл не найден, сеть недоступна, невалидный ввод пользователя). panic! — для нарушения инвариантов программы (баг, недостижимое состояние, повреждённые внутренние данные), когда продолжать работу опаснее, чем упасть.

КритерийResult<T, E>panic!
Природа ошибкиожидаемая, часть нормального потока (файл не найден, сетевой таймаут)нарушение инварианта, баг, недостижимое состояние
Кто обрабатываетвызывающий код решает, что делатьникто — процесс/поток аварийно завершается
Восстановлениеда, через match / ? / retryнет (кроме catch_unwind на границе FFI/потоков)
Стоимостьявная в сигнатуре, влияет на API«невидима» в сигнатуре, скрытая стоимость
Типичное применениепарсинг ввода, I/O, сеть, бизнес-валидацияdebug_assert!, недостижимые match-ветки, нарушенные invariants в unsafe-коде
Аналог в других языкахGo error values, checked exceptionsunchecked exception / abort / segfault-класс ошибок
Библиотечный кодпредпочтительно почти всегдаизбегать — паника библиотеки ломает вызывающее приложение
🚫 Не паникуй в библиотечном коде: Библиотека, вызывающая panic!/unwrap() на невалидном вводе, отбирает у пользователя библиотеки возможность обработать ошибку. Исключение — явные программистские контракты (например, debug_assert! для инвариантов, которые не должны нарушаться при корректном использовании API).

Кастомные типы ошибок

Идиоматичный способ описать ошибки домена — enum, реализующий трейты std::fmt::Display (человекочитаемое сообщение) и std::error::Error (интеграция с экосистемой: source() для цепочки причин, работа с Box<dyn Error>).

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    MissingField(String),
    InvalidValue { field: String, reason: String },
    Io(std::io::Error),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::MissingField(name) =>
                write!(f, "missing required field: {name}"),
            ConfigError::InvalidValue { field, reason } =>
                write!(f, "invalid value for {field}: {reason}"),
            ConfigError::Io(e) => write!(f, "I/O error: {e}"),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(e) => Some(e),
            _ => None,
        }
    }
}

Такой enum вручную писать многословно: три трейта (Debug, Display, Error) плюс конвертации From для каждого источника ошибки. На практике этот шаблонный код генерируют дерайв-макросы из крейта thiserror.

thiserror — библиотечные ошибки без шаблонного кода

thiserror предоставляет derive-макрос #[derive(Error)], который генерирует реализации Display и std::error::Error из атрибутов на enum. Это стандартный выбор для библиотечного кода: потребитель библиотеки получает конкретный, типизированный enum ошибок, по которому можно сматчиться.

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("missing required field: {0}")]
    MissingField(String),

    #[error("invalid value for {field}: {reason}")]
    InvalidValue { field: String, reason: String },

    #[error("I/O error")]
    Io(#[from] std::io::Error), // #[from] генерирует impl From<io::Error> for ConfigError

    #[error("parse error")]
    Parse(#[from] std::num::ParseIntError),
}

fn load_port(raw: &str) -> Result<u16, ConfigError> {
    let port: u16 = raw.parse()?; // ParseIntError -> ConfigError через #[from]
    Ok(port)
}

Атрибут #[from] одновременно генерирует impl From<SourceError> for MyError и делает поле доступным через source(). Это устраняет ручной код из предыдущего примера почти полностью — остаётся только декларативное описание вариантов и текстов сообщений.

🔑 #[error(...)] поддерживает интерполяцию: {0}/{1} для tuple-полей, {field} для именованных, и даже вызовы методов вроде {0:?} для Debug-форматирования.

anyhow — ошибки уровня приложения

anyhow решает другую задачу: когда пишется приложение (бинарник, CLI, сервис), а не библиотека, зачастую неважно, ошибка какого именно конкретного типа произошла — важно её залогировать, добавить контекст и завершить операцию. anyhow::Error — это ошибка-контейнер, в которую конвертируется любой тип, реализующий std::error::Error.

use anyhow::{Context, Result, bail, anyhow};

fn load_config(path: &str) -> Result<Config> { // Result<T> = Result<T, anyhow::Error>
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config at {path}"))?;

    let config: Config = toml::from_str(&raw)
        .context("failed to parse config as TOML")?;

    if config.port == 0 {
        bail!("port must not be zero"); // ранний return с anyhow::Error
    }

    if config.workers > 1024 {
        return Err(anyhow!("too many workers: {}", config.workers));
    }

    Ok(config)
}

fn main() -> Result<()> {
    let config = load_config("app.toml")?;
    println!("{config:?}");
    Ok(())
}

.context() и .with_context() оборачивают ошибку дополнительным сообщением, сохраняя исходную как source() — при печати через {:?} anyhow выводит всю цепочку причин («caused by»), что критично для дебага в проде.

✅ anyhow в main: fn main() -> anyhow::Result<()> — стандартный идиом для бинарников: любая ошибка внутри пропагируется через ? вплоть до main, где Rust runtime сам напечатает Debug-представление и выйдет с кодом 1.

thiserror vs anyhow — библиотека или приложение

Это не конкурирующие, а взаимодополняющие инструменты, применяемые на разных архитектурных уровнях. Частый паттерн в реальных проектах — библиотечные крейты внутри workspace используют thiserror для точных типов ошибок, а связывающий их бинарник — anyhow, чтобы просто пробрасывать и логировать любые ошибки с контекстом.

Критерийthiserroranyhow
Целевой кодбиблиотеки (crates.io, внутренние модули)приложения, бинарники, CLI, сервисы
Тип ошибкиконкретный enum/struct, известный на этапе компиляцииединый type-erased anyhow::Error (обёртка над Box<dyn Error>)
Матчинг вызывающей сторонойда, exhaustive match по вариантамнет напрямую (можно downcast, но это не основной путь)
Контекст ошибкизадаётся вручную при создании варианта.context()/.with_context() добавляется по цепочке вызовов
Стоимость аллокацииобычно ноль/минимальна — enum на стекевсегда одна heap-аллокация под Box
API стабильностьчасть публичного контрактасообщения — не часть контракта, могут меняться

From / Into для конвертации ошибок

Оператор ? неявно вызывает From::from для конвертации ошибки функции-источника в тип ошибки, объявленный в сигнатуре текущей функции. Именно поэтому в цепочке вызовов можно свободно пропагировать разнородные ошибки (I/O, парсинг, сериализация) в единый доменный тип — если для каждой реализован From.

use std::fmt;

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "io: {e}"),
            AppError::Parse(e) => write!(f, "parse: {e}"),
        }
    }
}
impl std::error::Error for AppError {}

// Ключевые конвертации — благодаря им заработает ?
impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::Io(e)
    }
}
impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let text = std::fs::read_to_string(path)?; // io::Error -> AppError
    let n: i32 = text.trim().parse()?;      // ParseIntError -> AppError
    Ok(n)
}

Into — зеркальный трейт: реализация From<A> for B автоматически даёт Into<B> for A. На практике почти всегда реализуют From, а Into используют в trait bounds (fn f<E: Into<AppError>>(e: E)), когда важна гибкость на стороне вызывающего.

  • From::from — явная, направленная конвертация «из одного типа в другой»
  • Into::into — обратная сторона той же реализации, полезна в generic-коде и в конце выражений: Err(e.into())
  • ? + From — механизм, позволяющий единообразно пропагировать разнородные ошибки без ручных .map_err() на каждом вызове
  • #[from] в thiserror — автогенерация именно этой связки From-конвертации для варианта enum
⚠️ Box<dyn Error> как компромисс: Если не хочется заводить кастомный enum и подключать thiserror, но нужен единый тип ошибки — можно использовать Box<dyn std::error::Error + Send + Sync>. Он теряет типизацию (нельзя exhaustively сматчиться), но подходит для простых скриптов и прототипов; для реальных библиотек предпочтительнее конкретные типы через thiserror.