swizard (swizard) wrote,
swizard
swizard

Category:

Ещё об обработке ошибок в Rust

С обработкой ошибок в Rust надо явно что-то придумывать. Мне категорически нравится, что в языке нет исключений, но без синтаксического сахара вроде хаскелевской конструкции do код с аккуратной обработкой ошибок визуально превращается в простыню с перекладыванием значений из одних типов в другие.

Давайте рассмотрим процесс на простом примере: допустим, бинарю на вход подаётся (или не подаётся) параметр такого вида:

~ $ ./a.out --sample 2,3,4.5,6.0,7

Соответственно, если всё хорошо, в программе мы хотим получить какой-то вектор действительных чисел.

Вроде всё предельно просто, что может пойти не так? Если отсутствие параметра — это штатная ситуация, то вариантов ошибок не так много: по-сути, только одна: кривые данные на входе, которые нельзя распарсить в число.

Итак, getopts вернул нам Option<String>, как нам получить из него Vec<f64>? Очевидно, что можно гопническим методом:

fn parse_sample_v0(param: Option<String>) -> Option<Vec<f64>> {
    param.map(|values| values.split(',').map(|v| v.parse().unwrap()).collect())
}

Понятно, что программа вылетит в panic на unwrap, если на вход подать мусор. Но есть множество случаев, когда это вполне допустимо (тем более, при панике будет какая-никакая диагностика, из которой можно понять причину проблемы). Удобно при прототипировании, или при разработке небольших утилит.

Однако, такой подход уже недопустим когда надо сделать библиотеку, или в других случаях, когда нельзя валить программу. Какая тогда должна быть правильная сигнатура? Вероятно, какая-то такая:

#[derive(Debug)]
enum ParamError {
    SampleValue(String, ParseFloatError),
}

fn parse_sample_v1(param: Option<String>) -> Result<Option<Vec<f64>>, ParamError> {

Тоесть результат выполнения функции может быть один из следующих вариантов:
  • Успех: результата нет (параметр не был задан).
  • Успех: результат Vec<f64> есть (параметр задан).
  • Ошибка: параметр задан, но не может быть распаршен.
    • В этом случае надо пробросить наверх информацию о том, какое именно число в векторе не распарсилось, и ошибку из метора parse.

Реализация функции с такой сигнатурой "в лоб" приводит к достаточно развесистому дереву условий:

fn parse_sample_v1(param: Option<String>) -> Result<Option<Vec<f64>>, ParamError> {
    if let Some(values) = param {
        let mut result = Vec::new();
        for value in values.split(',') {
            match value.parse() {
                Ok(v) => 
                    result.push(v),
                Err(e) =>
                    return Err(ParamError::SampleValue(value.to_owned(), e)),
            }
        }
        Ok(Some(result))
    } else {
        Ok(None)
    }
}

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

Воспользуемся реализацией FromIterator для Result, и всяким около-монадическим добром типа Result::map:

fn parse_sample_v2(param: Option<String>) -> Result<Option<Vec<f64>>, ParamError> {
    param.map(|value| value.split(',')
              .map(|value| value.parse().map_err(|e| ParamError::SampleValue(value.to_owned(), e)))
              .collect::<Result<_, _>>())
        .map(|r| r.map(|values| Some(values)))
        .unwrap_or(Ok(None))
}

Как переписать ещё короче, я не знаю.

И, хотя код здесь сократился в три раза, всё равно получается плохо. Потому что, по-сути, в этом сниппете из пяти строк кода, значимых реально две. А последние три суть церемониальный код, не делающий ничего, кроме переоборачивания типов в другие типы. Причём, с высокой долей вероятности, весь этот код будет тупо выкинут транслятором при инлайнинге, и весь его смысл чисто формальный: преобразовать один ADT в другой, путём приписывания пары конструкторов. Я, конечно, не уверен, но у меня есть смутное ощущение, что компилятор в силах с этим справиться сам :)

Как здесь сделать правильно — я не знаю. Без поддежки HKT в языке не хватает абстракции, чтобы сделать "красивые" монады, а без них непонятно как правильно реализовать сахар типа do. В языке существует ещё механизм из синергии макроса try! с трейтом-преобразователем From, для конвертации одних типов ошибок в другие. Но он реально упрощает только случаи с "плоским" кодом (в пределах одной функции с явными циклами), но как только у нас появляются замыкания (цепи итераторов или модификация внутри деревьев Option/Result), вариант с try! уже не помогает.

Просто я к чему: люди уже сильно избалованы подходом, когда можно тупо писать happy path, и вообще класть болт на обработку ошибок. Но для этого нужны исключения, а для них нужен рантайм и сборщик мусора, от чего в Rust как раз пытаются уйти. Я не знаю, правильно ли будет активно перенимать опыт из хаскеля (насколько я знаю, там аккуратно обрабатывать ошибки тоже не очень любят ;)) — не факт, что для нефункционального языка это получится удобно. Скорее всего, нужно просто брать текущую систему, и специальными расширениями в самом языке уменьшать количество церемониального кода, которое требуется сейчас для корректной обработки ошибок. Например, внести какую-нибудь волшебную директиву "удовлетворить компилятор", которая сама преобразует заданный тип в такой, который нужен согласно сигнатуре :)
Tags: code, error handling, errors, exceptions, language, rust
Subscribe
  • Post a new comment

    Error

    default userpic

    Your IP address will be recorded 

    When you submit the form an invisible reCAPTCHA check will be performed.
    You must follow the Privacy Policy and Google Terms of use.
  • 27 comments