14

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?

Vaughn Cato
  • 63,448
  • 5
  • 82
  • 132
Rich
  • 185
  • 1
  • 6
  • I'm sorry - I messed up minimising the code - corrected (hopefully) – Rich Jun 01 '17 at 12:11
  • Exaxt compiler used? I am aware of inconsistencies in code adjacent to this in some compilers. – Yakk - Adam Nevraumont Jun 01 '17 at 12:16
  • 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 same – Rich Jun 01 '17 at 12:19
  • 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 Jun 02 '17 at 08:34

2 Answers2

7

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.

Community
  • 1
  • 1
Barry
  • 286,269
  • 29
  • 621
  • 977
  • You are not "told to consider conversion functions" because they are instead considered when determining whether the copy/move constructors of `X` are viable candidates. They just can't compete with an exact-match reference binding. – T.C. Jun 01 '17 at 18:15
  • @T.C. So `X x{y}` will consider `X(X&&)` by way of `Y::operator X` a viable candidate, just a less viable one? Or in other words, the gcc bug I filed is a bug but not for the reasons I described? – Barry Jun 01 '17 at 18:28
  • Pretty much, yes. – T.C. Jun 01 '17 at 18:56
  • The bug you reported also happens for `T t(s)` – Johannes Schaub - litb Jun 01 '17 at 20:24
0

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.

Johannes Schaub - litb
  • 496,577
  • 130
  • 894
  • 1,212