Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Использовать panic! или не использовать panic!

Итак, как решить, когда следует вызывать panic!, а когда следует возвращать Result? Когда код паникует, восстановиться уже невозможно. Вы можете вызвать panic! в любой ошибочной ситуации, независимо от того, есть ли возможный способ восстановиться или нет, но тогда вы принимаете решение за вызывающий код о том, что ситуация невосстанавливаемая. Когда вы выбираете возврат значения Result, вы даете вызывающему коду варианты. Вызывающий код может попытаться восстановиться способом, подходящим для его ситуации, или решить, что значение Err в этом случае невосстанавливаемо, и вызвать panic!, превратив вашу восстанавливаемую ошибку в невосстанавливаемую. Поэтому возврат Result – хороший выбор по умолчанию при определении функции, которая может завершиться неудачно.

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

Примеры, прототипный код и тесты

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

Аналогично, методы unwrap и expect очень удобны при прототипировании, когда вы еще не готовы решить, как обрабатывать ошибки. Они оставляют в коде явные маркеры для момента, когда вы будете готовы сделать программу более надежной.

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

Когда у вас больше информации, чем у компилятора

Также уместно вызвать expect, когда у вас есть другая логика, гарантирующая, что Result будет содержать значение Ok, но эта логика не является чем-то, что понимает компилятор. У вас все равно будет значение Result, которое нужно обработать: любая вызываемая операция в общем случае все еще может завершиться неудачно, хотя в вашей конкретной ситуации это логически невозможно. Если с помощью ручной проверки кода вы можете убедиться, что варианта Err никогда не будет, вполне допустимо вызвать expect и в тексте аргумента задокументировать причину, по которой, как вы считаете, варианта Err никогда не будет. Вот пример:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Мы создаем экземпляр IpAddr, разбирая жестко заданную строку. Мы видим, что 127.0.0.1 – допустимый IP-адрес, поэтому здесь допустимо использовать expect. Однако наличие жестко заданной допустимой строки не меняет возвращаемый тип метода parse: мы все равно получаем значение Result, и компилятор все равно заставит нас обработать Result так, как если бы вариант Err был возможен, потому что компилятор недостаточно умен, чтобы увидеть, что эта строка всегда является допустимым IP-адресом. Если бы строка IP-адреса поступала от пользователя, а не была жестко задана в программе, и поэтому действительно могла привести к неудаче, мы определенно захотели бы вместо этого обработать Result более надежным способом. Упоминание предположения о том, что этот IP-адрес жестко задан, подскажет нам заменить expect на более качественный код обработки ошибок, если в будущем нам понадобится получать IP-адрес из другого источника.

Рекомендации по обработке ошибок

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

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

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

Однако когда неудача ожидаема, уместнее вернуть Result, чем вызывать panic!. Примеры включают парсер, которому передали некорректные данные, или HTTP-запрос, вернувший статус, показывающий, что вы достигли ограничения частоты запросов. В таких случаях возврат Result указывает, что неудача – ожидаемая возможность, и вызывающий код должен решить, как ее обработать.

Когда ваш код выполняет операцию, которая может подвергнуть пользователя риску, если ее вызвать с недопустимыми значениями, ваш код должен сначала проверить, что значения допустимы, и запаниковать, если они недопустимы. Это нужно в основном по причинам безопасности: попытка работать с недопустимыми данными может открыть ваш код для уязвимостей. Это главная причина, по которой стандартная библиотека вызовет panic!, если вы попытаетесь обратиться к памяти за допустимыми границами: попытка доступа к памяти, которая не принадлежит текущей структуре данных, является распространенной проблемой безопасности. У функций часто есть контракты: их поведение гарантируется только если входные данные удовлетворяют определенным требованиям. Паника при нарушении контракта имеет смысл, потому что нарушение контракта всегда указывает на ошибку со стороны вызывающего кода, и это не тот вид ошибки, который вызывающий код должен явно обрабатывать. Фактически у вызывающего кода нет разумного способа восстановиться; исправить код должны вызывающие программисты. Контракты функции, особенно когда нарушение приведет к панике, должны быть объяснены в API-документации этой функции.

Однако большое количество проверок ошибок во всех ваших функциях было бы многословным и раздражающим. К счастью, можно использовать систему типов Rust (и, следовательно, проверку типов, выполняемую компилятором), чтобы сделать многие проверки за вас. Если у функции есть параметр определенного типа, вы можете продолжать выполнять логику своего кода, зная, что компилятор уже убедился в наличии допустимого значения. Например, если у вас есть тип, а не Option, ваша программа ожидает получить что-то, а не ничего. Тогда коду не нужно обрабатывать два случая для вариантов Some и None: у него будет только один случай – значение точно есть. Код, который пытается передать в вашу функцию ничего, даже не скомпилируется, поэтому вашей функции не нужно проверять этот случай во время выполнения. Другой пример – использование беззнакового целочисленного типа, такого как u32, который гарантирует, что параметр никогда не будет отрицательным.

Пользовательские типы для валидации

Сделаем еще один шаг в идее использования системы типов Rust, чтобы обеспечить наличие допустимого значения, и рассмотрим создание пользовательского типа для валидации. Вспомните игру в угадывание из главы 2, где наш код просил пользователя угадать число от 1 до 100. Мы никогда не проверяли, что предположение пользователя находится между этими числами, прежде чем сравнивать его с нашим секретным числом; мы проверяли только, что предположение положительное. В этом случае последствия были не слишком серьезными: наш вывод «Слишком много» или «Слишком мало» все равно был бы корректным. Но полезным улучшением было бы направлять пользователя к допустимым предположениям и иметь разное поведение, когда пользователь угадывает число вне диапазона и когда пользователь вводит, например, буквы.

Один способ сделать это – разобрать предположение как i32, а не только как u32, чтобы допустить потенциально отрицательные числа, а затем добавить проверку, что число находится в диапазоне, вот так:

Filename: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Выражение if проверяет, находится ли наше значение вне диапазона, сообщает пользователю о проблеме и вызывает continue, чтобы начать следующую итерацию цикла и запросить другое предположение. После выражения if мы можем продолжить сравнения между guess и секретным числом, зная, что guess находится между 1 и 100.

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

Вместо этого мы можем создать новый тип в отдельном модуле и поместить валидации в функцию, которая создает экземпляр типа, а не повторять проверки повсюду. Тогда функции смогут безопасно использовать новый тип в своих сигнатурах и уверенно работать с полученными значениями. Листинг 9-13 показывает один способ определить тип Guess, который создаст экземпляр Guess только если функция new получит значение между 1 и 100.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: Тип Guess, который продолжит работу только со значениями между 1 и 100

Обратите внимание, что этот код в src/guessing_game.rs зависит от добавления объявления модуля mod guessing_game; в src/lib.rs, которое здесь не показано. В файле этого нового модуля мы определяем структуру с именем Guess, у которой есть поле с именем value, содержащее i32. Именно здесь будет храниться число.

Затем мы реализуем для Guess связанную функцию с именем new, которая создает экземпляры значений Guess. Функция new определена с одним параметром value типа i32 и возвращает Guess. Код в теле функции new проверяет value, чтобы убедиться, что оно находится между 1 и 100. Если value не проходит эту проверку, мы вызываем panic!, что предупредит программиста, пишущего вызывающий код, о наличии ошибки, которую нужно исправить, потому что создание Guess со значением value вне этого диапазона нарушило бы контракт, на который полагается Guess::new. Условия, при которых Guess::new может паниковать, следует обсудить в его публичной API-документации; в главе 14 мы рассмотрим соглашения документации, указывающие на возможность panic! в API-документации, которую вы создаете. Если value проходит проверку, мы создаем новый Guess, установив его поле value равным параметру value, и возвращаем Guess.

Затем мы реализуем метод с именем value, который заимствует self, не имеет других параметров и возвращает i32. Такой метод иногда называют геттером, потому что его цель – получить некоторые данные из полей и вернуть их. Этот публичный метод необходим, потому что поле value структуры Guess приватное. Важно, чтобы поле value было приватным, чтобы код, использующий структуру Guess, не мог устанавливать value напрямую: код вне модуля guessing_game обязан использовать функцию Guess::new для создания экземпляра Guess, тем самым гарантируя, что не существует способа получить Guess со значением value, которое не было проверено условиями в функции Guess::new.

Функция, параметром которой являются только числа между 1 и 100 или которая возвращает только такие числа, затем могла бы объявить в своей сигнатуре, что она принимает или возвращает Guess, а не i32, и ей не потребовались бы дополнительные проверки в теле.

Итоги

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

Теперь, когда вы увидели полезные способы, которыми стандартная библиотека использует обобщения с enum Option и Result, мы поговорим о том, как работают обобщения и как можно использовать их в своем коде.