42 Consider emplacement instead of insertion
假定有一个容器存放 std::string,当通过插入函数(insert push_front push_back insert_after)插入一个新的元素,传给函数的数据类型是 std::string。
不过这也不总是成立的。
std::vector<std::string> vs; // container of std::string
vs.push_back("xyzzy"); // add string literal
std::string,不过传入 push_back 的是字符串字面量,不是 std::string,即传入参数类型不是容器持有的元素的类型。
std::vector 的 push_back 对左值和右值有重载:
template <class T,
class Allocator = allocator<T>>
class vector
{
public:
void push_back(const T &x); // insert lvalue
void push_back(T &&x); // insert rvalue
};
const char[6])和 push_back 的参数类型(std::string 的引用)不同。通过从字符串字面量创建一个临时 std::string 可以解决这个问题,然后将这个临时对象传递给 push_back。也就是说,在编译器看来这个调用是
代码正常工作,但是性能并不是最佳的。
为了在 std::string 中创建一个新的元素,必须调用 std::string 的构造函数。但是上述代码调用了两次,还调用了一次 std::string 的析构函数。下面是调用 push_back 运行时发生的事情。
- 从字符串字面量
"xyzzy"构造一个临时的std::string对象,该对象没有名字,这里称为temp好了。构造temp是第一次调用std::string的构造函数。因为这是一个临时对象,所以是右值。 temp传递给接受右值引用的push_back函数,绑定到右值引用参数x上。在std::vector内存中构造一个x的副本。第二次构造是创建一个位于std::vector内的对象。由于x是右值引用,所以调用的是移动构造函数。- 当
push_back返回,temp被销毁,调用std::string的析构函数。
如果能直接将字符串字面量传入 push_back 然后类似第二步这样在 std::vector 内构造一个 std::string,这样就可以避免构造和销毁 temp 对象。这样就能性能最大化了。
这里需要调用 emplace_back 而不是 push_back。
emplace_back 使用传入的参数在 std::vector 内构造一个 std::string。这个过程不涉及临时对象。
emplace_back 使用完美转发,只要不是遇到其限制(参考 Item),可以向 emplace_back 传入任意个数的任意类型的参数。比如可以用接受一个字符和重复次数的 std::string 在 vs 内部构造一个 std::string 对象。
支持 push_back 的容器都支持 emplace_back。类似的,有 push_front 的容易都支持 emplace_front,支持 insert 的容器也支持 emplace。关联容器提供 emplace_hint 作为接受 hint 迭代器的 insert 函数的补充。std::forward_list 有 emplace_after,对应于 insert_after。
emplace 接口比插入接口强的一点是更灵活。必须传入要插入的对象给插入函数,但是可以传入可以构造要插入对象的参数给 emplace 函数。这使得 emplace 可以避免构造和销毁临时对象,而插入函数无法避免这一点。
由于传入 emplace 函数的参数类型可以是容器持有对象的类型(函数通过调用拷贝或移动构造函数实现),emplace 也可以用于对于插入函数而言也没有临时对象的场景。这种情况下,插入和 emplace 几乎做同样的事情。比如
vs.push_back(queenOfDisco); // copy-construct queenOfDisco at end of vs
vs.emplace_back(queenOfDisco); // ditto
emplace 能够完成所有插入函数能做的事情。有的时候 emplace 更高效,理论上至少不会更低效。为什么不总是使用它呢?
因为理论上是这样的,但是实际上是有区别的。在当前标准库的实现下,是有些场景使用 emplace 更高效,不过也有一些场景插入函数速度更快。这些场景不容易描述,因为这取决于传递的参数类型,使用的容器类型,要插入或 emplace 元素的位置,元素构造的异常安全性,对不允许插入重复值的容器(std::set std::mao std::unordered_set std::unordered_map)值是否在容器中。一般建议是说通过基准测试来确定使用插入函数还是 emplace。
下面是一些启发式的方法来帮助确定如何选择。
1. 新增的值是构造到容器,而不是赋值 本节开头的例子满足这一点。值本身不存在,因此新值被构造到 std::vector。如果考虑新的 std::string 被插入的位置,事情会发生变化。比如
std::vector<std::string> vs; // as before
// add elements to vs
vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs
vs[0] 的内存处构造被添加 std::string。通常会移动赋值把值放到对应位置。不过移动赋值需要一个被移动的对象,这意味需要创建一个临时对象。由于 emplace 主要优势就是没有临时对象的创建和销毁,因此当通过赋值来添加值的时候,emplace 就没有优势了。
通过构造还是赋值添加新的元素取决于实现,不过启发式的想法还是有帮助的。基于节点的容器通常使用构造的方式添加新的值,并且大部分容器都是基于节点的。只有 std::vector std::deque std::string 不是。std::array 也不是,但是不支持插入或 emplace,与这里讨论无关。对于不是基于节点的容器,使用 emplace_back 来使用构造而不是赋值向容器添加元素,对于 std::deque,使用 emplace_front 也是一样的。
- 传入的参数类型与容器持有的类型不同 如果需要新添的对象类型是
T,容器是container<T>,没有理由期望emplace比插入更快,因为使用插入接口也没有临时对象的创建。 - 容器大概率不需要拒绝插入重复值 这意味容器要么允许重复值的插入,要么大部分值都是唯一的。为了检测值是否在容器内,
emplace实现往往是创建一个新的节点,然后与容器内已有的节点进行比较。如果不存在则添加新节点。否则,emplace取消操作,销毁新节点,这意味创建和销毁新节点的开销都浪费了。相比插入函数而言,这类节点的创建更多是为了emplace。
之前的两个例子都满足上述条件,所以 emplace_back 都会运行的更快。
vs.emplace_back("xyzzy"); // construct new value at end of container; don't pass the type in
// container; don't use container rejecting duplicates
vs.emplace_back(50, 'x'); // ditto
emplace 函数,还有两个点需要考虑。第一个是资源管理。假定有持有 std::shared_ptr<Widget> 的容器
并且我们希望通过 std::shared_ptr 自定义删除器。一旦有了自定义删除器,我们无法通过 make_shared 来创建 std::shared_ptr,只能先 new 一个裸指针然后交给 std::shared_ptr 管理。
假定自定义删除器是下面的函数
那么使用插入函数的代码如下ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
ptrs.push_back({new Widget, killWidget});
std::shared_ptr 的对象在调用 push_back 之前被创建。因为 push_back 的参数是 std::shared_ptr 的引用,所以必须要有一个 std::shared_ptr 引用指向的实际对象。
这个例子中,使用 emplace_back 可以避免临时 std::shared_ptr 被创建出来。不过这个例子中,临时对象创建的意义远大于其开销。考虑如下场景。
new Widget创建了一个裸指针指向的对象,然后创建临时对象std::shared_ptr<Widget>持有这个裸指针。这个临时对象称为temp,push_back接受temp的引用。在分配一个临时节点来持有temp副本的时候,OOM 了。- 异常从
push_back中出来,temp对象被销毁。唯一管理Widget的std::shared_ptr对象会负责调用killWidget销毁这个Widget对象。
异常发生了,但是没有泄露任何资源。如果使用 emplace_back 替代 push_back,就会有问题。
new Widget创建的指针被完美转发,然后创建一个list节点准备持有该裸指针,但此时 OOM 了。- 异常从
emplace_back中出来,那个在栈上的裸指针是唯一访问Widget对象的途径,此时消失了,那个Widget及其持有的资源泄露了。
这种情况下,对象的生命周期处理问题,但是不是 std::shared_ptr 的错。有自定义删除器的 std::unique_ptr 也会有这个问题。类似 std::shared_ptr std::unique_ptr 资源管理的有效性的基础是自愿(比如 new 的裸指针)要立即传给资源管理对象。这也是 std::make_shared std::make_unique 这类函数自动做这些事情的原因。
利用资源管理对象的容器(比如 std::list<std::shared_ptr<Widget>>))插入函数,函数的参数确保了在获取资源(比如 new)和构造管理资源的对象之间没有其他操作。在 emplace 中,完美转发延迟了资源管理对象的创建,即当容器中有内存的时候再构造,这就为发生异常导致内存泄露开了一个窗。所有的标准容器都会有这个问题。所有当使用资源管理对象的容器,选择 emplace 而不是插入函数,要确保不能为了性能而损失了异常安全。
直白地说,我们不应该传入诸如 new Widget 到 emplace_back 或 push_back 或其他函数,这可能会导致异常安全问题。解决方法是使用独立的语句将 new Widget 的指针放到资源管理对象中,然后传递右值到你想传入 new Widget 的函数。下面是 push_back 和 emplace_back 的例子。
std::shared_ptr<Widget> spw(new Widget, // create Widget and
killWidget); // have spw manage it
ptrs.push_back(std::move(spw)); // add spw as rvalue
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));
spw 的成本。为了保证资源创建和转移给资源管理对象之间没有任何操作,这个开销是必须付出的。因此 emplace 就不会比插入快了。
第二个值得讨论的是 emplace 与 explicit 构造函数交互。C++11 已经支持了正则表达式,假设创建了一个包含正则表达式的容器
nullptr 指针表示的正在表达式,这是如何成功的呢?尝试写如下代码
如果使用 push_back 而不是 emplace_back,编译器也会报错。
可能的原因是可以使用字符串来创建 std::regex 对象。如下代码是合法的。
从字符串构造 std::regex 是相对耗时的事情,为了降低意外出现这种开销的可能性,接受 const char * 的 std::regex 构造函数是 explicit。这就是什么下面的代码无法编译。
这两种情况都需要将指针转化为 std::regex,但是 explicit 使得这种隐式转化被禁止。
当调用 emplace_back 的时候,没有声明要传入 std::regex 对象,传入的参数是能够构造 std::regex 的参数。无需考虑隐式转化。下面的代码是合法的。
const char * 的 std::regex 构造函数需要指向的字符串是一个合法的正则表达式,但是空指针不是。
我们先讨论如下类似的代码有着不同的结果。
使用标准术语说,用于r1 的初始化语法是拷贝初始化。初始化 r2 的语法(小括号,大括号也适用)是直接初始化。拷贝初始化不允许使用 explicit 构造函数,但是直接初始化可以用。这就是为什么第一个不能编译而第二个可以。
回到 push_back 和 emplace_back,emplace 使用直接初始化,可以用 explicit 构造函数,而插入函数使用拷贝初始化,不能用。因此
regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex
// ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor
emplace 函数,要小心确保传入正确的参数,因为编译器会考虑 explicit 来寻找一种合理的方式解释代码。
Things to Remember
- In principle, emplacement functions should sometimes be more efficient than their insertion counterparts, and they should never be less efficient.
- In practice, they're most likely to be faster when (1) the value being added is constructed into the container, not assigned; (2) the argument type(s) passed differ from the type held by the container; and (3) the container won't reject the value being added due to it being a duplicate.
- Emplacement functions may perform type conversions that would be rejected by insertion functions.