Когда следует использовать panic!?

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

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

Примеры, прототипы и тесты

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

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

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

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

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

Creating Custom Types for Validation

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

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

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Угадайте число!");

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

    loop {
        // --код сокращён--

        println!("Введите свою догадку.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Не удалось прочесть ввод.");

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

        if guess < 1 || guess > 100 {
            println!("Загаданное число находится в пределах от 1 до 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --код сокращён--
            Ordering::Less => println!("Слишком маленькое!"),
            Ordering::Greater => println!("Слишком большое!"),
            Ordering::Equal => {
                println!("Вы победили!");
                break;
            }
        }
    }
}

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

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

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

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Догадка {value} не принадлежит пределу от 1 до 100.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

Сначала мы определяем структуру с именем Guess, которая имеет поле с именем value типа i32, в котором будет храниться догадка.

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

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

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

Подведём итоги

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

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