?

Log in

No account? Create an account
 

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

About  

Previous Entry Ещё об обработке ошибок в Rust 24 авг, 2015 @ 22:46 Next Entry
С обработкой ошибок в 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 как раз пытаются уйти. Я не знаю, правильно ли будет активно перенимать опыт из хаскеля (насколько я знаю, там аккуратно обрабатывать ошибки тоже не очень любят ;)) — не факт, что для нефункционального языка это получится удобно. Скорее всего, нужно просто брать текущую систему, и специальными расширениями в самом языке уменьшать количество церемониального кода, которое требуется сейчас для корректной обработки ошибок. Например, внести какую-нибудь волшебную директиву "удовлетворить компилятор", которая сама преобразует заданный тип в такой, который нужен согласно сигнатуре :)
Оставить комментарий
[User Picture Icon]
From:permea_kra
Date:Август, 24, 2015 21:01 (UTC)
(Link)
>насколько я знаю, там аккуратно обрабатывать ошибки тоже не очень любят

Там не то чтобы этого не любят, там это можно делать слишком многими способами.

> непонятно как правильно реализовать сахар типа do

Он не нужен. Т.е. с ним конечно проще, но в цацкеле зачастую удобнее использовать операторы из class Monad напрямую. А конкретно для парсеров можно попробовать приспособить аппликативный интерфейс (Control.Applicative) - его, зачастую, достаточно, и вот его -то по идее должно быть возможно сделать. Тем более, что TCO в расте нет, а значит в монадическом коде можно легко попасть на растущий стек.


Edited at 2015-08-24 21:03 (UTC)
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 09:46 (UTC)
(Link)
Вот и я о том же.

Насчёт монадического кода - если конвертировать по Чёрчу, то переполнения стека не будет.
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 13:25 (UTC)
(Link)
Да, согласен насчёт Control.Applicative, похоже на правду.

Но в лоб всё равно не срастается: в хаскеле оно красиво сработает, потому что егонный список — это функтор. А в Rust используется механизм итераторов (в примере выше split возвращает итератор подстрок), который, вроде бы, по-сути тоже ленивый список, только в профиль, но при этом не функтор. Почему так — я не готов теоретизировать: подозреваю, что не может быть выполнено правило "fmap id = id", в том числе, потому что итератор может быть мутабельный.

Так что надо придумывать какие-то пути обхода.
[User Picture Icon]
From:permea_kra
Date:Август, 25, 2015 14:32 (UTC)
(Link)
как раз свойства представления входного потока для аппликативного интерфейса разборщика глубоко пофиг. Вопрос именно в том, чтобы получился этот аппликативный интерфейс, т.е. чтобы сам разборщик был функтором. Итераторы для этого наружу вообще необязательно показывать.

Другое дело, что в этом случае скорее всего придется выкинуть getopts и писать разбор параметров ручками, но это может и к лучшему.
[User Picture Icon]
From:antilamer
Date:Август, 25, 2015 02:30 (UTC)
(Link)
У нас на С++ есть тип "ошибка, или значение типа T"; обращаемся с ним при помощи макросов, в 95% случаев получается достаточно удобно. В остальных 5% да, нужно написать if.
Например, ASSIGN_OR_RETURN(auto foo, GetFoo()), или RETURN_IF_ERROR(DoBar()). И коротко, и понятно, где может произойти ошибка и как она и куда передается. Как я понял, это примерно то, что делает try!. Можешь привести пример, когда читаемо написать не получается, а с настоящей монадой бы получилось?
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 09:45 (UTC)
(Link)
https://ro-che.info/ccc/28

Это всё, что я могу сказать. ;)
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 13:42 (UTC)
(Link)
Да, try! по-сути делает ровно то же самое, плюс некоторая мелкая магия по автопреобразованию типа ошибки к тому, который требуется сигнатурой.

Это хорошо работает, но только в случае линейного кода. Например, вместо такого:

        for value in values.split(',') {
            match value.parse() {
                Ok(v) => 
                    result.push(v),
                Err(e) =>
                    return Err(ParamError::SampleValue(value.to_owned(), e)),
            }
        }

После некоторой подготовки можно написать так:

        for value in values.split(',') {
            result.push(try!(value.parse()))
        }

Но try! теряет полезность в замыканиях, потому что возвращает управление только из анонимной функции, а не из родительской (в КЛ, например, для этого есть return-from, которым можно вернуть управление из любого уровня вложенности).

Например, вот так можно собрать в массив длины всех строк, поданных на stdin:

    let stdin = io::stdin();
    let all_lines: Vec<_> = stdin
        .lock()
        .lines()
        .map(|line| line.unwrap().len())
        .collect();

Map принимает замыкание, в которое lines передаёт Result<String, io::Error>. Этот result внутри map уже нельзя так просто обработать через try!, потому что управление будет возвращено только из замыкания, но не из родительской функции.

А вот в хаскеле это всё можно делать через аппликативные функторы, например, если я всё правильно понимаю.

Edited at 2015-08-25 13:43 (UTC)
[User Picture Icon]
From:antilamer
Date:Август, 26, 2015 04:04 (UTC)
(Link)
Да, понял, о чем ты. Сам с такой необходимостью не сталкивался, но согласен, что тут на макросах далеко не уедешь. С другой стороны, можно заявить, что это забота функции map, т.е. что надо предоставить overload "map с возможностью ошибки" с хорошо задокументированной семантикой обработки оных ошибок. С аппликативными функторами, правда, можно было бы обойтись и без этого :)
From:dmzlj
Date:Август, 25, 2015 04:49 (UTC)
(Link)
Ну вот ASSIGN_OR_RETURN(auto foo, GetFoo()) - это и есть нечитабельно. а


wtf = do
foo <- GetFoo
doSomethingWith foo


довольно читабельно, хотя и вырожденный случай.



Edited at 2015-08-25 04:49 (UTC)
[User Picture Icon]
From:antilamer
Date:Август, 26, 2015 04:07 (UTC)
(Link)
Ну почему не читабельно? Мы вот читаем и ничего :)
Не очень красиво, конечно - но если оценивать по критерию "количество строк", то получается столько же; по критерию "понятно ли, что именно происходит", то может быть даже и лучше (по "<-" не всегда понятно, какая монада имеется в виду); по критерию "сколько ошибок происходит из-за неправильного понимания такого или похожего кода" - не могу припомнить ни одной ни в кодревью, ни в продакшне...
From:dmzlj
Date:Август, 26, 2015 04:42 (UTC)
(Link)
Ну потому, например, что auto foo это у нас стейтмент, а стоит на месте выражения. Макрос выглядит как функция, но вообще никак им в итоге не является. Отвратительно, на самом деле.
[User Picture Icon]
From:antilamer
Date:Август, 26, 2015 06:20 (UTC)
(Link)
Что я могу сказать... Твоя претензия мне понятна, но я ее не разделяю, у меня другая система ценностей в вопросах читаемости :)
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 09:43 (UTC)
(Link)
Твоя иерархия успехов это комбинация монад Maybe и Either (успех/неуспех и успех/конкретная ошибка). При этом монадичность оных не используется - если у тебя нет использования >>=, или ты можешь переписать всё на >>, то это аппликативный функтор.

Поэтому всё более-менее просто, даже do не надо. Разбор строки даёт Either (parse :: String -> Either String a), наличие/отсутствие параметра Maybe. Поэтому разбор комбинируется просто: fmap parseVec64f как раз будет иметь тип Maybe String -> Maybe (Either String Vec64f).
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 13:45 (UTC)
(Link)
Согласен насчёт аппликативных функторов. Ответил в другой ветке про это.
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 13:51 (UTC)
(Link)
> fmap parseVec64f как раз будет иметь тип Maybe String -> Maybe (Either String Vec64f).

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

А как быть, если внутри parseVec64f используется функция parse64f, тип ошибки у которой другой — например, перечисление вроде InvalidSymbol Char | Overflow | EmptyString. И, например, используется какая-то другая функция с другим типом ошибки.

Как это всё аккуратно принести наружу из parseVec64f? Например, мне в логике выше нужно делать разные действия, в зависимости от разных типов ошибок?
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 14:24 (UTC)
(Link)
В принципе, Either a b вполне себе обобщается до нужных результатов и ошибок.

Выражение fmap parse не изменится, если ты изменишь тип ошибки.
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 14:52 (UTC)
(Link)
Но в "Either a b" фиксированный тип "a", получается, что все функции, использующиеся внутри parseVec64f должны возвращать ошибку одного и того же типа.

Но что если это не так?
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 15:09 (UTC)
(Link)
Прошу привести пример, где это не так, это раз.

fmap для Either имеет тип (a -> b) -> Either e a -> Either e b. Для преобразования ошибок мы можем сделать комбинатор mapErr :: (e -> s) -> Either e a -> Either s a.

Тогда для преобразования ошибок мы можем использовать mapErr :: fmap (mapErr simplifyError . parse).

Это два.
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 15:16 (UTC)
(Link)
А, ну понятно теперь.

Собственно, в Rust ровно так и сделано (Either = Result, mapErr = map_err), я просто думал, в хаскеле ещё какой-то механизм есть.
[User Picture Icon]
From:thesz
Date:Август, 25, 2015 15:20 (UTC)
(Link)
Ну, что, как типизированное программирование? Общие впечатления, там, всё такое. ;)
[User Picture Icon]
From:swizard
Date:Август, 25, 2015 15:39 (UTC)
(Link)
Да так же, по-большому счёту :) Но сопоставление с образцом заметно удобней работает, конечно.

Но я ж не первый раз с типами встречаюсь, я на хаскеле уже успел попрограммировать. Даже в конкурсе каком-то недавно на нём участвовал =)
[User Picture Icon]
From:theiced
Date:Август, 25, 2015 09:48 (UTC)
(Link)
просто статическая тупизация - говно.
[User Picture Icon]
From:slonopotamus
Date:Август, 26, 2015 16:44 (UTC)
(Link)
Второй сниппет говно с точки зрения дебаггинга и расстановки брейкпоинтов. Первый снипет говно, т.к. основной success path забурился куда-то далеко вправо, вместо того, чтобы идти ровненько сверху вниз.
[User Picture Icon]
From:swizard
Date:Август, 26, 2015 17:52 (UTC)
(Link)
Я в тексте же написал про макрос try! (и в комментариях уже обсуждали) — с ним всё ровно вниз идёт.

Функциональный интерфейс (чейнинг) де-факто не может быть говном, потому что он как раз спрямляет дерево вложенных циклов (из-за которого логика вправо уезжает) в ровный вертикальный код. Но там как раз не работает try.

Смысл поста в поисках истины.
[User Picture Icon]
From:slonopotamus
Date:Август, 26, 2015 19:47 (UTC)
(Link)
Я вполне понимаю в чем смысл поста и всеми руками за Rust. Но кажется, что он таки чуть менее silver bullet, чем хотелось бы.
From:(Anonymous)
Date:Ноябрь, 2, 2016 12:56 (UTC)

Detmifr

(Link)

http://lyricageneric-l.com

Lyrica (http://lyricageneric-l.com)
(Оставить комментарий)
Top of Page Разработано LiveJournal.com