How is moving a const returned object possible?

419 views Asked by At

Lately, I have been reading this post and that post suggesting to stop returning const objects. This suggestion is also given by Stephan T. Lavavej in his talk in Going Native 2013.

I wrote a very simple test to help me understand which constructor/operator is called in all those cases:

  • Returning const or non const objects
  • What if Return Value Optimization (RVO) kicks in ?
  • What if Named Return Value Optimization (NRVO) kicks in ?

Here is the test:

#include <iostream>

void println(const std::string&s){
    try{std::cout<<s<<std::endl;}
    catch(...){}}

class A{
public:
    int m;
    A():m(0){println("    Default Constructor");}
    A(const A&a):m(a.m){println("    Copy Constructor");}
    A(A&&a):m(a.m){println("    Move Constructor");}
    const A&operator=(const A&a){m=a.m;println("    Copy Operator");return*this;}
    const A&operator=(A&&a){m=a.m;println("    Move Operator");return*this;}
    ~A(){println("    Destructor");}
};

A nrvo(){
    A nrvo;
    nrvo.m=17;
    return nrvo;}

const A cnrvo(){
    A nrvo;
    nrvo.m=17;
    return nrvo;}

A rvo(){
    return A();}

const A crvo(){
    return A();}

A sum(const A&l,const A&r){
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    A sum;
    sum.m=l.m+r.m;
    return sum;}

const A csum(const A&l,const A&r){
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    A sum;
    sum.m=l.m+r.m;
    return sum;}

int main(){
    println("build a");A a;a.m=12;
    println("build b");A b;b.m=5;
    println("Constructor nrvo");A anrvo=nrvo();
    println("Constructor cnrvo");A acnrvo=cnrvo();
    println("Constructor rvo");A arvo=rvo();
    println("Constructor crvo");A acrvo=crvo();
    println("Constructor sum");A asum=sum(a,b);
    println("Constructor csum");A acsum=csum(a,b);
    println("Affectation nrvo");a=nrvo();
    println("Affectation cnrvo");a=cnrvo();
    println("Affectation rvo");a=rvo();
    println("Affectation crvo");a=crvo();
    println("Affectation sum");a=sum(a,b);
    println("Affectation csum");a=csum(a,b);
    println("Done");
    return 0;
}

And Here is the output in release mode (with NRVO and RVO):

build a
    Default Constructor
build b
    Default Constructor
Constructor nrvo
    Default Constructor
Constructor cnrvo
    Default Constructor
Constructor rvo
    Default Constructor
Constructor crvo
    Default Constructor
Constructor sum
    Default Constructor
    Move Constructor
    Destructor
Constructor csum
    Default Constructor
    Move Constructor
    Destructor
Affectation nrvo
    Default Constructor
    Move Operator
    Destructor
Affectation cnrvo
    Default Constructor
    Copy Operator
    Destructor
Affectation rvo
    Default Constructor
    Move Operator
    Destructor
Affectation crvo
    Default Constructor
    Copy Operator
    Destructor
Affectation sum
    Copy Constructor
    Move Operator
    Destructor
Affectation csum
    Default Constructor
    Move Constructor
    Destructor
    Copy Operator
    Destructor
Done
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor

What I don't understant is this: why is the move constructor used in the "Constructor csum" test ?

The return object is const so I really feel like it should call the copy constructor.

What am I missing here ?

It should not be a bug from the compiler, both Visual Studio and clang give the same output.

4

There are 4 answers

0
David Rodríguez - dribeas On BEST ANSWER

What I don't understand is this: why is the move constructor used in the "Constructor csum" test ?

In this particular case the compiler is allowed to do [N]RVO, but it did not do it. The second best thing is to move-construct the returned object.

The return object is const so I really feel like it should call the copy constructor.

That does not matter at all. But I guess that is not completely obvious, so lets walk through what it conceptually mean to return a value, and what [N]RVO is. For that, the simplest approach is to ignore the returned object:

T f() {
   T obj;
   return obj;   // [1] Alternatively: return T();
}
void g() {
   f();          // ignore the value
}

This in the line marked as [1] there is a copy from the local/temporary object to the returned value. Even if the value is completely ignored. That is what you are exercising in the code above.

If you don't ignore the returned value, as in:

T t = f();

there is conceptually a second copy from the returned value to the t local variable. That second copy is being elided in all of your cases.

For the first copy, whether the object being returned is const or not does not matter, the compiler determines what to do based on the arguments to the [conceptual copy/move] constructor, not whether the object being constructed will be const or not. This is the same as:

// a is convertible to T somehow
const T ct(a);
T t(a);

Whether the destination object is const or not does not matter, the compiler needs to find the best constructor based on the arguments, not the destination.

Now if we take that back to your exercise, to make sure that the copy constructor is not called, you need to modify the argument to the return statement:

A force_copy(const A&l,const A&r){ // A need not be `const`
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    const A sum;
    return sum;
}

That should trigger copy construction, but then again it is simple enough that the compiler may elide the copy altogether if it finds it fit.

0
Simple On

The answer is that your A sum local variable is being moved into the const A returned by the function (this is the Move Constructor output) and then the copy from the returned value into A acsum is being elided by the compiler (so there is no Copy Constructor output).

0
Xin On

I disassembled the compiled binary, (VC12 release build, O2) and my conclusion is:

The move operation is to move result inside csum(a,b) before return to a stack-allocated const A temporary object, to be used as parameter for later A& operator=(const A&).

The move operation cannot move cv-qualified variable, but prior return from csum, the sum variable is still a non-const variable, so can be moved; and need to be moved for later use after return.

The const modifier just forbid compiler to move after return, but doesn't forbid move inside csum. If you remove the const from csum, the result would be:

Default Constructor
Move Constructor
Destructor
Move Operator
Destructor

BTW, your test program has a bug that will render a = sum(a, b); incorrect, the default ctor of A should be:

A() : m(3) { println("    Default Constructor"); }

Or you will find your given output difficult to explain for a = sum(a, b);


Below I'll try to analyze debug build ASM. The result is same. (Analyze release build is like suicide >_< )

main:

  a = csum(a, b);
00F66C95  lea         eax,[b]  
00F66C98  push        eax                           ;; param b
00F66C99  lea         ecx,[a]  
00F66C9C  push        ecx                           ;; param a
00F66C9D  lea         edx,[ebp-18Ch]  
00F66CA3  push        edx                           ;; alloc stack space for return value
00F66CA4  call        csum (0F610DCh)  
00F66CA9  add         esp,0Ch  
00F66CAC  mov         dword ptr [ebp-194h],eax  
00F66CB2  mov         eax,dword ptr [ebp-194h]  
00F66CB8  mov         dword ptr [ebp-198h],eax  
00F66CBE  mov         byte ptr [ebp-4],5  
00F66CC2  mov         ecx,dword ptr [ebp-198h]  
00F66CC8  push        ecx  
00F66CC9  lea         ecx,[a]  
00F66CCC  call        A::operator= (0F61136h)       ;; assign to var a in main()
00F66CD1  mov         byte ptr [ebp-4],3  
00F66CD5  lea         ecx,[ebp-18Ch]  
00F66CDB  call        A::~A (0F612A8h) 

csum:

  if (l.m == 0) {
00F665AA  mov         eax,dword ptr [l]  
00F665AD  cmp         dword ptr [eax],0  
00F665B0  jne         csum+79h (0F665D9h)  
    return r;
00F665B2  mov         eax,dword ptr [r]  
00F665B5  push        eax                            ;; r pushed as param for \
00F665B6  mov         ecx,dword ptr [ebp+8]  
00F665B9  call        A::A (0F613F2h)                ;; copy ctor of A
00F665BE  mov         dword ptr [ebp-4],0  
00F665C5  mov         ecx,dword ptr [ebp-0E4h]  
00F665CB  or          ecx,1  
00F665CE  mov         dword ptr [ebp-0E4h],ecx  
00F665D4  mov         eax,dword ptr [ebp+8]  
00F665D7  jmp         csum+0EEh (0F6664Eh)  
  }
  if (r.m == 0) {
00F665D9  mov         eax,dword ptr [r]  
00F665DC  cmp         dword ptr [eax],0  
00F665DF  jne         csum+0A8h (0F66608h)  
    return l;
00F665E1  mov         eax,dword ptr [l]  
00F665E4  push        eax                             ;; l pushed as param for \
00F665E5  mov         ecx,dword ptr [ebp+8]  
00F665E8  call        A::A (0F613F2h)                 ;; copy ctor of A
00F665ED  mov         dword ptr [ebp-4],0  
00F665F4  mov         ecx,dword ptr [ebp-0E4h]  
00F665FA  or          ecx,1  
00F665FD  mov         dword ptr [ebp-0E4h],ecx  
00F66603  mov         eax,dword ptr [ebp+8]  
00F66606  jmp         csum+0EEh (0F6664Eh)  
  }
  A sum;
00F66608  lea         ecx,[sum]  
  A sum;
00F6660B  call        A::A (0F61244h)                  ;; ctor of result sum
00F66610  mov         dword ptr [ebp-4],1  
  sum.m = l.m + r.m;
00F66617  mov         eax,dword ptr [l]  
00F6661A  mov         ecx,dword ptr [eax]  
00F6661C  mov         edx,dword ptr [r]  
00F6661F  add         ecx,dword ptr [edx]  
00F66621  mov         dword ptr [sum],ecx  
  return sum;
00F66624  lea         eax,[sum]  
00F66627  push        eax                              ;; sum pushed as param for \
00F66628  mov         ecx,dword ptr [ebp+8]  
00F6662B  call        A::A (0F610D2h)                  ;; move ctor of A (this one is pushed in main as a temp variable on stack)
00F66630  mov         ecx,dword ptr [ebp-0E4h]  
00F66636  or          ecx,1  
00F66639  mov         dword ptr [ebp-0E4h],ecx  
00F6663F  mov         byte ptr [ebp-4],0  
00F66643  lea         ecx,[sum]  
00F66646  call        A::~A (0F612A8h)                 ;; dtor of sum
00F6664B  mov         eax,dword ptr [ebp+8]  
}
0
AudioBubble On

From what I've observed, the move constructor takes precedence over the copy constructor. As Yakk says, you cannot elide the move constructor because of the multiple return paths.

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm#Copy%20vs%20Move

rvalues will prefer rvalue references. lvalues will prefer lvalue references. CV qualification conversions are considered secondary relative to r/l-value conversions. rvalues can still bind to a const lvalue reference (const A&), but only if there is not a more attractive rvalue reference in the overload set. lvalues can bind to an rvalue reference, but will prefer an lvalue reference if it exists in the overload set. The rule that a more cv-qualified object can not bind to a less cv-qualified reference stands ... both for lvalue and rvalue references.

A further language refinement can be made at this point. When returning a non-cv-qualified object with automatic storage from a function, there should be an implicit cast to rvalue:

string
operator+(const string& x, const string& y)
{
    string result;
    result.reserve(x.size() + y.size());
    result = x;
    result += y;
    return result;  // as if return static_cast<string&&>(result);
}

The logic resulting from this implicit cast results in an automatic hierarchy of "move semantics" from best to worst:

If you can elide the move/copy, do so (by present language rules)
Else if there is a move constructor, use it
Else if there is a copy constructor, use it
Else the program is ill formed

So what if you remove the const & in the parameters? It will still call the move constructor, but will call the copy constructor for the parameters. What if you return a const object instead? It will call the copy constructor for the local variable. What if you return a const &? It will also call the copy constructor.