【问题标题】:Why copy constructor called twice in heap array initialization?为什么在堆数组初始化中调用了两次复制构造函数?
【发布时间】:2021-08-05 03:30:08
【问题描述】:

对于下面的 C++14 代码,为什么 g++ 为new A[1]{x} 生成的代码似乎调用了两次复制构造函数?

#include <iostream>
using namespace std;

class A {
public:
    A()           { cout << "default ctor" << endl; }
    A(const A& o) { cout << "copy ctor" << endl;    }
    ~A()          { cout << "dtor" << endl;         }
};

int main()
{
    A x;
    cout << "=========" << endl;
    A* y = new A[1]{x};
    cout << "=========" << endl;
    delete[] y;
    return 0;
}

编译输出:

$ g++ -fno-elide-constructors -std=c++14 test.cpp && ./a.out
default ctor
=========
copy ctor
copy ctor
dtor
=========
dtor
dtor

有趣的是,对于同样的代码,clang++ 只调用了一次拷贝构造函数:

$ clang++ -fno-elide-constructors -std=c++14 test.cpp && ./a.out
default ctor
=========
copy ctor
=========
dtor
dtor

此外,在使用 g++ 时,将 A* y = new A[1]{x}; 行更改为以下任何内容都会导致复制构造函数仅被调用一次:

  • A* y = new A {x}; - 普通堆对象而不是大小为 1 的堆数组
  • A y[1] {x}; - 堆栈而不是堆上的数组

所以看来双拷贝构造函数的行为只在堆数组初始化中表现出来。

【问题讨论】:

  • 有趣的是,这种 gcc 行为似乎特定于 c++11/c++14,并且在使用 -std=c++17 编译时不会重现:godbolt.org/z/538fqd9KM
  • 同样有趣(可能)但无益(可能)的是,如果您添加一个移动 c'tor,那么 GCC 会调用它来代替 second 复制 c'tor称呼。可能,野兽正在创建一个初始化列表,然后用它来初始化新的数组数据?
  • @dewaffled 我怀疑这与 C++17 新引入的保证复制省略有关,即使使用 -fno-elide-constructor 也会导致某些复制结构被省略。

标签: c++ g++ c++14 copy-constructor clang++


【解决方案1】:

TL;DR:这可能是 GCC 缺陷,在这种情况下将 {x} 误解为暂时的。对于new A[N]{x1, x2, ... xN} 中的每个元素,应根据[decl.init][new.expr] 调用一次复制构造函数。相反,GCC 可能 将其解释为初始化列表,因此部分解释为中间右值。不过,我们可以强制 GCC 以其他方式解释它。


为什么 g++ 为new A[1]{x} 生成的代码似乎调用了复制构造函数两次?

因为没有移动构造函数。如果我们添加一个移动构造函数和更多输出,我们可以更好地了解情况 (Compiler Explorer):

#include <iostream>
using namespace std;

class A {
public:
    A()           { cout << "default ctor @" << this << endl; }
    A(A&& o)      { cout << "move ctor: " << &o << " to " << this << endl;    }
    A(const A& o) { cout << "copy ctor: " << &o << " to " << this << endl;    }
    ~A()          { cout << "dtor @" << this << endl;         }
};

int main()
{
    A x;
    cout << "=========" << endl;
    A* y = new A[1]{x};
    cout << "=========" << endl;
    delete[] y;
    return 0;
}

请注意,我们的新 A(A&amp;&amp;) 构造函数的存在向我们展示了中间临时:

default ctor @0x7ffec28b5476
=========
copy ctor: 0x7ffec28b5476 to 0x7ffec28b5477
move ctor: 0x7ffec28b5477 to 0x55d0a7fa6288
dtor @0x7ffec28b5477
=========
dtor @0x55d0a7fa6288
dtor @0x7ffec28b5476

确实,如果我们 A(A&amp;&amp;) = delete 构造函数,g++ 甚至不会再编译它(但 Clang 仍然接受它)。

似乎 g++ 误解了 braced-init-list。恕我直言,[expr.new]可能允许这种解释,但这似乎是一个 g++ 缺陷,应该这样报告。

然而,整个磨难让我想起了我的一个老问题 (Are curly braces really required around initialization?)。所以让我们引入更多的大括号来确保g++ 不会误解我们的初始化器:

int main()
{
    A x;
    cout << "=========" << endl;
    A* y = new A[1]{{{x}}};
    cout << "=========" << endl;
    delete[] y;
    return 0;
}

这个变体规避了 g++ 的行为:

initializer for T[1]     start : {
initializer for first element  : {
actual initializer for A       : {x}

那么程序输出就是(Explorer)

default ctor @0x7ffede3d9967
=========
copy ctor: 0x7ffede3d9967 to 0x1eb0ec8
=========
dtor @0x1eb0ec8
dtor @0x7ffede3d9967

所以对于多个元素,我们最终会陷入大括号地狱 (Compiler Explorer):

int main()
{
    A x;
    cout << "=========" << endl;
    A* y = new A[2]{{{x},{{x}}};
    cout << "=========" << endl;
    delete[] y;
    return 0;
}

同样,没有调用额外的构造函数:

default ctor @0x7fff3a2a7a27
=========
copy ctor: 0x7fff3a2a7a27 to 0x1f49ec8
copy ctor: 0x7fff3a2a7a27 to 0x1f49ec9
=========
dtor @0x1f49ec9
dtor @0x1f49ec8
dtor @0x7fff3a2a7a27

【讨论】:

    【解决方案2】:

    在对标准进行了一些研究之后,我得出的结论是 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:

    表单中发生的初始化

    • T x(a);
    • T x{a};

    以及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&amp;)构造函数时,复制构造函数显然是初始化时更好的选择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 点的要求,因为它是复制初始化,其中源类型(xA)与目标类型(A 的 @ 987654342@)。这意味着在这种情况下,复制构造函数也会被调用。

    结论

    似乎无论选择哪种解释,都应该只调用一个构造函数,并且 Clang 是正确的。我找不到任何证据表明应该创建一个临时的。对于更多基于示例的证据,其他编译器如 icc 和(诚然基于 clang 的)zapccelcc agree with clang,都只有一个复制构造函数调用。

    我不太了解g++ 的内部工作原理,但我有一个关于它为什么会执行两次复制构造函数调用的理论。有可能在内部 g++ 使用了一些后来总是被优化掉的辅助构造函数调用,并且 -fno-elide-constructors 标志的使用破坏了它们总是被优化掉的不变性。然而,这纯粹是我对g++ 的猜测,所以如果我错了,请纠正我。

    【讨论】:

    • 所有引号(仅)适用于被初始化的项目是类类型的对象,但在您的代码中,您正在初始化类对象的 一个数组类型。数组的大小为 1 的事实并没有改变它是数组类型的对象而不是类类型的对象这一事实。
    • 很奇怪。一方面,标准在两个地方说,当使用新表达式时,使用直接初始化。同时,[dcl.init.list] 似乎在说,在这种情况下,实际上它是应用的初始化形式而不是直接初始化,因为使用了大括号初始化器。如果是这种情况,则根据 8.5.4.3.3 实际执行聚合初始化,这又根据 8.5.1.2 导致复制初始化。但是,即使是这种情况,它仍然属于 8.5.17.6.1,这仍然意味着应该只调用一个复制构造函数。
    • 这里的{x} 是数组的初始化器,因此创建另一个临时A 来为A[1] 类型创建初始化器。然后A[1] 被复制初始化,其中一部分将是A 的复制构造函数的第二次调用。 可能出于多个原因(包括 as-if 规则)而被忽略,但是否保证会被忽略?
    • 根据 gcc no-elide-constructors 禁用所有 ctor 省略,包括标准中描述的那些。这是一个创建合规行为的选项。
    • @Swift-FridayPie 您能否提供一段解释临时创建的标准的摘录?在我的研究中,我在答案和上面的评论中显示,我找不到任何关于创建临时的提及。
    猜你喜欢
    • 1970-01-01
    • 2016-07-25
    • 1970-01-01
    • 1970-01-01
    • 2020-06-29
    • 1970-01-01
    • 2015-06-09
    • 2023-03-30
    相关资源
    最近更新 更多