Hacker News new | past | comments | ask | show | jobs | submit login
Functional exceptionless error-handling with optional and expected (tartanllama.xyz)
55 points by signa11 on Dec 3, 2017 | hide | past | favorite | 31 comments



Just thinking about the example... Let's say... just use excepions! This code

    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 excepion


The syntactic baggage is bothering me too. Sometimes this functional style is syntactically too heavyweight if your language doesn’t support it very well. Compare that example translated to Haskell:

    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.


A lot of people dislike the terseness of that style (especially the use of operators.) I'm not one of those people, but I can understand their point of view. Fortunately, Haskell also lets us write code in a way that managers can understand.

    getCuteCat img = do
        cropped <- findCat img
        withTie <- addBowTie cropped
        withSparkles <- makeEyesSparkle withTie
        return (addRainbow (makeSmaller withSparkles))


And, with this translation, I hope everyone can see how exceedingly simple the >>= operator is: “take the return value of what’s to the left of me and deliver it as an argument to the function to the right of me”.

(Nitpick: if we’re translating kccqzy’s example verbatim, the last line shouldn’t include return, since — as per his definition — addRainbow is monadic)


However this style breaks down as soon as you have to do something more complicated than thread a single value, or have to do something more complicated than pass failures through.


We've been using this style a lot in Elixir via the `with` macro

    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.


Code can always be broken up into simpler sub-components. The more you logically compartmentalize your code, the more it lends itself to these simple transformations. At the end of the day, you — not the problem you’re trying to model — decide how much you want to do in each function.


Naive question, how do I figure out in which part of the chain a null had occured? Or is the assumption that you'd establish only valid inputs and therefore never have a chance for this to happen?


The Maybe monad doesn’t have this feature. It’s purposefully simple: the value is either present or absent.

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.


> Yes Rust has .and_then which is same as >>= (sometimes also called flatMap or bind), but still the syntax isn’t lightweight enough.

Rust has try! and ?. The latter is quite lightweight.


These are not replacements for >>= because they don’t work with other monads. For example you cannot use try! and ? on lists/vectors.


The author kind of sabotaged his point here. Optional values are a greate choice if you expect the operation to fail and if you expect the consumer to do something meaningful in case of a failed operation. But if the the consumer can't really do anything in case of a failed operation it becomes a burden to deal with the optional values and you have to bring in additional machinery to keep your code clean and propagate the failure transparently one level up the call stack.

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.


I think there is also a niche for checked exceptions, which kind of fall in the middle between those two approaches.

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.


A method shouldn't know whether its consumer can do something meaningful on failure. Return an error value and let them decide.


You are not writing a function in a vacuum. You make choices about how arguments are passed into the function and choices about how results are returned from the function including errors. Optional values and exceptions offer different trade-offs and you should pick the one that is probably most useful and most easy to work with. What do I know what the caller will do with my return value, I will just return a XML string with the serialized result and let them do with that whatever they want is probably not a good design philosophy to adopt either.


Translated to today's Rust, this code looks like:

  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!


I agree with your final point. Sadly Rust’s model for errors is tied to a specific implementation. That is, you must issue check instructions after every function call. In C++, errors are truly zero-cost: Exceptions only cause overheard when they happen. (Also in C++, different exception handling implementations are supported)

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.


> Exceptions only cause overheard when they happen.

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."


> 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.

This is a fair point and elucidates the trade off being made. Perhaps this is the reason C++11 has the “nothrow” attribute.


It’s not exactly tied to that implementation but because error sum types are runtime values, it’s not clear whether a statement like “return foo” should return a normal value or do stack unwinding.


How are exceptions in C++ any more zero-cost than Option or Result in Rust? There must still be a branch operation in the code that C++ generates, or it wouldn't be able to handle the exception.


In C++ there is at most only one compare (presumably at the spot where the exception was detected). In Rust there is a compare all the way up the stack after every function call site, and it is executed if an error happens or not.


It's not a great usecase for optionals. a better one would be retrieving a value from a std::map - that one is pretty binary, it's either there or it isn't.

To me optionals are for things where you'd consider it reasonable to return a null that represents "doesn't exist".


But if you already have null then the method could return null, empty optional or optional with value.


I don't know about C++, but for example Java has a method "ofNullable" that will convert a null to a None option.

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...


I think that this dichotomy can be summarized with the old logical identity:

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


Ernest question: If it supports the platform you're targeting and you're not missing any critical libraries, what other reasons are there not to use Rust over C++ for new projects? Assuming of course you're not already proficient in C++.


Maturity of the language, quantity of developers available, knowledge base ecosystem (this is more than just library availability, as it includes implementations to solve problems resulting from years of practice by thousands of engineers), interoperability with other languages' libraries, to name a few. In some cases Rust's "escape hatch" to get unsafe has more boilerplate than equivalent implementations in C++: when Rust's safety model isn't as important it might not make sense to use it.

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.


It almost makes me want to write some C++. Still an interesting observation that people want to add new stuff to an already humongous language like C++


Doing the equivalent of "maybe chaining" isn't hard in c++ & quite elegant in c++17 in my view

https://stackoverflow.com/questions/7690864/haskell-style-ma...


Welcome to Monad Burger. Would you like a fmap with that?




Join us for AI Startup School this June 16-17 in San Francisco!

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: