在对标准进行了一些研究之后,我得出的结论是 g++ 是错误的,应该只有一个复制构造函数调用。有趣的是,对于此处发生哪种类型的初始化,似乎可以有两种解释。两者都得出相同的结论。
第一种解释——直接初始化
来自 C++14 标准 (Working Draft),[expr.new] 17:
创建T 类型对象的new-expression 将该对象初始化如下:
- (17.1) — 如果 new-initializer 被省略,则对象是默认初始化的 (8.5)。 [ 注意: 如果没有初始化
执行时,对象具有不确定的值。 ——尾注 ]i>
- (17.2) — 否则,new-initializer 根据 8.5 的初始化规则进行解释以进行直接初始化。
在我们的例子中,存在 new-initializer,因此(根据 17.2)new A[1]{x} 使用直接初始化规则进行解释。让我们看看[dcl.init] 16:
表单中发生的初始化
以及new 表达式 (5.3.4)、static_cast 表达式 (5.2.9) 中的函数符号类型转换
(5.2.3)、mem-initializers (12.6.2) 和 braced-init-list 形式的 condition 称为 直接初始化
好的,这进一步证实了我们正在处理直接初始化。现在让我们看看在 [dcl.init] 17 中直接初始化是如何工作的:
初始化器的语义如下。 目标类型是对象或引用的类型
已初始化,源类型是初始化表达式的类型。如果初始化器不是单个(可能
括号) 表达式,源类型未定义。
- [... 17.1 到 17.5 省略...]
- (17.6) — 如果目标类型是(可能是 cv 限定的)类类型:
- (17.6.1) — 如果初始化是直接初始化,或者是复制初始化,其中 cv 不合格
源类型的版本与目标类是同一类或派生类,
构造函数被考虑。列举了适用的构造函数(13.3.1.3),最好的
一个是通过重载决议(13.3)选择的。调用如此选择的构造函数进行初始化
以初始化表达式或 expression-list 作为其参数的对象。如果没有构造函数
适用,或重载决议不明确,初始化格式不正确。
根据上面的摘录,当被初始化的对象是类类型时(如这里的情况)并且在处理直接初始化时(如这里的情况),目标对象使用最合适的构造函数进行初始化。
我不会引用关于如何选择构造函数的规则,因为在这种情况下,当只有默认的A::A()构造函数和复制A::A(const A&)构造函数时,复制构造函数显然是初始化时更好的选择x 类型为 A。这是复制构造函数调用之一的来源。
我没有发现任何关于数组初始化的评论,特别是在 [expr.new] 部分以及为什么它会导致第二次构造函数调用。
二次解读——拷贝初始化
这里,我们可以从[dcl.init.list] 1开始:
List-initialization 是从braced-init-list 初始化对象或引用。这样的初始化程序是
称为initializer list,列表中以逗号分隔的initializer-clauses称为elements
初始化列表。初始化列表可能为空。列表初始化可以发生在直接初始化或复制初始化上下文中;直接初始化上下文中的列表初始化称为 direct-list-initialization 和
复制初始化上下文中的列表初始化称为复制列表初始化。 [ 注意:列表初始化
可以用
- (1.1) — 作为变量定义中的初始化程序 (8.5)
- (1.2) — 作为 new 表达式 (5.3.4) 中的初始化器
- [... 1.3 到 1.10 省略...]
——结束注释 ]
这段摘录可以理解为new A[1]{x}其实是一种列表初始化的形式,而不是直接初始化,因为使用了braced-init-list{x}。假设是这样,让我们看看它在 [dcl.init.list] 3 中是如何工作的:
T 类型的对象或引用的列表初始化定义如下:
- [... 3.1 到 3.2 省略...]
- (3.3) — 否则,如果
T 是一个聚合,则执行聚合初始化 (8.5.1)。
- [... 3.4 到 3.10 省略...]
在我们的例子中,第 3.3 点适用,因为我们正在初始化一个聚合数组,根据 [dcl.init.aggr] 1:
聚合 是一个数组或类(第 9 条),没有用户提供的构造函数 (12.1),没有私有或
受保护的非静态数据成员(第 11 条),无基类(第 10 条),无虚函数(10.3)。
因此,让我们看看 [dcl.init.aggr] 2 中聚合初始化是如何执行的:
当聚合被初始化列表初始化时,如 8.5.4 中所指定,初始化列表的元素
被视为聚合成员的初始值设定项,按递增的下标或成员顺序。每个
成员是从相应的 initializer-clause 复制初始化的。如果 initializer-clause 是一个表达式
并且需要缩小转换(8.5.4)来转换表达式,程序格式错误。
这个片段告诉我们元素是复制初始化的。因此y[0] 将从x 复制初始化。现在让我们看看 [dcl.init] 17 中的复制初始化是如何工作的:
初始化器的语义如下。 目标类型是对象或引用的类型
已初始化,源类型是初始化表达式的类型。如果初始化器不是单个(可能
括号) 表达式,源类型未定义。
- [... 17.1 到 17.5 省略...]
- (17.6) — 如果目标类型是(可能是 cv 限定的)类类型:
- (17.6.1) — 如果初始化是直接初始化,或者是复制初始化,其中 cv 不合格
源类型的版本与目标类是同一类或派生类,
构造函数被考虑。列举了适用的构造函数(13.3.1.3),最好的
一个是通过重载决议(13.3)选择的。调用如此选择的构造函数进行初始化
以初始化表达式或 expression-list 作为其参数的对象。如果没有构造函数
适用,或重载决议不明确,初始化格式不正确。
就像上次一样,此初始化满足第 17.6.1 点的要求,因为它是复制初始化,其中源类型(x 的A)与目标类型(A 的 @ 987654342@)。这意味着在这种情况下,复制构造函数也会被调用。
结论
似乎无论选择哪种解释,都应该只调用一个构造函数,并且 Clang 是正确的。我找不到任何证据表明应该创建一个临时的。对于更多基于示例的证据,其他编译器如 icc 和(诚然基于 clang 的)zapcc 和 elcc agree with clang,都只有一个复制构造函数调用。
我不太了解g++ 的内部工作原理,但我有一个关于它为什么会执行两次复制构造函数调用的理论。有可能在内部 g++ 使用了一些后来总是被优化掉的辅助构造函数调用,并且 -fno-elide-constructors 标志的使用破坏了它们总是被优化掉的不变性。然而,这纯粹是我对g++ 的猜测,所以如果我错了,请纠正我。