Given the following:
#include <stdio.h>
class X;
class Y
{
public:
Y() { printf(" 1\n"); } // 1
// operator X(); // 2
};
class X
{
public:
X(int) {}
X(const Y& rhs) { printf(" 3\n"); } // 3
X(Y&& rhs) { printf(" 4\n"); } // 4
};
// Y::operator X() { printf(" operator X() - 2\n"); return X{2}; }
int main()
{
Y y{}; // Calls (1)
printf("j\n");
X j{y}; // Calls (3)
printf("k\n");
X k = {y}; // Calls (3)
printf("m\n");
X m = y; // Calls (3)
printf("n\n");
X n(y); // Calls (3)
return 0;
}
So far, so good. Now, if I enable the conversion operator Y::operator X()
, I get this;-
X m = y; // Calls (2)
My understanding is that this happens because (2) is 'less const' than (3) and
therefore preferred. The call to the X
constructor is elided
My question is, why doesn't the definition X k = {y}
change its behavior in the same way? I know that = {}
is technically 'list copy initialization', but in the absence of a constructor taking an initializer_list
type, doesn't this revert to 'copy initialization' behavior? ie - the same as for X m = y
Where is the hole in my understanding?
Where is the hole in my understanding?
tltldr; Nobody understands initialization.
tldr; List-initialization prefers std::initializer_list<T>
constructors, but it doesn't fall-back to non-list-initialization. It only falls back to considering constructors. Non-list-initialization will consider conversion functions, but the fallback does not.
All of the initialization rules come from [dcl.init]. So let's just go from first principles.
- If the initializer is a (non-parenthesized) braced-init-list or is = braced-init-list, the object or reference is list-initialized.
The first first bullet point covers any list-initialization. This jumps X x{y}
and X x = {y}
over to [dcl.init.list]. We'll get back to that. The other case is easier. Let's look at X x = y
. We call straight down into:
- Otherwise (i.e., for the remaining copy-initialization cases), user-defined conversion sequences that can convert from the source type to the destination type or (when a conversion function is used) to a derived class thereof are enumerated as described in [over.match.copy], and the best one is chosen through overload resolution.
The candidates in [over.match.copy] are:
- The converting constructors of
T
[in our case,X
] are candidate functions.- When the type of the initializer expression is a class type “cv
S
”, the non-explicit conversion functions ofS
and its base classes are considered.In both cases, the argument list has one argument, which is the initializer expression.
This gives us candidates:
X(Y const &); // from the 1st bullet
Y::operator X(); // from the 2nd bullet
The 2nd is equivalent to having had a X(Y& )
, since the conversion function is not cv-qualified. This makes for a less cv-qualified reference than the converting constructor, so it's preferred. Note, there is no invocation of X(X&& )
here in C++17.
Now let's go back to the list-initialization cases. The first relevant bullet point is [dcl.init.list]/3.6:
Otherwise, if
T
is a class type, constructors are considered. The applicable constructors are enumerated and the best one is chosen through overload resolution ([over.match], [over.match.list]). If a narrowing conversion (see below) is required to convert any of the arguments, the program is ill-formed.
which in both cases takes us to [over.match.list] which defines two-phase overload resolution:
- Initially, the candidate functions are the initializer-list constructors ([dcl.init.list]) of the class T and the argument list consists of the initializer list as a single argument.
- If no viable initializer-list constructor is found, overload resolution is performed again, where the candidate functions are all the constructors of the class T and the argument list consists of the elements of the initializer list.
If the initializer list has no elements and T has a default constructor, the first phase is omitted. In copy-list-initialization, if an explicit constructor is chosen, the initialization is ill-formed.
The candidates are the constructors of X
. The only difference between X x{y}
and X x = {y}
are that if the latter chooses an explicit
constructor, the initialization is ill-formed. We don't even have any explicit
constructors, so the two are equivalent. Hence, we enumerate our constructors:
X(Y const& )
X(X&& )
by way of Y::operator X()
The former is a direct reference binding that is an Exact Match. The latter requires a user-defined conversion. Hence, we prefer X(Y const& )
in this case.
Note that gcc 7.1 gets this wrong in C++1z mode, so I've filed bug 80943.