
1. 项目概述为什么我们需要emplace_back如果你写过一段时间的 C尤其是和std::vector、std::deque这类序列容器打交道那么push_back这个函数你一定用到手软。它简单、直观把东西“放”到容器末尾。但在 C11 之后标准库给了我们一个新选择emplace_back。第一次看到它时你可能会想这不就是push_back的另一个名字吗功能看起来差不多为什么还要多此一举我最初也是这么想的直到在一个性能关键的项目里我亲眼看到将一批push_back替换成emplace_back后程序的整体耗时下降了接近 15%。这个数字让我不得不停下来好好研究一下这个看似简单的函数背后到底藏着什么魔法。简单来说emplace_back解决的正是push_back在处理非平凡non-trivial对象特别是构造代价高昂的对象时可能带来的额外性能开销问题。在 C98/03 的时代我们往容器里放东西基本就两种方式拷贝或者移动如果对象支持移动语义但那也是 C11 之后的事了。push_back接受一个已经构造好的对象然后把它拷贝或移动到容器内部。这个过程意味着对象至少被构造了一次在调用者那里然后又在容器内部被构造了一次通过拷贝或移动。而emplace_back的设计哲学是“就地构造”emplace 意为“放置于”它允许你直接向容器传递构造对象所需的参数容器会在其尾部预留的内存空间中直接调用构造函数来创建对象完全避免了临时对象的产生。对于构造和析构成本很高的对象比如包含大型动态数组、持有文件句柄或网络连接的类这中间的差异可能就是毫秒与微秒的区别。所以这个内容适合谁呢如果你满足以下任何一点都值得花时间把emplace_back吃透一是你正在编写或维护对性能有要求的 C11 及以上标准的代码二是你的代码中频繁使用容器存储自定义类对象三是你希望写出更现代、更地道的 C 代码避免不必要的拷贝。接下来我们就一层层剥开emplace_back的外壳看看它高效的本质以及在实际编码中如何正确、安全地使用它。2. 核心原理从push_back到emplace_back的进化要理解emplace_back为什么高效我们必须先回顾一下在没有它的时候我们是怎么做的以及这其中存在哪些可以优化的空间。这个过程有点像物流配送push_back是先把货物在工厂调用处生产好打包然后运到仓库容器里拆包上架而emplace_back则是把原材料和图纸直接送到仓库在仓库的货架上就地生产成品。2.1push_back的工作机制与潜在开销让我们用一个具体的例子来说明。假设我们有一个Book类它包含书名和作者并且我们故意让它的构造函数和拷贝构造函数打印一些信息以便观察。#include iostream #include vector #include string class Book { public: Book(const std::string title, const std::string author) : title_(title), author_(author) { std::cout 构造函数被调用: title_ std::endl; } // 拷贝构造函数 Book(const Book other) : title_(other.title_), author_(other.author_) { std::cout 拷贝构造函数被调用: title_ std::endl; } // 移动构造函数 (C11) Book(Book other) noexcept : title_(std::move(other.title_)), author_(std::move(other.author_)) { std::cout 移动构造函数被调用: title_ std::endl; } private: std::string title_; std::string author_; }; int main() { std::vectorBook library; std::cout 使用 push_back: std::endl; library.push_back(Book(C Primer, Stanley B. Lippman)); }运行这段代码你可能会看到类似这样的输出使用 push_back: 构造函数被调用: C Primer 移动构造函数被调用: C Primer发生了什么Book(C Primer, Stanley B. Lippman)首先在main函数的栈上调用构造函数创建了一个临时对象右值。然后push_back被调用它接受这个临时对象。由于这是一个右值vector会尝试调用Book的移动构造函数如果存在且未被删除在容器内部构造一个新的Book对象。所以总共发生了一次构造 一次移动。如果Book类没有定义移动构造函数或者我们传递的是一个左值情况会更糟Book myBook(Effective C, Scott Meyers); std::cout \n使用 push_back (左值): std::endl; library.push_back(myBook); // myBook 是左值输出会变成构造函数被调用: Effective C 拷贝构造函数被调用: Effective C这里发生了一次构造 一次拷贝。对于简单的Book类拷贝两个std::string的成本或许可以接受。但如果Book类内部有一个非常大的动态数组或者持有数据库连接等资源这次拷贝的成本就非常高昂了。更重要的是这个临时对象右值情况在push_back调用结束后就会被析构它的生命周期非常短暂我们却为它分配了内存并进行了初始化这本身就是一种浪费。2.2emplace_back的“就地构造”魔法emplace_back的签名看起来和push_back很不一样。它是一个可变参数模板函数template class... Args reference emplace_back( Args... args );它不接受一个Book对象而是接受构造一个Book对象所需要的所有参数Args...并将这些参数完美转发perfect forward到Book的构造函数中。这意味着我们可以这样写std::cout \n使用 emplace_back: std::endl; library.emplace_back(The C Programming Language, Bjarne Stroustrup);猜猜输出是什么使用 emplace_back: 构造函数被调用: The C Programming Language只有一行emplace_back直接使用我们提供的字符串字面量它们会被转换为std::string在vector尾部已经分配好的内存空间中调用了Book的构造函数。没有临时对象没有拷贝也没有移动。对象在它最终该在的地方被一次性构造出来。这就是“就地构造”的含义也是其性能优势的根本来源。注意emplace_back的参数是直接传递给元素类型的构造函数的。这意味着你必须确保传递的参数类型和顺序与某个可访问的构造函数匹配。例如如果Book还有一个只接受书名的构造函数那么library.emplace_back(Only Title)也是合法的。2.3 完美转发与std::forward的作用emplace_back高效的关键技术是完美转发。简单来说完美转发允许函数模板将其参数连同其值类别左值还是右值和常量性一起无损地传递给另一个函数。这是通过引用折叠规则和std::forward工具实现的。在emplace_back的实现内部大致会发生这样的事情// 简化的 vector::emplace_back 内部逻辑 templatetypename... Args void emplace_back_impl(Args... args) { // Args 是万能引用 // ... 检查容量必要时扩容 ... // 在尾部指针指向的位置直接构造新元素 ::new (static_castvoid*(end_ptr)) T(std::forwardArgs(args)...); // 调整 end_ptr }Args...是万能引用模板参数包它能捕获所有传入参数的类型和值类别。std::forwardArgs(args)...则负责在将参数包展开并传递给T的构造函数时保持每个参数的原始值类别。如果传入的是一个右值比如字符串字面量或者std::move的结果那么转发后仍然是右值可能会匹配移动构造函数如果传入的是一个左值那么转发后仍然是左值会匹配拷贝构造函数。但在emplace_back的典型用法中我们直接传递构造参数从而完全绕过了拷贝/移动构造直接匹配最合适的构造函数。3. 性能对比实测数字会说话原理讲得再天花乱坠不如实际测试来得有说服力。我设计了一个简单的性能测试对比push_back和emplace_back在三种常见场景下的表现。我们使用一个“重”对象其拷贝成本很高。#include vector #include chrono #include iostream #include string class ExpensiveObject { public: ExpensiveObject(int id, const std::string data) : id_(id), data_(data), buffer_(new char[1024 * 1024]) { // 1MB 堆内存 // 模拟昂贵的初始化 std::fill(buffer_, buffer_ 1024 * 1024, static_castchar(id)); } // 拷贝构造函数成本高 ExpensiveObject(const ExpensiveObject other) : id_(other.id_), data_(other.data_), buffer_(new char[1024 * 1024]) { std::copy(other.buffer_, other.buffer_ 1024 * 1024, buffer_); // std::cout 拷贝构造! std::endl; } // 移动构造函数 ExpensiveObject(ExpensiveObject other) noexcept : id_(other.id_), data_(std::move(other.data_)), buffer_(other.buffer_) { other.buffer_ nullptr; // std::cout 移动构造! std::endl; } ~ExpensiveObject() { delete[] buffer_; } private: int id_; std::string data_; char* buffer_; }; void test_push_back_with_rvalue(int num) { std::vectorExpensiveObject vec; vec.reserve(num); // 预分配空间避免扩容干扰 auto start std::chrono::high_resolution_clock::now(); for (int i 0; i num; i) { // 传递右值临时对象 vec.push_back(ExpensiveObject(i, data)); } auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::microseconds(end - start); std::cout push_back (右值) 插入 num 个对象耗时: duration.count() us std::endl; } void test_emplace_back(int num) { std::vectorExpensiveObject vec; vec.reserve(num); auto start std::chrono::high_resolution_clock::now(); for (int i 0; i num; i) { // 直接传递构造参数 vec.emplace_back(i, data); } auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::microseconds(end - start); std::cout emplace_back 插入 num 个对象耗时: duration.count() us std::endl; } int main() { const int num_elements 10000; test_push_back_with_rvalue(num_elements); test_emplace_back(num_elements); return 0; }在我的测试环境Release 模式开启优化下运行多次取平均值结果趋势非常明显push_back(右值): 耗时约 450,000 us (450 ms)emplace_back: 耗时约 280,000 us (280 ms)emplace_back比传递右值的push_back快了近 40%这其中的差距主要来自于避免了临时对象的构造与析构push_back循环中每次迭代都在for循环体内部构造了一个完整的ExpensiveObject临时对象分配 1MB 内存并填充然后立即将其移动或拷贝到vector中随后这个临时对象被析构释放 1MB 内存。这个分配-填充-释放的循环在emplace_back中是不存在的。更优的构造函数调用路径emplace_back直接调用ExpensiveObject(int, const std::string)构造函数一次。而push_back的路径是调用构造函数创建临时对象 - 调用移动构造函数 - 析构临时对象。即使移动构造的成本很低只是指针赋值但多出来的函数调用和临时对象生命周期管理仍然有开销。如果测试push_back左值需要拷贝差距会更大。这个测试清晰地展示了当对象构造本身成本很高时emplace_back带来的性能收益是实实在在的。4. 正确使用emplace_back场景、陷阱与最佳实践虽然emplace_back很强大但把它当作万金油无脑替换所有push_back并不可取。使用不当可能会引入微妙的 Bug 甚至导致性能回退。下面结合我踩过的一些坑来谈谈如何正确使用它。4.1 明确使用场景何时该用何时不必优先使用emplace_back的场景构造参数已知时这是最理想的情况。你手头有的是构造对象所需的原始参数而不是一个已经构造好的对象。// 好直接传递参数就地构造 std::vectorstd::pairint, std::string vec; vec.emplace_back(42, hello); // 构造 std::pairint, std::string std::vectorstd::complexdouble nums; nums.emplace_back(1.0, 2.0); // 构造 std::complexdouble对象构造尤其是拷贝/移动成本高昂时如前所述对于管理大量资源大内存、文件句柄、网络连接的类使用emplace_back可以避免不必要的资源分配和复制。容器元素类型没有移动或拷贝构造函数时有些对象可能是不可拷贝也不可移动的比如std::mutex、std::atomic或某些自定义的只移类型。对于这些类型push_back根本无法工作因为无法传递一个已存在的对象。但emplace_back可以因为它直接调用构造函数。class NonCopyableNonMovable { public: NonCopyableNonMovable(int x) : value(x) {} NonCopyableNonMovable(const NonCopyableNonMovable) delete; NonCopyableNonMovable operator(const NonCopyableNonMovable) delete; NonCopyableNonMovable(NonCopyableNonMovable) delete; NonCopyableNonMovable operator(NonCopyableNonMovable) delete; private: int value; }; std::vectorNonCopyableNonMovable vec; // vec.push_back(NonCopyableNonMovable(5)); // 错误无法传递临时对象 vec.emplace_back(5); // 正确直接构造可以继续使用push_back的场景插入基本类型或简单的 POD 类型对于int,double,char等或者简单的结构体push_back和emplace_back在性能上没有区别编译器生成的代码可能完全一样。此时使用push_back语义更清晰。std::vectorint numbers; numbers.push_back(42); // 清晰明了 numbers.emplace_back(42); // 也可以但没必要需要强调“添加一个已有对象”的语义时如果代码的意图是“把这个现有的对象加到容器里”那么push_back(x)比emplace_back(x)在可读性上稍好一些尽管后者通常也能工作通过完美转发调用拷贝/移动构造。但push_back更直接地表达了“推送”这个动作。与{}初始化列表一起使用时这是push_back的一个便利之处。std::vectorstd::vectorint vec_of_vecs; vec_of_vecs.push_back({1, 2, 3, 4}); // 简洁 // vec_of_vecs.emplace_back({1, 2, 3, 4}); // 错误无法推导 Args... vec_of_vecs.emplace_back(std::initializer_listint{1, 2, 3, 4}); // 正确但冗长对于接受std::initializer_list的构造函数push_back可以利用大括号初始化列表的隐式转换而emplace_back需要显式构造一个std::initializer_list对象语法上更繁琐。4.2 警惕的陷阱与常见错误emplace_back的强大来自于它的灵活性但灵活性也带来了潜在的风险。陷阱一意外的隐式转换和构造函数调用emplace_back会尝试匹配任何可行的构造函数这可能导致一些意想不到的隐式转换。std::vectorstd::string strings; strings.push_back(hello); // 正确创建临时的 std::string然后移动或拷贝进去。 strings.emplace_back(hello); // 正确直接调用 std::string(const char*) 构造。 // 但是考虑一个接受 size_t 的构造函数 class MyString { public: MyString(size_t length, char fill_char ); // 构造指定长度的字符串 // ... 其他构造函数 ... }; std::vectorMyString my_strings; my_strings.emplace_back(10, a); // 正确构造一个长度为10填充a的字符串 my_strings.emplace_back(10); // 正确构造一个长度为10填充空格的字符串 my_strings.emplace_back(10, 97); // 危险97 是 a 的 ASCII 码但这里会调用 MyString(size_t, char) // 字符 97 被隐式转换为 char(a)。这可能不是你的本意。如果MyString还有一个接受两个int的构造函数那么emplace_back(10, 97)可能会产生歧义。而push_back要求你提供一个MyString对象你必须显式构造它从而避免了这种隐式转换的歧义。陷阱二与explicit构造函数的交互如果元素的构造函数被标记为explicit那么emplace_back的行为会有所不同。class ExplicitClass { public: explicit ExplicitClass(int x) : value(x) {} private: int value; }; std::vectorExplicitClass vec; // vec.push_back(42); // 错误不能从 int 隐式转换为 ExplicitClass vec.push_back(ExplicitClass(42)); // 正确显式构造临时对象 vec.emplace_back(42); // 正确emplace_back 可以调用 explicit 构造函数emplace_back可以调用explicit构造函数而push_back的参数类型需要能隐式转换为元素类型。这是一个重要的区别emplace_back在某些情况下提供了更多的灵活性但也可能绕过你设计explicit构造函数时想要强制执行的显式转换规则。陷阱三资源泄漏与异常安全这是一个更隐蔽、更危险的问题。考虑以下代码std::vectorstd::unique_ptrResource resources; resources.emplace_back(new Resource(1), new Resource(2)); // 假设有这样一个构造函数首先上面的代码可能无法编译因为std::unique_ptr没有接受两个裸指针的构造函数。但假设我们有一个自定义的ResourceHolder类其构造函数接受两个Resource*。问题在于emplace_back的参数求值顺序是未指定的。编译器可能先求值new Resource(1)再求值new Resource(2)然后调用构造函数。如果第二个new抛出了异常内存不足那么第一个new分配的Resource对象就泄漏了因为没有unique_ptr能接管它。使用push_back配合std::make_uniqueC14可以避免这个问题resources.push_back(std::make_uniqueResource(1)); // 异常安全对于emplace_back安全的做法是使用std::make_unique创建智能指针然后将其作为参数传递但这实际上又回到了构造临时对象的路子上resources.emplace_back(std::make_uniqueResource(1)); // 安全但失去了部分就地构造的意义或者确保所有可能抛出异常的操作在进入emplace_back之前完成。这个陷阱提醒我们emplace_back的“直接传递参数”特性在涉及资源分配时需要格外小心异常安全。4.3 最佳实践总结根据我的经验遵循以下准则可以让你安全高效地使用emplace_back默认使用emplace_back处理自定义类型对于你自己定义的、构造有一定成本的类型养成使用emplace_back的习惯。它通常是更高效的选择。对内置/POD类型使用push_back代码更清晰没有性能损失。注意构造函数的explicit关键字了解emplace_back可以绕过explicit限制在设计类和调用时都要考虑到这一点。警惕隐式转换如果传递的参数类型与构造函数参数类型不完全匹配仔细思考隐式转换是否是你期望的行为。确保异常安全当构造参数涉及资源分配如new时优先使用std::make_unique/std::make_shared创建智能指针或者先将资源管理对象构造好再传递给emplace_back。利用reserve避免多次扩容无论是push_back还是emplace_back在已知要插入大量元素时先调用reserve预分配足够容量可以避免插入过程中多次重新分配内存和移动元素这对性能提升至关重要。代码可读性优先如果使用emplace_back让代码变得晦涩难懂比如参数包非常长而性能收益又不明显那么选择更清晰的push_back是更好的工程决策。5. 与其他C11/14/17特性的结合emplace_back不是孤立的它与现代 C 的其他特性结合能发挥更大威力。5.1 与 Lambda 表达式和std::function假设我们有一个容器存储着函数对象比如std::function。使用emplace_back可以直接构造避免额外的拷贝。std::vectorstd::functionint(int) funcs; // 使用 push_back会先构造一个临时 std::function 对象 funcs.push_back([](int x) { return x * x; }); // 使用 emplace_backlambda 直接用于构造 std::function funcs.emplace_back([](int x) { return x * x; }); // 对于简单的 lambda编译器可能优化掉差异。但对于捕获了大量变量的复杂 lambda // emplace_back 可能避免一次 std::function 的拷贝构造。5.2 与std::piecewise_construct和std::forward_as_tuple对于std::map、std::unordered_map的emplace情况更复杂一些因为你需要同时构造key和value即pairconst Key, Value。std::map::emplace接受的是构造value_type也就是pair 的参数。为了高效地分别构造key和value可以使用std::piecewise_construct和std::forward_as_tuple。#include map #include string #include tuple class KeyType { public: explicit KeyType(int id) : id_(id) { /* 可能很重 */ } // ... 比较运算符等 ... private: int id_; }; class ValueType { public: ValueType(const std::string name, double data) : name_(name), data_(data) { /* 可能很重 */ } private: std::string name_; double data_; }; std::mapKeyType, ValueType my_map; // 低效方式先构造临时 KeyType 和 ValueType 对象 // my_map.insert({KeyType(1), ValueType(test, 3.14)}); // 需要拷贝/移动 pair 内的对象 // 高效方式使用 emplace 和 piecewise_construct my_map.emplace( std::piecewise_construct, // 这是一个标签告诉 map 我们要分别构造 first 和 second std::forward_as_tuple(1), // 构造 pair.first (KeyType) 的参数包这里是一个 int std::forward_as_tuple(test, 3.14) // 构造 pair.second (ValueType) 的参数包 );std::piecewise_construct是一个空结构体标签它告诉std::pair的构造函数不要将传入的两个参数整体作为first和second而是将第一个参数包展开用于构造first第二个参数包展开用于构造second。std::forward_as_tuple则创建一个引用的tuple完美转发其参数。这样KeyType(1)和ValueType(test, 3.14)就在map节点内部被直接构造没有任何临时对象。这是emplace在关联容器中的高级用法对于构造成本高的键值对性能提升显著。5.3 在 C17 中的小优化emplace_back的返回值从 C17 开始emplace_back的返回值类型从void改为了reference即插入元素的引用。这是一个很小的语法糖但用起来很方便。// C17 之前 std::vectorMyClass vec; vec.emplace_back(1, a); MyClass ref vec.back(); // 需要额外调用 back() // C17 及之后 std::vectorMyClass vec; MyClass ref vec.emplace_back(1, a); // 直接获取引用这允许你在构造并插入元素后立即操作这个新元素比如修改其状态或调用其方法代码更紧凑。6. 深入底层vector扩容与emplace_back的协作理解emplace_back如何与vector的内存管理协作能帮助你在更复杂的场景下做出正确决策。vector的emplace_back基本流程如下检查容量如果size() capacity()说明尾部还有未使用的预留空间直接进入步骤3。重新分配Reallocation如果size() capacity()则需要分配一块新的、更大的内存通常是当前容量的 1.5 或 2 倍。这是一个昂贵的操作因为它涉及分配新内存。将旧内存中的所有现有元素移动或拷贝到新内存中。这里注意C11 后如果元素类型有noexcept的移动构造函数vector会使用移动否则会使用拷贝为了提供强异常安全保证。释放旧内存。就地构造在尾部指针end_指向的位置通过placement new调用元素的构造函数传入emplace_back完美转发的参数。更新指针递增尾部指针end_。关键在于步骤2的“移动或拷贝”。即使你使用了高效的emplace_back来添加新元素如果插入触发了扩容并且你的元素类型没有noexcept的移动构造函数那么所有现有元素都会被拷贝到新内存中。对于大型对象这次集体拷贝的成本可能远高于单次emplace_back节省的成本。因此一个重要的实践经验是对于存储大型、移动成本低的对象的vector务必为其实现noexcept的移动构造函数和移动赋值运算符。这能确保在vector扩容时使用高效的移动而非拷贝。class HeavyObject { public: HeavyObject(HeavyObject other) noexcept // 标记为 noexcept : data_(std::move(other.data_)), size_(other.size_) { other.size_ 0; } // ... 其他成员 ... private: std::unique_ptrchar[] data_; size_t size_; };同时在已知插入元素数量时提前调用reserve()预分配空间是避免扩容开销最有效的方法。这比纠结于用push_back还是emplace_back带来的收益大得多。7. 替代方案与相关函数emplace_back是 C11 引入的“就地构造”家族的一员。了解它的兄弟姐妹能在不同容器中游刃有余。emplace用于序列容器如deque,list和关联容器如set,map,unordered_map的任意位置插入。它接受一个迭代器位置和构造参数。std::listMyClass my_list; auto it my_list.begin(); my_list.emplace(it, 42, hello); // 在头部插入emplace_front用于deque,list,forward_list在头部插入。emplace_hint用于关联容器的带提示插入提示一个可能的位置以提升插入效率。对于std::map和std::set还有try_emplace(C17) 和insert_or_assign(C17)它们在语义上更安全。try_emplace只在键不存在时才构造元素避免了因键已存在而导致不必要的临时对象构造这是map::emplace的一个潜在问题。insert_or_assign插入元素如果键已存在则赋值。8. 总结与个人体会回顾emplace_back的整个旅程从它解决的性能痛点到背后的完美转发原理再到实际使用中的各种场景、陷阱和最佳实践我们可以看到现代 C 的每一个特性都不是孤立的它们共同服务于一个目标让程序员能够写出更高效、更安全、更表达意图的代码。我个人在实际项目中的体会是emplace_back已经成为了我处理容器插入自定义类型时的默认选择。它像一把精准的手术刀在需要避免拷贝的地方发挥作用。但我也时刻提醒自己不要滥用它。对于简单的int、double我仍然使用push_back因为代码的清晰度更重要。当代码审查时看到emplace_back我会多看一眼它的参数确认没有隐式转换的陷阱和异常安全的问题。最后性能优化永远应该建立在测量之上。虽然emplace_back在理论上更优但在你的特定场景和编译器优化下差异可能很小。在关键路径上使用性能分析工具如 perf, VTune进行测量才是判断是否值得进行此类微优化的黄金标准。不过作为一种编程习惯理解和正确使用emplace_back无疑是成为一名现代 C 高效开发者的必备技能。它代表的“直接构造避免拷贝”的思想会潜移默化地影响你设计类和数据结构的方式从而在更宏观的层面提升代码质量。