Когда следует использовать 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
, мы поговорим о том, как вообще работают обобщённые типы и как вы можете использовать их в своём коде.