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

Перевод | Автор оригинала: Stefan Baumgartner

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

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

Делаем невозможные состояния невозможными

В Rust нет таких вещей, как undefined или null, и у вас нет исключений, как вы знаете из языков программирования, таких как Java или C#. Вместо этого вы используете встроенные перечисления для моделирования состояния:

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

В этой статье я хочу сосредоточиться на Result<T, E>, поскольку он действительно содержит ошибки.

Result<T, E> - это перечисление с двумя вариантами:

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

T, E являются дженериками. T может быть любым значением, E может быть любой ошибкой. Два варианта Ok и Err доступны во всем мире.

Используйте Result<T, E>, когда у вас есть что-то, что может пойти не так. Ожидается, что операция будет успешной, но могут быть случаи, когда это не удается. Когда у вас есть значение Result, вы можете сделать следующее:

Давайте подробно рассмотрим, что я имею в виду.

Обработка состояния ошибки

Напишем небольшой фрагмент, в котором мы хотим прочитать строку из файла. Это требует от нас

  1. Прочтите файл
  2. Прочтите строку из этого файла.

Обе операции могут вызвать ошибку std::io::Error, потому что может произойти что-то непредвиденное (файл не существует, его нельзя прочитать и т.д.). Таким образом, функция, которую мы пишем, может возвращать либо String, либо io::Error.

use std::io;
use std::fs::File;

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let f = File::open(path);

    /* 1 */
    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    /* 2 */
    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(err) => Err(err),
    }
}

Вот что происходит:

  1. Когда мы открываем файл по пути, он либо может вернуть дескриптор файла для работы с Ok(файл), либо вызывает ошибку Err(e). При использовании match f мы вынуждены иметь дело с двумя возможными состояниями. Либо мы назначаем дескриптор файла f (обратите внимание на затенение f), либо возвращаемся из функции, возвращая ошибку. Оператор return здесь важен, поскольку мы хотим выйти из функции.
  2. Затем мы хотим прочитать содержимое только что созданной строки s. Он снова может либо завершиться успешно, либо выдать ошибку. Функция f.read_to_string возвращает длину прочитанных байтов, поэтому мы можем спокойно игнорировать значение и вернуть Ok(s) с прочитанной строкой. В противном случае мы просто возвращаем ту же ошибку. Обратите внимание, что я не ставил точку с запятой в конце выражения соответствия. Поскольку это выражение, это то, что мы возвращаем из функции в этот момент.

Это может показаться очень многословным (это…), но вы видите два очень важных аспекта обработки ошибок:

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

Операцию, которую мы только что сделали, часто называют разворачиванием. Потому что вы разворачиваете значение, заключенное внутри перечисления.

Кстати о разворачивании…

Игнорировать ошибки

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

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).unwrap(); /* 1 */
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap(); /* 1 */
    Ok(s) /* 2 */
}

Вот что происходит:

  1. Во всех случаях, которые могут вызвать ошибку, мы вызываем unwrap(), чтобы получить значение
  2. Оборачиваем результат в вариант Ok, который возвращаем. Мы могли бы просто вернуть s и оставить Result<T, E> в сигнатуре нашей функции. Мы сохраняем его, потому что снова используем его в других примерах.

Сама функция unwrap() очень похожа на то, что мы делали на первом шаге, когда мы работали со всеми состояниями:

// result.rs

impl<T, E: fmt::Debug> Result<T, E> {
    // ...

    pub fn unwrap(&self) -> T {
        match self {
            Ok(t) => t,
            Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
        }
    }

    // ...
}

unwrap_failed - это ярлык к панике! макрос. Это означает, что если вы используете .unwrap() и не получите успешного результата, ваше программное обеспечение выйдет из строя.

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

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

Паника!

Говоря о панике, вы также можете паниковать своим собственным паническим сообщением:

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).expect("Error opening file");
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap("Error reading file to string");
    Ok(s) 
}

То, что делает .expect (...), очень похоже на unwrap()

impl<T, E: fmt::Debug> Result<T, E> {
    // ...
    pub fn expect(self, msg: &str) -> T {
        match self {
            Ok(t) => t,
            Err(e) => unwrap_failed(msg, &e),
        }
    }
}

Но у вас в руках свои панические сообщения, которые могут вам понравиться!

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

Резервные значения

Rust имеет возможность использовать значения по умолчанию в своих перечислениях Result (и Option).

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).expect("Error opening file");
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap_or("admin"); /* 1 */
    Ok(s) 
}
  1. «admin» может быть не лучшим вариантом для имени пользователя, но идею вы поняли. Вместо сбоя мы возвращаем значение по умолчанию в случае результата ошибки. Метод .unwrap_or_else принимает закрытие для более сложных значений по умолчанию.

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

Распространение ошибки

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

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path)?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s) 
}

В этом фрагменте f - обработчик файла, f.read_to_string сохраняет в s. Если что-то пойдет не так, мы вернемся из функции с Err(io::Error). Краткий код, но мы имеем дело с ошибкой на один уровень выше:

fn main() {
    match read_username_from_file("user.txt") {
        Ok(username) => println!("Welcome {}", username),
        Err(err) => eprintln!("Whoopsie! {}", err)
    };
}

Что в этом хорошего?

  1. Мы по-прежнему недвусмысленны, мы должны что-то делать! Вы все еще можете найти все места, где могут произойти ошибки!
  2. Мы можем писать краткий код, как если бы ошибок не было. Ошибки еще предстоит исправить! Либо от нас, либо от пользователей нашей функции.

Оператор вопросительного знака также работает с Option, это также позволяет создать действительно красивый и элегантный код!

Распространение различных ошибок

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

fn read_number_from_file(filename: &str) -> Result<u64, ???> {
    let mut file = File::open(filename)?; /* 1 */

    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; /* 1 */

    let parsed: u64 = buffer.trim().parse()?; /* 2 */

    Ok(parsed)
}
  1. Эти две точки могут вызвать io::Error, как мы знаем из предыдущих примеров.
  2. Однако эта операция может вызвать ошибку ParseIntError.

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

Ознакомьтесь с нашей слегка измененной функцией

use std::error;

fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
    let mut file = File::open(filename)?; /* 1 */

    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; /* 1 */

    let parsed: u64 = buffer.trim().parse()?; /* 2 */

    Ok(parsed)
}

Вот что происходит:

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

Схема памяти Box и Box

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

Первый вопрос, который я задаю, когда показываю это людям на моих курсах: но можем ли мы в конечном итоге проверить, какой тип ошибки произошел? Мы можем! Метод downcast_ref() позволяет нам вернуться к исходному типу.

fn main() {
    match read_number_from_file("number.txt") {
        Ok(v) => println!("Your number is {}", v),
        Err(err) => {
            if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
                eprintln!("Error during IO! {}", io_err)
            } else if let Some(pars_err) = err.downcast_ref::<ParseIntError>() {
                eprintln!("Error during parsing {}", pars_err)
            }
        }
    };
}

Отлично!

Пользовательские ошибки

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

Вам не нужно реализовывать какие-либо функции std::error::Error, но вам нужно реализовать как трейт Debug, так и свойство Display. Причина в том, что ошибки хотят где-то печатать. Вот как выглядит пример:

#[derive(Debug)] /* 1 */
pub struct ParseArgumentsError(String); /* 2 */

impl std::error::Error for ParseArgumentsError {} /* 3 */

/* 4 */
impl Display for ParseArgumentsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
  1. Мы выводим трэйту Debug.
  2. Наша ParseArgumentsError - это структура кортежа с одним элементом: настраиваемое сообщение.
  3. Реализуем std::error::Error для ParseArgumentsError. Больше ничего реализовывать не нужно
  4. Мы реализуем Display, где выводим единственный элемент нашего кортежа.

И это все!

Anyhow…

Поскольку многие вещи, которые вы только что выучили, очень распространены, конечно, существуют крэйти, которые абстрагируют большую часть из них. Фантастический крэйт Anyhow Crate - один из них, который дает вам возможность обрабатывать ошибки на основе объектов с помощью удобных макросов и типов.

Нижняя линия

Это очень быстрое руководство по обработке ошибок в Rust. Конечно, это еще не все, но это должно помочь вам начать! Это также моя первая техническая статья по Rust, и я надеюсь, что ее будет еще много. Дайте мне знать, если вам это понравилось, и если вы обнаружите какие-либо… ха-ха… ошибки (ба-дум-ц 🥁), я просто напишу твит.