这段C++代码在技术上发生了什么?

45

我有一个包含类A向量的类B。我想通过构造函数初始化这个向量。类A输出一些调试信息,因此我可以看到它是何时构建、摧毁、复制或移动的。

#include <vector>
#include <iostream>

using namespace std;

class A {
public:
    A()           { cout << "A::A" << endl; }        
    ~A()          { cout << "A::~A" << endl; }               
    A(const A& t) { cout <<"A::A(A&)" << endl; }              
    A(A&& t)      { cout << "A::A(A&&)" << endl; }            
};

class B {
public:
    vector<A> va;
    B(const vector<A>& va) : va(va) {};
};

int main(void) {
    B b({ A() });
    return 0;
}

现在,当我运行这个程序(使用GCC选项-fno-elide-constructors编译,以便不优化移动构造函数调用)时,我会得到以下输出:

A::A
A::A(A&&)
A::A(A&&)
A::A(A&)
A::A(A&)
A::~A
A::~A
A::~A
A::~A
A::~A

因此,编译器不仅生成一个实例的A,而是生成了五个实例。 A被移动两次并被复制两次。这让我感到惊讶。向构造函数传递的向量是通过引用传递的,然后将其复制到类字段中。因此,我期望只有一个复制操作或甚至只有一个移动操作(因为我希望传递给构造函数的向量只是rvalue),而不是两个复制和两个移动。可以有人解释一下这个代码究竟发生了什么吗?它在哪里和为什么创建了所有这些A的副本?

4
@Kao 还有五个对析构函数的调用。因此必须有五个不同的实例。 - kayahr
1
那不是统一初始化,看起来你正在使用initializer_list。 - sp2danny
1
@T.C. 确实。从控制台启动可执行文件可以看到 dtors 在复制构造函数之后被调用(在我按下一个键之后 :-) )。 - TobiMcNamobi
2
如果你想要在B的构造函数中移动向量,你需要提供一个重载函数,接受vector<A> &&或者通过值传递向量并使用: va(std::move(va))。如果你使用const引用,它总是会进行复制。 - T.C.
2
@kayahr 像我在答案中讨论的那样,你无法避免从initializer_list复制,因为没有办法从其元素中移动。您将不得不手动填充向量(使用push_back的rvalue引用重载或简单地使用emplace_back),然后将该向量std::moveB的构造函数中。 - T.C.
显示剩余11条评论
4个回答

42

反向查看构造函数调用可能会有帮助。

B b({ A() });

为构造一个 B,编译器必须调用接受 const vector<A>& 的 B 构造函数。该构造函数反过来必须复制向量,包括其所有元素。这就是你看到的第二个复制构造函数调用。
为构造要传递给 B 构造函数的临时向量,编译器必须调用 std::vectorinitializer_list 构造函数。该构造函数反过来必须复制 initializer_list 中所包含的内容。这就是你看到的第一个复制构造函数调用。
标准在 §8.5.4 [dcl.init.list]/p5 中规定了如何构造 initializer_list 对象:

类型为 std::initializer_list<E> 的对象从初始化列表中构造,就好像实现分配了 N 个类型为 const E 的元素的数组,其中 N 是初始化列表中的元素数。该数组的每个元素都使用相应的初始化列表中的元素进行复制初始化,并构造 std::initializer_list<E> 对象以引用该数组。

从相同类型的某个东西复制初始化对象会使用重载决议来选择要使用的构造函数(§8.5 [dcl.init]/p17),因此,对于相同类型的 rvalue,如果有可用的移动构造函数,它将调用该移动构造函数。因此,为了从花括号初始化列表构造 initializer_list<A>,编译器将首先构造一个由临时构造的 A 移动而来的一个 const A 数组,导致移动构造函数调用,然后构造 initializer_list 对象以引用该数组。
不过,我无法确定 g++ 中的另一个移动构造函数是从哪里来的。 initializer_list 通常只是一对指针,标准规定复制它不会复制底层元素。当从临时对象创建 initializer_list 时,g++ 似乎会调用两次移动构造函数。甚至在从 lvalue 构造 initializer_list 时也会调用移动构造函数
我最好的猜测是它按照标准的非规范性示例文字实现。标准提供了以下示例:
struct X {
    X(std::initializer_list<double> v);
};

X x{ 1,2,3 };

The initialization will be implemented in a way roughly equivalent to this:**

const double __a[3] = {double{1}, double{2}, double{3}};
X x(std::initializer_list<double>(__a, __a+3));

assuming that the implementation can construct an initializer_list object with a pair of pointers.

如果您字面上理解这个例子,我们案例中的initializer_list下层的数组将被构建为:

const A __a[1] = { A{A()} };

由于它创建了一个临时的A,从第一个临时对象复制初始化了第二个临时对象,然后从第二个临时对象复制初始化了数组成员,因此会产生两次移动构造函数调用。但是标准的规范文本明确指出应该只有一次复制初始化,而不是两次,所以这似乎是一个错误。

最后,第一个A::A直接来自A()

关于析构函数调用,没有太多讨论。在构建b期间创建的所有临时对象(无论数量如何)都将在语句结尾以构建顺序相反的顺序被销毁,并且存储在b中的A将在b超出作用域时被销毁。


* 标准库容器的initializer_list构造函数被定义为等价于使用list.begin()list.end()的构造函数,这些成员函数返回一个const T*,因此无法从中移动。在C ++14中,支持数组的底层数组变为const,因此更清楚地表明您无法从中移动或以其他方式更改它。

** 此答案最初引用了N3337(C ++11标准加上一些小的编辑更改),该数组具有类型E的元素而不是const E,并且示例中的数组为double类型。在C ++14中,由于CWG 1418的结果,基础数组被设置为const


那么这两个复制构造函数呢?一个是从 A::va 通过值左值初始化(即复制)调用的,但前者呢? - Manu343726
它来自使用initializer_list构建临时vector。@Manu343726的第三段。 - T.C.
这正是困扰我的问题。我之前已经确定它来自于时间向量初始化(请参见我的答案中的第4点),但我不明白编译器为什么选择了复制初始化而不是移动初始化。难道初始化列表不被视为rvalue吗? - Manu343726
@Manu343726 没有修改 initializer_list 中存储元素的接口,因此您无法从中移动。访问存储的元素的唯一方法是通过 begin()end(),并且两者都返回 const T* - T.C.
非规范示例令人不安。我想知道 double{1}... 的意图是否只是将 int 强制转换为 double;如果示例读作 X x{ 1.0,2.0,3.0 }; 就更好了。 - Niall
1
真的很烦人,一个被设计为初始化程序的东西(因此它几乎总是用作rvalue)不支持移动语义......但谢谢,这很有道理。 - Manu343726

5
尝试将代码分解一下,以更好地理解其行为:
int main(void) {
    cout<<"Begin"<<endl;
    vector<A> va({A()});

    cout<<"After va;"<<endl;
    B b(va);

    cout<<"After b;"<<endl;
    return 0;
}

输出结果类似(请注意使用了-fno-elide-constructors
Begin
A::A        <-- temp A()
A::A(A&&)   <-- moved to initializer_list
A::A(A&&)   <-- no idea, but as @Manu343726, it's moved to vector's ctor
A::A(A&)    <-- copied to vector's element
A::~A
A::~A
A::~A
After va;
A::A(A&)    <-- copied to B's va
After b;
A::~A
A::~A

3
考虑以下情况:
  1. 临时的A实例被实例化:A()
  2. 该实例被移动到初始化列表中:A(A&&)
  3. 初始化列表被移动到向量构造函数中,因此其元素被移动:A(A&&)编辑正如T.C.注意到的那样,初始化列表元素在初始化列表移动/复制时不会被移动/复制。就像他的代码示例所展示的那样,看起来在初始化列表初始化期间使用了两个rvalue构造函数调用。
  4. 向量元素由值初始化,而不是通过移动初始化(为什么?我不确定):A(const A&)EDIT:再次提醒,这不是向量而是初始化列表
  5. 您的构造函数获取了这个临时向量并将其复制(请注意您的向量初始化),因此元素被复制:A(const A&)

1
复制/移动 initializer_list 不会复制或移动底层元素,因此第二个移动构造函数调用很奇怪。 - T.C.

0

A::A

当临时对象被创建时,构造函数被执行。

first A::A(A&&)

临时对象被移动到初始化列表中(也是右值)。

second A::A(A&&)

初始化列表被移动到向量的构造函数中。

first A::A(A&)

向量被复制,因为B的构造函数需要左值,而传递了一个右值。

second A::A(A&)

再次,在创建B的成员变量va时,向量被复制。

A::~A
A::~A
A::~A
A::~A
A::~A

每当调用构造函数、复制或移动构造函数时,都会为每个右值和左值调用析构函数(当对象被销毁时,析构函数被执行)。


这个答案和我的一模一样,但是没有弄清楚这里到底发生了什么:请注意,正如T.C.所说,initializer_list在复制/移动期间不会移动/复制其元素。此外,它是initializer list,而不是initialization list。另外,vector并没有被复制两次,只有在初始化A::va时才会复制vector,A的构造函数采用左值引用,这也可以引用右值。明确地说,-1。 - Manu343726

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