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).