从析构输出看 explicit、emplace_back 与 vector 扩容

发布于 · 修订 · 约 2 分钟

本文是我的 C++ 笔记的第三篇。

explicit#

explicit 用来防止类的构造函数被用于隐式转换。

class A
{
private:
    int x;

public:
    A(int x_) : x{x_} {}
};

// ...

A a = 3;  // OK
A a2{3};  // OK

这样是对的。而加上 explicit 之后:

class A
{
private:
    int x;

public:
    explicit A(int x_) : x{x_} {}
};

// ...

A a = 3;  // 编译错误:拷贝初始化不能调用 explicit 构造函数
A a2{3};  // OK

析构函数、移动构造与 emplace_back#

看下面的代码:

#include <iostream>
#include <vector>
using namespace std;

class A
{
private:
    int x;

public:
    A(int xx) : x{xx} {}
    A() : A(0) {}
    ~A() { cout << "goodbye " << x << endl; }
};

int main()
{
    vector<A> v;
    v.emplace_back(A{0});
    v.emplace_back(A{1});
    v.emplace_back(A{2});
    cout << "--------------------------------------\n";
}

它的输出是:

goodbye 0
goodbye 0
goodbye 1
goodbye 0
goodbye 1
goodbye 2
--------------------------------------
goodbye 0
goodbye 1
goodbye 2

原因如下。

首先,emplace_back 只有在传入构造函数的参数列表时才和 push_back 有区别,其他时候没有区别。emplace_back(A{0}) 相当于先生成临时对象 A{0},再把它拷贝构造到 v 里。注意 A 有用户声明的析构函数,所以编译器不会为它隐式生成移动构造函数,这里只能拷贝。语句结束时临时对象析构,于是输出一次 goodbye 0。

之后要加入 A{1},而此时 v 的容量只有 1,需要重新申请一块容量为 2 的空间。以 libstdc++ 为例:先在新空间里用临时对象 A{1} 拷贝构造出新元素,再把原来的 A{0} 拷贝过去,然后析构旧空间里的 A{0},输出 goodbye 0。emplace_back 返回之后,临时对象 A{1} 析构,输出 goodbye 1。

加入 A{2} 的过程一样:容量从 2 扩到 4,旧的 A{0}、A{1} 被拷贝过去后析构,最后临时对象 A{2} 析构。

最终,三个元素的生命周期随着 v 一起结束,依次析构。

我们把 capacity 打印出来,就能看到上面的猜测是正确的:

vector<A> v;
cout << "CAPACITY " << v.capacity() << endl;
v.emplace_back(A{0});
cout << "CAPACITY " << v.capacity() << endl;
v.emplace_back(A{1});
cout << "CAPACITY " << v.capacity() << endl;
v.emplace_back(A{2});
cout << "CAPACITY " << v.capacity() << endl;
cout << "--------------------------------------\n";

得到:

CAPACITY 0
goodbye 0
CAPACITY 1
goodbye 0
goodbye 1
CAPACITY 2
goodbye 0
goodbye 1
goodbye 2
CAPACITY 4
--------------------------------------
goodbye 0
goodbye 1
goodbye 2

而如果提前预留空间:

vector<A> v;
v.reserve(10);
v.emplace_back(A{0});
v.emplace_back(A{1});
v.emplace_back(A{2});

就不会再发生扩容,输出变为:

goodbye 0
goodbye 1
goodbye 2
--------------------------------------
goodbye 0
goodbye 1
goodbye 2

剩下的三次 goodbye 来自三个临时对象。如果直接把构造参数传给 emplace_back,连临时对象也没有了:

vector<A> v;
v.reserve(10);
v.emplace_back(0);
v.emplace_back(1);
v.emplace_back(2);
--------------------------------------
goodbye 0
goodbye 1
goodbye 2

结论#

  • emplace_back 以构造参数列表的形式传入(如 v.emplace_back(0))时,不论有没有移动构造函数,都是原地构造,只调用一次构造函数。只有这一点和 push_back 有区别,其他情况两者都一样。

  • 以左值对象的形式传入时,不论有没有移动构造函数,都调用一次拷贝构造函数。

  • 以右值对象的形式传入(例如 std::move(左值对象),或者本身就是临时对象,如 v.emplace_back(A{0}))时:

    • 有移动构造函数,调用一次移动构造;
    • 没有移动构造函数,调用拷贝构造函数。

    如果传入的是临时对象,那么过程是:构造临时对象,再移动构造(或拷贝构造)到容器里,最后临时对象析构。


2026 年 10 月修订。 修正了 explicit 的拼写和示例里重复定义的变量名,补上了 #include。扩容那一段按实际顺序重写:新元素先在新空间里构造,旧元素搬过去之后才析构,临时对象最后析构。补充了 A 没有移动构造函数的原因(用户声明了析构函数),以及直接传构造参数的对照实验。原文结论里借用的 Person("aaa", "shandong", 1991) 例子换成了本文的 A。上面的输出用 GCC 16(libstdc++,-std=c++17)验证过。容量按 1、2、4 翻倍增长是 libstdc++ 和 libc++ 的实现;MSVC 按 1.5 倍增长,输出会不一样。