Question

How would std::optional need to be modified in order to make it a monad via coroutines?

There's not really much when searching for "optional", but from the final part of this answer I'd deduce that std::optional can't be made to work with the coroutine below non-intrusively, i.e. without changing std::optional. However, I want to understand the details of this fact.

Here's the coroutine:

template<typename T>
std::optional<T> plus(std::optional<T> a, std::optional<T> b) {
  co_return (co_await a) + (co_await b);
}

By "work with the coroutine" I mean that this should pass:

assert(plus(std::make_optional(3), std::make_optional(4)) == std::make_optional(7));
assert(plus(std::make_optional(3), std::nullopt)          == std::nullopt);
assert(plus(std::nullopt,          std::make_optional(4)) == std::nullopt);

I guess I'm a bit confused by the fact that std::optional plays at the same time several roles

  1. type return type of the coroutine, because it is... the return type of the coroutine
  2. the awaitable, because it is operand to co_await
  3. the container of the value that is passed to co_return (if neither co_await has suspended yet because its operand was std::nullopt)

and I'm not sure which of these roles, if any, implies that std::optional would need to be modified.

Especially 1 and 3 together are most confusing to me, because the coroutine should return the object via std_optional<T> promise_type::get_return_object(), but at that time the returned object is empty (or is it?), but then the co_return, if the code gets to that point, would need to alter that very object, but what would void promise_type::return_value(auto v) do to make that happen?

Below is some attempt to reason about the first part of the question (i.e. "does std::optional need to be modified?") and here is a failed attempt at implementing a std_optional alternative to std::optional.


In the coroutine body, both co_await expression will/won't cause suspension depending on whether their argument is/isn't empty; whether it is empty is determined by the await_ready member function of the awaitable (and the value returned to the coroutine if the optional isn't empty would be available via await_resume of the same awaitable).

struct awaitable {
bool await_ready() const { return o.has_value(); }
auto await_resume() { return o.value(); }
//...
}

Now, despite the operands to co_await are std::optional, that per se doesn't mean std::optional need to be made an awaitable (which would be intrusive, as one would have to open std::optional and define the 3 await_* functions in it), but one can just define a co_await operator with a custom awaitable:

template<typename T>
auto operator co_await(std_optional<T> o) {
    struct awaitable {
        std_optional<T> o;
        bool await_ready() const { return o.has_value(); }
        void await_suspend(std::coroutine_handle<>) {}
        auto await_resume() { return o.value(); }
    };
    return awaitable{o};
}

As far as the two co_awaits expressions in plus are concerned, the promise type too can be provided in a way that is not intrusive to std::optional:

template<typename T, typename ...Args>
struct std::coroutine_traits<std_optional<T>, Args...> {
    struct promise_type {
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std_optional<T> get_return_object() { return {}; }
        void unhandled_exception() { throw; }
        void return_value(auto) {}
        T val;
    };
};

Indeed, with this just in place, we can see that a call like this

auto o = plus(std::make_optional(3), std::make_optional(4))

will result in evaluating (co_await a) + (co_await b) to 7, which is correct, but what does not happen is that that 7 should be made available in the returned std::optional.

My understanding is that putting 7 in the std::optional returned to plus's caller is return_value's job.


(¹) On the other hand this proposal by Barry Revzin incidentally mentions the same thing (but for std::expected), saying that it can be made to work in a fully-conformant way, but I'm not sure if that's implying that no change to std::expected would be needed or what.

 4  129  4
1 Jan 1970

Solution

 0

For a toy example, we'll start with a notstd::optional that allows someone to remotely provide it with state:

namespace notstd {

template<class T>
struct optional {
  std::shared_ptr<std::optional<T>> data;
  optional():data(std::make_shared<std::optional<T>>()) {}
  explicit optional(std::shared_ptr<std::optional<T>> p):data(p) {}

  template<class U> requires std::is_constructible_v<std::optional<T>>
  optional(U u):optional( std::make_shared<std::optional<T>>(std::forward<U>(u))) {}

  explicit operator bool() const { return data && *data; }
  bool has_value() const { return (bool)*this; }

  T const& value() const { return **this; }
  T value() && { return std::move(**this); }
  T& value() & { return **this; }

  optional(optional&&)=default;
  optional(optional const&)=default;
  optional& operator=(optional&&)& =default;
  optional& operator=(optional const&)& =default;

  T& operator*() { return **data; }
};

Next, our promise_type:

template<class T>
struct optional_promise_type {
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    notstd::optional<T> get_return_object() { return val; }
    void unhandled_exception() { throw; }
    void return_value(T t) {
      *val.data = std::move(t);
    }
    notstd::optional<T> val;
};
}

the magic is in return_value, where we end up populating the already-existing get_return_object's state.

The semantics of our notstd::optional don't match std::optional exactly, but fixing that should sound plausible to you (we just need to have a way to get a hook into the pre-existing object's state).

Everything else is sort of pedestrian?

template<typename T, typename ...Args>
struct std::coroutine_traits<notstd::optional<T>, Args...> {
  using promise_type = notstd::optional_promise_type<T>;
};


namespace notstd {
  template<typename T>
  auto operator co_await(optional<T> o) {
    struct awaitable {
        optional<T> o;
        bool await_ready() const { return o.has_value(); }
        void await_suspend(std::coroutine_handle<optional_promise_type<T>> h) {}
        auto await_resume() { return o.value(); }
    };
    return awaitable{o};
}
}

notstd::optional<int> add( notstd::optional<int> lhs, notstd::optional<int> rhs ) {
  co_return co_await lhs + co_await rhs;
}

I'm betting it probably leaks, but you can probably fix that in await_suspend.

int main() {
  auto x = add(3,4);
  auto y = add({}, 4);
  std::cout << (bool)x << (bool)y << "\n";
}

this prints 10 as you'd expect.

Live example.

The flow looks sort of like this.

We call add. The return object is created, then the code starts to run.

When you hit co_await on a notstd::optional, if there is data we extract it immediately and continue. If there isn't data, we suspend with no way to unsuspend. There is probably a way to trigger cleanup here, I'm just not an expert, so I think we leak for now. Maybe just a call to .destroy() on the coroutine handle would do it?

The return value of the promise_type starts off as a nullopt equivalent, so it is correct; once suspended, the calling code has what appears to be an empty optional.

If it doesn't suspend in any co_await, we end up with a co_return. Our co_return expects a T (I could modify it to accept an optional<T>, to allow explicit nullopt return, but I don't want to).

When we hit it, we go off and populate the already returned optional<T> from get_return_object with the value. Then we are done; the coroutine cleans up.

(Invoking destroy() seems correct: live example - but I don't know coroutine lifetime rules well enough to guarantee it.)

2024-07-15
Yakk - Adam Nevraumont