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

Перевод | Автор оригинала: Andrew Gallant

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

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

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

Целевая аудитория: новички в Rust, которые еще не знакомы с его идиомами обработки ошибок. Некоторое знакомство с Rust полезно. (В этой статье широко используются некоторые стандартные трэйты и очень мало используются замыкания и макросы.)

Краткие заметки

Все примеры кода в этом посте компилируются с помощью Rust 1.0.0-beta.5. Они должны продолжать работать после выхода стабильной версии Rust 1.0.

Весь код можно найти и скомпилировать в репозитории моего блога.

В Rust Book есть раздел по обработке ошибок. Он дает очень краткий обзор, но (пока) не дает достаточно подробностей, особенно при работе с некоторыми из последних дополнений к стандартной библиотеке.

Запускаем код!

Если вы хотите запустить какой-либо из приведенных ниже примеров кода, следующее должно работать:

$ git clone git://github.com/BurntSushi/blog
$ cd blog/code/rust-error-handling
$ cargo run --bin NAME-OF-CODE-SAMPLE [ args ... ]

Каждый образец кода помечен своим именем. (Примеры кода без имени недоступны для запуска таким способом. Извините.)

Оглавление

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

Основы

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

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

// Guess a number between 1 and 10.
// If it matches the number I had in mind, return true. Else, return false.
fn guess(n: i32) -> bool {
    if n < 1 || n > 10 {
        panic!("Invalid number: {}", n);
    }
    n == 5
}

fn main() {
    guess(11);
}

(Если хотите, этот код легко запустить.)

Если вы попытаетесь запустить этот код, программа выйдет из строя с таким сообщением:

поток '

' запаниковал из-за 'Неверный номер: 11', src/bin / panic-simple.rs: 5

Вот еще один пример, который немного менее надуманный. Программа, которая принимает в качестве аргумента целое число, удваивает его и печатает.

// unwrap-double.rs
use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // error 1
    let n: i32 = arg.parse().unwrap(); // error 2
    println!("{}", 2 * n);
}

// $ cargo run --bin unwrap-double 5
// 10

Если вы дадите этой программе нулевые аргументы (ошибка 1) или если первый аргумент не является целым числом (ошибка 2), программа запаникует, как и в первом примере.

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

Объяснение распаковки

В предыдущем примере (unwrap-double) я утверждал, что программа просто запаникует, если достигнет одного из двух условий ошибки, но в программе нет явного вызова паники, как в первом примере (panic-simple). Это потому, что паника встроена в вызовы развертки.

«Развернуть» что-то в Rust - значит сказать: «Дайте мне результат вычислений, и если произошла ошибка, просто запаникуйте и остановите программу». Было бы лучше, если бы я просто показал код для разворачивания, потому что он настолько прост, но для этого нам сначала нужно изучить типы Option и Result. Для обоих этих типов определен метод unwrap.

Тип опции

Тип Option определен в стандартной библиотеке:

// option-def.rs
enum Option<T> {
    None,
    Some(T),
}

Тип Option - это способ использования системы типов Rust для выражения возможности отсутствия. Кодирование возможности отсутствия в системе типов является важной концепцией, потому что это заставит компилятор вынудить программиста обработать это отсутствие. Давайте посмотрим на пример, который пытается найти символ в строке:

// option-ex-string-find.rs

// Searches `haystack` for the Unicode character `needle`. If one is found, the
// byte offset of the character is returned. Otherwise, `None` is returned.
fn find(haystack: &str, needle: char) -> Option<usize> {
    for (offset, c) in haystack.char_indices() {
        if c == needle {
            return Some(offset);
        }
    }
    None
}

(Совет: не используйте этот код. Вместо этого используйте метод find из стандартной библиотеки.)

Обратите внимание: когда эта функция находит соответствующий символ, она не просто возвращает смещение. Вместо этого он возвращает Some(смещение). Некоторые из них являются вариантом или конструктором значения для типа Option. Вы можете думать об этом как о функции с типом fn(value: T) -> Option. Соответственно, None также является конструктором значения, за исключением того, что у него нет аргументов. Вы можете рассматривать None как функцию с типом fn() -> Option.

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

// option-ex-string-find.rs
fn main_find() {
    let file_name = "foobar.rs";
    match find(file_name, '.') {
        None => println!("No file extension found."),
        Some(i) => println!("File extension: {}", &file_name[i+1..]),
    }
}

Этот код использует сопоставление с образцом для анализа случая для Option, возвращаемого функцией поиска. Фактически, анализ случая - единственный способ получить значение, хранящееся внутри Option. Это означает, что вы, как программист, должны обрабатывать случай, когда Option имеет значение None вместо Some(t).

Но подождите, а как насчет развёртки, используемой в развёртке-двойнике? Там не было анализа случая! Вместо этого анализ случая был помещен в метод разворачивания для вас. Вы можете определить это сами, если хотите:

// option-def-unwrap.rs
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"),
        }
    }
}

Метод разворачивания абстрагируется от анализа случая. Это как раз то, что делает развёртку эргономичной в использовании. К сожалению, паника! означает, что разворачивание невозможно составить: это бык в посудной лавке.

Значения параметра Composing Option

В option-ex-string-find мы увидели, как использовать find для обнаружения расширения в имени файла. Конечно, не все имена файлов имеют расширение. в них, поэтому возможно, что у имени файла нет расширения. Эта возможность отсутствия кодируется в типы с помощью Option. Другими словами, компилятор заставит нас учитывать возможность того, что расширения не существует. В нашем случае мы просто распечатываем сообщение, в котором говорится как таковое.

Получение расширения имени файла - довольно распространенная операция, поэтому имеет смысл поместить ее в функцию:

// option-ex-string-find.rs

// Returns the extension of the given file name, where the extension is defined
// as all characters succeeding the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension_explicit(file_name: &str) -> Option<&str> {
    match find(file_name, '.') {
        None => None,
        Some(i) => Some(&file_name[i+1..]),
    }
}

(Совет: не используйте этот код. Вместо этого используйте метод расширения из стандартной библиотеки.)

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

Фактически, анализ case в extension_explicit следует очень распространенному шаблону: сопоставить функцию со значением внутри Option, если только опция не является None, и в этом случае просто верните None.

В Rust есть параметрический полиморфизм, поэтому очень легко определить комбинатор, который абстрагирует этот шаблон:

// option-map.rs
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)),
    }
}

Действительно, карта определяется как метод в Option в стандартной библиотеке.

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

// option-ex-string-find.rs

// Returns the extension of the given file name, where the extension is defined
// as all characters succeeding the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension(file_name: &str) -> Option<&str> {
    find(file_name, '.').map(|i| &file_name[i+1..])
}

Еще один шаблон, который я считаю очень распространенным, - это присвоение значения по умолчанию тому случаю, когда значение параметра равно None. Например, ваша программа предполагает, что расширение файла - rs, даже если его нет. Как вы могли догадаться, анализ случая для этого не относится к расширениям файлов - он может работать с любым Option:

// option-unwrap-or.rs

fn unwrap_or<T>(option: Option<T>, default: T) -> T {
    match option {
        None => default,
        Some(value) => value,
    }
}

Уловка здесь в том, что значение по умолчанию должно иметь тот же тип, что и значение, которое может быть внутри Option. В нашем случае использовать его очень просто:

// option-ex-string-find.rs

fn main() {
    assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv");
    assert_eq!(extension("foobar").unwrap_or("rs"), "rs");
}

(Обратите внимание, что unwrap_or определен как метод в Option в стандартной библиотеке, поэтому мы используем его здесь вместо автономной функции, которую мы определили выше. Не забудьте проверить более общий метод unwrap_or_else.)

Есть еще один комбинатор, на который, я думаю, стоит обратить особое внимание: and_then. Это позволяет легко составлять отдельные вычисления, допускающие возможность отсутствия. Например, большая часть кода в этом разделе посвящена поиску расширения по имени файла. Для этого вам сначала понадобится имя файла, которое обычно извлекается из пути к файлу. Хотя большинство путей к файлам имеют имя файла, не все из них. Например,., .. или /.

Итак, перед нами стоит задача найти расширение по пути к файлу. Начнем с подробного анализа случая:

// option-ex-string-find.rs

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> {
  // implementation elided
  unimplemented!()
}

Вы можете подумать, что мы могли бы просто использовать комбинатор карт, чтобы сократить анализ случая, но его тип не совсем подходит. А именно, map принимает функцию, которая что-то делает только с внутренним значением. Результат этой функции затем всегда перевертывается с помощью Some. Вместо этого нам нужно что-то вроде map, но которое позволяет вызывающему вернуть другой Option. Его общая реализация даже проще, чем map:

// option-and-then
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 без явного анализа регистра:

// option-ex-string-find.rs

fn file_path_ext(file_path: &str) -> Option<&str> {
    file_name(file_path).and_then(extension)
}

Тип Option имеет много других комбинаторов, определенных в стандартной библиотеке. Рекомендуется просмотреть этот список и ознакомиться с тем, что доступно - они часто могут сократить вам анализ случая. Знакомство с этими комбинаторами принесет дивиденды, потому что многие из них также определены (с аналогичной семантикой) для Result, о чем мы поговорим дальше.

Комбинаторы делают использование таких типов, как Option, эргономичным, поскольку они сокращают явный анализ случаев. Их также можно компоновать, поскольку они позволяют вызывающему абоненту обрабатывать возможность отсутствия по-своему. Такие методы, как unwrap, удаляют варианты, потому что они вызовут панику, если Option равно None.

Тип результата

Тип результата также определен в стандартной библиотеке:

// result-def.rs

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Тип Result - это расширенная версия Option. Вместо того, чтобы выражать возможность отсутствия, как это делает Option, Result выражает возможность ошибки. Обычно ошибка используется для объяснения того, почему результат какого-либо вычисления не удался. Это строго более общая форма Option. Рассмотрим следующий псевдоним типа, который семантически эквивалентен реальному Option во всех отношениях:

// option-as-result.rs

type Option<T> = Result<T,()>;

Это фиксирует второй параметр типа Result, который всегда будет() (произносится как «единица» или «пустой кортеж»). Ровно одно значение принадлежит типу():(). (Ага, термины типа и уровня значения имеют одинаковую нотацию!)

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

Как и Option, тип Result также имеет метод разворачивания, определенный в стандартной библиотеке. Давайте определим это:

// result-def.rs
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! сообщение. Это упрощает отладку, но также требует, чтобы мы добавили ограничение Debug для параметра типа E (который представляет наш тип ошибки). Поскольку подавляющее большинство типов должно удовлетворять ограничению отладки, на практике это работает. (Отладка типа просто означает, что существует разумный способ распечатать удобочитаемое описание значений с этим типом.)

Хорошо, перейдем к примеру.

Анализ целых чисел

Стандартная библиотека Rust упрощает преобразование строк в целые числа. На самом деле это настолько просто, что очень хочется написать что-то вроде следующего:

// result-num-unwrap.rs

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. Но к чему? Что ж, для этого нужно посмотреть сигнатуру метода синтаксического анализа в стандартной библиотеке:

impl str {
    fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}

Хм. Итак, мы, по крайней мере, знаем, что нам нужно использовать Result. Конечно, вполне возможно, что это могло вернуть Option. В конце концов, строка либо разбирается как число, либо нет, верно? Это, безусловно, разумный путь, но реализация внутренне различает, почему строка не была проанализирована как целое число. (Будь то пустая строка, недопустимая цифра, слишком большая или слишком маленькая.) Следовательно, использование Result имеет смысл, потому что мы хотим предоставить больше информации, чем просто «отсутствие». Мы хотим сказать, почему парсинг не удался. Вы должны попытаться подражать этой линии рассуждений, когда сталкиваетесь с выбором между Вариантом и Результатом. Если вы можете предоставить подробную информацию об ошибке, вам, вероятно, следует. (Подробнее об этом мы поговорим позже.)

Хорошо, но как нам написать наш возвращаемый тип? Метод синтаксического анализа, как определено выше, является общим для всех различных числовых типов, определенных в стандартной библиотеке. Мы могли бы (и, вероятно, должны) также сделать нашу функцию универсальной, но давайте пока остановимся на явности. Нас интересует только i32, поэтому нам нужно найти его реализацию FromStr (нажмите CTRL-F в вашем браузере для «FromStr») и посмотрите на связанный с ним тип Err. Мы сделали это, чтобы найти конкретный тип ошибки. В данном случае это std::num::ParseIntError. Наконец, мы можем переписать нашу функцию:

// result-num-no-unwrap.rs

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. В частности, карта является частью этого перекрестка:

// result-num-no-unwrap-map.rs

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, чтобы иметь два параметра типа. Как мы можем уйти, указав только один? Ключевым моментом является определение псевдонима типа Result, который фиксирует один из параметров типа для определенного типа. Обычно фиксированный тип - это тип ошибки. Например, наш предыдущий пример анализа целых чисел может быть переписан следующим образом:

// result-num-no-unwrap-map-alias.rs

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, что дает понять, что вы используете псевдоним типа модуля io вместо простого определения из std::result. (Эта идиома также используется для fmt::Result.) Краткая интерлюдия: разворачивание - не зло

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

Тем не менее, развернуть все еще можно разумно. Что именно оправдывает использование разворачивания, - это своего рода серая зона, и разумные люди могут не согласиться. Я резюмирую некоторые из своих мнений по этому поводу.

  • В примерах и быстром "н" грязном коде. Иногда вы пишете примеры или быструю программу, и обработка ошибок просто не важна. В таких сценариях может быть сложно превзойти удобство развертывания, поэтому это очень привлекательно.
  • При панике указывает на ошибку в программе. Когда инварианты вашего кода должны предотвращать возникновение определенного случая (например, выскакивание из пустого стека), тогда паника может быть допустимой. Это потому, что он обнаруживает ошибку в вашей программе. Это может быть явным, например assert! сбой, или это могло быть потому, что ваш индекс в массиве был вне пределов.

Вероятно, это не исчерпывающий список. Более того, при использовании Option часто лучше использовать его метод expect. expect делает то же самое, что и unwrap, за исключением того, что печатает сообщение, которое вы даете ожидаемому. Это делает возникшую панику немного приятнее, поскольку она покажет ваше сообщение вместо «вызванного разворачивания при значении None».

Мой совет сводится к следующему: используйте здравый смысл. Есть причина, по которой я не использую слова «никогда не делайте X» или «Y считается вредным». Есть компромиссы для всех вещей, и вам как программисту решать, что приемлемо для ваших вариантов использования. Моя цель - помочь вам максимально точно оценить компромиссы.

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

Работа с несколькими типами ошибок

До сих пор мы рассматривали обработку ошибок, когда все было либо Option, либо Result<T, SomeError>. Но что происходит, когда у вас есть и вариант, и результат? Или что, если у вас есть Результат <T, Error1> и Результат <T, Error2>? Обработка композиции различных типов ошибок - следующая задача, стоящая перед нами, и она будет основной темой в оставшейся части этой статьи.

Composing Option and Result

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

Конечно, в реальном коде все не всегда так чисто. Иногда у вас есть сочетание типов Option и Result. Должны ли мы прибегать к явному анализу случаев или мы можем продолжать использовать комбинаторы?

А пока давайте вернемся к одному из первых примеров в этой статье:

use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // error 1
    let n: i32 = arg.parse().unwrap(); // error 2
    println!("{}", 2 * n);
}
// $ cargo run --bin unwrap-double 5
// 10

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

Сложность здесь в том, что argv.nth (1) создает параметр, а arg.parse() возвращает результат. Их нельзя комбинировать напрямую. Когда вы сталкиваетесь и с Вариантом, и с Результатом, решение обычно состоит в том, чтобы преобразовать Вариант в Результат. В нашем случае отсутствие параметра командной строки (из env::args()) означает, что пользователь неправильно запустил программу. Мы могли бы просто использовать String для описания ошибки. Давай попробуем:

// error-double-string.rs

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()))
        .map(|i| i * 2)
}

fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {}", err),
    }
}

В этом примере есть пара новых вещей. Первый - это использование комбинатора Option::ok_or. Это один из способов превратить опцию в результат. Преобразование требует, чтобы вы указали, какую ошибку использовать, если для параметра Option установлено значение None. Как и другие комбинаторы, которые мы видели, его определение очень простое:

// option-ok-or-def.rs

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 является значением Ok(...), то оно возвращается без изменений.

Мы используем здесь map_err, потому что необходимо, чтобы типы ошибок оставались неизменными (из-за использования and_then). Поскольку мы решили преобразовать Option (из argv.nth (1)) в Result<String, String>, мы также должны преобразовать ParseIntError из arg.parse() в String.

Пределы комбинаторов

Выполнение ввода-вывода и синтаксический анализ ввода - очень распространенная задача, и я лично много этим занимался в Rust. Поэтому мы будем использовать (и продолжим использовать) ввод-вывод и различные процедуры синтаксического анализа, чтобы проиллюстрировать обработку ошибок.

Начнем с простого. Нам нужно открыть файл, прочитать все его содержимое и преобразовать его в число. Затем мы умножаем его на 2 и распечатываем результат.

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

// io-basic-unwrap.rs

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(); // error 1
    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap(); // error 2
    let n: i32 = contents.trim().parse().unwrap(); // error 3
    2 * n
}

fn main() {
    let doubled = file_double("foobar");
    println!("{}", doubled);
}

(N.B. AsRef используется, потому что это те же границы, что и для std::fs::File::open. Это делает эргономичным использование любой строки в качестве пути к файлу.)

Здесь могут возникнуть три различных ошибки:

  1. Проблема с открытием файла.
  2. Проблема чтения данных из файла.
  3. Проблема с анализом данных как числа.

Первые две проблемы описываются с помощью типа std::io::Error. Мы знаем это благодаря возвращаемым типам std::fs::File::open и std::io::Read::read_to_string. (Обратите внимание, что оба они используют идиому псевдонима типа Result, описанную ранее. Если вы щелкните тип Result, вы увидите псевдоним типа и, следовательно, базовый тип io::Error.) Третья проблема описывается std::num::ParseIntError тип. В частности, тип io::Error широко используется в стандартной библиотеке. Вы будете видеть это снова и снова.

Приступим к процессу рефакторинга функции file_double. Чтобы сделать эту функцию совместимой с другими компонентами программы, не следует паниковать, если выполняется какое-либо из указанных выше условий ошибки. Фактически это означает, что функция должна возвращать ошибку, если какая-либо из ее операций завершилась неудачно. Наша проблема в том, что тип возвращаемого значения file_double - i32, что не дает нам никакого полезного способа сообщить об ошибке. Таким образом, мы должны начать с изменения типа возвращаемого значения с i32 на что-то другое.

Первое, что нам нужно решить: использовать Option или Result? Конечно, мы могли бы очень легко использовать Option. Если произойдет какая-либо из трех ошибок, мы можем просто вернуть None. Это сработает, и это лучше, чем паниковать, но мы можем сделать намного лучше. Вместо этого мы должны передать некоторые подробности о произошедшей ошибке. Поскольку мы хотим выразить возможность ошибки, мы должны использовать Result<i32, E>. Но какой должна быть Е? Поскольку могут возникать два разных типа ошибок, нам необходимо преобразовать их в общий тип. Один из таких типов - String. Посмотрим, как это повлияет на наш код:

// io-basic-error-string.rs

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!("Error: {}", err),
    }
}

Этот код выглядит немного запутанным. Может потребоваться немало практики, прежде чем такой код станет легко писать. Я пишу это, следуя типам. Как только я изменил тип возвращаемого значения file_double на Result<i32, String>, мне пришлось начать поиск подходящих комбинаторов. В этом случае мы использовали только три разных комбинатора: and_then, map и map_err.

and_then используется для объединения нескольких вычислений, каждое из которых может возвращать ошибку. После открытия файла могут произойти еще два вычисления, которые могут завершиться ошибкой: чтение из файла и анализ содержимого в виде числа. Соответственно, есть два вызова and_then.

map используется для применения функции к значению Ok(...) результата. Например, самый последний вызов map умножает значение Ok(...) (которое является i32) на 2. Если бы ошибка произошла до этой точки, эта операция была бы пропущена из-за того, как определяется карта.

map_err - это уловка, которая заставляет всю эту работу работать. map_err похож на map, за исключением того, что он применяет функцию к значению Err(...) результата. В этом случае мы хотим преобразовать все наши ошибки в один тип: String. Поскольку и io::Error, и num::ParseIntError реализуют ToString, мы можем вызвать метод to_string() для их преобразования.

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

Раннее возвращение

Я хотел бы взять код из предыдущего раздела и переписать его, используя ранние возвраты. Ранний возврат позволяет раньше выйти из функции. Мы не можем вернуться раньше в file_double из другого закрытия, поэтому нам нужно будет вернуться к явному анализу case.

// io-basic-error-string-early-return.rs

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!("Error: {}", err),
    }
}

Разумные люди могут не согласиться с тем, лучше ли этот код, чем код, использующий комбинаторы, но если вы не знакомы с комбинаторным подходом, этот код мне кажется более простым для чтения. Он использует явный анализ регистра с сопоставлением и условием разрешения. Если возникает ошибка, он просто прекращает выполнение функции и возвращает ошибку (преобразовывая ее в строку).

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

Попробуй! макрос /? оператор

В более старых версиях Rust (Rust 1.12 или старше) краеугольным камнем обработки ошибок в Rust является попытка! макрос. Попробуй! макрос абстрагирует анализ случая точно так же, как комбинаторы, но, в отличие от комбинаторов, он также абстрагирует поток управления. А именно, он может абстрагироваться от схемы раннего возврата, показанной выше.

Вот упрощенное определение попытки! макрос:

// try-def-simple.rs

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

(Настоящее определение немного сложнее. Мы вернемся к этому позже.)

Используя попытку! макрос позволяет очень легко упростить наш последний пример. Поскольку он выполняет для нас анализ случая и ранний возврат, мы получаем более жесткий код, который легче читать:

// io-basic-error-try.rs

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!("Error: {}", err),
    }
}

Вызовы map_err по-прежнему необходимы, учитывая наше определение try !. Это связано с тем, что типы ошибок все еще необходимо преобразовать в String. Хорошая новость в том, что скоро мы узнаем, как удалить эти вызовы map_err! Плохая новость в том, что нам нужно будет узнать немного больше о паре важных особенностей стандартной библиотеки, прежде чем мы сможем удалить вызовы map_err.

В более новых версиях Rust (Rust 1.13 или новее) попробуйте! макрос был заменен на? оператор. Хотя он предназначен для развития новых способностей, о которых мы не будем говорить здесь, используя? вместо того, чтобы пытаться! просто:

// io-basic-error-question.rs

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 = File::open(file_path).map_err(|e| e.to_string())?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).map_err(|e| e.to_string())?;
    let n = 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!("Error: {}", err),
    }
}

Определение собственного типа ошибки

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

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

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

Второй и более важный недостаток - это то, что строки несут потери. То есть, если все ошибки преобразованы в строки, то ошибки, которые мы передаем вызывающей стороне, становятся полностью непрозрачными. Единственная разумная вещь, которую вызывающий может сделать с ошибкой String, - это показать ее пользователю. Конечно, проверка строки для определения типа ошибки не является надежной. (Следует признать, что этот недостаток гораздо более важен внутри библиотеки, чем, скажем, в приложении.)

Например, тип io::Error включает io::ErrorKind, который представляет собой структурированные данные, представляющие, что пошло не так во время операции ввода-вывода. Это важно, потому что вы можете захотеть отреагировать по-разному в зависимости от ошибки. (например, ошибка BrokenPipe может означать изящный выход из вашей программы, в то время как ошибка NotFound может означать выход с кодом ошибки и показом ошибки пользователю.) С помощью io::ErrorKind вызывающий может проверить тип ошибки с анализом случая , что значительно превосходит попытки выявить детали ошибки внутри String.

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

Идеальный способ представить одну из многих возможностей - определить наш собственный тип суммы с помощью enum. В нашем случае ошибка - это либо io::Error, либо num::ParseIntError, поэтому возникает естественное определение:

// io-basic-error-custom.rs
use std::io;
use std::num;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

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

// io-basic-error-custom.rs
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = File::open(file_path).map_err(CliError::Io)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).map_err(CliError::Io)?;
    let n: i32 = contents.trim().parse().map_err(CliError::Parse)?;
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {:?}", err),
    }
}

Единственное изменение - это переключение map_Err(| e | e.to_string()) (который преобразует ошибки в строки) на map_Err(CliError::Io) или map_Err(CliError::Parse). Вызывающий может выбрать уровень детализации для сообщения пользователю. Фактически, использование String в качестве типа ошибки удаляет выбор у вызывающей стороны, в то время как использование настраиваемого типа ошибки enum, такого как CliError, дает вызывающей стороне все удобства, как и раньше, в дополнение к структурированным данным, описывающим ошибку.

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

Стандартные библиотечные трэйты, используемые для обработки ошибок

Стандартная библиотека определяет две неотъемлемые трэйты для обработки ошибок: std::error::Error и std::convert::From. В то время как Error разработан специально для общего описания ошибок, трэйта From выполняет более общую роль для преобразования значений между двумя различными типами.

Трэйта ошибки

Трэйт Error определен в стандартной библиотеке:

// error-def.rs

use std::fmt::{Debug, Display};

trait Error: Debug + Display {
  /// A short description of the error.
  fn description(&self) -> &str;

  /// The lower level cause of this error, if any.
  fn cause(&self) -> Option<&Error> { None }
}

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

  • Получите отладочное представление ошибки.
  • Получите отображение ошибки на дисплее, обращенное к пользователю.
  • Получите краткое описание ошибки (с помощью метода описания).
  • Изучите причинную цепочку ошибки, если таковая существует (с помощью метода причины).

Первые два являются результатом ошибки, требующей выполнения как для отладки, так и для отображения. Последние два относятся к двум методам, определенным в Error. Сила Error проистекает из того факта, что все типы ошибок подразумевают Error, что означает, что ошибки могут быть экзистенциально определены количественно как объект-трэйт. Это проявляется как Box или &Error. В самом деле, метод cause возвращает &Error, который сам является объектом трэйта. Позже мы еще вернемся к полезности свойства Error как объекта свойства.

А пока достаточно показать пример, реализующий трейт Error. Давайте использовать тип ошибки, который мы определили в предыдущем разделе:

// error-impl.rs

use std::io;
use std::num;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

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

Реализация ошибки довольно проста. В основном это будет подробный анализ случая.

// error-impl.rs

use std::error;
use std::fmt;

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            // Both underlying errors already impl `Display`, so we defer to
            // their implementations.
            CliError::Io(ref err) => write!(f, "IO error: {}", err),
            CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
        }
    }
}

impl error::Error for CliError {
    fn description(&self) -> &str {
        // Both underlying errors already impl `Error`, so we defer to their
        // implementations.
        match *self {
            CliError::Io(ref err) => err.description(),
            // Normally we can just write `err.description()`, but the error
            // type has a concrete method called `description`, which conflicts
            // with the trait method. For now, we must explicitly call
            // `description` through the `Error` trait.
            CliError::Parse(ref err) => error::Error::description(err),
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        match *self {
            // N.B. Both of these implicitly cast `err` from their concrete
            // types (either `&io::Error` or `&num::ParseIntError`)
            // to a trait object `&Error`. This works because both error types
            // implement `Error`.
            CliError::Io(ref err) => Some(err),
            CliError::Parse(ref err) => Some(err),
        }
    }
}

Я отмечаю, что это очень типичная реализация ошибки: сопоставьте разные типы ошибок и выполните контракты, определенные для описания и причины.

Трэйта From

Типаж std::convert::From определен в стандартной библиотеке:

// from-def.rs

trait From<T> {
    fn from(T) -> Self;
}

Восхитительно просто, да? From очень полезен, потому что дает нам общий способ поговорить о преобразовании из определенного типа T в какой-то другой тип (в данном случае «какой-то другой тип» является предметом impl или Self). Суть From - это набор реализаций, предоставляемых стандартной библиотекой.

Вот несколько простых примеров, демонстрирующих, как работает From:

// from-examples.rs

let string: String = From::from("foo");
let bytes: Vec<u8> = From::from("foo");
let cow: ::std::borrow::Cow<str> = From::from("foo");

Итак, From полезен для преобразования между строками. А как насчет ошибок? Оказывается, есть одна критическая импликация:

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

Это подразумевает, что для любого типа, который подразумевает Error, мы можем преобразовать его в типаж-объект Box. Это может показаться не очень удивительным, но в общем контексте это полезно.

Помните две ошибки, с которыми мы имели дело ранее? В частности, io::Error и num::ParseIntError. Поскольку оба подразумевают ошибку, они работают с From:

// from-examples-errors.rs

use std::error::Error;
use std::fs;
use std::io;
use std::num;

// We have to jump through some hoops to actually get error values.
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// OK, here are the conversions.
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

Здесь следует распознать действительно важную закономерность. И err1, и err2 имеют один и тот же тип. Это потому, что они являются экзистенциально количественно определенными типами или объектами-трэйтами. В частности, их базовый тип стирается из информации компилятора, поэтому он действительно видит err1 и err2 как одно и то же. Кроме того, мы создали err1 и err2, используя один и тот же вызов функции: From::from. Это связано с тем, что From::from перегружен как своим аргументом, так и возвращаемым типом.

Этот шаблон важен, потому что он решает проблему, с которой мы сталкивались ранее: он дает нам способ надежно преобразовывать ошибки в один и тот же тип с помощью одной и той же функции.

Пора вернуться к старому другу; попробовать! макрос /? оператор.

Настоящая try! macro/? оператор

Ранее я представил это определение try !:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

Это не настоящее определение. Настоящее определение находится в стандартной библиотеке:

// try-def.rs

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(::std::convert::From::from(err)),
    });
}

Есть одно крошечное, но важное изменение: значение ошибки передается через From::from. Это заставляет попробовать! макрос намного более мощный, потому что он дает вам автоматическое преобразование типов бесплатно. Это также очень похоже на то, как? Оператор работает, что определяется несколько иначе. А именно x? десахариды примерно до следующего:

// questionmark-def.rs

match ::std::ops::Try::into_result(x) {
    Ok(v) => v,
    Err(e) => return ::std::ops::Try::from_error(From::from(e)),
}

Трэйт Try по-прежнему нестабилен и выходит за рамки данной статьи, но его суть в том, что он предоставляет способ абстрагироваться от множества различных типов сценариев успеха / неудачи, не будучи тесно связанным с Result<T, E>. Как вы можете видеть, x? синтаксис по-прежнему вызывает From::from, благодаря чему достигается автоматическое преобразование ошибок.

Поскольку большинство написанного сегодня кода использует? вместо 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 = File::open(file_path).map_err(|e| e.to_string())?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).map_err(|e| e.to_string())?;
    let n = contents.trim().parse::<i32>().map_err(|e| e.to_string())?;
    Ok(2 * n)
}

Ранее я обещал избавиться от вызовов map_err. В самом деле, все, что нам нужно сделать, это выбрать тип, с которым работает From. Как мы видели в предыдущем разделе, From подразумевает преобразование любого типа ошибки в Box:

// io-basic-error-try-from.rs

use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {
    let mut file = File::open(file_path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let n = contents.trim().parse::<i32>()?;
    Ok(2 * n)
}

Мы очень близки к идеальной обработке ошибок. У нашего кода очень мало накладных расходов из-за обработки ошибок, потому что? Оператор инкапсулирует одновременно три вещи:

  1. Анализ случая.
  2. Поток управления.
  3. Преобразование типа ошибки.

Когда все три вещи объединены, мы получаем код, не обремененный комбинаторами, вызовами развертывания или анализом кейсов.

Осталась одна маленькая нюанс: тип Box непрозрачен. Если мы вернем вызывающей стороне Box, вызывающая сторона не сможет (легко) проверить основной тип ошибки. Ситуация, безусловно, лучше, чем String, потому что вызывающий может вызывать такие методы, как описание и причина, но ограничение остается: Box непрозрачен. (N.B. Это не совсем так, потому что в Rust есть отражение во время выполнения, которое полезно в некоторых сценариях, выходящих за рамки данной статьи.)

Пришло время вернуться к нашему пользовательскому типу CliError и связать все вместе.

Создание настраиваемых типов ошибок

В последнем разделе мы рассмотрели настоящие? оператор и как он выполняет для нас автоматическое преобразование типов, вызывая From::from для значения ошибки. В частности, мы преобразовали ошибки в Box, который работает, но тип непрозрачен для вызывающих.

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

// io-basic-error-custom-from.rs

use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

fn file_double_verbose<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = File::open(file_path).map_err(CliError::Io)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).map_err(CliError::Io)?;
    let n: i32 = contents.trim().parse().map_err(CliError::Parse)?;
    Ok(2 * n)
}

Обратите внимание, что у нас все еще есть вызовы map_err. Почему? Что ж, вспомните определения? оператор и From. Проблема в том, что нет From impl, который позволяет нам преобразовывать типы ошибок, такие как io::Error и num::ParseIntError, в наш собственный пользовательский CliError. Конечно, это легко исправить! Поскольку мы определили CliError, мы можем наложить на него From:

// io-basic-error-custom-from.rs

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

impl From<num::ParseIntError> for CliError {
    fn from(err: num::ParseIntError) -> CliError {
        CliError::Parse(err)
    }
}

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

Наконец-то мы можем переписать file_double:

// io-basic-error-custom-from.rs

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = File::open(file_path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let n: i32 = contents.trim().parse()?;
    Ok(2 * n)
}

Единственное, что мы здесь сделали, это удалили вызовы map_err. Они больше не нужны, потому что? Оператор вызывает From::from для значения ошибки. Это работает, потому что мы предоставили From для всех типов ошибок, которые могут возникнуть.

Если мы изменили нашу функцию file_double для выполнения какой-либо другой операции, например, преобразования строки в число с плавающей запятой, тогда нам нужно было бы добавить новый вариант к нашему типу ошибки:

enum CliError {
    Io(io::Error),
    ParseInt(num::ParseIntError),
    ParseFloat(num::ParseFloatError),
}

Чтобы отразить это изменение, нам нужно обновить предыдущий impl Fromnum::ParseIntError для CliError и добавить новый impl Fromnum::ParseFloatError для CliError:

impl From<num::ParseIntError> for CliError {
    fn from(err: num::ParseIntError) -> CliError {
        CliError::ParseInt(err)
    }
}

impl From<num::ParseFloatError> for CliError {
    fn from(err: num::ParseFloatError) -> CliError {
        CliError::ParseFloat(err)
    }
}

И это все!

Совет для писателей библиотеки

Идиомы для библиотек Rust все еще формируются, но если ваша библиотека должна сообщать о пользовательских ошибках, вам, вероятно, следует определить свой собственный тип ошибки. Вам решать, раскрывать ли его представление (например, ErrorKind) или сохранять его скрытым (например, ParseIntError). Независимо от того, как вы это делаете, обычно рекомендуется предоставить хотя бы некоторую информацию об ошибке, помимо ее строкового представления. Но, конечно, это будет варьироваться в зависимости от сценария использования.

Как минимум, вам, вероятно, следует реализовать трэйту Error. Это даст пользователям вашей библиотеки некоторую минимальную гибкость при составлении ошибок. Реализация трейта Error также означает, что пользователям гарантируется возможность получить строковое представление ошибки (поскольку для этого требуются impls как для fmt::Debug, так и для fmt::Display).

Помимо этого, также может быть полезно предоставить реализации From для ваших типов ошибок. Это позволяет вам (автору библиотеки) и вашим пользователям составлять более подробные ошибки. Например, csv::Error предоставляет From для обоих io::Error и byteorder::Error.

Наконец, в зависимости от ваших вкусов, вы также можете определить псевдоним типа Result, особенно если ваша библиотека определяет единственный тип ошибки. Это используется в стандартной библиотеке для io::Result и fmt::Result.

Пример: программа для чтения данных о населении

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

Для этого я хотел бы создать программу командной строки, которая позволит вам запрашивать данные о населении мира. Цель проста: вы указываете ему местоположение, и оно сообщит вам население. Несмотря на простоту, есть много вещей, которые могут пойти не так!

Данные, которые мы будем использовать, взяты из Data Science Toolkit. Я подготовил некоторые данные для этого упражнения. Вы можете получить данные о населении мира (41 МБ в сжатом формате gzip, 145 МБ без сжатия) или только данные о населении США (2,2 МБ в сжатом формате gzip, 7,2 МБ без сжатия).

До сих пор я ограничивал код стандартной библиотекой Rust. Однако для реальной задачи, подобной этой, нам нужно хотя бы использовать что-нибудь для анализа данных CSV, анализа аргументов программы и автоматического декодирования этого материала в типы Rust. Для этого мы будем использовать крейты csv, docopt и rustc-serialize.

Это на Github

Окончательный код этого примера находится на Github. Если у вас установлены Rust и Cargo, то все, что вам нужно сделать, это:

git clone git://github.com/BurntSushi/rust-error-handling-case-study
cd rust-error-handling-case-study
cargo build --release
./target/release/city-pop --help

Построим этот проект по частям. Читайте и следуйте!

Начальная настройка

Я не собираюсь тратить много времени на настройку проекта с помощью Cargo, потому что он уже хорошо освещен в книге Rust и документации Cargo.

Чтобы начать с нуля, запустите Cargo new --bin city-pop и убедитесь, что ваш Cargo.toml выглядит примерно так:

[package]
name = "city-pop"
version = "0.1.0"
authors = ["Andrew Gallant <jamslam@gmail.com>"]

[[bin]]
name = "city-pop"

[dependencies]
csv = "0.*"
docopt = "0.*"
rustc-serialize = "0.*"

Вы уже должны иметь возможность запускать:

cargo build --release
./target/release/city-pop
#Outputs: Hello, world!

Разбор аргумента

Давайте перестанем разбираться в аргументах. Я не буду вдаваться в подробности о Docopt, но есть хорошая веб-страница с описанием этого и документацией по крэйту Rust. Вкратце, Docopt генерирует синтаксический анализатор аргументов из строки использования. После завершения анализа мы можем декодировать аргументы программы в структуру Rust. Вот наша программа с соответствующими операторами, строкой использования, нашей структурой Args и пустой main:

static USAGE: &'static str = "
Usage: city-pop [options] <data-path> <city>
       city-pop --help

Options:
    -h, --help     Show this usage message.
";

struct Args {
    arg_data_path: String,
    arg_city: String,
}

fn main() {

}

Хорошо, пора заняться кодированием. В документации для Docopt говорится, что мы можем создать новый синтаксический анализатор с помощью Docopt::new, а затем декодировать текущие аргументы программы в структуру с помощью Docopt::decode. Загвоздка в том, что обе эти функции могут возвращать docopt::Error. Мы можем начать с подробного анализа случая:

// These use statements were added below the `extern` statements.
// I'll elide them in the future. Don't worry! It's all on Github:
// https://github.com/BurntSushi/rust-error-handling-case-study
//use std::io::{self, Write};
//use std::process;
//use docopt::Docopt;

fn main() {
    let args: Args = match Docopt::new(USAGE) {
        Err(err) => {
            writeln!(&mut io::stderr(), "{}", err).unwrap();
            process::exit(1);
        }
        Ok(dopt) => match dopt.decode() {
            Err(err) => {
                writeln!(&mut io::stderr(), "{}", err).unwrap();
                process::exit(1);
            }
            Ok(args) => args,
        }
    };
}

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

// fatal-def.rs
macro_rules! fatal {
    ($($tt:tt)*) => {{
        use std::io::Write;
        writeln!(&mut ::std::io::stderr(), $($tt)*).unwrap();
        ::std::process::exit(1)
    }}
}

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

Код выглядит лучше, но явный анализ случая по-прежнему мешает:

let args: Args = match Docopt::new(USAGE) {
    Err(err) => fatal!("{}", err),
    Ok(dopt) => match dopt.decode() {
        Err(err) => fatal!("{}", err),
        Ok(args) => args,
    }
};

К счастью, тип docopt::Error определяет удобный выход из метода, который эффективно выполняет то, что мы только что сделали. Совместите это с нашими знаниями комбинаторов, и мы получим краткий, легкий для чтения код:

let args: Args = Docopt::new(USAGE)
                        .and_then(|d| d.decode())
                        .unwrap_or_else(|err| err.exit());

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

Написание логики

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

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

// This struct represents the data in each row of the CSV file.
// Type based decoding absolves us of a lot of the nitty gritty error
// handling, like parsing strings as integers or floats.
struct Row {
    country: String,
    city: String,
    accent_city: String,
    region: String,

    // Not every row has data for the population, latitude or longitude!
    // So we express them as `Option` types, which admits the possibility of
    // absence. The CSV parser will fill in the correct value for us.
    population: Option<u64>,
    latitude: Option<f64>,
    longitude: Option<f64>,
}

fn main() {
    let args: Args = Docopt::new(USAGE)
                            .and_then(|d| d.decode())
                            .unwrap_or_else(|err| err.exit());

    let file = fs::File::open(args.arg_data_path).unwrap();
    let mut rdr = csv::Reader::from_reader(file);
    for row in rdr.decode::<Row>() {
        let row = row.unwrap();
        if row.city == args.arg_city {
            println!("{}, {}: {:?}",
                     row.city, row.country,
                     row.population.expect("population count"));
        }
    }
}

Обозначим ошибки. Мы можем начать с очевидного: три места, которые разворачиваются, называются:

  1. fs::File::open может возвращать io::Error.
  2. csv::Reader::decode декодирует по одной записи за раз, и декодирование записи (посмотрите на связанный с элементом тип в Iterator impl) может вызвать csv::Error.
  3. Если row.population имеет значение None, вызов expect вызовет панику.

Есть ли другие? Что делать, если мы не можем найти подходящий город? Такие инструменты, как grep, вернут код ошибки, так что мы, вероятно, тоже должны. Итак, у нас есть логические ошибки, специфичные для нашей проблемы, ошибки ввода-вывода и ошибки синтаксического анализа CSV. Мы собираемся изучить два разных подхода к устранению этих ошибок.

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

Обработка ошибок с помощью Box

Коробка хороша тем, что просто работает. Вам не нужно определять собственные типы ошибок, и вам не нужны никакие реализации From. Обратной стороной является то, что, поскольку Box является типажным объектом, он стирает тип, что означает, что компилятор больше не может рассуждать о своем базовом типе.

Ранее мы начали рефакторинг нашего кода, изменив тип нашей функции с T на Result<T, OurErrorType>. В этом случае OurErrorType - это просто Box. Но что такое Т? А можно ли добавить в main возвращаемый тип?

Ответ на второй вопрос - нет, не можем. Это означает, что нам нужно будет написать новую функцию. Но что такое Т? Самое простое, что мы можем сделать, - это вернуть список совпадающих значений Row как Vec. (Лучший код вернет итератор, но это оставлено читателю в качестве упражнения.)

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

struct Row {
    // unchanged
}

struct PopulationCount {
    city: String,
    country: String,
    // This is no longer an `Option` because values of this type are only
    // constructed if they have a population count.
    count: u64,
}

fn search<P: AsRef<Path>>(file_path: P, city: &str) -> Vec<PopulationCount> {
    let mut found = vec![];
    let file = fs::File::open(file_path).unwrap();
    let mut rdr = csv::Reader::from_reader(file);
    for row in rdr.decode::<Row>() {
        let row = row.unwrap();
        match row.population {
            None => { } // skip it
            Some(count) => if row.city == city {
                found.push(PopulationCount {
                    city: row.city,
                    country: row.country,
                    count: count,
                });
            },
        }
    }
    found
}

fn main() {
    let args: Args = Docopt::new(USAGE)
                            .and_then(|d| d.decode())
                            .unwrap_or_else(|err| err.exit());

    for pop in search(&args.arg_data_path, &args.arg_city) {
        println!("{}, {}: {:?}", pop.city, pop.country, pop.count);
    }
}

Хотя мы избавились от одного использования expect (который является более приятным вариантом разворачивания), мы все же должны справиться с отсутствием каких-либо результатов поиска.

Чтобы преобразовать это в правильную обработку ошибок, нам нужно сделать следующее:

  1. Измените тип возвращаемого результата поиска на Результат <Vec, Box>.
  2. Используйте символ? оператор, чтобы ошибки возвращались вызывающей стороне вместо паники программы.
  3. Обработайте ошибку в main.

Давай попробуем:

fn search<P: AsRef<Path>>
         (file_path: P, city: &str)
         -> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> {
    let mut found = vec![];
    let file = fs::File::open(file_path)?;
    let mut rdr = csv::Reader::from_reader(file);
    for row in rdr.decode::<Row>() {
        let row = row?;
        match row.population {
            None => { } // skip it
            Some(count) => if row.city == city {
                found.push(PopulationCount {
                    city: row.city,
                    country: row.country,
                    count: count,
                });
            },
        }
    }
    if found.is_empty() {
        Err(From::from("No matching cities with a population were found."))
    } else {
        Ok(found)
    }
}

Вместо x.unwrap() у нас теперь есть x ?. Поскольку наша функция возвращает Result<T, E>, знак? Оператор вернется из функции раньше, если произойдет ошибка.

В этом коде есть одна большая ошибка: мы использовали Box<Error + Send + Sync> вместо Box. Мы сделали это, чтобы преобразовать простую строку в тип ошибки. Нам нужны эти дополнительные границы, чтобы мы могли использовать соответствующие From impls:

// We are making use of this impl in the code above, since we call `From::from`
// on a `&'static str`.
impl<'a, 'b> From<&'b str> for Box<Error + Send + Sync + 'a>

// But this is also useful when you need to allocate a new string for an
// error message, usually with `format!`.
impl From<String> for Box<Error + Send + Sync>

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

Чтение из стандартного ввода

В нашей программе мы принимаем на вход один файл и выполняем один проход по данным. Это означает, что мы, вероятно, сможем принимать ввод на stdin. Но, возможно, нам тоже нравится текущий формат - так что давайте и то, и другое!

Добавить поддержку stdin на самом деле довольно просто. Нам нужно сделать только две вещи:

  1. Настройте аргументы программы так, чтобы единственный параметр - город - мог быть принят, пока данные о населении считываются из стандартного ввода.
  2. Измените функцию поиска, чтобы использовать необязательный путь к файлу. Если нет, он должен знать, что нужно читать из стандартного ввода.

Во-первых, вот новое использование и структура Args:

static USAGE: &'static str = "
Usage: city-pop [options] [<data-path>] <city>
       city-pop --help

Options:
    -h, --help     Show this usage message.
";

struct Args {
    arg_data_path: Option<String>,
    arg_city: String,
}

Все, что мы сделали, - это сделали аргумент пути к данным необязательным в строке использования Docopt и сделали соответствующий член структуры arg_data_path необязательным. Все остальное сделает крэйт docopt.

Изменить поиск немного сложнее. Крейт csv может создать синтаксический анализатор любого типа, реализующего io::Read. Но как мы можем использовать один и тот же код для обоих типов? На самом деле есть несколько способов сделать это. Один из способов - написать поиск таким образом, чтобы он был универсальным для некоторого параметра типа R, удовлетворяющего io::Read. Другой способ - просто использовать трейт-объекты:

fn search<P: AsRef<Path>>
         (file_path: &Option<P>, city: &str)
         -> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> {
    let mut found = vec![];
    let input: Box<io::Read> = match *file_path {
        None => Box::new(io::stdin()),
        Some(ref file_path) => Box::new(fs::File::open(file_path)?),
    };
    let mut rdr = csv::Reader::from_reader(input);
    // The rest remains unchanged!
}

Обработка ошибок с пользовательским типом

Ранее мы научились составлять ошибки, используя настраиваемый тип ошибки. Мы сделали это, определив наш тип ошибки как перечисление и реализовав Error и From.

Поскольку у нас есть три различных ошибки (ввод-вывод, синтаксический анализ CSV и не обнаружено), давайте определим перечисление с тремя вариантами:

enum CliError {
    Io(io::Error),
    Csv(csv::Error),
    NotFound,
}

А теперь о выводах на дисплей и при ошибке: (And now for impls on Display and Error:)

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CliError::Io(ref err) => err.fmt(f),
            CliError::Csv(ref err) => err.fmt(f),
            CliError::NotFound => write!(f, "No matching cities with a \
                                             population were found."),
        }
    }
}

impl Error for CliError {
    fn description(&self) -> &str {
        match *self {
            CliError::Io(ref err) => err.description(),
            CliError::Csv(ref err) => err.description(),
            CliError::NotFound => "not found",
        }
    }
}

Прежде чем мы сможем использовать наш тип CliError в нашей функции поиска, нам нужно предоставить пару From impls. Как мы узнаем, какие имплименты нужно предоставить? Что ж, нам нужно преобразовать io::Error и csv::Error в CliError. Это единственные внешние ошибки, поэтому на данный момент нам понадобятся только два имплиента From:

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

impl From<csv::Error> for CliError {
    fn from(err: csv::Error) -> CliError {
        CliError::Csv(err)
    }
}

Импульсы From важны из-за того, как? оператор определен. В частности, если возникает ошибка, для ошибки вызывается From::from, который в этом случае преобразует ее в наш собственный тип ошибки CliError.

После того, как импликации From выполнены, нам нужно только внести две небольшие корректировки в нашу функцию поиска: тип возвращаемого значения и ошибка «не найдено». Вот он полностью:

fn search<P: AsRef<Path>>
         (file_path: &Option<P>, city: &str)
         -> Result<Vec<PopulationCount>, CliError> {
    let mut found = vec![];
    let input: Box<io::Read> = match *file_path {
        None => Box::new(io::stdin()),
        Some(ref file_path) => Box::new(fs::File::open(file_path)?),
    };
    let mut rdr = csv::Reader::from_reader(input);
    for row in rdr.decode::<Row>() {
        let row = row?;
        match row.population {
            None => { } // skip it
            Some(count) => if row.city == city {
                found.push(PopulationCount {
                    city: row.city,
                    country: row.country,
                    count: count,
                });
            },
        }
    }
    if found.is_empty() {
        Err(CliError::NotFound)
    } else {
        Ok(found)
    }
}

Никаких других изменений не требуется.

Добавление функциональности

Если вы похожи на меня, писать общий код - это хорошо, потому что обобщать - это круто! Но иногда сок не стоит того. Посмотрите, что мы только что сделали на предыдущем шаге:

  • Определен новый тип ошибки.
  • Добавлены имплименты для ошибок, отображения и два для From.

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

Одним из полезных аспектов использования настраиваемого типа ошибки, как мы делали здесь, является то, что основная функция теперь может выбирать обработку ошибок по-другому. Раньше с Box у него не было особого выбора: просто распечатать сообщение. Мы все еще делаем это здесь, но что, если мы хотим, скажем, добавить флаг --quiet? Флаг --quiet должен заглушить любой подробный вывод.

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

Итак, начнем с добавления флагов. Как и раньше, нам нужно настроить строку использования и добавить флаг в структуру Args. Все остальное сделает крэйт docopt:

static USAGE: &'static str = "
Usage: city-pop [options] [<data-path>] <city>
       city-pop --help

Options:
    -h, --help     Show this usage message.
    -q, --quiet    Don't show noisy messages.
";

struct Args {
    arg_data_path: Option<String>,
    arg_city: String,
    flag_quiet: bool,
}

Теперь нам просто нужно реализовать нашу «тихую» функциональность. Это требует от нас настройки анализа кейса в основном:

match search(&args.arg_data_path, &args.arg_city) {
    Err(CliError::NotFound) if args.flag_quiet => process::exit(1),
    Err(err) => fatal!("{}", err),
    Ok(pops) => for pop in pops {
        println!("{}, {}: {:?}", pop.city, pop.country, pop.count);
    }
}

Конечно, мы не хотим молчать, если произошла ошибка ввода-вывода или если данные не удалось проанализировать. Поэтому мы используем анализ случаев, чтобы проверить, является ли тип ошибки NotFound и включен ли параметр --quiet. Если поиск не удался, мы все равно выходим с кодом выхода (согласно соглашению grep).

Если бы мы застряли с Box, было бы довольно сложно реализовать функциональность --quiet.

Это в значительной степени подводит итог нашему тематическому исследованию. Отсюда вы должны быть готовы выйти в мир и написать свои собственные программы и библиотеки с надлежащей обработкой ошибок.

Краткий рассказ

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

  • Если вы пишете короткий пример кода, который будет перегружен обработкой ошибок, вероятно, будет вполне нормально использовать развертку (будь то Result::unwrap, Option::unwrap или предпочтительно Option::expect). Пользователи вашего кода должны знать, как правильно обрабатывать ошибки. (Если нет, отправьте их сюда!)
  • Если вы пишете «быструю» и «грязную» программу, не стесняйтесь использовать развертку. Будьте осторожны: если он попадет в чьи-то руки, не удивляйтесь, если его взволнуют плохие сообщения об ошибках!
  • Если вы пишете непростую программу и в любом случае стыдитесь паники, вам, вероятно, следует использовать Box (или Box<Error + Send + Sync>), как показано в примерах выше. Еще одна многообещающая альтернатива - это крэйт anyhow и его тип anyhow::Error. В любом случае к вашим ошибкам будут автоматически прикреплены трассировки при использовании ночного Rust.
  • В противном случае в программе определите свои собственные типы ошибок с соответствующими подразумеваемыми From и Error, чтобы сделать? Операторный макрос более эргономичный.
  • Если вы пишете библиотеку и ваш код может вызывать ошибки, определите свой собственный тип ошибки и реализуйте трейт std::error::Error. Где возможно, реализуйте From, чтобы упростить написание как кода вашей библиотеки, так и кода вызывающего абонента. (Из-за правил согласованности Rust вызывающие программы не смогут применить From к вашему типу ошибки, поэтому ваша библиотека должна это делать.)
  • Изучите комбинаторы, определенные для Option и Result. Использование только их может быть временами немного утомительным, но я лично нашел полезное сочетание? операторы и комбинаторы весьма привлекательны. and_then, map и unwrap_or - мои любимые.