[Перевод] Обработка ошибок в Rust
Как и многие языки программирования, Rust призывает разработчика определенным способом обрабатывать ошибки. Вообще, существует два общих подхода обработки ошибок: с помощью исключений и через возвращаемые значения. И Rust предпочитает возвращаемые значения.
В этой статье мы намерены подробно изложить работу с ошибками в Rust. Более того, мы попробуем раз за разом погружаться в обработку ошибок с различных сторон, так что под конец у вас будет уверенное практическое представление о том, как все это сходится воедино.
В наивной реализации обработка ошибок в Rust может выглядеть многословной и раздражающей. Мы рассмотрим основные камни преткновения, а также продемонстрируем, как сделать обработку ошибок лаконичной и удобной, пользуясь стандартной библиотекой.
Содержание
Эта статья очень длинная, в основном потому, что мы начнем с самого начала — рассмотрения типов-сумм (sum type) и комбинаторов, и далее попытаемся последовательно объяснить подход Rust к обработке ошибок. Так что разработчики, которые имеют опыт работы с другими выразительными системами типов, могут свободно перескакивать от раздела к разделу.
Основы
Обработку ошибок можно рассматривать как вариативный анализ того, было ли некоторое вычисление выполнено успешно или нет. Как будет показано далее, ключом к удобству обработки ошибок является сокращение количества явного вариативного анализа, который должен выполнять разработчик, сохраняя при этом код легко сочетаемым с другим кодом (composability).
(Примечание переводчика: Вариативный анализ – это один из наиболее общеприменимых методов аналитического мышления, который заключается в рассмотрении проблемы, вопроса или некоторой ситуации с точки зрения каждого возможного конкретного случая. При этом рассмотрение по отдельности каждого такого случая является достаточным для того, чтобы решить первоначальный вопрос.
Важным аспектом такого подхода к решению проблем является то, что такой анализ должен быть исчерпывающим (exhaustive). Другими словами, при использовании вариативного анализа должны быть рассмотрены все возможные случаи.
В Rust вариативный анализ реализуется с помощью синтаксической конструкции match
. При этом компилятор гарантирует, что такой анализ будет исчерпывающим: если разработчик не рассмотрит все возможные варианты заданного значения, программа не будет скомпилирована.)
Сохранять сочетаемость кода важно, потому что без этого требования мы могли бы просто получать panic
всякий раз, когда мы сталкивались бы с чем-то неожиданным. (panic
вызывает прерывание текущего потока и, в большинстве случаев, приводит к завершению всей программы.) Вот пример:
// Попробуйте угадать число от 1 до 10.
// Если заданное число соответствует тому, что мы загадали, возвращается true.
// В противном случае возвращается false.
fn guess(n: i32) -> bool {
if n < 1 || n > 10 {
panic!("Неверное число: {}", n);
}
n == 5
}
fn main() {
guess(11);
}
Если попробовать запустить этот код, то программа аварийно завершится с сообщением вроде этого:
thread '<main>' panicked at 'Неверное число: 11', src/bin/panic-simple.rs:6
Вот другой, менее надуманный пример. Программа, которая принимает число в качестве аргумента, удваивает его значение и печатает на экране.
use std::env;
fn main() {
let mut argv = env::args();
let arg: String = argv.nth(1).unwrap(); // ошибка 1
let n: i32 = arg.parse().unwrap(); // ошибка 2
println!("{}", 2 * n);
}
Если вы запустите эту программу без параметров (ошибка 1) или если первый параметр будет не целым числом (ошибка 2), программа завершится паникой, так же, как и в первом примере.
Обработка ошибок в подобном стиле подобна слону в посудной лавке. Слон будет нестись в направлении, в котором ему вздумается, и крушить все на своем пути.
Объяснение unwrap
В предыдущем примере мы утверждали, что программа будет просто паниковать, если будет выполнено одно из двух условий для возникновения ошибки, хотя, в отличии от первого примера, в коде программы нет явного вызова panic
. Тем не менее, вызов panic
встроен в вызов unwrap
.
Вызывать unwrap
в Rust подобно тому, что сказать: «Верни мне результат вычислений, а если произошла ошибка, просто паникуй и останавливай программу». Мы могли бы просто показать исходный код функции unwrap
, ведь это довольно просто, но перед этим мы должны разобратся с типами Option
и Result
. Оба этих типа имеют определенный для них метод unwrap
.
Тип Option
Тип Option
объявлен в стандартной библиотеке:
enum Option<T> {
None,
Some(T),
}
Тип Option
— это способ выразить возможность отсутствия чего бы то ни было, используя систему типов Rust. Выражение возможности отсутствия через систему типов является важной концепцией, поскольку такой подход позволяет компилятору требовать от разработчика обрабатывать такое отсутствие. Давайте взглянем на пример, который пытается найти символ в строке:
// Поиск Unicode-символа `needle` в `haystack`. Когда первый символ найден,
// возвращается побайтовое смещение для этого символа. Иначе возвращается `None`.
fn find(haystack: &str, needle: char) -> Option<usize> {
for (offset, c) in haystack.char_indices() {
if c == needle {
return Some(offset);
}
}
None
}
Обратите внимание, что когда эта функция находит соответствующий символ, она возвращает не просто offset
. Вместо этого она возвращает Some(offset)
. Some
— это вариант или конструктор значения для типа Option
. Его можно интерпретировать как функцию типа fn<T>(value: T) -> Option<T>
. Соответственно, None
— это также конструктор значения, только у него нет параметров. Его можно интерпретировать как функцию типа fn<T>() -> Option<T>
.
Может показаться, что мы подняли много шума из ничего, но это только половина истории. Вторая половина — это использование функции find
, которую мы написали. Давайте попробуем использовать ее, чтобы найти расширение в имени файла.
fn main() {
let file_name = "foobar.rs";
match find(file_name, '.') {
None => println!("Расширение файла не найдено."),
Some(i) => println!("Расширение файла: {}", &file_name[i+1..]),
}
}
Этот код использует сопоставление с образцом чтобы выполнить вариативный анализ для возвращаемого функцией find
значения Option<usize>
. На самом деле, вариативный анализ является единственным способом добраться до значения, сохраненного внутри Option<T>
. Это означает, что вы, как разработчик, обязаны обработать случай, когда значение Option<T>
равно None
, а не Some(t)
.
Но подождите, как насчет unwrap
, который мы до этого
использовали? Там не было никакого вариативного анализа! Вместо этого, вариативный анализ был перемещен внутрь метода unwrap
. Вы можете сделать это самостоятельно, если захотите:
enum Option<T> {
None,
Some(T),
}
impl<T> Option<T> {
fn unwrap(self) -> T {
match self {
Option::Some(val) => val,
Option::None =>
panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
Метод unwrap
абстрагирует вариативный анализ. Это именно то, что делает unwrap
удобным в использовании. К сожалению, panic!
означает, что unwrap
неудобно сочетать с другим кодом: это слон в посудной лавке.
Совмещение значений Option<T>
В предыдущем примере мы рассмотрели, как можно воспользоватся find
для того, чтобы получить расширение имени файла. Конечно, не во всех именах файлов можно найти .
, так что существует вероятность, что имя некоторого файла не имеет расширения. Эта возможность отсутствия интерпретируется на уровне типов через использование Option<T>
. Другими словами, компилятор заставит нас рассмотреть возможность того, что расширение не существует. В нашем случае мы просто печатаем сообщение об этом.
Получение расширения имени файла — довольно распространенная операция, так что имеет смысл вынести код в отдельную функцию:
// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension_explicit(file_name: &str) -> Option<&str> {
match find(file_name, '.') {
None => None,
Some(i) => Some(&file_name[i+1..]),
}
}
(Подсказка: не используйте этот код. Вместо этого используйте метод extension
из стандартной библиотеки.)
Код выглядит простым, но его важный аспект заключается в том, что функция find
заставляет нас рассмотреть вероятность отсутствия значения. Это хорошо, поскольку это означает, что компилятор не позволит нам случайно забыть о том варианте, когда в имени файла отсутствует расширение. С другой стороны, каждый раз выполнять явный вариативный анализ, подобно тому, как мы делали это в extension_explicit
, может стать немного утомительным.
На самом деле, вариативный анализ в extension_explicit
является очень распространенным паттерном: если Option<T>
владеет определенным значением T
, то выполнить его преобразование с помощью функции, а если нет — то просто вернуть None
.
Rust поддерживает параметрический полиморфизм, так что можно очень легко объявить комбинатор, который абстрагирует это поведение:
fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A {
match option {
None => None,
Some(value) => Some(f(value)),
}
}
В действительности, map
определен в стандартной библиотеке как метод Option<T>
.
Вооружившись нашим новым комбинатором, мы можем переписать наш метод extension_explicit
так, чтобы избавиться от вариативного анализа:
// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension(file_name: &str) -> Option<&str> {
find(file_name, '.').map(|i| &file_name[i+1..])
}
Есть еще одно поведение, которое можно часто встретить — это использование значения по-умолчанию в случае, когда значение Option
равно None
. К примеру, ваша программа может считать, что расширение файла равно rs
в случае, если на самом деле оно отсутствует.
Легко представить, что этот случай вариативного анализа не специфичен только для расширений файлов — такой подход может работать с любым Option<T>
:
fn unwrap_or<T>(option: Option<T>, default: T) -> T {
match option {
None => default,
Some(value) => value,
}
}
Хитрость только в том, что значение по-умолчанию должно иметь тот же тип, что и значение, которое может находится внутри Option<T>
. Использование этого метода элементарно:
fn main() {
assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv");
assert_eq!(extension("foobar").unwrap_or("rs"), "rs");
}
(Обратите внимание, что unwrap_or
объявлен как метод Option<T>
в стандартной библиотеке, так что мы воспользовались им вместо функции, которую мы объявили ранее. Не забудьте также изучить более общий метод unwrap_or_else
).
Существует еще один комбинатор, на который, как мы думаем, стоит обратить особое внимание: and_then
. Он позволяет легко сочетать различные вычисления, которые допускают возможность отсутствия. Пример — большая часть кода в этом разделе, который связан с определением расширения заданного имени файла. Чтобы делать это, нам для начала необходимо узнать имя файла, которое как правило извлекается из файлового пути. Хотя большинство файловых путей содержат имя файла, подобное нельзя сказать обо всех файловых путях. Примером могут послужить пути .
, ..
или /
.
Таким образом, мы определили задачу нахождения расширения заданного файлового пути. Начнем с явного вариативного анализа:
fn file_path_ext_explicit(file_path: &str) -> Option<&str> {
match file_name(file_path) {
None => None,
Some(name) => match extension(name) {
None => None,
Some(ext) => Some(ext),
}
}
}
fn file_name(file_path: &str) -> Option<&str> {
unimplemented!() // опустим реализацию
}
Можно подумать, мы могли бы просто использовать комбинатор map
, чтобы уменьшить вариативный анализ, но его тип не совсем подходит. Дело в том, что map
принимает функцию, которая делает что-то только с внутренним значением. Результат такой функции всегда оборачивается в Some
. Вместо этого, нам нужен метод, похожий map
, но который позволяет вызывающему передать еще один Option
. Его общая реализация даже проще, чем map
:
fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A>
where F: FnOnce(T) -> Option<A> {
match option {
None => None,
Some(value) => f(value),
}
}
Теперь мы можем переписать нашу функцию file_path_ext
без явного вариативного анализа:
fn file_path_ext(file_path: &str) -> Option<&str> {
file_name(file_path).and_then(extension)
}
Тип Option
имеет много других комбинаторов определенных в стандартной библиотеке. Очень полезно просмотреть этот список и ознакомиться с доступными методами — они не раз помогут вам сократить количество вариативного анализа. Ознакомление с этими комбинаторами окупится еще и потому, что многие из них определены с аналогичной семантикой и для типа Result
, о котором мы поговорим далее.
Комбинаторы делают использование типов вроде Option
более удобным, ведь они сокращают явный вариативный анализ. Они также соответствуют требованиям сочетаемости, поскольку они позволяют вызывающему обрабатывать возможность отсутствия результата собственным способом. Такие методы, как unwrap
, лишают этой возможности, ведь они будут паниковать в случае, когда Option<T>
равен None
.
Тип Result
Тип Result
также определен в стандартной библиотеке:
enum Result<T, E> {
Ok(T),
Err(E),
}
Тип Result
— это продвинутая версия Option
. Вместо того, чтобы выражать возможность отсутствия, как это делает Option
, Result
выражает возможность ошибки. Как правило, ошибки необходимы для объяснения того, почему результат определенного вычисления не был получен. Строго говоря, это более общая форма Option
. Рассмотрим следующий псевдоним типа, который во всех смыслах семантически эквивалентен реальному Option<T>
:
type Option<T> = Result<T, ()>;
Здесь второй параметр типа Result
фиксируется и определяется через ()
(произносится как «unit» или «пустой кортеж»). Тип ()
имеет ровно одно значение — ()
. (Да, это тип и значение этого типа, которые выглядят одинаково!)
Тип Result
— это способ выразить один из двух возможных исходов вычисления. По соглашению, один исход означает ожидаемый результат или "Ok
", в то время как другой исход означает исключительную ситуацию или "Err
".
Подобно Option
, тип Result
имеет метод unwrap
, определенный в стандартной библиотеке. Давайте объявим его самостоятельно:
impl<T, E: ::std::fmt::Debug> Result<T, E> {
fn unwrap(self) -> T {
match self {
Result::Ok(val) => val,
Result::Err(err) =>
panic!("called `Result::unwrap()` on an `Err` value: {:?}", err),
}
}
}
Это фактически то же самое, что и определение Option::unwrap
, за исключением того, что мы добавили значение ошибки в сообщение panic!
. Это делает отладку проще, но это вынуждает нас требовать от типа-параметра E
(который представляет наш тип ошибки) реализации Debug
. Поскольку подавляющее большинство типов должны реализовывать Debug
, обычно на практике такое ограничение не мешает. (Реализация Debug
для некоторого типа просто означает, что существует разумный способ печати удобочитаемого описания значения этого типа.)
Окей, давайте перейдем к примеру.
Преобразование строки в число
Стандартная библиотека Rust позволяет элементарно преобразовывать строки в целые числа. На самом деле это настолько просто, что возникает соблазн написать что-то вроде:
fn double_number(number_str: &str) -> i32 {
2 * number_str.parse::<i32>().unwrap()
}
fn main() {
let n: i32 = double_number("10");
assert_eq!(n, 20);
}
Здесь вы должны быть скептически настроены по-поводу вызова unwrap
. Если строку нельзя распарсить как число, вы получите панику:
thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', /home/rustbuild/src/rust-buildbot/slave/beta-dist-rustc-linux/build/src/libcore/result.rs:729
Это довольно неприятно, и если бы подобное произошло в используемой вами библиотеке, вы могли бы небезосновательно разгневаться. Так что нам стоит попытаться обработать ошибку в нашей функции, и пусть вызывающий сам решит что с этим делать. Это означает необходимость изменения типа, который возвращается double_number
. Но на какой? Чтобы понять это, необходимо посмотреть на сигнатуру метода parse
из стандартной библиотеки:
impl str {
fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}
Хмм. По крайней мере мы знаем, что должны использовать Result
. Вполне возможно, что метод мог возвращать Option
. В конце концов, строка либо парсится как число, либо нет, не так ли? Это, конечно, разумный путь, но внутренняя реализация знает почему строка не распарсилась как целое число. (Это может быть пустая строка, или неправильные цифры, слишком большая или слишком маленькая длина и т.д.) Таким образом, использование Result
имеет смысл, ведь мы хотим предоставить больше информации, чем просто «отсутствие». Мы хотим сказать, почему преобразование не удалось. Вам стоит рассуждать похожим образом, когда вы сталкиваетесь с выбором между Option
и Result
. Если вы можете предоставить подробную информацию об ошибке, то вам, вероятно, следует это сделать. (Позже мы поговорим об этом подробнее.)
Хорошо, но как мы запишем наш тип возвращаемого значения? Метод parse
является обобщенным (generic) для всех различных типов чисел из стандартной библиотеки. Мы могли бы (и, вероятно, должны) также сделать нашу функцию обобщенной, но давайте пока остановимся на конкретной реализации. Нас интересует только тип i32
, так что нам стоит найти его реализацию FromStr
(выполните поиск в вашем браузере по строке «FromStr») и посмотреть на его ассоциированный тип Err
. Мы делаем это чтобы определить конкретный тип ошибки. В данном случае, это std::num::ParseIntError
. Наконец, мы можем переписать нашу функцию:
use std::num::ParseIntError;
fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
match number_str.parse::<i32>() {
Ok(n) => Ok(2 * n),
Err(err) => Err(err),
}
}
fn main() {
match double_number("10") {
Ok(n) => assert_eq!(n, 20),
Err(err) => println!("Error: {:?}", err),
}
}
Неплохо, но нам пришлось написать гораздо больше кода! И нас опять раздражает вариативный анализ.
Комбинаторы спешат на помощь! Подобно Option
, Result
имеет много комбинаторов, определенных в качестве методов. Существует большой список комбинаторов, общих между Result
и Option
. И map
входит в этот список:
use std::num::ParseIntError;
fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
number_str.parse::<i32>().map(|n| 2 * n)
}
fn main() {
match double_number("10") {
Ok(n) => assert_eq!(n, 20),
Err(err) => println!("Error: {:?}", err),
}
}
Все ожидаемые методы реализованы для Result
, включая unwrap_or
и and_then
. Кроме того, поскольку Result
имеет второй параметр типа, существуют комбинаторы, которые влияют только на значение ошибки, такие как map_err
(аналог map
) и or_else
(аналог and_then
).
Создание псевдонима типа Result
В стандартной библиотеке можно часто увидеть типы вроде Result<i32>
. Но постойте, ведь мы определили Result
с двумя параметрами типа. Как мы можем обойти это, указывая только один из них? Ответ заключается в определении псевдонима типа Result
, который фиксирует один из параметров конкретным типом. Обычно фиксируется тип ошибки. Например, наш предыдущий пример с преобразованием строк в числа можно переписать так:
use std::num::ParseIntError;
use std::result;
type Result<T> = result::Result<T, ParseIntError>;
fn double_number(number_str: &str) -> Result<i32> {
unimplemented!();
}
Зачем мы это делаем? Что ж, если у нас есть много функций, которые могут вернуть ParseIntError
, то гораздо удобнее определить псевдоним, который всегда использует ParseIntError
, так что мы не будем повторяться все время.
Самый заметный случай использования такого подхода в стандартной библиотеке — псевдоним io::Result
. Как правило, достаточно писать io::Result<T>
, чтобы было понятно, что вы используете псевдоним типа из модуля io
, а не обычное определение из std::result
. (Этот подход также используется для fmt::Result
)
Короткое отступление: unwrap
— не обязательно зло
Если вы были внимательны, то возможно заметили, что я занял довольно жесткую позицию по отношению к методам вроде unwrap
, которые могут вызвать panic
и прервать исполнение вашей программы. В основном, это хороший совет.
Тем не менее, unwrap
все-таки можно использовать разумно. Факторы, которые оправдывают использование unwrap
, являются несколько туманными, и разумные люди могут со мной не согласиться. Я кратко изложу свое мнение по этому вопросу:
- Примеры и «грязный» код. Когда вы пишете просто пример или быстрый скрипт, обработка ошибок просто не требуется. Для подобных случаев трудно найти что-либо удобнее чем
unwrap
, так что здесь его использование очень привлекательно. - Паника указывает на ошибку в программе. Если логика вашего кода должна предотвращать определенное поведение (скажем, получение элемента из пустого стека), то использование
panic
также допустимо. Дело в том, что в этом случае паника будет сообщать о баге в вашей программе. Это может происходить явно, например от неудачного вызоваassert!
, или происходить потому, что индекс по массиву находится за пределами выделенной памяти.
Вероятно, это не исчерпывающий список. Кроме того, при использовании Option
зачастую лучше использовать метод expect
. Этот метод делает ровно то же, что и unwrap
, за исключением того, что в случае паники напечатает ваше сообщение. Это позволит лучше понять причину ошибки, ведь будет показано конкретное сообщение, а не просто «called unwrap on a None
value».
Мой совет сводится к следующему: используйте здравый смысл. Есть причины, по которым слова вроде «никогда не делать X» или «Y считается вредным» не появятся в этой статье. У любых решений существуют компромиссы, и это ваша задача, как разработчика, определить, что именно является приемлемым для вашего случая. Моя цель состоит только в том, чтобы помочь вам оценить компромиссы как можно точнее.
Теперь, когда мы рассмотрели основы обработки ошибок в Rust и разобрались с unwrap
, давайте подробнее изучим стандартную библиотеку.
Работа с несколькими типами ошибок
До этого момента мы расматривали обработку ошибок только для случаев, когда все сводилось либо только к Option<T>
, либо только к Result<T, SomeError>
. Но что делать, когда у вас есть и Option
, и Result
? Или если у вас есть Result<T, Error1>
и Result<T, Error2>
? Наша следующуя задача — обработка композиции различных типов ошибок, и это будет главной темой на протяжении всей этой статьи.
Совмещение Option
и Result
Пока что мы говорили о комбинаторах, определенных для Option
, и комбинаторах, определенных для Result
. Эти комбинаторы можно использовать для того, чтобы сочетать результаты различных вычислений, не делая подробного вариативного анализа.
Конечно, в реальном коде все происходит не так гладко. Иногда у вас есть сочетания типов Option
и Result
. Должны ли мы прибегать к явному вариативному анализу, или можно продолжить использовать комбинаторы?
Давайте на время вернемся к одному из первых примеров в этой статье:
use std::env;
fn main() {
let mut argv = env::args();
let arg: String = argv.nth(1).unwrap(); // ошибка 1
let n: i32 = arg.parse().unwrap(); // ошибка 2
println!("{}", 2 * n);
}
Учитывая наши знания о типах Option
и Result
, а также их различных комбинаторах, мы можем попытаться переписать этот код так, чтобы ошибки обрабатывались должным образом, и программа не паниковала в случае ошибки.
Ньюанс заключается в том, что argv.nth(1)
возвращает Option
, в то время как arg.parse()
возвращает Result
. Они не могут быть скомпонованы непосредственно. Когда вы сталкиваетесь одновременно с Option
и Result
, обычно наилучшее решение — преобразовать Option
в Result
. В нашем случае, отсутствие параметра командной строки (из env::args()
) означает, что пользователь не правильно вызвал программу. Мы могли бы просто использовать String
для описания ошибки. Давайте попробуем:
use std::env;
fn double_arg(mut argv: env::Args) -> Result<i32, String> {
argv.nth(1)
.ok_or("Please give at least one argument".to_owned())
.and_then(|arg| arg.parse::<i32>().map_err(|err| err.to_string()))
}
fn main() {
match double_arg(env::args()) {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}
Раcсмотрим пару новых моментов на этом примере. Во-первых, использование комбинатора Option::ok_or
. Это один из способов преобразования Option
в Result
. Такое преобразование требует явного определения ошибки, которую необходимо вернуть в случае, когда значение Option
равно None
. Как и для всех комбинаторов, которые мы рассматривали, его объявление очень простое:
fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> {
match option {
Some(val) => Ok(val),
None => Err(err),
}
}
Второй новый комбинатор, который мы использовали — Result::map_err
. Это то же самое, что и Result::map
, за исключением того, функция применяется к ошибке внутри Result
. Если значение Result
равно Оk(...)
, то оно возвращается без изменений.
Мы используем map_err
, потому что нам необходимо привести все ошибки к одинаковому типу (из-за нашего использования and_then
). Поскольку мы решили преобразовывать Option<String>
(из argv.nth(1)
) в Result<String, String>
, мы также обязаны преобразовывать ParseIntError
из arg.parse()
в String
.
Ограничения комбинаторов
Работа с IO и анализ входных данных — очень типичные задачи, и это то, чем лично я много занимаюсь с Rust. Так что мы будем использовать IO и различные процедуры анализа как примеры обработки ошибок.
Давайте начнем с простого. Поставим задачу открыть файл, прочесть все его содержимое и преобразовать это содержимое в число. После этого нужно будет умножить значение на 2
и распечатать результат.
Хоть я и пытался убедить вас не использовать unwrap
, иногда бывает полезным для начала написать код с unwrap
. Это позволяет сосредоточиться на проблеме, а не на обработке ошибок, и это выявляет места, где надлежащая обработка ошибок необходима. Давайте начнем с того, что напишем просто работающий код, а затем отрефакторим его для лучшей обработки ошибок.
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) -> i32 {
let mut file = File::open(file_path).unwrap(); // ошибка 1
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap(); // ошибка 2
let n: i32 = contents.trim().parse().unwrap(); // ошибка 3
2 * n
}
fn main() {
let doubled = file_double("foobar");
println!("{}", doubled);
}
(Замечание: Мы используем AsRef
по тем же причинам, почему он используется в std::fs::File::open
. Это позволяет удобно использовать любой тип строки в качестве пути к файлу.)
У нас есть три потенциальные ошибки, которые могут возникнуть:
- Проблема при открытии файла.
- Проблема при чтении данных из файла.
- Проблема при преобразовании данных в число.
Первые две проблемы определяются типом std::io::Error
. Мы знаем это из типа возвращаемого значения методов std::fs::File::open
и std::io::Read::read_to_string
. (Обратите внимание, что они оба используют концепцию с псевдонимом типа Result
, описанную ранее. Если вы кликните на тип Result
, вы увидите псевдоним типа, и следовательно, лежащий в основе тип io::Error
.) Третья проблема определяется типом std::num::ParseIntError
. Кстати, тип io::Error
часто используется по всей стандартной библиотеке. Вы будете видеть его снова и снова.
Давайте начнем рефакторинг функции file_double
. Для того, чтобы эту функцию можно было сочетать с остальным кодом, она не должна паниковать, если какие-либо из перечисленных выше ошибок действительно произойдут. Фактически, это означает, что функция должна возвращать ошибку, если любая из возможных операций завершилась неудачей. Проблема состоит в том, что тип возвращаемого значения сейчас i32
, который не дает нам никакого разумного способа сообщить об ошибке. Таким образом, мы должны начать с изменения типа возвращаемого значения с i32
на что-то другое.
Первое, что мы должны решить: какой из типов использовать: Option
или Result
? Мы, конечно, могли бы с легкостью использовать Option
. Если какая-либо из трех ошибок происходит, мы могли бы просто вернуть None
. Это будет работать, и это лучше, чем просто паниковать, но мы можем сделать гораздо лучше. Вместо этого, мы будем сообщать некоторые детали о возникшей проблеме. Поскольку мы хотим выразить возможность ошибки, мы должны использовать Result<i32, E>
. Но каким должен быть тип E
? Поскольку может возникнуть два разных типа ошибок, мы должны преобразовать их к общему типу. Одним из таких типов является String
. Давайте посмотрим, как это отразится на нашем коде:
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
File::open(file_path)
.map_err(|err| err.to_string())
.and_then(|mut file| {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| err.to_string())
.map(|_| contents)
})
.and_then(|contents| {
contents.trim().parse::<i32>()
.map_err(|err| err.to_string())
})
.map(|n| 2 * n)
}
fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Ошибка: {}", err),
}
}
Выглядит немного запутанно. Может потребоваться довольно много практики, прежде вы сможете писать такое. Написание кода в таком стиле называется следованием за типом. Когда мы изменили тип возвращаемого значения file_double
на Result<i32, String>
, нам пришлось начать подбирать правильные комбинатороы. В данном случае мы использовали только три различных комбинатора: and_then
, map
и map_err
.
Комбинатор and_then
используется для объединения по цепочке нескольких вычислений, где каждое вычисление может вернуть ошибку. После открытия файла есть еще два вычисления, которые могут завершиться неудачей: чтение из файла и преобразование содержимого в число. Соответственно, имеем два вызова and_then
.
Комбинатор map
используется, чтобы применить функцию к значению Ok(...)
типа Result
. Например, в самом последнем вызове, map
умножает значение Ok(...)
(типа i32
) на 2
. Если ошибка произошла до этого момента, эта операция была бы пропущена. Это следует из определения map
.
Комбинатор map_err
— это уловка, которая позволяют всему этому заработать. Этот комбинатор, такой же, как и map
, за исключением того, что применяет функцию к Err(...)
значению Result
. В данном случае мы хотим привести все наши ошибки к одному типу — String
. Поскольку как io::Error
, так и num::ParseIntError
реализуют ToString
, мы можем вызвать метод to_string
, чтобы выполнить преобразование.
Не смотря на все сказанное, код по-прежнему выглядит запутанным. Мастерство использования комбинаторов является важным, но у них есть свои недостатки. Давайте попробуем другой подход: преждевременный возврат.
Преждевременный return
Давайте возьмем код из предыдущего раздела и перепишем его с применением раннего возврата. Ранний return
позволяет выйти из функции досрочно. Мы не можем выполнить return
для file_double
внутри замыкания, поэтому нам необходимо вернуться к явному вариативному анализу.
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = match File::open(file_path) {
Ok(file) => file,
Err(err) => return Err(err.to_string()),
};
let mut contents = String::new();
if let Err(err) = file.read_to_string(&mut contents) {
return Err(err.to_string());
}
let n: i32 = match contents.trim().parse() {
Ok(n) => n,
Err(err) => return Err(err.to_string()),
};
Ok(2 * n)
}
fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Ошибка: {}", err),
}
}
Кто-то может обосновано не согласиться с тем, что этот код лучше, чем тот, который использует комбинаторы, но если вы не знакомы с комбинаторами, на мой взгляд, этот код будет выглядеть проще. Он выполняет явный вариативный анализ с помощью match
и if let
. Если происходит ошибка, мы просто прекращаем выполнение функции и возвращаем ошибку (после преобразования в строку).
Разве это не шаг назад? Ранее мы говорили, что ключ к удобной обработке ошибок — сокращение явного вариативного анализа, но здесь мы вернулись к тому, с чего начинали. Оказывается, существует несколько способов его уменьшения. И комбинаторы — не единственный путь.
Макрос try!
Краеугольный камень обработки ошибок в Rust — это макрос try!
. Этот макрос абстрагирует анализ вариантов так же, как и комбинаторы, но в отличие от них, он также абстрагирует поток выполнения. А именно, он умеет абстрагировать идею досрочного возврата, которую мы только что реализовали.
Вот упрощенное определение макроса `try!:
macro_rules! try {
($e:expr) => (match $e {
Ok(val) => val,
Err(err) => return Err(err),
});
}
(Реальное определение выглядит немного сложнее. Мы обсудим это далее).
Использование макроса try!
может очень легко упростить наш последний пример. Поскольку он выполняет анализ вариантов и досрочной возврат из функции, мы получаем более плотный код, который легче читать:
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
let mut contents = String::new();
try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
Ok(2 * n)
}
fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Ошибка: {}", err),
}
}
Вызов map_err
по-прежнему необходим, учитывая наше определение try!
, поскольку ошибки все еще должны быть преобразованы в String
. Хорошей новостью является то, что в ближайшее время мы узнаем, как убрать все эти вызовы map_err
! Плохая новость состоит в том, что для этого нам придется кое-что узнать о паре важных типажей из стандартной библиотеки.
Объявление собственного типа ошибки
Прежде чем мы погрузимся в аспекты некоторых типажей из стандартной библиотеки, связанных с ошибками, я бы хотел завершить этот раздел отказом от использования String
как типа ошибки в наших примерах.
Использование String
в том стиле, в котором мы использовали его в предыдущих примерах удобно потому, что достаточно легко конвертировать любые ошибки в строки, или даже создавать свои собственные ошибки на ходу. Тем не менее, использование типа String
для ошибок имеет некоторые недостатки.
Первый недостаток в том, что сообщения об ошибках, как правило, загромождают код. Можно определять сообщения об ошибках в другом месте, но это поможет только если вы необыкновенно дисциплинированны, поскольку очень заманчиво вставлять сообщения об ошибках прямо в код. На самом деле, мы именно этим и занимались предыдущем примере.
Второй и более важный недостаток заключается в том, что использование String
чревато потерей информации. Другими словами, если все ошибки будут преобразованы в строки, то когда мы будем возвращать их вызывающей стороне, они не будут иметь никакого смысла. Единственное разумное, что вызывающая сторона может сделать с ошибкой типа String
— это показать ее пользователю. Безусловно, можно проверить строку по значению, чтобы определить тип ошибки, но такой подход не может похвастаться надежностью. (Правда, в гораздо большей степени это недостаток для библиотек, чем для конечных приложений).
Например, тип io::Error
включает в себя тип io::ErrorKind
, который является структурированными данными, представляющими то, что пошло не так во время выполнения операции ввода-вывода. Это важно, поскольку может возникнуть необходимость по-разному реагировать на разли