Search code examples
c++c++11c++14list-initializationcopy-initialization

Copy initialization of the form '= {}'


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?


Solution

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