问题实例
如果当前有一个容器,其内部元素类型为std::string,当我们通过插入函数(insert、push_front、push_back或者是std::forward_list的insert_after)传递一个std::string类型的元素到容器中时,理论上容器内部将会存在这个元素。之所以说是“理论上”,是因为可能未必如此,考虑下述代码:1
2std::vector<std::string> vs; // container of std::string
vs.push_back("xyzzy"); // add string literal
显然,我们插入的并不是一个string而是一个字符串常量,也就是说我们并没有插入容器持有的元素类型。
性能分析
std::vector对左值和右值均进行了重载,其形式大致如下:1
2
3
4
5
6
7
8template <class T,class Allocator = allocator<T> >
class vector {
public:
…
void push_back(const T& x); // insert lvalue
void push_back(T&& x); // insert rvalue
…
};
在调用语句1
vs.push_back("xyzzy");
编译器将发现实参类型(const char [6])与push_back声明的形参类型(reference-to-std::string)并不匹配,因此它们通过从字符串常量创建临时std::string对象来解决不匹配问题,并将该临时对象传递给push_back。 因此,调用语句可看作为:1
vs.push_back(std::string("xyzzy")); // create temp. std::string and pass it to push_back
尽管这段程序能够被正确编译和运行,但实际上存在着效率问题。在vector中创建一个元素必然会调用一次构造函数,但上述程序调用了2次string构造函数,并且还调用了一次string析构函数:
- 字符串常量创建了匿名string对象,此处发生了一次string构造,该匿名对象是一个右值。
- 该临时对象被传递给push_back后触发其右值重载版本,因此在vector内部移动构造了一个string对象,此处为第二次构造。
- push_back完成后临时对象被析构,此时触发析构函数。
emplace_back
通过上述分析可以得知,如果我们能够在vector内部直接利用参数构造对象,则无需付出临时对象的构造与析构成本。为了达到这目的,我们应当将push_back替换为emplace_back,后者的功能正是将利用传入参数直接在容器内部完成构造,根本不需要任何临时对象插手:1
vs.emplace_back("xyzzy"); // construct std::string inside vs directly from "xyzzy"
emplace_back使用完美转发,因此只要不使用Item30所述的任何禁形,开发者可以通过emplace_back传递任何数量、任何类型的参数,例如我们可以这样撰写:1
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters
所有支持push_back的标准库容器都支持emplace_back,同理,所有支持push_front的标准库容器都支持emplace_front,此外,所有支持insert的标准库容器(除了std::forward_list和std::array)都支持emplace。关联容器以emplace_hint以补足传统的insert函数,而std::forward_list使用emplace_after来弥补其insert_after。
emplace的优势在于相对于insert,它的接口更加灵活。insert需要插入对象,而emplace值需要构造函数的参数即可直接在容器内部完成构造,这一特性避免了临时对象的生成。如果传递的就是容器元素类型,此时insert和emplace均不会产生临时对象,因此可以认为二者等价。
emplace的使用
emplace似乎能够完成insert可以完成的一切任务,并且在某些场合具备更高的性能,那我们为什么不直接使用emplace取代insert呢?
唉,理想很丰满,现实很骨感。目前标准库的实现在某些方面与我们的设想不大一致,虽然大多数场景下emplace的确优于insert,但可悲的是,某些场景下insert要比emplace快。此类场景不太好判断,因为这取决于传递的参数类型、使用的容器、插入容器的位置、容器元素类型构造函数的异常安全性、容器是否允许插入重复的值、要插入的元素是否已经在容器中了等等。
emplace的使用条件
上文描述的很是含混,想必很难让读者满意,因此我们最终还是给出一组判断依据:如果以下条件全部符合,则emplace必然比insert高效:
元素必须被构造于容器内部,而非采取赋值形式。
现在我们简单地修改问题实例,令newString占据已存在元素的内存:1
2
3std::vector<std::string> vs; // as before
… // add elements to vs
vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs很少有编译器会将上述代码实现为构造一个std::string对象然后放到vs[0]位置上,大多数编译器的实现是进行移动赋值,将传入的参数直接移动赋值到目标位置。但是移动赋值需要传入的是一个对象,这就需要构建一个临时对象,然后移动过去,等操作完成后再将该对象析构。显然这种做法并没有发挥emplace的优势,我们依旧需要生成一个临时对象。
当然少有代码能够明确判断出当前执行的是构造还是赋值,但依然存在一条通用准则以供参考。基于节点的容器往往倾向于采用构造方式,并且大多数标准库容器均基于节点(std::vector、std::deque、std::string并非基于节点)。对于非节点式容器而言,push_back几乎总是触发构造(当然,deque的push_front也总是构造)。传递的参数类型和容器中的类型不同
emplace的优势便在于避免临时对象的构造和析构,如果传递的参数类型和容器中的类型相同,则不会产生临时对象,因此emplace也就毫无优势可言。容器并不因元素重复而拒绝接纳
这意味着容器允许内部元素重复,或者我们添加的大部分元素都是唯一的。为了检测某值是否已经存在于容器内部,emplace通常会创建一个具有新值的节点,以便将此节点的值与现有容器节点进行比较。如果不存在重复则加入该节点,否则emplace将负责析构该节点。一旦节点被析构,这意味着之前的构造成本均付诸东流。emplace构造节点的频率要高于insert,因此在使用前最好判断一下该条件。
资源管理
在上述条件之外还有两点需要考虑,其一在于资源管理。假设当前存在一个容器,其元素类型为std::shared_ptr<Widget>:1
std::list<std::shared_ptr<Widget> > ptrs;
并且我们想添加一个具备自定义删除器的shared_ptr,由于此种情况无法通过make_shared获取shared_ptr(见Item27),因此必须使用new获取原始指针。
假定现有删除器如下:1
void killWidget(Widget* pWidget);
那么insert版本大致如下所示:1
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
当然也可以写为如下形式:1
ptrs.push_back({ new Widget, killWidget });
无论哪种方式,在调用push_back之前都会构造一个临时的std::shared_ptr,原因在于push_back的形参是一个reference-to-shared_ptr,因此必须存在一个shared_ptr才行。emplace可以规避临时对象的产生,但我们可能需要为此付出更大的代价。试考虑以下事件序列
- 在上述的任一调用期间,编译器构造了一个临时的std::shared_ptr<Widget>对象以保存由“new Widget”产生的原始指针,将此对象称为temp。
- push_back通过引用获取temp,在分配节点以保存临时副本期间,抛出了out-of-memory异常。
- 当异常传播出push_back时,temp被析构,作为当前唯一指向Widget的sptr,它将自动调用killWidget以释放Widget。
可以看出,push_back版本具备一定的异常安全性,即使抛出异常也能够保证资源不被泄露。
假若当前以emplace替换insert版本:1
ptrs.emplace_back(new Widget, killWidget);
- 通过new Widget产生的裸指针,经过完美转发后进入push_back函数内部,然后容器开始分配内存用于构造新元素,在分配内存时抛出out-of-memory异常。
- emplace_back发生异常后指向Widget的原始指针析构,但其所指向的内存还没有释放,已经无法获取Widget占用的内存,触发资源泄露事件。
显然,使用emplace_back会导致程序可能产生内存泄漏,对于需要自定义删除器的unique_ptr亦同样存在这一问题,这也从侧面反映出std::make_shared和std::make_unique的重要性。
对于持有资源管理类的容器(例如std::list<std::shared_ptr<Widget> >)来说,insert确保在获取资源与资源管理类初始化之间不存在任何阻塞,而emplace的完美转发延迟了资源管理对象的创建(直到容器完成内存分配才执行构造),因此在异常触发时易于导致内存泄漏。所有标准库容器均存在此类问题,因此你必须在效率与异常安全性之间做出权衡。
在Item21中我们便已提及资源管理类初始化语句必须独立,我们不应当将new Widget这样的表达式作为参数传递给emplace_back、push_back,或是其他任何函数。正确的做法是完成资源管理对象构造后将其传递给emplace_back或push_back:1
2
3
4std::shared_ptr<Widget> spw(new Widget,killWidget); // create Widget and have spw manage it
ptrs.push_back(std::move(spw)); // add sp
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));
上述两种方案均存在spw对象的构造和析构的开销,而我们使用emplace_back替换push_back的目的就是为了避免临时对象的构造和析构开销,而spw与临时对象等价。但使用emplace_back往容器中插入资源管理对象时,必须要以某种策略保证资获取与资源管理对象构造之间不被干涉,这也就是spw对象存在的意义。
emplace与explicit的交互
第二个因素是和explicit构造函数交互相关。C++11引入了正则表达式,下述程序试图创建一组正则表达式对象并将其存放至容器内:1
std::vector<std::regex> regexes;
出于疏忽,你不小心写下了如下看似毫无意义的程序:1
regexes.emplace_back(nullptr);
众所周知,正则表达式对象可由字符串(这里并非特指std::string)构造:1
std::regex upperCaseWord("[A-Z]+");
从string创建std::regex需要较大的运行期成本,为了最大限度地降低开销,以const char *为形参的std::regex构造函数被声明为explicit,因此下述程序无法编译:1
2std::regex r = nullptr; // error! won't compile
regexes.push_back(nullptr); // error! won't compile
上述两条语句均需要执行隐式转换,但explict拒绝了此类转换。
然而在对emplace_back的调用中,我们并未明确声明传入std::regex对象,而是正在为std::regex对象传递构造函数参数,这并不被视为隐式转换请求,反而被编译器视为等价于如下程序:1
std::regex r(nullptr); // compiles
编译通过并不是什么好事,原因在于以nullptr初始化的正则表达式对象行为未知,可能对开发者最好的结局就是运行期崩溃。
暂且抛开push_back与emplace_back,我们将注意力投向这些非常相似的初始化语法,它们将产生不同的结果:1
2std::regex r1 = nullptr; // error! won't compile
std::regex r2(nullptr); // compiles
用于初始化r1(使用等号)的语法对应于所谓的拷贝初始化,用于初始化r2的语法(使用小括号,其实也可以使用大括号)产生所谓的直接初始化。拷贝初始化禁止使用显式构造函数,而直接初始化允许,这也就是二者一个可以编译一个不能编译的原因所在。
emplace使用直接初始化,而insert使用拷贝初始化,因此:1
2regexes.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时需要保证传递的实参正确,因为编译器为了找到某种方法将代码有效化会不惜使用显式构造函数。