Обработка ошибок в Rust
Перевод | Автор оригинала: Stefan Baumgartner
Я начал читать университетские лекции по Rust, а также проводить семинары и тренинги. Одной из частей, которая превратилась из пары слайдов в полноценный сеанс, было все, что касалось обработки ошибок в Rust, поскольку это невероятно хорошо!
Это не только помогает сделать невозможные состояния невозможными, но и содержит так много деталей, что обработка ошибок - как и все в Rust - становится очень эргономичной и простой для чтения и использования.
Делаем невозможные состояния невозможными
В Rust нет таких вещей, как undefined или null, и у вас нет исключений, как вы знаете из языков программирования, таких как Java или C#. Вместо этого вы используете встроенные перечисления для моделирования состояния:
- Option
для привязок, которые могут не иметь значения (например, Some(x) или None) - Result<T, E> для результатов операций, которые могут привести к ошибке (например, Ok(val) vs Err(error))
Разница между ними очень тонкая и во многом зависит от семантики вашего кода. Однако способ работы обоих перечислений очень похож. На мой взгляд, наиболее важным является то, что оба типа просят вас разобраться с ними. Либо явно обрабатывая все состояния, либо явно игнорируя их.
В этой статье я хочу сосредоточиться на Result<T, E>, поскольку он действительно содержит ошибки.
Result<T, E> - это перечисление с двумя вариантами:
enum Result<T, E> {
Ok(T),
Err(E),
}
T, E являются дженериками. T может быть любым значением, E может быть любой ошибкой. Два варианта Ok и Err доступны во всем мире.
Используйте Result<T, E>
, когда у вас есть что-то, что может пойти не так. Ожидается, что операция будет успешной, но могут быть случаи, когда это не удается. Когда у вас есть значение Result, вы можете сделать следующее:
- Разберитесь с государствами!
- Игнорируй это
- Паника!
- Используйте запасные варианты
- Распространять ошибки
Давайте подробно рассмотрим, что я имею в виду.
Обработка состояния ошибки
Напишем небольшой фрагмент, в котором мы хотим прочитать строку из файла. Это требует от нас
- Прочтите файл
- Прочтите строку из этого файла.
Обе операции могут вызвать ошибку std::io::Error, потому что может произойти что-то непредвиденное (файл не существует, его нельзя прочитать и т.д.). Таким образом, функция, которую мы пишем, может возвращать либо String, либо io::Error.
use std::io;
use std::fs::File;
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let f = File::open(path);
/* 1 */
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
/* 2 */
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(err) => Err(err),
}
}
Вот что происходит:
- Когда мы открываем файл по пути, он либо может вернуть дескриптор файла для работы с Ok(файл), либо вызывает ошибку Err(e). При использовании match f мы вынуждены иметь дело с двумя возможными состояниями. Либо мы назначаем дескриптор файла f (обратите внимание на затенение f), либо возвращаемся из функции, возвращая ошибку. Оператор return здесь важен, поскольку мы хотим выйти из функции.
- Затем мы хотим прочитать содержимое только что созданной строки s. Он снова может либо завершиться успешно, либо выдать ошибку. Функция f.read_to_string возвращает длину прочитанных байтов, поэтому мы можем спокойно игнорировать значение и вернуть Ok(s) с прочитанной строкой. В противном случае мы просто возвращаем ту же ошибку. Обратите внимание, что я не ставил точку с запятой в конце выражения соответствия. Поскольку это выражение, это то, что мы возвращаем из функции в этот момент.
Это может показаться очень многословным (это…), но вы видите два очень важных аспекта обработки ошибок:
- В обоих случаях ожидается, что вы будете иметь дело с двумя возможными состояниями. Вы не можете продолжить, если ничего не сделаете
- Такие функции, как затенение (привязка значения к существующему имени) и выражения, позволяют легко читать и использовать даже подробный код.
Операцию, которую мы только что сделали, часто называют разворачиванием. Потому что вы разворачиваете значение, заключенное внутри перечисления.
Кстати о разворачивании…
Игнорировать ошибки
Если вы абсолютно уверены, что ваша программа не потерпит неудачу, вы можете просто .unwrap() свои значения, используя встроенные функции:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).unwrap(); /* 1 */
let mut s = String::new();
f.read_to_string(&mut s).unwrap(); /* 1 */
Ok(s) /* 2 */
}
Вот что происходит:
- Во всех случаях, которые могут вызвать ошибку, мы вызываем unwrap(), чтобы получить значение
- Оборачиваем результат в вариант Ok, который возвращаем. Мы могли бы просто вернуть s и оставить Result<T, E> в сигнатуре нашей функции. Мы сохраняем его, потому что снова используем его в других примерах.
Сама функция unwrap() очень похожа на то, что мы делали на первом шаге, когда мы работали со всеми состояниями:
// result.rs
impl<T, E: fmt::Debug> Result<T, E> {
// ...
pub fn unwrap(&self) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
}
}
// ...
}
unwrap_failed - это ярлык к панике! макрос. Это означает, что если вы используете .unwrap() и не получите успешного результата, ваше программное обеспечение выйдет из строя.
Вы можете спросить себя: чем это отличается от ошибок, которые просто приводят к сбою программного обеспечения на других языках программирования? Ответ прост: вы должны четко заявить об этом. Rust требует, чтобы вы что-то делали, даже если он явно позволяет паниковать.
Существует множество различных функций .unwrap_, которые можно использовать в различных ситуациях. Мы рассмотрим один или два из них дальше.
Паника!
Говоря о панике, вы также можете паниковать своим собственным паническим сообщением:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).expect("Error opening file");
let mut s = String::new();
f.read_to_string(&mut s).unwrap("Error reading file to string");
Ok(s)
}
То, что делает .expect (...), очень похоже на unwrap()
impl<T, E: fmt::Debug> Result<T, E> {
// ...
pub fn expect(self, msg: &str) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed(msg, &e),
}
}
}
Но у вас в руках свои панические сообщения, которые могут вам понравиться!
Но даже если мы всегда будем откровенны, мы можем захотеть, чтобы наше программное обеспечение не паниковало и не падало всякий раз, когда мы сталкиваемся с ошибкой. Возможно, мы захотим сделать что-нибудь полезное, например, предоставить запасные варианты или… ну… собственноручно обработать ошибки.
Резервные значения
Rust имеет возможность использовать значения по умолчанию в своих перечислениях Result (и Option).
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).expect("Error opening file");
let mut s = String::new();
f.read_to_string(&mut s).unwrap_or("admin"); /* 1 */
Ok(s)
}
- «admin» может быть не лучшим вариантом для имени пользователя, но идею вы поняли. Вместо сбоя мы возвращаем значение по умолчанию в случае результата ошибки. Метод .unwrap_or_else принимает закрытие для более сложных значений по умолчанию.
Так-то лучше! Тем не менее, то, что мы до сих пор узнали, - это компромисс между слишком подробным описанием или допуском явных сбоев или, возможно, наличием резервных значений. Но можем ли мы получить и то, и другое? Краткий код и безопасность от ошибок? Мы можем!
Распространение ошибки
Одна из функций, которые мне больше всего нравятся в типах результатов Rust, - это возможность распространения ошибки. Обе функции, которые могут вызвать ошибку, имеют один и тот же тип ошибки: io::Error. Мы можем использовать оператор вопросительного знака после каждой операции, чтобы писать код для счастливого пути (только успешные результаты) и возвращать результаты ошибок, если что-то пойдет не так:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
В этом фрагменте f - обработчик файла, f.read_to_string сохраняет в s. Если что-то пойдет не так, мы вернемся из функции с Err(io::Error). Краткий код, но мы имеем дело с ошибкой на один уровень выше:
fn main() {
match read_username_from_file("user.txt") {
Ok(username) => println!("Welcome {}", username),
Err(err) => eprintln!("Whoopsie! {}", err)
};
}
Что в этом хорошего?
- Мы по-прежнему недвусмысленны, мы должны что-то делать! Вы все еще можете найти все места, где могут произойти ошибки!
- Мы можем писать краткий код, как если бы ошибок не было. Ошибки еще предстоит исправить! Либо от нас, либо от пользователей нашей функции.
Оператор вопросительного знака также работает с Option
Распространение различных ошибок
Проблема в том, что подобные методы работают только при одинаковом типе ошибок. Если у нас есть два разных типа ошибок, мы должны проявить творческий подход. Посмотрите на эту слегка измененную функцию, в которой мы открываем и читаем файлы, а затем анализируем прочитанное содержимое в u64
fn read_number_from_file(filename: &str) -> Result<u64, ???> {
let mut file = File::open(filename)?; /* 1 */
let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */
let parsed: u64 = buffer.trim().parse()?; /* 2 */
Ok(parsed)
}
- Эти две точки могут вызвать io::Error, как мы знаем из предыдущих примеров.
- Однако эта операция может вызвать ошибку ParseIntError.
Проблема в том, что мы не знаем, какую ошибку получаем во время компиляции. Это полностью зависит от запуска нашего кода. Мы можем обрабатывать каждую ошибку с помощью выражений сопоставления и возвращать собственный тип ошибки. Что верно, но снова делает наш код многословным. Или мы готовимся к «вещам, которые происходят во время выполнения»!
Ознакомьтесь с нашей слегка измененной функцией
use std::error;
fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
let mut file = File::open(filename)?; /* 1 */
let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */
let parsed: u64 = buffer.trim().parse()?; /* 2 */
Ok(parsed)
}
Вот что происходит:
- Вместо того, чтобы возвращать реализацию ошибки, мы сообщаем Rust, что идет что-то, реализующее трэйту ошибки Error.
- Поскольку мы не знаем, что это может быть во время компиляции, мы должны сделать его типажным объектом: dyn std::error::Error.
- А поскольку мы не знаем, насколько это будет большим, мы упаковываем его в Box. Умный указатель, указывающий на данные, которые в конечном итоге будут в куче
Box
И теперь наш код снова лаконичен, и нашим пользователям приходится иметь дело с возможной ошибкой.
Первый вопрос, который я задаю, когда показываю это людям на моих курсах: но можем ли мы в конечном итоге проверить, какой тип ошибки произошел? Мы можем! Метод downcast_ref() позволяет нам вернуться к исходному типу.
fn main() {
match read_number_from_file("number.txt") {
Ok(v) => println!("Your number is {}", v),
Err(err) => {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
eprintln!("Error during IO! {}", io_err)
} else if let Some(pars_err) = err.downcast_ref::<ParseIntError>() {
eprintln!("Error during parsing {}", pars_err)
}
}
};
}
Отлично!
Пользовательские ошибки
Он становится еще лучше и гибче, если вы хотите создавать собственные ошибки для своих операций. Чтобы использовать настраиваемые ошибки, ваши структуры ошибок должны реализовывать трэйту std::error::Error. Это может быть классическая структура, кортежная структура или даже единичная структура.
Вам не нужно реализовывать какие-либо функции std::error::Error, но вам нужно реализовать как трейт Debug, так и свойство Display. Причина в том, что ошибки хотят где-то печатать. Вот как выглядит пример:
#[derive(Debug)] /* 1 */
pub struct ParseArgumentsError(String); /* 2 */
impl std::error::Error for ParseArgumentsError {} /* 3 */
/* 4 */
impl Display for ParseArgumentsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
- Мы выводим трэйту Debug.
- Наша ParseArgumentsError - это структура кортежа с одним элементом: настраиваемое сообщение.
- Реализуем std::error::Error для ParseArgumentsError. Больше ничего реализовывать не нужно
- Мы реализуем Display, где выводим единственный элемент нашего кортежа.
И это все!
Anyhow…
Поскольку многие вещи, которые вы только что выучили, очень распространены, конечно, существуют крэйти, которые абстрагируют большую часть из них. Фантастический крэйт Anyhow Crate - один из них, который дает вам возможность обрабатывать ошибки на основе объектов с помощью удобных макросов и типов.
Нижняя линия
Это очень быстрое руководство по обработке ошибок в Rust. Конечно, это еще не все, но это должно помочь вам начать! Это также моя первая техническая статья по Rust, и я надеюсь, что ее будет еще много. Дайте мне знать, если вам это понравилось, и если вы обнаружите какие-либо… ха-ха… ошибки (ба-дум-ц 🥁), я просто напишу твит.