Functional exception-less error handling with C++23’s optional and expected
This post is an updated version of one I made over five years ago, now that everything I talked about is in the standard and implemented in Visual Studio.
In software things can go wrong. Sometimes we might expect them to go wrong. Sometimes it’s a surprise. In most cases we want to build in some way of handling these misfortunes. Let’s call them disappointments.
std::optional
was added in C++17 to provide a new standard way of expressing disappointments and more, and it has been extended in C++23 with a new interface inspired by functional programming.
std::optional<T>
expresses “either a T
or nothing”. C++23 comes with a new type, std::expected<T,E>
which expresses “either the expected T
, or some E
telling you what went wrong”. This type also comes with that special new functional interface. As of Visual Studio 2022 version 17.6 Preview 3, all of these features are available in our standard library. Armed with an STL implementation you can try yourself, I’m going to exhibit how to use std::optional
‘s new interface, and the new std::expected
to handle disappointments.
One way to express and handle disappointments is exceptions:
void pet_cat() {
try {
auto cat = find_cat();
scratch_behind_ears(cat);
}
catch (const no_cat_found& err) {
//oh no
be_sad();
}
}
There are a myriad of discussions, resources, rants, tirades, and debates about the value of exceptions123456, and I will not repeat them here. Suffice to say that there are cases in which exceptions are not the best tool for the job. For the sake of being uncontroversial, I’ll take the example of disappointments which are expected within reasonable use of an API.
The Internet loves cats. Suppose that you and I are involved in the business of producing the cutest images of cats the world has ever seen. We have produced a high-quality C++ library geared towards this sole aim, and we want it to be at the bleeding edge of modern C++.
A common operation in feline cutification programs is to locate cats in a given image. How should we express this in our API? One option is exceptions:
// Throws no_cat_found if a cat is not found.
image_view find_cat (image_view img);
This function takes a view of an image and returns a smaller view which contains the first cat it finds. If it does not find a cat, then it throws an exception. If we’re going to be giving this function a million images, half of which do not contain cats, then that’s a lot of exceptions being thrown. In fact, we’re pretty much using exceptions for control flow at that point, which is A Bad Thingâ„¢.
What we really want to express is a function which either returns a cat if it finds one, or it returns nothing. Enter std::optional
.
std::optional<image_view> find_cat (image_view img);
std::optional
was introduced in C++17 for representing a value which may or may not be present. It is intended to be a vocabulary type — i.e. the canonical choice for expressing some concept in your code. The difference between this signature and the previous one is powerful; we’ve moved the description of what happens on an error from the documentation into the type system. Now it’s impossible for the user to forget to read the docs, because the compiler is reading them for us, and you can be sure that it’ll shout at you if you use the type incorrectly.
Now we’re ready to use our find_cat
function along with some other friends from our library to make embarrassingly adorable pictures of cats:
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));
}
Well this is… okay. The user is made to explicitly handle what happens in case of an error, so they can’t forget about it, which is good. But there are two issues with this:
- There’s no information about why the operations failed.
- There’s too much noise; error handling dominates the logic of the code.
I’ll address these two points in turn.
Why did something fail?
std::optional
is great for expressing that some operation produced no value, but it gives us no information to help us understand why this occurred; we’re left to use whatever context we have available, or (please, no) output parameters. What we want is a type which either contains a value, or contains some information about why the value isn’t there. This is called std::expected
.
With std::expected
our code might look like this:
std::expected<image_view, error_code> get_cute_cat (image_view img) {
auto cropped = find_cat(img);
if (!cropped) {
return no_cat_found;
}
auto with_tie = add_bow_tie(*cropped);
if (!with_tie) {
return cannot_see_neck;
}
auto with_sparkles = make_eyes_sparkle(*with_tie);
if (!with_sparkles) {
return cat_has_eyes_shut;
}
return add_rainbow(make_smaller(*with_sparkles));
}
Now when we call get_cute_cat
and don’t get a lovely image back, we have some useful information to report to the user as to why we got into this situation.
Noisy error handling
Unfortunately, with both the std::optional
and std::expected
versions, there’s still a lot of noise. This is a disappointing solution to handling disappointments.
What we really want is a way to express the operations we want to carry out while pushing the disappointment handling off to the side. As is becoming increasingly trendy in the world of C++, we’ll look to the world of functional programming for help. In this case, the help comes in the form of transform
and and_then
.
If we have some std::optional
and we want to carry out some operation on it if and only if there’s a value stored, then we can use transform
:
cat make_cuter(cat);
std::optional<cat> result = maybe_get_cat().transform(make_cuter);
//use result
This code is roughly equivalent to:
cat make_cuter(cat);
auto opt_cat = maybe_get_cat();
if (opt_cat) {
cat result = make_cuter(*opt_cat);
//use result
}
If we want to carry out some operation which could itself fail then we can use and_then
:
std::optional<cat> maybe_make_cuter (cat);
std::optional<cat> result = maybe_get_cat().and_then(maybe_make_cuter);
//use result
This code is roughly equivalent to:
std::optional<cat> maybe_make_cuter (const cat&);
auto opt_cat = maybe_get_cat();
if (opt_cat) {
std::optional<cat> result = maybe_make_cuter(*opt_cat);
//use result
}
and_then
and transform
for expected
act in much the same way as for optional
: if there is an expected value then the given function will be called with that value, otherwise the stored unexpected value will be returned. Additionally, there is a transform_error
function which allows mapping functions over unexpected values.
The real power of these functions comes when we begin to chain operations together. Let’s look at that original get_cute_cat
implementation again:
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));
}
With transform
and and_then
, our code transforms into this:
std::optional<image_view> get_cute_cat (image_view img) {
return find_cat(img)
.and_then(add_bow_tie)
.and_then(make_eyes_sparkle)
.transform(make_smaller)
.transform(add_rainbow);
}
With these two functions we’ve successfully pushed the error handling off to the side, allowing us to express a series of operations which may fail without interrupting the flow of logic to test an optional
. For more discussion about this code and the equivalent exception-based code, I’d recommend reading Vittorio Romeo‘s Why choose sum types over exceptions? article.
A theoretical aside
I didn’t make up transform
and and_then
off the top of my head; other languages have had equivalent features for a long time, and the theoretical concepts are common subjects in Category Theory.
I won’t attempt to explain all the relevant concepts in this post, as others have done it far better than I could. The basic idea is that transform
comes from the concept of a functor, and and_then
comes from monads. These two functions are called fmap
and >>=
(bind) in Haskell. The best description of these concepts which I have read is Functors, Applicatives, And Monads In Pictures by Aditya Bhargava. Give it a read if you’d like to learn more about these ideas.
A note on overload sets
One use-case which is annoyingly verbose is passing overloaded functions to transform
or and_then
. For example:
cat make_cuter(cat);
std::optional<cat> c;
auto cute_cat = c.transform(make_cuter);
The above code works fine. But as soon as we add another overload to make_cuter
:
cat make_cuter(cat);
dog make_cuter(dog);
std::optional<cat> c;
auto cute_cat = c.transform(make_cuter);
then it fails to compile, because it’s not clear which overload we want to pass to transform.
One solution for this is to use a generic lambda:
std::optional<cat> c;
auto cute_cat = c.transform([](auto x) { return make_cuter(x); });
Another is a LIFT
macro:
#define FWD(...) std::forward<decltype(__VA_ARGS__)>(__VA_ARGS__)
#define LIFT(f) \
[](auto&&... xs) noexcept(noexcept(f(FWD(xs)...))) -> decltype(f(FWD(xs)...)) \
{ return f(FWD(xs)...); }
std::optional<cat> c;
auto cute_cat = c.transform(LIFT(make_cuter));
Personally I hope to see overload set lifting in some form get into the standard so that we don’t need to bother with the above solutions.
If you want to read more about specifically this problem, I have a whole blog post on it.
Try them out
The functional extensions to std::expected
and std::optional
are available in Visual Studio 2022 version 17.6 Preview 3. Please try them out and let us know what you think! If you have any questions, comments, or issues with the features, you can comment below, or reach us via email at visualcpp@microsoft.com or via Twitter at @VisualC.
If you’re stuck on old versions of C++, I have written implementations of optional
and expected
with the functional interfaces as single-header libraries, released under the CC0 license. You can find them at tl::optional
and tl::expected
.
1 comment
Very much like Railway Oriented Programming described on https://fsharpforfunandprofit.com/rop