Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can multiple std::expected<void, E> values be combined correctly with intuitive syntax?

I am attempting to introduce std::expected into a code base that has many functions that return a bool indicating whether the function was successful (e.g. orig_check1 and orig_check2). These functions are frequently invoked by higher level functions that also return a bool (e.g. orig_check).

Specifically, I would like to introduce error codes for all functions. I believe std::expected<void, std::string_view> is a natural choice.

The following program contains 3 different approaches in the check function to combine the results of the lower-level functions (check1 and check2).

#include <cassert>
#include <expected>
#include <string_view>
#include <iostream>
#include <functional>

bool orig_check1(int val) { return val % 2 == 1; }
bool orig_check2(int val, int threshold) { return val <= threshold; }
bool orig_check(int val) { return orig_check1(val) && orig_check2(val, 100); }

////////////////////

std::expected<void, std::string_view> check1(int val)
{
    if (val % 2 == 0) {
        return std::unexpected{"even"};
    }
    return {};
}

std::expected<void, std::string_view> check2(int val, int threshold)
{
    if (val > threshold) {
        return std::unexpected{"too big"};
    }
    return {};
}

template <typename E>
std::expected<void, E> operator&&(std::expected<void, E> lhs, std::expected<void, E> rhs)
{
    return !lhs ? lhs : rhs;
}

std::expected<void, std::string_view> check(int val)
{
    // too verbose
    auto ret1 = [&]() -> std::expected<void, std::string_view> {
        if (auto ret = check1(val); !ret) {
            return ret;
        }
        if (auto ret = check2(val, 100); !ret) {
            return ret;
        }
        return {};
    }();

    // too complex
    std::expected ret2 = check1(val).and_then(std::bind_front(check2, val, 100));
    
    // incorrect due to lack of short-circuit evaluation
    std::expected ret3 = check1(val) && check2(val, 100);
    
    // sanity checking functional equivalence
    assert(ret1 == ret2 && ret1 == ret3);
    return ret1;
}

int main()
{
    check(0);
    check(1);
    check(123);
    return 0;
}

I'm not thrilled about any of these approaches. #1 is too verbose. #2 is too complex. #3 is incorrect. Ideally, I would be able to write the syntax of #3 with short-circuit evaluation. Is this possible? If not, is there a different approach that should be considered?

like image 613
MarkB Avatar asked Oct 24 '25 18:10

MarkB


1 Answers

First, just to have said it: If your unexpected result paths are truly unexpected (or dare I even say exceptional), you could consider just using exceptions.

With that out of the way, I actually had a quite similar situation recently and considered two other approaches from the ones you tried.

  1. Lazy Evaluation

This is a similar idea to @Caleth's answer, but uses lambdas to avoid the overhead of std::function: To get the short-circuiting behavior, we need to delay the actual invocation of check2 until we are sure that check1 succeeded. This can be done by wrapping the call to check2 in a lambda:

[&]() -> decltype(auto) { return check2(val, 100); }

and since writing that lambda every time can be quite annoying and error prone, you could hide it in a macro (and even add on correct noexcept propagation as a bonus):

#define LAZY(...)                                                    \
    [&]() noexcept(noexcept(__VA_ARGS__)) -> decltype(__VA_ARGS__) { \
        return __VA_ARGS__;                                          \
    }

This lambda is then some invocable type that yieds a std::expected. We can use that to define the && operator as follows:

template <typename E, typename Lazy>
    requires std::is_invocable_r_v<std::expected<void, E>, Lazy>
std::expected<void, E> operator&&(std::expected<void, E> lhs, Lazy&& rhs)
{
    return !lhs ? lhs : std::forward<Lazy>(rhs)();
}

Your check method could then look as follows:

std::expected<void, std::string_view> check(int val) {
    return check1(val) && LAZY(check2(val, 100));
}

here is a working example on godbolt.

  1. Coroutines

I tried (ab)using co-routines to make expecteds awaitable and short-circuit in case they are in the unexpected state. Your check method could then look like this:

Expected<void, std::string_view> check(int val)
{
    co_await cehck1(val);
    co_await check2(val, 100);
    
    co_return {};
}

This would have the desired short-circuiting behavior and, while a little unusual at first, is quite straight forward once you have seen it a few times.

There are a few caveats however:

  • notice that the above return type was Expected instead of std::expected. This is because you have to specialize std::coroutine_traits for the return type and specializing types in std is only allowed when using at least one custom type.
  • AFAIK there are no readily available libraries for this, so you would have to implement and maintain it yourself. And from my few dabbles in coroutines, the underlying machinery is quite difficult to get completely right.
  • Coroutines have a non-negligible overhead (the coroutine frame, heap allocations for said frame, etc...). Especially the heap allocations are what stopped me personally from using this approach. (While compilers are allowed to elide the heap allocation, at the time of writing this only clang is able to do it somewhat reliably).

If all that doesn't deter you, here is the godbolt playground I used to test this. It is by no means a complete implementation (no error checks, constraints, etc...), but could give you a good starting point.

like image 64
Velocirobtor Avatar answered Oct 26 '25 08:10

Velocirobtor