17. 移动语义在标准库中的应用:std::vector::push_back 与 emplace_back 的区别
说实话,push_back 和 emplace_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_back 为 emplace_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_back 和 emplace_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_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::vector 的 push_back 和 emplace_back 都提供了强异常安全保证——如果插入过程中抛出异常,容器状态不会改变。
但有一个细节:emplace_back 是在容器内存中直接构造元素,如果构造函数抛出异常,容器需要保证已经分配的内存被正确回滚。而 push_back 是先构造临时对象,再移动进容器——临时对象的析构由编译器自动处理。
说白了,两者在异常安全上表现一致,但 emplace_back 的实现更复杂一些。好在标准库实现已经帮我们处理好了,你不需要操心。
总结
push_back 和 emplace_back 的核心区别,就是「先构造再移动」vs「就地构造」。完美转发让 emplace_back 能够把参数原封不动地传递给构造函数,省去中间环节。
但记住,性能不是唯一标准。代码的可读性、维护性同样重要。我见过有人为了省一次移动构造,把代码写得晦涩难懂——这其实得不偿失。你想想看,一次移动构造才几个纳秒?而代码被误解导致的 bug 可能让你排查一整天。
所以我的原则是:用对场景,而不是用尽技巧。
公众号:蓝海资料掘金营,微信deep3321