4

I want help writing readable code for composing the monadic callbacks of c++23. What I'm finding is that, while the code I'm producing tends to be more correct, scenarios where I need to merge the results of two computations produce code that is significantly less readable than the standard way of doing things.

Take an example with optional functions with the constraint that foo() must be called before bar(): as a motivating example assume we are writing a parser and both foo and bar consume some token from our list of tokens to parse.

std::optional<int, Error> foo();
std::optional<int, Error> bar();

And multiply their results if they exist or propagate a nullopt otherwise

"The usual way" of doing this

std::optional<int> baz() {
 std::optional<int> maybe_foo = foo();
 if(!maybe_foo.has_value()) {
 return std::nullopt;
 }
 std::optional<int> maybe_bar = bar();
 if(!maybe_bar.has_value()) {
 return std::nullopt;
 }
 return maybe_foo.value() * maybe_bar.value();
}

Monadic approach:

std::optional<int> baz() {
 return foo().and_then([](int foo_res) {
 return bar().and_then([foo_res](int bar_res) {
 return foo_res * bar_res;
 });
 });
}

And this nesting really troubles me. In more complicated computations, I'm finding it gets worse still, where this growing pyramid of logic shoots out from my functions, as we are never able to short circuit our logic.

What am I doing wrong?

As a more demonstrative example, below is a function from a parser I'm writing that I consider particularly unreadable. The functionality of the function is less important than the pyramid of callbacks described above...

template <Generator<TokenOrError> Gen>
Parser<Gen>::maybe_expression Parser<Gen>::assignment() {
 // Given an arbitrary expression, return the VariableExpression contained within
 // if one exists, otherwise return a nullopt
 auto try_extract_variable = [](grammar::Expression expr) 
 -> std::optional<grammar::VariableExpression> {
 return try_get<grammar::PrimaryExpression>(std::move(expr))
 .and_then([](grammar::PrimaryExpression primary_expr) -> std::optional<grammar::VariableExpression> {
 return try_get<grammar::VariableExpression>(std::move(primary_expr.data));
 });
 };
 return equality()
 .and_then([&](std::unique_ptr<grammar::Expression> expr) {
 // If the top token after parsing Equality() is an =, we either return an
 // assignment expression or an error. Otherwise, we directly return the Equality() expression
 return consume<token::Equal>()
 .transform([&](const token::Equal &equal) {
 // We are parsing an assignment expression, and so we would like to extract the 
 // Variable that we are to assign, otherwise return an error.
 return try_extract_variable(std::move(*expr))
 .transform([&](const grammar::VariableExpression &variable) -> maybe_expression {
 return expression()
 .map([&](std::unique_ptr<grammar::Expression> assign_to) {
 return std::make_unique<grammar::Expression>(grammar::AssignmentExpression{
 variable, std::move(assign_to), variable.line_number
 });
 });
 })
 .value_or(tl::unexpected{Error{equal.line_number,
 ErrorType::kBadAssign, 
 fmt::format("Incomplete assignment expression") 
 }});
 })
 .or_else([&] -> std::optional<maybe_expression> {
 return std::move(expr);
 })
 .value();
 });
}
Barry
311k32 gold badges732 silver badges1.1k bronze badges
asked Dec 28, 2024 at 17:08
1
  • Consider Error in your explanation, it might actually be reasonable to just try { return foo().value() * bar.value(); } catch (std::bad_optional_access) { return std::nullopt; }. The performance of parsing invalid input rarely matters. Commented Jan 3 at 15:44

4 Answers 4

2

This is basically the worst case for the monadic operations. You need to call two operations, sequentially, and then use their results together.

I think the best you can do right now is a macro. That is, take this:

std::optional<int> baz() {
 std::optional<int> maybe_foo = foo();
 if(!maybe_foo.has_value()) {
 return std::nullopt;
 }
 std::optional<int> maybe_bar = bar();
 if(!maybe_bar.has_value()) {
 return std::nullopt;
 }
 return maybe_foo.value() * maybe_bar.value();
}

And introduce a macro that assigns-or-returns. Usage would be:

std::optional<int> baz() {
 ASSIGN_OR_RETURN(int f, foo());
 ASSIGN_OR_RETURN(int b, bar());
 return f * b;
}

It's not a difficult macro to implement, but as you can see it's a pretty significant improvement in readability (and reduction in typo-related errors, e.g. you check the wrong optional by accident).

answered Jan 1 at 0:06
Sign up to request clarification or add additional context in comments.

7 Comments

Are there functional languages that have figured this sort of thing out in a better way?
Additionally, have you seen macros like this in modern cpp codebases? I agree that it is effective, but I'm dubious that a PR I put up with code like this would actually get merged into production
@Thornsider3 Rust has ? for this (usage would just be foo()? * bar()?). Haskell would use do notation (<-). Otherwise yeah, I've seen the macro used plenty. There's a lot of reasons to dislike macros, but they certainly have their uses.
There's got to be a non-macro solution.
@Thornsider3 Sure, you can do that. It has the downsides that statement-expressions don't have move semantics implemented and also that you must move out of the optional/expected instead of just putting it on the stack and taking a reference to it.
|
2

My comments on another answer have grown, so I think they should move somewhere more permanent.


For your simple cases, where bar doesn't rely on the result of foo, you can write a variadic version of transform for optionals, in the same way that std::visit is variadic. Similarly and_then.

template <typename F, typename... Ts>
inline auto transform(F&& fn, Ts&&... opts)
 -> std::optional<std::invoke_result_t<F, decltype(*opts)...>> {
 if ((... and opts)) {
 return fn(*std::forward<Ts>(opts)...);
 }
 return std::nullopt;
}
template <typename F, typename... Ts>
inline auto and_then(F&& fn, Ts&&... opts)
 -> std::invoke_result_t<F, decltype(*opts)...> {
 if ((... and opts)) {
 return fn(*std::forward<Ts>(opts)...);
 }
 return std::nullopt;
}

If you have function that you want to short circuit, you can wrap them in a lambda to delay invocation.

template <typename F>
struct lazy {
 F f{};
 std::invoke_result_t<F> opt{};
 
 constexpr explicit(true) operator bool() {
 opt = std::invoke(f);
 return opt.has_value();
 }
 
 template <typename Self>
 constexpr auto operator*(this Self&& self) {
 return *(std::forward<Self>(self).opt);
 }
};
template <typename F, typename... Args>
auto lazy_call(F&& f, Args&&... args) {
 return lazy([&]{ return std::invoke(std::forward<F>(f), std::forward<Args>(args)...); });
}

However this doesn't really help when the call to bar depends on the result of foo, which is the case in some of your grammar calls. You could use a library like coroutine-monad which simulates do-notation with co_await.

See it on coliru

answered Jan 3 at 14:34

2 Comments

It is solvable - I mean the case when following "calls" depends on results of previous "calls". We just need to evaluate argument one by one and pack results to the tuple. With each step of evaluating - with C++20 concepts - can check if next "value" is an optional or functor that requires (or not) arguments and if we have these values already unpacked - then use it. Then this is possible: std::optional<int> foo(); std::optional<int> bar(int); return transform(foo(), [](auto foo) { return bar(foo); }, std::multiplication<>{});. This is just too long for SO answer.
@PiotrNycz You can write that for specific cases, but in general it's ambiguous. I'd much prefer something like do-notation.
2

An alternate solution to the posted one (though significantly less ergonomic, it reduces the nesting)

template <typename... Ts>
inline auto transform_all(auto &&fn, std::optional<Ts> &&...optionals)
 -> std::optional<std::invoke_result_t<decltype(fn), Ts...>> {
 bool is_error = (!optionals.has_value() || ...);
 if (is_error) {
 return std::nullopt;
 }
 return fn(std::forward<decltype(optionals)>(optionals).value()...);
}

Now our snippet becomes:

std::optional<int> baz() {
 auto maybe_foo = foo();
 auto maybe_bar = bar();
 return transform_all(std::multiplies<int>{}, maybe_foo, maybe_bar);
}
answered Jan 1 at 2:01

4 Comments

The drawback of this version is that it calls bar even if maybe_foo is empty, which is bad if bar is expensive. What about rewriting transform_all to accept functions to call instead of their results?
std::optional<Ts> && doesn't deduce nicely, you probably want something like this
You can also use std::multiplies{} in place of your lambda
@KrzysiekKarbowiak you can write a lazy call wrapper, as described here, e.g. like this
2

Another solution is to translate N optionals into one optional of N values tuple:

template <typename ...T>
std::optional<std::tuple<T...>> when_all(std::optional<T>... a)
{
 if (!(a.has_value() && ...)) return std::nullopt;
 return std::make_tuple(*a...);
}
std::optional<int> baz() 
{
 return when_all(foo(), bar())
 .transform([](auto arg) {
 return std::get<0>(arg) * std::get<1>(arg);
 });
}

For more sophisticated cases - when we want to diagnose which exactly values were not present - we could use std::expected:

template <typename ...T>
std::expected<std::tuple<T...>, std::bitset<sizeof...(T)>>
when_all(std::optional<T> ...a)
{
 const char presence[]{ (a ? '1' : '0')...};
 const std::bitset<sizeof...(T)> err{presence, sizeof...(T)};
 if (err.all()) return std::make_tuple(*a...);
 return std::unexpected(err);
}
answered Jan 3 at 10:47

Comments

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.