Введение
Обработка ошибок в Rust — одна из наиболее отличительных особенностей языка. В отличие от исключений в других языках, Rust делает ошибки явными через систему типов, вынуждая разработчиков обрабатывать сценарии отказа. Такой подход приводит к более надёжному ПО, но требует понимания типов Result, преобразования ошибок и экосистемы crates для обработки ошибок.
Тип Result
Базовое использование Result
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
// Использование if let для простых случаев
if let Ok(result) = divide(10.0, 0.0) {
println!("Result: {}", result);
} else {
println!("Division failed");
}
}
Оператор ?
fn read_config() -> Result<Config, std::io::Error> {
let contents = std::fs::read_to_string("config.json")?; // Возвращает управление раньше при ошибке
let config: Config = serde_json::from_str(&contents)?; // Требуется реализация From
Ok(config)
}
unwrap и expect
// unwrap: паника при ошибке (избегайте в production)
let file = std::fs::read_to_string("config.json").unwrap();
// expect: паника с сообщением
let file = std::fs::read_to_string("config.json")
.expect("Failed to read config file");
// Используйте только когда:
// - Вы знаете, что ошибка невозможна
// - Ошибка указывала бы на баг
// - В примерах/прототипах
Пользовательские типы ошибок
Простые ошибки на основе enum
#[derive(Debug)]
pub enum AppError {
NotFound,
PermissionDenied,
InvalidInput(String),
DatabaseError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
AppError::NotFound => write!(f, "Resource not found"),
AppError::PermissionDenied => write!(f, "Permission denied"),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
Использование thiserror
Crate thiserror упрощает определение пользовательских ошибок:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Permission denied")]
PermissionDenied,
#[error("Invalid input: {message}")]
InvalidInput { message: String },
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("Configuration error: {0}")]
Config(#[from] config::ConfigError),
}
// Теперь можно использовать ? с автоматическим преобразованием
fn get_user(id: i32) -> Result<User, AppError> {
let user = db.query("SELECT * FROM users WHERE id = ?", id)?; // sqlx::Error -> AppError
user.ok_or_else(|| AppError::NotFound(format!("User {}", id)))
}
Использование anyhow для приложений
Для кода приложений, где вам не нужны специфические типы ошибок:
use anyhow::{Context, Result, bail, ensure};
fn process_file(path: &str) -> Result<Data> {
let contents = std::fs::read_to_string(path)
.context("Failed to read input file")?;
let data: Data = serde_json::from_str(&contents)
.context("Failed to parse JSON")?;
// Условная ошибка
ensure!(!data.items.is_empty(), "Data must have at least one item");
// Ранний возврат с ошибкой
if data.version < 2 {
bail!("Unsupported data version: {}", data.version);
}
Ok(data)
}
fn main() {
match process_file("data.json") {
Ok(data) => println!("Processed: {:?}", data),
Err(e) => {
// Вывести ошибку с цепочкой причин
eprintln!("Error: {:?}", e);
// Или только сообщение
eprintln!("Error: {}", e);
}
}
}
Преобразование ошибок
Реализация трейта From
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Parse(ParseIntError),
Negative,
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> Self {
MyError::Parse(err)
}
}
fn parse_positive(s: &str) -> Result<u32, MyError> {
let num: i32 = s.parse()?; // ParseIntError преобразован в MyError
if num < 0 {
Err(MyError::Negative)
} else {
Ok(num as u32)
}
}
Преобразование между типами ошибок
// Преобразовать тип ошибки
let result = some_operation()
.map_err(|e| AppError::Operation(e.to_string()))?;
// С контекстом с использованием anyhow
let result = some_operation()
.map_err(|e| anyhow::anyhow!("Operation failed: {}", e))?;
Option vs Result
// Option: значение может не существовать (отсутствие — не ошибка)
fn find_user(id: i32) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
// Result: операция может завершиться неудачей (отсутствие — ошибка)
fn get_user(id: i32) -> Result<User, AppError> {
users.iter()
.find(|u| u.id == id)
.cloned()
.ok_or(AppError::NotFound)
}
// Преобразование Option в Result
let user = find_user(123).ok_or(AppError::NotFound)?;
Обработка нескольких типов ошибок
Box<dyn Error>
use std::error::Error;
fn process() -> Result<(), Box<dyn Error>> {
let file = std::fs::read_to_string("config.json")?;
let num: i32 = file.trim().parse()?;
println!("Number: {}", num);
Ok(())
}
Пользовательская ошибка с source
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Failed to read configuration")]
ConfigRead(#[source] std::io::Error),
#[error("Failed to parse configuration")]
ConfigParse {
#[source]
source: serde_json::Error,
path: String,
},
}
// Доступ к source
fn handle_error(e: AppError) {
eprintln!("Error: {}", e);
if let Some(source) = e.source() {
eprintln!("Caused by: {}", source);
}
}
Паттерны обработки ошибок
Комбинаторы Result
// map: преобразовать значение Ok
let result = "42".parse::<i32>().map(|n| n * 2);
// map_err: преобразовать значение Err
let result = operation().map_err(|e| AppError::from(e));
// and_then: связать операции в цепочку
let result = "42".parse::<i32>()
.and_then(|n| {
if n > 0 { Ok(n) }
else { Err("must be positive".parse().unwrap()) }
});
// or_else: обработать ошибку и, возможно, восстановиться
let result = operation()
.or_else(|_| fallback_operation());
// unwrap_or: значение по умолчанию при ошибке
let value = "invalid".parse::<i32>().unwrap_or(0);
// unwrap_or_else: вычисляемое значение по умолчанию
let value = "invalid".parse::<i32>()
.unwrap_or_else(|_| calculate_default());
Сбор результатов
// Остановиться на первой ошибке
let results: Result<Vec<i32>, _> = strings
.iter()
.map(|s| s.parse::<i32>())
.collect();
// Собрать успешные результаты, игнорируя ошибки
let values: Vec<i32> = strings
.iter()
.filter_map(|s| s.parse().ok())
.collect();
// Разделить на успешные результаты и ошибки
let (successes, errors): (Vec<_>, Vec<_>) = strings
.iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
let values: Vec<i32> = successes.into_iter().map(Result::unwrap).collect();
Async-обработка ошибок
use anyhow::Result;
async fn fetch_data(url: &str) -> Result<Data> {
let response = reqwest::get(url)
.await
.context("Failed to fetch URL")?;
let data = response
.json::<Data>()
.await
.context("Failed to parse response")?;
Ok(data)
}
#[tokio::main]
async fn main() -> Result<()> {
let data = fetch_data("https://api.example.com/data").await?;
println!("{:?}", data);
Ok(())
}
Логирование ошибок
use tracing::{error, warn, info};
fn process_request(req: Request) -> Result<Response, AppError> {
let user = get_user(req.user_id).map_err(|e| {
error!(user_id = req.user_id, error = ?e, "Failed to get user");
e
})?;
// Или с использованием anyhow и tracing
let data = fetch_data(&req.url)
.inspect_err(|e| error!(url = %req.url, error = ?e, "Fetch failed"))?;
Ok(Response::new(data))
}
Лучшие практики
Ошибки библиотек vs приложений
Библиотеки: используйте специфические типы ошибок с thiserror.
// Хорошо для библиотек — вызывающие могут сопоставлять варианты
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Invalid format at position {0}")]
InvalidFormat(usize),
#[error("Unexpected end of input")]
UnexpectedEof,
}
Приложения: используйте anyhow для удобства.
// Хорошо для приложений — легко добавлять контекст
fn main() -> anyhow::Result<()> {
let config = load_config().context("Failed to load configuration")?;
run_server(config).context("Server error")?;
Ok(())
}
Не паникуйте в библиотеках
// Плохо: паника при некорректном вводе
pub fn process(data: &str) -> Output {
let parsed = data.parse().unwrap(); // Не делайте так
// ...
}
// Хорошо: возвращайте Result
pub fn process(data: &str) -> Result<Output, ParseError> {
let parsed = data.parse()?;
// ...
}
Добавляйте контекст к ошибкам
// Плохо: просто пробрасывать дальше
let file = std::fs::read_to_string(path)?;
// Хорошо: добавляйте контекст
let file = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path))?;
Продвинутое управление памятью и ошибки
Понимание того, как ошибки взаимодействуют с моделью владения Rust, критически важно для senior-инженеров.
Времена жизни в типах ошибок
Когда типы ошибок хранят ссылки, необходимо аннотировать времена жизни:
#[derive(Error, Debug)]
pub enum ParseError<'a> {
#[error("Invalid token at: {0}")]
InvalidToken(&'a str), // Reference to input string
#[error("Unexpected end of input")]
UnexpectedEof,
}
fn parse<'a>(input: &'a str) -> Result<Data, ParseError<'a>> {
// Возвращаемая ошибка может ссылаться на входные данные
if input.is_empty() {
return Err(ParseError::UnexpectedEof);
}
// ...
}
Interior mutability при работе с ошибками
Иногда нужно накапливать ошибки, сохраняя неизменяемую ссылку:
use std::cell::RefCell;
struct Validator {
errors: RefCell<Vec<String>>,
}
impl Validator {
fn validate(&self, data: &Data) {
// У нас есть &self (неизменяемая), но мы можем добавлять ошибки
if data.name.is_empty() {
self.errors.borrow_mut().push("Name is required".to_string());
}
}
fn into_result(self) -> Result<(), Vec<String>> {
let errors = self.errors.into_inner();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
Умные указатели в обработке ошибок
Для ошибок, которыми нужно делиться между потоками:
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct ErrorCollector {
errors: Arc<Mutex<Vec<AppError>>>,
}
impl ErrorCollector {
fn add_error(&self, error: AppError) {
let mut errors = self.errors.lock().unwrap();
errors.push(error);
}
fn has_errors(&self) -> bool {
let errors = self.errors.lock().unwrap();
!errors.is_empty()
}
}
// Безопасно разделять между потоками
async fn process_batch(collector: ErrorCollector, items: Vec<Item>) {
let handles: Vec<_> = items.into_iter().map(|item| {
let collector = collector.clone();
tokio::spawn(async move {
if let Err(e) = process_item(&item).await {
collector.add_error(e);
}
})
}).collect();
futures::future::join_all(handles).await;
}
Borrow checker и распространение ошибок
Распространённая ошибка — попытка вернуть ссылки на локальные данные в ошибках:
// ПЛОХО: не скомпилируется — возврат ссылки на локальную переменную
fn validate_bad(input: String) -> Result<Data, &str> {
if input.is_empty() {
return Err("Input is empty"); // Этот &str живёт дольше, чем input
}
// ...
}
// ХОРОШО: владейте данными ошибки
fn validate_good(input: String) -> Result<Data, String> {
if input.is_empty() {
return Err("Input is empty".to_string()); // Владеемая String
}
// ...
}
Заключение
Обработка ошибок в Rust обеспечивает явную обработку сценариев отказа, что приводит к более надёжному коду. Используйте thiserror для типов ошибок библиотек и anyhow для кода приложений. Всегда добавляйте контекст к ошибкам и предпочитайте Result паникам. Понимание того, как ошибки взаимодействуют с временами жизни, владением и умными указателями, необходимо для написания Rust-кода уровня senior. Паттерны из этого руководства помогут вам писать код обработки ошибок, который одновременно безопасен и удобен.