• C++编程:揭秘 emplace 如何帮你秒杀性能瓶颈
  • 发布于 2天前
  • 24 热度
    0 评论
  • BruceLe
  • 1 粉丝 57 篇博客
  •   
1.为什么我们需要 emplace?
问题背景
在 C++11 标准问世之前,我们向 STL 容器中添加新元素的主要方式是 push_back和 insert。这些方法直观易用,但在某些场景下,它们会触发不必要的性能开销。让我们通过一个简单的例子来观察这个问题:
#include <iostream>
#include <vector>
#include <string>

struct President {
    std::string name;
    std::string country;
    int year;

    President(std::string p_name, std::string p_country, int p_year)
        : name(std::move(p_name)), country(std::move(p_country)), year(p_year) {
        std::cout << "I am being constructed.\n";
    }

    President(const President& other)
        : name(other.name), country(other.country), year(other.year) {
        std::cout << "I am being copy-constructed.\n";
    }

    President(President&& other) noexcept
        : name(std::move(other.name)), country(std::move(other.country)), year(other.year) {
        std::cout << "I am being move-constructed.\n";
    }
};

int main() {
    std::vector<President> elections;
    std::cout << "Using push_back:\n";
    elections.push_back(President("Nelson Mandela", "South Africa", 1994));
}
上述代码的输出会是:
Using push_back:
I am being constructed.
I am being move-constructed.
这里发生了什么?
President("Nelson Mandela", "South Africa", 1994)创建了一个临时对象。
push_back函数接收这个临时对象(作为右值),并在 std::vector内部为新元素分配内存。
vector调用 President的移动构造函数,将临时对象的内容移动到 vector管理的内存中。
临时对象在表达式结束后被销毁。
即使有移动语义(C++11 的一大改进)的优化,我们仍然无法避免一次临时对象的构造和一次移动构造。如果对象的构造或移动成本很高,或者对象不支持移动(例如只定义了拷贝构造函数),这里的开销将变得非常可观。

核心思想
问题的根源在于,我们创建了一个“中间”对象。我们真正想要的是直接在容器的内存空间里,用我们提供的参数构造出一个对象。这正是 emplace系列函数诞生的核心动机。emplace的核心思想是“就地构造” (in-place construction)。它不再需要我们预先创建一个对象再将其拷贝或移动到容器中,而是允许我们直接将构造函数所需的参数传递给容器,容器则负责在其内部管理的内存上直接调用构造函数,从而从根源上消除了临时对象的创建和数据移动。

历史沿革
emplace系列函数(emplace_back, emplace_front, emplace等)是 C++11 标准引入的一组重要特性,它们是现代 C++ 性能优化的关键工具之一。

2. 深入原理:emplace是如何工作的?
emplace的魔法效果背后,是 C++11 引入的两个强大的语言特性:可变参数模板 (Variadic Templates)和 **完美转发 (Perfect Forwarding)**。
关键技术
可变参数模板 (Variadic Templates)emplace能够接受任意数量、任意类型的参数,这完全归功于可变参数模板。其函数签名通常看起来像这样:
template<class... Args>
void emplace_back(Args&&... args);
这里的 class... Args就是一个模板参数包,Args&&... args则是一个函数参数包。这使得 emplace_back可以接收用于构造 President对象的所有参数,例如 emplace_back("Nelson Mandela", "South Africa", 1994)。完美转发 (Perfect Forwarding)仅仅能接收参数还不够,emplace还需要以“原汁原味”的方式将这些参数传递给元素的构造函数。这意味着参数的值类别(左值或右值)必须被保留。如果传递的是一个右值(如临时字符串),它应该被作为右值转发,以便触发移动构造;如果传递的是一个左值(如一个变量),它就应该被作为左值转发。

std::forward结合 C++ 的引用折叠规则(reference collapsing)实现了完美转发。通过 std::forward<Args>(args)...,emplace将参数包 args完美地转发给对象 T的构造函数。

工作流程
我们可以通过一个简化的 vector::emplace_back伪代码来理解其工作流程:
template<typename T, typename Allocator = std::allocator<T>>
class vector {
    // ...
public:
    template<class... Args>
    void emplace_back(Args&&... args) {
        // 1. 检查是否需要扩容
        if (size_ == capacity_) {
            reallocate(); // 如果需要,重新分配内存并移动现有元素
        }

        // 2. 在预留的内存位置上直接构造对象
        // 使用 placement new 在指定内存地址 (m_data + size_) 上调用构造函数
        // std::forward 确保参数以原始的值类别被传递
        new (m_data + size_) T(std::forward<Args>(args)...);
        // 堆代码 duidaima.com
        // 3. 增加容器的大小
        ++size_;
    }
    // ...
private:
    T* m_data;
    size_t size_;
    size_t capacity_;
};
关键点: new (m_data + size_) T(...)是一个 placement new表达式。它不会分配新内存,而是在一个已经存在的、由 m_data + size_指向的内存地址上调用类型 T的构造函数。

与 insert/push的本质区别
push_back(T&&): 接收一个已经构造好的对象(右值引用),然后在容器内部移动构造一个新的对象。
emplace_back(Args&&...): 接收用于构造对象的参数列表,然后在容器内部直接构造一个新对象。
emplace将对象的“构造”这一行为,从函数外部推迟到了容器的内部,从而避免了中间环节。

3. 性能对决:emplacevs. insert/push_back
场景分析
emplace的性能优势在以下场景中最为显著:
元素对象的构造函数开销较大时: 如果对象的构造函数需要进行复杂计算、文件I/O或大量内存分配,那么避免一次额外的构造(即使是移动构造)所节省的时间将非常可观。
元素对象不支持移动,或移动成本依然很高时: 对于没有移动构造函数的老旧代码,push_back会回退到拷贝构造,性能差距会更大。即使支持移动,如果对象管理着某些无法“移动”的资源(如某些硬件句柄),其移动操作可能并不比拷贝高效多少。
量化对比
让我们回到 President的例子,并添加一个计数器来精确追踪各种操作的调用次数。

案例1: std::vector
// ... (President class with counters)
std::vector<President> presidents;
std::cout << "--- emplace_back ---\n";
presidents.emplace_back("Ronald Reagan", "USA", 1981); // 只需要传入构造函数的参数
std::cout << "\n--- push_back ---\n";
presidents.push_back(President("Margaret Thatcher", "UK", 1979)); // 需要创建一个临时对象
分析结果:
emplace_back("Ronald Reagan", "USA", 1981):
构造函数: 1次 (在 vector 内部直接构造)
移动构造: 0次
拷贝构造: 0次
push_back(President(...)):
构造函数: 1次 (构造临时对象)
移动构造: 1次 (从临时对象移动到 vector 内部)
拷贝构造: 0次
emplace_back完胜,它节省了一次移动构造和一次临时对象的析构。

案例2: std::map
对于 map,insert接受一个 std::pair。
std::map<std::string, President> presidents_map;
std::cout << "\n--- map::emplace ---\n";
presidents_map.emplace("USA", President("Ronald Reagan", "USA", 1981));
std::cout << "\n--- map::insert ---\n";
presidents_map.insert(std::make_pair("UK", President("Margaret Thatcher", "UK", 1979)));
emplace可以更高效地直接构造 std::pair的两个部分,特别是当键和值都可以就地构造时。C++17 引入的 try_emplace在这方面做得更好(详见“避坑指南”)。

无差别场景
明确指出在哪些情况下 emplace的性能与传统方法几乎无异是同样重要的:
插入的是基本数据类型 (POD): 如 int, double, char*。它们的移动和拷贝成本极低,几乎可以忽略不计。
插入的是一个已经创建好的左值对象:
President reagan("Ronald Reagan", "USA", 1981);
presidents.push_back(reagan);    // 调用拷贝构造
presidents.emplace_back(reagan); // 同样调用拷贝构造

在这两种情况下,emplace_back和 push_back都会调用 President的拷贝构造函数。因为传递给它们的是一个左值 reagan,emplace_back只是将这个左值转发给了 President的构造函数,最终匹配到的还是拷贝构造函数。


4. 实战演练:emplace在主流容器中的应用
以下是 emplace在不同容器中的可编译、可运行的代码范例。
#include <iostream>
#include <vector>
#include <deque>
#include <list>
#include <map>
#include <set>
#include <unordered_map>
#include <string>

/**
 * @class Widget
 * @brief A simple class to demonstrate construction.
 */
struct Widget {
    int id;
    std::string name;

    /**
     * @brief Construct a new Widget object
     * @param i The id of the widget.
     * @param n The name of the widget.
     */
    Widget(int i, std::string n) : id(i), name(std::move(n)) {
        std::cout << "  Constructing Widget " << id << " with name " << name << "\n";
    }
};

int main() {
    // --- 序列容器 ---
    std::cout << "1. std::vector::emplace_back\n";
    std::vector<Widget> v;
    v.emplace_back(1, "VectorWidget");

    std::cout << "\n2. std::deque::emplace_front\n";
    std::deque<Widget> d;
    d.emplace_front(2, "DequeWidget");

    std::cout << "\n3. std::list::emplace\n";
    std::list<Widget> l;
    l.emplace(l.begin(), 3, "ListWidget");

    // --- 关联容器 ---
    std::cout << "\n4. std::map::emplace\n";
    std::map<int, std::string> m;
    // emplace 构造 std::pair<const int, std::string>
    m.emplace(10, "MapValue");
    
    // 使用 std::piecewise_construct 进行更复杂的构造
    std::map<std::string, Widget> widget_map;
    widget_map.emplace(std::piecewise_construct,
                       std::forward_as_tuple("key1"),
                       std::forward_as_tuple(4, "WidgetInMap"));


    std::cout << "\n5. std::set::emplace\n";
    std::set<Widget> s;
    // 注意:set 的比较操作符需要被定义
    // 这里为了简化,我们假设Widget可以通过id比较(实际需要提供比较器)
    // s.emplace(5, "SetWidget"); // 这行需要比较器才能编译

    // --- 无序关联容器 ---
    std::cout << "\n6. std::unordered_map::emplace\n";
    std::unordered_map<int, std::string> um;
    um.emplace(20, "UnorderedMapValue");

    return 0;
}
5. 避坑指南:常见陷阱与最佳实践
emplace虽然强大,但并非银弹,错误的使用可能导致难以察觉的bug。

陷阱1:意外的隐式类型转换
这是 emplace最著名的陷阱。当构造函数有多个重载时,emplace传递的参数可能会通过隐式类型转换匹配到非预期的重载。
考虑一个持有 std::string的 vector:
std::vector<std::string> vs;
// 我们期望调用 std::string(size_t count, char ch) 构造函数
// 创建一个包含10个'c'的字符串 "cccccccccc"
vs.emplace_back(10, 'c'); // 实际行为是正确的

// 但是考虑这种情况:
class MyString {
public:
    // 接受C风格字符串的构造函数
    MyString(const char* s) { std::cout << "MyString(const char*)\n"; }
    // 接受布尔值的构造函数
    MyString(bool b) { std::cout << "MyString(bool)\n"; }
};

std::vector<MyString> vm;
vm.emplace_back(nullptr); // 你期望调用哪个?
nullptr可以被隐式转换为 const char*,也可以被隐式转换为 bool(值为 false)。这会导致编译错误或调用非预期的构造函数。相比之下,push_back(MyString(nullptr))的意图是明确的,因为它会先构造 MyString对象,此时编译器会完成正确的重载决议。

陷阱2:std::pair的特殊性:emplacevs. try_emplace
对于 std::map或 std::unordered_map,emplace在键已存在时,其行为可能出乎意料。
std::map<int, std::unique_ptr<Widget>> my_map;
auto ptr = std::make_unique<Widget>(1, "MyWidget");
my_map.emplace(1, std::move(ptr)); // 第一次插入,OK

// ptr 现在是空的
auto ptr2 = std::make_unique<Widget>(2, "AnotherWidget");

// 尝试用一个已存在的键再次 emplace
my_map.emplace(1, std::move(ptr2));
emplace的签名是 emplace(Args&&... args)。为了判断键是否存在,它必须先利用 args...**构造出 value_type**(即 std::pair<const int, std::unique_ptr<Widget>>)。在这个过程中,ptr2的所有权被转移给了这个临时的 pair。然后 emplace发现键 1已存在,插入失败。但此时 ptr2已经被移走,变成了空指针!

C++17 引入的 try_emplace完美解决了这个问题。
// C++17
auto ptr3 = std::make_unique<Widget>(3, "ThirdWidget");
my_map.try_emplace(1, std::move(ptr3));
try_emplace(Key&& k, Args&&... args)会首先检查键 k是否存在。只有当键不存在时,它才会使用 args...去构造 mapped_type (即 std::unique_ptr<Widget>)。因此,如果键已存在,ptr3的所有权不会被转移。
陷阱3:冗余的移动操作
即使用了 emplace,也可能无法完全避免移动。最常见的例子是 std::vector扩容。 当你调用 emplace_back时,如果 vector的 size()等于 capacity(),vector会分配一块更大的新内存,并将所有旧的元素从老内存区移动(或拷贝)到新内存区,然后才在末尾就地构造新元素。 emplace保证了新元素是就地构造的,但无法消除扩容带来的对存量元素的移动开销。

最佳实践
优先使用 emplace: 在大多数情况下,当你需要向容器中添加一个新创建的对象时,emplace是性能更优、代码更简洁的选择。
警惕隐式转换: 当构造函数存在多个可能匹配的重载时,如果 emplace的参数类型不是精确匹配,请考虑使用 push_back(T{...})或 insert(T{...})这种显式构造的方式,以消除歧义。
对 map类容器,优先使用 try_emplace(C++17): try_emplace提供了更清晰的意图、更强的异常安全保证,并避免了在插入失败时对值参数的意外移动。
预分配内存: 对于 std::vector和 std::unordered_map等,如果能预估元素数量,使用 reserve()提前分配足够内存,可以完全避免因扩容导致的元素移动,最大化 emplace的性能优势。
用户评论