"= {}"形式的复制初始化

14

以下是给定的内容:

#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;
}

到目前为止,一切都很好。现在,如果我启用转换运算符 Y::operator X(),我会得到这个结果:-
  X m = y; // Calls (2)

我的理解是,这是因为(2)比(3)'less const',因此更受欢迎。对X构造函数的调用被省略了。

我的问题是,为什么定义X k = {y}的行为没有以同样的方式改变?我知道 = {} 在技术上是'list copy initialization',但在没有接受initializer_list类型的构造函数的情况下,是否会回归到'copy initialization'行为?也就是说-与X m = y相同。

我的理解有哪些漏洞?


抱歉,我弄错了代码的最小化 - 已经更正(希望如此)。 - Rich
使用的编译器是准确的吗?我知道在某些编译器中,与此相邻的代码存在不一致性。 - Yakk - Adam Nevraumont
改进了示例。我已经使用cppreference.com上的编译器工具(clang 3.8)在11、14和17模式下尝试过。结果是相同的。 - Rich
感谢大家的帮助和详尽的回答。我现在明白发生了什么。但我必须同意Barry的观点 - C++初始化非常复杂! :-) - Rich
2个回答

7

我的理解哪里出了问题?

tltldr; 没有人真正理解初始化。

tldr; 列表初始化更倾向于使用 std::initializer_list<T> 构造函数,但它不会退回到非列表初始化。它只会考虑构造函数。非列表初始化将考虑转换函数,但后备选项不包括此项。


所有的初始化规则都来自于 [dcl.init]。因此,让我们从第一原理开始。

[dcl.init]/17.1:

  • 如果初始化器是一个非括号的 花括号初始化列表 或者是 = 花括号初始化列表,那么对象或引用将进行列表初始化。

第一个要点涵盖了任何列表初始化。这使得 X x{y}X x = {y} 跳转到 [dcl.init.list]。我们待会儿再回来看这个。另一种情况更简单。我们来看 X x = y。我们直接调用:

[dcl.init]/17.6.3:

否则(即对于剩余的复制初始化情况),可以将能够将源类型转换为目标类型或(使用转换函数时)其派生类的用户定义转换序列枚举,如[over.match.copy]所述,并通过重载决议选择最佳转换。
[over.match.copy]中的候选者为:
- T [在我们的案例中是X] 的转换构造函数是候选函数。 - 当初始化表达式的类型为类类型“cvS”时,将考虑S及其基类的非显式转换函数。
在这两种情况下,参数列表有一个参数,即初始化表达式。
这给我们提供了候选者:
X(Y const &);     // from the 1st bullet
Y::operator X();  // from the 2nd bullet

第二种情况相当于已经有了一个X(Y& ),因为转换函数没有cv限定符。这导致引用的cv限定符比转换构造函数更少,因此更优先使用。请注意,在C++17中这里没有调用X(X&& )
现在让我们回到列表初始化的情况。第一个相关的要点是[dcl.init.list]/3.6

否则,如果T是类类型,则会考虑构造函数。应用的构造函数被枚举并通过重载决议([over.match],[over.match.list])选择最佳构造函数。如果需要缩小转换(见下文)来转换任何参数,则程序无效。

这两种情况都会带我们到[over.match.list],它定义了两阶段重载解析:

起初,候选函数是类T的初始化器列表构造函数([dcl.init.list]),参数列表由初始化器列表作为单个参数组成。如果找不到可行的初始化器列表构造函数,则再次执行重载决议,其中候选函数是类T的所有构造函数,参数列表由初始化器列表的元素组成。如果初始化器列表没有元素且T有默认构造函数,则省略第一阶段。在复制列表初始化中,如果选择了显式构造函数,则初始化将无效。候选者是X的构造函数。X x{y}和X x = {y}之间唯一的区别在于,如果后者选择一个显式构造函数,则初始化将无效。我们甚至没有任何显式构造函数,因此两者是等价的。因此,我们列举我们的构造函数:
  • X(Y const& )
  • X(X&& )通过Y::operator X()
  • 前者是直接引用绑定,是精确匹配。后者需要用户定义的转换。因此,在这种情况下,我们更喜欢使用 X(Y const&)
    请注意,在C++1z模式下,gcc 7.1会出现错误,因此我已经报告了bug 80943

    你不需要“考虑转换函数”,因为它们在确定 X 的复制/移动构造函数是否是可行候选项时已经被考虑了。它们只是无法与精确匹配的引用绑定竞争。 - T.C.
    @T.C. 那么 X x{y} 将考虑通过 Y::operator X 的方式将 X(X&&) 视为可行的候选,只是一个不太可行的候选?换句话说,我报告的 gcc 错误是一个错误,但原因不是我描述的那些? - Barry
    基本上,是的。 - T.C.
    你报告的错误在 T t(s) 也会发生。 - Johannes Schaub - litb

    0
    我的问题是,为什么定义X k = {y}的行为没有以同样的方式改变?
    因为从概念上讲,= {..} 是对某个东西进行 初始化 的一种方式,它会自动选择从括号中最佳的方式来初始化目标。而= value 也是一种初始化,但从概念上来说,它还将转换成另一个值。转换是完全对称的:如果检查源值是否提供一种创建目标的方法,同时检查目标是否提供一种接受源的方法。
    如果你的目标类型是struct A { int x; },那么使用= { 10 }将不会尝试将10转换为A(这将失败)。但它会寻找最佳(在他们看来)的初始化形式,这里相当于聚合初始化。然而,如果A不是一个聚合体(添加构造函数),那么它将调用构造函数,在你的情况下,它发现Y可以直接接受而不需要转换。使用= value形式进行转换时,源和目标之间没有这样的对称性。
    你对转换函数的“less const”怀疑是完全正确的。如果你将转换函数设置为const成员,则会变得模棱两可。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接