Паниковать или не паниковать?
Итак, как вы решаете, когда следует вызывать 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, чтобы разрешить потенциально отрицательные числа, а затем добавить проверку на то, что число находится в диапазоне, вот так:
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.
#![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 } } }
Guess, который продолжит работу только со значениями между 1 и 100Обратите внимание, что этот код в src/guessing_game.rs зависит от добавления объявления модуля mod guessing_game; в src/lib.rs, которое мы здесь не показали. В файле этого нового модуля мы определяем структуру в этом модуле с именем Guess, которая имеет поле с именем value, которое хранит i32. Здесь будет храниться число.
Затем мы реализуем ассоциированную функцию с именем new для Guess, которая создаёт экземпляры значений 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. Такой метод иногда называется геттером, потому что его цель — получить некоторые данные из его полей и вернуть их. Этот публичный метод необходим, потому что поле value структуры Guess является приватным. Важно, чтобы поле value было приватным, чтобы код, использующий структуру Guess, не мог устанавливать value напрямую: код вне модуля guessing_game должен использовать функцию Guess::new для создания экземпляра Guess, тем самым обеспечивая, что нет способа для Guess иметь value, который не был проверен условиями в функции Guess::new.
Функция, которая имеет параметр или возвращает только числа между 1 и 100, может затем объявить в своей сигнатуре, что она принимает или возвращает Guess вместо i32 и не будет необходимости делать дополнительные проверки в своём теле.
Краткое содержание
Функции обработки ошибок Rust предназначены, чтобы помочь вам писать более надёжный код. Макрос panic! сигнализирует, что ваша программа находится в состоянии, которое она не может обработать, и позволяет вам сказать процессу остановиться вместо того, чтобы пытаться продолжить с невалидными или некорректными значениями. Перечисление Result использует систему типов Rust, чтобы указать, что операции могут завершиться неудачей таким образом, что ваш код может восстановиться. Вы можете использовать Result, чтобы сообщить коду, вызывающему ваш код, что он также должен обрабатывать потенциальный успех или неудачу. Использование panic! и Result в соответствующих ситуациях сделает ваш код более надёжным перед лицом неизбежных проблем.
Теперь, когда вы увидели полезные способы, которыми стандартная библиотека использует обобщения с перечислениями Option и Result, мы поговорим о том, как работают обобщения и как вы можете использовать их в своём коде.