c++movecopy-constructorcopy-elisionnrvo

Shouldn't there be a copy ctor invocation here? Elision disabled (no named return value optimization)


struct Test {
    int field = 30;
    Test() { cout << "In ctor" << endl; }
    Test(const Test &other) { field = other.field; cout << "In copy ctor" << endl; }
    Test(Test &&other) { field = other.field; cout << "In move ctor" << endl; }
    Test &operator=(const Test &other) { field = other.field; cout << "In copy assignment" << endl; return *this; }
    Test &operator=(Test &&other) { field = other.field; cout << "In move assignment" << endl; return *this; }
    ~Test() { cout << "In dtor" << endl; }
};

Test get_test() {
    Test t;
    return t;
}

int main() {
    Test t2 = get_test();
}

I think this is the canonical NRVO example. I'm compiling with -fno-elide-constructors and I see that the following are called: ctor, move ctor, dtor, dtor.

So the first ctor call corresponds to the line Test t;, the move ctor is constructing the t2 in main, then the temporary that is returned from get_test is destroyed, then the t2 in main is destroyed.

What I don't understand is: shouldn't there be a copy ctor invocation when returning by value? That is, I thought that get_test should be making a copy of t and then this copy is moved into t2. It seems like t is moved into t2 right away.


Solution

  • C++17

    Starting from C++17, there is mandatory copy elison which says:

    Under the following circumstances, the compilers are required to omit the copy and move construction of class objects, even if the copy/move constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would otherwise be copied/moved to. The copy/move constructors need not be present or accessible:

    • In the initialization of an object, when the initializer expression is a prvalue of the same class type (ignoring cv-qualification) as the variable type.
    • Initializing the returned object in a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type:
    T f()
    {
       return U(); // constructs a temporary of type U,
                   // then initializes the returned T from the temporary
    }
    T g()
    {
       return T(); // constructs the returned T directly; no move
    }
    

    (emphasis mine)

    This means that t2 is constructed directly from the prvalue that get_test returns. And since a prvalue is used to construct t2, the move constructor is used. Note that in C++17, the flag -fno-elide-constructors have no effect on return value optimization(RVO) and is different from NRVO.


    Pre-C++17

    But prior to C++17, there was non-mandatory copy elison and since you've provided the -fno-elide-constructors flag, a temporary prvalue is returned by get_test using the move constructor. So you see the first call to the move ctor. Then, that temporary is used to initialize t2 again using the move constructor and hence we get the second call to the move ctor.