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.
-
They are implicit: When looking at some code it isn’t obvious which things can throw exceptions and which can’t. This makes it difficult to reason about correctness. Sure, C++11 added
noexcept
but this is only visible in the function signature and not all functions that don’t throw anything are markednoexcept
, so you have to refer to a function documentation. -
They are difficult to use correctly: Writing exception safe code is hard, especially in generic code where you don’t know the type you’re dealing with. You have to assume that everything can throw, which makes the strong exception safety impossible to achieve, or you have to put additional requirements on your types (i.e. this function must not throw), but it is currently impossible to statically verify them.
Sure you can
static_assert()
that they are markednoexcept
— but they may not be markednoexcept
and still don’t throw! Or you can write a wrapper function that is markednoexcept
that invokes it — but this leads tostd::terminate
.
- They are not easily composable: There is only one current exception, you can’t have multiple ones. This was a problem, for example, for the C++17 parallel algorithms. What if an exception is thrown in multiple of the worker threads? How to report all of them back to the caller? The implementation gave up on solving that problem and just decided to terminate the program if any exception is thrown.
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.
-
They are explicit: If you have a function that returns an ADT, every single function that calls it has to handle the error. They are not simply passed along anymore, you have to do extra work.
-
They are not ergonomic: If you want to do multiple things in sequence, you either have to write verbose code or resolve to using functional paradigms, which aren’t particularly friendly to use in C++. Just compare the two examples given:
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);
- They can be ignored:
They’re just return types, they can easily ignored. To quote Walter Bright: “How many people check the return value of
printf()
?
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:
-
How does generic programming work?
-
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++):
-
They are explicit: In Swift, when you have a function that may throw an exception, you have to specify the function as
throw
:func canThrowErrors() throws -> String func cannotThrowErrors() -> String
But unlike
noexcept
, this is statically enforced.In addition, when invoking a function that may throw an exception, you have to make it clear as well:
result = try canThrowErrors(); result2 = cannotThrowErrors();
This makes it immediately obvious which functions can throw exceptions and which can’t.
-
They are not difficult to use correctly: Sure, you still have to worry about exception safety but there are no implicit requirements on your code: they’re made clear.
And asthrows
is part of the type system, Swift protocols - basically C++0x concepts - handle them as well. If you don’t allow a certain protocol to provide a throwing function, you may not pass it a type that has a throwing function. In addition,defer
allows guaranteed cleanup without the boilerplate of RAII. -
They are (somewhat) composable: In addition to calling a function with
try
, you can also call it withtry?
: This will convert it into an optional, which can be composed. There is alsotry!
that terminates the program if the call threw an exception.
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:
-
They are ergonomic: A common pattern when dealing with ADTs is this one:
result = foo(); if (!result) return result.error(); // do something with result.value()
This pattern is so common, Rust provided a boilerplate solution:
// old way result = try!(foo()); // new built-in language feature result = foo()?;
This does the same as the code above: early return with an error or continue otherwise.
In addition, Rust also provides the function style facilities and proper pattern matching.
-
They must not be ignored:
Result
is marked with a special attribute so the compiler will complain if the return value is simply discarded.
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:
- is implicit but not completely hidden away
- is explicit but not too verbose
- flexible enough to be used in all kinds of situations
- fully part of the type system so it can be checked with concepts
- cannot be ignored
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:
-
What is
E
exactly? Should it be the same for all functions? One the one hand, this makes composing trivial as all functions that return aresult<int>
have the same return type. But maybe this is to inflexible? -
How and when is
E
converted into an exception? And which exception type? -
…
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.
This blog post was written for my old blog design and ported over. If there are any issues, please let me know.