The C++23 standard includes several new functions to the std::optional
class: and_then
, transform
, or_else
. These are monadic operations and are intended to simplify the chaining of several operations that may or may not produce a value. In this post, I want to briefly present these functions and to make some observations on them.
A starting example
Let’s take the following example as a starting point for the discussion:
enum pizza_size {small, regular}; struct pizza { pizza_size size; bool has_pepperoni; bool has_basil; bool has_artichokes; }; std::optional<pizza> make_pizza(pizza_size const size) { return pizza{ .size = size}; } std::optional<pizza> add_pepperoni(std::optional<pizza> p) { if (!p->has_pepperoni) p->has_pepperoni = true; return p; } std::optional<pizza> add_basil(std::optional<pizza> p) { if (!p->has_basil) p->has_basil = true; return p; } std::optional<pizza> add_artichokes(std::optional<pizza> p) { if (!p->has_artichokes) p->has_artichokes = true; return p; } double get_price(std::optional<pizza> p) { if (p) { double price = p->size == pizza_size::small ? 10 : 15; if (p->has_pepperoni) price += 2; if (p->has_basil) price += 1; if (p->has_artichokes) price += 3; return price; } return 0; }
What we have here is:
- a function that creates a
pizza
object (make_function
) - several functions that take a
pizza
object and modify it (add_pepperoni
,add_basil
,add_artichokes
) - a function that takes a pizza and returns a double representing the price of the pizza (
get_price
)
Prior to C++23, using these functions would could have looked as follows:
int main() { auto p = make_pizza(pizza_size::regular); if (!p) { std::cout << "Failed to create pizza\n"; return -1; } p = add_pepperoni(p); p = add_basil(p); p = add_artichokes(p); auto cost = get_price(p); std::cout << cost << '\n'; }
In C++23, this can be simplified using monadic operations to chain the calls together, as follows:
int main() { auto p = make_pizza(pizza_size::regular) .or_else([]() -> std::optional<pizza> { std::cout << "Failed to create pizza\n"; return std::nullopt; }) .and_then(add_pepperoni) .and_then(add_basil) .and_then(add_artichokes) .transform(get_price); std::cout << p.value_or(0) << '\n'; }
Let’s look at these operations in the order seen above.
or_else
The or_else function does the following:
- if the
optional
object contains a value, it simply returns theoptional
object without doing anything - otherwise, if the
optional
object is empty, it invokes the supplied callable and returns its return value, that must be itself anoptional
object
If you paid attention to the example above, you have noticed that the lambda passed to or_else
returns an optional
value itself. This differs a bit from what you can find in various articles on the web, including the original proposal by Sy Brand:
Here, examples look as follows:
get_opt().or_else([]{std::cout << "get_opt failed";}); get_opt().or_else([]{throw std::runtime_error("get_opt_failed")});
These examples had this form because the initial implementation was planned as this (from here):
template <class F /*...*/> optional<T> or_else(F &&f) & { if (has_value()) return *this; std::forward<F>(f)(); return nullopt; }
I believe that at some point during the final stages of including this in the standard it was thought that instead of or_else
returning an empty optional
, it could be better to allow the callable to return an alternative value. In many cases this could be std::nullopt
, but in some case it could be an optional
holding some other value (such as a default value).
Therefore, the implementation now has the following form (expositional only):
template <class F /*...*/> optional<T> or_else(F &&f) & { if (has_value()) return *this; else return std::forward<F>(f)(); }
We can expand the previous example with a call to or_else
after each invocation of a transforming function, as shown next:
auto p = make_pizza(pizza_size::regular) .or_else([]() -> std::optional<pizza> { std::cout << "Failed to create pizza\n"; return std::nullopt; }) .and_then(add_pepperoni) .or_else([]() -> std::optional<pizza> { std::cout << "Failed to add pepperoni\n"; return std::nullopt; }) .and_then(add_basil) .or_else([]() -> std::optional<pizza> { std::cout << "Failed to add basil\n"; return std::nullopt; }) .and_then(add_artichokes) .or_else([]() -> std::optional<pizza> { std::cout << "Failed to add pepperoni\n"; return std::nullopt; }) .transform(get_price);
and_then
This is the opposite of or_else
. This function does the following:
- if the
optional
object contains a value, then it invokes the callable argument and returns the value returned by this invocation. This must be a specialization ofstd::optional
, or else the program is ill-formed. - if the
optional
object is empty, it does nothing and returns an empty optional.
In our example, the functions add_pepperoni
, add_basil
, add_artichokes
all take an optional<pizza>
do some transformation on it and return back an optional<pizza>
. This makes it possible to chain them in any given order.
transform
This function does the following:
- if the
optional
object contains a value, then it invokes the callable argument and return anoptional
object containing the value returned by this invocation. - if the
optional
object is empty, it does nothing and returns an emptyoptional
At a first glance, this is very similar to and_then
. However, there is a key difference: the callable passed to and_then
must return a specialization of std::optional
. There is no such requirement for the callable passed to transform. However, transform wraps the result of invoking the callable into an std::optional
, so if the callable passed to transform returns a std::optional<T>
, the result will be a std::optional<std::optional<T>>
.
In the following example, price
is a std::optional<double>
:
std::optional<double> price = make_pizza(pizza_size::regular) .transform(get_price);
But if we change get_price()
to return a std::optional<double>
instead, then price
becomes a std::optional<std::optional<double>>
:
std::optional<double> get_price(std::optional<pizza> p) { if (p) { double price = p->size == pizza_size::small ? 10 : 15; if (p->has_pepperoni) price += 2; if (p->has_basil) price += 1; if (p->has_artichokes) price += 3; return price; } return std::nullopt; } std::optional<std::optional<double>> price = make_pizza(pizza_size::regular) .transform(get_price);
I love monads and I’m not ashamed!