17. 移动语义在标准库中的应用:std::vector::push_back 与 emplace_back 的区别

说实话,push_backemplace_back 的区别,是面试里高频出现的问题。但很多人只是背结论——「emplace_back 性能更好」。嗯,这话没错,但不够精确。我当年刚接触 C++11 时也这么以为,直到有一次在项目里用错了场景,反而把性能搞得更差了。

今天咱们就把这两个函数掰开揉碎,看看它们到底差在哪。

先看一个最简单的例子

假设我们有一个类:

class Widget {
public:
    Widget(int a, double b) : a_(a), b_(b) {
        std::cout << "构造函数\n";
    }
    Widget(const Widget& other) : a_(other.a_), b_(other.b_) {
        std::cout << "拷贝构造\n";
    }
    Widget(Widget&& other) noexcept : a_(other.a_), b_(other.b_) {
        std::cout << "移动构造\n";
    }
private:
    int a_;
    double b_;
};

然后我们往 std::vector<Widget> 里添加元素:

std::vector<Widget> vec;
vec.reserve(10);  // 先预留空间,避免扩容干扰

// 方式一:push_back
vec.push_back(Widget(1, 2.0));

// 方式二:emplace_back
vec.emplace_back(1, 2.0);

猜猜输出分别是什么?

push_back 版本:

构造函数
移动构造

emplace_back 版本:

构造函数

看到了吗?emplace_back 少了一次移动构造。为什么?因为 push_back 接收的是一个已经构造好的 Widget 对象,然后把它移动(或拷贝)进容器。而 emplace_back 直接把构造参数转发给 Widget 的构造函数,在容器内部就地构造。

核心区别一句话:

  • push_back:先构造临时对象,再移动/拷贝进容器
  • emplace_back:直接在容器内存里构造,零拷贝

完美转发在这里扮演的角色

你可能会问:emplace_back 是怎么做到「就地构造」的?

答案就是——完美转发

emplace_back 是一个可变参数模板函数,它的签名大致长这样:

template<class... Args>
reference emplace_back(Args&&... args);

它把 args 原封不动地转发给元素的构造函数。注意这里的 Args&& 是转发引用(也叫万能引用),配合 std::forward<Args>(args)... 实现完美转发。

说白了,你传左值,它就调左值版本的构造函数;你传右值,它就调右值版本。不会多一次拷贝,也不会多一次移动。

我个人习惯: 当需要往容器里添加新元素,且构造参数已经准备好时,我几乎只用 emplace_back。但如果你手里已经有一个现成的对象,比如从别处拿到的 Widget w,那 push_back(std::move(w))emplace_back(std::move(w)) 其实没区别——因为 emplace_back 内部也会调用移动构造。

什么时候 push_back 反而更好?

嗯,这里有个坑。我曾经在项目里无脑替换所有 push_backemplace_back,结果发现某些场景下代码反而变慢了。

举个例子:

std::vector<std::string> vec;
vec.reserve(10);

// 场景 A:emplace_back 传字符串字面量
vec.emplace_back("hello");  // 调用 string(const char*) 构造

// 场景 B:push_back 传字符串字面量
vec.push_back("hello");     // 先构造临时 string,再移动进容器

看起来场景 A 少了一次移动,应该更快对吧?但实际测试中,对于 std::string 这种小字符串(SSO 优化),移动构造几乎就是复制几个指针,开销极小。而 emplace_back 的模板实例化可能会让编译出的代码体积变大。

更极端的例子是 std::unique_ptr

std::vector<std::unique_ptr<Widget>> vec;

// 正确用法
vec.push_back(std::make_unique<Widget>(1, 2.0));

// 错误用法
vec.emplace_back(std::make_unique<Widget>(1, 2.0));  // 也能编译,但没必要

这里 push_backemplace_back 效果完全一样——都是移动构造 unique_ptr。但 push_back 的意图更清晰:我就是把一个已经构造好的智能指针放进去。

我曾经踩过的坑:emplace_back 里传初始化列表。比如 vec.emplace_back({1, 2, 3}) 会编译失败,因为模板参数推导无法从花括号初始化列表推导出类型。这时候必须显式写 vec.emplace_back(std::initializer_list<int>{1, 2, 3}),或者干脆用 push_back

性能对比:一张图看懂

下面这张 SVG 图展示了 push_backemplace_back 在添加元素时的完整流程对比:

push_back vs emplace_back 流程对比 push_back(Widget(1, 2.0)) ① 构造临时 Widget 对象 ② 移动构造到容器内存 ③ 销毁临时对象 emplace_back(1, 2.0) ① 完美转发参数 (1, 2.0) ② 在容器内存中就地构造 关键差异 push_back 多了一次临时对象的构造和销毁(或移动) emplace_back 直接构造,零中间对象

实际项目中的选择策略

说了这么多,到底该怎么选?我总结了一个简单的决策树:

场景 推荐用法 原因
已有现成对象,想放入容器 push_back(std::move(obj)) 语义清晰,且 emplace_back 不会更优
有构造参数,想构造新元素 emplace_back(args...) 避免临时对象,性能更好
元素类型是基础类型(int, double 等) 两者均可,无差别 基础类型没有构造/移动开销
需要传递初始化列表 push_back({...}) emplace_back 无法推导花括号类型
代码可读性优先 push_back 更直观,新手也能看懂

我的建议:

  • 日常开发中,如果构造参数已经准备好,优先用 emplace_back
  • 如果手里已经有对象,用 push_back 配合 std::move
  • 不要为了「性能」盲目替换所有 push_back——先测量,再优化

一个容易被忽略的点:异常安全

嗯,这里要提一嘴异常安全。C++11 之后,std::vectorpush_backemplace_back 都提供了强异常安全保证——如果插入过程中抛出异常,容器状态不会改变。

但有一个细节:emplace_back 是在容器内存中直接构造元素,如果构造函数抛出异常,容器需要保证已经分配的内存被正确回滚。而 push_back 是先构造临时对象,再移动进容器——临时对象的析构由编译器自动处理。

说白了,两者在异常安全上表现一致,但 emplace_back 的实现更复杂一些。好在标准库实现已经帮我们处理好了,你不需要操心。

总结

push_backemplace_back 的核心区别,就是「先构造再移动」vs「就地构造」。完美转发让 emplace_back 能够把参数原封不动地传递给构造函数,省去中间环节。

但记住,性能不是唯一标准。代码的可读性、维护性同样重要。我见过有人为了省一次移动构造,把代码写得晦涩难懂——这其实得不偿失。你想想看,一次移动构造才几个纳秒?而代码被误解导致的 bug 可能让你排查一整天。

所以我的原则是:用对场景,而不是用尽技巧


公众号:蓝海资料掘金营,微信deep3321