《Effective C++》第五章笔记
第五章 实现
条款26:尽量延迟变量定义式的出现时间
尽量延后变量的定义,直到使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够赋给它初始实参为止。
如果是在循环中,考虑:1.赋值成本与”构造+析构“成本;2.程序可阅读性和效率性;两者哪个更重要。
条款27:尽量少做转型动作
尽量以新转型形式代替旧转型形式:旧转型形式Type(x)或(Type)x,尽量只在将其它类型转换成类时使用;新转型形式:
1
2
3
4const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)1.const_cast:通常用来将对象的常量性解除,也是唯一具有此能力的转型操作符;2.dynamic_cast:执行”安全向下转型“,用来决定某对象是否归属于继承体系中的某个类型。唯一旧式语法无法执行的动作,唯一可能耗费重大运行成本的转型动作;3.reinterpret_cast:意图实现底层转型。4.static_cast:强制进行隐式转换。比如把non-const转换成const、把派生类转换为基类等。但是不能完成const_cast的功能。
一旦打算转型,就要考虑转带来的危险和资源消耗:例如,将一个派生类的对象的地址取出赋给一个基类指针,那么这个两个地址值或许并不相同(即单一对象可能拥有两个以上的地址,包括指向基类对象的指针和指向派生类的指针,这随具体实现不同而不同,对象的布局设计随编译器不同而不同);再例如在基类的虚函数中将this指针转换成派生类调用派生类的同名虚函数,由于类型转换会生成一个临时副本,所以并不会调用该对象的基类的虚方法,而是调用一个临时对象的基类虚方法。如果想调用基类的虚方法的话,应该使用基类域解析运算符。
dynamic_cast的成本相当高:许多dynamic_cast的实现版本执行速度非常慢(比如进行class名称匹配确定是哪一个类)。
需要使用这种转换的场景:手里有派生类对象,但是希望以基类的接口使用这个对象(在一个继承体系中向上转换)。
避免使用dynamic_cast的方法:1.使用特定类型的容器装对应类型的指针,不再统一使用基类指针指向派生类对象。这样做的缺点是假如在一个继承体系中有多种派生类,就需要多种容器;2.通过base class的virtual函数接口,令所有派生类在virtual接口中实现想做的事情,这样就可以通过指向基类的指针或者引用使用派生类的方法。
应该避免使用dynamic_cast判断是继承体系里哪一个类,这样一旦改变继承体系,就会使得代码段出现错误。如果转型是必要的,那么可以将转型动作隐藏于某个函数背后,让用户调用这个接口函数。
条款28:避免返回handles指向对象内部成分
handles包括:引用、指针、迭代器
- 改善封装性:1.成员变量的封装性最多只等于“返回其reference”的函数的访问级别。这意味着如果这个函数是public,那么成员变量的访问级别也是public。2.如果一个const函数返回的是非const引用,那么这个对象的成员有可能被改变。
- 降低虚吊(悬挂)handles的可能性:即因为返回的是成员的handles,而这个handles的存储持续性要比对象本身的存储持续性长,因此会产生悬挂的handles。
PS:不是说绝对不能返回handles,比如operator[]运算符就允许访问string的某个字符,这是设计string的目的,但是这样的情况不是常态。
条款29:为异常安全努力是是值得的
异常安全函数(代码)就是指即使发生异常也不会引起资源泄露或者允许数据结构发生破坏。防止引起资源泄露通常可以“使用对象管理资源”实现。
异常安全函数分为三种类型:
- 基本型:如果异常被抛出,程序内的所有事物仍然保持在有效状态下。但是不确定有效状态是指继续保持未调用函数时的状态还是函数执行时做了某些默认操作的状态。
- 强烈型:如果异常被抛出,程序状态不改变,即保持未调用函数的状态。常用的策略是copy and swap,即为打算修改的对象(原件)作出一个副本,然后在副本上做一切修改,如果修改动作完成且没有抛出异常,再将副本与原对象在一个不抛出异常的操作中互换(swap)。PS:这里副本的实现通常是把原对象的所有数据复制进另一个对象,然后使用一个指针指向副本,即pimpl。
- 不抛出异常型:不抛出异常,总是能够完成自己的任务。但是实际上如果出现异常会调用“意想不到的函数”,可以通过set_unexpected()设置发生异常时调用的函数。
PS:函数的异常安全性最多等于它内部调用的函数的异常安全性,例如一个强烈安全的函数如果调用了一个不安全的函数,那么这个函数也不具有强烈安全性。
另外,强烈安全由于其要求对资源进行复制,因此考虑到效率问题,可能只能完成基本安全性。
条款30:透彻了解inline
inline函数:对于使用函数的地方使用函数本体代替。
1.如果多次使用inline函数,可能会使得目标码变大,从而导致程序体积过大,进而导致额外的换页行为,降低指令高速缓存的命中率。而如果inline函数比较小,并且调用次数少,那么会比“函数调用”的目标码更小。
2.inline只是对于编译器的申请,不是强制命令。inline可以隐喻提出,比如在类定义中定义成员函数。也可以显式声明。
3.inline通常位于头文件内,是因为在编译过程中编译器必须知道inline是什么样子。(如果放在其它文件中,就需要在链接期间确定)同理适用于template。
4.一个表面上看似inline的函数是否真的为inline,取决于编译器的实现。比如太复杂的函数或者virtual函数,编译器拒绝使其成为inline。另外比如派生类中的空默认构造函数,虽然确实可以成为inline,但是如果类中有很多类对象,需要调用其构造函数,而如果构造过程出现异常,需要销毁基类对象和成员对象,这些代码都由编译器生成,一般放在派生类构造函数中,因此这些函数也无法成为inline。再比如取出一个函数的指针操作,那么这个函数一定会有本体存在,不然无法取地址。
5.inline函数意味着难以升级,如果改变inline函数,就必须重新编译文件;调试inline函数也是很困难。
条款31:文件间的编译依存关系降到最低
常见的包含头文件的格式,如果头文件发生改变,那么编译时头文件和包含头文件的文件也需要进行重新编译。
为什么class的定义式中需要声明所有的实现条目,比如包含的其它类的类型?最重要的原因是编译器需要在看到类型时知道为其分配多少内存。(Java等语言定义对象时只分配一个指针指向这个对象,这样就不需要知道类型需要占用多大的空间)
构想:相依于声明式,不要相依于定义式。
- handles class:采用pimpl原则,即将原类分成两个类,其中一个类作为handles,另一个类作为implement类,前者的成员只包含一个指向implement类的指针,后者则包含真正的成员。两个类的所有接口声明都相同,因此需要为handles前置声明所有需要的类型。这样的设计原则就会使得类的接口与真正的实现分开,如果想要使用这个类,实际上的编译关系为“主文件–handles class–真正的实现类”,那么如果实现发生了改变,不会使得主文件重新编译。
基本原则:尽量让头文件自我满足,如果做不到,就让它与其他文件的声明式相依。1.如果使用对象引用或者指针就可以完成任务,就不要使用对象。因为仅靠一个类型声明式就可以完成指针或者引用的定义,但是如果想要定义某个对象,就必须有它的类型定义。2.尽量以class声明式替换class定义式。当声明某个函数(并非调用和定义)时,仅需类的声明式即可,不需要定义式。3.为声明式和定义式提供不同的头文件。这样用户只需要#include一个声明式文件。C++标准库也是这么做的,比如声明式文件为“xxxfwd.h”,定义式则可以分布在不同的文件内“xxx.h”等。
如何实现handles类:将所有的函数都转交给implement类完成, - interface class:即使得上面所说的handles类成为一个抽象基类,这个类的唯一目的是描述它的派生类(上面所说的implement类)的所有接口。通常没有成员变量、没有构造函数,只有一个virtual析构函数和一些纯虚函数。
如何利用这个抽象基类创建一个所需要的派生类对象:提供一个特殊函数,即静态成员函数,这个静态成员函数创建一个派生类对象(调用派生类的构造函数)并返回一个智能指针。PS:这体现了实现接口类的其中一种方法,即从接口类继承接口规格,时候实现出所有函数。另一种方法涉及到多重继承(条款40)。
以上两种方式也是需要付出额外代价的。
- 对于handles class:成员函数必须承担一次额外函数调用的成本;增加一个指针内存消耗;由于指针的存在,必须承担动态分配的额外开销以及可能的异常。
- 对于interface class:每个函数都是虚函数,因此有额外函数调用开销;每一个vptr都会增加内存。
PS:头文件应该仅仅涉及声明式,包括template。(意思是应该把template使用pimpl原则实现)