Copy and Move Elision in C++ — What is RVO/NRVO?

Ryonald Teofilo
4 min readAug 20, 2023

--

Source: Randy Glasbergen

Copy and move elision is an optimisation technique used by many C++ compilers to avoid unnecessary copying and moving of temporary variables.

This speeds up processes that may otherwise be slow and inefficient, especially with objects that are “expensive” — such as containers or complex user-defined types.

Let’s take a look at an example to understand what this optimisation does.

#include <iostream>

class Foo
{
public:
int x = 0;

// default ctor
Foo()
{
std::cout << "Default ctor\n";
}

// copy ctor
Foo(const Foo& rhs)
{
std::cout << "Copy ctor\n";
}
};

Foo CreateFooA()
{
return Foo();
}

Foo CreateFooB()
{
Foo temp;
temp.x = 42; // update member variable
return temp;
}

int main()
{
Foo t1(CreateFooA());
Foo t2(CreateFooB());

return 0;
}

And for all the nerds out there, I will be using g++ 11.4.0 for this demo.

$ g++ --version
g++ (GCC) 11.4.0

If we compile the code above and run the executable, we get the following output (note that we are compiling in C++14, more on this later).

$ g++ -std=gnu++14 copyelision.cpp -o app
$ ./app
Default ctor
Default ctor

Now, if we compile the same code, but this time we explicitly telling our compiler to not elide constructors using -fno-elide-constructors, we will get the following output.

$ g++ -std=gnu++14 -fno-elide-constructors copyelision.cpp -o appnoelide
$ ./appnoelide
Default ctor
Copy ctor
Copy ctor
Default ctor
Copy ctor
Copy ctor

Woah! That is a lot more output than when we compiled it with default settings! Let’s take a look at why this is the case.

Foo t1(CreateFooA());

When we declare t1 and initialised it with the returned rvalue from CreateFooA(). The following will happen:

  1. CreateFooA() creates a temporary Foo object to return.
  2. The temporary object will then be copied into the object that will be returned by CreateFooA().
  3. The value returned by CreateFooA() will then be copied into t1.

This is tedious and would adversely affect performance if the object we are dealing with is expensive to copy!

Foo t2(CreateFooB());

The initialisation of t2 goes through a similar process:

  1. CreateFooB() creates a temporary Foo variable, temp.
  2. Do things with temp. In our case, we update the member variable temp.x to 42.
  3. temp then has to be copied into the value to be returned by CreateFooB().
  4. The value returned by CreateFooB() will then be copied into t2.

Again, very tedious and inefficient. In short, the whole process can be summarised as the following.

$ ./appnoelide
Default ctor # construct temporary variable to return in CreateFooA()
Copy ctor # copying temporary variable to returning object of CreateFooA()
Copy ctor # copying rvalue returned by CreateFooA() into t1
Default ctor # construct 'temp' inside CreateFooB()
Copy ctor # copy 'temp' into to returning object of CreateFooB()
Copy ctor # copying rvalue returned by CreateFooB() into t2

C++11’s move semantics help improve this by avoiding copying resources, but moving them instead.

// Move constructor
Foo::Foo(Foo&& rhs)
{
std::cout << "Move ctor\n";
}

By adding a move constructor, our output without eliding constructors will now be as follows.

$ ./appnoelide
Default ctor
Move ctor
Move ctor
Default ctor
Move ctor
Move ctor

But wait, CreateFooB() returns a named variable, which means it is an lvalue. Why is it able to call on the move constructor?

This is because of a rule in the C++ standard, specifically in 12.8. It states that when a function has a class return type and criteria for copy elision is met, if the expression to be returned is a named variable (lvalue), the object is treated as an rvalue when selecting the constructor for the copy i.e. move constructor will be used if it is available.

This makes uncopyable objects like std::unique_ptr able to be returned by value even if it is a named variable.

std::unique_ptr<int> CreateUnique()
{
auto ptr = std::make_unique<int>(0);
return ptr; // This compiles!
}

It is important to note though, that if a copy or move constructor is elided, that constructor must still exist — This ensures that the optimisation still respects whether an object is of a class that is uncopyable or unmovable.

Now, if we go back to the output of the executable with copy elision optimisation enabled — remember that it is enabled by default!

$ ./app
Default ctor
Default ctor

Here we elide extra calls to constructors, and directly construct the “to be returned” objects to the variable it is assigned to.

Comparing the volume of output, this optimisation technique improves speed and efficiency. The optimisations performed on CreateFooA() and CreateFooB() are officially called RVO (Return Value Optimisation) and N(Named)RVO respectively.

Since this optimisation is enabled by default, it is crucial to remember that this elision will be applied even if it means not calling any code that might be present in the constructors. Therefore, critical logic inside copy/move constructors should be avoided as we cannot rely on them being called!

C++17’s Guarantee on RVO

As a last note of this post, remember how we compiled the code earlier in C++14? This is because C++17 provides us with a guarantee for copy elision for RVO. However this is not the case for NRVO.

This means that CreateFooA() is guaranteed to elide copying/moving, whilst CreateFooB() might or might not elide them.

$ g++ -std=gnu++17 -fno-elide-constructors copyelision.cpp -o appnoelide
$ g++ -std=gnu++17 copyelision.cpp -o app

# Similar output to C++14
$ ./app
Default ctor
Default ctor

# Even if we disable copy/move elision, it will still be performed for
# RVO situations
$ ./appnoelide
Default ctor
Default ctor
Move ctor

Hopefully that clears things on copy/move elision and RVO/NRVO in C++. Feel free to leave a comment if there are any doubts, or something you would like to add!

--

--