Does NRVO also apply to coroutines?

Viewed 340

In the following example, NRVO (Named Return Value Optimization) applies as per this article:

std::string f1()
{
    std::string str;

    return str; // NVRO applies here!
}

However, consider:

task<std::string> f2()
{
    std::string str;

    co_return str; // Does NVRO also apply here?
}
4 Answers

I know that NRVO (Named Return Value Optimization) is mandatory since C++17:

It is not. NRVO is still an optimisation.

The non-named Return Value Optimisation (RVO) is mandatory.

// Is NVRO also guaranteed here?

No, because NRVO is never guaranteed.

For completeness, guaranteed elision in C++17 only applies to returning a prvalue from a function directly. Returning a named variable is only subject to elision if the compiler feels like it.

As for the meat of your question, co_return values are never subject to copy elision, guaranteed or otherwise. Elision for return values keys off of the return keyword, and coroutines aren't allowed to use return. They use co_return, which the elision logic in the standard doesn't key off of. So elision does not apply.

The reason why this was done is because of how coroutines work. A coroutine is a function that has a promise object in it. This promise object is how you shepard the coroutine's co_return value (and other state) to the "future" object that the coroutine function returned.

Elision works in normal functions because calling conventions require the caller to pass the storage for a return value to the function. So the function's implementation can choose to just build the object in that storage rather than building a separate stack object and copying into it upon return.

In a coroutine, the return value lives inside the promise, so that can't really happen.

NRVO as defined by the article you linked (i.e. not even creating a temporary) isn't a thing for coroutines because how co_return works is up to the user-provided coroutine promise type: the expression in the co_return statement is fed to the promise's return_value method, which can decide what to do with it.

However there is a related optimization that still may be useful. [class.copy.elision]/3 says the following:

An implicitly movable entity is a variable of automatic storage duration that is either a non-volatile object or an rvalue reference to a non-volatile object type. In the following copy-initialization contexts, a move operation is first considered before attempting a copy operation:

  • If the expression in a return ([stmt.return]) or co_­return ([stmt.return.coroutine]) statement is a (possibly parenthesized) id-expression that names an implicitly movable entity declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression, or
  • [...]

overload resolution to select the constructor for the copy or the return_­value overload to call is first performed as if the expression or operand were an rvalue. If the first overload resolution fails or was not performed, overload resolution is performed again, considering the expression or operand as an lvalue.

This means that if you return a local variable by name from a coroutine it will be moved, not copied (as long as the promise type supports this). For example, clang accepts the following despite the fact that it's not possible to copy a std::unique_ptr<int>:

// Assume a coroutine task type called Task<T> whose associated promise has a
// return_value(T) method. The co_return here will successfully call that
// method.
Task<std::unique_ptr<int>> MakeInt() {
  auto result = std::make_unique<int>(17);
  co_return result;
}

So the optimization "value is fed to coroutine promise as an rvalue reference even though std::move wasn't used" does apply. But the standard doesn't say "move constructor isn't even called", and it can't because it's up to the promise what to do with the expression it's given.

Related