本章介绍一种通用技术(传值调用)和一种通用特性(原地构造),它们都受到多种因素的影响,作者能给的建议只是“考虑用一下”,实践上要根据具体情况来定。
Item41:对于可复制的、移动非常廉价、总是复制的参数,考虑调用时传值
有些函数参数就是要被复制的:
| 1 | class Widget { | 
上面的两个函数一个处理左值,一个处理右值,但实际上它们的逻辑都是一样的,但我们写了两个函数,两个实现。
假如你想用普适引用来代替上面两个函数:
| 1 | class Widget { | 
代码省掉了一份,但又导致了其它问题。作为模板函数,addName需要放到头文件里。而且它不一定只有两个实例化版本(左值和右值),所有可以用于构造std::string的类型都可能会实例化一个版本(参见Item25)。同时,还有一些参数类型没办法使用普适引用(参见Item30)。如果调用方传递错类型,编译错误信息会非常恐怖(参见Item27)。
一种方法可以让我们只写一个函数,且没有普适引用的各种问题:参数直接传值,不使用引用:
| 1 | class Widget { | 
这个版本中,我们知道:
- newName与实参没有关系,因此如何修改- newName都不会影响到实参。
- 这是newName最后一次被使用,因此移动它不会影响到后面程序的运行。
我们只需要写一个函数,因此:
- 避免了代码重复,包括源代码和目标代码。
- 没用普适引用,因此不会污染头文件,不会导致奇怪的运行失败或编译错误。
但它的开销如何?
当实参是左值时,实参到形参newName会有一次复制。但当实参是右值时,newName的构造会使用移动构造函数,因此它的构造开销是一次移动。后面构造names中的元素时,无论实参是左值还是右值,都是一次移动。
因此上面的方法中,当实参是左值时,开销是一次复制+一次移动;当实参是右值时,开销是两次移动。对比原来的重载版本,当实参是左值时,开销是一次复制;当实参是右值时,开销是一次移动。因此传值方法会比重载方法多一次移动的开销。
对于普适引用版本,情况有点复杂。当T是可以用于构造std::string时,普适引用在实参到形参中不会有对象构造,而是直接使用实参去构造names中的元素。本节我们不考虑这种情况,只假设实参是std::string,那么普适引用版本的开销与重载版本相同。
回头看一下标题,“对于可复制的、移动非常廉价、总是复制的参数,考虑调用时传值”:
- 你只能是考虑要不要用传值方法。它确实有很多优点,但它也确实比其它版本多一次移动的开销。一些场景下(后面会讨论),这次开销不可忽视。 
- 只能对可复制的参数使用传值方法。对于只能移动的类型,我们只能移动构造形参,就不存在需要写两个重载版本的问题,也就不需要使用传值方法了:直接传右值引用多简单。 
- 传值方法只适用于“移动非常廉价”的类型。 
- 只有当参数的复制不可避免时,才需要考虑传值方法。假如有某个分支下我们不需要复制参数,那么重载版本就不需要复制参数,而传值版本在调用那一刻已经复制完了,没办法省掉。 - 1 
 2
 3
 4
 5
 6
 7
 8
 9- class Widget { 
 public:
 void addName(std::string newName) {
 if ((newName.length() >= minLen) && (newName.length() <= maxLen)) {
 names.push_back(std::move(newName));
 }
 }
 ...
 };
即使上面三个条件都满足,也有场景不适用于传值方法。我们说复制时,不光是复制构造,还有复制赋值。考虑到这点,开销分析就更复杂了。
| 1 | class Password { | 
构造Password显然是可以用传值方法的:
| 1 | std::string initPwd("Supercalifragilisticexpialidocious"); | 
但在调用changeTo时:
| 1 | std::string newPassword = "Beware the Jabberwock"; | 
newPassword是左值,因此newPwd要进行复制构造,这里会分配内存。之后newPwd移动赋值给text时,text会释放自己原有的内存块,转而使用newPwd持有的内存块。因此changeTo有两次内存操作,一次分配,一次释放。
但我们这个例子中,旧密码比新密码长,因此如果我们使用重载方法,就不会有内存分配或释放(直接复制到旧密码的内存块上):
| 1 | void Password::ChangeTo(const std::string& newPwd) { | 
因此在这个例子中,传值版本比重载版本的开销多了两次内存操作,很可能比字符串的移动开销大一个数量级。
但在旧密码比新密码短的例子中,重载版本也没办法避免掉两次内存操作,这时传值方法的优势又回来了。
以上分析只针对实参为左值的情况,当实参为右值时,移动总是更好的。
由此可以看出,当有赋值时,可能影响结论的因素太多了,比如Password这个例子中std::string是否使用了SSO优化也会影响我们的结论。
实践中通常采用“有罪推定”原则,即优先使用重载方法或普适引用方法,直到传值方法显示出它的优势。对于那些对性能有极致要求的软件,传值方法就不太合适了。首先,多出的一次移动的开销可能很重要;其次,很难确定到底多了几次移动。假设我们构造链路上有N层,有可能每层的构造都使用了传值方法,看起来简单的一次构造实际上多了N次移动的开销。而且我们还很难发现这件事。
传值方法的另一个问题,是当有继承的时候,传值可能引发“切片问题”。当形参是基类而实参是派生类型时,实参到形参的构造会丢掉派生类型多出的部分,最终只得到一个基类对象。而传引用就不会有这个问题。这也是C++98中传值不被接受的一个原因。
Item42: 考虑用原地构造替代插入
假设我们有一个容器,元素类型是std::string。当我们向这个容器插入一个新元素时,新元素的类型是什么?直觉告诉我们,新元素的类型就是std::string。
但直觉不总是对的。看下面的代码:
| 1 | std::vector<std::string> vs; | 
这里,我们插入的新元素类型不是std::string,而是char[6]或char*。std::vector有两个版本的push_back:
| 1 | template <typename T, class Allocator = allocator<T>> | 
当编译器发现实参类型与形参类型不匹配时,它会生成一些代码,构造一个临时的std::string对象,效果类似于:
| 1 | vs.push_back(std::string("xyzzy")); | 
整个过程为:
- 构造临时对象。
- std::vector分配空间给新元素。
- 将临时对象复制到新的空间上。
- 析构临时对象。
于是这里偷偷的多了一次对象的构造和析构。另外还有一次临时对象的复制。当我们很关心性能时,这些额外开销是不可忽视的。
C++11新增的emplace_back方法就可以避免这个问题:
| 1 | vs.emplace_back("xyzzy"); | 
它会先分配空间,再在新空间上使用传入参数直接构造出std::string。每个支持push_back的容器也都支持emplace_back,支持push_front的容器也都支持emplace_front,支持insert的容器也都支持emplace。
一般来说insert和emplace的效果是完全相同的,同时emplace还省掉了临时对象的构造和析构,那么还有什么情况下我们不用emplace呢?
目前的C++标准库实现中,既有emplace比insert快的场景,也有emplace比insert慢的场景。这些场景很难列举,取决于传入的参数类型、使用的容器、新元素所处的位置、元素的构造函数的异常安全性,以及对于map和set类容器,要插入的元素是否已经存在等因素。因此在决定使用insert还是emplace前,先测一下性能。
当然也有些启发式的方法来判断emplace适用于哪些场景。以下条件如果为真,emplace就很可能比insert性能更好:
- 新元素在容器内直接构造,而不是先构造再赋值。 - 在前面的例子中,我们要在 - vs的尾部新增一个元素,显然这里之前不存在对象,我们只能构造一个对象。emplace此时就比较有优势。但下面这个例子中:- 1 
 2
 3- std::vector<std::string> vs; 
 ...
 vs.emplace(vs.begin(), "xyzzy");- 我们要在 - vs的头部新增一个对象。大多数实现会先用- ""xyzzy"构造出一个临时的- std::string,再移动赋值给目标对象。这样emplace相比insert的优势就没有了。- 当然,这取决于我们用的实现。但此时启发式方法还是有用的。理论上基于节点的容器都会构造新元素,而大多数STL容器都是基于节点的。只有几个容器不基于节点: - std::vector、- std::deque、- std::string(- std::array基于节点,但它没有emplace和insert类的方法)。当你明确知道新元素会被构造出来时,就可以毫不犹豫的使用emplace。这三个容器的- emplace_back都是推荐用的,对于- std::deque来说,- emplace_front也推荐使用。
- 实参类型与容器的元素类型不同。(解释略) 
- 容器不会因重复元素而拒绝插入。这里说的是对于 - std::set或- std::map这样的容器,在插入时需要比较,那么就需要把实参先构造为一个临时对象。这样emplace的优势就没有了。
下面两次调用就满足上面的条件:
| 1 | vs.emplace_back("xyzzy"); | 
在决定使用emplace后,有两个问题值得考虑。第一个是资源管理的问题。假设你有一个容器:
| 1 | std::list<std::shared_ptr<Widget>> ptrs; | 
Widget需要的自定义销毁函数是:
| 1 | void killWidget(Widget* pWidget); | 
根据Item21,这种情况下我们没办法用std::make_shared了。insert版本是:
| 1 | ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget)); | 
或:
| 1 | ptrs.push_back({new Widget, killWidget}); | 
无论哪种情况,都要构造出一个临时对象。这不就是emplace能避免的吗?
| 1 | ptrs.emplace_back(new Widget, killWidget); | 
但注意,临时对象带来的好处远比它的构造和析构成本要大得多。考虑一种情况:
- 我们构造了一个临时对象temp,持有new Widget的结果。
- 容器扩张时抛了个异常。
- 异常传播到外层,temp被销毁,Widget*被释放。
而emplace版本则是:
- new Widget的结果,一个裸指针,传进了- emplace_back函数内。
- 容器扩张时抛了个异常。
- 没有智能指针持有前面的裸指针,内存泄漏。
类似的问题也会出现在每个RAII类中。将裸指针(或其它未受保护的资源)通过完美转发的方式传递进emplace函数后,在RAII对象构造之前,有个窗口期。正确的方式是:
| 1 | std::shared_ptr<Widget> spw(new Widget, killWidget); | 
或:
| 1 | std::shared_ptr<Widget> spw(new Widget, killWidget); | 
无论哪种方式都要先构造对象,此时emplace和insert就没什么区别了。
第二个问题是emplace与explicit构造函数的相互作用。想象你有一个正则表达式的容器:
| 1 | std::vector<std::regex> regexes; | 
有一天你写了这么一行代码:
| 1 | regexes.emplace_back(nullptr); | 
然后编译器居然不报错!nullptr怎么可能是正则表达式呢?
std::regex r = nullptr是没办法编译通过的。而regexes.push_back(nullptr)也是非法的。
问题在于std::regex有一个接受const char*的析构函数:
| 1 | std::regex upperCaseWord("[A-Z]+"); | 
但它是explicit的,因此下面这么用会报错:
| 1 | std::regex r = nullptr; | 
但显式调用构造函数是可以的:
| 1 | std::regex r(nullptr); | 
不幸的是emplace函数中就是这么构造对象的,能编译,但运行结果未定义。
下面两种很类似的构造方式,但结果不同:
| 1 | std::regex r1 = nullptr; // Error | 
第一种是复制初始化,第二种是直接初始化。复制初始化不允许使用explicit构造函数,而直接初始化则可以。emplace函数中执行的是对象的直接初始化,而insert函数中则是复制初始化。
因此当你使用emplace的时候,注意看一下你传递的类型对不对,因为它会在你没注意到的时候绕开explicit的限制,然后制造一个大新闻。
目录
- Chapter1 类型推断 (Item 1-4)
- Chapter2 auto (Item 5-6)
- Chapter3 现代C++(Item 7-10)
- Chapter3 现代C++(Item 11-14)
- Chapter3 现代C++(Item 15-17)
- Chapter4 智能指针 (Item 18-22)
- Chapter5 右值引用、移动语义、完美转发(Item 23-26)
- Chapter5 右值引用、移动语义、完美转发(Item 27-30)
- Chapter6: Lamba表达式 (Item 31-34)
- Chapter7: 并发API (Item 35-37)
- Chapter7: 并发API (Item 38-40)
- Chapter8: 杂项 (Item 41-42)