Свои типы ошибок в Rust
На странице Result каждая ошибка была просто String. Для маленьких примеров это нормально, но всё ломается, как только функция может завершиться неудачей несколькими разными способами. По String нельзя сделать match, поэтому вызывающий код не может понять, какая именно неудача случилась, не разбирая сам текст.
Чем плохи ошибки-строки
Заголовок раздела «Чем плохи ошибки-строки»fn withdraw(balance: f64, amount: f64) -> Result<f64, String> { if amount > balance { return Err(String::from("недостаточно средств")); } if amount < 0.0 { return Err(String::from("сумма не может быть отрицательной")); } Ok(balance - amount)}Если вызывающий код хочет по-разному реагировать на «недостаточно средств» и «отрицательную сумму», ему остаётся только сравнивать саму строку ошибки. Это хрупко и ломается, как только ты переформулируешь сообщение.
Ошибка как перечисление
Заголовок раздела «Ошибка как перечисление»Это исправляет перечисление: каждый вид неудачи становится отдельным вариантом, по которому вызывающий код может сразу сделать match:
enum WithdrawError { InsufficientFunds, NegativeAmount,}
fn withdraw(balance: f64, amount: f64) -> Result<f64, WithdrawError> { if amount < 0.0 { return Err(WithdrawError::NegativeAmount); } if amount > balance { return Err(WithdrawError::InsufficientFunds); } Ok(balance - amount)}
fn main() { match withdraw(100.0, 150.0) { Ok(remaining) => println!("Новый баланс: {remaining}"), Err(WithdrawError::InsufficientFunds) => println!("Не хватает денег"), Err(WithdrawError::NegativeAmount) => println!("Нельзя снять отрицательную сумму"), }}Вывод:
Не хватает денегТеперь компилятор следит, чтобы был обработан каждый вид неудачи. Это та же гарантия, которую match всегда давал с перечислениями, только применённая к ошибкам.
Реализуем Display для нормального сообщения об ошибке
Заголовок раздела «Реализуем Display для нормального сообщения об ошибке»Попробуй сейчас вывести WithdrawError через {} — код не скомпилируется: Rust не знает, как превратить его в текст. Это исправляет реализация трейта Display. Трейтам скоро будут посвящены свои страницы (Трейты и Популярные трейты). Пока что можно просто скопировать этот шаблон:
use std::fmt;
enum WithdrawError { InsufficientFunds, NegativeAmount,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InsufficientFunds => write!(f, "недостаточно средств"), WithdrawError::NegativeAmount => write!(f, "сумма не может быть отрицательной"), } }}
fn main() { let error = WithdrawError::InsufficientFunds; println!("Ошибка: {error}");}Вывод:
Ошибка: недостаточно средствТеперь WithdrawError ведёт себя как любая другая ошибка, которую хочется показать пользователю или записать в журнал. И при этом по ней по-прежнему можно сделать match там, где коду нужно отреагировать на конкретный случай.
Когда может помочь крейт
Заголовок раздела «Когда может помочь крейт»Писать вручную Display и весь шаблонный код вокруг своего типа ошибки в настоящем проекте быстро надоедает. Крейт thiserror (на английском) генерирует большую часть этого за тебя через #[derive], а anyhow (на английском) популярен в коде приложений, которым нужно просто передавать ошибки дальше, не объявляя тип для каждой. Знать о них стоит, но для изучения основ они не нужны. Когда будешь готов, на странице anyhow и thiserror ты увидишь оба крейта в деле и узнаешь, когда какой выбирать.
Практика
Заголовок раздела «Практика»1. Ошибки входа
Заголовок раздела «1. Ошибки входа»Объяви перечисление LoginError с двумя вариантами, EmptyUsername и PasswordTooShort, и реализуй для него Display. Затем напиши fn check_login(user: &str, password: &str) -> Result<String, LoginError>, которая возвращает Ok с приветствием или подходящую ошибку. Пароль должен быть не короче 8 символов. Подсказка: для проверок хватит .is_empty() и .len() у &str.
Показать решение
use std::fmt;
enum LoginError { EmptyUsername, PasswordTooShort,}
impl fmt::Display for LoginError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { LoginError::EmptyUsername => write!(f, "имя пользователя не может быть пустым"), LoginError::PasswordTooShort => write!(f, "пароль должен быть не короче 8 символов"), } }}
fn check_login(user: &str, password: &str) -> Result<String, LoginError> { if user.is_empty() { return Err(LoginError::EmptyUsername); } if password.len() < 8 { return Err(LoginError::PasswordTooShort); } Ok(format!("Добро пожаловать, {user}!"))}
fn main() { let attempts = [("ana", "correct-horse"), ("", "secret123"), ("ben", "short")];
for (user, password) in attempts { match check_login(user, password) { Ok(message) => println!("{message}"), Err(e) => println!("Ошибка: {e}"), } }}Вывод:
Добро пожаловать, ana!Ошибка: имя пользователя не может быть пустымОшибка: пароль должен быть не короче 8 символовЛогины и пароли здесь латиницей не случайно: .len() считает байты, а не символы, так что пароль из кириллицы «удлинился» бы вдвое.
2. Ошибки с подробностями
Заголовок раздела «2. Ошибки с подробностями»Напиши fn parse_percent(input: &str) -> Result<u32, PercentError>, где у PercentError два варианта с данными: NotANumber(String) (неправильный текст) и TooLarge(u32) (число больше 100). Пусть сообщения Display содержат эти подробности. Подсказка: варианты перечисления могут хранить значения, как на странице Перечисления (enum). В fmt сопоставляй с PercentError::TooLarge(n) => ..., чтобы достать значение.
Показать решение
use std::fmt;
enum PercentError { NotANumber(String), TooLarge(u32),}
impl fmt::Display for PercentError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { PercentError::NotANumber(text) => write!(f, "'{text}' — не число"), PercentError::TooLarge(n) => write!(f, "{n} больше 100"), } }}
fn parse_percent(input: &str) -> Result<u32, PercentError> { match input.parse::<u32>() { Ok(n) => { if n > 100 { Err(PercentError::TooLarge(n)) } else { Ok(n) } } Err(_) => Err(PercentError::NotANumber(input.to_string())), }}
fn main() { for input in ["75", "половина", "250"] { match parse_percent(input) { Ok(n) => println!("{n}%"), Err(e) => println!("Ошибка: {e}"), } }}Вывод:
75%Ошибка: 'половина' — не числоОшибка: 250 больше 100- Ошибка-
Stringподходит для маленьких примеров, но вызывающий код не может различать такие ошибки черезmatch. - Объяви перечисление с вариантом для каждого вида неудачи, чтобы вызывающий код мог сделать
matchпо конкретной ошибке. - Реализуй
Display(impl fmt::Display for YourError), чтобы тип ошибки можно было вывести через{}. - В больших проектах крейты вроде
thiserrorиanyhowубирают большую часть шаблонного кода.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Почему ошибки типа String перестают устраивать, как только функция может завершиться неудачей несколькими способами?
2. Что позволяет коду, вызывающему withdraw, по-разному реагировать на InsufficientFunds и NegativeAmount?
3. Что даёт реализация Display для своего типа ошибки?
4. С чем помогают крейты вроде thiserror и anyhow?
5. Почему сравнивать сырые строки сообщений об ошибках считается хрупким подходом?
Результат: 0 / 5
Что почитать
Заголовок раздела «Что почитать»- Объявление типа ошибки — Rust на примерах: короткий запускаемый пример своего типа ошибки с
Display(перевод на русский). - Оборачивание ошибок — Rust на примерах: следующий шаг — перечисление ошибок, которое оборачивает другие ошибки и работает с
?черезFrom(перевод на русский). std::error::Error— документация std (на английском): стандартный трейт, который реализуют «настоящие» типы ошибок, и зачем нужен его методsource().- panic! или не panic! — книга «Язык программирования Rust»: рекомендации, когда возвращать ошибку, а когда останавливать программу (перевод на русский).
- thiserror — docs.rs (на английском): крейт, который выводит
DisplayиErrorдля твоих перечислений ошибок, так что кода приходится писать гораздо меньше.