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();
});
}
4 Answers 4
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).
7 Comments
? 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.optional/expected instead of just putting it on the stack and taking a reference to it.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.
2 Comments
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.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);
}
4 Comments
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::multiplies{} in place of your lambdaAnother 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);
}
Errorin your explanation, it might actually be reasonable to justtry { return foo().value() * bar.value(); } catch (std::bad_optional_access) { return std::nullopt; }. The performance of parsing invalid input rarely matters.