Using the C++23 std::expected type

The C++23 standard will feature a new utility type called std::expected. This type either contains an expected value, or an unexpected one, typically providing information about the reason something failed (and the expected value could not be returned). This feature is, at this time, supported in GCC 12 and MSVC 19.33 (Visual Studio 2022 17.3). In this article, we’ll see what std::expected is and how it can be used.

Why do we need std::expected?

Suppose you have to write a function that returns some data. It has to perform one or more operations that may fail. This function needs to return the data, but also needs to indicate failure and the cause for the failure. There are different ways to implement this.

Alternative 1: status code + reference parameter

One alternative is to return a status code indicating success or the reason of failure. Additionally, the actual returned data is a parameter passed by reference.

enum class Status
{
   Ok,
   AccessDenied,
   DataSourceError,
   DataError,
};

bool HasAcccess() { return true; }
int OpenConnection() { return 0; }
int Fetch() { return 0; }

Status ReadData(std::vector<int>& data)
{
   if (!HasAcccess())
      return Status::AccessDenied;

   if (OpenConnection() != 0)
      return Status::DataSourceError;

   if (Fetch() != 0)
      return Status::DataError;

   data.push_back(42);

   return Status::Ok;
}

This is how it can be used:

void print_value(int const v)
{
   std::cout << v << '\n';
}

int main()
{
   std::vector<int> data;
   Status result = ReadData(data);
   if (result == Status::Ok)
   {
      std::ranges::for_each(data, print_value);
   }
   else
   {
      std::cout << std::format("Error code: {}\n", (int)result);
   }
}

Alternative 2: using exceptions

Another alternative is to return the actual data but in case of failure throw an exception.

struct status_exception : public std::exception
{
   status_exception(Status status) : std::exception(), status_(status) {}

   status_exception(Status status, char const* const message) : std::exception(message), status_(status) {}

   Status status() const { return status_; }

private:
   Status status_;
};

std::vector<int> ReadData()
{
   if (!HasAcccess())
      throw status_exception(Status::AccessDenied);

   if (OpenConnection() != 0)
      throw status_exception(Status::DataSourceError);

   if (Fetch() != 0)
      throw status_exception(Status::DataError);

   std::vector<int> data;

   data.push_back(42);

   return data;
}

This time, we need to try-catch the call:

int main()
{
   try
   {
      auto data = ReadData();
      std::ranges::for_each(data, print_value);
   }
   catch (status_exception const& e)
   {
      std::cout << std::format("Error code: {}\n", (int)e.status());
   }      
}

Choosing between one of these could be a personal choice or may depend on imposed restrictions. For instance, there could be a no-exceptions requirement, in which case the 2nd alternative cannot be used.

Alternative 3: using std::variant

Another possible options, in C++17, is to use std::variant. In this case, our function could looks as follows:

std::variant<std::vector<int>, Status> ReadData()
{
   if (!HasAcccess())
      return { Status::AccessDenied };

   if (OpenConnection() != 0)
      return {Status::DataSourceError};

   if (Fetch() != 0)
      return {Status::DataError};

   std::vector<int> data;

   data.push_back(42);

   return data;
}

However, when it comes to using it, it gets nasty. We need to visit each possible alternative of the variant type and the syntax to do so is horrendous.

int main()
{
   auto result = ReadData();
   std::visit([](auto& arg) {
      using T = std::decay_t<decltype(arg)>;

      if constexpr (std::is_same_v<T, std::vector<int>>)
      {
         std::ranges::for_each(arg, print_value);
      }
      else if constexpr (std::is_same_v<T, Status>)
      {
         std::cout << std::format("Error code: {}\n", (int)arg);
      }
   }, result);
}

In my opinion, std::variant is difficult to use, and I don’t like making use of it.

Note: you can read more about std::variant in this article: std::visit is everything wrong with modern C++.

Alternative 4: using std::optional

The std::optional type may contain or may not contain a value. This can be used when returning no data is a valid option for a function that normally would return a value. Like in our case:

std::optional<std::vector<int>> ReadData()
{
   if (!HasAcccess()) return {};

   if (OpenConnection() != 0) return {};

   if (Fetch() != 0) return {};

   std::vector<int> data;

   data.push_back(42);

   return data;
}

We can use this as follows:

int main()
{
   auto result = ReadData();
   if (result)
   {
      std::ranges::for_each(result.value(), print_value);
   }
   else
   {
      std::cout << "No data\n";
   }
}

The std::optional type has several members for checking and accessing the value, including:

  • has_value() (e.g. if(result.has_value())) checks whether the object contains a value
  • operator bool (e.g. if(result)) performs the same check
  • value() (e.g. result.value()) returns the contained value or throws std::bad_optional_access if the object does not contain a value
  • value_or() (e.g. result.value_or(...)) returns the contained value or the supplied one if the object does not contain any value
  • operator-> and operator* (e.g. *result) access the contained value but have undefined behavior if the object does not contain any value

The problem with this particular implementation of ReadData is that we didn’t get the reason for the failure back. To do so, we would either need to introduce a function parameter (passed by reference) or throw an exception (like with the second alternative presented earlier).

Enter std::expected

In C++23, we get this new utility type, std::expected<T, E>, in the new <expected> header. This is supposed to be used for functions that return a value but may encounter some errors in which case they may return something else, such as information about the error. In a way, std::expected is a combination of std::variant and std::optional. On one hand, it’s a discriminated union, it either hold a T (the expected type) or an E (the unexpected type). This is at least, logically; but more of this, shortly. On the other hand, it was an interface similar to std::optional<T>:

  • has_value() (e.g. if(result.has_value())) returns true if the object contains the expected value (not the unexpected one)
  • operator bool (e.g. if(result)) same as has_value
  • value() (e.g. result.value()) returns the expected value if the object contains one or throws std::bad_expected_access<E>, an exception type that contains the unexpected value stored by the std::expected<T, E> object
  • value_or() (e.g. result.value_or(...)) returns the expected value if the object contains one or, otherwise, the supplied value
  • error() returns the unexpected value contained by the std::expected<T, E> object
  • operator-> and operator* access the expected value, if the object contains one; otherwise, the behavior is undefined

Let’s see how the ReadData function may look when using std::expected<T, E> for the return type:

std::expected<std::vector<int>, Status> ReadData()
{
   if (!HasAcccess())
      return std::unexpected<Status> { Status::AccessDenied };

   if (OpenConnection() != 0)
      return std::unexpected<Status> {Status::DataSourceError};

   if (Fetch() != 0)
      return std::unexpected<Status> {Status::DataError};

   std::vector<int> data;

   data.push_back(42);

   return data;
}

This implementation can be used as follows:

int main()
{
   auto result = ReadData();
   if (result)
   {
      std::ranges::for_each(result.value(), print_value);
   }
   else
   {
      std::cout << std::format("Error code: {}\n", (int)result.error());
   }
}

In this implementation, when an error occurs, an std::unexpected<Status> value is return. This std::unexpected is a class template that acts as a container for an unexpected value of type E. The std::expected<T, E> models a discriminated union of types T and std::unexpected<E>.

In the previous example, the different functions called by ReadData had different ways of indicating success (and returning data). When you have an algorithm, or routine that is made of smaller parts, and each part is a function that returns the same std::expected instantiation, the calls could be easily chained. Here is an example. Let’s consider a function that builds a user’s avatar, adding a frame, badge, and text to an existing image. For this, let’s assume the following stubs:

struct avatar
{
};

enum class error_code
{
   ok,
   error,
};

using avatar_result = std::expected<avatar, error_code>;

avatar_result add_frame(avatar const& a) { return a; /* std::unexpected<error_code>(error_code::error); */ }
avatar_result add_badge(avatar const& a) { return a; /* std::unexpected<error_code>(error_code::error); */ }
avatar_result add_text(avatar const& a)  { return a; /* std::unexpected<error_code>(error_code::error); */ }

Using these, we can write the following make_avatar function:

avatar_result make_avatar(avatar const& a, bool const with_frame, bool const with_badge, bool const with_text)
{
   avatar_result result = a;

   if (with_frame)
   {
      result = add_frame(*result);
      if (!result)
         return result;
   }

   if (with_badge)
   {
      result = add_badge(*result);
      if (!result)
         return result;
   }

   if (with_text)
   {
      result = add_text(*result);
      if (!result)
         return result;
   }

   return result;
}

Each step is handled in the same manner and the code is very simple. This make_avatar function can be used as follows:

int main()
{
   avatar a;

   auto result = make_avatar(a, true, true, false);
   
   if (result)
   {
      std::cout << "success\n";
   }
   else
   {
      std::cout << "Error: " << (int)result.error() << '\n';
   }
}

References

Support for std::expected is new and there isn’t a lot of documentation about it. But if you want to learn more, check the following:

3 Replies to “Using the C++23 std::expected type”

  1. is used variant and std::holds_alternative instead of using std::visit, seems to work
    template
    inline CextExpected::operator bool() const
    {
    return std::holds_alternative(_var);
    }

  2. Why is the std::expected type introduced in C++23, and how does it address the need for returning data along with failure indication in a more efficient and expressive manner compared to alternative approaches, such as using a status code and reference parameter as illustrated in the provided example?
    Telkom Telekomunikasi

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.