Functional exceptionless error-handling with optional and expected
blog.tartanllama.xyz
blog.tartanllama.xyz
std::optional<image_view> get_cute_cat (image_view img) {
auto cropped = find_cat(img);
if (!cropped) {
return std::nullopt;
}
auto with_tie = add_bow_tie(*cropped);
if (!with_tie) {
return std::nullopt;
}
auto with_sparkles = make_eyes_sparkle(*with_tie);
if (!with_sparkles) {
return std::nullopt;
}
return add_rainbow(make_smaller(*with_sparkles));
}
Becomes image_view get_cute_cat (image_view img) {
auto cropped = find_cat(img);
auto with_tie = add_bow_tie(*cropped);
auto with_sparkles = make_eyes_sparkle(*with_tie);
return add_rainbow(make_smaller(*with_sparkles));
}
And it could throw a no_cat_found, a cannot_see_neck, or a cat_has_eyes_shut excepionTo me optionals are for things where you'd consider it reasonable to return a null that represents "doesn't exist".
But yes, that could potentially happen. IIRC that problem exists in many languages, including F# and Scala. Not sure how big of an issue it is in practice...
fn get_cute_cat(img: image_view) -> Option<image_view> {
let cropped = find_cat(img)?;
let with_tie = add_bow_tie(cropped)?;
let with_sparkles = make_eyes_sparkle(with_tie)?;
add_rainbow(make_smaller(with_sparkles))
}
Basically, with the same amount of boilerplate as your exception-based solution, just three ?s. If you prefer, you could also do fn get_cute_cat(img: image_view) -> Option<image_view> {
find_cat(img).and_then(|cropped|
add_bow_tie(cropped).and_then(|with_tie|
make_eyes_sparkle(with_tie).and_then(|with_sparkles)|
add_rainbow(make_smaller(with_sparkles))
)
)
)
}
... but that has rightward drift issues. I _think_ you could do fn get_cute_cat(img: image_view) -> Option<image_view> {
find_cat(img)
.and_then(add_bow_tie)
.and_then(make_eyes_sparkle)
.and_then(|with_sparkles|
add_rainbow(make_smaller(with_sparkles))
)
)
}
but I'm not 100% sure without a comparable example.I bring this up because I don't believe the thing you're seeing is inherent to this style of error-handling, but instead to the language's support for these kinds of monadic patterns. Haskell would look even more succinct than the Rust!
Rust could do something like this but it would need syntactic support for something like “throw.” In this hypothetical world, on throw the compiler could generate code that does the unwinding and all that. When a normal value is returned, it would just return as normal. The function call ABI would be one such that functions that returned Result<T> and returned normally would be assumed to have succeeded. Actually Result<T> wouldn’t need to be a real type, just decoration at the syntax level.
They only execute instructions when they happen... but they still inhibit the optimizer when they don't. Calls that may throw act like branches in the control flow graph. They also introduce more code bloat for their landing pads.
> Rust’s model for errors is tied to a specific implementation.
The Rust ABI is unspecified for a reason. We're currently seeing a lot of optimization around struct and enum layout; I'm not sure why this couldn't be applied to the calling convention without any extra syntax for "throw."
This is a fair point and elucidates the trade off being made. Perhaps this is the reason C++11 has the “nothrow” attribute.
With exceptions on the other hand propagating the failure up the call stack is the default behavior not requiring additional code unless you can and want to deal with the failure. As so often it seems to me that you don't want one or the other but the right one for the situation. The author unfortunately picked an example that seems a much better fit for exception because in his example he is unable to respond to the possible failures and just wants to propagate it on level up the call stack.
A checked-exception forces the programmer to notice and make a design-decision.
At the same time, if the designer chooses to merely propagate it up the stack, that's extremely easy to do just by editing the function signature.
getCuteCat img = findCat img >>= addBowTie >>= makeEyesSparkle >>= addRainbow . makeSmaller
No more manually checking whether a value is null or not, and the chaining Simply Just Works. Syntactically lightweight and conveys the important information without line noise.Yes this example uses monads, and there’s a reason why monads aren’t that popular in other languages. Most of the time you also need parenthesis-less function application, currying, and lightweight lambdas to make monad-heavy code syntactically acceptable. Yes Rust has .and_then which is same as >>= (sometimes also called flatMap or bind), but still the syntax isn’t lightweight enough.
I wish people could just stop copying this idea from Haskell and use what’s reasonable in their language. Shoehorning monads and monad-heavy code into a typical language isn’t reasonable.
getCuteCat img = do
cropped <- findCat img
withTie <- addBowTie cropped
withSparkles <- makeEyesSparkle withTie
return (addRainbow (makeSmaller withSparkles)) def get_cute_cat(image_id) do
with {:ok, cropped} <- find_cat(image_id),
{:ok, with_tie} <- add_bowtie(cropped),
{:ok, with_sparkles} <- make_eyes_sparkle(with_tie)
do
with_sparkles
|> make_smaller()
|> add_rainbow()
end
end
If any of the values on the rhs of a `<-` cannot be pattern matched, it returns up the returned value. This value can be pattern matched easily inside an `else` clause underneath the main block. The use of the `:ok,` vs `:error,` tuple convention allows multiple values to be destructured and passed around if needed (rather than an optional type).If you find the code getting unwieldily, as runeks said you can break it down into simpler constructs.
(Nitpick: if we’re translating kccqzy’s example verbatim, the last line shouldn’t include return, since — as per his definition — addRainbow is monadic)
If you want different errors, use the Either monad. It basically means either the value you want is there, or it’s not there but there’s an explanation. Chaining works just as well for the Either monad.
Rust has try! and ?. The latter is quite lightweight.
A or B = not(A) implies B
Considered as a statement about types, 'A or B' is a variant type over two constructors A/B (this is equivalent to an optional type, where A=unit).
Also as a statement about types, 'not(A) implies B' is a function taking a continuation on A (the exception handler) and returning a B (if it decides not to raise an exception).
So (as we often find in CS) this is a very old idea, older than computers and programming languages. :D
Personally, as a person very experienced with C++ I prefer Rust when it's available on the target platform. The low level, high efficiency computations I might do use numerical algorithms that neither Rust nor C++ are very good for, although C++ is slightly better in this case.
https://stackoverflow.com/questions/7690864/haskell-style-ma...