Let’s say we have this C++ class with all five constructors:
|
|
Now, let’s examine this piece of code:
|
|
What output will the program produce? How often will the copy constructor be invoked?
Unfortunately, C++17 doesn’t specify it clearly. Possible outputs include:
|
|
|
|
|
|
When I ran this code, the output was the first option. None of the copy constructors were called. So what rule governs this behavior?
Copy Elision
The C++ compiler uses a technique called copy elision. It ensures that if some calls to copy constructors can be avoided, they are. But first, let’s understand when a copy constructor is invoked.
When the Copy Constructor is Called
The copy constructor is called whenever an object is initialized (by direct-initialization or copy-initialization) from another object of the same type (unless overload resolution selects a better match or the call is elided), which includes. – cppreference
While direct initialization is straightforward, initializing an object from an explicit set of constructor arguments (e.g., T object(arg1, arg2, ...);
), copy-initialization is more nuanced. According to cppreference, there are several scenarios:
T object = other;
- A named variable is declared with an equal sign.f(other)
- Passing an argument to a function by value.return other;
- Returning from a function that returns by value.throw object; catch (T object)
- Throwing or catching an exception by value.
In the first code snippet, two copy constructors should be called: the first when returning from a function (3) and the second when declaring a variable with an equal sign (1).
Are There Guarantees?
Since C++17, there’s something called guaranteed copy elision. It states:
Since C++17, a prvalue is not materialized until needed, and then it is constructed directly into the storage of its final destination. – cppreference
It means, that even when the syntax suggests a copy constructor should be called, but the value that is the source of the copy is a prvalue, the compiler can optimize it away. The result is just a single constructor call in the final destination.
The documentation provides two examples of this guarantee:
When initializing an object in a return statement with a prvalue:
1
return Foo{5};
This optimization was earlier called URVO - “unnamed return value optimization” and was a common optimization even before C++17, but is now a part of the standard.
During object initialization when the initializer expression is a prvalue:
1
Foo x = Foo{Foo{Foo{5}}};
Here, the fact that the constructors are chained together doesn’t matter. It’s worth noting that “move” assignments are elided, not “copy”.
Beyond that, the standard also specifies situations where the compiler may apply copy elision but isn’t obligated to, such as:
return
statements with a named operand. This optimization is called NRVO - “named return value optimization” and example of that was in the first code snippet. As we saw, most compilers implement this optimization, but it’s not mandatory.- Object initialization from a temporary.
throw
expressions with a named operand.- Exception handlers.
For more details, check cppreference.
With the introduction of move semantics in C++11, the compiler can also elide move constructors the same way it does with copy constructors. {:.notice–info}
Some strange example
The compilers can be easily tricked when it comes to copy elision.
Take this code for example:
|
|
The result is:
|
|
The code compiled without any warnings or errors. The output is unexpected, as the object is destroyed and then copied. If there are any rules in the C++ that says I can’t do that, they are not easy to find. C++ reference only says about the exception throwing:
Let
ex
be the conversion result:
- The exception object is copy-initialized from
ex
.
The exepction object wasn’t copy-initialized, but moved-initialized and produced an undifined behavior.
If we changed the catch parameter to const Foo& foo
, the output would be very simmiliar but the reported x value would be 0
.
If we would change throw foo;
to throw Foo{5};
, the move would be elided.
Maybe the conclusion is to always use throw
with a temporary object, not a named one.
Summary
Before C++17, copy elision was an optimization that compilers could apply, but it wasn’t guaranteed. It could generate different results depending on the compiler and optimization level (like debug/release mode). It’s worth noting that the code that relies on possible optimizations like “named return value optimization” is not portable and can produce different results on different compilers.