Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Відновлювані помилки з Result

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

Пригадайте з “Обробка можливої невдачі за допомогою Result у Розділі 2, що перелічення Result визначено як такий, що має два варіанти, Ok і Err, ось так:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

T і E — це узагальнені (generic) параметри типу: ми докладніше обговоримо узагальнені типи в Розділі 10. Що вам потрібно знати зараз, так це те, що T представляє тип значення, яке буде повернено в разі успіху у варіанті Ok, а E представляє тип помилки, яка буде повернена в разі невдачі у варіанті Err. Оскільки Result має ці узагальнені параметри типу, ми можемо використовувати тип Result і функції, визначені для нього, в багатьох різних ситуаціях, де значення успіху і значення помилки, які ми хочемо повернути, можуть відрізнятися.

Давайте назвемо функцію, яка повертає значення Result, такою, що може завершитися невдачею. У Лістингу 9-3 ми намагаємося відкрити файл.

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

Тип повернення File::openResult<T, E>. Узагальнений параметр T був заповнений реалізацією File::open типом значення успіху, std::fs::File, який є файловим дескриптором. Тип E, що використовується у значенні помилки, — std::io::Error. Цей тип повернення означає, що виклик File::open може завершитися успіхом і повернути файловий дескриптор, з якого ми можемо читати або в який можемо записувати. Виклик функції також може завершитися невдачею: наприклад, файл може не існувати, або ми можемо не мати дозволу на доступ до файлу. Функція File::open потребує способу повідомити нам, чи вона завершилася успіхом, чи невдачею, і водночас дати нам або файловий дескриптор, або інформацію про помилку. Саме цю інформацію і передає перелічення Result.

У випадку, коли File::open завершується успіхом, значення в змінній greeting_file_result буде екземпляром Ok, що містить файловий дескриптор. У випадку, коли вона завершується невдачею, значення в greeting_file_result буде екземпляром Err, що містить більше інформації про тип помилки, яка виникла.

Нам потрібно додати до коду в Лістингу 9-3, щоб виконувати різні дії залежно від значення, яке повертає File::open. Лістинг 9-4 показує один зі способів обробити Result за допомогою базового інструмента — виразу match, про який ми говорили в Розділі 6.

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

Зверніть увагу, що, як і перелічення Option, перелічення Result і його варіанти були внесені в область видимості завдяки prelude, тож нам не потрібно вказувати Result:: перед варіантами Ok і Err у гілках match.

Коли результат — Ok, цей код поверне внутрішнє значення file з варіанта Ok, і тоді ми присвоїмо це значення файлового дескриптора змінній greeting_file. Після match ми можемо використовувати файловий дескриптор для читання або запису.

Інша гілка match обробляє випадок, коли ми отримуємо значення Err від File::open. У цьому прикладі ми вирішили викликати макрос panic!. Якщо в нашому поточному каталозі немає файлу з назвою hello.txt і ми запустимо цей код, ми побачимо такий вивід від макроса panic!:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Як зазвичай, цей вивід точно повідомляє нам, що саме пішло не так.

Зіставлення з різними помилками

Код у Лістингу 9-4 викличе panic! незалежно від того, чому File::open завершився невдачею. Однак ми хочемо виконувати різні дії для різних причин невдачі. Якщо File::open завершився невдачею тому, що файл не існує, ми хочемо створити файл і повернути дескриптор нового файлу. Якщо File::open завершився невдачею з будь-якої іншої причини — наприклад, тому що ми не мали дозволу на відкриття файлу — ми все одно хочемо, щоб код викликав panic! так само, як це було у Лістингу 9-4. Для цього ми додаємо внутрішній вираз match, як показано у Лістингу 9-5.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

Тип значення, яке File::open повертає всередині варіанта Err, — io::Error, який є структурою, наданою стандартною бібліотекою. Ця структура має метод kind, який ми можемо викликати, щоб отримати значення io::ErrorKind. Перелічення io::ErrorKind надається стандартною бібліотекою і має варіанти, що представляють різні види помилок, які можуть виникнути під час операції io. Варіант, який ми хочемо використати, — ErrorKind::NotFound, що вказує, що файл, який ми намагаємося відкрити, ще не існує. Отже, ми виконуємо зіставлення на greeting_file_result, але також маємо внутрішнє зіставлення на error.kind().

Умова, яку ми хочемо перевірити у внутрішньому match, полягає в тому, чи є значення, повернуте error.kind(), варіантом NotFound перелічення ErrorKind. Якщо так, ми намагаємося створити файл за допомогою File::create. Однак, оскільки File::create також може завершитися невдачею, нам потрібна друга гілка у внутрішньому виразі match. Коли файл не вдається створити, виводиться інше повідомлення про помилку. Друга гілка зовнішнього match залишається тією ж, тож програма завершується панікою для будь-якої помилки, окрім помилки відсутнього файлу.

Альтернативи використанню match з Result<T, E>

Це багато match! Вираз match дуже корисний, але також дуже примітивний. У Розділі 13 ви дізнаєтеся про замикання, які використовуються з багатьма методами, визначеними на Result<T, E>. Ці методи можуть бути лаконічнішими, ніж використання match, під час обробки значень Result<T, E> у вашому коді.

Наприклад, ось ще один спосіб записати ту саму логіку, що показана в Лістингу 9-5, цього разу використовуючи замикання і метод unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

Хоча цей код має таку саму поведінку, як у Лістингу 9-5, він не містить жодних виразів match і є чистішим для читання. Поверніться до цього прикладу після того, як прочитаєте Розділ 13, і пошукайте метод unwrap_or_else у документації стандартної бібліотеки. Є ще багато таких методів, які можуть прибрати величезні вкладені вирази match, коли ви працюєте з помилками.

Скорочені способи викликати паніку при помилці

Використання match працює достатньо добре, але це може бути дещо багатослівним і не завжди добре передає намір. Тип Result<T, E> має багато допоміжних методів, визначених для нього, щоб виконувати різні, більш специфічні завдання. Метод unwrap — це метод-швидкий спосіб, реалізований точно так само, як вираз match, який ми написали у Лістингу 9-4. Якщо значення Result є варіантом Ok, unwrap поверне значення всередині Ok. Якщо Result є варіантом Err, unwrap викличе для нас макрос panic!. Ось приклад unwrap у дії:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

Якщо ми запустимо цей код без файлу hello.txt, ми побачимо повідомлення про помилку від виклику panic!, який робить метод unwrap:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Аналогічно, метод expect також дає нам змогу обрати повідомлення про помилку для panic!. Використання expect замість unwrap і надання добрих повідомлень про помилки може передати ваш намір і полегшити пошук джерела паніки. Синтаксис expect виглядає так:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Ми використовуємо expect так само, як unwrap: щоб повернути файловий дескриптор або викликати макрос panic!. Повідомлення про помилку, яке expect використовує у своєму виклику panic!, буде параметром, який ми передаємо до expect, а не стандартним повідомленням panic!, яке використовує unwrap. Ось як це виглядає:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

У коді промислової якості більшість растацеанців (Rustaceans) обирають expect, а не unwrap, і дають більше контексту про те, чому операція, як очікується, має завжди завершуватися успіхом. Так, якщо ваші припущення коли-небудь виявляться хибними, у вас буде більше інформації для налагодження.

Поширення помилок

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

Наприклад, Лістинг 9-6 показує функцію, яка читає ім’я користувача з файлу. Якщо файл не існує або його неможливо прочитати, ця функція поверне ці помилки коду, що викликав функцію.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

Цю функцію можна написати значно коротше, але ми почнемо з того, що зробимо багато чого вручну, щоб дослідити обробку помилок; наприкінці ми покажемо коротший спосіб. Спершу погляньмо на тип повернення функції: Result<String, io::Error>. Це означає, що функція повертає значення типу Result<T, E>, де узагальнений параметр T було заповнено конкретним типом String, а узагальнений тип E було заповнено конкретним типом io::Error.

Якщо ця функція завершується успіхом без будь-яких проблем, код, що викликає цю функцію, отримає значення Ok, яке містить Stringusername, яке ця функція прочитала з файлу. Якщо ця функція стикається з будь-якими проблемами, код, що викликає, отримає значення Err, яке містить екземпляр io::Error, що містить більше інформації про те, якими були проблеми. Ми обрали io::Error як тип повернення цієї функції, тому що саме такий тип має значення помилки, яке повертається обома операціями, які ми викликаємо в тілі цієї функції і які можуть завершитися невдачею: функцією File::open і методом read_to_string.

Тіло функції починається з виклику функції File::open. Потім ми обробляємо значення Result за допомогою match, подібного до match у Лістингу 9-4. Якщо File::open завершується успіхом, файловий дескриптор у шаблонній змінній file стає значенням у змінній username_file, і функція продовжується. У випадку Err, замість виклику panic!, ми використовуємо ключове слово return, щоб достроково вийти з функції повністю і передати значення помилки з File::open, тепер у шаблонній змінній e, назад коду, що викликає, як значення помилки цієї функції.

Отже, якщо ми маємо файловий дескриптор у username_file, функція потім створює новий String у змінній username і викликає метод read_to_string на файловому дескрипторі в username_file, щоб прочитати вміст файлу в username. Метод read_to_string також повертає Result, тому що він може завершитися невдачею, навіть якщо File::open завершився успіхом. Отже, нам потрібен ще один match, щоб обробити цей Result: якщо read_to_string завершується успіхом, тоді наша функція завершується успіхом, і ми повертаємо ім’я користувача з файлу, яке тепер у username, загорнуте в Ok. Якщо read_to_string завершується невдачею, ми повертаємо значення помилки так само, як ми повертали значення помилки в match, який обробляв повернене значення File::open. Однак нам не потрібно явно писати return, тому що це останній вираз у функції.

Код, що викликає цей код, потім оброблятиме отримання або значення Ok, яке містить ім’я користувача, або значення Err, яке містить io::Error. Саме коду, що викликає, вирішувати, що робити з цими значеннями. Якщо код, що викликає, отримує значення Err, він може викликати panic! і аварійно завершити програму, використати стандартне ім’я користувача або, наприклад, шукати ім’я користувача десь, крім файлу. У нас недостатньо інформації про те, що саме намагається зробити код, що викликає, тому ми поширюємо всю інформацію про успіх або помилку вгору, щоб він обробив її належним чином.

Цей шаблон поширення помилок настільки поширений у Rust, що Rust надає оператор питання ?, щоб спростити це.

Скорочений спосіб поширення помилок: оператор ?

Лістинг 9-7 показує реалізацію read_username_from_file, яка має ту саму функціональність, що й у Лістингу 9-6, але ця реалізація використовує оператор ?.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

?, поставлений після значення Result, визначено так, щоб працювати майже так само, як вирази match, які ми визначили для обробки значень Result у Лістингу 9-6. Якщо значення ResultOk, значення всередині Ok буде повернено з цього виразу, і програма продовжить виконання. Якщо значення — Err, Err буде повернено з усієї функції так, ніби ми використали ключове слово return, щоб значення помилки було поширено до коду, що викликає.

Існує різниця між тим, що робить вираз match з Лістингу 9-6, і тим, що робить оператор ?: значення помилки, до яких застосовано оператор ?, проходять через функцію from, визначену в трейті From у стандартній бібліотеці, яка використовується для перетворення значень з одного типу в інший. Коли оператор ? викликає функцію from, отриманий тип помилки перетворюється на тип помилки, визначений у типі повернення поточної функції. Це корисно, коли функція повертає один тип помилки, щоб представити всі способи, якими функція може завершитися невдачею, навіть якщо окремі частини можуть завершитися невдачею з багатьох різних причин.

Наприклад, ми могли б змінити функцію read_username_from_file у Лістингу 9-7, щоб вона повертала власний тип помилки з назвою OurError, який ми визначаємо. Якщо ми також визначимо impl From<io::Error> for OurError, щоб створювати екземпляр OurError з io::Error, тоді виклики оператора ? у тілі read_username_from_file викликатимуть from і перетворюватимуть типи помилок без потреби додавати ще якийсь код до функції.

У контексті Лістингу 9-7, ? у кінці виклику File::open поверне значення всередині Ok у змінну username_file. Якщо виникне помилка, оператор ? достроково поверне її з усієї функції і передасть будь-яке значення Err коду, що викликає. Те саме стосується ? у кінці виклику read_to_string.

Оператор ? усуває багато шаблонного коду і робить реалізацію цієї функції простішою. Ми навіть могли б ще скоротити цей код, безпосередньо ланцюжуючи виклики методів після ?, як показано у Лістингу 9-8.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

Ми перенесли створення нового String у username на початок функції; ця частина не змінилася. Замість створення змінної username_file ми безпосередньо приєднали виклик read_to_string до результату File::open("hello.txt")?. У нас усе ще є ? у кінці виклику read_to_string, і ми все ще повертаємо значення Ok, що містить username, коли і File::open, і read_to_string завершуються успіхом, а не повертають помилки. Функціональність знову та сама, що й у Лістингу 9-6 і у Лістингу 9-7; це просто інший, більш зручний спосіб записати це.

Лістинг 9-9 показує спосіб зробити це ще коротшим, використовуючи fs::read_to_string.

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

Читання файлу в рядок — це досить поширена операція, тож стандартна бібліотека надає зручну функцію fs::read_to_string, яка відкриває файл, створює новий String, читає вміст файлу, поміщає вміст у цей String і повертає його. Звісно, використання fs::read_to_string не дає нам можливості пояснити всю обробку помилок, тож спочатку ми зробили це довшим способом.

Де можна використовувати оператор ?

Оператор ? можна використовувати лише у функціях, тип повернення яких сумісний зі значенням, на якому використовується ?. Це тому, що оператор ? визначено так, щоб виконувати дострокове повернення значення з функції, так само, як і вираз match, який ми визначили у Лістингу 9-6. У Лістингу 9-6 match використовував значення Result, а гілка дострокового повернення повертала значення Err(e). Тип повернення функції має бути Result, щоб він був сумісний з цим return.

У Лістингу 9-10 подивімося на помилку, яку ми отримаємо, якщо використаємо оператор ? у функції main з типом повернення, несумісним із типом значення, на якому ми використовуємо ?.

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

Цей код відкриває файл, що може завершитися невдачею. Оператор ? іде після значення Result, поверненого File::open, але ця функція main має тип повернення (), а не Result. Коли ми компілюємо цей код, ми отримуємо таке повідомлення про помилку:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

Ця помилка вказує, що нам дозволено використовувати оператор ? лише у функції, яка повертає Result, Option або інший тип, що реалізує FromResidual.

Щоб виправити помилку, у вас є два варіанти. Один варіант — змінити тип повернення вашої функції так, щоб він був сумісний зі значенням, на якому ви використовуєте оператор ?, якщо вас нічого не обмежує. Інший варіант — це використати match або один із методів Result<T, E>, щоб обробити Result<T, E> у будь-який відповідний спосіб.

У повідомленні про помилку також згадувалося, що ? можна використовувати і зі значеннями Option<T>. Як і у випадку використання ? з Result, ви можете використовувати ? на Option лише у функції, яка повертає Option. Поведінка оператора ?, коли його застосовують до Option<T>, подібна до його поведінки, коли його застосовують до Result<T, E>: якщо значення — None, Noneбуде повернено достроково з функції в цій точці. Якщо значення —Some, значення всередині Some` є результатним значенням виразу, і функція продовжується. Лістинг 9-11 містить приклад функції, яка знаходить останній символ першого рядка в заданому тексті.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

Ця функція повертає Option<char>, тому що там може бути символ, але також може і не бути. Цей код бере аргумент-зріз рядка text і викликає на ньому метод lines, який повертає ітератор по рядках у рядку. Оскільки ця функція хоче розглянути перший рядок, вона викликає next на ітераторі, щоб отримати перше значення з ітератора. Якщо text — порожній рядок, цей виклик next поверне None, і в такому разі ми використовуємо ?, щоб зупинитися і повернути None з last_char_of_first_line. Якщо text не є порожнім рядком, next поверне значення Some, що містить зріз рядка першого рядка в text.

? витягує зріз рядка, і ми можемо викликати chars на цьому зрізі рядка, щоб отримати ітератор його символів. Нас цікавить останній символ у цьому першому рядку, тож ми викликаємо last, щоб повернути останній елемент з ітератора. Це Option, тому що можливо, що перший рядок є порожнім рядком; наприклад, якщо text починається з порожнього рядка, але має символи в інших рядках, як у "\nhi". Однак якщо в першому рядку є останній символ, він буде повернений у варіанті Some. Оператор ? посередині дає нам лаконічний спосіб виразити цю логіку, дозволяючи реалізувати функцію в один рядок. Якби ми не могли використовувати оператор ? на Option, нам би довелося реалізовувати цю логіку за допомогою більшої кількості викликів методів або виразу match.

Зверніть увагу, що ви можете використовувати оператор ? на Result у функції, яка повертає Result, і ви можете використовувати оператор ? на Option у функції, яка повертає Option, але ви не можете їх змішувати. Оператор ? не перетворює автоматично Result на Option або навпаки; у таких випадках ви можете використовувати методи на кшталт ok на Result або ok_or на Option, щоб виконати перетворення явно.

Поки що всі функції main, які ми використовували, повертають (). Функція main є особливою, тому що вона є точкою входу і точкою виходу виконуваної програми, і існують обмеження щодо того, яким може бути її тип повернення, щоб програма поводилася так, як очікується.

На щастя, main також може повертати Result<(), E>. Лістинг 9-12 містить код із Лістингу 9-10, але ми змінили тип повернення main на Result<(), Box<dyn Error>> і додали значення повернення Ok(()) у кінець. Тепер цей код скомпілюється.

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Тип Box<dyn Error> — це об’єкт трейту, про який ми поговоримо в “Використання трейт-об’єктів для абстрагування над спільною поведінкою” у Розділі 18. Поки що ви можете читати Box<dyn Error> як “будь-який тип помилки”. Використання ? на значенні Result у функції main з типом помилки Box<dyn Error> дозволено, тому що це дає змогу будь-якому значенню Err бути поверненим достроково. Хоча тіло цієї функції main завжди повертатиме лише помилки типу std::io::Error, якщо вказати Box<dyn Error>, цей сигнатурний тип залишатиметься правильним навіть якщо до тіла main буде додано більше коду, який повертає інші помилки.

Коли функція main повертає Result<(), E>, виконуваний файл завершить роботу зі значенням 0, якщо main поверне Ok(()), і завершить роботу з ненульовим значенням, якщо main поверне значення Err. Виконувані файли, написані в C, повертають цілі числа під час завершення: програми, що завершуються успішно, повертають ціле число 0, а програми, що завершуються з помилкою, повертають деяке ціле число, відмінне від 0. Rust також повертає цілі числа з виконуваних файлів, щоб бути сумісним із цією конвенцією.

Функція main може повертати будь-які типи, які реалізують трейт std::process::Termination, що містить функцію report, яка повертає ExitCode. Зверніться до документації стандартної бібліотеки для отримання додаткової інформації про реалізацію трейту Termination для власних типів.

Тепер, коли ми обговорили деталі виклику panic! або повернення Result, давайте повернемося до теми того, як вирішити, що саме доречно використовувати в яких випадках.