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 — обобщённые параметры типа; подробно обобщения мы обсудим в главе 10. Сейчас достаточно знать, что T — это тип значения, которое вернётся в случае успеха внутри варианта Ok, а E — тип ошибки, которая вернётся в случае неудачи внутри варианта Err. Благодаря этим обобщённым параметрам тип Result и его функции можно использовать в самых разных ситуациях, где значения успеха и ошибки бывают разными.

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

Файл: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
Листинг 9-3: Открываем файл

File::open возвращает Result<T, E>. Вместо обобщённого параметра T реализация File::open подставила тип значения успеха — std::fs::File, то есть дескриптор файла. А тип ошибки E — std::io::Error. Такой возвращаемый тип означает, что вызов File::open может пройти успешно и вернуть дескриптор файла, из которого можно читать или в который можно писать. Но может и не сработать: например, файла может не быть или у нас может не хватать прав на доступ к нему. Функции File::open нужно как-то сообщить, получилось у неё или нет, и заодно отдать либо дескриптор файла, либо сведения об ошибке. Именно это и передаёт перечисление Result.

Если File::open сработает, в переменной greeting_file_result окажется экземпляр Ok с дескриптором файла. Если нет — экземпляр Err с подробностями о том, какая ошибка произошла.

Код из листинга 9-3 нужно дополнить, чтобы он вёл себя по-разному в зависимости от того, что вернула File::open. В листинге 9-4 показан один из способов обработать Result с помощью базового инструмента — выражения match, которое мы обсуждали в главе 6.

Файл: src/main.rs
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:?}"),
    };
}
Листинг 9-4: Обрабатываем возможные варианты Result выражением match

Заметьте, что перечисление Result и его варианты, как и Option, уже подключены прелюдией, поэтому в ветках match не нужно писать Result:: перед Ok и Err.

Если результат — Ok, этот код достаёт из варианта Ok внутреннее значение file, и мы присваиваем этот дескриптор переменной greeting_file. После match дескриптором можно пользоваться для чтения или записи.

Вторая ветка match обрабатывает случай, когда File::open вернула Err. В этом примере мы решили вызвать макрос 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' (6018048) 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 не сработала по любой другой причине — например, не хватает прав на открытие файла, — код по-прежнему должен вызывать panic!, как в листинге 9-4. Для этого добавим внутреннее выражение match, как в листинге 9-5.

Файл: src/main.rs
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:?}");
            }
        },
    };
}
Листинг 9-5: Обрабатываем разные виды ошибок по-разному

Внутри варианта Err, который возвращает File::open, лежит значение типа io::Error — это структура из стандартной библиотеки. У неё есть метод kind, который возвращает значение io::ErrorKind. Перечисление io::ErrorKind тоже из стандартной библиотеки, его варианты описывают разные виды ошибок, которые могут возникнуть при операциях ввода-вывода. Нам нужен вариант 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>. При обработке значений Result<T, E> эти методы бывают лаконичнее, чем match.

Например, вот другой способ записать ту же логику, что и в листинге 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. Если Err — unwrap сам вызовет для нас макрос panic!. Вот unwrap в действии:

Файл: src/main.rs
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 выглядит так:

Файл: src/main.rs
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!. Только сообщением при вызове panic! будет не стандартный текст, который использует unwrap, а параметр, переданный в expect. Выглядит это так:

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

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

Проброс ошибок

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

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

Файл: src/main.rs
#![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),
    }
}
}
Листинг 9-6: Функция возвращает ошибки вызывающему коду с помощью match

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

Если функция отработает без проблем, вызывающий код получит значение Ok со String внутри — именем пользователя (username), которое функция прочитала из файла. Если возникнут проблемы, вызывающий код получит значение 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 и вызывает у дескриптора username_file метод read_to_string, чтобы прочитать содержимое файла в username. Метод read_to_string тоже возвращает Result: он может не сработать, даже если File::open прошла успешно. Поэтому нужен ещё один match. Если read_to_string сработал, то и вся функция сработала, и мы возвращаем имя пользователя из файла, которое теперь лежит в username, обёрнутое в Ok. Если read_to_string не сработал, возвращаем значение ошибки — так же, как в match, обрабатывавшем результат File::open. Только писать return явно не нужно: это последнее выражение в функции.

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

Такой проброс ошибок встречается в Rust настолько часто, что для него есть специальный оператор — вопросительный знак ?.

Сокращение: оператор ?

В листинге 9-7 — реализация read_username_from_file, которая работает так же, как в листинге 9-6, но использует оператор ?.

Файл: src/main.rs
#![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)
}
}
Листинг 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> for OurError, чтобы создавать OurError из io::Error, то вызовы оператора ? в теле read_username_from_file сами вызовут from и преобразуют типы ошибок — без всякого дополнительного кода в функции.

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

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

Файл: src/main.rs
#![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)
}
}
Листинг 9-8: Цепочка вызовов методов после оператора ?

Создание новой 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.

Файл: src/main.rs
#![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")
}
}
Листинг 9-9: fs::read_to_string вместо открытия и последующего чтения файла

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

Где можно использовать оператор ?

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

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

Файл: src/main.rs
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: 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.

Исправить это можно двумя способами. Первый — поменять возвращаемый тип функции на совместимый со значением, к которому применяется ?, если ничто этому не мешает. Второй — обработать Result<T, E> выражением match или одним из методов 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);
}
Листинг 9-11: Оператор ? со значением Option<T>

Функция возвращает 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(()). Теперь код скомпилируется.

Файл: src/main.rs
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> — это трейт-объект; о них мы поговорим в разделе «Трейт-объекты: абстракция над общим поведением» главы 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, а завершившиеся с ошибкой — какое-то другое число. Rust тоже возвращает из исполняемых файлов целые числа, чтобы соответствовать этому соглашению.

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

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