seastar icon indicating copy to clipboard operation
seastar copied to clipboard

Make `.then(e)` preserve the value category of `e`?

Open kbr- opened this issue 3 years ago • 6 comments

This code does not compile:

auto f = std::bind_front([] (std::unique_ptr<int>) { return make_ready_future<>(); }, std::make_unique<int>(0));
(void)make_ready_future<>().then(std::move(f));

because then(std::move(f)) tries to call f(), and the expression f() is ill-formed; it tries to copy unique_ptr from the bind_front object into the lambda parameter, but unique_ptr does not have a copy constructor.

Instead, then should preserve the value category of the passed-in function object when calling it. In our example, it should do the equivalent of std::move(f)() (note: std::move(f)() causes the unique_ptr to be moved).

kbr- avatar Jan 21 '22 13:01 kbr-

"preserve the value category" means future::then should behave differently depending on the value category of its parameter (i.e. also remember if it was passed an ordinary reference). I think that's too much complication for an already complicated interface, also one that is being discouraged in most places in favor of coroutines.

I agree your example should compile. I think we can unconditionally call Func::operator()() &&. The func object is owned by future::then, and won't be used again, so it's fine to std::move() it before calling its operator()().

avikivity avatar Jan 22 '22 18:01 avikivity

@avikivity

"preserve the value category" means future::then should behave differently depending on the value category of its parameter (i.e. also remember if it was passed an ordinary reference). I think that's too much complication for an already complicated interface (...).

In my opinion, it's the opposite. Changing the value category is what introduces complexity. Indeed, if we preserve the value category, then would call the function as if the user of then called it directly. So if the user decides to use std::move(f), then does the same. If the user wants f, then does the same. I think right now the behavior of then is surprising, it does not preserve the intention of the user of then.

The func object is owned by future::then, and won't be used again, so it's fine to std::move() it before calling its operator()().

I don't think it's true. There's nothing that prevents a function object from being used after then, or even suggests that it shouldn't be used. Indeed, then takes a forwarding reference, so it's possible to pass both lvalues and rvalues. If we wanted, we could restrict the interface to take rvalue references only, but we didn't. And suppose I have a stateless function object, with pure operator(). Why should it be illegal to use it in multiple then calls? Consider:

future<> test() {
    struct func {
        future<> operator()() {
            return make_ready_future<>();
        }
    };

    func f{};

    return make_ready_future<>().then(f).then(std::move(f));
}

It should indeed be illegal to use f again after the last then because I passed an rvalue-reference to it to then. But I don't see a reason why it would be illegal to use it after the first then (btw. please ignore the use-after-free, I could fix the example but let's keep it simple).

Alternative approach would be to restrict then to take rvalue references only. That would be consistent with co_await, because there's no way to reuse the continuation after co_await.

kbr- avatar Jul 28 '22 13:07 kbr-

I think restricting to rvalues makes the most sense (and by restricting, I mean having a deprecation period where lvalues still "work", but emit a warning).

By far the most common usage of future::then() is to pass rvalues. I think it's wrong to elaborate the API to handle the very uncommon lvalue use case. If someone wants to pass lvalues, that's absolutely okay, but they can annotate them with std::ref() as a way of saying "I understand that I am now responsible for the lifetime of this object".

avikivity avatar Jul 28 '22 14:07 avikivity

Should we start preserving the value category during the deprecation period? Or should we instantly switch to using std::move everywhere - and always call the rvalue-reference version of operator() if there is one?

kbr- avatar Jul 28 '22 14:07 kbr-

We should keep doing what we do now to avoid breaking code.

avikivity avatar Jul 28 '22 14:07 avikivity

About which operator() should we call - I don't have an intuition. I suppose calling operator()(...) && will decay to operator()(...) & or operator()(...) const & if just those are provided, so it won't break anything.

avikivity avatar Jul 28 '22 14:07 avikivity