14
votes

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?

2
I'm sorry - I messed up minimising the code - corrected (hopefully)Rich
Exaxt compiler used? I am aware of inconsistencies in code adjacent to this in some compilers.Yakk - Adam Nevraumont
Improved the example. I've tried this with compiler tool on cppreference.com (clang 3.8) in 11, 14, and 17 modes. The result is the sameRich
Thank you all for your help and comprehensive answers. I think I understand what's going on now. But I have to agree with Barry's sentiment - C++ initialisation is nuts! :-)Rich

2 Answers

7
votes

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.

0
votes

My question is, why doesn't the definition X k = {y} change its behavior in the same way?

Because, conceptually speaking, a = { .. } is an initialization for something that automatically chooses the "best" way to initialize the target from the braces, while = value is also an initialization, but conceptually also a conversion of the value to a different value. The conversion is completely symmetric: If will look into the source value to see whether it provides a way to create the target, and will look into the target to see whether it provides a way to accept the source.

If your target type is struct A { int x; } then using = { 10 } will not try to convert the 10 to A (which will fail). But it will seek the best (in their eyes) form of initialization, which here amounts to aggregate initialization. However if A is not an aggregate (add constructors), then it will call the constructors, where in your case it finds the Y accepted readily without a conversion needed. There is no such symmetry between the source and the target like there is with the conversion when using the = value form.

Your suspicion about the "less const" of the conversion function is exactly right. If you make the conversion function a const member, then it will become ambiguous.