Tech Blog Menu

Tech Blog

Interaction between Move Semantic and Copy Elision in C++

By Kevin Lalumiere —

C++11 introduced move semantic, which allows, as its name suggests, to move objects instead of copying them. The move process typically involves copying pointers to some resources, and then setting the original pointers to nullptr, so they cannot be used to access the resources anymore. Of course, all of this is done transparently to the user of the class.

With this power comes a complexity, which is enhanced when it is coupled with copy elision. Copy elision is the general process where, when returned from a function, an object is not copied nor moved, resulting in zero-copy pass-by-value semantics. It includes both return value optimization (RVO) and named return value optimization (NRVO).

While the links on cppreference provided above probably contain all the information you might want to know about the interaction of move semantic with copy elision, they can be a bit of an arid reading. The goal of this post is to give a quick and incomplete introduction on the subject. This introduction is then used to present an interesting difference between the g++ and clang++ 3.8 compilers.

Returning a local variable

Consider the following code:

class Verbose {
public:
    Verbose() { std::cerr << "Constructed\n"; }
    Verbose(const Verbose&) { std::cerr << "Copied\n"; }
    Verbose(Verbose&&) { std::cerr << "Moved\n"; }
};

Verbose create() {
    Verbose result;
    return result;
}

int main() {
    Verbose v = create();
    return 0;
}

When compiled using the clang++ -std=c++14 -fno-elide-constructors move.cpp command, this code prints:

Constructed
Moved
Moved

The result object is Constructed, and then it is Moved. At first, it is moved from the create to the main function, and then from the right hand side to the left hand side of Verbose v = create();. This happens because the result variable is eligible for copy elision, and in C++11 or more, a variable eligible for copy elision will be moved if the optimization doesn’t take place (see return statement).

Notice that the compilation option -fno-elide-constructors was used. If it’s not used, the output is:

Constructed

The object is constructed in place and it is never copied or moved; this is copy elision in action. Notice that, in C++14, it is an optimization (so it might not happen), but in C++17, it is required by the standard.

Consider adding a std::move() on the return line in create:

Verbose create() {
    Verbose result;
    return std::move(result);
}

When compiled with clang++ -std=c++14 move.cpp, i.e., without -fno-elide-constructors, we observed:

Constructed
Moved

Copy elision does not take place since NRVO can only happen when the expression on the return line is the name of a non-volatile object with automatic storage duration (see Copy elision). More precisely, the wording on cppreference, which is similar to the wording in the standard’s draft, suggests that NRVO can only take place with an lvalue and RVO with a prvalue, while std::move(result) is an xvalue.

Compilers with different perspectives

Let’s now discuss a confusing difference between g++ and clang++ 3.8. Consider:

class VerboseChild: public Verbose {
public:
    void functionOnlyOnChild() {}
};

std::unique_ptr<Verbose> createPointer() {
    auto pointer = std::make_unique<VerboseChild>();
    pointer->functionOnlyOnChild();
    return pointer;
}

Click here to compile with g++ and here to compile with clang++ 3.8.

As you can see, this compiles without any problems with g++, but this does not compile with clang++ 3.8. This is because we are using covariance here. Hence, the return type in the signature of the function is not the same than the type of the object returned. It seems that the clang++ 3.8 developers decided that, since the types are actually different, pointer should not be eligible for copy elision, thus it should not be moved. On the other hand, the g++ developers seem to have decided that implementing covariance properly required the covariant types to behave as a single type in this particular case.

The solution for the code to compile is to add a std::move in these cases, and to add a comment to explain why it is required since somebody compiling your code with g++ could be surprised by this std::move and be tempted to remove it when refactoring.

std::unique_ptr<Verbose> createPointer() {
    auto pointer = std::make_unique<VerboseChild>();
    pointer->functionOnlyOnChild();
    return std::move(pointer); // required by clang++ 3.8 since covariant
}

An alternative fix is to use a version of clang greater or equal to 3.9.0. This might seem like an easy fix considering that the latest version of clang is 7.0.0, but many people still use 3.8.0 since the popular Ubuntu 16.04 is packaged with it.

Conclusion

One of the goal of the design of move semantic seems to be transparency. When possible, the user of a class defining a move constructor should be able to enjoy the performance gain due to move semantic without any special syntax required. The interaction of move semantic with copy elision follow that principle: what could be simpler than returning by value?

There is a complexity, but a typical user should be shielded from it, especially if they follow leading practices like the single responsibility principle (SRP). This goes along with what seems to be the usual philosophy of C++, i.e., to make a language which is performant and easy to use at the cost of a high complexity behind the scene. After all, did you understand overloading in depth the first time you wrote std::cout << "Hello World" << std::endl;?

Written by Kevin Lalumiere
Software Developer, Index