《Effective Modern C++》学习笔记 - Item 42: 考虑使用emplace代替插入

  • 为容器添加元素时,我们自然想到的是使用 插入(insertion) 函数,包括 push_backpush_frontinsert等。但是如果你追求极致的性能,那么这样做可能不是最优的。考虑下面的情况:
class MyString {
    
    
public:
    MyString() : str("default string.") {
    
    }

    MyString(const char* pc) : str(pc) {
    
    
        cout << "MyString: from string literal ctor" << endl;
    }

    MyString(const MyString& rhs) : str(rhs.get()) {
    
    
        cout << "MyString: copy ctor" << endl;
    }
    MyString(MyString&& rhs) : str(std::move(rhs.get())) {
    
    
        cout << "MyString: move ctor" << endl;
    }
    std::string get() const {
    
     return str; }
private:
    std::string str;
};

int main()
{
    
    
    vector<MyString> v;
    v.push_back("hello");
    return 0;
}

MyString 类只是 std::string 的一个包装,能打印出构造函数的调用方便观察。我们对容器 v 调用 push_back,但传入参数是一个 string literal 而非 MyString 对象。运行会发现,构造函数调用情况为:
在这里插入图片描述
分析调用流程:

  1. push_back 函数有两个重载版本,参数类型分别为 const T&T&&,本例中 TMyString。由于字面值 “hello” 不属于这两种类型(其类型为 const char*),所以首先要调用对应构造函数从 “hello” 创建一个临时的 MyString 对象,它是一个没有名称的右值,我们将其称为 temp
  2. temp 被传入 T&& 参数版本的 push_back,然后被 “拷贝” 一份,得到的对象存储在 std::vector 中。由于 temp 是右值,“拷贝” 是通过移动构造实现的。
  3. push_back 返回后,temp 被销毁。

不难看出,temp 对象的构建和销毁实际上是多余的,那么有没有一种方法能将 “hello” 字面值直接传给 std::vector,供其构建存储的对象呢?emplace 函数做的就是这件事。一句话总结,插入函数的参数是要被插入的对象,而 emplace 函数的参数是构建要被插入的对象的构造函数参数。因此 emplace 函数的参数是任意多个,它们会被 完美转发T 的构造函数。这样就避免了插入函数当入参对象需要转换才能得到 T 类型对象时不必要的临时对象的产生。

emplace 函数与插入函数一一对应,因此无需做额外记忆,例如 push_back 对应 emplace_backpush_front 对应 emplace_frontinsert 对应 emplace

  • 如此看来,emplace 函数是否是插入函数的完全上位替代呢?作者表示虽然理论上如此,但在实现上还是有一些场景插入函数的效率会更高。最佳的方法当然是对二者做 benchmark 比较,不过作者也给出了三个判断条件,如果三个条件都满足,那么 emplace 的性能几乎一定高于插入:
  1. 对象是通过构造,而非赋值置入容器中的。例如 vectoremplace_back 是在尾部插入元素,肯定是构造新对象;但 v.emplace(v.begin(), "hello") 是在头部插入元素,此时大部分实现会通过移动赋值将新对象赋给 v[0]。然而移动赋值需要一个源对象,于是还是要用传入的构造函数参数去创建一个临时对象,这样 emplace 函数的优势就消失了。大部分基于节点的容器都是通过构造添加元素的,而 vectorstringdeque 不是。
  2. 参数类型与容器元素类型不同。这也是我们本节引入 emplace 函数使用的初衷。如果入参就是一个容器元素类型的对象,那么插入和 emplace 函数没有区别。
  3. 容器不太可能因为元素重复拒绝新对象加入。对于 setmap 这样不允许存在重复元素的容器,对于插入一般会先用参数构造一个临时对象并与现有节点比较,判断是否接受该对象,这种情况下 emplace 函数一般会比插入函数更经常地创建临时对象(作者没有具体解释)。
  • 最后,使用 emplace 函数还需要注意两个 “坑点”:
    • 关于资源分配,如果容器的元素是 智能指针,如 std::list<std::shared_ptr<Widget>> ptrs,而且在调用 emplace 函数同时创建指针管理的对象:

      ptrs.emplace_back(new Widget, killWidget); // killWidget 是一个自定义 deleter
      

      这样的代码是危险的,因为如果在执行 new Widget 和利用两个参数构造 shared_ptr 之间抛出了异常,例如容器在分配节点时超内存了,那么 new Widget 分配的资源将泄漏。相比之下,调用 push_back 函数先创建了临时智能指针对象,保证即使发生异常 new Widget 的资源也不会泄漏。当然在智能指针一节也有写过:我们应该注意不让分配资源和智能指针构造间出现可能抛出异常的行为,因此上例可以改写成下面这样,就不会有危险了:

      std::shared_ptr<Widget> spw(new Widget, killWidget);
      ptrs.emplace_back(std::move(spw));
      
    • 关于 explicit 构造函数,以 C++ 中的正则表达式为例,std::regex 可以通过一个 const char* 对象构建,但由于这样构建的开销较大,因此标准中将其声明为 explicit,即只有显式调用时才成功,隐式转换不被允许。这导致了一个有趣的现象:假如你将应该放字符串的位置错误写为了 nullptr,那么使用 push_backemplace_back,结果将不同:

      vector<regex> regexes;
      regexes.push_back(nullptr);		// 编译错误
      regexes.emplace_back(nullptr);	// 编译成功,但程序运行会异常退出
      

      push_back 要求的参数是一个 std::regex 对象,而由于 nullptrstd::regex 对象的隐式构造转换被禁止了,所以会编译失败;而 emplace_back 是将 nullptr 作为参数调用构造函数创建一个 std::regex 对象,是 explicit 的,所以编译能够通过(当然这样错误的行为会导致运行时出现异常)。

总结

  1. 理论上,emplace 函数的效率应该永远大于等于插入函数的效率。
  2. 实践中,如果满足上面的3个条件,那么 emplace 函数几乎一定比插入函数效率高。
  3. emplace 函数可能会进行插入函数不会的类型转换。

猜你喜欢

转载自blog.csdn.net/Altair_alpha/article/details/123965556