Search code examples
c++type-conversioncopy-elision

Understanding conversion sequence and role of copy elision


I'd like to understand copy elision rules (if these are the one that apply) in a conversion sequence, during an object direct initialization.

#include <cstdio>

class To {
   public:
    To() { std::puts("To default constructor"); }
    explicit To(int Input) : val(Input) { std::puts("To value constructor"); }
    To(To&& object) : val(object.val) { std::puts("To move constructor"); }
    ~To() { std::puts("To destructor"); }

    int val = -1;
};

class From {
   public:
    explicit From(int Input) : val(Input) {
        std::puts("From value constructor");
    }
    operator To() const {
        std::puts("From implicit conversion");
        return To{val};
    };
    ~From() { std::puts("From destructor"); }

    int val = -1;
};

int main() {
    To obj0(To{42});  // 1
    std::puts("--------------");
    To obj1(From{42});  // 2
    std::puts("--------------");
}

LIVE

Line 1 gives the output:

To value constructor

It seems that copy elision kicks in: despite of its construction side-effects, the temporary To{42} is not materialized. I think that Prvalue semantics ("guaranteed copy elision") applies. Is it correct?
Yet I would appreciate a clear explanation on why the rvalue materialization is not needed.

Moving on a more complex scenario where a From object can be converted to a To one and To object does not have direct construction from From object, I'm observing following output:

From value constructor
From implicit conversion
To value constructor
From destructor

At first I would have expected

From value constructor
From implicit conversion
To move constructor
From destructor

But in the light of line 1 I may (temporarily) admit that copy elision takes place to remove the temporary To but why does not the logic go further and remove also the From object.
My guess is that in general, the compiler cannot make assumptions on how From is constructed (maybe it squares the initializer), while with a sequence of To it can "short circuit" (I'm unclear because these rules are unclear for me). But following this line of reasoning, as the conversion happens, my understanding is that it materializes the temporary To object and if it is materialized, the guaranteed copy elision rule shouldn't apply anymore?

Thus what are exactly the rules that apply at line 2 and why?


Solution

  • Prior to C++17, an initialization like

    To obj0(To{42});
    

    would ask for a temporary object of type To to be created, and then used to move-initialize obj0. But the compiler was given leeway to eliminate that temporary by rewriting the initialization to

    To obj0{42};
    

    The standard explicitly allowed the compiler to perform this optimization even though it could have a different range of well-defined behaviour from the original program. (Most optimizations are allowed only as long as the resulting optimized program behaves in a way that the original program would have been allowed to.)

    In C++17, this rewrite effectively became mandatory: when an object is initialized from a prvalue of the same type (ignoring cv-qualifiers) the compiler must generate code that initializes the object in whatever manner the erstwhile temporary object would have been initialized. The standard no longer even acknowledges any temporary to elide; To{42}, a prvalue, simply doesn't represent any temporary in the first place. However, there are certain contexts in which a prvalue can be "provoked" into creating a temporary, such as when it is at the top level; To{42}; is a statement that creates a temporary To object. This is known as materializing the prvalue. A prvalue that is immediately used to initialize a named object of the same type is not materialized.

    In the obj1 case, To value constructor certainly must be printed because you explicitly call that constructor in the following statement:

    return To{val};
    

    However, instead of this creating a temporary To object that is then moved into obj1, this is what happens instead: the return statement directly calls the value constructor of the final destination, which is obj1. (The way this works under the hood is that From::operator To has a hidden extra parameter that is like a To*, and the return statement initializes the object that that pointer points to.)

    However, even though the "copy elision" in the obj1 case is clearly desirable, we currently lack the precise wording in the standard to specify it. This is CWG2327.