More Effective C++
在译序中作者提到 C++ 提供了四种不同的思维模型: procedural-based, object-based, object-oriented, generic paradigm.
第二种意指“没有多态的面向对象”[1]
条款一:指针和引用
引用必定指向一个对象,而指针则不然。如果有一个变量它可能不指向某个对象,就使用指针(这时可以将其设置为 null),如果它必须要指向一个对象,就使用引用(此变量不允许为 null)。
以下代码会引起未定义行为,代码中不应当出现:
char* pc = 0; char& rc = *pc;
避免以上代码以避免引用为 null 的情况
由于引用必定指向某个对象,因此必须在声明时立刻初始化,而指针则不然。
由于没有 null 引用,因此引用在使用前无需测试其有效性,因此也更加有效率。
指针可以更改指定的对象,而引用总是指向其初始化时指定的对象
当函数需要返回一个“能够被赋值的对象”时需要返回引用:
vector<int> v(10); v[5] = 19;
如果在某些情况下无法返回一个正常的引用,则应当将其以异常的形式抛出。例如 dynamic_cast |
条款二:使用 C++ 类型转换
C++ 提供了四种 类型转换
操作符以替代旧式的类型转换。相比旧式的类型转换而言,新式的类型转换表达的意思更加清晰、类型转换的范围也更窄,因此应当尽量使用新式的类型转换。
例如:
const_cast 只能用来更改表达式的 const 和 volidate 。这满足了“最小权限原则”
dynamic_cast 可以用来进行类型之间的转换动作,这依赖于类的多态,另一个作用是用来找到被某对象占用的内存的起点
reinterpret_cast 用来在相关类型之间进行转换,但是函数指针的类型转换不具备移植性,因此如非必要不应当使用。
条款三:不要以多态的方式处理数组
HINT: 继承的最重要性质之一就是你可以使用指向基类的指针或 引用 来操纵派生类对象
此条款的原因有二:
通过基类数组存储派生类对象的时候,使用 operator[] 对数组进行随机访问的时候对象的位置可能被算错
C++ 规定:通过基类指针删除一个由派生类对象构成的数组的行为是 未定义 的。
条款四:尽量不要提供默认构造函数
如果类的字段没法默认提供一个有意义的值,那么不应当为其提供默认构造函数,这可以减少其他成员函数的代码量。但是无默认构造函数也有一些代价:
初始化栈上的数组需要使用初始化列表的方式逐一初始化,而且数组必须在初始化的时候就提供所有值
无法构建堆上的数组,解决办法是使用指针数组。而且可能需要使用 原地初始化 来避免额外的内存开销
一些模板容器依赖于默认构造函数
如果这个类可能成为虚继承基类,那么不应当不提供默认构造函数
条款五:对定制的“类型转换函数”保持警惕
只需要提供一个变量就能完成初始化的构造函数应当使用 explicit 加以限制,防止其他对象在意料之外隐式转换为此类。
条款六:分区前置操作符和后置操作符
前置操作符先执行操作再返回对象,返回的是一个左值 后置操作符执行操作后返回的是旧值,返回的是一个 const 限定的右值
条款七:不要重载 &&、||和逗号操作符
这三个操作符的意义众所周知,如果重载会影响所有人对其的使用。这会导致很多潜在的 bug
逗号表达式可能看起来很违反直觉,很多人从来没注意到逗号表达式,事实上,逗号表达式的规则如下: 表达式内如果有逗号,则先评估左边的值,再评估右边的值,最后以右边的值为准 例如:
在调用时传入 func 中的只有两个参数,分别为 4, 5。根据入栈顺序可知表达式 (4,5) 先于表达式 (3,4,5) 计算 |
条款八:了解各种不同意义的 new 和 delete
new operator 和 operator new
假设有以下代码:
string* str = new string(); void * str2 = operator new(sizeof(string))
第一种形式就是 new operator 。其返回一个类型相关的指针。 第二种形式是调用了 operator new 函数。其返回总为 void *
这两种形式有以下区别: * operator new 与 malloc 的作用相同,只是用来分配内存 * new operator 首先调用 operator new 获得内存,再在这块内存区域上调用构造函数
原地初始化
有时我们需要将对象在指定内存区域上构造,这就用到了原地初始化:
void * buffer = operator new(sizeof(string)) string* str = new(buffer) string("hello");
与 new operator 和 operator new 类似。delete operator 先调用析构函数再调用 operator delete,operator delete 只用来回收内存。
原地初始化得到的对象不能使用 delete operator 进行回收,因为它只是简单地调用了 operator delete ,因此你需要手动调用一次析构函数。当然 delete 也有相应的“原地析构” |
相应的 operator new[] 和 operator delete[] 用于数组上。
条款九:利用析构函数避免泄漏资源
C 中使用的 setjump 和 loadjmp 无法保证局部对象的析构函数一定会被执行,因此引入了异常机制。无论何时函数结束,函数中的局部对象总是能够保证调用析构函数,因此可以在析构函数中执行相关的清理代码。
此节为 RAII 内容的一部分,这里不再赘述。
条款十:在构造函数中阻止资源泄漏
保证删除空指针是安全的,因此删除指针之前无需进行检查 |
对于栈对象而言,C++ 只会自动调用已经完成构造的对象的析构函数,如果对象在构造时抛出了异常,那么其析构函数不会被调用。
解决构造函数中的异常有两种情况:
在构造函数内分配内存
在构造函数内分配内存可以使用 try-catch 捕获异常,然后完成清理工作后 重新抛出异常
初始化列表中分配内存
成员常量指针只能通过初始化列表初始化
因为初始化列表只接收表达式而不接收语句,因此无法使用 try-catch 。这时有两种办法: * 使用智能指针 * 使用其他函数构造对象,然后在初始化列表中调用此函数
条款十一:禁止异常流出析构函数之外
构造函数在两种情况下会被调用:
对象离开作用域或被显式删除
对象由于异常传播过程中的 栈展开 而被销毁
如果对象在第二种情况下被调用析构函数,而这时析构函数又抛出了异常,那么 terminate() 会被直接调用。
如果在函数执行的过程中存在未捕获的异常(例如由于异常而调用的析构函数),那么 uncaught_exception 会返回 true。
在 C++ 11 中,析构函数是 noexception 的,因此它 不应当 抛出异常。 |
Destructors that throw and why they’re evil
条款十二:了解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”之间的差异
条款十三:按引用捕获异常
异常的捕获有三种方式:按指针、按值、按引用
按指针:这种方式无法控制异常对象的生命周期,而且与标准异常相驳,因此永远不纳入考虑
按值:这种方式会引起异常对象的多次复制,还会导致对象分片的问题,因此不纳入考虑
按引用:按引用既不需要关注异常对象的生命周期,也不会导致异常对象的多次复制,还不会导致对象分片。因此按引用就是最佳实践
条款十四:明智利用异常规范
所谓 异常规范 (Exception Specifications)
,就是位于函数后面的 throw() 子句。如果函数在运行时调用了一个位于异常规范之外的异常,函数 unexpected 会被自动调用,而 unexpected 默认行为是调用 terminate 。
也就是说,如果函数违反了异常规范,那么默认行为是程序被直接中止,任何局部变量都不会获得销毁的机会。尤其是当异常规范被用于模板的时候,函数更有可能抛出异常规范之外的异常。而且 C++ 也规定 typedef 中不得出现异常规范。
总之,异常规范有很多问题,在将其加入函数中,必须需要好好考虑其带来的得失
throw() 在 C++ 11 中被删除,因此,无论如何都不应当在新代码中使用它 |
条款十五:了解异常处理的成本
即使你从来不用异常,也必须为异常付出一些代价:程序需要一些数据结构来记录哪些对象已经被构造妥当,而且还需要维护这些数据结构。
第二个成本来着 try 语句,一旦你有一处使用它,那么代码一般会膨胀 5%~10% ,速度下降亦约为如此。而 异常规范 造成的后果与其类似。
与函数正常返回相比, 抛出异常导致的函数返回 效率大概下降三个数量级,注意,只有抛出异常才会付出此代价,而异常的出现应当是罕见的,因此它不应当被用作表现循环完成等一些基础问题上
条款十七:考虑使用缓式评估
所谓缓式评估,就是拖延战术的术语叫法而已,毕竟 “最好的运算就是从来都不执行的运算”。延迟计算直至它不得不被计算为止。例如:
写时复制。比如 QString
在缓式评估中,比较重要的一点是区分读和写,这点请参考 条款三十:代理类
缓式取出:用于优化从磁盘读数据
表达式缓式计算:用于优化大规模运算
条款十八:分期摊还预期的计算成本
此条款术语名为“超前评估”,与缓式评估正好是两个反面。在一些缓式评估站不住脚的情况下,超前做出一些额外工作可以提升性能。例如
缓存
预先支出
条款十九:了解临时对象的来源
C++ 真正的临时对象一般是不可见的,这点需要和局部对象做区分。只要你产生了一个无名的非堆对象,那么便产生了一个临时对象。
这些临时对象一般产生于两种情况:
类型转换
函数返回
调用函数时形参和实参不匹配发生的隐式类型转换,这时产生的临时对象在函数返回时销毁。
第二种情况一般会被执行 ROV ,这里不再赘述。
只有形参是 const 限定的引用时,此类型转换才会发生。 |
条款二十:协助完成“返回值优化(ROV)”
在需要返回一个对象时,将其以构造函数的形式返回,这是 C++ 允许编译器对此匿名对象进行优化,从而省略了构造函数和析构函数的开销。
const Matrix func(){
return Matrix();
}
但是 C++ 不允许对具名对象进行优化。
1996 年 ISO ANSI 委员会宣布,具名对象和匿名对象都允许被执行 ROV 。因此上述两种情况可以被优化为相同的代码。 |
条款二十一:利用重载技术避免隐式类型转换
如题,很简单。目的是为了避免临时对象的产生。
条款二十二:使用复合重载符取代其独身形式
对于一位程序库作者而言,复合形式和独身形式的代码都应该提供,而对于应用软件开发者而言,应当使用符合形式取代独身形式,因为复合形式往往具有更高的效率:
const Matrix operator+(const Matrix&lhs, const Matrix&rhs){
return Matrix(lhs) += rhs;
}
这种形式可以被用于 ROV ,而且维护时只需要维护复合形式即可。
条款二十三:考虑使用其他程序库
iostream 效率一般比 stdio 低
条款二十四:了解虚函数、多继承、虚继承、 RTTI 的成本
对于存在虚函数的类而言,每一个类都包含一个 vtbl。如果你拥有大量带有虚函数的类,或者是每一个类包含大量的虚函数,vtbl 就会占用不少内存。
vtbl 存在的位置分为两种情况:
对于同时拥有编译器和链接器的厂商而言,可以在每份目标文件中都产生一个 vtbl 副本,只需要在链接的时候剥离多余的副本即可
将 vtbl 放在“第一个非内联非纯虚函数定义”的目标文件中。一般而言其实就是类的实现文件对应的目标文件。
如果虚函数被声明为内联函数,那么第二种做法就无法实现,这是会回退到第一种方式。因此,应当避免将虚函数声明为内联函数。 |
对于含有虚函数的类而言,其每一个对象中都包含一个 vptr 。其大小一般为 4 字节。但对于多继承而言,有多少个多态基类,就含有多少个 vptr 。
如果虚函数是通过对象被调用的,其实际开销与非虚函数接近,并且此时对虚函数的内联有意义。而通过指针或引用调用的虚函数则不然。
虚有继承会改变类的内存分布形式,还可能会引入新的指针。
RTTI 只能保证在具多态类上校验对象的动态类型。类的类型信息被保存到一个单独的 type_info 对象中,所有类对象共享此 type_info 。所付出的代价仅仅是在 vtbl 中添加一个指向此 type_info 的指针而已,并且此指针一般位于 vtbl 头部。
性质 | 对象大小增加 | 类数据量增加 | 内联几率降低 |
---|---|---|---|
虚函数 | 是 | 是 | 是 |
多重继承 | 是 | 是 | 否 |
虚基类 | 往往如此 | 有时候 | 否 |
RTTI | 否 | 是 | 否 |
条款二十五:虚有构造函数和非成员函数
在某些情况下,我们可以需要根据已有的数据产生不同的对象,这时虚有构造函数就起作用了。需要注意的是这里的虚有构造函数是一种简称,并不是由 virtual 修饰的构造函数。
这里涉及到两种虚有构造函数:
普通构造函数:实际上就是
../设计模式/创建型模式
中的工厂模式,主要用途是根据数据产生不同的基类对象虚有拷贝构造函数:实际上就是
../设计模式/创建型模式
中的原型模式,主要用途是 通过基类指针对对象进行拷贝
虚有非成员函数很简单,就是写一个干活的虚函数,然后非成员函数调用它就行了
条款二十六:限制类能够产生的对象数量
这部分内容与 单例模式 发生了重叠,这里不再赘述,但是需要注意以下几点:
用于计数的静态对象既可以存在于友元函数中又可以存在于类中。这其中的差别是友元函数中的静态对象只有在被第一次访问的时候才会被构造,而类的静态成员则不然
对于类中的静态成员 C++ 无法保证不同编译单元中对象的初始化顺序
静态函数不要搭配内敛
对于非单例模式而言,可以在超过允许的数量时抛出异常
而且作者又提出了以下几个技巧:
永远不要让具体类继承具体类(尤其是基类含有计数器等功能时)
含有私有构造函数的类无法被继承
C++ 中的对象计数
条款二十七:限制/禁止 对象产生于堆中
要要求对象只能产生于堆中,很简单,只需要将析构函数声明为保护即可,这时的类还能被用来继承和组合。这里用到的技巧是防止编译器隐式生成的代码。当然,你必须创建一个成员函数用来手动析构对象
禁止对象产生于堆中:将 operator new 删除即可
条款二十八:智能指针
条款二十九:引用计数
条款三十:代理类
条款三十一:让函数根据一个以上的对象来决定如何虚化
条款三十二:在未来时态发展程序
使用 C++ 技术来限制技术,而不是使用注释。比如禁止拷贝、禁止继承
类的设计应当着手于合理性,而不是方便性
按照设计来选择是否让函数成为虚有函数,而不是需求。否则你会习惯于不声明任何虚有函数。
为每一个类处理拷贝构造和分配运算符。即使它们现在并不被使用
明确删除不必要的函数,以免函数由于类型转换等原因被隐式调用
让类的行为具有直观的语法和语意,并与内置类型保持一致
进行防御式编程。(见 条款三十三:将非尾端类设计为抽象类 、E46)
尽可能将实现细节放在类的私有部分
尽量使用匿名的命名空间和文件内的静态函数或静态对象。(见 条款三十一:让函数根据一个以上的对象来决定如何虚化 )
避免设计出虚有继承,(见 条款四:尽量不要提供默认构造函数 和 E43)
避免设计以 RTTI 为基础并因此导致的层层条件语句。因为这样一旦类的继承体系改变,这些条件语句也要变化
如果有人通过基类指针删除派生类对象,那就代表你需要虚有析构函数
如果基类没有虚有的析构函数,那么派生类不应当设置析构函数
如果多继承体系中有任何析构函数,那么所有的基类都需要设置虚有析构函数
另外:
提供完整的类
设计你的接口,使有利于共同的操作、阻止共同的错误(见 E46 )
泛化你的代码,除非它有不良后果。