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.
[dcl.init]/17.1:
- 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:
[dcl.init]/17.6.3:
- 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 of S
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.