Обработка ошибок в Rust: отказ от try/catch в пользу безопасности
В отличие от Python или Java, в Rust нет классической конструкции try/catch. Разработчики языка пошли другим путём, сделав обработку ошибок частью системы типов. Это не прихоть, а фундаментальное решение, которое позволяет гарантировать безопасность памяти и предсказуемость работы программы на этапе компиляции. В этой статье мы разберём все способы обработки ошибок в Rust: от базовых Result и Option до продвинутых методов с оператором ? и пользовательскими типами ошибок.
1. Два главных типа: Result и Option
В Rust все ошибки делятся на два лагеря: восстанавливаемые (обрабатываются через Result) и невосстанавливаемые (приводят к panic!). Также существует тип Option для случаев, когда значение может отсутствовать.
Тип Result
Result<T, E> — это перечисление (enum), которое может быть либо Ok(T) (успех с данными), либо Err(E) (ошибка с описанием). Это основной инструмент для обработки ожидаемых ошибок: ошибки ввода-вывода, парсинга, сетевых запросов.
use std::fs::File;
use std::io::Read;
fn read_file_content(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
Тип Option
Option<T> — ещё один enum, который принимает значения Some(T) (значение есть) или None (значения нет). Используется, когда отсутствие данных — нормальная ситуация, а не ошибка.
fn find_user(id: u32) -> Option<String> {
let users = vec!["Alice".to_string(), "Bob".to_string()];
users.get(id as usize).cloned()
}
fn main() {
match find_user(0) {
Some(name) => println!("Найден пользователь: {}", name),
None => println!("Пользователь не найден"),
}
}
2. Оператор ? — элегантная замена try/catch
Если бы в Rust был try/catch, он выглядел бы громоздко. Вместо этого язык предлагает оператор ?. Он работает так: если результат — Ok, значение извлекается и передаётся дальше; если Err — ошибка немедленно возвращается из функции.
use std::num::ParseIntError;
fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
let value: i32 = input.parse()?; // если ошибка — сразу return Err
Ok(value * 2)
}
fn main() {
match parse_and_double("42") {
Ok(result) => println!("Результат: {}", result),
Err(e) => println!("Ошибка парсинга: {}", e),
}
}
Оператор ? можно использовать только в функциях, которые возвращают Result или Option. Это вынуждает разработчика явно указывать, что функция может завершиться ошибкой — в отличие от исключений в Python, которые могут «вылететь» из любого места.
3. Когда использовать panic! и unwrap
Не все ошибки нужно обрабатывать. panic! — это макрос для невосстанавливаемых ошибок. Он останавливает программу и разворачивает стек (stack unwind). Используйте его только в исключительных ситуациях:
- Аварийные состояния: нарушение инвариантов, которые невозможно исправить.
- Примеры и прототипы: в учебном коде или однодневных скриптах.
- Тесты: для проверки того, что код падает в ожидаемых местах.
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Деление на ноль! a={}, b={}", a, b);
}
a / b
}
unwrap() и expect() — это методы, которые вызывают panic! при ошибке. Разница: expect позволяет добавить сообщение.
let number = "42".parse::<i32>().unwrap(); // просто паника
let number = "42".parse::<i32>().expect("Не число!"); // паника с пояснением
Правило большого пальца: в production-коде используйте unwrap только если вы на 100% уверены, что ошибки не будет. Во всех остальных случаях — обрабатывайте через match или оператор ?.
4. Создание собственных типов ошибок
Когда ваша программа становится сложнее, одного std::io::Error недостаточно. Rust позволяет создавать свои типы ошибок с помощью перечислений (enum) и библиотеки thiserror (для крейтов) или anyhow (для приложений).
Пример с thiserror
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Ошибка ввода-вывода: {0}")]
Io(#[from] std::io::Error),
#[error("Ошибка парсинга: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Некорректные данные: {message}")]
InvalidData { message: String },
}
fn process_data(path: &str) -> Result<i32, AppError> {
let content = std::fs::read_to_string(path)?;
let number: i32 = content.trim().parse()?;
if number < 0 {
return Err(AppError::InvalidData {
message: "Число не может быть отрицательным".into(),
});
}
Ok(number)
}
Пример с anyhow
Для простых приложений, где не важна конкретная ошибка, используйте anyhow:
use anyhow::{Result, Context};
fn read_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("Не удалось прочитать конфиг: {}", path))
}
Заключение: философия Rust в обработке ошибок
Обработка ошибок в Rust — не просто техническая деталь, а часть