Обработка ошибок: Result, ? и свои типы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Обработка ошибок: Result, ? и свои типы
Ошибка в Rust это значение, а не исключение: она лежит в
Result, видна в сигнатуре и не пролетит мимо незамеченной. Разбираемpanic!противResult, оператор?, свои типы ошибок черезthiserrorиanyhowна границе приложения.
Идея
В Rust нет исключений, и это решение, а не пробел. Исключение летит сквозь стек невидимкой: по сигнатуре readConfig(path) в TypeScript не узнать, бросает ли она, что бросает и кто обязан ловить. Rust записывает возможность провала прямо в тип возврата, ты видел это в уроке про enum: Result<T, E> это либо Ok(T), либо Err(E), и компилятор не даст притвориться, что второго варианта не существует.
Но прежде чем писать первый Result, нужно развести два сорта ошибок, потому что инструменты для них разные.
Первый сорт: баг программиста. Индекс за границей вектора, нарушенный инвариант, ветка, которая «не может случиться». Такую ошибку не обрабатывают, её чинят правкой кода, а программа при встрече с ней честно падает: это паника.
Второй сорт: ожидаемая ошибка среды. Файла нет, сеть оборвалась, пользователь ввёл «abc» вместо числа. Это не баг, это вторник. Такие исходы часть нормальной работы программы, они описываются Result, и решать, что с ними делать, обязан вызывающий.
Весь хаос с обработкой ошибок в любом языке растёт из смешения этих сортов: баги глотаются как «ожидаемое», ожидаемое роняет процесс как баг, а код покрывается unwrap и try/catch на всякий случай. Дисциплина Rust проста: паника для багов, Result для среды, и граница между ними проходит по вопросу «может ли это случиться в корректной программе».
panic! и когда он допустим
Паника это контролируемое падение. По умолчанию она запускает раскрутку стека, освобождает ресурсы и завершает процесс с сообщением:
let v = vec![1, 2, 3];
let x = v[99]; // panic: index out of bounds
let n: i32 = "abc".parse().unwrap(); // panic: called unwrap on an Err
unwrap это «достань значение или паникуй», и в учебных листингах он мелькает постоянно. В настоящем коде у него есть старший брат expect, который паникует с твоим сообщением, и правило хорошего тона: если уж утверждаешь, что ошибки быть не может, скажи почему.
let config = load_default_config()
.expect("дефолтный конфиг зашит в бинарник и обязан парситься");
Где паника уместна. В тестах: упавший assert и должен ронять тест. В прототипах и примерах: пока ты исследуешь задачу, unwrap честно помечает места, до которых руки дойдут позже. На нарушенных инвариантах: если функция получила состояние, невозможное по замыслу, падение с внятным сообщением лучше тихой порчи данных.
Где паника недопустима: в библиотеке на чужом входе. Библиотека не знает, что для вызывающего значит провал, и не ей решать, жить ли процессу. Паникующая на кривом входе библиотечная функция это ошибка дизайна: правильный ответ всегда Result, а решение паниковать пусть принимает приложение, которому виднее. Стандартная библиотека следует этому правилу и обычно предлагает пару: v[i] паникует, v.get(i) возвращает Option, выбор за тобой.
Result и оператор ?
Теперь главный инструмент. Прочитаем порт сервера из файла конфига, сначала в лоб через match:
use std::fs;
use std::num::ParseIntError;
fn read_port(path: &str) -> Result<u16, String> {
let raw = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) => return Err(format!("не прочитал {path}: {e}")),
};
match raw.trim().parse::<u16>() {
Ok(port) => Ok(port),
Err(e) => Err(format!("порт не число: {e}")),
}
}
Работает, но видна болезнь: на каждую операцию по четыре строки разбора, и за ними не разглядеть сюжет функции. А сюжет простой: прочитай, распарси, верни, любую ошибку отдай наверх. Для «отдай наверх» в Rust есть оператор ?:
use std::fs;
fn read_port(path: &str) -> Result<u16, ConfigError> {
let raw = fs::read_to_string(path)?; // Err? верни его из функции
let port = raw.trim().parse::<u16>()?; // Ok? достань значение и продолжай
Ok(port)
}
? после Result означает: если там Ok, распакуй и продолжай; если Err, верни его из текущей функции немедленно. Счастливый путь читается подряд, как будто ошибок не бывает, а несчастливый обслуживается одним символом. Это та же двухдорожечная композиция, которую ты собирал руками из Either в уроке про монадную обработку ошибок: значение едет по успешной дорожке, ошибка проскакивает мимо всех остановок. В Rust дорожка встроена в язык.
Две детали, без которых ? не понять до конца. Первая: он работает только в функции, которая сама возвращает Result или Option, ошибке нужно куда-то уезжать. В Option-функциях ? разворачивает Some и досрочно возвращает None. Вторая: возвращая ошибку, ? автоматически конвертирует её тип через конверсию From, если она описана. Именно поэтому пример выше компилируется: ошибки файла и парсинга разных типов, но обе умеют превращаться в наш ConfigError, который мы сейчас и построим.
Мосты между Option и Result наводят комбинаторы, их полно, но на практике чаще всего нужны два:
let port: Result<u16, String> = args.get(1) // Option<&String>
.ok_or(String::from("порт не передан")) // нет значения? вот ошибка
.and_then(|s| s.parse().map_err(|e| format!("{e}"))); // подмени тип ошибки
ok_or превращает Option в Result, добавляя ошибку для случая None. map_err меняет ошибку, не трогая успех: им подгоняют чужой тип ошибки под свой там, где нет конверсии From.
Свои типы ошибок и thiserror
String в роли ошибки годится для набросков, но вызывающий не может на неё среагировать: по тексту не сматчишься. Настоящий тип ошибки это enum по вариантам провала, ты уже умеешь такие проектировать:
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
BadNumber(std::num::ParseIntError),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
Реализация From и есть та конверсия, которую ? вызывает молча: fs::read_to_string провалился с io::Error, ? прогнал его через From и вернул уже ConfigError::Io. Допиши такую же для ParseIntError, потом impl Display с человеческим текстом для каждого варианта, и тип готов. И уже на втором таком enum рука устанет: три реализации бойлерплейта на каждый тип ошибки.
Этот бойлерплейт индустрия сложила в крейт thiserror, стандарт де-факто для библиотечных ошибок:
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("не удалось прочитать конфиг: {0}")]
Io(#[from] std::io::Error),
#[error("порт должен быть числом: {0}")]
BadNumber(#[from] std::num::ParseIntError),
}
Атрибут #[error(...)] генерирует Display, #[from] генерирует ту самую конверсию для ?. Двенадцать строк описывают то же, что тридцать рукописных, и читаются как документация по вариантам провала. Как derive творит такое, разберём в уроке про трейты и позже в блоке про макросы; пока важно, что thiserror не добавляет ничего в рантайме, это чистая кодогенерация.
Заметь, какой знакомой стала картина: ошибки домена это тип-сумма с данными в вариантах, вызывающий разбирает их match и точно знает, что может пойти не так. Та же дисциплина, что у тегированных ошибок в Effect и Either в Haskell, Rust лишь добавил ?, чтобы дорожка ошибок не загораживала счастливый путь.
anyhow на границе приложения
У библиотеки и у приложения разные отношения с ошибками. Библиотека обязана отдать точный тип: по нему вызывающий решит, ретраить, падать или подставлять дефолт. Приложению на верхнем этаже чаще всего нужно другое: собрать ошибку из любого слоя, нацепить контекст и показать пользователю. Для этого есть крейт anyhow с одним универсальным типом anyhow::Error, в который складывается любая ошибка:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let port = read_port("app.conf")
.context("запуск сервера прерван")?;
println!("слушаю порт {port}");
Ok(())
}
Result<()> здесь это anyhow::Result, сокращение для Result<(), anyhow::Error>. Наш ConfigError уезжает в него через ? без всяких From, а context наслаивает объяснение поверх причины. Если конфиг не прочитался, пользователь увидит цепочку от верха до корня:
Error: запуск сервера прерван
Caused by:
0: не удалось прочитать конфиг: No such file or directory (os error 2)
Правило слоёв получается короткое. Внутри, в библиотеках и доменных модулях: свой enum через thiserror, точно и типизированно. Снаружи, в main и обработчиках CLI: anyhow с контекстом, удобно и без чужого бойлерплейта. Перепутать стороны это распространённая ошибка: anyhow в библиотеке стирает типы и лишает вызывающего возможности среагировать, а thiserror на каждый чих в main плодит enum, которые никто не разбирает.
Практика
Четыре задачи в редакторе: от переписывания паникующего кода до цепочки причин через source(). Крейтов в песочнице нет, так что весь бойлерплейт, который в проде прячет thiserror, здесь ты честно пишешь руками: Display, Error, From. Один раз прожить его пальцами полезно, потом будешь понимать, что именно генерируют макросы.
ДЗ
Дальше
Блок RU0 закрыт: ты пишешь на Rust со структурами, enum, коллекциями и честными ошибками, а владение из противника стало системой подсказок. Следующий блок про сердце системы типов. Начнём с дженериков: как писать один код для многих типов, ничего не платя в рантайме, и что такое мономорфизация, которая это оплачивает.