4

My question originates from delving into std::move in return statements, such as in the following example:

struct A
{
    A() { std::cout << "Constructed " << this << std::endl; }
    A(A&&) noexcept { std::cout << "Moved " << this << std::endl; }
 };

A nrvo()
{
    A local;
    return local;
}

A no_nrvo()
{
    A local;
    return std::move(local);
}

int main()
{
    A a1(nrvo());
    A a2(no_nrvo());
}

which prints (MSVC, /std:c++17, release)

Constructed 0000000C0BD4F990
Constructed 0000000C0BD4F991
Moved 0000000C0BD4F992

I am interested in the general initialization rules for return statements in functions that return by-value and which rules apply when returning a local variable with std::move as shown above.

The general case

Regarding return statements you can read

  1. Evaluates the expression, terminates the current function and returns the result of the expression to the caller after implicit conversion to the function return type. [...]

on cppreference.com.

Amongst others Copy initialization happens

  1. when returning from a function that returns by value like so
 return other;

Coming back to my example, according to my current knowledge - and in contrast to the above-named rule - A a1(nrvo()); is a statement that direct-initializes a1 with the prvalue nrvo(). So which object exactly is copy-initialized as described at cppreference.com for return statements?

The std::move case

For this case, I've referred to ipc's answer on Are returned locals automatically xvalues. I want to make sure that the following is correct: std::move(local) has the type A&& but no_nrvo() is declared to return the type A, so here the

returns the result of the expression to the caller after implicit conversion to the function return type

part should come into play. I think this should be an Lvalue to rvalue conversion:

A glvalue of any non-function, non-array type T can be implicitly converted to a prvalue of the same type. [...] For a class type, this conversion [...] converts the glvalue to a prvalue whose result object is copy-initialized by the glvalue.

To convert from A&& to A A's move constructor is used, which is also why NRVO is disabled here. Are those the rules that apply in this case, and did I understand them correctly? Also, again they say copy-initialized by the glvalue but A a2(no_nrvo()); is a direct initialization. So this also touches on the first case.

Ruperrrt
  • 489
  • 2
  • 13
  • "which is also why NRVO is disabled here." I think you are overthinking this part. NRVO doesn't apply simply because you return a temporary: the result of `std::move()`, which is a "normal" function. It doesn't have a name, so **N**RVO is out. –  Aug 22 '21 at 17:38
  • 1
    _"...In a return statement, when the operand is __the name of a non-volatile object__ with automatic storage duration, which isn't a function parameter or a catch clause parameter, and which is of the same class type (ignoring cv-qualification) as the function return type. This variant of copy elision is known as NRVO, "named return value optimization"..."_ _"...Return value optimization is mandatory and no longer considered as copy elision since C++17..."_ https://en.cppreference.com/w/cpp/language/copy_elision – Richard Critten Aug 22 '21 at 17:52
  • @DanielLangr Granted, the result of std::move is indeed not a temporary object in its own right. Consider my use of "temporary" as a simplification, but the general principal remains. It's not a name, so NRVO is a no-go. –  Aug 22 '21 at 18:05
  • @Frank I understood what you meant the moment I read your answer. Deleted my comment. Just wanted to clarify that there is no temporary of type `A` created. – Daniel Langr Aug 22 '21 at 18:07
  • @DanielLangr It was a fair point. This is a nitty-gritty nomenclature question, so the comments/answers should be precise. –  Aug 22 '21 at 18:08
  • The compiler knows it's the last line of the function and so explicit move isn't req'd to move. – QuentinUK Aug 22 '21 at 18:22
  • Thank you for your comments! I should have noticed that std::move(local) is NOT a name, which automatically means **N**RVO cannot apply. The only confusing point for me, from a terminology perspective, is if the same memory location can be copy-initialized and direct-initialized at the same time depending on the perspective. Maybe I've understood these terms wrong. – Ruperrrt Aug 22 '21 at 22:33

1 Answers1

2

You have to be careful with cppreference.com when diving into such nitty-gritty, as it's not an authoritative source.

So which object exactly is copy-initialized as described at cppreference.com for return statements?

In this case, none. That's what copy elision is: The copy that would normally happen is skipped. The cppreference (4) clause could be written as "when returning from a function that returns by value, and the copy is not elided", but that's kind of redundant. The standard: [stmt.return] is a lot clearer on the subject.

To convert from A&& to A A's move constructor is used, which is also why NRVO is disabled here. Are those the rules that apply in this case, and did I understand them correctly?

That's not quite right. NRVO only applies to names of non-volatile objects. However, in return std::move(local);, it's not local that is being returned, it's the A&& that is the result of the call to std::move(). This has no name, thus mandatory NRVO does not apply.

I think this should be an Lvalue to rvalue conversion:

The A&& returned by std::move() is decidedly not an Lvalue. It's an xvalue, and thus an rvalue already. There is no Lvalue to rvalue conversion happening here.

but A a2(no_nrvo()); is a direct initialization. So this also touches on the first case.

Not really. Whether a function has to perform copy-initialization of its result as part of a return statement is not impacted in any way by how that function is invoked. Similarly, how a function's return argument is used at the callsite is not impacted by the function's definition.

In both cases, an is direct-initialized by the result of the function. In practice, this means that the compiler will use the same memory location for the an object as for the return value of the function.

In A a1(nrvo());, thanks to NRVO, the memory location assigned to local is the same as the function's result value, which happens to be a1 already. Effectively, local and a1 were the same object all along.

In A a2(no_nrvo()), local has its own storage, and the result of the function, aka a2 is move-constructed from it. Effectively, local is moved into a2.

  • Thank you for your answer! I understand the behaviour you've described in your last two paragraphs and your clarifications regarding NRVO and Lvalue to Rvalue conversion. What remains unclear is the precise meaning of copy and direct initialization here. Did I understand you correctly when I say from the function's perspective the return object is copy-initialized (which can also mean by a move), and from the callers perspective a2 is direct-initialized by the prvalue returned by the function? So the perspective decides about what term to use? – Ruperrrt Aug 22 '21 at 22:22