Вы забудете о необработанных исключениях, если научитесь обращаться с ошибками, как это принято в Rust
Обработка ошибок – важнейшая часть любого языка программирования, а в Rust предлагается для этого уникальный мощный подход. Rust, в отличие от многих других языков программирования, не предлагает исключений, а предоставляет перечисление Result. У разработчика нет иного выхода, кроме как обрабатывать все ошибки согласованно и предсказуемо. Поэтому ошибки становится проще идентифицировать и диагностировать.
Поскольку в Rust не предусмотрены исключения, любая функция обязана возвращать либо значение, либо "панику". Когда функция паникует, процесс сразу же завершается и предоставляет вызывающей стороне конкретную обратную связь. Чисто технически можно отлавливать случаи паники в Rust при помощи catch_unwind, но постоянно так делать не рекомендуется. Вместо этого в Rust предлагается перечисление Result; пользуясь им, разработчик вынужден обрабатывать все ошибки сам.
В этом посте будут рассмотрены применяемые в Rust идиоматические паттерны обработки ошибок, и вы в общих чертах поймёте, как это делается. Мы разберём перечисление Result, посмотрим, как с его помощью обрабатываются ошибки в программах Rust, и какие крейты чаще всего используются, чтобы упростить этот процесс.
Тип Result
Если функция подвержена ошибкам — то есть, она может тем или образом отказать — то обычно она возвращает тип Result, предусмотренный в Rust. При возврате Result от функции разработчик обязан возвращать её в одном из существующих вариантов: Result::Ok и Result::Err. Так как эти варианты так распространены, они предоставляются в prelude, поэтому можно написать просто Ok или Err.
Поскольку Result – это перечисление, не составляет труда сопоставить его с ошибкой и обработать любой из случаев:
fn fallible(succeed: bool) -> Result<&'static str, &'static str> {
if succeed {
return Ok("success!");
}
Err("this is an error message")
}
fn main() -> Result<(), &'static str> {
let result = fallible(false);
let value = match result {
Ok(value) => value,
Err(err) => {
return Err(err);
}
};
println!("got a value: {value}");
Ok(())
}
Как видите, здесь можно воспользоваться стандартным оператором match, предусмотренным в Rust. Этот оператор предусмотрен для ветвления в любой из вариантов перечисления. В Rust функция не выбрасывает исключения, а либо выполняет panic (в идеале, такого не должно происходить ни в коем случае), либо возвращает тип Result. В этом искусственном примере код всегда возвращает ошибку, но можно представить себе и более сложную функцию, которая отказывала бы каким-нибудь неожиданным образом.
Rust разрешает возвращать Result даже в функции main. Если значение, возвращённое из функции main, является ошибкой, то Rust выведет эту ошибку в отладочном представлении Debug и завершит процесс, сопроводив его кодом ошибки.
Оператор «вопросительный знак»
Когда в базе кода активно используется Result, обрабатывать каждый отдельный случай ошибки может быть затруднительно. Чтобы упростить эту ситуацию, в Rust предлагается оператор «вопросительный знак», при помощи которого мы сокращённо представляем развёртку успешного результата или возвращаем ошибку вызывающей стороне. В сущности, этот оператор «вопросительный знак» доставляет ошибку вызывающей стороне, обеспечивая «всплытие» этой ошибки.
Например, вот как можно значительно упростить предыдущий пример:
fn fallible(succeed: bool) -> Result<&'static str, &'static str> {
if succeed {
return Ok("success!");
}
Err("this is an error message")
}
fn main() -> Result<(), &'static str> {
let value = fallible(false)?;
println!("got a value: {value}");
Ok(())
}
Этот пример полностью эквивалентен первому, но здесь при помощи оператора? мы с лёгкостью извлекаем интересующее нас значение из Result::Ok, если оно там есть. В противном случае возвращаем вызывающей стороне вариант Result::Error.
Ошибки при упаковке
В вышеприведённых примерах мы разбирали возвращаемые ошибки, представляющие собой простые строки. В более сложном сценарии выше вероятность, что вы столкнётесь с иными типами ошибок – программа также может их возвращать.
Рассмотрим следующий пример:
use reqwest::blocking::get;
fn download() -> Result<String, reqwest::Error> {
let website_text = get("https://www.rust-lang.org")?.text()?;
Ok(website_text)
}
fn main() -> Result<(), reqwest::Error> {
let value = download()?;
println!("got a value: {value}");
Ok(())
}
В этом примере мы скачиваем информацию с сайта https://www.rust-lang.org и используем оператор ?, чтобы упростить обработку ошибок. Обратите внимание: второй тип, передаваемый каждому из Result – это теперь reqwest::Error. Этот тип нам подходит, поскольку и get(), и text() возвращают Result с ошибкой типа reqwest::Error.
Теперь допустим, что вы также хотите сохранить текст со скачанного сайта в отдельном файле. Легко дополнить наш код, чтобы в нём выполнялась и такая операция:
use tempfile::tempfile;
use std::io::copy;
use reqwest::blocking::get;
fn download() -> Result<String, reqwest::Error> {
let mut file = tempfile()?;
let website_text = get("https://www.rust-lang.org")?.text()?;
copy(&mut website_text.as_bytes(), &mut file)?;
Ok(website_text)
}
fn main() -> Result<(), reqwest::Error> {
let value = download()?;
println!("got a value: {value}");
Ok(())
}
Но этот код не компилируется! При попытке его собрать получаем следующую ошибку:
|
5 | fn download() -> Result<String, reqwest::Error> {
| ------------------------------ expected `reqwest::Error` because of this
6 | let mut file = tempfile()?;
| ^ the trait `From<std::io::Error>` is not implemented for `reqwest::Error`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
= help: the following other types implement trait `FromResidual<R>`:
<Result<T, F> as FromResidual<Result<Infallible, E>>>
<Result<T, F> as FromResidual<Yeet<E>>>
= note: required for `Result<String, reqwest::Error>` to implement `FromResidual<Result<Infallible, std::io::Error>>`
Такая ошибка возникает, так как tempfile() возвращает тип ошибки, не совпадающий с сигнатурой нашей функции Result<String, reqwest::Error>. Проще всего решить эту проблему так: «упаковать» ошибку, а затем вернуть вместо неё Result<String, Box<dyn std::error::Error>>.
В стандартной библиотеке предоставляется такой типаж std::error::Error, предназначенный для обработки как раз тех случаев, в которых нам требуется возвращать по несколько типов ошибок. Ошибки тех типов, что предоставляются в библиотеках, должны реализовывать Error, чтобы мы могли без труда преобразовывать различные типы ошибок в обобщённый типаж object. Опять же, оператор «вопросительный знак» это сильно упрощает до степени «просто работает».
use reqwest::blocking::get;
use std::error::Error;
use std::io::copy;
use tempfile::tempfile;
fn download() -> Result<String, Box<dyn Error>> {
let mut file = tempfile()?;
let website_text = get("https://www.rust-lang.org")?.text()?;
copy(&mut website_text.as_bytes(), &mut file)?;
Ok(website_text)
}
fn main() -> Result<(), Box<dyn Error>> {
let value = download()?;
println!("got a value: {value}");
Ok(())
}
Также стоит отметить anyhow, ещё один популярный крейт, который схожим образом позволяет использовать типаж Box>, но с некоторыми дополнениями, которые повышают эргономичность работы.
Использование thiserror
Есть такой недостаток при использовании anyhow или Box>: мы теряем информацию о типах ошибок, возвращаемых функции, а знать типы зачастую полезно, когда программируешь логику ветвления в зависимости от того, какая именно ошибка возникла.
При отслеживании контекста ошибки действует такое соглашение: собираем наше собственное перечисление ошибок, в котором описываются все типы ошибок, что могут произойти в данном крейте или модуле.
use reqwest::blocking::get;
use std::io::copy;
use tempfile::tempfile;
fn download() -> Result<String, Error> {
let mut file = tempfile().map_err(|_| Error::File)?;
let website_text = get("https://www.rust-lang.org")
.map_err(|_| Error::Download)?
.text()
.map_err(|_| Error::Download)?;
copy(&mut website_text.as_bytes(), &mut file).map_err(|_| Error::File)?;
Ok(website_text)
}
fn main() -> Result<(), Error> {
let value = download()?;
println!("got a value: {value}");
Ok(())
}
#[derive(Debug)]
enum Error {
File,
Download,
}
Теперь вызывающая сторона знает контекст и тип той ошибки, которая возникла в функции скачивания. Но нам требуется вручную преобразовать ошибки, чтобы поместить их в наше пользовательское перечисление Error. Для этого применяется функция map_err, довольно неудобная и многословная.
При помощи Thiserror проще отображать типы ошибок на созданные вами пользовательские типы ошибок:
use reqwest::blocking::get;
use std::io::copy;
use tempfile::tempfile;
use thiserror::Error;
fn download() -> Result<String, Error> {
let mut file = tempfile()?;
let website_text = get("https://www.rust-lang.org")?.text()?;
copy(&mut website_text.as_bytes(), &mut file)?;
Ok(website_text)
}
fn main() -> Result<(), Error> {
let value = download()?;
println!("got a value: {value}");
Ok(())
}
#[derive(Debug, Error)]
enum Error {
#[error("file error: {0}")]
File(#[from] std::io::Error),
#[error("download error: {0}")]
Download(#[from] reqwest::Error),
}
Обратите внимание, как нам удалось сохранить отображение ошибок на наше собственное декларативное перечисление. Для этого мы воспользовались макросами, которые предоставляются в thiserror. Наш код остаётся чистым, а для контролируемого просачивания ошибок в нём мы можем с лёгкостью воспользоваться оператором «вопросительный знак».
Заключение
Обработка ошибок – неотъемлемая составляющая любого языка программирования, а в Rust она реализована особенно хорошо и надёжно. Rust, в отличие от многих других языков программирования, вообще не использует исключений, а перечисляет вместо них перечисление Result, при работе с которым программисту приходится самостоятельно обрабатывать все ошибки. Программировать в таком стиле не только эффективнее, но и приятнее. Благодаря перечислению Result очень просто осмысливать возникающие условия ошибок и справляться с ними в коде.
____
P.S.
Также обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Комментарии (4)
NightShad0w
29.09.2023 13:15Ну как-то так.... Прекрасный пересказ вводной статьи из руководства по Rust. А каким-же все-таки идиоматическим способом обрабатывать ошибки в реальном софте, в котором 40 зависимостей, каждая со своим набором ошибок, обмазано все асинхронностью, хотя бы с помощью Tokyo, и все это затолкано в собственные крейты для удобства администрирования и декомпозиции, не рассказано. За перевод - спасибо, притарю статью в закладки.
boldape
29.09.2023 13:15-1Я тоже зашёл поучиться как это должно быть и не нашел ответа. Я читал что есть 2 подхода энихау и зисэрор. Иделмптически энихау применяется в конечных приложениях, а зисэрор в библиотеках. У меня технически библиотека, длл, но по факту это миниприложение, плагин, в котором уже много бизнес логики и будет ещё больше. Я делаю так, у меня есть мой тип ошибки, что типа наивный энихау, но у меня 3 категории ошибок к которым я свожу все ошибки зависимостей. Я бы взял как раз энихау, но мне нужно 3 категории, а у него только 1. Мои категории это внутренняя логическая ошибка плагина - баг плагина, ошибка на стороне хоста не валидный ввод/прекандишен ваолэйшен - баг на стороне хоста/придожения, и ошибка среды/системная/рантайм, в общем все остальное, что не папало в первые 2 категории - не баг, а просто так звёзды сложились.
И все у меня в общем то хорошо получается, я в ручную доптсываю фром для новых ошибок когда надо, довольно редко, а дальше будет ещё реже.
Но есть одна проблема, которая меня беспокоит, но как ее решить я пока не знаю - у меня есть служебный/библиотечный крэйт который потенциально можно заопенсорсить и как раз в нем находиться определение моей ошибки, а бизнес логика живёт в другом крэйте. Проблема в том что при добавлении зависимости в бизнес крэйт мне приходиться ее также добавить и в служебный, что бы я мог написать реализацию фром трэйта для новой ошибки. Других причин для добавления зависимости в служебный крэйт нет и это напрягает.
Идеально было бы реализовать фром из одной ошибки в другую в бизнес крэйте, но это не возможно т.к. обе ошибки и конечно же сам трэйт внешние по отношению к бизнес крэйту.
NekoiNemo
29.09.2023 13:15`#[from]` Это, конечно, здорово, для простейших ошибок или туториалов вроде этого, но есть маленькая проблема в практическом применении. from()/into() не работают когда нужно добавить какие-то доп поля в ошибку.
A
From
impl is generated for each variant containing a#[from]
attribute.Note that the variant must not contain any other fields beyond the source error and possibly a backtrace. A backtrace is captured from within the
From
impl if there is a field for it.Вот, например, гипотетический вариант: мы хотим сделать upload директории рекурсивно (в не download в temp file), в какой-то момент этот процесс выдает ошибку и мы ее открываем и видим там Error::File(std::io::Error), даже можем достать ErorrKind из нее который нам скажет PermissionDenied... А для какого файла? В IoError по дизайну эта инфа не записывается, а в месте вызова нашей чудо функции у нас есть только путь к директории внутри которой зловредный файл расположен...
А для такого простого варианта где нам нужно вернуть несколько возможных значений из функции внутри нашего приложения (а не, анпример, из публичного API библиотеки) - тут и простой enum без this_error макро подойдет. Да и, на самом деле, в anyhow можно сделать downcast ошибки чтобы получить типизированную внутреннюю ошибку, что может быть жестью в либе, а в конкретном одном месте своего собственного приложения - вполне норм
Kelbon
))))))))))))))))))))))))