Simon Brand (a.k.a. TartanLlama) recently
published an article called “Functional
exceptionless error-handling with optional and expected”
where he excellently explains how sum types
(e.g. std::optional
and std::variant
)
can be used in lieu of exceptions in order to implement error
handling.
The article focuses on the usage of std::optional
, on
std::expected
, and on two monadic operations,
map
and and_then
, which reduce boilerplate and
increase readability of the code. While Simon briefly mentions the
benefits of ADTs (algebraic data
types) over exceptions, I think that the topic deserves more
attention, as some readers missed the point.
Here’s an example comment from the article’s /r/cpp thread:
return crop_to_cat(img) .and_then(add_bow_tie) .and_then(make_eyes_sparkle) .map(make_smaller) .map(add_rainbow);
Compared to
(img); crop_to_cat(img); add_bow_tie(img); make_eyes_sparkle(img); make_smaller(img); add_rainbow
?
It’s just cute. Also, if there’s any memory allocation needed anywhere, it’s either lying about not throwing or each such function is wrapped in a try/catch. (And no, you can’t rig operator new to return
nullptr
because std lib needs it to throw).
While at first this might seem like a proper rebuttal, I strongly believe it isn’t.
exception-based solution
We’ll start by analyzing the exception-based solution proposed in the comment:
(image_view img)
image_view get_cute_cat{
(img);
crop_to_cat(img);
add_bow_tie(img);
make_eyes_sparkle(img);
make_smaller(img);
add_rainbow
return img;
}
These might be the signatures of the operations on
image_view
:
void crop_to_cat(image_view&);
void add_bow_tie(image_view&);
void make_eyes_sparkle(image_view&);
void make_smaller(image_view&);
void add_rainbow(image_view&);
What do these signatures tell us about potential failures or error cases? Nothing.
With this design, the user is not aware that these functions might fail/throw an exception unless it’s explicitly documented. Similarly, the type system does not expose any failure case - this means that the compiler won’t be able to help us remember to handle them.
Here’s an example of what I mean:
void send(image_view);
(image_view x)
image_view just_crop{
(x); // <== (0)
crop_to_catreturn x;
}
int main()
{
auto cropped = just_crop(get_cat_image()); // <== (1)
(cropped); // <== (2)
send}
When you write/read the code snippet above, you need to ask yourself these questions:
Should
(0)
be wrapped in atry...catch
block?Should
(1)
be wrapped in atry...catch
block?Can I safely invoke
send(cropped)
on(2)
without checking if an exception has been thrown?
These questions do not have obvious answers. The developer/reviewer is forced to look at human-written documentation or at the implementation of the functions. The compiler can not help in this situation.
In my opinion, these are the major pain points with an exception-based solution:
1. Function signatures do not expose possible failure states.
2. It is not immediately obvious whether or not an operation can fail and how.
3. The compiler cannot help us prevent mistakes.
My claim is that all of these issues are solved when using algebraic data types for error handling. Let’s see how!
ADT-based solution
Consider the following solution based on tl::optional
:
::optional<image_view> get_cute_cat(image_view img)
tl{
return crop_to_cat(img)
.and_then(add_bow_tie)
.and_then(make_eyes_sparkle)
.map(make_smaller)
.map(add_rainbow);
}
These might be the signatures of the operations on
image_view
:
::optional<image_view> crop_to_cat(image_view);
tl::optional<image_view> add_bow_tie(image_view);
tl::optional<image_view> make_eyes_sparkle(image_view);
tl(image_view);
image_view make_smaller(image_view); image_view add_rainbow
Just by looking at the above declarations, we immediately know the following:
crop_to_cat
,add_bow_tie
, andmake_eyes_sparkle
might fail.make_smaller
andadd_rainbow
will never fail.
This means that, in order to have a correct program, we
must check whether or not the tl::optional
instance returned by the first three function is set and act
accordingly. The best part is that the compiler will help us
here!
Remember the previous just_crop
example? Here it is
again, this time using ADTs:
void send(image_view);
::optional<image_view> just_crop(image_view x)
tl{
return crop_to_cat(x); // <== (0)
}
int main()
{
auto cropped = just_crop(get_cat_image()); // <== (1)
.map(send); // <== (2)
cropped}
This version of the snippet is much more readable and easier to understand, as all possible failure points are obvious and “checked” by the compiler.
At
(0)
,crop_to_cat
’s signature exactly matches our desired operation.At
(1)
, we know thatget_cat_image()
will never fail as it returns animage_view
. We also know thatcropped
might or might not be in a valid state (becausejust_crop
returns atl::optional
).At
(2)
, we know thatsend
will never fail as it returnsvoid
. Most importantly, we will get a compilation error if we attempt to write:(cropped); send
The type system is protecting us from human mistakes here. By using
cropped.map
, we explicitly handle the possible failure case and only invokesend
with a valid argument.
Did we solve all of the previously mentioned pain points?
1. Function signatures do not expose possible failure states.
- Thanks to ADTs, it is obvious when a function might or might not fail. Glancing at the signature is enough to understand whether or not a possible failure must be handled.
2. It is not immediately obvious whether or not an operation can fail and how.
- By using monadic operations such as
map
andand_then
, the code becomes self-explanatory in regard to possible failures. Instead of guessing whether or not an exception will be thrown, possible failure states become explicit.
3. The compiler cannot help us prevent mistakes.
- The type system will prevent us from writing code that doesn’t
properly check failure states. We simply cannot write
send(cropped)
without checking the state ofcropped
beforehand - the program will fail to compile.
symptom of a larger problem?
You might argue that this whole article is based upon an incorrect premise:
You should have never used exceptions to handle errors in
get_cute_cat
in the first place.
I completely agree with that - exceptions should only be used
for truly exceptional errors. In the case of
get_cute_cat
, failure states are not exceptional: we’re
dealing with image data and image transformations - we should be
prepared to handle failures.
Unfortunately, the reality is that I keep seeing exceptions being
used for basic error handling almost every day. Due to the lack of
familiarity with ADTs or due to laziness (“why refactor all the
signatures when I can just add a throw
here?”),
developers choose exceptions to handle unexceptional
errors. This leads to a lack of clarity (uninformative
interfaces) and human mistakes (“I should have put a
try...catch
block there!”).
The moral of the story is: write code optimizing for readability and safety. As shown in this article, ADTs are more readable and safer than exceptions for basic error handling. If you have been using exceptions for years, it might take a while to get used to ADTs - but it is worth it.