C++23 - std::expected, the superior way of returning a value or an error
In today's post, I like to jump in time and fast forward to what is coming with C++23, a new data type in the STL std::expected
(P0323). The idea behind this data type isn't new. The idea was originally brought up by Andrei Alexandrescu. He explained it more deeply in his CppCon talk Expect the expected.
This new data type, std::expected
, contains either the return or the error value. However, even in an error case, the function returns a std::expected
as a data type.
You can compare it with C++17's std::optional
or maybe with a std::variant
. A std::expected
contains either a success or an error value. This new data type offers several advantages.
(Too?) close coupling of error information
Let's start looking at a code example. Say you have a simple wrapper around the POSIX open
. In C++20, such a wrapper may look like this:
1 2 3 4 5 6 |
|
The result is that all call sides check for -1
and use errno
for potential error detection. For our example, you might want to generate different error messages depending on whether the file does not exist or you don't have permission to access the file.
The -1
and error
mean that you drag API elements from POSIX all over into our code, where you end up writing a lot of code like this:
1 2 3 4 5 6 7 8 9 10 11 |
|
Do you really want this? What if you want to stay clean in the C++ world? The simple wrapper could do better than just using a std::string_view
.
Expected
The answer is that you don't really want this mix. Let's have a look at how OpenFile
looks once you sprinkle std::expected
in:
1 2 3 4 5 6 7 8 9 10 |
|
With the help of std::expected
, you can move the -1
and the access to errno
into OpenFile
. In A, you handle the error case, grab the value of errno
and stash it into std::expected
.
Hopefully, you have a special case in this example because having a dedicated type for the error code is always better. The return and error types are of the same data types. How does one distinguish between the two? The answer is by using another data type, std::unexpected
. You may know std::unexpected
from previous C++ standards. In case you haven't heard, the original std::unexpected
was deprecated in C++17. The data type I'm referring to here is the C++23 incarnation, which has nothing to do with the original function.
A std::unexpected
stores the error value and is assignable to a std::expected
, given that the error data type is the same for both.
The nice additional benefit of this helper type is that you can spot the error case or cases quickly in your code. You only have to look out for std::unexpected
.
For the good case, the success path, you simply write the return value as you always do. As you can see in B, no additional effort is required.
With the change to OpenFile
, you also have to refactor the using side of our code. With the powers of std::expected
, the code now looks like this:
1 2 3 4 5 6 7 8 9 10 11 |
|
Additionally, we can also eliminate the errno
values by removing the last dependency to POSIX in our using code part. You could, for example, map the value to a class enum. The nice benefit of this is that the compiler will warn us if you do not handle an enumeration in a switch statement.
Delaying an exception.
Returning to Andrei Alexandrescu's talk, what if you call value
on a std::expected
which contains an unexpected
? Well, in this case, an exception is thrown. This approach is a nice way to delay an exception up to a certain point. For example, in our OpenFile
example, there could be cases where not opening the file is a fatal error, regardless of the reason. In such a case, you can spare us checking for the error. You can simply try to access the success value. If no value exists, a bad_expected_access<E>
exception is thrown, where E
is the error type of std::unexpected
.
Other parts of the interface
The design of type-punning solutionstd::expected
matched std::optional
. Like optional
expected
comes with a value_or
function. This can be handy if an error gets translated into a default good value.
Additionally, like std::optional
, you can access the value with value
, by dereferencing the expected object or via pointer-like access. You can replace the if(not res.has_value)
with if(!res)
like for a std::optional
.
Andreas