Обработка ошибок в Rust с anyhow и thiserror
На страницах Result и Свои типы ошибок ты писал типы ошибок вручную: перечисление, impl Display, а иногда и impl From, чтобы работал ?. Учиться правильно именно так. Но в настоящем проекте один и тот же шаблонный код приходится писать снова и снова.
Два небольших крейта одного автора избавляют от этого шаблонного кода. Они решают две разные задачи:
thiserrorпомогает объявлять типы ошибок. Перечисление ты по-прежнему пишешь сам, ноDisplay,ErrorиFromза тебя пишет#[derive].anyhowпомогает передавать ошибки дальше. Он даёт один тип ошибки, который может хранить любую ошибку, и простые способы добавить сообщение вроде «при загрузке файла настроек».
Библиотеки и приложения
Заголовок раздела «Библиотеки и приложения»Что выбрать, зависит от того, кто обрабатывает ошибку.
Библиотека — это код, который вызывают другие люди. Им может понадобиться по-разному реагировать на каждую неудачу: повторить запрос при тайм-ауте, показать ошибку в форме при неправильном вводе. Поэтому библиотека должна возвращать точное перечисление, по которому вызывающий код может сделать match. Это thiserror.
Приложение (твой main.rs) обычно последняя остановка. Большинство ошибок в итоге превращаются в сообщение для человека и, может быть, код выхода. По ним редко делают match: нужно, чтобы их было легко передать наверх через ? и легко прочитать. Это anyhow.
Хорошее правило: thiserror — для кода, который вызывают другие, anyhow — для верхнего уровня программы. Многие проекты используют оба, как ты увидишь в конце этой страницы.
Подготовка
Заголовок раздела «Подготовка»cargo add thiserror anyhow[dependencies]anyhow = "1"thiserror = "2"Cargo.toml тебе пока незнаком? Загляни на страницу Cargo и крейты. Оба крейта есть в Rust Playground, так что все кнопки «Попробуй сам» на этой странице работают.
thiserror: выводим Error и пишем сообщения
Заголовок раздела «thiserror: выводим Error и пишем сообщения»Поставь на перечисление #[derive(Debug, Error)], а каждому варианту дай сообщение #[error("...")]. Это сообщение станет текстом Display для варианта. Внутри него {0} значит первое поле кортежа, а {balance} — именованное поле:
use thiserror::Error;
#[derive(Debug, Error)]enum BankError { #[error("счёт `{0}` не найден")] NotFound(String), #[error("недостаточно средств: на балансе {balance}, попытка снять {amount}")] InsufficientFunds { balance: u32, amount: u32 }, #[error("сумма должна быть больше нуля")] ZeroAmount,}
fn main() { let errors = vec![ BankError::NotFound(String::from("alice")), BankError::InsufficientFunds { balance: 50, amount: 80 }, BankError::ZeroAmount, ];
for e in &errors { println!("{e}"); } println!("{:?}", errors[1]);}Вывод:
счёт `alice` не найденнедостаточно средств: на балансе 50, попытка снять 80сумма должна быть больше нуляInsufficientFunds { balance: 50, amount: 80 }Это ровно то перечисление со страницы Свои типы ошибок, только весь блок impl Display исчез. Во время работы ничего не прячется: thiserror лишь пишет код, который ты написал бы вручную. Вызывающий код видит обычное перечисление.
thiserror: #[from] и #[source]
Заголовок раздела «thiserror: #[from] и #[source]»У ошибок часто есть причина: «порт неверный», потому что «в строке встретилась недопустимая цифра». У стандартного трейта Error для этого есть метод .source(). thiserror заполняет его за тебя.
#[from] делает две вещи. Он отмечает поле как источник (source) и пишет impl From, так что ? автоматически превращает внутреннюю ошибку в твой вариант:
use std::error::Error; // подключает метод .source()use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]enum ConfigError { #[error("нет `=` в строке {0:?}")] NoEquals(String), #[error("порт — не корректное число")] BadPort(#[from] ParseIntError),}
fn parse_port(line: &str) -> Result<u16, ConfigError> { let (_, value) = line .split_once('=') .ok_or_else(|| ConfigError::NoEquals(line.to_string()))?; let port = value.trim().parse::<u16>()?; // ParseIntError превращается в ConfigError::BadPort Ok(port)}
fn main() { for line in ["port = 8080", "port 8080", "port = восемьдесят"] { match parse_port(line) { Ok(port) => println!("готово: {port}"), Err(e) => { println!("ошибка: {e}"); if let Some(cause) = e.source() { println!(" причина: {cause}"); } } } }}Вывод:
готово: 8080ошибка: нет `=` в строке "port 8080"ошибка: порт — не корректное число причина: invalid digit found in string«invalid digit found in string» — это сообщение самой стандартной библиотеки («в строке найдена недопустимая цифра»), поэтому оно на английском.
Иногда нужен источник, но не автоматический From, потому что нужно добавить дополнительные данные, например номер строки. Тогда поставь на поле #[source] и собери ошибку сам через .map_err(). (Поле, которое просто называется source, считается источником автоматически.) А ещё thiserror работает не только с перечислениями, но и со структурами:
use std::error::Error;use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]#[error("в строке {line} нет числа")]struct LineError { line: usize, #[source] cause: ParseIntError,}
fn sum_lines(text: &str) -> Result<i32, LineError> { let mut total = 0; for (i, row) in text.lines().enumerate() { let n: i32 = row .trim() .parse() .map_err(|cause| LineError { line: i + 1, cause })?; total += n; } Ok(total)}
fn main() { println!("сумма: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nдвадцать\n30").unwrap_err(); println!("{err}"); println!("причина: {}", err.source().unwrap());}Вывод:
сумма: 60в строке 2 нет числапричина: invalid digit found in stringanyhow: один тип Result на всё
Заголовок раздела «anyhow: один тип Result на всё»anyhow::Result<T> — это сокращение для Result<T, anyhow::Error>. anyhow::Error может хранить любой тип ошибки, который реализует стандартный трейт Error. Поэтому ? работает со всеми ними, даже когда одна функция сталкивается с несколькими разными типами ошибок:
use anyhow::Result;
// Два разных типа ошибок: ParseIntError и ParseFloatError.// anyhow::Result принимает оба через `?`.fn total_price(quantity: &str, price: &str) -> Result<f64> { let quantity: u32 = quantity.trim().parse()?; let price: f64 = price.trim().parse()?; Ok(quantity as f64 * price)}
fn main() { for (quantity, price) in [("3", "2.50"), ("три", "2.50"), ("3", "дёшево")] { match total_price(quantity, price) { Ok(total) => println!("итого: {total}"), Err(e) => println!("ошибка: {e}"), } }}Вывод:
итого: 7.5ошибка: invalid digit found in stringошибка: invalid float literalБез anyhow понадобилось бы своё перечисление с двумя вариантами или Box<dyn Error>. anyhow — это как более удобный Box<dyn Error>. Но заметь проблему: «invalid digit found in string» не говорит, какое именно значение было неправильным. Для этого и нужен контекст.
anyhow: добавляем контекст и выводим цепочку
Заголовок раздела «anyhow: добавляем контекст и выводим цепочку»Подключи трейт Context, и у каждого Result появятся два новых метода:
.context("сообщение")оборачивает ошибку фиксированным сообщением..with_context(|| format!(...))принимает замыкание. Сообщение собирается, только если ошибка действительно случилась, так что используй его, когда для сообщения нуженformat!.
Каждый слой контекста добавляет одно звено в цепочку ошибок (error chain). От того, как ты выводишь ошибку, зависит, какую часть цепочки ты увидишь:
use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> { let port: u16 = text .parse() .with_context(|| format!("`{text}` — неверный порт"))?; Ok(port)}
fn load_settings(port_text: &str) -> Result<u16> { let port = parse_port(port_text).context("не удалось загрузить настройки")?; Ok(port)}
fn main() { let err = load_settings("99999").unwrap_err();
println!("{err}"); // только самое внешнее сообщение println!("{err:#}"); // вся цепочка в одну строку
for (i, cause) in err.chain().enumerate() { println!("{i}: {cause}"); }}Вывод:
не удалось загрузить настройкине удалось загрузить настройки: `99999` — неверный порт: number too large to fit in target type0: не удалось загрузить настройки1: `99999` — неверный порт2: number too large to fit in target type«number too large to fit in target type» — сообщение стандартной библиотеки: «число слишком велико для целевого типа» (u16 вмещает максимум 65535).
Третий формат — {:?}. Он выводит многострочный отчёт, и именно его ты видишь, когда main возвращает ошибку:
println!("{err:?}");не удалось загрузить настройки
Caused by: 0: `99999` — неверный порт 1: number too large to fit in target type«Caused by:» («вызвано:») пишет сам anyhow. Если задана переменная окружения RUST_BACKTRACE=1, {:?} добавляет ещё и трассировку стека, которая показывает, где была создана ошибка. См. Окружение и процессы.
Типичный пример из жизни — чтение файла, где сообщение операционной системы («No such file or directory») никогда не говорит, какой это был файл:
let text = std::fs::read_to_string(path) .with_context(|| format!("не удалось прочитать файл настроек {path}"))?;anyhow: bail!, ensure! и anyhow!
Заголовок раздела «anyhow: bail!, ensure! и anyhow!»Иногда никакой исходной ошибки нет: ты сам решаешь, что что-то не так. Для этого у anyhow есть три макроса:
bail!("...")сразу возвращает ошибку. Это сокращение дляreturn Err(anyhow!("...")).ensure!(условие, "...")возвращает ошибку, если условие ложно.anyhow!("...")создаёт значение ошибки, но не возвращает его.
Все три принимают сообщения в стиле format!.
use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> { ensure!(amount > 0, "сумма должна быть больше нуля"); if amount > balance { bail!("недостаточно средств: на балансе {balance}, попытка снять {amount}"); } Ok(balance - amount)}
fn find_user(id: u32) -> Result<&'static str> { match id { 1 => Ok("Феррис"), _ => Err(anyhow!("нет пользователя с id {id}")), }}
fn main() { println!("{:?}", withdraw(100, 30).ok()); for (balance, amount) in [(100, 0), (100, 250)] { if let Err(e) = withdraw(balance, amount) { println!("ошибка: {e}"); } } println!("{}", find_user(1).unwrap()); println!("ошибка: {}", find_user(7).unwrap_err());}Вывод:
Some(70)ошибка: сумма должна быть больше нуляошибка: недостаточно средств: на балансе 100, попытка снять 250Феррисошибка: нет пользователя с id 7Возвращаем anyhow::Result из main
Заголовок раздела «Возвращаем anyhow::Result из main»Чаще всего anyhow встречается именно здесь: main возвращает anyhow::Result<()>, а ? используется повсюду. Если ошибка доходит до main, Rust выводит её через {:?} в stderr и завершает программу с кодом 1:
use anyhow::{Context, Result};
fn main() -> Result<()> { let text = "сорок два"; let n: i32 = text .parse() .with_context(|| format!("не удалось прочитать {text:?} как число"))?; println!("{n}"); Ok(())}В stdout ничего не выводится. В stderr ты увидишь:
Error: не удалось прочитать "сорок два" как число
Caused by: invalid digit found in stringИспользуем оба вместе
Заголовок раздела «Используем оба вместе»Вот обычная форма настоящего проекта. Модуль inventory играет роль библиотеки: он возвращает точную ошибку InventoryError, сделанную через thiserror. Код приложения использует anyhow, добавляет контекст и заглядывает внутрь ошибки только там, где это действительно важно. downcast_ref спрашивает anyhow::Error: «а ты, случайно, не вот этот тип?» — и работает даже сквозь добавленный контекст:
// ----- часть «библиотека»: точные ошибки с thiserror -----mod inventory { use thiserror::Error;
#[derive(Debug, Error)] pub enum InventoryError { #[error("неизвестный товар `{0}`")] UnknownItem(String), #[error("осталось только {available}, нельзя взять {wanted}")] NotEnough { available: u32, wanted: u32 }, }
pub struct Store { pub apples: u32, }
impl Store { pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> { if item != "apple" { return Err(InventoryError::UnknownItem(item.to_string())); } if wanted > self.apples { return Err(InventoryError::NotEnough { available: self.apples, wanted }); } self.apples -= wanted; Ok(()) } }}
// ----- часть «приложение»: anyhow + контекст -----use anyhow::{Context, Result};use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> { store .take(item, amount) .with_context(|| format!("заказ №{id} не выполнен"))?; println!("заказ №{id} выполнен"); Ok(())}
fn main() { let mut store = Store { apples: 5 }; let orders = [(1, "apple", 2), (2, "plum", 1), (3, "apple", 9)];
for (id, item, amount) in orders { if let Err(e) = process_order(&mut store, id, item, amount) { println!("ошибка: {e:#}"); // Мы всё ещё можем заглянуть внутрь и найти исходный тип из thiserror: if let Some(InventoryError::NotEnough { available, .. }) = e.downcast_ref::<InventoryError>() { println!(" подсказка: закажи {available} или меньше"); } } } println!("яблок осталось: {}", store.apples);}Вывод:
заказ №1 выполненошибка: заказ №2 не выполнен: неизвестный товар `plum`ошибка: заказ №3 не выполнен: осталось только 3, нельзя взять 9 подсказка: закажи 3 или меньшеяблок осталось: 3Частые ошибки
Заголовок раздела «Частые ошибки»Забыл подключить Context. .context() — метод трейта anyhow::Context, а методы трейта работают, только когда трейт в области видимости:
use anyhow::Result;
fn parse(text: &str) -> Result<i32> { let n: i32 = text.parse().context("not a number")?; Ok(n)}error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope --> src/main.rs:4:31 |4 | let n: i32 = text.parse().context("not a number")?; | ^^^^^^^ | = help: items from traits can only be used if the trait is in scopehelp: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it |1 + use anyhow::Context; |Компилятор не находит у Result метод context и прямо подсказывает: трейт Context, который его даёт, не подключён — добавь use anyhow::Context;. Исправление: use anyhow::{Context, Result};.
Повторяешь сообщение источника в своём сообщении. С #[from] внутренняя ошибка уже является источником. Если ещё и вставить {0} в сообщение, полный отчёт выведет её дважды:
#[derive(Debug, thiserror::Error)]enum AppError { #[error("invalid port: {0}")] // включает текст источника... BadPort(#[from] ParseIntError), // ...и это поле тоже источник}При выводе через anyhow с {:#} получится invalid port: invalid digit found in string: invalid digit found in string. Исправление: либо напиши #[error("invalid port")] и дай цепочке показать причину, либо оставь {0}, но не отмечай поле как источник. Выбери что-то одно.
Выводишь через {} и теряешь причину. println!("{e}") для anyhow::Error показывает только самое внешнее сообщение, например «не удалось загрузить настройки». Когда выводишь ошибку для человека, используй {:#} (одна строка) или {:?} (полный отчёт).
anyhow в публичном API библиотеки. Если библиотека возвращает anyhow::Result, вызывающий код не может сделать match по твоим ошибкам. Ему придётся угадывать тип через downcast_ref. Пусть публичные функции возвращают перечисление на thiserror, а anyhow оставь для приложений, тестов и примеров.
- thiserror — для объявления ошибок:
#[derive(Error)]и сообщение#[error("...")]у каждого варианта заменяют написанные вручнуюDisplayиError. #[from]позволяет?превращать внутреннюю ошибку в твой вариант и записывает её как источник.#[source]записывает источник без такого превращения.- anyhow — для передачи ошибок наверх:
anyhow::Result<T>принимает любую ошибку через?. .context()и.with_context()добавляют в цепочку ошибок понятный человеку слой. Выводи цепочку через{:#}(одна строка) или{:?}(отчёт).bail!сразу возвращает ошибку,ensure!возвращает её, если условие ложно, аanyhow!создаёт её.- Обычная схема: thiserror в коде библиотеки, anyhow в
main.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Ты пишешь библиотеку, и вызывающему коду нужно по-разному реагировать на тайм-аут и на неправильный ввод. Какой крейт подходит лучше?
2. Что делает #[from] на поле варианта?
3. Какой формат выводит anyhow::Error со всей цепочкой в одну строку?
4. Когда стоит использовать .with_context(|| ...), а не .context(...)?
5. Что делает ensure!(amount > 0, "сумма должна быть положительной"), когда amount равно 0?
Результат: 0 / 5
Что почитать
Заголовок раздела «Что почитать»- anyhow documentation — docs.rs (на английском): полный API, включая
Context,chain()и downcasting. - thiserror documentation — docs.rs (на английском): все атрибуты, например
#[error(transparent)]и поля с трассировкой. - The Context trait — anyhow (на английском): как работают
contextиwith_context, с примерами. - Обработка ошибок — книга «Язык программирования Rust»: основы, на которых строятся оба крейта (перевод на русский).
- std::error::Error (на английском): стандартный трейт, на котором держатся
.source()и цепочки ошибок.