Раздел 23 · Rust

Обработка ошибок: Result, ? и свои типы

middle~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Обработка ошибок: 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, коллекциями и честными ошибками, а владение из противника стало системой подсказок. Следующий блок про сердце системы типов. Начнём с дженериков: как писать один код для многих типов, ничего не платя в рантайме, и что такое мономорфизация, которая это оплачивает.