17

In C++23, given:

expected<A, string> getA(const X& x);
expected<B, string> getB(const Y& y);

C compute_all(const A& a, const B& b);

Is there a way to avoid a classic style check like:

auto a_ret = getA(x);
if (!a_ret)
  return a_ret.error();

auto b_ret = getB(y);
if (!b_ret)
  return b_ret.error();

C final_ret = compute_all(*a_ret, *b_ret);

and write something like

expected<C, string> final_ret = magic_apply(compute_all, getA(x), getB(y))

This is an idea of implementation of magic_apply but I need something more generic (perhaps using variadic templates), that allows passing to compute_all some parameter that is not an std::expected.

template<typename Func, typename A, typename B, typename Err>
auto magic_apply(Func func, const std::expected<A, Err>& a, const std::expected<B, Err>& b)
->
std::expected<decltype(func(a.value(), b.value())), Err>
{
    if(!a) {
        return std::unexpected{ a.error() };
    }
    if(!b) {
        return std::unexpected{ b.error() };
    }
    return func(a.value(), b.value());
}

Does some feature already exist in the language I could use to write this?

25
  • 3
    In Haskell this is called liftA2 and in Rust the ! syntax expands to the pattern you wrote. C++ has neither of these things, I'm afraid.
    – Botje
    Commented Jul 11 at 9:57
  • 2
    @Lundin No, just impossible. These are function arguments; how would you write a for loop that iterates over them?
    – cigien
    Commented Jul 11 at 11:25
  • 1
    @cigien: you might iterate over a std::array<std::optional<Err>, N>, and if no error just call return func(expecteds.value()...)
    – Jarod42
    Commented Jul 11 at 11:33
  • 1
    @Lundin: I won't say that having functions with more than one parameter is a bad design...
    – Jarod42
    Commented Jul 11 at 11:58
  • 2
    @fiorentinoing: fixed version.
    – Jarod42
    Commented Jul 11 at 14:05

7 Answers 7

12

A generic version can be implemented with the help of std::optional and fold-expression:

#include <expected>
#include <functional>
#include <optional>

template<typename Func, typename... Args, typename Err>
std::expected<std::invoke_result_t<Func&, const Args&...>, Err>
magic_apply(Func func, const std::expected<Args, Err>&... args)
{
  if (std::optional<Err> err;
      ([&] {
        if (!args.has_value())
          err.emplace(args.error());
        return err.has_value();
       }() || ...)
     )
    return std::unexpected{*err};

  return std::invoke(func, args.value()...);
}

Demo

2
  • 1
    I'm not sure of the order of expansion, but will this return the first error, or the last one?
    – cigien
    Commented Jul 11 at 12:43
  • 3
    @cigien the first, || still short circuits in a fold
    – Caleth
    Commented Jul 11 at 12:45
7

You could implement magic_apply like this:

template<typename F, typename ...Ts> 
auto magic_apply(F && f, Ts... ts) 
{
    // construct an array<pair<bool, string>>, where the bool indicates an error, and the string the error message
    auto errs = std::to_array({ std::make_pair(ts.has_value(), ts.has_value() ? "" : ts.error()) ...});
    
    // Find an element that actually contains an error.
    auto itr = std::ranges::find_if(errs, [](const auto & pair) {
        return pair.first == false;
    });
    
    auto ret = std::expected<decltype(f(*ts...)), decltype(itr->second)>{};

    // Either we return the error
    if (itr != errs.end()) 
        ret = std::unexpected(itr->second);
    // or the result of calling the function on all the expecteds
    else ret = f(*ts...);

    return ret;
}

Here's a demo.

4

If you curry your function, you can apply it like this:

struct transformer_t {
    template <typename F, typename T, typename... Ts>
    auto operator()(F&& curried, T&& next, Ts&&... rest){
        return curried.and_then([&]<typename G>(G&& g){ 
            return (*this)(std::forward<T>(next).transform(std::forward<G>(g)), std::forward<Ts>(rest)...); 
        });
    }
    template <typename R>
    auto operator()(R&& result){
        return std::forward<R>(result);
    }
};

template <typename... Ts>
using common_error_t = std::common_type_t<typename Ts::error_type...>;

template <typename F, typename... Ts>
requires std::invocable<F, typename Ts::value_type...>
std::expected<std::invoke_result_t<F, typename Ts::value_type...>, common_error_t<Ts...>> transform(F&& f, Ts&&... ts){
    using curry_t = decltype(curry(std::forward<F>(f)));
    std::expected<curry_t, common_error_t<Ts...>> curried = curry(std::forward<F>(f));
    return transformer_t{}(curried, std::forward<Ts>(ts)...);
}
4

Since the error types are same, a pair of and_then with transform will work fine:

return a.and_then([&](auto&& a/*hide the captured one*/){
      return b.transform([&](auto&& b/*hide the captured one*/){
            return func(a,b);
      });// b.transform
});// a.and_then

It works like and operator; if a is invalid, then a.error is returned. The inner transform only replaces value with return of func, and_then needs an operand returning expected with same error type. If there are more than 2 operands, you can chain and_thens till the last one that transforms. The general case - where error types are different - is difficult to handle; the compound expected type needs careful thought:

  • expected<result,variant<error1,error2>>
  • expected<result,common_type_t<error1,error2>>
  • expected<expected<result,error2>,error1>
  • expected<result,error1>
  • expected<result,error2>
  • else?

Each option has its own advantages and downsides. You can check for more monadic functions in the documentation for other cases. if (a.has_value()) should be the last resort, and dereference operator (*a) almost never should be used.

4
  • I don't like the nested (scoped) lambdas. Can with the current standard we achieve something better? Commented Jul 11 at 17:39
  • 4
    As I mentioned the main problem lies within combining the error types. If the error type is fixed, it is possible to define a variadic and_then; but since it lacks generic enough error handling , it is not in the std. If monadic interface is discarded, the case of unique error type may have a simpler solution.
    – Red.Wave
    Commented Jul 11 at 18:05
  • It is astonishing how much of this is so natural in Haskell. If a and b were Eithers and func was the binary function taking the two Right types, you'd write just func <$> a <*> b. And the requested f' could written in point-free style as f' = (<*>) . (f <$>).
    – Enlico
    Commented Jul 12 at 15:34
  • I would also add that the proposed solution also matches the one sketched by the OP as regards the error case, in that it short-circuit, i.e. it returns a.error() even if both a and b are unexpected; Haskell does the same, with reference to the syntax I used in my previous comment.
    – Enlico
    Commented Jul 12 at 15:46
4

While the other answers provide you with a way of getting an std::expected<C, std::string> from compute_all, I think that it is problematic to discard errors other than the first. We can write a type composite_error to hold those:

template<typename E>
struct composite_error
{
    std::vector<E> errors;
    void add(E error) { errors.push_back(std::move(error)); }
    void add(composite_error error) { errors.insert(errors.end(), std::move_iterator(error.errors.begin()), std::move_iterator(error.errors.end())); }
};

Then we can write our magic_apply in terms of composite_error, extracting a common error type.

template<typename F, typename... Ts>
requires expected_invocable<F, Ts...>
std::expected<std::invoke_result_t<F, expected_value_t<Ts>...>, composite_error<common_error_t<Ts...>>> magic_apply(F&& f, Ts&&... args)
{
    if (composite_error<common_error_t<Ts...>> error;
        ((args ? 0 : (error.add(std::forward<Ts>(args).error()), 1)) + ...) > 0){
        return std::unexpected{ std::move(error) };
    }
    return std::forward<F>(f)(std::forward<Ts>(args).value()...);
}

Demo

1

It's been too long since I've studied coroutines, and I've never actually used them in production code, so I can't really provide a working example¹, but coroutines are a way to deal with this.

If I remember correctly, the STL implementations could make std::expected an awaitable (and std::optional as well), which would allow writing code like this:

expected<A, string> getA(const X& x);
expected<B, string> getB(const Y& y);
C compute_all(const A& a, const B& b);

expected<C, string> coro_compute_all(expected<A, string> a, expected<B, string> b) {
    co_return compute_all(co_await a, co_await b);
}

where the coro_compute_all is essentially "lifting" compute_all into the expected monad.

Here's a presentation from Toby Allsopp about coroutines, and here is the part that is precisely about this topic, i.e. using coroutines to implement the optional monad (and hence the expected monad, which is not much more complicated).

There's also a repo with the code.

The above presentation is also referred to in the proposal P2561 by Barry Revzin, which is dated 2023-05-18, barely over a year ago, so you understand that the topic of what the best solution for your usecase is, is actually still a hot topic today.

A bit off topic, maybe, but this rabbit hole brought me to this talk as well, which is absolutily mind blowing.


(¹) Other than a very clunky and surely affected by UB example with an ugly imitation of std::optional here. Please, don't downvote for this, I'm not really a coroutine expert at all, but I wanted to give a rough idea of the amount of code one would need to write.

0

Since std::expected<T,E>::value throws an exception, you could do:

template<typename Func, typename A, typename B, typename Err>
auto magic_apply(Func func, const std::expected<A, Err>& a, const std::expected<B, Err>& b)
->
std::expected<decltype(func(a.value(), b.value())), Err> {
    try {
        return func(a.value(), b.value());
    } catch (std::bad_expected_access<Err>& e) {
        return std::unexpected(std::move(e.error()));
    }
}
1
  • 7
    I think one of the reasons for std::expected was that errors can happen frequently and should not be considered an exception, so hiding throw in this implementation goes against that I think. I have to admit I do not have a better or any solution.
    – Quimby
    Commented Jul 11 at 10:39

Not the answer you're looking for? Browse other questions tagged or ask your own question.