Does C++17 forbid copy elision in a case where C++14 allowed it?
Asked Answered
T

1

12

Consider the following:

struct X {
    X() {}
    X(X&&) { puts("move"); }
};
X x = X();

In C++14, the move could be elided despite the fact that the move constructor has side effects thanks to [class.copy]/31,

This elision of copy/move operations ... is permitted in the following circumstances ... when a temporary class object that has not been bound to a reference (12.2) would be copied/moved to a class object with the same cv-unqualified type

In C++17 this bullet was removed. Instead the move is guaranteed to be elided thanks to [dcl.init]/17.6.1:

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 ]

Thus far the facts I've stated are well-known. But now let's change the code so that it reads:

X x({});

In C++14, overload resolution is performed and {} is converted to a temporary of type X using the default constructor, then moved into x. The copy elision rules allow this move to be elided.

In C++17, the overload resolution is the same, but now [dcl.init]/17.6.1 doesn't apply and the bullet from C++14 isn't there anymore. There is no initializer expression, since the initializer is a braced-init-list. Instead it appears that [dcl.init]/(17.6.2) applies:

Otherwise, 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 (16.3.1.3), and the best one is chosen through overload resolution (16.3). The constructor so selected is called to initialize the object, with the initializer expression or expression-list as its argument(s). If no constructor applies, or the overload resolution is ambiguous, the initialization is ill-formed.

This appears to require the move constructor to be called, and if there's a rule elsewhere in the standard that says it's ok to elide it, I don't know where it is.

Tatiana answered 8/2, 2018 at 9:7 Comment(7)
This is another variant of Core issue 2327.Welloff
@Welloff I can't find that issue on the publicly available listTatiana
I'm not so sure about your C++14 conclusion. {} is converted to a temporary of type X... which is then bound to the reference from the move constructor. I don't think your initial quote applies.Doordie
@Doordie if the move is going to be elided, then there's no reference binding occurring, surely.Tatiana
@Brian That is presuming the antecedent. If there is a bind, it cannot be elided, so assuming there is no bind because it is elided is a bit much.Liquid
@Yakk It seems to me that the only possible interpretation is that the copy/move can be elided if the source has not already been bound to a reference. If only the copy/move to be elided requires binding it to a reference, then it can be elided. With your interpretation, no elision would ever be possible.Tatiana
IMO it is just clear now, with the C++17 wording, that the move must happen. With C++14, we had sort of weasel wording because a "move" was defined as "by initialization (12.1, 8.5), including for function argument passing". The only initializations I see are of X by {} and of X&& by a temporary of X (or {}, weasel-worded again). Arguably, nowhere is an X initialized by a temporary there.Beehive
D
5

As T.C. points out, this is in a similar vein to CWG 2327:

Consider an example like:

struct Cat {};
struct Dog { operator Cat(); };

Dog d;
Cat c(d);

This goes to 11.6 [dcl.init] bullet 17.6.2:

Otherwise, 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 (16.3.1.3 [over.match.ctor]), and the best one is chosen through overload resolution (16.3 [over.match]). The constructor so selected is called to initialize the object, with the initializer expression or expression-list as its argument(s). If no constructor applies, or the overload resolution is ambiguous, the initialization is ill-formed.

Overload resolution selects the move constructor of Cat. Initializing the Cat&& parameter of the constructor results in a temporary, per 11.6.3 [dcl.init.ref] bullet 5.2.1.2. This precludes the possitiblity of copy elision for this case.

This seems to be an oversight in the wording change for guaranteed copy elision. We should presumably be simultaneously considering both constructors and conversion functions in this case, as we would for copy-initialization, but we'll need to make sure that doesn't introduce any novel problems or ambiguities.

What makes this the same underlying problem is that we have an initializer (in OP, {}, in this example, d) that's the wrong type - we need to convert it to the right type (X or Cat), but to figure out how to do that, we need to perform overload resolution. This already gets us to the move constructor - where we're binding that rvalue reference parameter to a new object that we just created to make this happen. At this point, it's too late to elide the move. We're already there. We can't... back up, ctrl-z, abort abort, okay start over.

As I mentioned in the comments, I'm not sure this was different in C++14 either. In order to evaluate X x({}), we have to construct an X that we're binding to the rvalue reference parameter of the move constructor - we can't elide the move at that point, the reference binding happens before we even know we're doing a move.

Doordie answered 8/2, 2018 at 22:45 Comment(4)
Where did you find that defect report? It doesn't seem to appear on the publicly available issue list.Tatiana
@Brian It's not yet, but should be at some point. It's based on naturally, T.C.'s reportDoordie
I disagree with the reasoning that performing overload resolution requires the reference binding to actually occur. I believe it simply determines that the move constructor should be called because the move constructor is the best viable function, but the compiler is then allowed to elide it because the reference binding hasn't actually happened yet.Tatiana
@Brian Public now.Doordie

© 2022 - 2024 — McMap. All rights reserved.