How does guaranteed copy elision work?
Asked Answered
D

2

124

At the 2016 Oulu ISO C++ Standards meeting, a proposal called Guaranteed copy elision through simplified value categories was voted into C++17 by the standards committee.

How exactly does guaranteed copy elision work? Does it cover some cases where copy elision was already permitted, or are code changes needed to guarantee copy elision?

Dibromide answered 26/6, 2016 at 21:23 Comment(0)
R
176

Copy elision was permitted to happen under a number of circumstances. However, even if it was permitted, the code still had to be able to work as if the copy were not elided. Namely, there had to be an accessible copy and/or move constructor.

Guaranteed copy elision redefines a number of C++ concepts, such that certain circumstances where copies/moves could be elided don't actually provoke a copy/move at all. The compiler isn't eliding a copy; the standard says that no such copying could ever happen.

Consider this function:

T Func() {return T();}

Under non-guaranteed copy elision rules, this will create a temporary, then move from that temporary into the function's return value. That move operation may be elided, but T must still have an accessible move constructor even if it is never used.

Similarly:

T t = Func();

This is copy initialization of t. This will copy initialize t with the return value of Func. However, T still has to have a move constructor, even though it will not be called.

Guaranteed copy elision redefines the meaning of a prvalue expression. Pre-C++17, prvalues are temporary objects. In C++17, a prvalue expression is merely something which can materialize a temporary, but it isn't a temporary yet.

If you use a prvalue to initialize an object of the prvalue's type, then no temporary is materialized. When you do return T();, this initializes the return value of the function via a prvalue. Since that function returns T, no temporary is created; the initialization of the prvalue simply directly initilaizes the return value.

The thing to understand is that, since the return value is a prvalue, it is not an object yet. It is merely an initializer for an object, just like T() is.

When you do T t = Func();, the prvalue of the return value directly initializes the object t; there is no "create a temporary and copy/move" stage. Since Func()'s return value is a prvalue equivalent to T(), t is directly initialized by T(), exactly as if you had done T t = T().

If a prvalue is used in any other way, the prvalue will materialize a temporary object, which will be used in that expression (or discarded if there is no expression). So if you did const T &rt = Func();, the prvalue would materialize a temporary (using T() as the initializer), whose reference would be stored in rt, along with the usual temporary lifetime extension stuff.

One thing guaranteed elision permits you to do is return objects which are immobile. For example, lock_guard cannot be copied or moved, so you couldn't have a function that returned it by value. But with guaranteed copy elision, you can.

Guaranteed elision also works with direct initialization:

new T(FactoryFunction());

If FactoryFunction returns T by value, this expression will not copy the return value into the allocated memory. It will instead allocate memory and use the allocated memory as the return value memory for the function call directly.

So factory functions that return by value can directly initialize heap allocated memory without even knowing about it. So long as these function internally follow the rules of guaranteed copy elision, of course. They have to return a prvalue of type T.

Of course, this works too:

new auto(FactoryFunction());

In case you don't like writing typenames.


It is important to recognize that the above guarantees only work for prvalues. That is, you get no guarantee when returning a named variable:

T Func()
{
   T t = ...;
   ...
   return t;
}

In this instance, t must still have an accessible copy/move constructor. Yes, the compiler can choose to optimize away the copy/move. But the compiler must still verify the existence of an accessible copy/move constructor.

So nothing changes for named return value optimization (NRVO).

Reconsider answered 26/6, 2016 at 21:40 Comment(25)
Was there really no ABI in use that returned word-sized UDT in a register? This sort of rule seems to kill performance of iterators and wrapper types such as are found in dimensional-correctness libraries (std::chrono has some of these types). Or maybe return-in-register is still ok if the type is trivially-copyable, so that determining whether elision occurred is impossible?Cogitable
@BenVoigt: Putting non-trivially-copyable user-defined types into registers is not a viable thing an ABI can do, whether elision is available or not.Reconsider
Now that the rules are public, it may be worth to update this with the "prvalues are initializations" concept.Hamill
@M.M: I think you messed up your example. You probably meant for main to call b rather than a. But the answer is the same either way: it does not materialize any temporaries. "you say that the A() materializes in the return object of a()" No I did not. I said, "this initializes the return object of the function via a prvalue". That's not the same thing as materializing a temporary. Basically, the A() prvalue initializes a's return value, which initializes b's return value, which initializes the object c. Thus, by the transitive property, A() initializes c.Reconsider
For the T var = Func() case, if Func returns t; (i.e a named automatic), I think that var is initialized by t, first by treating t as an rvalue (etc.. the usual rules). So in a sense, "guaranteed-copy-elision" is involved here aswell. But normal copy-elision can also apply, which can omit the function-local object t altogether and fold it into var.Hamill
Recently I found that we have also real cases of guaranteed copy-elision in the Standard: Within a constexpr evaluation, a compiler is required to apply all copy-elisions (to ensure that pointers established in a called constexpr function are still valid in the caller). However, here move/copy-constructors are still required to be accessible, since that's a case of copy-elision that is guaranteed. Since the question title reads "How does guaranteed copy elision work?", it may be helpful to add an explanation for that case aswell.Hamill
@JohannesSchaub-litb: The OP's question involved citing a specific proposal, asking about how it works. They're not asking about this constexpr mechanism you're referring to.Reconsider
@JohannesSchaub-litb: It's only "ambiguous" if you know entirely too much about the minutiae of the C++ standard. For 99% of the C++ community, we know what "guaranteed copy elision" refers to. The actual paper proposing the feature is even titled "Guaranteed Copy Elision". Adding "through simplified value categories" merely makes it confusing and difficult for users to understand. Also it's a misnomer, since these rules don't really "simplify" the rules around value categories. Whether you like it or not, the term "guaranteed copy elision" refers to this feature and nothing else.Reconsider
I so want to be able to pick up a prvalue and carry it around. I guess that this is just a (one-shot) std::function<T()> really.Albuminuria
In wording for guaranteed copy elision, it mentions initializer expression several times (e.g. 8.5 Bullet 17.6), but what is an initializer expression. I could not find a definition for it.Brayton
@LiuSha: It's in the standard itself; it already has meaning, so the proposal didn't need to explain it. However, it is exactly what it says it is: an expression used to initialize something.Reconsider
@NicolBolas Is the following understanding correct for the example A x = instanceA + returnsA() + returnsRefToA() + returnsRRefToA()? returnsA() is an prvalue which is materialized to be an operand for +, returnsRefToA() is an lvalue; while returnsRRefToA() is an xvalue. But they are all used to initialize the parameter for operator +, should they all be prvalue? Or they are glvalue converted to prvalue automatically?Brayton
Is guaranteed copy elision dependent on compiler settings?Finsen
@RobertAndrzejuk: It's a C++17 feature, so you have to be doing whatever your compiler requires to get C++17 features. Assuming it implements the feature.Reconsider
For T t = Func(); you say: "This is copy initialization of t. This will copy initialize t with the return value of Func. However, T still has to have a move constructor, even though it will not be called.". I fail to comprehend how that is true. Isn't Func() either a prvalue or xvalue and hence a possibly existing move construcotr will be called? As for example in: coliru.stacked-crooked.com/a/f3339b1c6604b928Villainy
@user1658887: It's true because the standard explicitly allows this to be true. That statement explicitly allows this to happen. This is called "copy elision".Reconsider
Sorry, I misunderstood your statement. To me it read like as if you were saying that under no circumstances the move constructor will be called in this scenario. I just read your answer again and realized that this is not at all what you were intending to convey, i.e., the move constructor has to be present even if it is elided. My bad.Villainy
Did emplace_back become irrelevant if copy elision is guarantee? If you can just call push_back with a tmp obj constructor, isnt that guarantee to be elided?Ephram
@Icebone1000: No, to both questions. Once it has a name, such as the name of a parameter, it's not a prvalue anymore. And guaranteed elision only applies to prvalues.Reconsider
Would it be correct to say that the Func()'s return value and the T() in its return statement both have the t variable as their result object? Thanks for your help.Determine
@CyberMarmot: Yes, that's how copy elision works. In any version of C++, T t = T(); is initialization (just another way to spell T t{};). C++17 makes T t = Func(); legal for initialization too; Func must, by definition, construct the value it's returning directly into memory provided by the caller, the object cannot be stored anywhere but the caller-provided location. Even if the caller does not use the return value (Func(); on a line by itself), the caller is implicitly (w/compiler assistance) providing the memory that the return value is placed in, then destructed immediately.Martellato
(Pedantic note: I'm not sure any given means of initialization is ever fully equivalent to another for arbitrary types, so saying T t = T(); is equivalent to T t{}; is almost certainly inaccurate for specific types, but I'm just trying to make it clear that T t = T(); has always meant "construct a T into storage named t", it doesn't mean "make a temporary T and then copy/move construct it to t and throw away the temporary" in any standard version of C++ I'm aware of)Martellato
If a prvalue is used in any other way -- what if T is a type that can be compared, and I write if ( Func() > 0 ) ...?Katekatee
@SwissFrank: That still requires materializing a temporary. It can't call the comparison operator on something that isn't an object.Reconsider
@NicolBolas Is it correct to say that in T t = T(); the expression T() doesn't denote an object since it remains the prvalue i.e. there is no prvalue to xvalue conversion? Despite that, the result object of the prvalue is the variable t. Generally if that holds, I presume it would be correct to say that prvalues never denote objects.Frons
S
3

I think details of copy elision have been well shared here. However, I found this article: https://jonasdevlieghere.com/guaranteed-copy-elision which refers to guaranteed copy elision in C++17 in return value optimization case.

It also refers to how using the gcc option: -fno-elide-constructors, one can disable the copy elision and see that instead of the constructor directly being called at destination, we see 2 copy constructors(or move constructors in c++11) and their corresponding destructors being called. Following example shows both cases:

#include <iostream>
using namespace std;
class Foo {
public:
    Foo() {cout << "Foo constructed" << endl; }
    Foo(const Foo& foo) {cout << "Foo copy constructed" << endl;}
    Foo(const Foo&& foo) {cout << "Foo move constructed" << endl;}
    ~Foo() {cout << "Foo destructed" << endl;}
};

Foo fReturnValueOptimization() {
    cout << "Running: fReturnValueOptimization" << endl;
    return Foo();
}

Foo fNamedReturnValueOptimization() {
    cout << "Running: fNamedReturnValueOptimization" << endl;
    Foo foo;
    return foo;
}

int main() {
    Foo foo1 = fReturnValueOptimization();
    Foo foo2 = fNamedReturnValueOptimization();
}
vinegupt@bhoscl88-04(~/progs/cc/src)$ g++ -std=c++11 testFooCopyElision.cxx # Copy elision enabled by default
vinegupt@bhoscl88-04(~/progs/cc/src)$ ./a.out
Running: fReturnValueOptimization
Foo constructed
Running: fNamedReturnValueOptimization
Foo constructed
Foo destructed
Foo destructed
vinegupt@bhoscl88-04(~/progs/cc/src)$ g++ -std=c++11 -fno-elide-constructors testFooCopyElision.cxx # Copy elision disabled
vinegupt@bhoscl88-04(~/progs/cc/src)$ ./a.out
Running: fReturnValueOptimization
Foo constructed
Foo move constructed
Foo destructed
Foo move constructed
Foo destructed
Running: fNamedReturnValueOptimization
Foo constructed
Foo move constructed
Foo destructed
Foo move constructed
Foo destructed
Foo destructed
Foo destructed

I see that return value optimization .i.e. copy elision of temporary objects in return statements generally being guaranteed irrespective of c++ 17.

However, named return value optimization of returned local variables happening mostly but not guaranteed. In a function with different return statements, I see that if the each of the return statements returns variables of local scope, or variables of same scope it will happen. Otherwise, if in different return statements variables of different scopes are returned it would be hard for compiler to perform copy elision.

It would be nice, if there was a way to guarantee copy elision or get some sort of warning when copy elision can't be performed which would make developers make sure copy elision is performed and re-factor code if it couldn't be performed.

Strader answered 19/6, 2021 at 22:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.