Найти тему
Nuances of programming

Ошибки в Rust: формула

Источник: Nuances of Programming

В Rust у ошибок имеется фактор вкуса, ведь это язык со множеством альтернативных способов сделать что-то. В его конструкциях сочетаются язык ML, как в Ocaml, функциональные и Algol-подобные разновидности, лучшие идеи берутся в Rust из других языков, соблюдаются строгие правила безопасности: конкурентность, доступ к памяти, разделение ресурсов…

Поэтому обработка ошибок в Rust  —  это многослойный торт.

P. S. Если не терпится, переходите сразу к формуле/семи пунктам ошибок в Rust.

Выбор правильной философии обработки ошибок

В Go применяется, пожалуй, правильный подход: «Ошибка  —  это просто. Обрабатывай ее в месте вызова, иначе крышка». А в Java  —  как многократно доказано историей, очень неправильный: «Выбрасывай, кто-нибудь обработает».

Проблема в том, что когда ошибки считаются чем-то простым, как в Go  —  обычно это строка или объект,  —  и добираются до обработчика, то для принятия разумного решения о восстановлении при ошибке недостает контекста или информации.

А при подходе Java выбрасывается ошибка, со стеком случается проблема. К тому же сказывается отсутствие лучших практик, для каждого слоя передачи вверх теряется все больше контекста, и опять же, когда ошибка добирается до обработчика, трудно принять обоснованное решение.

Философия ошибок в Rust

Вот главное в ошибках Rust:

// для удобства определяется тип «Result», уже кодируя в типе «Error»
type Result<T> = Result<T, ParserError>;

// «Error» — это тип, ничего особенного
#[derive(Debug)]
enum ParserError {
EmptyText,
Parse(String),
}
// чтобы найти «match» в его содержимом, возвращается результат, который может быть или не быть ошибкой.
fn parse(..) -> Result<AST>{
..
}

Имеется тип Result, которым кодируются состояния Ok и Err  —  создаваемые типы. Тип ошибки  —  любой, обычно это enum.

Функцией возвращается Result.

Что касается управления ошибками, на Rust прорабатываются недостатки других языков и указывается на:

  • обработку ошибок в месте вызова или
  • упорядоченную передачу вверх ошибок, и без механизма throw/catch, как в Java.

Для первоклассной обработки ошибок в Rust имеется все необходимое, это часть языка:

  • Поддерживается широкая система типов ошибок, в отличие от Go.
  • Рекомендуется выполнять отображение ошибок, в Rust ошибкам и сбоям придается большое значение; чтобы избегать досадных неудач при работе с ними, здесь рассматривается эргономика нетривиальных ситуаций обработки ошибок. Опять же, в отличие от Go.
  • Передача ошибок аналогична возврату результатов из функций, язык программирования уже «умеет» это делать. Как Go, но в отличие от Java. То есть программиста не удивить, каждая мощная конструкция, которую он создает или которой обрабатывает результаты функций  —  агрегирование, построение выражений, преобразование, параллельное выполнение и т. д.,  —  доступна для обработки ошибок, в отличие от Go и Java.

Поскольку ошибка  —  лишь возвращаемое значение, это моделируется на любом другом языке программирования.

Ошибки моделируются как типы результатов в Java. Важно, как все связывается: когда стандартная библиотека полностью ориентирована на это, ошибки учитываются синтаксисом как результаты, сообщество со своим мнением уже тут как тут, и получается нирвана-история ошибок.

Ошибки  —  это не легко

При всем уважении к Go, разница между им и Rust  —  отличная иллюстрация того, как «языковое мышление» согласно, например, гипотезе Уорфа сказывается на коде и когнитивной нагрузке.

Все, кто перешел с Go на Rust и с кем я говорил, не желали возвращаться и не могли поверить, как они просчитались в том, насколько сложны ошибки. Сегодня они видят те пограничные случаи, о которых не известно в коде на Go.

Все дело в перераспределении когнитивной нагрузки: в C ошибки просты, ведь он был ассемблерным препроцессором. Поэтому сложность перекладывается на разработчика. В Go, нацеленном на доработку этого классически, но по-прежнему руководствуясь простотой, эта нагрузка также смещена в сторону разработчика. Разница в том, что в C нет системы безопасности.

Поэтому-то они и не могли поверить, что повелись на эту идею об «одномерности», простоте ошибок согласно, например, подходу «Обрабатывай, иначе просто сломается» с типичным на Go мышечным рефлексом if err != nil. Сбои  —  это не легко. Случаи ошибок не тривиальны. Так и происходят аварии космических кораблей.

Ничего не дается даром

Ошибки в Rust  —  это и полезный опыт, и мощный инструмент, а не что-то неприятное, с чем приходится иметь дело. Справляешься с ошибками, значит, справляешься с языком.

В Rust имеются инструменты для создания отличной истории моделирования и обработки ошибок критически важного ПО. Даже если вы не создаете программное обеспечение для космических кораблей и ядерных реакторов, всегда приятно делать надежное ПО, от которого не вскакиваешь по ночам.

Поэтому ошибки в Rust требуют больших вложений с вашей стороны.

  • Когнитивная нагрузка при создании ошибок больше. Нельзя просто применить new Error("oops"), или return "can't load", или throw new Foobar(). Ошибки создаются как тип и в соответствии с правилами.
  • Связь с историей сбоя  —  для обратной трассировки и выявления первопричины.
  • Связь с пользовательским взаимодействием реализацией отладки и отображения  —  для оператора и конечного пользователя соответственно.
  • Разумное и ответственное преобразование результатов. Здесь быстро понимаешь, что функция, возвращающая Result<(), ErrA>, не работает с внутренней функцией, возвращающей Result<(), ErrB>.
  • Работа со сложностью реальной системы и получением многослойных ошибок вроде такой: Error(DatabaseError(ConnectionError(PoolError(reason)))). В каждом слое этой луковицы ошибок имеется ответвление и необходимое для явного написания кода решение. И хорошо, если автор библиотеки не моделировал ошибки, тогда вы просто ликвидируете их недостатки  —  тоже в любом случае действие по предотвращению багов.
  • Ответственность к другим. Возвращаемые ошибки используют и другие. Подумайте о получаемой ими от вас истории ошибок.

Структура, семантика и пользовательское взаимодействие

Рассмотрим практические шаблоны и проблемы проектирования. Построим все двумя библиотеками ошибок:

  1. thiserror  —  для библиотек и ядра.
  2. eyre  —  аналог/упрощенная альтернатива anyhow дополнительно для приложений, в основном CLI.

Изучим:

  • создание и добавление контекста к ошибкам;
  • разделение на слои, вложение и обертывание ошибок;
  • отображение ошибок из зависимостей на собственные ошибки в соотношении 1:1, 1:N, N:1.

Создание ошибок

Сохраняя форму перечисления, создаем иерархию ошибок с сохранением контекста:

enum ParserError {
EmptyFile(String) // путь к файлу
Json(serde_json::Error) // исходная ошибка парсера JSON
SyntaxError { line: String, pos: usize } // сложная структура ошибки
}

При создании ошибок или их возвращении придерживаются таких принципов.

  • Меньше ввода: больше From и автоматических преобразований с ?.
  • Цикл for-in вместо отображения вычисления по сокращенной схеме.
  • Сбор Result<Vec> для агрегирования вместо Vec<Result<..>>:
let res: Result<Vec<_>> = foo.map(..).collect();
  • Ошибки источника по возможности всегда сохраняются.
  • В ошибке Error важны и отображение, и отладка; аудитория: отображение  —  для конечных пользователей, например редактирование секретов, сокращение текста, а отладка  —  для операторов, например устранение неполадок, ведение журнала, диагностика.
  • Никаких необязательных expect, unwrap, panic в коде, если только ими не восполняется недостаток в одной из внешних библиотек.

Ошибки на уровне крейта и модуля

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

Разделяем один тип Result на множество разных типов Result подмодулей так:

compiler/ -> (1) Result<T,E> = Result<.., Error>
parser/ -> (2) Result<T,E> = Result<.., ParserError>
scanner/ -> (3) Result<T,E> = Result<.., ScannerError>

Тип ошибки на уровне крейта  —  (1).

Тип ошибки на уровне модуля  —  (2) и (3).

Здесь три разных типа Result. При возвращении Result<.., ParserError> в функцию compiler, ожидающую Result<.., Error>, код сломается.

Если результат T тот же, понадобится .map_err или снова обернуть Result при передаче значения вверх от нижних зависимостей parser к верхним compiler.

Отображение ошибок

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

Для начала требуется содержательная базовая ошибка, берем ее за основу для каждого вновь создаваемого модуля:

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
Message(String),
// обертка центрального ввода-вывода
#[error(transparent)]
IO(#[from] std::io::Error),
// используется с «.map_err(Box::from)?;»
#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),

// другие типичные преобразования
#[error(transparent)]
Pattern(#[from] regex::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}

Обертывание serde_json::Error посредством Error::Json похоже не на добавление значения, а на повторение имеющейся информации.

Вот что здесь происходит:

  1. Объединение различных типов ошибок, возможно, из разных библиотек, в один унифицированный тип ошибки enum, чем сильно оптимизируются типы Result. Это ключ к полезной обработке ошибок библиотеки третьими лицами. Лучше ожидать только один тип ошибки, которым указывается на все ошибки, возможные в используемой вами библиотеке.
  2. Автоматические преобразования с #[from] для очистки кода, уменьшения ввода и сопровождения за счет обхода точки принятия решения о преобразовании ошибки. Чтобы самостоятельно, вручную выполнить преобразование в каждой точке кода кодовой базы, #[from] удаляется.
  3. Сохраняется запасной вариант для альтернативной anyhow. Ошибка нас не интересует? Не знаем, что с ней делать? Авторы библиотеки сделали работу с ней невозможной? Тогда выполняем такой прием из карате: .map_err(Box::from)?; и обертываем собственным доступным типом Error.

Отображение N:1

Когда осуществляется отображение многих сторонних типов ошибок на один из собственных?

  • Когда уровень детализации ошибок одной или нескольких библиотек слишком высок. Например, когда Error::HttpThrottling, Error::RateLimit и Error::AccountDepleated одинаковы и указывается: you’re out of your API credits, or you’re abusing your credits  —  chill out! («У вас закончились кредиты API или вы злоупотребляете кредитами, остыньте!»).
  • Когда уже знаешь: игра окончена. Знание специфики ошибки не поможет. Например, Error::DiskFull, Error::CorruptPartition и т. д. Просто обертываем их в MyError::Fatal и сохраняем в этом исходную ошибку, применяя box для большей детализации.
  • Когда разными библиотеками делается одно и то же и реализуется архитектура поставщика, в которой меняются разные поставщики, реализующие один и тот же типаж trait. Например, Error::PostgresConnection, Error::MySqlConnectionPool с заменяемым поставщиком базы данных в крейте означает для вас просто MyError::Connection. Не забываем: если типаж должен быть универсальным для поставщиков, возвращаемые в его функциях ошибки  —  тоже.

Такое отображение создается двумя способами:

1. Отображение N:1  —  разделение на слои и передача вверх

По сути, нужно сделать тип ошибки агрегирования первого уровня и агрегирование ошибок верхнего уровня.

Имея поставщиков баз данных и собственный крейт для доступа к данным, сначала создаем тип ошибки первого уровня:

enum DbProviderError {
// все это неизменно одинаково для всех поставщиков баз данных,
// с которыми мы имеем дело
Connection(..)
PoolLimit(..)
SqlSyntax(..)
}

При наличии типажа для этих поставщиков ошибка выше вернется  —  для соотнесения всех особенностей каждой отдельной ошибки разных поставщиков:

trait DbProvider {
fn connect(..) -> Result<(), DbProviderError>
}

Наконец, крейтом, которым используется DbProvider, устанавливается правильный поставщик и т. д., принимается DbProviderError:

enum MyError {
//..
Message(String)
#[error(transparent)]
DB(#[from] DbProviderError),
}

Теперь, преобразуя с помощью ? ошибку базы данных в ошибку уровня крейта, создадим аккуратную историю ошибок.

Внимание: когда два типа результатов типажа и тип результатов на уровне крейта несовместимы, преобразование посредством ? не выполнится. Придется вручную вызывать .into() в DbProviderError, чтобы превратить его в MyError при выполнении .map_err или создании нового DbProviderError.

Пример:

fn embed_inputs(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>> {
Err(EmbeddingError::Other(
"an embedding cannot be made".to_string(),
)
.into())
}

Здесь у embed_inputs в модуле embedding имеются собственные иерархия ошибок, история и тип Error.

Но им возвращается Result более высокого уровня крейта: хотя в нем содержится Error уровня крейта, которым с помощью типажа from преобразуется EmbeddingError, он не выводится автоматически. Поэтому мы используем into() в директиве Err.

Другой способ преобразования вручную  —  напрямую вызвать типаж into и преобразовать с помощью ?:

fn embed_inputs(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>> {
let res = provider
.do_something()
.map_err(Into::<..error type..>::into);
Ok(res?)
}

О структуре папок и модулей

При работе с поставщиками и поставщиками, реализующими типажи, часто приходится отображать N типов и вариантов ошибок на собственный вариант.

В этом случае создается аналогичная архитектура ошибок, каждый уровень которой «знает» собственные конкретные ошибки:

root/
error.rs
providers/
error.rs
providerA/
error.rs
providerB/
error.rs
...

Следующий этап  —  предложить инкапсулированные типы ошибок и модульно-локальные преобразования:

root/
error.rs
providers/
error.rs
.. {
ProviderA(provider_a::error::ProviderAError)
ProviderB
}
providerA/
error.rs
.. {
SqlConnectionError(extlib::conn:Error)
DataTransferError(extlib::conn:Error)
}
providerB/
error.rs
...

Обычно нужно «сгруппировать» ошибки поставщика без детализации каждой из них, потому что уровень слишком низок и пользователь не может их обработать: имеется только ошибка поставщика. Мы, по сути, группируем ошибки N к 1.

2. Отображение N:1  —  применение «box»

Если конкретный тип ошибки поставщика базы данных не важен, обернем ее, применим box и отправим с ошибкой уровня крейта MyError:

enum MyError {
//..
Message(String)
DB(Box<dyn std::error::Error + Send + Sync>),
}

И затем  —  .map_err(Box::from).map_err(|e| MyError::DB(e)). Здесь очень явно указывается .map_err с возможностью добавления вариантов Box<dyn Error> в типе MyError.

Отображение 1:1

Отображение единственной сторонней ошибки на один из собственных вариантов ошибок, stdlib тоже считается сторонней.

Так осуществляется отображение на собственные варианты ошибок, благодаря которым мы перемещаемся по иерархиям ошибок как они есть в модулях и крейтах.

Например, для передачи вверх по слоям, рассматриваемым как набор абстрактных действий:

3rd-party error -> (wrap!) -> ModuleError -> (wrap!) -> CrateError

Рассматривая конкретный пример парсера, представляемого в виде дерева:

// крейт
ParserError::Invalid(
// модуль
ScannerError::BadInput(
// сторонняя
Regex::Error(..)
)
)

Перемещаясь по слоям в коде:

fn parse(..) -> Result<String, ParserError> {
scan()?; // ошибка модуля -> ошибка крейта
}

fn scan(..) -> Result<String, ScannerError> {
scan_with_regex()?; // ошибка библиотеки -> ошибка модуля
}

fn scan_with_regex(..) -> Result<String, Regex::Error> {
...
}

Нужны ли все эти слои? Обычно нет. Но, понимая эту базовую структуру ошибок, вы разберетесь в других библиотеках, «вырезав» из этой общей картины то, что нужно.

Отображение 1:N

Такое отображение осуществляется, когда получается один тип ошибки и в коде требуется больше детализации. Типичный пример  —  HTTPError, которым все считается за ошибку. Но 404 отличается от 500, поэтому нужны разные стратегии обработки ошибок.

Для этого в Rust имеется .map_err, с предложением match он еще и эргономичнее:

.map_err(|e| match e {
// создаем варианты ошибок с помощью e-кода
})

Приемы для ошибок

Специальный «into»

fn do_something(..) -> Result<String> {
foobar(..).map_err(Into<ModuleError>::into)?;
...
}

2-уровневый «From»

Иногда перемещение по ошибкам на два уровня вверх выполняется в каждом месте вызова. Когда это делается достаточное количество раз, приходится кстати типаж From, так очищается код и эффективно централизуется принятие решения об ошибках:

impl From<lib error> for Error {
fn from(e: <lib error>) -> Self {
Self::SomeCrateError(super::ModuleError::SomeModuleError(...))
}
}

// какое-то другое место
fn do_something(..) -> Result<String> {
foobar(..)?;
...
}

Быстрый «box»

Если имеется такая ошибка:

#[derive(thiserror::Error, Debug)]
enum MyError {
#[error("{0}")]
Message(String)
// обратите внимание на этот вариант
#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),
}

Тогда быстрым приемом карате преобразовываем ее в MyError::Any:

foo(..).map_err(Box::from)?;

MyError::Any  —  хорошая альтернатива, если используете anyhow: не нужно добавлять другую библиотеку.

Обработка ошибок

Match, map, wrap

Обрабатывая ошибки из крейта AWS SDK, мы хотим сказать, что отсутствующий параметр в ssm  —  это нормально, а не случай ошибки при удалении параметра:

fn handle_delete(e: SdkError<DeleteParameterError>, pm: &PathMap) -> Result<()> {
match e.into_service_error() {
DeleteParameterError::ParameterNotFound(_) => {
// все нормально
Ok(())
}
e => Err(crate::Error::DeleteError {
path: pm.path.to_string(),
msg: e.to_string(),
}),
}
}
  • Match: из основного типа Error выбираются различные случаи ошибок.
  • Map: для исходного типа Result создается другая семантика  —  с отображением случая ошибки на случай Ok.
  • Wrap: возвращается оптимизированный, знакомый тип Error, который предоставляется крейтом конечным пользователям для компоновки.

Общее практическое правило: обработка ошибок  —  это всегда выбор (1) match, (2) map, (3) wrap или сочетание их всех.

Обработка завершения

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

Вот пример такой обработки, где программой сообщается об ожидаемой ошибке как части значения CmdResponse  —  это не Error в Rust,  —  а кроме того, учитывая неожиданную ошибку с Error, сообщается об этом упорядоченно:

const DEFAULT_ERR_EXIT_CODE: i32 = 1;
pub fn result_exit(res: Result<CmdResponse>) {
let exit_with = match res {
Ok(cmd) => {
if let Some(message) = cmd.message {
if exitcode::is_success(cmd.code) {
eprintln!("{message}");
} else {
eprintln!("{} {}", style("error:").red().bold(), style(message).red());
};
}
cmd.code
}
Err(e) => {
eprintln!("error: {e:?}");
DEFAULT_ERR_EXIT_CODE
}
};
exit(exit_with)
}

Продумаем контактную зону

Убеждаемся, что крейтом предоставляется один тип ошибки.

Зачем?

  • Так пользователями создается одно преобразование from, и дело в шляпе.
  • Документация о том, что и как обрабатывать, находится в одном месте.
  • Хотите охватить все случаи? Полный охват  —  при отображении всех вариантов одного перечисления Error.
  • Фокус на отчетах, отладке и работоспособности: при работе с крейтом пользователи знакомятся с одним типом ошибки и ее контекстом, эффективно ее обрабатывают в сеансе отладки или через код.
  • Один тип ошибки означает один тип Result, что само по себе  —  лучшее проектирование API.
  • При необходимости выполняется вложение других типов ошибок в этом единственном типе ошибок в один из его вариантов.

Ошибки: формула

Поэтапно достигаем нирваны ошибок в Rust:

1. Добавляем и изучаем зависимости

  • thiserror для всех ошибок в крейте;
  • eyre для CLI.

2. Для каждого крейта создаем тип базовой ошибки

Помещаем его в mod.rs или lib.rs верхнего уровня:

// lib/mod.rs
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
Message(String),
// обертка центрального ввода-вывода
#[error(transparent)]
IO(#[from] std::io::Error),
// используется с «.map_err(Box::from)?;»
#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),

// другие типичные преобразования
#[error(transparent)]
Pattern(#[from] regex::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}

3. Match, map, wrap

Берем ошибки из зависимостей, stdlib или других модулей и, где необходимо, применяем match и извлекаем информацию, или отображаем их с помощью map_err, или обертываем в собственный тип ошибок.

4. Продолжаем использовать преобразования с «?» в коде

Компилятором выполняем автоматические преобразования from.

Если ошибка из сторонней библиотеки автоматически не преобразовывается, добавляем к ошибке верхнего уровня крейта с атрибутом enum вариант #[from]. Проверяем, что не создаются конкурирующие варианты, об этом позаботится компилятор.

Централизуем точки принятия решений об ошибках, пробуя преобразовать несколько слоев ошибок и кодируя собственный типаж From:

impl From<RustBertError> for Error {
fn from(e: RustBertError) -> Self {
Self::EmbeddingErr(super::EmbeddingError::SentenceEmbedding(Box::from(e)))
}
}

Не забываем также применять .map_err для отображения на ошибку, преобразуемую типажами ? и from.

5. Создаем контекстуальные варианты, но без фанатизма  —  чтобы не переусердствовать

Делаем варианты с информацией, доступной при создании ошибки:

InvalidSyntax{ file: String, line: usize, reason: String }

Стараемся не переусердствовать: не стоит включать в них все на свете.

6. Подумайте об удобстве пользователя

  • Может ли он что-то сделать с помощью создаваемой ошибки, чтобы восстановиться с кодируемой вами информацией?
  • Это автоматическое восстановление или ручное?
  • Важно ли время, пространство, ресурсы, аппаратное обеспечение?
  • Появятся ли ошибки в логах? Что будет в них?
  • Будет ли пользователю предоставлено в ошибке достаточно информации для устранения проблемы после аварийного завершения?

7. Когда не удается все остальное, применяем «Box»

Если исходная ошибка не важна, но нужно создать эргономичную кодовую базу, используем в ошибке вариант, которым просто принимается dyn Error.

Читайте также:

Читайте нас в Telegram, VK

Перевод статьи Dotan Nahum: Errors in Rust: A Formula