Effective Modern C++:微调
这一章有两个对更具体话题的讨论,虽然不适于普适的应用场景,但通过阅读学习,也能一定程度上加深对 C++ 编程语言的理解。作者发挥了他一贯的先假设再否定的行文风格,如果先验知识不足,很可能跟不上思路。
条款 41:对于可以复制的形参,在移动成本低并且一定会被复制的前提下,可以考虑按值传递
条款标题很长,因为这个条款的前置条件很多,只有在这些前置条件都满足的情况下,才可以考虑将一个形参按值来传递。
阅读下文之前,需要已经明确搞清楚以下几个概念:
- 移动和复制
- 复制构造和复制赋值
- 左值和右值
- 左值引用和右值引用
和前边一样,我不会按原文的行文顺序去复述,而是按我理解地最容易被读者接受的顺序来介绍。
首先我们梳理下条款要说明的内容。我们知道,函数形参的传递方式,可以分为按引用(包括按指针,这里不讨论)和按值传递。为了保证参数传递的效率,通常会建议对于用户自定义类型或者复杂的容器类型使用按引用传递,避免拷贝数据带来的开销。
本条款想提出一个建议,如果一个类型作为形参时,无论它的复制成本有多重,当同时满足以下条件时,可以按值传递:
- 条件一:实现了复制操作(这意味着只移类型不满足)
- 条件二:移动操作成本很低
- 条件三:按值传递时,一定会被复制
条件二为什么是必要的?
下边给出要讨论的示例代码,我们需要实现一套普通成员函数:
1 | class Widget { |
示例中给出了三种实现,我们依次对这三种实现的参数传递代价做分析:
方案 1,通过引用传递参数,因为调用方的实参是通过引用传入被调用方形参,所以没有复制或移动的成本。在左值引用形参的重载版本中,引用形参 newName 通过复制传入 names;在右值引用形参的重载版本中,引用形参 newName 通过移动传入 names。
方案 1 成本合计是,对于传入左值实参,发生一次复制,对于传入右值实参,发生一次移动。
方案 2,通过万能引用传递参数,实参到形参的过程,依然没有复制或移动的成本。由于 std::forward 的操作,实际内部传入 names 时的行为,和方案 1 没有区别。
方案 2 成本合计同方案 1,对于传入左值实参,发生一次复制,对于传入右值实参,发生一次移动。
方案 3,按值传递参数,从调用方的实参到被调用方的形参过程中,无论是左值还是右值,都会进行一次构造,如果传入左值,发生一次复制构造,如果传入右值,发生一次移动构造(std::string 有移动构造,我们也假设本条款讨论的类型有移动构造)。在函数体,newName 一定会通过移动传入 names,因为 newName 作为局部变量,后边没有调用了。
方案 3 成本合计是,对于传入左值实参,发生一次复制一次移动,对于传入右值实参,发生两次移动。
这么看来,似乎方案 3 一定比方案 1 和方案 2 代价更高,无论左值还是右值,都会多一次移动操作。但回头看一下本条款的前置条件,其中包括 “移动操作成本很低”。也就是说,从上边的成本分析来看,满足条件的类型,使用方案 3 和使用 方案 1 或方案 2 的性能开销基本是相同的。
但使用方案 3 带来了什么好处呢?方案 1 中,我们需要编写多个类似的重载函数,代码更冗余,不利于维护;方案 2 虽然避免了方案 1 的问题,但使用万能引用又带来了其他问题,具体可回顾条款 26。方案 3 此时就有了用武之地。
条件一为什么是必要的?
解释完条件二的成本代价,我们回头再分析下条件一,为什么是必要的。
对于只移形参来说,使用上文的方案 1,通过重载的方式,因为只移对象没有复制构造函数,所以我们只需要编写一个接受右值引用的形参的重载版本即可。
那么,使用方案 1 最明显的代价,也就是编写多个重载版本函数的问题,也就不存在了,反过来说,使用方案 3 针对方案 1 的优势也就不存在了。此时,方案 3 还存在着额外移动一次的成本,总体就不值得推荐了。
条件三为什么是必要的?
这一条是表述歧义最大的一条,虽然我极力想要换一种描述方式,但并没有找到同样简略的用词,所以保留了书中翻译版本的结果(这里的 “复制” 二字是歧义的来源)。
条件三的 “一定发生复制” 是指,形参一定会被用于移动到另一个容器中,也就是上例中,newName 一定会被移动到 names 容器中。
书中给出的违反条件三的例子是,假设在将 newName 移动到 names 之前,先判断 newName 的长度,只选择一个范围内长度的 newName 才执行移动:
1 | void addName3(std::string newName) { |
当一个输入参数,长度不满足 [minLen, maxLen] 区间,那么就不会执行移动到 names 的操作。此时,如果换成引用传递参数,则不会带来任何开销,但使用按值传递参数,则仍然有构造的开销,从而放大了按值传递的缺点,不值得使用。
总结一下:
| 阶段 | 按引用传递,重载函数 | 按引用传递,万能引用 | 按值传递 |
|---|---|---|---|
| 形参构造阶段 | 无代价 | 无代价 | 左值一次复制构造,右值一次移动构造 |
| 形参传入容器 | 左值一次复制构造,右值一次移动构造 | 左值一次复制构造,右值一次移动构造 | 一次移动构造 |
当三个条件都被满足时,使用按值传递参数,其代价才是可接受的。
如果将复制构造改成赋值
前边讨论的是,使用 std::vector<T>::push_back 来实现对形参的处理,push_back 内部,会直接在容器堆内存的末尾创建新元素的空间(这里不考虑扩容时重新分配内存的问题),然后把数据拷贝到新元素空间(通过复制构造或移动构造)。
但是,如果我们不使用构造的方式,而是使用赋值的方式来操作形参呢?考虑以下示例(我们这里假设实参一定是左值,省略右值的讨论,左右值实参的区别可见上文):
1 | class Password { |
两个方案中,都使用了赋值的方式来使用形参,但方案 1 是按值传递参数,方案 2 是按引用传递参数。两者有什么区别?
对于方案 1,传递参数时,调用一次构造函数(左值调用复制构造,右值调用移动构造),赋值 text 时,调用一次移动构造;对于方案 2,传递参数时,不会发生成本,赋值 text 时,调用一次构造函数(左值调用复制构造,右值调用移动构造)。
然而我们要讨论的重点是进一步的话题。如果 text 之前已经有值,也就是 std::string 类型的对象,已经分配了一块堆内存来存放之前的一个旧密码。那么,如果新密码比旧密码长,会发生什么?反之如果新密码比旧密码短,会发生什么成本?这里我们不考虑 std::string 的小字符串优化,假设数据一定被存放在堆空间中。
对于方案 1,当新值比旧值更短时,调用 change1,形参 newPwd 通过构造函数创建,发生一次堆内存分配和一次内存拷贝。在函数体内,因为是移动赋值,通常的实现是 text 中指向堆内存的指针由之前的旧密码空间,改为指向新密码空间。之前旧密码空间被释放,没有额外构造成本。
当新值比旧值更长时,调用 change1,形参 newPwd 通过构造函数创建,同样发生一次堆内存分配和一次内存拷贝。在函数体内,同样直接通过移动操作,没有构造成本。
对于方案 2,当新值比旧值更短时,调用 change2,形参通过引用传递,没有额外构造成本。在函数体内,发生拷贝赋值,由于新值更短,直接使用原旧密码空间,不用重新分配内存。
当新值比旧值更长时,调用 change2,形参构造同样没有成本。函数体内,原旧密码空间不足以存放新密码,所以需要重新分配空间,然后释放旧密码空间,并使用拷贝赋值填充新密码数据。
总结一下:
| 阶段 | 按值传递,新值更短 | 按值传递,新值更长 | 按引用传递,新值更短 | 按引用传递,新值更长 |
|---|---|---|---|---|
| 形参构造阶段 | 一次分配空间、一次拷贝构造(左值实参) | 一次分配空间、一次拷贝构造(左值实参) | 无代价 | 无代价 |
| 赋值阶段 | 无代价 | 无代价 | 一次拷贝赋值(左值实参) | 一次分配空间、一次拷贝赋值(左值实参) |
很清晰地可以看出,若按值传递参数,那么分配空间是一定会发生的,无法重复利用之前旧密码已经开辟的堆空间。而按引用传递参数,当新数据比旧数据短时,可以避免重新分配空间。
这是一个潜在的性能陷阱,只有在特定的输入时,即新值比旧值更长、没有触发小字符串优化时,才会发生。
采用按赋值的方式使用形参的函数,按值传递参数的代价,取决于左值和右值的成分和类型是否要按动态分配内存。当然还涉及到赋值运算符的实现,以及输入参数对空间的需求与原始动态分配空间的大小之间的关系。对于 std::string 这种类型,还需要考虑优化的影响。
总结
按值传递参数带来的成本是很复杂的,本条款就是借此来展开讨论。通常情况下,惯用的做法是,优先使用重载或万能引用的方式,使用引用传递参数,除非一定要在这里去探究性能差异,才去考虑是否能满足本条款的三个条件,从而生成更有益的代码(改善引用传递参数实现的不足)。
原文末尾还有一段,讨论了按值传递参数时,如果实参是派生类类型,形参是基类类型时,传递参数带来的类型退化问题。这也是按值传递参数的一个缺点,我认为和本条款关系不大,也比较简单,不展开介绍。
条款 42:灵活选择置入还是插入
一个老生常谈的话题了。对于很多 C++ 标准容器,通常会提供 emplace 和 insert 两种操作,比如对于 std::vector 会提供 emplace_back 和 push_back 这两种将元素追加到容器队列结尾的操作。
本条款讨论了这两种不同的操作之间的差异,以及各自适用的场合。由于我实在没有找到更合适的 emplacement 的中文翻译,所以选择了中文译本的 “置入” 一词(虽然平时完全不会这么说)。
首先最基础的一点,想必大家都了解。插入类的操作,是将一个对象以移动或复制的方式,放在与该对象类型相同的容器元素类型容器中;而置入操作,是在容器的选定位置,直接构造一个该对象类型的对象,传入的是类型构造函数的参数而非类型本身。
比如说:
1 | std::vector<std::string> vec; |
a 操作为插入操作,其实等价于:
1 | vec.push_back(std::string("abc")); |
b 操作为置入操作。两者的区别是,a 操作会在传入实参前,做一次构造创建一个临时对象,因为是右值,所以通过移动构造传入 push_back 形参后,临时对象被析构。b 操作不会有这种动作,而是直接在容器末尾通过构造函数创建一个对象。
在这个例子中,使用置入要比使用插入效率更高。emplace 函数使用了完美转发,所以并不会带来右值参数传入时的额外成本。反过来,如果对于插入函数并不会创建临时对象的情况,使用置入函数,通常也不会有问题,置入不会带来额外成本。
不只是 emplace_back,任何支持 push_front 操作的容器,也都支持 emplace_front,任何支持 insert 操作的容器,也都支持 emplace 操作,任何支持 insert_after 操作的容器,也都支持 emplace_after 操作。
使用置入函数的推荐前提
虽然这么说,但上边我还是把 “通常” 加粗了。理论上来说,置入能完全取代插入,避免性能问题,但这只是理论上,如果想确定哪个更好,定量分析还是需要做基准测试。
定性讨论的话,书中提到,满足以下三条情况,使用置入操作替代插入操作是没问题的:
- 对于非节点型的容器(比如
std::vector,std::string)。它们以构造的方式添加值(而非赋值的方式),插入会带来额外的临时对象构造,但赋值的方式添加值,临时对象无法省略; - 传递的实参类型和容器所持有的类型不同。实参类型不同时,一定会调用容器类型的构造函数来创建临时对象;
- 容器不太可能出现重复的情况而拒绝添加值。比如不会拒绝重复元素的容器,和那些虽然拒绝重复元素,但大概率运行时不会遇到重复元素的场景;
置入函数在非性能方面的问题
还有两个场景,置入函数不值得推荐。
第一个场景是有关于资源管理时发生异常导致资源泄漏的问题。假设我们有一个管理智能指针的容器:
1 | std::list<std::shared_ptr<Widget>> ptrs; |
在之前条款 21 中提到,如果在指定 shared_ptr 智能指针时,还需要指定自定义析构器,就不能使用 make_shared 函数来辅助,但如果不使用 make_shared 函数,就可能遇到发生异常时资源泄漏的问题。比如说:
1 | // 这是自定义析构器 |
当在插入到容器时发生异常(比如内存不足),两种实现有什么差异呢?对于插入来说,由于会产生一个临时对象,所以 new Widget 的内存指针会保存在临时对象中,如果 std::list::push_back 插入失败,会调用这个临时对象的析构函数,从而将分配好的内存释放掉。
对于置入来说,new Widget 的内存指针直接通过引用方式传入 emplace_back 中,当容器内插入时发生异常,没有能调用的析构函数,异常直接会被抛出外边,内存指针被彻底丢失,发生内存泄漏。
在这种情况下,建议使用插入而不是置入。当然更合理的做法是正如条款 21 中所言,将 new Widget 的结果单独保存成一个对象,再将对象以移动的方式传入插入或置入函数中。只要资源作为栈对象被命名,那它最终会被析构。
另一个场景是有关于带有 explicit 修饰的构造函数类型的情况。说实话作者想的是真细。
具体来说,有一些类型定义中,有些构造函数使用了 explicit 来修饰,这意味着它的这种构造方式,只允许通过显示调用的方式来访问,这就暴露了插入和置入两者的区别。书中的例子很有趣:
1 | // 保存正则表达式的容器 |
编译器允许置入一个空指针,但不允许插入一个空指针。虽然,用空指针构造 std::regex 类型本身是没有意义的,但标准库中支持,原因是 std::regex 实现了一个由 const char* 作为形参的构造函数。本身它是为了支持将字符串形态的正则表达式构造为 std::regex,但却没有将 nullptr 排除在外(也是指针,可以传给 const char*)。
由于这个构造函数使用 explicit 来修饰,所以只能显式调用。而插入操作中,从 nullptr 到 std::regex 中间,使用了复制初始化,隐式调用了构造函数,所以编译器报错了。不过对于置入操作,它将 nullptr 传入 emplace_back 内部后直接调用了构造函数,所以编译器没有报错。
谬误:置入函数一定比插入函数更推荐使用。
结尾
翻开最后一页,发现是空白了,又读完了一本书。总体来说,这本书的内容很不错,对于有一定基础的 C++ 开发者来说,是一个查漏补缺的机会,我收获很大。期待 Scott Meyers 能出下一本 Effective 系列。
本系列的其他文章:
- Effective Modern C++ 读书笔记:类型推导
- Effective Modern C++ 读书笔记:auto
- Effective Modern C++ 读书笔记:转向现代C++
- Effective Modern C++ 读书笔记:智能指针
- Effective Modern C++:右值引用、移动语义和完美转发
- Effective Modern C++:lambda 表达式
- Effective Modern C++:并发 API
- Effective Modern C++:微调
本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1966160349238629902













