Программирование PC Rust: обработка ошибок Fri, October 18 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: обработка ошибок Печать
Добавил(а) microsin   

Ошибки это факт жизни в программном обеспечении, так что Rust предусматривает несколько фич для обработки ситуаций, когда что-то пошло не так. Во многих случаях Rust требует, чтобы вы признали возможность ошибки, и предприняли некоторые действия, прежде чем ваш код будет скомпилирован. Это требование делает вашу программу более надежной, потому что многие ошибки будут распознаны на ранней стадии, с гарантией, что в их обнаружите и обработаете надлежащим образом, прежде чем развернете свой код в рабочей среде.

Rust группирует runtime-ошибки по двум основным категориям: восстановимые (recoverable errors) и не восстановимые (unrecoverable errors). Для восстановимой ошибки, наподобие ошибки не найденного файла (file not found error), мы скорее всего можем просто сообщить пользователю о проблеме, и повторить оперцию. Не восстановимые ошибки всегда являются симптомом багов, наподобие попытки доступа к элементу за концом массива, и тогда мы захотим немедленно остановить программу (паника программы).

Большинство языков не делают различий между этими двумя видами ошибок, и обрабатывают их одинаковым способом, используя такие механизмы, как исключения (exceptions). Rust не имеет exceptions. Вместо этого в нем есть тип Result< T, E> для восстановимых ошибок и макрос panic!, который остановит выполнение, когда программа столкнется с не восстановимой ошибкой. Эта глава сначала объясняет вызов panic!, и затем рассказывает про возвращаемые значения Result< T, E>. Дополнительно мы рассмотрим соображения при принятии решения о том, следует ли пытаться восстановиться из состоянии ошибки, или же следует остановить выполнение программы.

[Не восстановимые ошибки и panic!]

Иногда в вашем коде происходят плохие вещи, и вы ничего не можете с этим поделать. Для таких случаев у Rust есть макрос panic!. Есть два способа на практике вызвать панику программы: либо произвести какие-нибудь недопустимые действия, которые приведу код к панике (как например доступ к элементу массива за его пределами), либо явно вызвать макрос panic!. В обоих случаях мы вызовем панику в программе. По умолчанию эти паники напечатают сообщение отказа, отмотают (unwind), очистят стек, и произведут выход из программы. С помощью переменной окружения вы также можете заставить Rust отобразить при панике историю вызовов программы (call stack), что упрощает отслеживание источника паники.

По умолчанию, когда происходит паника, программа запустит отматывание стека (unwinding), т. е. Rust производит обратную прокрутку стека с очисткой в нем данных, оставшихся от вызова каждой функции. Однако это обратная прогулка по стеку о очистка представляет большую работу. Таким образом, Rust позволяет вам выбрать альтернативу - немедленно прервать выполнение (aborting), что сразу завершит программу без очистки.

Память, которую программа использовала, должна быть очищена операционной системой. Если для вашего проекта нужно сделать конечный бинарник, который должен быть минимальный по размеру, вы можете переключиться из unwinding на aborting при панике, путем добавления panic = 'abort' для соответствующих секций [profile] в вашем файле Cargo.toml. Например, если вы захотите применит abort при панике в режиме релиза, добавьте в Cargo.toml следующее:

[profile.release]
panic = 'abort'

Давайте попробуем вызвать макрос panic! в простой программе:

fn main() {
    panic!("crash and burn");
}

Когда вы запустите эту программу, увидите что-то типа:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Вызов panic! приводит к выводу сообщения об ошибке в последних строках. Здесь показывается место в коде, где произошла паника: src/main.rs:2:5 указывает на вторую строку и пятый символ исходного кода файла src/main.rs.

В этом случае указанная строка является частью вашего кода, и если мы перейдем к этой строке, то увидим вызов макроса panic!. В других случаях вызов panic! может быть в коде, который ваш код вызывает, и в сообщении ошибки будут другие имя файла и номер строки, откуда был вызван макрос panic!, а не строка вашего кода, которая в результате привела к этой ситуации. Мы можем использовать backtrace функций, чтобы отследить место вызова panic! и промежуточные функции, которые привели к нему, что поможет найти причину проблемы.

Использование panic! backtrace. Давайте разберем другой пример, чтобы увидеть, что происходит, когда вызов макроса panic! происходит из библиотеки, потому что ошибка была в нашем коде, а не из нашего кода, вызывающего макрос panic! напрямую. В листинге 9-1 код пытается обратиться по индексу в векторе за пределами допустимого диапазона индексов.

fn main() {
    let v = vec![1, 2, 3];
v[99]; }

Листинг 9-1. Попытка доступа к элементу вектора за его концом приведет к вызову макроса panic!.

Здесь мы попытались обратиться к сотому элементу нашего вектора (индекс 99 потому что индексация начинается от нуля), но в векторе всего лишь 3 элемента. В этой ситуации Rust будет паниковать. Использование [] предполагает возврат элемента вектора, но если вы передали недопустимый индекс, то нет элемента, который мог бы возвратить.

На языке C попытка чтения за концом структуры данных приведет к неопределенному поведению. Вы можете получить все, что находится в месте памяти, которая соответствует этому элементу, даже если память не принадлежит этой структуре. Это называется ошибкой переполнения буфера (buffer overread), и может привести к уязвимостям в системе безопасности, если атакующий сможет произвольно управлять индексом, то таким способом он может прочитать данные, хранящиеся за структурой данных, хотя эти данные ему не следует разрешать читать.

Чтобы защитить вашу программу от такого вида уязвимости, при попытке чтения элемента по индексу, который не существует, Rust остановит выполнение кода и не даст продолжить чтение. Попробуйте запустить этот код, и увидите:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Эта ошибка указывает на строку 4 нашего main.rs, где мы попытались обратиться по индексу 99. Следующая строка примечания говорит нам, что можно установить переменную окружения RUST_BACKTRACE, чтобы получить backtrace с информацией, что послужило причиной ошибки. Backtrace это список всех функций, которые были вызваны для достижения этого места в программе. Backtrace в Rust работает так же, как в других языках: ключ к расшифровке backtrace - начать читать сверху и продолжать, пока не увидите написанные вами файлы. Тогда вы увидите место, откуда произошла проблема. Строки выше этого места являются кодом, к которому обратился ваш код; строки ниже это код, который обратился к вашему коду. Эти строки до-и-после могут включать код ядра Rust, стандартный библиотечный код или код крейтов (crates), которые вы используете. Давайте попробуем установить переменную окружения RUST_BACKTRACE в любое значение, кроме 0. Листинг 9-2 показывает примерно то, что вы увидите.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/std/src/panicking.rs:645:5
   1: core::panicking::panic_fmt
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:72:14
   2: core::panicking::panic_bounds_check
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:208:5
   3: < usize as core::slice::index::SliceIndex< [T]>>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/slice/index.rs:255:10
   4: core::slice::index::< impl core::ops::index::Index< I> for [T]>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/slice/index.rs:18:9
   5: < alloc::vec::Vec< T,A> as core::ops::index::Index< I>>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/alloc/src/vec/mod.rs:2770:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Листинг 9-2. Сообщение backtrace, сгенерированное вызовом panic!, которое отображается при установке переменной окружения RUST_BACKTRACE.

Точное содержимое этого вывода может отличаться в зависимости от вашей операционной системы и версии Rust version. Для получения обратных ссылок (backtraces) из этой информации должны быть разрешены символы отладки (debug symbols). По умолчанию символы отладки разрешены, если использовалась для компиляции команда cargo build или cargo run без флага --release, как было сделано в этом примере.

В выводе листинга 9-2 строка 6 backtrace указывает на строку в вашем проекте, которая вызвала проблему: строка 4 файла src/main.rs. Если мы не хотим, чтобы наша программа паниковала, то должны начать исследование в указанном месте файла нашего кода, в этом примере это файл src/main.rs, строка 4. В листинге 9-1, где мы намеренно написали код, вызывающий панику, способ исправить проблему - не запрашивать элемент вне диапазона индексов вектора. Когда ваш код паникует при будущем использовании, необходимо выяснить, какое действие он предпринимает и с какими значениями, чтобы вызвать панику, и что на самом деле вместо этого код должен делать, чтобы подобная ситуация не повторялась.

Далее в этой главе мы еще вернемся к макросу panic!, и к принятию решения о его применении - когда должны или когда не должны использовать panic! для обработки ситуаций ошибки (см. далее "Применять panic! или нет?". Мы рассмотрим способ восстановления из состояния ошибки с использованием Result.

[Recoverable Errors и Result]

Большинство ошибок не являются достаточно серьезными, чтобы требовать полностью завершать программу. Иногда, когда вызов функции был неудачным, причину неудачи вы можете легко интерпретировать и реализовать правильную реакцию на причину ошибки. Например, если вы попробовали открыть файл, и эта операция не получилась из-за отсутствия файла, то вы можете создать файл вместо завершения процесса программы.

Вспомните содержимое секции "Обработка потенциального сбоя чтения ввода" из главы [2], в которой мы было определено перечисление Result с двумя вариантами Ok и Err, следующим образом:

enum Result< T, E> {
    Ok(T),
    Err(E),
}

Здесь T и E это параметры универсального generic-типа: более подробно типы generic мы обсудим в главе 10. Что вам нужно знать прямо сейчас, так это то, что T представляет тип значения, которое будет возвращено в случае успеха в варианте Ok, и E представляет тип ошибки, который будет возвращен в случае неудачи с вариантом Err. Поскольку Result имеет эти параметры generic-типа, мы можем использовать тип Result и определенные в нем функции для многих различных ситуаций, когда могут отличаться значения успеха или ошибки, которые мы хотим вернуть.

Давайте вызовем функцию, которая возвратит значение Result, потому что её вызов может потерпеть неудачу. В листинге 9-3 мы пытаемся открыть файл.

use std::fs::File;

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

Листинг 9-3. Открытие файла.

Из File::open возвращается тип Result< T, E>. Здесь generic-параметр T заполнен в реализации File::open типом значения успеха std::fs::File, что представляет собой дескриптор файла (file handle). Тип E используется в значении ошибки std::io::Error. Возвращаемый тип Result означает, что вызов File::open может быть успешным, и мы в случае успешного вызова можем читать или записывать файл. Также вызов функции может быть и неудачным: например, файл может отсутствовать, или у нас может быть недостаточно прав доступа к этому файлу. У функции File::open должен быть способ сказать нам, какой был ли результат вызова успешен или нет, и в то же время выдать нам либо дескриптор файла в случае успеха, или же информацию о причине ошибки в случае неудачи. Именно такую информацию и передает перечисление Result.

В случае, когда функция File::open сработала успешно, значение в переменной greeting_file_result будет экземпляром Ok, содержащим file handle. В случае неудачи значение в greeting_file_result будет экземпляром Err, в котором будет дополнительная информация о произошедшей ошибке.

Нам нужно добавить код в листинге 9-3, чтобы предпринять различные действия в зависимости от значения возврата из функции File::open. Листинг 9-4 показывает способ обработки Result с использованием базового функционала Rust - выражения match, которое мы обсуждали в главе 6 (см. [3]).

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!("Проблема открытия файла: {error:?}"), }; }

Листинг 9-4. Использование выражения match для обработки вариантов, которые могут быть возвращены.

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

Когда результат Ok, этот код возвратит внутреннее значение file (дескриптор файла) из варианта Ok, и мы затем тогда присвоим значение дескриптора файла переменной greeting_file. После выражения match мы сможем использовать дескриптор файла для чтения или записи.

Другая arm выражения match обрабатывает случай, когда было получено значение Err из File::open. В этом примере был сделан простейший выбор вызова макроса panic!. Если файл hello.txt не находится в нашей текущей директории, то при запуске этого кода мы увидим следующий вывод из макроса panic!:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [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" }

Замечание: для отображения запускайте эту команду вместе с установкой переменной окружения 'RUST_BACKTRACE=1'. Обычно это явно укажет на причину ошибки.

Реакция на различные причины ошибки. В коде 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:?}"), }, other_error => { panic!("Problem opening the file: {other_error:?}"); } }, }; }

Листинг 9-5. Обработка различных видов ошибок различными способами.

Тип, возвращаемый из File::open в варианте Err, это io::Error, который является структурой из стандартной библиотеки. Эта структура имеет метод kind, который мы можем вызвать, чтобы получить значение io::ErrorKind. Перечисление io::ErrorKind предоставляется стандартной библиотекой, и содержит варианты, представляющие различные виды ошибок, которые могут быть результатом операции ввода-вывода io. Вариант, который мы хотим использовать, это ErrorKind::NotFound, который показывает, что мы пытаемся открыть не существующий файл. Здесь мы получим совпадение match на greeting_file_result, но также будем использовать внутреннее match на error.kind().

Состояние, которое мы хотим проверить во внутреннем выражении match, состоит в том, является ли значение, возвращаемое из error.kind(), вариантом NotFound перечисления ErrorKind. Если это так, то мы попробуем создать файл вызовом File::create. Однако из-за того, что File::create также может потерпеть неудачу, нам нужна еще вторая ветка arm во внутреннем выражении match. Когда файл не может быть создан, будет напечатано другое сообщение об ошибке. Вторая ветка arm внешнего выражения match останется такой же, так что программа будет паниковать на любой ошибке, кроме ошибки отсутствующего файла.

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

Для примера ниже показан другой способ написать ту же самую логику, которая была в листинге 9-5. На этот раз используется closures и метод 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!("Проблема создания файла: {error:?}"); }) } else { panic!("Проблема открытия файла: {error:?}"); } }); }

Хотя этот код ведет себя так же, как код листинга 9-5, в нем не содержится выражения match, и его проще читать. Вернитесь к этому примеру после прочтения главы 13, и просмотрите в документации по стандартной библиотеке описание метода unwrap_or_else. Многие другие подобные методы могут очистить огромные вложенные выражения match, когда вы имеете дело с обработкой ошибок.

Шорткаты для паники на Error: unwrap и expect. Использование match работает достаточно хорошо, но это может быть довольно многословным способом обработки ошибки, и не всегда хорошо передает намерение. Тип Result< T, E> имеет много определенных в нем вспомогательных методов, чтобы выполнять различные, более специфичные задачи. Метод unwrap это shortcut-метод, реализованный наподобие выражению 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 должен быть включен в этот проект"); }

Мы используем expect так же, как и unwrap: для возврата дескриптора файла или вызова макроса panic!. Сообщение об ошибке, используемое expect в своем вызове panic! указывается в параметре, который мы передаем в expect, вместо того, чтобы показывать сообщение panic! по умолчанию, которое использует unwrap. При отсутствии файла hello.txt будет показано сообщение:

thread 'main' panicked at src/main.rs:5:10:
Файл hello.txt должен быть включен в этот проект: Os { code: 2, kind: NotFound,
 message: "No such file or directory" }

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

Распространение ошибок. Когда реализация функции A вызывает какую-нибудь внутреннюю функцию B, которая может привести к отказу, вместо обработки ошибки внутри функции B можно передать эту ошибку на уровень выше, в функцию A, чтобы уже в ней принять решение, что надо делать. Эта техника известна как распространение ошибки (propagating error), и она дает больше контроля вызывающему коду, где может быть больше информации или логики о том, как ошибка должна быть обработана по сравнению с тем, что у вас есть в контексте нижнего уровня кода (т. е. в функции B).

Для примера листинг 9-6 показывает функцию, которая читает имя пользователя из файла. Если файл не существует или не может быть прочитан, то эта функция возвратит эти ошибки в код, который её вызвал.

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), } }

Листингg 9-6. Функция, которая возвращает ошибки в вызывающий код, используя выражение match.

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

Если эта функция завершится без каких-либо проблем, то код, который её вызвал, получит значение Ok, в котором значение будет хранить String - имя пользователя, прочитанное из файла. Если же эта функция столкнется с любыми проблемами, то вызывающий её код получит значение Err, в котором хранится экземпляр типа io::Error, где содержится больше информации по описанию возникшей проблемы. Вы выбрали io::Error в качестве возвращаемого значения из этой функции, потому что это тип значения ошибки, возвращаемый обоими операциями, которые мы вызываем в текущем коде, и которые могут потерпеть неудачу: функция File::open и метод read_to_string.

Тело функции начинается с вызова File::open. Затем мы обрабатываем значение Result с помощью match подобно тому, как это делалось в листинге 9-4. Если вызов File::open был успешен, то дескриптор файла в переменной шаблона file становится значением мутируемой переменной username_file, и функция продолжит работу. В случае Err вместо вызова макроса panic! мы используем ключевое слово return для раннего выхода из функции и передачи значения ошибки из File::open, теперь в переменной шаблона e, и это значение ошибки попадет обратно в вызывающий нашу функцию код.

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

Код который вызовет функцию из листинга 9-6, будет обрабатывать полученное значение - либо значение Ok, где содержится прочитанное имя пользователя, либо значение Err, где содержится io::Error. Теперь вызывающий код принимает решение, что делать с этими значениями. Например, если вызывающий код получит значение Err, то он должен либо вызвать panic! для падения программы, либо использовать имя пользователя по умолчанию, либо искать имя пользователя из другого источника. У нас в функции read_username_from_file недостаточно информации о том, что должен делать вызывающий её код, так что мы просто передаем все наверх - информацию как успешного, так и неудачного вызова, чтобы код верхнего уровня мог должным образом обработать эти ситуации.

Техника распространения ошибок часто используется в Rust, и для её упрощения Rust предоставляет оператор ?.

Оператор ? в качестве шортката распространения ошибок. Листинг 9-7 показывает реализацию функции read_username_from_file, которая обладает такой же функциональностью, что и реализация 9-6, но здесь используется оператор ?.

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) }

Листинг 9-7. Функция, которая возвратит ошибки в вызывающий код, используя оператор ?.

Оператор ?, размещенный после значения Result, будет работать почти так же, как выражение match, которое мы определили для обработки значений Result в листинге 9-6. Если значение Result это Ok, то значение внутри Ok будет возвращено из этого выражения, и программа продолжит работу. Если значение это Err, то Err будет возвращено из всей функции целиком, как если бы мы использовали ключевое слово return, так что ошибка распространится на верхний уровень, в вызывающий код.

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

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

В контексте листинга 9-7 оператор ? в конце вызова File::open возвратит значение внутри Ok для переменной username_file. Если произойдет ошибка, то оператор ? приведет к раннему выходу из всей функции, и предоставит любое значение Err в вызывающий код. То же самое относится и к ? в конце вызова read_to_string.

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

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) }

Листинг 9-8. Метод последовательного применения оператора ?.

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

Листинг 9-9 показывает еще более сокращенный вариант реализации, с использованием fs::read_to_string.

use std::fs;
use std::io;

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

Листинг 9-9. Использование fs::read_to_string вместо открытия и затем чтения файла вручную.

Чтение файла в строку это довольно общая операция, так что стандартная библиотека предоставила для этого удобную функцию fs::read_to_string, которая открывает файл, создает новую строку String, читает содержимое файла, помещает его в этот String, и возвращает String в случае успеха. Конечно, использование fs::read_to_string не дает нам альтернативы объяснить все обработки ошибок, как мы это делали более длинными способами реализации.

Где оператор ? может использоваться. Оператор ? может использоваться только в функциях, у которых возвращаемый тип совместим со значением, на котором используется ?. Причина в том, что оператор ? определен для раннего возврата значения из функции, тем же способом, как мы определили выражение match в листинге 9-6. В листинге 9-6 выражение match использовало значение Result, и делало ранний возврат на ветке arm, возвращающей значение Err(e). Возвращаемый тип функции, имеет Result, совместимый с этим возвратом.

В листинге 9-10 давайте разберем ошибку, которую мы получим при использовании оператора ? в функции main с возвращаемым типом, не совместимым с типом значения, на котором мы вызываем ?:

use std::fs::File;

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

Листинг 9-10. Неудачная попытка использовать ? в функции main, которая возвращает ().

Этот код открывает файл, что может завершиться неудачей. Оператор ?, который идет за значением 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: the trait `FromResidual< Result< Infallible, std::io::Error>>` is not
 implemented for `()`
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.

Чтобы исправить ошибку, у вас есть 2 варианта на выбор. Один это поменять возвращаемый тип вашей функции, чтобы он был совместим с типом, на котором вы используете оператор ? до тех пор, пока у вас нет ограничений, препятствующих этому. Другая техника - использовать match или один из методов Result< T, E> для обработки Result< T, E> походящим способом.

Сообщение об ошибке также напоминает, что можно использовать ? со значениями Option< T>. Как с использованием ? на Result, вы можете только использовать ? на Option в функции, которая возвращает Option. Поведение оператора ?, когда он вызван на Option< T>, подобен его поведению, когда он вызван на Result< T, E>: если значение None, то None будет возвращено с ранним выходом из функции, где ? применяется. Если значение Some, то значение внутри Some будет результирующим значением выражения с оператором ?, и выполнение функции продолжится. В листинге 9-11 показан пример функции, которая ищет последний символ первой строки предоставленного text:

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

Листинг 9-11. Использование оператора ? на значении Option< T>.

Эта функция возвратит Option< char>, потому что вероятно здесь результатом будет символ, однако также остается возможность, что символа не окажется. Этот код принимает слайс строки text в качестве аргумента, и вызывает на нем метод, который возвратит итератор по строкам в text. Поскольку эта функция хочет проверить первую строку в тексте, она вызывает next на итераторе, чтобы получить первое значение из итератора. Если text окажется пустой строкой, то этот вызов next возвратит None, и тогда мы используем ? для остановки и возврата None из last_char_of_first_line. Если же text не пустая строка, то next возвратит значение Some, где содержится слайс первой строки текста из text.

Оператор ? извлекает слайс строки, и мы можем вызвать chars на этом слайсе строки, чтобы получить итератор на его символы. Нас интересует последний символ первой строки текста, так что мы вызываем last для возврата последнего элемента итератора по символам. Это тип Option, потому что есть возможность того, что первая строка пустая, например когда text начинается с пустой строки, но имеются символы на других строках, как например если в 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(()) }

Листинг 9-12. Изменение main для возврата Result< (), E>, позволяющее использовать оператор ? на значениях Result.

Тип Box< dyn Error> это объект трейта (trait object), про который мы поговорим в секции "Using Trait Objects that Allow for Values of Different Types" главы 17. В настоящий момент вы можете читать 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, и программы, которые возвращают ошибку, возвращают ненулевое целое значение. Rust также возвращает целые значения из исполняемых файлов, чтобы сохранить совместимость с этим соглашением.

Функция main может возвратить любые типы, которые реализуют трейт std::process::Termination. В этом трейте содержится функция report, которая возвращает ExitCode. См. документацию по стандартной библиотеке для дополнительной информации по реализации трейта Termination для ваших собственных типов.

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

[Применять panic! или нет?]

Так как все-таки понять, когда следует вызвать panic!, а когда нужно возвратить Result? Когда код паникует, то нет никакого способа восстановиться. Вы можете вызвать panic! для любой ситуации ошибки, независимо от того, есть ли какой-то способ восстановления, или нет, но тогда вы сами принимаете решение от имени вызывающего кода, что ситуация не может быть восстановлена. Когда вы выбрали вариант возвратить значение Result, вы даете вызывающему коду опции принять решение, что делать дальше. Вызывающий код может попытаться восстановится способом, подходящим для его ситуации, или он может решить, что значение Err в этом случае относится к не восстановимой ошибке, так что тогда он может вызвать panic! и тем самым превратить восстановимую ошибку в не восстановимую. Таким образом, возврат Result это хороший вариант выбора по умолчанию, когда вы определяете функцию, которая может потерпеть неудачу.

В ситуациях, таких как примеры, код прототипа и тесты, лучше подойдет выбор писать код так, чтобы он вызывал паники вместо возврата Result. Давайте рассмотрим, почему так, и затем обсудим ситуации, в которых компилятор не может сказать, что сбой невозможен, но вы как человек можете. И глава закончится общими рекомендациями, как принять решение, нужна ли паника в библиотечном коде.

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

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

Если вызов метода при тесте терпит неудачу, то вы вероятно захотите, чтобы весь тест также остановился по ошибке, даже если этот метод не относится к тестируемому функционалу. Потому что panic! это как раз то, чем тест помечается как неудачный, и вызов unwrap или expect будет именно тем, что должно произойти.

Случаи, когда у вас больше информации, чем у компилятора. Было бы также уместно вызвать unwrap или expect, когда у вас какая-то другая логика, которая гарантирует, что Result получит значение Ok, но логика не является чем-то таким, что может понять компилятор. У вас по-прежнему будет значение Result, которое вам нужно обработать: любая операция, которую вы вызываете, по-прежнему может потерпеть неудачу в целом, даже если логически невозможно в вашей конкретной ситуации. Если вы можете убедиться, вручную проверив код, что у вас никогда не будет варианта Err, то вполне допустимо вызвать unwrap, и даже лучше документировать причину, по который вы думаете, что никогда не будет варианта Err в тексте expect. Вот пример:

    use std::net::IpAddr;
let home: IpAddr = "127.0.0.1" .parse() .expect("Hardcoded IP-адрес должен быть допустимым");

Мы создаем экземпляр типа IpAddr путем парсинга жестко закодированной (hardcoded) строки. Мы можем увидеть, что 127.0.0.1 это допустимый IP-адрес, так что допустимо использовать здесь expect. Однако наличие жестко закодированной, допустимой строки никогда не поменяет возвращаемый тип метода parse: мы все равно получаем значение Result, и компилятор все еще заставит вам обработать Result, как если бы возвращаемый вариант Err был возможен, потому что компилятор не настолько умен, чтобы увидеть, что эта строка всегда будет соответствовать допустимому IP-адресу. Если строка IP-адреса приходит от пользователя вместо жестко закодированной строки в программе, и как следствие имеется возможность сбоя, то вместо этого мы определенно хотели бы обрабатывать Result более надежным способом. Упоминание факта, что этот IP-адрес жестко закодирован, побудит нас изменить expect на более подходящий код обработки ошибки, если в будущем на нам понадобится получать IP-адрес из какого-то другого источника.

Рекомендации по обработке ошибок. Желательно, чтобы ваш код вызывал панику, когда возможна ситуация, что он попал в плохое состояние. В этом контексте плохое состояние это когда некоторое предположение, гарантия, контракт или инвариант были нарушены, например когда недопустимые значения, противоречивые значения, или отсутствующие значения передаются вашему коду - плюс одно или больше из следующего:

• Плохое состояние это то, что является неожиданным, в отличие от того, что вероятно будет происходить время о времени. Например пользователь вводит данные в неправильном формате.
• Ваш код после этого пункта должен полагаться на то, чтобы не быть в этом плохом состоянии, вместо проверки наличия проблемы на каждом шаге.
• Нет хорошего способа кодировать эту информацию в типах, которые вы используете. Мы рассмотрим это на примере, что имеется в виду, в секции "Encoding States and Behavior as Types" главы 17.

Если кто-нибудь вызывает ваш код, и передает значения, которые не имеют смысла, то лучше всего возвратить ошибку, если вы можете, чтобы пользователь мог сам решить, что он хочет делать в этом случае. Однако в случаях где продолжение может быть не безопасным или или вредным, лучшим выбором будет вызвать panic! и оповестить пользователя вашей библиотеки о баге в его коде, чтобы он мог исправить его на стадии разработки. Подобным образом panic! часто подходит, если вы вызываете внешний код, который находится вне вашего контроля, и он возвращает недопустимое состояние, в котором у вас нет способа исправить ситуацию.

Однако когда отказ ожидаемый, более подходящим будет возвратить Result вместо того, чтобы делать вызов panic!. В качестве примера можно привести парсер, которому передаются неправильно сформированные данные или запрос HTTP, возвращающий состояние, указывающее на достижение предела скорости. В этих случаях возврат Result показывает, что отказ это ожидаемая возможность, для которой вызывающий код должен сам принять решение, как эту ошибку обработать.

Когда ваш код выполняет операцию, которая может подвергнуть пользователя риску, если он делает вызовы, используя недопустимые значения, то ваш код должен проверить правильность значений и паниковать, если значения недействительные. Так надо делать по соображениям безопасности: попытка работать с недопустимые данными может ввести в ваш код уязвимости. Это основная причина, по которой стандартная библиотека будет вызывать panic!, если вы попытаетесь осуществить доступ к памяти вне допустимого диапазона: если пытаться обращаться к памяти, которая не принадлежит к текущей структур данных, то это общая проблема безопасности. Функции часто имеют контракт: их поведение гарантируется только если входные данные удовлетворяют определенным требованиям. Паниковать имеет смысл, когда контракт нарушен, потому что нарушение контракта всегда показывает на баг на стороне вызывающего кода, и это не тот вид ошибки, при который вы хотели бы полагаться на обработку со стороны вызывающего кода. Фактически нет разумной причины вызова кода для восстановления; код должна исправить вызывающая сторона. Контракты для функции, особенно когда нарушение вызовет панику, должны быть объяснены в документации API для функции.

Тем не менее наличие слишком большого количества проверок на ошибки во всех ваших функциях будет многословным и раздражающим. К счастью, вы можете использовать систему типов Rust (и эта проверка типа выполняется компилятором), чтобы она делала для вас большинство проверок. Если у вашей функции есть параметр определенного типа, то вы можете реализовать логику вашего кода, полагаясь на то, что компилятор уже гарантирует допустимое значение для параметра. Например, если у вас тип, отличающийся от Option, то ваша программа ожидает здесь что-то, отличающееся от nothing. Ваш код тогда не должен обрабатывать два случая для вариантов Some и None: он будет иметь дело только со случаем, когда определенно получает значение. Код, который попытается передать nothing в вашу функцию, не скомпилируется, так что ваша функция не должна содержать runtime-проверку на значение недопустимого типа. Другой пример - использование беззнакового целочисленного типа, такого как u32, что гарантирует, что параметр никогда не будет отрицательным.

Создание пользовательских типов для проверки. Давайте возьмем на вооружение идею использовать систему типов Rust, чтобы гарантировать, что у нас есть достоверное значение на один шаг дальше, и рассмотрим создание пользовательского типа для проверки. Вспомним игру на угадывание числа из главы 2 (см. [2]), в которой наш код запрашивал у пользователя угадать число между 1 и 100. Мы никогда не проверял, что предположение пользователя находилось между этими цифрами, прежде чем сверить его со своим секретным загаданным числом; мы только лишь проверяли, что что число является положительным. В этом случае последствия были не очень страшными: наш вывод our output of "Слишком много" или "Слишком мало" все еще оставался корректным. Однако было бы полезным улучшением направить пользователя к действительным предположениями и задать другое поведение, когда пользователь предлагает число, лежащее вне указанного диапазона от 1 до 100. Например, когда пользователь вместо чисел вводит буквы, или вводит слишком большое число.

Один из способов достичь этого - парсинг введенного пользователем числа на предмет того, что это тип i32 вместо того, чтобы позволять вводить только u32 и тем самым разрешить отрицательные числа, и затем добавить проверку, что число находится в указанном диапазоне, примерно так:

    loop {
        // -- вырезано --
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) { // -- вырезано -- }

Выражение if проверяет, выходит ли введенное пользователем число из диапазона, и сообщает пользователю о проблеме, после чего вызывает continue для старта следующей итерации цикла, и снова выводит запрос на угадывание. После выражения if мы можем приступить к сравнению угадываемого числа с загаданным секретным числом, и уже будем знать, что введенное число находится в диапазоне между 1 и 100.

Однако это не идеальное решение: если абсолютно важно, что программа работает только со значениями между 1 и 100, и имелось бы много функций с таким требованием, то такая проверка в каждой функции была бы утомительной (и могла бы повлиять на производительность).

Вместо этого мы можем создать новый тип, и ввести проверку в его функцию при создании экземпляра типа, вместо того, чтобы проверять значения в коде где ни попадя. Таким способом мы обеспечим безопасность для функции благодаря использованию нового типа в их сигнатурах, и они смогут уверенно использовать полученные значения. Листинг 9-13 показывает один из способов определить тип Guess, который создаст экземпляр только тогда, когда его функция new получит значение между 1 и 100.

pub struct Guess {
    value: i32,
}

impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Угадываемое значение должно быть между 1 и 100, а получено {value}."); }
Guess { value } }
pub fn value(&self) -> i32 { self.value } }

Листинг 9-13. Тип Guess, который не позволит продолжить работу со значениями, выходящими за диапазон между 1 и 100.

Сначала мы определили структуру с именем Guess, у которой есть поле с именем value, хранящее i32. Это то место, где будет сохраняться число.

Затем мы реализовали на типе Guess связанную функцию с именем new, которая создает экземпляры значений Guess. Функция new определена так, что имеет параметр с именем value типа i32, и она возвращает Guess. Код в теле функции new проверяет value, чтобы его значение было между 1 и 100. Если значение не прошло эту проверку, то мы вызовем макрос panic!, который предупредит программиста, пишущего вызывающий код, что у него ошибка, которую надо исправить, потому что создание Guess со значением, выходящим за контракт функцией Guess::new не допускается. Условия, в которых Guess::new может паниковать, должны быть обсуждены публично, и представлены в API документации; мы рассмотрим соглашения по составлению документации, указывающие на возможность panic!, в документации API, которую создадим в главе 14. Если value прошло проверку, то мы создаем новое значение Guess в полем value, установленном в значение параметра value, и возвратим Guess.

Далее мы реализуем метод с именем value, который заимствует self, не имея никаких других параметров, и возвращает i32. Этот вид метода иногда называют getter (т. е. "получатель значения"), поскольку его назначение получить (т. е. get) некие данные из полей типа и возвратить их. Этот публичный метод необходим, потому что поле value структуры Guess сделано приватным. Это важный момент, что поле value приватное, так что коду, использующему структуру Guess, не разрешено установить value напрямую: код снаружи модуля должен использовать функцию Guess::new для создания экземпляра Guess, тем самым гарантируя, что Guess не может иметь value, которое не было проверено в функции Guess::new.

Функция, которая имеет параметр или возвращает числа только между 1 и 100, может затем объявить в своей сигнатуре, что она принимает и возвращает Guess вместо i32, и ей не нужно будет выполнять какие-либо дополнительные проверки в своем теле.

[Общие выводы]

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

Теперь мы рассмотрим полезные способы, какими стандартная библиотека использует generic-типы с перечислениями Option и Result, мы поговорим о том, как работают generic-и, и как вы можете использовать их в своем коде.

[Ссылки]

1. Rust Error Handling site:rust-lang.org.
2. Rust: программирование игры - угадывание числа.
3. Rust: общая концепция программирования.

 

Добавить комментарий


Защитный код
Обновить

Top of Page