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