Notes on std::optional’s monadic operations

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 the optional 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 an optional 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 of std::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 an optional object containing the value returned by this invocation.
  • if the optional object is empty, it does nothing and returns an empty optional

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

See also

1 Reply to “Notes on std::optional’s monadic operations”

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.