panic! чи не panic!

Отже, як приймається рішення, коли слід викликати panic!, а коли повернути Result? При паніці код не може відновити своє виконання. Можна було б викликати panic! для будь-якої помилкової ситуації, незалежно від того, чи є спосіб відновлення, чи ні, але з іншого боку, ви приймаєте рішення від імені коду, який викликає, що ситуація необоротна. Коли ви повертаєте значення Result, ви делегуєте прийняття рішення коду, що викликає. Код, що викликає, може спробувати виконати відновлення способом, який підходить в даній ситуації, або ж він може вирішити, що з помилки в Err не можна відновитися і викличе panic!, перетворивши вашу помилку, що виправляється, в невиправну. Тому повернення Result є гарним вибором за замовчуванням для функції, яка може дати збій.

У таких ситуаціях як приклади, прототипи та тести, більш доречно писати код, який панікує замість повернення Result. Розгляньмо чому, а потім обговоримо ситуації, коли компілятор не може довести, що помилка неможлива, але ви, як людина, можете це зробити. Глава закінчуватиметься деякими загальними керівними принципами про те, як вирішити, чи варто панікувати в коді бібліотеки.

Приклади, прототипування та тести

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

Так само методи unwrap та expect є дуже зручними при створенні прототипу, перш ніж ви будете готові вирішити, як обробляти помилки. Вони залишають чіткі маркери в коді до моменту, коли ви будете готові зробити програму надійнішою.

Якщо в тесті відбувається збій при виклику методу, то ви б хотіли, щоб весь тест не пройшов, навіть якщо цей метод не є функціональністю, що тестується. Оскільки виклик panic! це спосіб, яким тест позначається як невдалий, використання unwrap чи expect – саме те, що потрібно.

Випадки, коли у вас більше інформації, ніж у компілятора.

Також було б доцільно викликати unwrap або expect, коли у вас є якась інша логіка, яка гарантує, що Result буде мати значення Ok, але вашу логіку не розуміє компілятор. У вас, як і раніше, буде значення Result, яке потрібно обробити: будь-яка операція, яку ви викликаєте, все ще має можливість невдачі в цілому, хоча це логічно неможливо у вашій конкретній ситуації. Якщо, перевіряючи код вручну, ви можете переконатися, що ніколи не буде варіанту Err, то можна викликати unwrap, а ще краще задокументувати причину, з якої ви думаєте, що ніколи не матимете варіант Err у тексті expect. Ось приклад:

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-адресу з іншого джерела.

Інструкція з обробки помилок

Бажано, щоб код панікував, якщо він може опинитися в некоректному стані. В цьому контексті некоректний стан це такий стан, коли деяке допущення, гарантія, контракт чи інваріант були порушені. Наприклад, коли неприпустимі, суперечливі чи пропущенні значення передаються у ваш код, та інші приклади зі списку нижче:

  • Некоректний стан - це щось неочікуване, відмінне від того, що може відбуватися час від часу, наприклад, коли користувач вводить дані у неправильному форматі.
  • Ваш код після цієї точки повинен покладатися на те, що він не знаходиться у некоректному стані, замість перевірок наявності проблеми на кожному етапі.
  • Немає гарного способу закодувати цю інформацію в типах, які ви використовуєте. Ми подивимося приклад того, що ми маємо на увазі в розділі “Кодування станів та поведінки на основі типів” розділу 17.

Якщо хтось викликає ваш код та передає значення, які не мають сенсу, краще за все повернути помилку, якщо це можливо, щоб користувач бібліотеки мав змогу вирішити, що йому робити в цьому випадку. Однак, у випадках, коли продовження може бути небезпечним чи шкідливим, найкращим вибором може бути виклик panic! для оповіщення користувача бібліотеки, що в його коді є помилка й він може її виправити. Також panic! підходить, якщо ви викликаєте зовнішній, неконтрольований вами код, і він повертає неприпустимий стан, який ви не можете виправити.

Однак, якщо очікується збій, краще повернути Result, ніж виконати виклик panic!. Як приклад можна привести синтаксичний аналізатор, якому передали неправильно сформовані дані чи статус HTTP-запиту, що повернувся, вказує на те, що ви досягли обмеження частоти запитів. У цих випадках повертання Result вказує на те, що відмова є очікуваною, такою, яку код, що викликає, повинен вирішити, як саме обробити.

Коли ваш код виконує операцію, яка може бути ризикованою для користувача, якщо використовуються неприпустимі значення, ваш код повинен спочатку перевірити чи вони коректні, та панікувати, якщо це не так. Діяти таким чином рекомендується в основному з міркувань безпеки: спроба оперувати некоректними даними може спричинити вразливість вашого коду. Це основна причина, через що стандартна бібліотека буде викликати panic!, якщо спробувати отримати доступ до пам'яті поза межами масиву: доступ до пам'яті, яка не стосується поточної структури даних, є відомою проблемою безпеки. Функції часто мають контракти: їх поведінка гарантується, тільки якщо вхідні дані відповідають визначеним вимогам. Паніка при порушенні контракту має сенс, тому що це завжди вказує на дефект з боку коду, що викликає, і це не помилка, яку б ви хотіли, щоб код, що викликає, явно обробляв. Насправді немає розумного способу для відновлення коду, що викликає; Програмісти, що викликають ваш код, повинні виправити свій. Контракти для функції, особливо порушення яких викликає паніку, слід описати в документації API функції.

Проте, наявність великої кількості перевірок помилок у всіх ваших функціях було б багатослівним та дратівливим. На радість, можна використовувати систему типів Rust (отже і перевірку типів компілятором), щоб вона зробила множину перевірок замість вас. Якщо ваша функція має визначений тип в якості параметру, ви можете продовжити роботу з логікою коду знаючи, що компілятор вже забезпечив правильне значення. Наприклад, якщо використовується звичайний тип, а не тип Option, то ваша програма очікує наявність чогось замість нічого. Ваш код не повинен буде опрацювати обидва варіанти Some та None: він буде мати тільки один варіант для певного значення. Код, який намагається нічого не передавати у функцію, не буде навіть компілюватися, тому ваша функція не повинна перевіряти такий випадок під час виконання. Інший приклад - це використання цілого типу без знаку, такого як u32, який гарантує, що параметр ніколи не буде від'ємним.

Створення користувацьких типів для перевірки

Розвиньмо ідею використання системи типів Rust щоб переконатися, що в нас є коректне значення, та розглянемо створення користувацького типа для валідації. Згадаємо гру вгадування числа з розділу 2, в якому наш код просив користувача вгадати число між 1 й 100. Ми ніколи не перевіряли, що припущення користувача знаходяться в межах цих чисел, перед порівнянням з задуманим нами числом; ми тільки перевіряли, що воно додатне. У цьому випадку наслідки були не дуже страшними: наші повідомлення “Забагато” чи “Замало”, які виводилися у консоль, все одно були коректними. Але було б краще підштовхувати користувача до правильних догадок та мати різну поведінку для випадків, коли користувач пропонує число за межами діапазону, і коли користувач вводить, наприклад, літери замість цифр.

One way to do this would be to parse the guess as an i32 instead of only a u32 to allow potentially negative numbers, and then add a check for the number being in range, like so:

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.

However, this is not an ideal solution: if it was absolutely critical that the program only operated on values between 1 and 100, and it had many functions with this requirement, having a check like this in every function would be tedious (and might impact performance).

Замість цього можна створити новий тип та помістити перевірки у функцію створення екземпляру цього типу, не повторюючи їх повсюди. Таким чином, функції можуть використовувати новий тип у своїх сигнатурах та бути впевненими у значеннях, які їм передають. Лістинг 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
    }
}
}

Блок коду 9-13: Тип Guess, який буде створювати екземпляри тільки для значень від 1 до 100

Спочатку ми визначимо структуру з ім'ям 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. Цей метод іноді називають витягувач (getter), тому що його метою є вилучити дані з полів структури та повернути їх. Цей публічний метод є необхідним, оскільки поле value структури Guess є приватним. Важливо, щоб поле value було приватним, щоб код, який використовує структуру Guess, не міг встановлювати value напряму: код зовні модуля повинен використовувати функцію Guess::new для створення екземпляру Guess, таким чином гарантуючи, що у Guess немає можливості отримати value, не перевірене умовами у функції Guess::new.

A function that has a parameter or returns only numbers between 1 and 100 could then declare in its signature that it takes or returns a Guess rather than an i32 and wouldn’t need to do any additional checks in its body.

Підсумок

Можливості обробки помилок в Rust покликані допомогти написанню більш надійного коду. Макрос panic! сигналізує, що ваша програма знаходиться у стані, яке вона не може обробити, та дозволяє сказати процесу щоб він зупинив своє виконання, замість спроби продовжити виконання з некоректними чи невірними значеннями. Перерахунок (enum) Result використовує систему типів Rust, щоб повідомити, що операції можуть завершитися невдачею, і ваш код мав змогу відновитися. Можна використовувати Result, щоб повідомити коду, що викликає, що він повинен обробити потенціальний успіх чи потенційну невдачу. Використання panic! та Result правильним чином зробить ваш код більш надійним перед обличчям неминучих помилок.

Now that you’ve seen useful ways that the standard library uses generics with the Option and Result enums, we’ll talk about how generics work and how you can use them in your code.