Signaling error conditions without exceptions

In this article we’re going to look at a feature new to C++23 which supports a way to return an error condition from a function where a result value was anticipated. This is enabled in a new header <expected> which (as far as I am aware) does not require specific compiler support. (MSVC 19.37 with /std:c++latest was used to test the program code in this article.)

Callee functions which wish to signal error conditions simply return an instance of type std::expected, whose declaration looks like:

template<class T, class E>
class expected;

In the caller function, the return value is likely to be assigned to a (function local) variable, which can be examined to determine whether it contains an expected (result of type T) or an unexpected (error of type E). Upon discovery of an unexpected in the caller function, it can choose to:

  1. Handle (or ignore) the error condition within the function.
  2. Return the same (or different) unexpected to its own caller, propagating it “up” the call stack.
  3. Throw an exception.

Although new to the C++ Standard Library, this idea is hardly ground-breaking; other languages such as Rust and Haskell support this with types Result and Either respectively, and in Zig support for “error union” return types is baked into the language. The C++ LLVM infrastructure even defines an Expected type, but that’s probably only useful to code making use of LLVM; alternatively it is entirely possible to “roll-your-own”.

The provision of a standardized way to use expected/unexpected return types should be seen as a “good thing” since it is a lightweight and highly adaptable alternative to traditional C++ exception handling. Dealing with conditions which cause “unexpected” results is frequently necessary in some C++ programming domain heartlands, and the general rule that no more than one exception should be thrown every second of running time provides a solid use case for std::expected.

The program shown below demonstrates use of std::expected with a function toString() which attempts to convert an int to a std::string. The return type of this function is std::expected<std::string,Error> where the unexpected type is a scoped enum. To return a valid result value, this function simply returns a std::string, while to return an error, it returns std::unexpected() with the parameter being a member of the scoped enum.

#include <expected>
#include <iostream>
#include <string>
#include <stdexcept>
#include <initializer_list>

enum class Error { None, TooBig, TooSmall, Answer };
using StringOrError = std::expected<std::string,Error>;

auto toString(int n) -> StringOrError {
    if (0 > n) {
        return std::unexpected{ Error::TooSmall };
    }
    else if (100 <= n) {
        return std::unexpected{ Error::TooBig };
    }
    else if (42 == n) {
        return std::unexpected{ Error::Answer };
    }
    else {
        auto s = std::to_string(n);
        return s;
    }
}

int main() {
    try {
        for (auto number : { 1, 99, -1, 100, 42, 99 }) {
            auto result = toString(number);
            if (result.has_value()) {
                std::cout << "Value: " << result.value() << '\n';
            }
            else {
                if (result.error() == Error::Answer) {
                    throw std::runtime_error("Found the answer");
                }
                std::cerr << "Error: " << static_cast<int>(result.error()) << '\n';
            }
        }
    }
    catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
}

The output from running this program is:

Value: 1
Value: 99
Error: 2
Error: 1
Exception: Found the answer

Note that the number 1 appears as both a value and an error, this is correct as they are distinct from each other in the logic of the program. Also, we never reach the end of the list as 42 causes an exception to be thrown in main(), which exits the loop. The variable result can be queried with the member function has_value() (which returns a Boolean) and the result or error extracted with member functions result() and error().

An alternative to using a scoped enum as the error type would be to use a std::variant of (possibly non-related) error classes. The std::exception base class should not be used as the error type due to slicing considerations, instead use std::variant<std::runtime_error,std::logic_error,...>, and query this in the caller function as needed. Alternatively define your own, possibly empty, exception classes and use a std::variant wrapper around these as shown in the next example.

The std::expected class contains a useful member function and_then() which allows for “chaining” of operations returning the same std::expected type. This method accepts as its single parameter a function pointer (or “callable” type) which must return a value of this same std::expected type. Suppose we have a lambda which simply returns double its input, and another lambda which adds three to its input. We need to check that the result does not overflow when passing a number input to the first lambda and passing the result to the second. A code example should make this a lot clearer:

#include <expected>
#include <iostream>
#include <variant>
#include <initializer_list>

class OverflowFromDoubling {};
class OverflowFromAddingThree {};

using Error = std::variant<OverflowFromDoubling,OverflowFromAddingThree>;
using ResultOrError = std::expected<int,Error>;

int main() {
    const int overflow = 10;
    auto doubleMe = [&overflow](int n) -> ResultOrError {
        n = n * 2;
        if (n < overflow) {
            return n;
        }
        else {
            return std::unexpected{ OverflowFromDoubling{} };
        }
    };
    auto addThree = [&overflow](int n) -> ResultOrError {
        n = n + 3;
        if (n < overflow) {
            return n;
        }
        else {
            return std::unexpected{ OverflowFromAddingThree{} };
        }
    };
    for (auto number : { 1, 2, 3, 4, 5 }) {
        auto result = doubleMe(number).and_then(addThree);
        std::cout << number << ": ";
        if (result.has_value()) {
            std::cout << result.value() << '\n';
        }
        else {
            switch (result.error().index()) {
                case 0:
                    std::cout << "Overflow from doubling\n";
                    break;
                case 1:
                    std::cout << "Overflow from adding three\n";
                    break;
                default:
                    break;
            }
        }
    }
}

The lambda functions doubleMe and addThree are declared with trailing return-type syntax, with this type being ResultOrError in both cases. The assignment to variable result calls doubleMe in the usual way and then chains addThree to the return value, which results in the output being:

1: 5
2: 7
3: 9
4: Overflow from adding three
5: Overflow from doubling

The fourth line shows that the second lambda in the chain (addThree) caused the overflow, and the final line shows the overflow happened in doubleMe, the overflow being when the operation would give a number greater or equal to 10. Free functions or functors could be used instead of lambda functions, and the method chains can be of arbitrary length.

That concludes this article about std::expected, source codes for the above programs are available on GitHub.

Leave a comment