c++c++17language-lawyercopy-elisionprvalue

Why doesn't "Guaranteed Copy Elision" mean that push_back({arg1, arg2}) is the same as emplace_back(arg1, arg2)?


Firstly, I've heard that Guaranteed Copy Elision is a misnomer (as, I currently understand, it's more about fundamentally redefining the fundamental value categories, r/l-values to l/x/pr-values, which fundamentally changes the meaning and requirements of a copy), but as that's what it's commonly referred as, I will too.

After reading a bit on this topic, I thought I finally understood it - at least well enough to think that:

my_vector.push_back({arg1, arg2});

is, as of c++17, equivalent to:

my_vector.emplace_back(arg1, arg2);

I recently tried to convince my colleague of this. The only problem was he showed me that I was completely wrong! He wrote some godbolt code (like this) where the assembly shows that the push_back creates a temporary that gets moved into the vector.


So, to complete my question I must first justify that there's some reason for confusion here. I'll quote the well-regarded stackoverflow answer on this topic (emphasis mine):

Guaranteed copy elision redefines the meaning of a prvalue expression. [...] 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. [...]

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 [...]

In my case I would've thought that auto il = {arg1, arg2} would call the constructor for std::initializer_list, but that {arg1, arg2} in push_back({arg1, arg2}) would be a prvalue (as it's unnamed) and so would be an initiliser for the vector element without being initialised itself.

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.

Guaranteed elision also works with direct initialization

Could someone kindly explain to me why Guaranteed Copy Elision doesn't apply to my example the way that I expected?


Solution

  • but that {arg1, arg2} in push_back({arg1, arg2}) would be a prvalue (as it's unnamed) and so would be an initiliser for the vector object without being initialised itself.

    I assume that with "vector object" you mean here the vector element, the object that will be stored in the storage managed by the vector and which the push_back/emplace_back is supposed to add to the it.

    {arg1, arg2} itself is not an expression, it is just a braced-init-list, a different grammatical construct. So it itself doesn't have a value category. However it has rules as to how it acts in overload resolution and how it initializes objects and references.

    The overload chosen for push_back will be

    void push_back(value_type&&);
    

    where value_type is the element type of the vector. The reference parameter in this overload needs to reference some object of type value_type. So the braced-init-list must be used to construct an (temporary) object of type value_type to bind this reference to. However this can't be the object that is stored in the vector, because it is a temporary created in the context of the caller. The caller doesn't know where push_back will construct the actual element for the vector. Hence push_back will need to do a move construction from the temporary object bound to the parameter reference to the actual object placed in the vector's storage.

    So effectively my_vector.push_back({arg1, arg2}); is the same as my_vector.push_back(value_type{arg1, arg2});, only that value_type{arg1, arg2} is an actual prvalue expression which will be materialized to a temporary object when initializing push_back's reference parameter from it. Because of this almost identical behavior one might sloppily say that {arg1, arg2} "is a prvalue" or "is a temporary", even though that is technically not correct.

    push_back doesn't have any overload that doesn't take a reference to a value_type, so this is always unavoidably with it. emplace_back on the other hand takes any types as arguments and then just forwards them directly to the construction of the object stored in the vector's storage.

    It is also impossible to forward braced-init-lists. There is no syntax to capture them while preserving the type of the individual list elements. You can only initialize an object of a specified type from the whole list as with push_back or initialize an array or std::initializer_list with homogeneous element type (one element from each list element), which is what an initializer-list constructor would do (with the homogeneous type being the vector's element type).