Exceptions vs expected: Let’s find a compromise

This isn’t the blog post I wanted to publish today, this is the blog post I had to publish.

Simon blogged about using ADTs for error handling, leading to an interesting reddit discussion. Then Vittorio wanted to share his thoughts on the matter, leading to an even bigger reddit discussion. Now I’d like to chime in and offer a reasonable solution.

It is the age-old question: return codes vs exceptions. But this time, return codes have gotten an upgrade: std::expected and similar types.

The problems with exceptions

As far as I can tell, people have the following issues with exceptions:

Besides any size/speed overhead, I’m not talking about that here.

Sure you can static_assert() that they are marked noexcept — but they may not be marked noexcept and still don’t throw! Or you can write a wrapper function that is marked noexcept that invokes it — but this leads to std::terminate.

The problems with ADTs

A type like std::expected is what’s known as an algebraic data type in the functional world. In fact, this is a common pattern there.

ADTs for error handling have the following complains:

Those are mainly taken from this reddit discussion.

return crop_to_cat(img)
       .and_then(add_bow_tie)
       .and_then(make_eyes_sparkle)
       .map(make_smaller)
       .map(add_rainbow);

// vs.

crop_to_cat(img);
add_bow_tie(img);
make_eyes_sparkle(img);
make_smaller(img);
add_rainbow(img);

Looking at the bigger picture

As with most things, the disadvantages are opposites: “exceptions are too implicit!” — “ADTs are too explicit!”

So let’s take a step back and look at the bigger picture. In particular: if you’re writing a library and have a function that may fail — how do you report the error?

I’m going to quote this answer from Tony van Eerd here, as he put it so well:

Here’s the thing: as a function author (and thus decider of how it will report errors), how am I suppose to know whether a caller will want to handle the error right away or pass it on?

For example:

string read_file(string_view filename); // read file contents, return as a string

Is file-not-found an exceptional error, or expected? If the filename is coming from the User (ie a File Open dialog), shouldn’t the file picker always give you a valid file? But if you are reloading a project (ie a video editor, and the file is “some_movie.avi”, which is part of your project) you shouldn’t be too surprised if the file has gone missing.

Or if the calling code is string config = read_file("hard-coded-config.txt"). That is probably a programmer error (or install error), and maybe shouldn’t be handled by any code (not immediate, not higher up).

etc.

As a function author, I don’t think using “the caller probably will handle this” is a valid way to choose an error strategy.

If you want to write a truly flexible API, you have to do both: exceptions and error return codes. Because sometimes the error is “exceptional” and sometimes it isn’t.

This is what the C++17 filesystem library did:

void copy(const path& from, const path& to); // throws an exception on error
void copy(const path& from, const path& to, error_code& ec); // sets error code

However, this leads to some code duplication or boilerplate that happens when you implement one in terms of the other.

Quick question: Which one should you implement in terms of which? Answer will come later on.

So what are you supposed to do?

Do what others do.

In particular, take a look at different programming languages. When I hear about a new programming language, I look at two things:

  1. How does generic programming work?

  2. How does error handling work?

Both are relatively hard problems and it is interesting to see how they can be solved if you’re not limited to the C++ way. So let’s take a look how two other programming languages solve error handling: Swift and Rust.

Disclaimer: I’ve never actually written a non-trivial program in those languages, so my description may not be accurate.

Error handling in Swift

I heavily recommend taking a look at Swift. It is designed by some ex-C++ people and has a lot of things C++ didn’t manage to do.

Swift choose to use exceptions.

If you’re familiar with Swift, you can correctly claim that this is not entirely accurate. But I’m using this term here to prove a point.

However, they do not suffer any of the problems listed above (as least not as much as C++):

Error handling in Rust

Rust, on the other hand, decided to use ADTs for error handling. In particular, Result<T, E> — either result value T or error E — is used.

They’ve also managed to solve most of the problems I listed:

Combining both worlds

What’s interesting here is that both Swift and Rust error handling are very similar: The main difference is the way the error is transported over the call stack.

And this means that both approaches are great solutions for specific situations: Exceptions still have a runtime overhead when thrown, so they shouldn’t be used for non-exceptional cases. Whereas return values and branches have a small overhead when not thrown, so they shouldn’t be used for rare errors.

However, if you’re writing a widely usable library only the caller knows whether or not a situation is non-exceptional!

So we need a way to report errors, that:

If we want something that is fully part of the type system right now, without changing the language, we have to put the error information in the return type.

“What about constructors?", you might ask. Don’t worry, I’ve got you covered.

But this has an additional benefit: Converting a return value into an exception can be done without any overhead: The only cost is an additional branch for the if (result.error()) throw error;, but the function that produces the result will probably have a branch already. If the call to the function is inlined, the extra branch can be eliminated.

So we need a new return type: Let’s call it result<T, E>. Much like std::expected or Rust’s Result it either contains the “normal” return value T or some error information E. And unlike std::expected it not only has the optional-like interface but also the monadic error handling routines (map, and_then etc). People who want to use functional ADTs are happy already.

In order to please the exception fans, let’s also provide a function value_or_throw() it either returns the T or converts E into some exceptions and throws that. If you want to handle failure using exceptions, all you need is type a few characters after the function.

And if failure is a programming mistake, just call value() without any checks. If an error occurred, this can lead to a debug assertion as it should.

But what if the error is ignored?

C++17 added [[nodiscard]], which is great but can easily be suppressed. I propose something like an assert(!unhandled_error) in the destructor of result that terminates the program, if you destroy a result without handling the error. That way you must not forget handling it or call something explicit like .ignore_error().

This solves all problems when invoking a single library function in your program. However, it doesn’t solve the problems of the library that needs to compose multiple results or writing generic code. Dealing with result is still more verbose than exceptions:

result<T, E> calculate_bar()
{
  auto first_result = calculate_foo1();
  if (!first_result)
    return first_result.error();

  auto second_result = calculate_foo2(first_result.value());
  if (!second_result)
    return second_result.error();

  return bar(second_result.value());
}

However, this can be solved with a small language addition - operator try. It is Rust’s try! or ? and makes it perfect:

result<T, E> calculate_bar()
{
  auto first_result = try calculate_foo1();
  auto second_result = try calculate_foo2(first_result);
  return bar(second_result);
}

Conclusion

Error handling is difficult.

But I truly think that something like the result I’ve discussed combined with some form of try operator can be the solution to the problem.

Of course, I’ve glossed over many details and important design decisions:

There are many different implementations of this result for this reason: proposed std::expected has the basic things already, (Boost.)Outcome is another. I suggest you take a look at them, the authors spend a lot more time thinking about the problem than I just did.

Of course, if you’re simply writing application code you can use whichever one you like. However, if you’re writing a general purpose library, consider adopting these techniques.

Note that this way of error handling isn’t usable for all kinds of errors. An example would be out of memory. For that you should rather use the exception handler technique I’ve described here.

If you've liked this blog post, consider supporting me - a dollar a month can really help me out.

This blog post was written for my old blog design and ported over. If there are any issues, please let me know.