介绍原书剩下的条款26-55。全文8.4k字。不熟悉C++的话阅读本文可能比上一篇要更加困难。本文同步存于我的Github仓库,
5 实现
26 尽可能延后变量定义式的出现时间
- 只要定义了一个变量, 当控制流到达这个变量时, 就不得不承担其构造成本和后续的析构成本
- 这个析构成本有时候由于提前返回, 异常, 分支等等原因, 我们需要无端背负它们, 无谓的构造, 然后无谓地析构
- 因此我们应该尽量延后变量的定义, 甚至直到我们能给它们初始化实参, 将其写在其它函数的构造序列中为止
- 循环中的变量是特殊情况, 如果目标的赋值成本低于构造+析构成本, 那么就应该在循环外定义, 否则在循环里面. 在循环外面定义变量有时候会造成误解
27 尽量少做转型动作
- 和其它很多语言不同, C++的显式转型在很多时候是可以避免的. 为了减少副作用尽量应该少转型
- C++有四大类新式转型, 处于方便后续查找和窄化转型的使用范围, 最好始终都用新式转型:
const_cast
: 将对象的const, volatile, __unaligned属性去除. 是唯一能做到这一点的新式转型符dynamic_cast
: 安全下转型(动态转型). 是运行时类型识别(RTTI)的一大工具, 可以将引用, 指针, 右值引用从基类转到派生类. 使用场景和static_cast
类似, 在下转型的时候会进行动态类型检查, 能提早排查出static_cast
找不出的错误. 指针转型失败的时候会抛出空指针, 引用失败则抛出异常.reinterpret_cast
: 低级转型. 对内存区域进行位级别的重新解释, 可以处理所有类型的指针. 很少使用. 其结果与编译器有关, 所以不可移植.static_cast
: 静态转型. 相当于传统的强制类型转换, 是最常用的转换. 只要目标类型不包含const, 就都可以用这个转换, 例如从void*中恢复原来的类型. 这个转换不会经过运行时检测, 所以使用的时候要小心.
- 使用转型的时候要注意转型往往会编译出运行时不同的代码, 例如int转double必然导致底层安排发生改变.
static_cast
将基类进行下转型后, 并不代表其就一定能调用派生类的函数了, 它仍然调用着基类自己的函数, 因为它是编译期实现的, 无法动态得知当前对象的继承关系dynamic_cast
适用于需要为一个认定为派生类的基类动态调用非虚派生类函数, 这是static_cast
做不到的. 由于会改变函数表的指向且需要进行大量继承分析, 因此这个转型很危险且很多时候执行得很慢. 最好还是尽量用虚函数多态来解决这个问题- 尤其要用多态来代替if语句完成的一连串
dynamic_cast
, 大量的dynamic_cast
得到的代码又大又慢
28 避免返回handles指向对象内部成分
- 在设计成员函数的时候要小心不要创建了一些低封装性的接口暴露了内部高封装的成员, 例如一个public函数返回了一个private成员的引用就完全破坏了封装
- 解决方法是遇到这类需求的时候, 给返回值加上const
- 但是即便如此返回诸如指针和引用的handles仍然危险, 用户可能会因为返回了一个右值的handle从而得到了一个空悬指针
29 为"异常安全"而努力是值得的
- 声称自己是异常安全的函数需要向外提供以下三个保证之一:
- 基本承诺: 如果异常被抛出, 程序的所有事务仍然保持在有效的状态下, 不会有类似互斥锁没有释放的情况, 但有可能改变部分程序状态, 例如流的读取记号改变
- 强烈保证: 如果异常被抛出, 程序状态不改变, 这种函数的执行只有完全成功和回滚两种可能
- 不抛出: 承诺在内部处理并吞掉异常, 外部一切都能正常继续运行. 所有的内置类型都是不抛出的, 是最强烈的保证
- 我们应该尽可能编写异常安全的代码
- 不抛出保证实际上很难达成, 大部分时候我们只能妥协于其它两个保证
- copy and swap是能产生强烈保证的一大技巧: 首先将需要修改的对象创建出一个副本, 然后修改那个副本, 一切正常的时候才用swap进行交换.
- copy and swap技巧通常借助pimpl, 也就是在类内部用智能指针指向实际运作的对象, 然后交换指针来实现.
- copy and swap并不总有强烈保证, 这是因为函数内部常常存在连带影响, 几个异常安全的函数由于可能修改了外部数据而导致它们的结合体不再安全
- 强烈保证许多时候需要巨大的开销来实现, 因此基本保证最终才是最常用的
- 一个软件要么就具备异常安全性, 要么就不要具备. 局部异常安全没有意义.
30 透彻了解inlining的里里外外
- 标记函数为inline后, 编译器就有能力进行优化了. 但inline优化通常策略是对每个函数调用都进行函数展开, 这可能导致生成的目标码太大, 产生额外的内存换页问题
- 编译器会权衡inline替换的函数和直接调用函数两者产生的目标码的代价差异, 自己决定是否优化
- inline和template一样, 通常被定义在头文件中, 这是因为编译器进行替换的时候需要能够直接明白它们的形式. 但不要将template和inline混淆, 没有理由要求template的所有函数都是inline的
- inline通常都是编译期行为, 很少环境能在连接期甚至运行期进行inline
- 几乎所有虚函数调用都不会inline
- 编译器拒绝inline的时候通常会提出一个警告
- 如果程序需要获取函数的地址, 那么那个函数会被编译器生成出outlined的版本
- 构造函数和析构函数往往很不适合inline, 因为它们常常会自动生成大量隐藏的代码, 例如对基类的构造, 大量的异常处理等. 如果它们被inline, 代码将会高速膨胀.
- inline的函数无法随着程序库的升级而升级. 当一个inline函数被改变时, 其它所有用到这个函数的文件都需要重新编译
- 通常我们无法对inline函数进行断点调试
- 建议一开始不要inline任何函数, 或者最多inline那些非常简单的函数上, 直到后期优化的时候再来用inline精细优化高消耗的代码.
31 将文件间的编译依存关系降至最低
- C++文件间相互依赖的本质原因是编译依存关系: 编译器必须在编译期间知道函数内定义的对象的大小, 这导致了编译器必须看见目标对象的定义
- 采用指针指向实现(pointer to implementation; pimpl)的设计思路可以解决这个问题, 因为此时编译器就了解对象大小只是一个指针. 为此需要为类编写两个头文件:
- 接口类头文件: 同时包含若干声明. 接口类有两种形式, 一种是一个在头文件中inline完整实现的委托类, 其成员只有一个指向实现对象的指针, 通过同步所有的接口并利用指针间接调用来实现. 令一种是写为一个纯虚基类, 所有接口都是纯虚函数用来多态调用实现类的具体函数.
- 实现类头文件: 以工厂模式扮演具现化对象的目标, 都有一个static的初始化函数以供接口类获得指向自己的指针. 这个初始化函数一般在接口类的构造函数或初始化列表中被调用. 别忘了虚析构函数
- 采用这种方法将实现和接口解耦的代价是运行期需要额外增加很多间接访问, 且对象增加一个实现指针或一个虚表指针的内存. 但是同样, 等到代价成为瓶颈时再去更新
- 减少编译依存关系还有以下两个老操作:
- 如果使用引用或指针就可以表述好一个对象的方法, 那么函数中不要用真正的对象.
- 如果可以, 尽量用class声明式来替换class定义式
6 继承与面向对象设计
32 确定你的public继承塑模出is-a关系
- 公有继承意味着派生类"is-a"是一种基类, 正如鸽子是一种鸟
- 所以公有继承需要主张: 所有基类可以使用的地方, D也可以派上用场, 尽管具体行为可能有所不同
- 不同的行为应该用虚函数来实现, 这非常重要
- 应该尽可能阻断那些派生类与基类的不同的接口, 运行期的方法是在派生类的对应函数中用error报错, 编译期的方法是额外分出细化的基类, 然后让特定的方法只在特定的基类中有对应的虚函数可重写(覆盖)
33 避免遮掩继承而来的名称
- 当一个名称同时在小作用域和大作用域中存在时, C++会进行遮掩(name-hiding), 至于这两个名称类型是否相同并不被考虑
- 这是非常危险的特性, 如下图派生类中的mf3函数会将基类的两个mf3一起进行遮掩, 无论基类那两个函数类型和形式是什么样的
- 因此对于公有继承来说, 如果进行了重写, 合理方法就是重写基类所有同名对应函数, 否则可以如下图通过在public域中用using将基类的名称重新提出才能得到想要的部分重写效果:
- 如果遮掩本身即是程序需要达到的效果, 那么应该转用private继承防止误导用户
34 区分接口继承和实现继承
- 这部分是为了从语义上来理解C++的继承设计
- 成员函数的接口总是会被继承
- 纯虚函数: 仅接口, 意味着希望派生类自己进行实现
- 非纯虚函数: 接口与默认实现, 也就是类内=0但类外单独实现的纯虚函数, 允许派生类按照Base::interface来使用基类提供的默认实现
- 非虚函数: 接口与强制实现, 不希望派生类对这个接口进行改变
35 考虑virtual函数的其他选择
- 非虚拟接口(non-virtual interface; NVI)手法: 令用户通过非虚公有函数来间接调用私有虚函数, 是模板方法设计模式的一种表现
- NVI手法的目的是在核心虚函数外面完成一些特定的外带行为, 令用户只修改核心部分, 但利用总的接口来使用
- NVI手法需要允许用户修改私有的虚函数, 这恰好是C++中"派生类可以重新定义继承来的私有虚函数, 因为继承而来的虚函数与基类无关"这个特性的应用
- 当派生类需要使用基类的一些内容时虚函数也会被写为protected
- NVI手法还可以进一步扩展为实现策略设计模式的函数指针方法, 使用函数指针来替代虚函数, 这让我们可以动态改变每个对象的某个行为
- 但是仅用函数指针还是太笨拙了, 使用标准库的模板类
std::function
可以将任何兼容于所需类型的可调用对象(函数, 函数对象, 成员函数...)作为参数接受 - 我们还可以对这个函数对象设置默认参数令其使用默认行为
36 绝不重新定义继承而来的non-virtual函数
- 非虚函数的继承是静态绑定的, 因此如果我们用基类指针指向派生类对象, 然后调用这个非虚函数, 或者反之操作, 都只会调用指针本身声明的那个类型下的函数, 无关其实际对象的类型
- 相类似的, 函数中的参数和引用在这类场景下也会产生相似的效果
- 这也是前面 条款7 和 条款34 的一种解释
37 绝不重新定义继承而来的缺省参数值
- 虚函数是动态绑定的, 但是函数的缺省参数值却是静态绑定的, 只与你填写这个缺省参数值时的类型有关, 与指针指向的实际类型无关
- 这种特性会在你试图使用多态的缺省参数值时产生非常奇怪的效果, 因此千万不要重新定义
- 这种需求可以用NVI手法进行一定程度的解决, 因为协议中我们永远不应该重写非虚函数, 所以在非虚函数中设置缺省参数是安全的
38 通过复合塑模出has-a关系或"根据某物实现出"特性
- 复合(composition): 指某种类型内含它种类型的对象(作为变量之类), 这主要是语义上的意义, 用来指导我们应该基于怎样的目的使用复合
- 当我们表示A有一个B时, 可以采用复合结构
- 当我们表示A由B实现(is-implemented-in-terms-of), 但是A并非B的派生类(例如stack默认由deque实现, 但是stack并不属于deque, 只是依据在deque上而已)
39 明智而审慎地使用private继承
- 由于访问限制的原因, 编译器无法自动将private继承的派生类转型为基类, 且派生类无法使用基类的成员
- 因此private继承实际上同样表示has-a/is-implemented-in-terms-of关系, 意味着我们只需要继承基类的实现而无需对应的接口, 只能通过重新定义基类的虚函数来使用对应的实现
- 但是private继承都可以用复合取代, 且复合能做得更好. 所以尽可能使用复合
- private继承的唯一好处是空间最优化, 当我们需要让自己的类拥有一个空类(连虚函数都没有的类)时, 如果用复合会占用至少1个字节(多数情况下由于对齐会是一个int的大小), 但是用继承则由于空白基类类最优化(empty base optimization)的原因不会占用额外的字节
40 明智而审慎地使用多重继承
- 多重继承可能会导致很多歧义, 例如要调用两个基类都有的同名函数时, 需要进行匹配性计算, 这个匹配计算与函数的访问性无关, 只和重载一样和名称与参数有关, 所以很容易二义
- 更复杂的情况是下图的"菱形继承":
- 菱形继承中, 对于不同基类都拥有的同名成员, C++默认会复制多份以供使用, 如果不希望复制就应该使用虚继承, 令我们想要承载数据的那个基类成为虚基类
- 虚基类让编译器动态计算成员所需的位置从而匹配, 但是使用了虚继承的类产生的对象会比非虚继承的对象又大又慢
- 所以非必要不要使用虚继承, 如果一定要用, 那么别在虚基类中防止成员数据. 这个规矩也就是Java等语言中对接口这种多重继承性质的类有特殊设计的原因
- 当用到这种虚基类作为接口时, 一般都采用公有继承, 因为其没有实际变量, 那么各种接口函数都应该是设计给用户使用的
7 模板与泛型编程
41 了解隐式接口和编译期多态
- 普通编程的时候一个类所需的都是显式接口(类头文件声明的接口)和运行期多态(继承而来的接口), 多数都是我们可控的
- 模板编程的时候我们看不到模板参数具体的头文件声明(因为模板参数本身是未定的, 在编译期才被具现化出来), 需要的是隐式接口(参数被传入模板后受到模板中的调用)和编译期多态(不同模板参数具象化出不同的模板导致了调用不同的接口), 很难把握
- 隐式接口并不基于函数签名式决定, 而是按照模板内的表达式决定, 我们提前进行的设计需要尽量满足表达式的输入和返回的类型
- 不管是显式接口还是隐式接口, 都在编译期完成检查, 因此我们都要好好检查, 可能被传入模板的类型到底能不能满足模板的隐式接口
42 了解typename的双重意义
typename一般出现在模板参数中作为参数前缀, 在这种情况下typename和class是等价的(但是typename较晚推出, 建议使用语义更清晰的typename)
当一个模板中某个名称依赖于模板参数时, 这个名称称为从属名称(dependent names). 当这个名称还处于模板类中时, 称为嵌套从属名称(nested dependent names). 如下:
template<typename T>
void foo(T& t){
// 这就是嵌套从属名称, 编译器拒绝这里的解析
T::const_iterator iter;
}
编译器无法在语法检查阶段判断T到底是不是一个类, 因此怀疑这个域作用符是无效的, 于是拒绝编译这一段代码
为了让这段代码通过, 我们需要将这一行改为typename T::const_iterator iter;
, 通过显式保证这个模板参数是一个类型, 编译器才会开始编译
当然如果传入参数有误编译器依然会报错
任何时候想要在模板中使用一个嵌套从属名称时都需要以上处理, 包括参数列中. 只有一种例外, 不允许在成员初值列和基类列中使用typename
部分编译器接受没有typename的代码的编译, 但这是不规范的, 我们还是应该手动写好
43 学习处理模板化基类内的名称
- 编译器无法知道模板类实际上继承了模板基类的什么内容, 因此我们无法直接在模板类中调用模板化的基类的成员
- 有三种方法处理这个问题:
- 在调用基类函数前加上this指针
this->foo();
, 用指针进行多态调用 - 用using声明式
using Base<Tmp>::foo;
将名字人工指定(这里并非暴露名称, 而是类似提前声明) - 直接指定基类来调用函数
Base<Tmp>::foo();
, 这是最不被推荐的做法, 因为这种做法完全关闭了虚函数的绑定行为
44 将与参数无关的代码抽离
- 模板在编写的时候非常方便, 但是一旦使用不当, 模板被编译器具现化的时候可能会产生非常多的重复二进制代码
- 和普通的函数编写不同, 模板的重复无法直观看出来, 需要想象目标模板被多个不同类型具现化的时候可能发生的重复
- 核心手法和简化普通函数相同, 就是将模板操作中不变的部分抽离出来, 独立成尽可能与模板无关代码, 通过指针之类的方法进行连接使用
- 但是简化的时候也要注意有些时候抽离得越复杂, 副作用就越多, 所以要形成效率与安全性之间的取舍
45 运用成员函数模板接受所有兼容类型
- 模板之间并没有什么类似继承关系的固有关系, 无法自动在继承类之间进行隐式转换, 智能指针类通过底层的转型来模拟这种关系
- 方法是编写用于指针间类型转换的构造模板, 称为成员函数模板(member function template)
- 智能指针类编写了非explicit的构造函数, 在自身底层是T类型的指针时, 接受一个U类型的指针作为构造函数的参数, 然后通过原始指针本身的转换和继承形式将T类型转为了U类型, 从而实现了模板类的隐式类型转换
- 这类的转换的接口形如下图:
46 需要类型转换的时候请为模板定义非成员函数
- 模板函数进行实参推导的过程中不会自动考虑模板函数的隐式类型转换, 因为从一开始编译器就看不见这个目标转换函数
- 如果想要写一个可以隐式处理模板类的函数(例如运算符), 需要用friend在目标模板类中指明需要的模板函数, 遮掩能将自身的构造函数暴露给编译器, 然后将这个函数inline放到模板类内, 从而编译器又能看到转型函数的声明, 且也一起提供了定义式供给连接器
- 如果想要减少这种奇怪的语法的影响, 可以选择让inline的函数去调用真正计算的函数, 起到跳板的作用.
47 请使用traits classes 表示类型信息
- traits是用来弥补C++缺少反射机制的模板库, 目的是对使用的类型进行一些基本信息的提取. 其本质是一种协议, 且运行在编译期
- traits的标准版本由一个由非常多偏特化版本的模板类经过复杂的重载形成, 我们通过对需要被traits提取信息的类自己声明出对应的typedef, 将自己类所属的类型
- 例如属于
random_access_iterator
的类typedef一个typedef random_access_iterator_tag iterator_category;
, 然后traits类会非常泛化地从类型中这个去取出对应的属性, 操作一般是:typename std::iterator_traits<T>::iterator_category();
- 借助重载我们可以在模板编程中进行一些编译期的if判断, 因为编译器只会选择最匹配重载件的实参来处理. 因此我们可以使用类似下面的代码在编译期根据traits的属性来对不同类型的类具现化不同的函数来运行
- 实际使用的时候我们再在每个可用这个与类型相关的函数上包装一个公有的控制函数, 从而将接口转为通用形式.
- C++标准库提供了多个适用于不同属性的traits和一组用来我们自己标注类属性的对应的struct. 但是到了C++11性能更加强大的type traits系列模板将我们从人工设定属性上进一步解放, 值得深入使用
48 认识template元编程
- 所谓模板元编程(template metaprogramming; TMP)就是编写运行在编译期的程序的技巧, 程序的计算结果将在编译结束的时候就得到, 无须经过运行阶段.
- TMP的优势是用好这个高阶技巧可以让我们获得更小的可执行文件, 更短的运行期, 更小的内存需求等等优势, 性能相当强大.
- TMP的缺点是需要付出长得多的编译时间
- TMP已经被证明是图灵完备的, 因此可以用来实现一切可计算的任务
- TMP的语法非常怪异, 核心三点写法如下:
- 变量: 使用前面 条款2 提到的使用enum来实现编译期常数定义的方法得到
- 循环: 没有真正的循环, 通过具现化模板递归来实现
- 条件: 没有if语句, 依靠模板重载来实现编译期判断
- 下面是TMP的入门例子, 在编译期进行阶乘计算, 使用struct是为了简化public声明. 主函数只要直接使用具现化的类中enumhack得到的常量即可
std::cout<<Factorial<10>::value;
, 这个值在编译期已经计算完了:
- 借助TMP我们可以在程序运行前完成很多工作, 实现早期的错误侦测, 实现按照客户要求生成代码, 还能优化很多算法, 还是有学习的意义的
8 定制new和delete
49 了解new-handler的行为
- new由于例如内存不足的原因抛出异常前, 其会尝试不断调用错误处理函数new-handler. 这个函数有全局默认的版本, 也可由我们自己指定
- 通过使用
set_new_handler(new_handler p)
函数可以以函数指针的形式将我们自己的函数设置为new-handler, 并返回之前绑定在global的new-handler - 一个设计良好的new-handler函数应该做到:
- 让更多内存可用: 不管是预留的或者是从其它位置分配
- 切换下一个new-handler: 如果当前的new-handler无法取得更多内存, 应该尝试另一个, 或者改变自己的行为
- 卸除new-handler: 将null传给set_new_handler的话就会退出这个查找循环
- 抛出bad_alloc: 当new-handler申请内存失败的时候抛出, 这个异常不会被new捕获
- 不返回: 无计可施时调用abort()或exit()结束
- 如果想让自己的类支持自定义new-handler, 应该在类内设定static的new-handler, 然后实现属于自己的set_new_handler函数, 当new调用的时候就会调用自定义版本的, 并将原本的new-handler保存下来, 将自己的new-handler设置到global并继续, 如果这个handle过程能够正常结束的话再将原先的new-handler恢复到global.
- 这个获取并恢复的过程通常利用如下的模板类来实现. 这个类核心部分就是常用的RAII操作, 获取-保持-析构时还原. 主要是模板部分比较奇怪, 其使用了模板参数但却没有用到它, 这是为了利用模板具现化来为每个不同的类具现化出实体互异的复件. 这种写法称为"怪异的循环模板模式"(CRTP):
- 有些旧版本的new函数会使用
std::nothrow
版本的, 分配失败的时候会返回null指针. 尽管new本身不会抛出异常, 但是这个null指针可能导致上层抛出异常, 见仁见智吧
50 了解new和delete的合理替换时机
- 重载自己的new函数主要是为了给标准的new操作加上额外操作, 最常见的就是特殊的内存分配器(例如池分配)和对内存分配的志记操作
- 如果要写自己的内存分配器的话, 要注意令返回的指针有正确的字节对齐, 建议还是用现有的其他人写的可靠的分配器
51 编写new和delete时需固守常规
- 编写new的时候有几个需要遵守的惯例:
- 必须返回正确的值, 如果有能力供给内存就返回指向内存的指针, 否则抛出bad_alloc
- 即便客户要求0 bytes, 也得返回一个合法的指针, 一般返回1 bytes
- new内部有一个无限循环反复询问下一个new-handler, 因此要设置到new-handler的退出行为
- new作为成员函数时一样会被继承, 所以要小心new被继承后行为是否还能正常使用
- new[]唯一需要做的就是返回一块长度正确的未加工的内存, 不要做任何额外的操作
- 编写delete比较简单, 保证delete nullptr的安全即可, 例如遇到nullptr时什么都不做
- 建议底层的操作都通过调用::new和::delete来完成, 自定义的版本做一些附加操作即可
52 写了placement new也要写placement delete
- new除了接受隐式传入的size_t外, 还可以接受其它的参数, 此时称为placement new, 最常用的是接受一个指针指向需要构造对象的内存位置
- 同样也有接受多个参数的placement delete
- 但是系统尽管一定会调用placement new, 却只会在new异常发生的时候才会查找那个参数个数和类型都与当前placement new相同的那个placement delete进行调用. 且如果找得到对应的placement delete, 那么调用, 如果找不到会调用默认的无参数delete
- 因此为了防止placement new导致额外的内存泄露, 一定要记得写好所有对应placement delete
- 一种简单的方法是用一个类似下图的基类写好常用的new和delete, 然后继承即可. 这下面写到的placement版本已经纳入标准库
9 杂项讨论
53 不要轻忽编译器的警告
- 草率编程然后倚赖编译器为你指出错误并不可取, 编译器团队比你强
54 让自己熟悉包括TR1在内的标准程序库
- TR1已经过时了, 但是目前的标准库更大了, 为了写好C++一定要了解
55 让自己熟悉Boost
- Boost是一个与C++标准委员会关系深厚的社群, 其接纳的程序库质量都很高很实用, 且很多设计有望进入未来的C++, 值得了解