Concise Result Extraction in Modern C++

A popular idiom in functional programming is the use of sum types to express results or optional values. When a function returns, it either succeeded and we get the result, or it failed and we have an error on our hands. This is a pattern in Modern C++ as well, enabled by standard library types such as std::variant and std::optional. In this article we will explore how to improve the ergonomics of handling multiple results and potential error values.

The ‘Result’ Type

For the purpose of this example we will define a simple ‘Result’ type: a variant that is either an ‘Error’ or a templated Type:

struct Error {
    std::string message;
    Error(std::string _message) : message(std::move(_message)) {}
};
template<typename Type>
using Result = std::variant<Error, Type>;

Now we can define functions that either succeed and return a specified type, or an Error object which holds a message:

Result<int> getCloudInteger(Cloud& cloud) {
    if(!cloud.ok()) {
        return Error(cloud.get_error_message());
    }

    return cloud.get_int();
}

Getting Results

This a pretty neat and clear interface, however, as with many operations we may be performing multiple operations that may success or fail. This results in a growing amount of boilerplate in the business logic code. Even when using a overloaded + operator for combining errors:

auto res1 = getCloudInteger(cloud1);
auto res2 = getCloudInteger(cloud2);
auto res3 = getCloudInteger(cloud3);
if(is_error(res1 && is_error(res2) && is_error(res3)) {
    return get_error(res1) + get_error(res2) + get_error(res3);
} else if(is_error(res1) && is_error(res2)) {
    return get_error(res1) + get_error(res2);
} else if(is_error(res1) && is_error(res3)) {
    return get_error(res1) + get_error(res3);
} else if(is_error(res1)) {
    return get_error(res1);
} else if(is_error(res2)) {
    return get_error(res2);
} else if(is_error(res3)) {
    return get_error(res3);
}
auto val1 = get_ok(res1);
auto val2 = get_ok(res2);
auto val3 = get_ok(res3);

Note: is_error, get_error and get_ok are simple utility functions wrapping std::getand std::holds_alternative

As the number of results we are processing increases, more and more lines of code are to be added. Obviously, it is best to keep your business logic as clear and uncluttered from ‘implementation-level’ concerns are possible. So how can we do this better? Modern C++ gives us template parameter packs, which allows to type-safe, variable argument functions with derived return types. We will leverage them to iterate over all the results and consolidate any errors.

Using Parameter Packs

Parameter packs are often iterated via recursion (especially before C++17). So to start we will define two overloaded functions:

  • One that takes only a distinct result type.
  • One that takes a distinct result type and a pack argument.
template<typename Type>
auto get_all(Result<Type>& r) -> ?;

template<typename Type, typename... Types>
auto get_all(Result<Type>& r, Types&... rest) -> ?;

Now, we must determine a suitable return value interface for this operation. We can use our Result type, as it already provides a value or error paradigm. What should be the success type within the Result? Since we want this function to be used generically we don’t want a class with named member types. And using a standard library container type (such as std::vector) would add unnecessary overhead. Using a std::tuple with the same result order as the provided pack is the best option. This also provides compatibility with very convenient syntax sugar such as std::tie and C++17 structure bindings.

Let define our single argument get_all function first:

template<typename Type>
auto get_all(Result<Type>& r) -> Result<std::tuple<Type>> {
    if(is_error(r)) {
        return get_error(r);
    }
    return std::make_tuple(get_ok(r));
}

This is one is easy. The return type is simply a Result of std::tuple composed of one value: the results success type. If the result contains an error we simple return that error. Otherwise we get the value, stuff it in a tuple and stuff that tuple in a Result.

The variable argument version is a little more tricky. We will have to leverage some Modern C++ to implement it properly. First we need to determine the return type for the signature. Again, the overarching type is the Result type. What is the success type? A tuple of all the success types of all the provided Result arguments. We need to extract that by feeding some tuple utility function faux calls to the decltype specifier.

template<typename Type, typename... Types>
auto get_all(Result<Type>& r, Types&... rest)
-> Result<decltype(std::tuple_cat(std::make_tuple(get_ok(r)), get_ok(get_all(rest...))))>

Whew! Quite a bit! Although, if you’re used to C++, you will actually find that signature to be pretty clear. The decltype specifier simply “returns” the type of the expression it is provided. In this case we are saying:

  • decltype: Get me the type of this expression
  • std::tuple_cat: Concatenate these tuples.
  • std::make_tuple: Make a tuple with these arguments. We provide one argument: the success result of the provided first argument.
  • get_ok(get_all(res…)): Get the success result of getting the combined result of all arguments except the first one (the return type of this function call is Result of std::tuple<…>)

Here is final body:

template<typename Type, typename... Types>
auto get_all(Result<Type>& r, Types&... rest)
-> Result<decltype(std::tuple_cat(std::make_tuple(get_ok(r)), get_ok(get_all(rest...))))> {
    auto restRes = get_all(rest...);
    if(is_error(r) && is_error(restRes)) {
        return get_error(r) + get_error(restRes);
    } else if(is_error(r)) {
        return get_error(r);
    } else if(is_error(restRes)) {
        return get_error(restRes);
    }

    return std::tuple_cat(std::make_tuple(get_ok(r)), get_ok(restRes));
}

The function body is a lot simpler once we know what we are returning. First we recursively call get_all on the argument except the first one. Then we check their error status. If both are errors, we combine the errors using an overloaded + operator, if only the current result is an error we return that error, otherwise we return the consolidated error from the consecutive arguments. If there are no errors at all, we concatenate a tuple comprised of the first result and the tuple returned from the recursive call on the remaining arguments. Viola!

Finale

Now we can ergonomically and concisely extract results and check for errors:

auto res1 = getCloudInteger(cloud1);
auto res2 = getCloudInteger(cloud2);
auto res3 = getCloudInteger(cloud3);
auto aggregate_res = get_all(res1, res2, res3);
if(is_error(aggregate_res)) {
    return get_error(aggregate_res);
}
auto [val1, val2, val3] = get_ok(aggregate_res);

This can be further refined and modified to suit your needs. Perhaps you can use an R-Value Reference argument signature and eliminate the need for the temporary Result variables:

auto aggregate_res = get_all(
    getCloudInteger(cloud1), getCloudInteger(cloud2), getCloudInteger(cloud3));
if(is_error(aggregate_res)) {
    return get_error(aggregate_res);
}
auto [val1, val2, val3] = get_ok(aggregate_res);

Or, if you prefer exception-based error handling but are dealing with a functional interface, you can simply throw exceptions within a get_all wrapper, eliminating even more boilerplate:

auto [val1, val2, val3] = get_all_vals(
    getCloudInteger(cloud1), getCloudInteger(cloud2), getCloudInteger(cloud3)

Modern C++ Parameter Packs and error handling allow for cleaner call sites around your business logic. Writing a simple helper function like this is not difficult and can reduce the number of lines of code while allowing you to express your ideas and control flow more cleanly. This is especially useful for std::async and std::future and other libraries like it.

Link to Complete Code Example