What changes to C++ made copy initialization work for class with explicit constructor?
Asked Answered
D

2

21

Consider this code:

struct X{
    explicit X(){}
    explicit X(const X&){}
};

void foo(X a = X()){}

int main(){}

Using C++14 standard, both GCC 7.1 and clang 4.0 rejects the code, which is what I expected.

However, using C++17 (-std=c++1z), they both accept the code. What rule changed?


For both compilers to exhibit this same behavior, I doubt this to be a bug. But as far as I can tell, the latest draft still says, default argument uses the semantics of copy-initialization 1. Again, we know that explicit constructors will only allow direct initialization 2.

1: dcl.fct.default/5; 2: class.conv.ctor/2

Dobsonfly answered 24/5, 2017 at 9:24 Comment(2)
"Copy-initialization" does not mean that a copy will be made. For example, struct A { int x; } a = { 0 }; also employs copy-initialization of a from { 0 }, yet there is no copy being made.Magree
@JohannesSchaub-litb. Thanks, I understand. Additionally, your quote "prvalues are initialization" concept in Nicol Bolas answer here, including Nicol's answer helped me understood the extensive change to the prvalue and temporary materialization brouhaha.Dobsonfly
M
13

Because the behavior of copy elision changes from C++17; for this case copy elision is mandatory.

Mandatory elision of copy/move operations

Under the following circumstances, the compilers are required to omit the copy and move construction of class objects, even if the copy/move constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would otherwise be copied/moved to. The copy/move constructors need not be present or accessible:

  • 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:

    T f() {
        return T();
    }
    
    T x = T(T(f())); // only one call to default constructor of T, to initialize x
    

Note: the rule above does not specify an optimization: C++17 core language specification of prvalues and temporaries is fundamentally different from that of the earlier C++ revisions: there is no longer a temporary to copy/move from. Another way to describe C++17 mechanics is "unmaterialized value passing": prvalues are returned and used without ever materializing a temporary.

And for copy initialization:

The effects of copy initialization are:

  • First, if T is a class type and the initializer is a prvalue expression whose cv-unqualified type is the same class as T, the initializer expression itself, rather that a temporary materialized from it, is used to initialize the destination object: see copy elision (since C++17)

  • If T is a class type and the cv-unqualified version of the type of other is T or a class derived from T, the non-explicit constructors of T are examined and the best match is selected by overload resolution. The constructor is then called to initialize the object.

That means for X a = X(), a will be default constructed directly, the copy/move constructors and their side effects will be omiited completely. The selection of non-explicit constructors for overload resolution won't take place, which is required in C++14 (and before). For these guaranteed cases, the copy/move constructors don't participate in, then it won't matter whether they're explicit or not.

Metaplasia answered 24/5, 2017 at 9:32 Comment(13)
This doesn't seem to address the fundamental issue here: copy elision is only legal if the non-elided version would have been legal. So, for example, a class with a private copy constructor cannot be initialized by an expression like T t = T(3);, because the copy constructor is inaccessible. Copy elision doesn't make this code legal.Montgolfier
@PeteBecker: Guaranteed elision does. There is no copy or move happening C++17. So the explicit copy constructor is never even considered. If T(3) is legal from where it is, then using that prvalue to initialize any object of type T is legal. Whether by copy-initialization or direct-initialization.Fruge
@PeteBecker Only for those unguaranteed copy elision, the copy/move constructors must be present and accessible. liveMetaplasia
@songyuanyao -- as I said, your answer does not address this. "The optimization is mandatory" asserts that it is an optimization, not a change in semantics.Montgolfier
@PeteBecker it seems you're quibbling over terminology. Copy-elision has always been a change in semantics (constructor side-effects are suppressed). If you want to use the word "optimization" to only refer to things that do not change semantics, then it could never have been used with copy-elision (and the terminology RVO would be incorrect).Viosterol
It's not an optimization anymore in C++17. It's a change in meaning. T() does not create an object anymore, but just specifies an initialization mechanism (that of value initialization). It may or may not actually initialize an object (most of the time it does). The same expression may even initialize more than one object: T f() { return T(); } T t1 = f(); t T2 = f();. Here, T() initializes both t1 and t2 (transitively through f(), which also is a prvalue).Magree
@Viosterol -- you're missing the point. There are two questions here: first, is the code legal? And second, if so, what does it do? "The optimization is mandatory" addresses the second one: the compiler used to be allowed to elide the copy construction, and now it's required to. Fine. But that doesn't address the first one: if the copy constructor was not accessible, the code was illegal. The question is, what changed to make it legal?Montgolfier
@NicolBolas the point that Pete makes is that "Guaranteed elision" would mean that checking of constraints would be guaranteed, and emission of the copy would be guaranteed. But with C++17 we do not guarantee checking of constraint, in fact we guarantee not checking the constraint. It is not necessary to elide anything. The "guaranteed copy elision" only is the name of the proposed change from C++14 to C++17. The actual behavior in C++17 is not that of an elided copy.Magree
The article on copy-elision of cppreference is quite confusing. I added a comment on the discussion-page of it to request clarification.Magree
Calling this "copy-elision" is like calling int x = 0; "new-elision", and saying "the compiler is required to elide the *new int(0) away since C++98".Magree
According to this answer, copy constructors are not required for cases where copy-elision is required. This is incorrect: coliru.stacked-crooked.com/a/d16f3305d691935c . In that example, copy elision is required (because we are in a constexpr context), yet the compiler complains about the missing copy constructor.Magree
Pete Becker's point is central to what I missed when I encountered this. I know quite a bit about mandatory copy elision, even answered some questions here on it. But I had always perceived that copy elision was an optimization in C++17 required of compilers. I never really saw it as a major change in the overall language semantics, If I had, I probably would have explored that and understood what was happening without asking this question. Thank you songyuanyao! Thanks everyoneDobsonfly
@JohannesSchaub-litb But with C++17 we do not guarantee checking of constraint, in fact we guarantee not checking the constraint This answer doesn't show where this is guaranteed.Footwork
F
2

The most important for the example in the question rule is [expr.type.conv]/2. But lets start from [dcl.init]/17:

The semantics of initializers are as follows. The destination type is the type of the object or reference being initialized and the source type is the type of the initializer expression. If the initializer is not a single (possibly parenthesized) expression, the source type is not defined.

...

(17.6) — If the destination type is a (possibly cv-qualified) class type:

— If the initializer expression is a prvalue and the cv-unqualified version of the source type is the same class as the class of the destination, the initializer expression is used to initialize the destination object. [Example: T x = T(T(T())); calls the T default constructor to initialize x.  — end example]

So, in X a = X(), the initializer expression X() is used to initialize the destination object. Of course, this is not enough to answer: why default constructor is selected (i.e. how X() becomes ()) and why explicit default constructor is fine.

The X() expression is explicit type conversion in functional notation, so lets look into [expr.type.conv]/2:

If the initializer is a parenthesized single expression, the type conversion expression is equivalent (in definedness, and if defined in meaning) to the corresponding cast expression. If the type is cv void and the initializer is (), the expression is a prvalue of the specified type that performs no initialization. Otherwise, the expression is a prvalue of the specified type whose result object is direct-initialized with the initializer.

Emphasis of the relevant sentence is mine. It says that for X():

  • the object is initialized with () (it is "the initializer" by [expr.type.conv]/1), that's why the default constructor is selected;

  • the object is direct-initialized, that's why it is OK that the default constructor is explicit.


In more details: when the initializer is (), [dcl.init]/(17.4) apply:

If the initializer is (), the object is value-initialized.

[dcl.init]/8:

To value-initialize an object of type T means:
— if T is a (possibly cv-qualified) class type with either no default constructor ([class.ctor]) or a default constructor that is user-provided or deleted, then the object is default-initialized;

[dcl.init]/7:

To default-initialize an object of type T means:
— If T is a (possibly cv-qualified) class type, constructors are considered. The applicable constructors are enumerated ([over.match.ctor]), and the best one for the initializer () is chosen through overload resolution. The constructor thus selected is called, with an empty argument list, to initialize the object.

[over.match.ctor]/1

When objects of class type are direct-initialized, copy-initialized from an expression of the same or a derived class type ([dcl.init]), or default-initialized, overload resolution selects the constructor. For direct-initialization or default-initialization that is not in the context of copy-initialization, the candidate functions are all the constructors of the class of the object being initialized.


In C++14, [dcl.init](17.6) was saying:

— If the destination type is a (possibly cv-qualified) class type:

— If the initialization is direct-initialization, or if it is copy-initialization where the cv-unqualified version of the source type is the same class as, or a derived class of, the class of the destination, constructors are considered. The applicable constructors are enumerated ([over.match.ctor]), and the best one is chosen through overload resolution ([over.match]).

So for X a = X(), only converting (non-explicit) constructors accepting one argument of type X will be considered (which are copy and move constructors).

Footwork answered 17/6, 2020 at 13:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.