Вы забудете о необработанных исключениях, если научитесь обращаться с ошибками, как это принято в Rust


Обработка ошибок – важнейшая часть любого языка программирования, а в Rust предлагается для этого уникальный мощный подход. Rust, в отличие от многих других языков программирования, не предлагает исключений, а предоставляет перечисление Result. У разработчика нет иного выхода, кроме как обрабатывать все ошибки согласованно и предсказуемо. Поэтому ошибки становится проще идентифицировать и диагностировать.

Поскольку в Rust не предусмотрены исключения, любая функция обязана возвращать либо значение, либо "панику". Когда функция паникует, процесс сразу же завершается и предоставляет вызывающей стороне конкретную обратную связь. Чисто технически можно отлавливать случаи паники в Rust при помощи catch_unwind, но постоянно так делать не рекомендуется. Вместо этого в Rust предлагается перечисление Result; пользуясь им, разработчик вынужден обрабатывать все ошибки сам.

В этом посте будут рассмотрены применяемые в Rust идиоматические паттерны обработки ошибок, и вы в общих чертах поймёте, как это делается. Мы разберём перечисление Result, посмотрим, как с его помощью обрабатываются ошибки в программах Rust, и какие крейты чаще всего используются, чтобы упростить этот процесс.

image

Тип 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)


  1. Kelbon
    29.09.2023 13:15
    -7

    ))))))))))))))))))))))))


  1. NightShad0w
    29.09.2023 13:15

    Ну как-то так.... Прекрасный пересказ вводной статьи из руководства по Rust. А каким-же все-таки идиоматическим способом обрабатывать ошибки в реальном софте, в котором 40 зависимостей, каждая со своим набором ошибок, обмазано все асинхронностью, хотя бы с помощью Tokyo, и все это затолкано в собственные крейты для удобства администрирования и декомпозиции, не рассказано. За перевод - спасибо, притарю статью в закладки.


    1. boldape
      29.09.2023 13:15
      -1

      Я тоже зашёл поучиться как это должно быть и не нашел ответа. Я читал что есть 2 подхода энихау и зисэрор. Иделмптически энихау применяется в конечных приложениях, а зисэрор в библиотеках. У меня технически библиотека, длл, но по факту это миниприложение, плагин, в котором уже много бизнес логики и будет ещё больше. Я делаю так, у меня есть мой тип ошибки, что типа наивный энихау, но у меня 3 категории ошибок к которым я свожу все ошибки зависимостей. Я бы взял как раз энихау, но мне нужно 3 категории, а у него только 1. Мои категории это внутренняя логическая ошибка плагина - баг плагина, ошибка на стороне хоста не валидный ввод/прекандишен ваолэйшен - баг на стороне хоста/придожения, и ошибка среды/системная/рантайм, в общем все остальное, что не папало в первые 2 категории - не баг, а просто так звёзды сложились.

      И все у меня в общем то хорошо получается, я в ручную доптсываю фром для новых ошибок когда надо, довольно редко, а дальше будет ещё реже.

      Но есть одна проблема, которая меня беспокоит, но как ее решить я пока не знаю - у меня есть служебный/библиотечный крэйт который потенциально можно заопенсорсить и как раз в нем находиться определение моей ошибки, а бизнес логика живёт в другом крэйте. Проблема в том что при добавлении зависимости в бизнес крэйт мне приходиться ее также добавить и в служебный, что бы я мог написать реализацию фром трэйта для новой ошибки. Других причин для добавления зависимости в служебный крэйт нет и это напрягает.

      Идеально было бы реализовать фром из одной ошибки в другую в бизнес крэйте, но это не возможно т.к. обе ошибки и конечно же сам трэйт внешние по отношению к бизнес крэйту.


  1. NekoiNemo
    29.09.2023 13:15

    `#[from]` Это, конечно, здорово, для простейших ошибок или туториалов вроде этого, но есть маленькая проблема в практическом применении. from()/into() не работают когда нужно добавить какие-то доп поля в ошибку.

    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 ошибки чтобы получить типизированную внутреннюю ошибку, что может быть жестью в либе, а в конкретном одном месте своего собственного приложения - вполне норм