Перейти к содержимому

Свои типы ошибок в 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 ты увидишь оба крейта в деле и узнаешь, когда какой выбирать.

Объяви перечисление 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() считает байты, а не символы, так что пароль из кириллицы «удлинился» бы вдвое.

Напиши 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