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();
});
}
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).
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With