条款42 :考虑置入而非插入

ZaynPei Lv6

该条款的核心是介绍 C++11 引入的“置入函数”(如 emplace_back, emplace 等),并将其与传统的“插入函数”(如 push_back, insert 等)进行比较。在许多情况下,置入函数比插入函数更高效。这是因为置入函数避免了不必要的复制或移动操作,直接在容器中构造对象,而插入函数则需要先构造对象,然后再将其复制或移动到容器中。

插入函数和置入函数的根本区别在于它们接受的参数类型

  • 插入函数 (如 push_back, insert):接受一个对象容器所存储类型的对象)作为参数。
  • 置入函数 (如 emplace_back, emplace):接受用于在容器中构造一个对象的构造函数实参。它使用完美转发将这些参数直接传递给对象的构造函数。

因此, 置入函数的主要性能优势在于,它可以避免创建和销毁临时对象。

1
2
3
4
5
6
std::vector<std::string> vs;

// 插入操作
vs.push_back("xyzzy");
// 置入操作, 这里由于是字符串因此构造函数实参和对象一致
vs.emplace_back("xyzzy");
push_back 的过程:

  • 编译器首先从字符串字面量 “xyzzy” 创建一个临时的 std::string 对象。
  • 然后,这个临时的 std::string 对象(一个右值)被移动构造到 vector 的内存空间中。
  • 最后,这个临时的 std::string 对象被析构。
  • 整个过程涉及一次临时对象的构造、一次移动构造和一次析构。

emplace_back 的过程:

  • emplace_back 将其参数 (“xyzzy”) 完美转发到 std::string 的构造函数
  • std::string 对象直接在 vector 的内存空间中被构造出来。
  • 这个过程完全避免了创建和销毁临时对象,因此效率更高。

移动的本质是转移堆数据的所有权, 在上述示例中, string 的管理对象(指针等)在栈上, 但是实际的字符串数据在堆上, 这导致push_back相比emplace_back的性能提升不大(因为有移动语义的存在); 但是如果是一个大型对象, 其数据在栈上, 那么移动语义就无法发挥作用, 只能来拷贝构造, 这时push_back的性能损失就会非常明显

理论上,置入函数应该永远不比插入函数慢。在实践中,当以下三个条件都成立时,置入几乎肯定会比插入更高效:

  • 值是以构造而非赋值方式加入容器:这对于所有基于节点的容器(如 std::list, std::map)和在序列容器(如 std::vector)尾部 emplace_back 都成立。
  • 传递的实参类型与容器持有之物的类型不同:性能优势主要来自于避免创建临时对象。如果传递的实参类型与容器内元素的类型不同(如传递 const char* 给 std::vector),置入就能避免创建临时的 std::string。
  • 容器不太可能因为存在重复值而拒绝新值:对于 std::set 或 std::map 等不允许重复键的容器,emplace 的实现通常会先创建一个新节点,再将其与容器内现有节点比较。如果此时发现值已存在,这个新创建的节点就会被销毁,其构造和析构的开销就被浪费了。

置入的注意事项与陷阱

尽管置入很高效,但在使用时也存在两个重要的陷阱。

陷阱一:资源管理与异常安全 当容器持有资源管理类对象(如智能指针)时,使用置入函数可能会破坏异常安全。

1
2
3
4
5
6
7
std::list<std::shared_ptr<Widget>> ptrs;

// push_back 版本(异常安全)
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));

// emplace_back 版本(危险!)
ptrs.emplace_back(new Widget, killWidget);
push_back 的情况:std::shared_ptr 的临时对象在 push_back 函数被调用前就已经创建了,它安全地接管了 new Widget 返回的裸指针。如果后续 push_back 在分配内存时抛出异常,这个临时 shared_ptr 的析构函数会确保 Widget 被正确删除,不会发生资源泄漏。

emplace_back 的情况:new Widget 的结果(一个裸指针)被直接转发到 emplace_back 内部。如果在 emplace_back 内部、在为 std::shared_ptr 分配内存之前,发生了另一次内存分配失败(例如为 list 的节点分配内存),那么 new Widget 返回的裸指针就会丢失,导致资源泄漏。

陷阱二:与 explicit 构造函数的交互 置入函数使用直接初始化,而插入函数使用复制初始化。这意味着置入函数可以调用 explicit 的构造函数,而插入函数不能。 因为 emplace_back 的内部行为相当于直接调用构造函数,这遵循的是直接初始化 (Direct-Initialization) 的规则

1
2
3
4
5
6
7
8
std::vector<std::regex> regexes;
// 已知std::regex 的构造函数 std::regex(const char*) 是 explicit 的。

// 编译失败:push_back 使用复制初始化,不能调用 explicit 构造函数
regexes.push_back(nullptr);

// 编译成功!emplace_back 使用直接初始化,可以调用 explicit 构造函数
regexes.emplace_back(nullptr);
这里虽然 emplace_back(nullptr) 能通过编译,但将一个空指针传递给 std::regex 的构造函数会在运行时导致未定义行为。

因此, 置入函数可能会调用那些会被插入函数拒绝的 explicit 构造函数,这可能会“隐藏”一些潜在的 bug,让本应在编译期发现的问题延迟到运行时。

On this page
条款42 :考虑置入而非插入