Logo

Blog


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 deeper in his CppCon talk Expect the expected.

This new data type, std::expected, contains either the return or the error value. However, the function returns a std::expected as a data type even in an error case.

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. You get several advantages from this new data type.

(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
int OpenFile(std::string_view name)
{
  const int ret = open(name.data(), 0);

  return ret;
}

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
const auto res = OpenFile("config.txt"sv);

if(-1 == res) {
  switch(errno) {
    case EACCES: Show("Permissions error"sv); break;
    case ENOENT: Show("File does not exist"sv); break;
    default: std::terminate();  // Programming error
  }
}

A We can use the file

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
std::expected<int, int> OpenFile(std::string_view name)
{
  const int ret = open(name.data(), 0);

  if(-1 == ret) {
    return std::unexpected{errno};  A 
  }

  return ret;  B 
}

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.

You have a hopefully special case in this example because it is always better to have a dedicated type for the error code. 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 stored 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 you get from this helper type is that you can spot the error case, or cases, quickly in our 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. No additional effort is required, as you can see in B.

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
const auto res = OpenFile("config.txt"sv);

if(not res.has_value()) {
  switch(res.error()) {
    case EACCES: Show("Permissions error"sv); break;
    case ENOENT: Show("File does not exist"sv); break;
    default: std::terminate();  // Programming error
  }
}

A We can use the file

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 std::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