В 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"),
}
Вместо явного 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 — второй краеугольный 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}"),
}
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");
}
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! — это не «исключение» в духе Java/Python. Паника означает, что программа обнаружила состояние, при котором дальнейшее выполнение небезопасно или бессмысленно с точки зрения инвариантов кода. По умолчанию паника разворачивает стек (unwind), вызывая деструкторы, но может быть настроена на abort (в Cargo.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 exceptions | unchecked 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 предоставляет 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(). Это устраняет ручной код из предыдущего примера почти полностью — остаётся только декларативное описание вариантов и текстов сообщений.
{0}/{1} для tuple-полей, {field} для именованных, и даже вызовы методов вроде {0:?} для Debug-форматирования.
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»), что критично для дебага в проде.
fn main() -> anyhow::Result<()> — стандартный идиом для бинарников: любая ошибка внутри пропагируется через ? вплоть до main, где Rust runtime сам напечатает Debug-представление и выйдет с кодом 1.
Это не конкурирующие, а взаимодополняющие инструменты, применяемые на разных архитектурных уровнях. Частый паттерн в реальных проектах — библиотечные крейты внутри workspace используют thiserror для точных типов ошибок, а связывающий их бинарник — anyhow, чтобы просто пробрасывать и логировать любые ошибки с контекстом.
| Критерий | thiserror | anyhow |
|---|---|---|
| Целевой код | библиотеки (crates.io, внутренние модули) | приложения, бинарники, CLI, сервисы |
| Тип ошибки | конкретный enum/struct, известный на этапе компиляции | единый type-erased anyhow::Error (обёртка над Box<dyn Error>) |
| Матчинг вызывающей стороной | да, exhaustive match по вариантам | нет напрямую (можно downcast, но это не основной путь) |
| Контекст ошибки | задаётся вручную при создании варианта | .context()/.with_context() добавляется по цепочке вызовов |
| Стоимость аллокации | обычно ноль/минимальна — enum на стеке | всегда одна heap-аллокация под Box |
| API стабильность | часть публичного контракта | сообщения — не часть контракта, могут меняться |
Оператор ? неявно вызывает 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)), когда важна гибкость на стороне вызывающего.
Err(e.into()).map_err() на каждом вызовеFrom-конвертации для варианта enumBox<dyn std::error::Error + Send + Sync>. Он теряет типизацию (нельзя exhaustively сматчиться), но подходит для простых скриптов и прототипов; для реальных библиотек предпочтительнее конкретные типы через thiserror.