c++c++11c++17copy-elisionnrvo

Why Does Disabling Non-Mandatory Copy Elision Result in Different Behaviors Before and After C++17


I've been experimenting with the following C++ code using different compiler flags and versions, observing differing behaviors in object construction between C++11 and C++17.

I noticed that with the -fno-elide-constructors flag in both C++17 and C++11, NRVO is disabled inside the function f. However, in C++17, the object t1 is created directly at the location of t2, but in C++11, this step wasn't eliminated. I'm wondering why in C++17, despite disabling that flag, this step was still eliminated, considering this is not a case of mandatory copy elision!

Also I checked a lot of sources here and those famous questions and couldn't figure it out why!

Her is also link to the code: https://godbolt.org/z/PYfejba6M

#include <iostream>

struct Thing
{
    Thing() { std::cout << "Constructor\n"; }

    ~Thing() { std::cout << "Destructor\n"; }

    Thing(const Thing&) { std::cout << "Copy Constructor\n"; }

    Thing& operator=(const Thing&) { std::cout << "Copy Assignment Operator\n"; return *this; }

    Thing(Thing&&) noexcept { std::cout << "Move Constructor\n"; }

    Thing& operator=(Thing&&) noexcept { std::cout << "Move Assignment Operator\n"; return *this; }
};

Thing f()
{
    std::cout << "Entering f()\n"; 
    Thing t1;
    std::cout << "Thing t1 constructed in f()\n"; 
    std::cout << "Preparing to return t1 from f()\n";
    return t1;
}

int main()
{
    std::cout << "Entering main()\n"; 
    [[maybe_unused]] Thing t2 = f();
    std::cout << "t2 object returned and assigned in main()\n";
    std::cout << "Exiting main()\n"; 
}

Solution

  • So, first off, when you say "in C++17, the object t1 is created directly at the location of t2", you're not describing the behavior of your code correctly. In your own godbolt link, with -std=c++17 -fno-elide-constructors, you'll note it still shows one Move constructor output, and a paired Destructor output. t1 was not created directly at the location of t2. With -std=c++11 -fno-elide-constructors, you get two Move constructor outputs, so C++17 is eliding one of two moves even with the flag, but not both.

    The reason is that -fno-elide-constructors disables optional copy-elision. NRVO is optional in both standards, so regardless of C++ standard, it does, in fact, cause a single move to occur when you do return t1; in f, moving from the t1 inside the function to the "staging area" for the return value.

    In C++11, this "staging area" is separate from the t2 that is initialized by the function call; the copy-elision from the "staging area" to t2 is non-mandatory (because C++11 lacks mandatory copy-elision) and is disabled by -fno-elide-constructors, so you end up moving twice, once from t1 to staging area, once from staging area to t2.

    By contrast, in C++17 and higher, the copy from "staging area" to t2 is a case where copy-elision is mandatory. So while -fno-elide-constructors disables the non-mandatory copy-elision from t1 to the "staging area", the "staging area" itself is required to be t2 itself (because Thing t2 = f(); requires elision), so only one move occurs, directly from t1 to t2.