A look behind the scenes of mandatory copy elision.
Mandatory Copy Elision in C++
The term copy elision may seem a little strange, but it is an important part of C++ because it means that a copy can often be omitted (or elided) which in turn makes pass-by-value far cheaper and practical than it would be if copies were not elided.
This is highly desirable, so under some circumstances copy elision is mandatory.
According to cppreference.com, copy elision is mandatory…
- “In a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type.”
- “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.”
When copy elision occurs, copy constructors, move constructors, and destructors are omitted, even if they have demonstrable side-effects.
What does this mean in practice, and how does it work?
Demonstrating Copy Elision
Here is a class, Elide
. It has a constructor, a copy constructor, and a destructor.
class Elide
{
public:
Elide(); // Constructor.
Elide(const Elide& e); // Copy constructor.
~Elide(); // Destructor.
private:
int x_;
int y_;
int z_;
};
As a side effect, each function writes a message to stdout
when called.
Elide::Elide() : x_{1}, y_{2}, z_{3}
{
std::cout << "\tElide::Elide();\n"; // Side effect.
}
Elide::Elide(const Elide& e)
{
std::cout << "\tElide::Elide(const Elide& e);\n"; // Side effect.
x_ = e.x_;
y_ = e.y_;
z_ = e.z_;
}
Elide::~Elide()
{
std::cout << "\tElide::~Elide();\n"; // Side effect.
}
The following function, MakeElide()
, creates and returns an instance of Elide
. It does so via copy-elision, because
this is a “return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the
function return type.”
Elide MakeElide()
{
return Elide();
}
Demonstration
We can see the effects of copy elision when using Elide
by running the following code…
{
Elide e1; // Calls Elide's constructor only.
Elide e2(MakeElide()); // Calls Elide's constructor only. Copy elision occurs.
Elide e3(e2); // Calls Elide's copy constructor.
Elide e4 = e3; // Calls Elide's copy constructor.
Elide e5 = Elide(Elide(MakeElide())); // Calls Elide's constructor only. Copy elision occurs.
}
Here’s the output.
Elide::Elide();
Elide::Elide();
Elide::Elide(const Elide& e);
Elide::Elide(const Elide& e);
Elide::Elide();
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Explanation
So, what happened here? Let’s take the statements one at a time.
This runs the default constructor only. No surprises here.
Elide e1;
This also runs the default constructor only, even though it looks as if copy construction is taking place. Instead, we get copy-elision because “the intializer expression is a prvalue value of the same class type (ignoring cv-qualification) as the variable type.”
Elide e2(MakeElide());
Here we get to see the copy constructor run, because e2
is an lvalue, not a prvalue. The requirements for mandatory
copy elision are not met.
Elide e3(e2);
It runs here again, because Elide
doesn’t have a copy assignment operator, and e3
is an lvalue
expression.
Elide e4 = e3;
This runs the default constructor only, and it runs it just once, because copy elision is taking place.
Elide e5 = Elide(Elide(MakeElide()));
Finally, the destructors run when e1
, e2
, e3
, e4
, and e5
go out of scope.
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Elide::~Elide();
Behind the scenes
We can look at the generated assembly language to understand this better. In the example below, gcc has been used to compile it to x64, with no optimization otherwise there wouldn’t be much to see.
Here’s some code that initializes e2
with the result of a call to MakeElide()
.
Elide e2(MakeElide()); // Calls Elide's constructor only. Copy elision occurs.
Here’s the corresponding assembly language. It is using the System V AMD64 ABI,
so the first (hidden) parameter to MakeElide()
is passed in RDI
. Looking at the following code, when MakeElide()
is called, RDI
holds the address of e2
.
lea rax, [rbp-40] ; put the address of e2 into RAX
mov rdi, rax ; copy it into RDI, so now RDI holds the address of e2
call MakeElide() ; call MakeElide
Here’s MakeElide()
itself.
Elide MakeElide()
{
return Elide();
}
Here’s the corresponding assembly language. When MakeElide()
was called above, RDI
held the address of e2
, and
here MakeElide()
passes it through to Elide
’s constructor.
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi ; save RDI (which holds the address of e2) on the stack
mov rax, QWORD PTR [rbp-8]
mov rdi, rax ; RDI holds the address of e2
call Elide::Elide() ; call Elide's constructor with RDI holding the address of e2
mov rax, QWORD PTR [rbp-8]
leave
ret
Here’s Elide
’s constructor.
Elide::Elide() : x_{1}, y_{2}, z_{3}
{
std::cout << "\tElide::Elide();\n"; // Side effect.
}
Here’s the corresponding assembly language. As before, RDI
holds the address of e2
, so the following code directly
initializes e2
and no copy takes place. In other words, the copy was elided.
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi ; save RDI on the stack
; x_{1}
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 1
; y_{2}
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+4], 2
; z_{3}
mov rax, QWORD PTR [rbp-8] ; z_{3}
mov DWORD PTR [rax+8], 3
; std::cout << "\t...";
mov esi, OFFSET FLAT:.LC0
mov edi, OFFSET FLAT:_ZSt4cout
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
nop
leave
ret
}
Conclusion
Mandatory copy elision makes pass-by-value in C++ practical and efficient as it allows objects to be constructed directly by omitting the copy. Without it, pass-by-value would be far less practical.
Further Reading
For more information, follow these links:
- Copy elision on cppreference.com
- Copy elision on wikipedia
- A video about copy elision from the Copperspice YouTube channel