До 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!. Приклади включають аналізатор (parser), якому передають пошкоджені дані,
або 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
}
}
}
Зверніть увагу, що цей код у src/guessing_game.rs залежить від додавання
оголошення модуля mod guessing_game; у src/lib.rs, яке ми тут не
показували. Усередині файла цього нового модуля ми визначаємо структуру з
назвою Guess, яка має поле з назвою value, що містить i32. Тут буде
зберігатися число.
Потім ми реалізуємо пов’язану функцію (associated function) з назвою 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. Такий метод іноді називають
гетер (getter), тому що його мета — отримати деякі дані з його полів і повернути
їх. Цей публічний метод необхідний, тому що поле value структури Guess
є приватним. Важливо, щоб поле value було приватним, щоб код, який
використовує структуру Guess, не міг встановлювати value безпосередньо:
код поза модулем guessing_game повинен використовувати функцію
Guess::new, щоб створити екземпляр Guess, тим самим гарантуючи, що
немає способу для Guess мати value, яке не було перевірене умовами у
функції Guess::new.
Функція, яка має параметр або повертає лише числа між 1 і 100, тоді могла б
оголосити в своїй сигнатурі, що вона приймає або повертає Guess, а не i32,
і не потребувала б жодних додаткових перевірок у своєму тілі.
Підсумок
Можливості обробки помилок Rust розроблено, щоб допомогти вам писати
надійніший код. Макрос panic! сигналізує, що ваша програма перебуває в
стані, з яким вона не може впоратися, і дає вам змогу наказати процесу
стані, з яким вона не може впоратися, і дає вам змогу наказати процесу
зупинитися замість спроби продовжувати з недійсними або неправильними
значеннями. Лістинг 9-13 використовує систему типів Rust, щоб вказати, що
операції можуть зазнати невдачі способом, від якого ваш код міг би
відновитися. Ви можете використовувати Result, щоб повідомити коду, який
викликає ваш код, що йому також потрібно обробляти можливий успіх або збій.
Використання panic! і Result у відповідних ситуаціях зробить ваш код
надійнішим перед неминучими проблемами.
Тепер, коли ви побачили корисні способи, якими стандартна бібліотека
використовує узагальнені типи з переліченнями Option і Result, ми поговоримо
про те, як працюють узагальнені (generic) типи і як ви можете використовувати їх у
своєму коді.