ОБРАБОТКА ОШИБОК

Как спроектировать устойчивость к панике в Rust

Не паникуйте! Научитесь создавать качественное программное обеспечение, устойчивое к ошибкам.

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

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

В 1990 году обновление программного обеспечения привело к отказу всех 114 общенациональных систем электронной коммутации AT&T. Когда башня выходила из строя, она отправляла сообщение соседним башням, что останавливала движение. Сообщение, полученное другими вышками, заставило их войти в аналогичное состояние отказа, сохраняя сообщение через всю междугороднюю сеть AT&T.

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

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

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

Цель номер один в обработке ошибок - предотвратить сбои.

Некоторые новые языки, разработанные после C, используют исключения для обработки ошибок, что является абстракцией высокого уровня для кодов ошибок. Для вызова функции, которая может дать сбой и вызвать исключение, требуются try и catch block - для выполнения кода, который может вызвать исключение, и для обработки ошибки, о которой сигнализирует исключение. Исключения по-прежнему не всегда обрабатываются явным образом, и поэтому некоторые ленивые программисты обрабатывают исключения так же, как .unwrap() обрабатывают ошибки в Rust - выводят ошибку и терпят неудачу.

В Rust ошибки явные. Ошибки в Rust бывают по идиомам Result и Option типов. Поскольку ошибки являются явными, программисты устают с ними работать и в конечном итоге предпочитают .unwrap() каждую, что не делает ничего во имя устойчивости к панике. Unwrap поощряет «самые лучшие надежды» и слепо погружается в те данные, которые могут существовать, и если в любом случае это не удастся, это серьезный сбой - паника.

Цель номер один в обработке ошибок - предотвратить сбои.

Давайте рассмотрим три важных раздела об обработке ошибок в Rust:

  1. Когда паниковать,
  2. Обработка ошибок,
  3. И дополнительный раздел: Обработка ошибок в библиотеке.

Когда паниковать

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

Авария никогда не бывает «уместным» поведением, но это лучше, чем позволить вашей системе причинить физический ущерб. Если в какой-то момент можно предположить, что программное обеспечение может вызвать что-то смертельное, дорогое или разрушительное, вероятно, лучше его закрыть.

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

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

Лучший способ завершить выполнение программы - дать ей возможность работать до последней закрывающей скобки. Каким-то образом запрограммировать такое поведение. Это позволяет всем объектам безопасно уничтожить себя. См. Черта Капли.

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

Паника - это последнее средство, а не изящная встроенная функция, которая позволяет легко выйти с сообщением!

Result, Option, std::io::Result и некоторые другие типы могут представлять успех операции. В идиоматическом Rust, если что-то может выйти из строя, он возвращает Result, который кодирует два возможных значения: либо значение успеха (Ok), либо значение ошибки (Err). Result<T, E>, где T - это тип значения успех, а E - тип значения ошибка.

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

  1. Можем ли мы попробовать еще раз?
  2. Являются ли данные абсолютно обязательными для продолжения, или они могут быть сгенерированы, скорректированы или допущены?

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

В следующем примере применяется первый вопрос: Можем ли мы попробовать еще раз?

fn open_config() -> Result<std::fs::File, std::io::Error> {
    use std::fs::File;
    match File::open("config.toml") {
        Ok(f) => Ok(f),
        // If not found, search in second location:
        Err(e) => match File::open("data/config.toml") {
            Ok(f) => Ok(f),
            // Otherwise, bubble first error up to caller
            _ => Err(e),
        }
    }
}
fn main() {
    let mut result = open_config();
    if result.is_err() { // If failed the first time
        // Try again in about 5 seconds ...
        std::thread::sleep(std::time::Duration::from_secs(5));
        // Reattempt
        result = open_config();
    }
    match result {
        // Proceed as usual ...
        Ok(cfg) => println!("Opened the config file"),
        // Print the cause to stderr, and DONT PANIC
        Err(e) => {
            eprintln!("File could not be opened: {:?}", e.kind());
            std::process::exit(1); // Exit with code 1 (fail)
    }
}

Этот код пытается найти файл конфигурации в «config.toml». Если это не удается, он пытается найти тот же файл конфигурации в определенной подпапке. Только после того, как вторая попытка окажется неудачной, сообщение об ошибке будет передано вызывающему. Вызывающий является основным и готов повторить попытку сбоя в первый раз после 5-секундной паузы. Обратите внимание, что даже main не запаникует, потому что, хотя это фатальная ошибка, можно возобновить нормальный процесс выхода. Всегда лучше выйти с кодом ошибки, чем паниковать.

Но то же самое и с паникой, std::process:exit может предотвратить разрушение объектов, поэтому используйте его только тогда, когда все еще принадлежит ничто, требующее свойства Drop. Так что паника - это последнее средство, а не изящная встроенная функция, которая позволяет легко выйти с сообщением!

В последнем примере применяется второй вопрос: Являются ли данные абсолютно обязательными для продолжения?

/// Get the username from the config if it is present.
fn read_username_from_config() -> Option<String> {
    // Implementation hidden
}
/// Get the username if present. Otherwise returns the
/// default username "unnamed".
fn get_username() -> String {
    read_username_from_config()
        .unwrap_or(String::from("unnamed"))
}
fn main() {
    let username = get_username();
    println!("Username is: {}", username);
}

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

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

Обработка ошибок в библиотеке

Перечисления - это способ представления значений ошибок в виде кодов.

#[derive(Debug, PartialEq)] // Use derives
enum SafeDivideError { // Define a custom error type
  DivideByZero,
}
fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, SafeDivideError> {
  if divisor == 0 { // If the dividend is zero …
    Err(SafeDivideError::DivideByZero) // Return the error value
  } else { // Otherwise …
    Ok(dividend / divisor) // Return the success value
  }
}
assert_eq!(safe_divide(4, 2), Ok(4/2));
assert_eq!(safe_divide(4, 0), Err(SafeDivideError::DivideByZero));

В приведенном выше примере мы используем настраиваемое перечисление в качестве возвращаемого типа ошибки. Это позволяет легко увидеть, для чего мы создали ошибку, но деление на ноль - единственная причина, по которой нам нужно будет сообщить об ошибке в этой ситуации. Так что, вероятно, лучше использовать Option<i32>.

Создание настраиваемых типов ошибок полезно. Когда вы используете голое перечисление в качестве типа ошибки, объем данных может быть небольшим. Это важно, потому что независимо от использования значения успеха или ошибки, например, Result<i32, String>, он потребляет столько памяти, сколько его самый большой член. В данном случае это строка, размер которой на 16 байт больше, чем i32 в стеке. Напротив, перечисление SafeDivideError потребляет «нулевые» байты памяти.

println!("String:          {}B", std::mem::size_of::<String>());
println!("SafeDivideError: {}B", std::mem::size_of::<SafeDivideError>());
// String:          24B
// SafeDivideError: 0B

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

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

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

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

Сноски

  1. Источник: https://www.phworld.org/history/attcrash.htm
  2. Вот их действительно хороший список: https://en.wikipedia.org/wiki/List_of_software_bugs
  3. Возникновение ошибок - это когда код передает ошибку в восходящий поток, потому что определенно не обрабатывает ее. AKA распространение ошибок.
  4. Но не выполняйте тихую манипуляцию данными.