More Effective C++笔记
条款1:指针与引用的区别
指针与引用看上去完全不同(指针用操作符’*’和’->’,引用使用操作符’.’),但是它们似乎有相同的功能。指针与引用都是让你间接引用其他对象。你如何决定在什么时候使用指针,在什么时候使用引用呢?
首先,要认识到在任何情况下都不能用指向空值的引用。一个引用必须总是指向某些对象。因此如果你使用一个变量并让它指向一个对象,但是该变量在某些时候也可能不指向任何对象,这时你应该把变量声明为指针,因为这样你可以赋空值给该变量。相反,如果变量肯定指向一个对象,例如你的设计不允许变量为空,这时你就可以把变量声明为引用。
1 | char *pc = 0; // 设置指针为空值 |
这是非常有害的,毫无疑问。结果将是不确定的(编译器能产生一些输出,导致任何事情都有可能发生)。因为引用肯定会指向一个对象,在C里,引用应被初始化。
1 | string& rs; // 错误,引用必须被初始化 |
指针没有这样的限制。
1 | string *ps; // 未初始化的指针 |
不存在指向空值的引用这个事实意味着使用引用的代码效率比使用指针的要高。因为在使用引用之前不需要测试它的合法性。
1 | void printDouble(const double& rd) { |
相反,指针则应该总是被测试,防止其为空:
1 | void printDouble(const double *pd) { |
指针与引用的另一个重要的不同是指针可以被重新赋值以指向另一个不同的对象。但是引用则总是指向在初始化时被指定的对象,以后不能改变。
1 | string s1("Nancy"); |
总的来说,在以下情况下你应该使用指针:
- 一是你考虑到存在不指向任何对象的可能(在这种情况下,你能够设置指针为空);
- 二是你需要能够在不同的时刻指向不同的对象(在这种情况下,你能改变指针的指向)。
- 如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么你应该使用引用。
还有一种情况,就是当你重载某个操作符时,你应该使用引用。最普通的例子是操作符[]。这个操作符典型的用法是返回一个目标对象,其能被赋值。
1 | vector<int> v(10); // 建立整形向量(vector),大小为10; |
如果操作符[]返回一个指针,那么后一个语句就得这样写:
1 | *v[5] = 10; |
但是这样会使得v看上去象是一个向量指针。因此你会选择让操作符返回一个引用。(这有一个有趣的例外,参见条款30)
当你知道你必须指向一个对象并且不想改变其指向时,或者在重载操作符并为防止不必要的语义误解时,你不应该使用指针。而在除此之外的其他情况下,则应使用指针。
条款2:尽量使用C++风格的类型转换
C++通过引进四个新的类型转换操作符克服了C风格类型转换的缺点,这四个操作符是,static_cast,const_cast,dynamic_cast, 和reinterpret_cast。在大多数情况下,对于这些操作符你只需要知道原来你习惯于这样写,
1 | (type) expression |
而现在你总应该这样写:
1 | static_cast<type>(expression) |
例如,假设你想把一个int转换成double,以便让包含int类型变量的表达式产生出浮点数值的结果。如果用C风格的类型转换,你能这样写:
1 | int firstNumber, secondNumber; |
如果用上述新的类型转换方法,你应该这样写:
1 | double result = static_cast<double>(firstNumber)/secondNumber; |
static_cast也有功能上限制。例如,你不能用static_cast象用C风格的类型转换一样把struct转换成int类型或者把double类型转换成指针类型,另外,static_cast不能从表达式中去除const属性,因为另一个新的类型转换操作符const_cast有这样的功能。
const_cast用于类型转换掉表达式的const或volatileness属性。通过使用const_cast,你向人们和编译器强调你通过类型转换想做的只是改变一些东西的constness 或者 volatileness属性。这个含义被编译器所约束。如果你试图使用const_cast来完成修改constness 或者 volatileness属性之外的事情,你的类型转换将被拒绝。下面是一些例子:
1 | class Widget { ... }; |
到目前为止,const_cast最普通的用途就是转换掉对象的const属性。
第二种特殊的类型转换符是dynamic_cast,它被用于安全地沿着类的继承关系向下进行类型转换。这就是说,你能用dynamic_cast把指向基类的指针或引用转换成指向其派生类或其兄弟类的指针或引用,而且你能知道转换是否成功。失败的转换将返回空指针(当对指针进行类型转换时)或者抛出异常(当对引用进行类型转换时):
1 | Widget *pw; |
dynamic_casts在帮助你浏览继承层次上是有限制的。它不能被用于缺乏虚函数的类型上(参见条款24),也不能用它来转换掉constness:
1 | int firstNumber, secondNumber; |
如你想在没有继承关系的类型中进行转换,你可能想到static_cast。如果是为了去除const,你总得用const_cast。
reinterpret_cast被用于的类型转换的转换结果几乎都是实现时定义(implementation-defined)。因此,使用reinterpret_casts的代码很难移植。reinterpret_casts的最普通的用途就是在函数指针类型之间进行转换。例如,假设你有一个函数指针数组:
1 | typedef void (*FuncPtr)(); // FuncPtr is 一个指向函数 |
让我们假设你希望(因为某些莫名其妙的原因)把一个指向下面函数的指针存入funcPtrArray数组:
1 | int doSomething(); |
你不能不经过类型转换而直接去做,因为doSomething函数对于funcPtrArray数组来说有一个错误的类型。在FuncPtrArray数组里的函数返回值是void类型,而doSomething函数返回值是int类型。reinterpret_cast可以让你迫使编译器以你的方法去看待它们:
1 | funcPtrArray[0] = // this compiles |
转换函数指针的代码是不可移植的(C++不保证所有的函数指针都被用一样的方法表示),在一些情况下这样的转换会产生不正确的结果(参见条款31),所以你应该避免转换函数指针类型。
条款3:不要使用多态性数组
类继承的最重要的特性是你可以通过基类指针或引用来操作派生类。这样的指针或引用具有行为的多态性,就好像它们同时具有多种形态。C++允许你通过基类指针和引用来操作派生类数组。不过这根本就不是一个特性,因为这样的代码根本无法如你所愿地那样运行。
假设你有一个类BST(比如是搜索树对象)和继承自BST类的派生类BalancedBST:
1 | class BST { ... }; |
有这样一个函数,它能打印出BST类数组中每一个BST对象的内容:
1 | void printBSTArray(ostream& s, |
当你传递给该函数一个含有BST对象的数组变量时,它能够正常运行:
1 | BST BSTArray[10]; |
然而,请考虑一下,当你把含有BalancedBST对象的数组变量传递给printBSTArray函数时,会产生什么样的后果:
1 | BalancedBST bBSTArray[10]; |
你的编译器将会毫无警告地编译这个函数,但是再看一下这个函数的循环代码:
1 | for (int i = 0; i < numElements; ) { |
array数组中每一个元素都是BST类型,因此每个元素与数组起始地址的间隔是i*sizeof(BST)。BalancedBST对象长度的比BST长,printBSTArray函数生成的指针算法将是错误的。多态和指针算法不能混合在一起来用,所以数组与多态也不能用在一起。
条款4:避免无用的缺省构造函数
缺省构造函数(指没有参数的构造函数)在C++语言中是一种让你无中生有的方法。缺省构造函数则可以不利用任何在建立对象时的外部数据就能初始化对象。如果一个类没有缺省构造函数,就会存在一些使用上的限制。
请考虑一下有这样一个类,它表示公司的设备,这个类包含一个公司的ID代码,这个ID代码被强制做为构造函数的参数:
1 | class EquipmentPiece { |
因为EquipmentPiece类没有一个缺省构造函数,所以在三种情况下使用它,就会遇到问题。第一种情况是建立数组时。一般来说,没有一种办法能在建立对象数组时给构造函数传递参数。所以在通常情况下,不可能建立EquipmentPiece对象数组:
1 | EquipmentPiece bestPieces[10]; // 错误!没有正确调用 |
不过还是有三种方法能回避开这个限制。对于使用非堆数组(non-heap arrays)(即不在堆中给数组分配内存。译者注)的一种解决方法是在数组定义时提供必要的参数:
1 | int ID1, ID2, ID3, ..., ID10; // 存储设备ID号的变量 |
不过很遗憾,这种方法不能用在堆数组(heap arrays)的定义上。更通用的解决方法是利用指针数组来代替一个对象数组:
1 | typedef EquipmentPiece* PEP; // PEP 指针指向 |
在指针数组里的每一个指针被重新赋值,以指向一个不同的EquipmentPiece对象:
1 | for (int i = 0; i < 10; ++i) |
不过这种方法有两个缺点,第一你必须删除数组里每个指针所指向的对象。如果你忘了,就会发生内存泄漏。第二增加了内存分配量,因为正如你需要空间来容纳EquipmentPiece对象一样,你也需要空间来容纳指针。如果你为数组分配raw memory,你就可以避免浪费内存。使用placement new方法(参见条款8)在内存中构造EquipmentPiece对象:
1 | // 为大小为10的数组 分配足够的内存 |
使用placement new的缺点除了是大多数程序员对它不熟悉外(能使用它就更难了),还有就是当你不想让它继续存在使用时,必须手动调用数组对象的析构函数,调用操作符delete[]来释放 raw memory(请再参见条款8):
1 | // 以与构造bestPieces对象相反的顺序解构它。 |
对于类里没有定义缺省构造函数所造成的第二个问题是它们无法在许多基于模板(template-based)容器类里使用。因为实例化一个模板时,模板的类型参数应该提供一个缺省构造函数,这是一个常见的要求。这个要求总是来自于模板内部,被建立的模板参数类型数组里。例如一个数组模板类:
1 | template<class T> |
在多数情况下,通过仔细设计模板可以杜绝对缺省构造函数的需求。例如标准的vector模板(生成一个类似于可扩展数组的类)对它的类型参数没有必须有缺省构造函数的要求。
一些人认为所有的类都应该有缺省构造函数,即使缺省构造函数没有足够的数据来初始化一个对象。比如这个原则的拥护者会这样修改EquipmentPiece类:
1 | class EquipmentPiece { |
这允许这样建立EquipmentPiece对象
1 | EquipmentPiece e; //这样合法 |
这样的修改使得其他成员函数变得复杂,因为不再能确保EquipmentPiece对象进行有意义的初始化。
条款5:谨慎定义类型转换函数
C++编译器能够在两种数据类型之间进行隐式转换(implicit conversions),例如允许把char隐式转换为int,C中许多这种可怕的转换可能会导致数据的丢失。有两种函数允许编译器进行这些的转换:单参数构造函数(single-argument constructors)和隐式类型转换运算符。单参数构造函数是指只用一个参数即可以调用的构造函数。该函数可以是只定义了一个参数,也可以是虽定义了多个参数但第一个参数以后的所有参数都有缺省值。以下有两个例子:
1 | class Name { // for names of things |
隐式类型转换运算符只是一个样子奇怪的成员函数:operator 关键字,其后跟一个类型符号。你不用定义函数的返回类型,因为返回类型就是这个函数的名字。例如为了允许Rational(有理数)类隐式地转换为double类型(在用有理数进行混合类型运算时,可能有用),你可以如此声明Rational类:
1 | class Rational { |
在下面这种情况下,这个函数会被自动调用:
1 | Rational r(1, 2); // r 的值是1/2 |
当你在不需要使用转换函数时,这些的函数缺却能被调用运行。结果这些不正确的程序会做出一些令人恼火的事情,而你又很难判断出原因。让我们首先分析一下隐式类型转换运算符,它们是最容易处理的。假设你有一个如上所述的Rational类,你想让该类拥有打印有理数对象的功能,就好像它是一个内置类型。因此,你可能会这么写:
1 | Rational r(1, 2); |
再假设你忘了为Rational对象定义operator<<。你可能想打印操作将失败,因为没有合适的operator<<被调用。但是你错了。当编译器调用operator<<时,会发现没有这样的函数存在,但是它会试图找到一个合适的隐式类型转换顺序以使得函数调用正常运行。类型转换顺序的规则定义是复杂的,但是在这种情况下编译器会发现它们能调用Rational::operator double函数,来把r转换为double类型。所以上述代码打印的结果是一个浮点数,而不是一个有理数。这简直是一个灾难,但是它表明了隐式类型转换的缺点:它们的存在将导致错误的发生。
解决方法是用等同的函数来替代转换运算符,而不用语法关键字。例如为了把Rational对象转换为double,用asDouble函数代替operator double函数:
1 | class Rational { |
这个成员函数能被显式调用:
1 | Rational r(1, 2); |
在多数情况下,这种显式转换函数的使用虽然不方便,但是函数被悄悄调用的情况不再会发生,这点损失是值得的。
通过单参数构造函数进行隐式类型转换更难消除。而且在很多情况下这些函数所导致的问题要甚于隐式类型转换运算符。举一个例子,一个array类模板,这些数组需要调用者确定边界的上限与下限:
1 | template<class T> |
第一个构造函数允许调用者确定数组索引的范围,例如从10到20。它是一个两参数构造函数,所以不能做为类型转换函数。第二个构造函数让调用者仅仅定义数组元素的个数(使用方法与内置数组的使用相似),不过不同的是它能做为类型转换函数使用,能导致无穷的痛苦。例如比较Array<int>对象,部分代码如下:
1 | bool operator==( const Array<int>& lhs, |
我们想用a的每个元素与b的每个元素相比较,但是当录入a时,我们偶然忘记了数组下标。当然我们希望编译器能报出各种各样的警告信息,但是它根本没有。因为它把这个调用看成用Array<int>参数(对于a)和int (对于b[i])参数调用operator==函数 ,然而没有operator==函数是这些的参数类型,我们的编译器注意到它能通过调用Array<int>构造函数能转换int类型到Array<int>类型,这个构造函数只有一个int 类型的参数。然后编译器如此去编译,生成的代码就象这样:
1 | for (int i = 0; i < 10; ++i) |
每一次循环都把a的内容与一个大小为b[i]的临时数组(内容是未定义的)比较 。这不仅不可能以正确的方法运行,而且还是效率低下的。因为每一次循环我们都必须建立和释放Array<int>对象。
通过不声明运算符(operator)的方法,可以克服隐式类型转换运算符的缺点,但是单参数构造函数没有那么简单。毕竟,你确实想给调用者提供一个单参数构造函数。同时你也希望防止编译器不加鉴别地调用这个构造函数。幸运的是,有一个方法可以让你鱼肉与熊掌兼得。事实上是两个方法:一是容易的方法,二是当你的编译器不支持容易的方法时所必须使用的方法。
容易的方法是利用一个最新编译器的特性,explicit关键字。为了解决隐式类型转换而特别引入的这个特性,它的使用方法很好理解。构造函数用explicit声明,如果这样做,编译器会拒绝为了隐式类型转换而调用构造函数。显式类型转换依然合法:
1 | template<class T> |
在例子里使用了static_cast(参见条款2),两个“>”字符间的空格不能漏掉,如果这样写语句:
1 | if (a == static_cast<Array<int>>(b[i])) ... |
这是一个不同的含义的语句。因为C++编译器把”>>”做为一个符号来解释。在两个”>”间没有空格,语句会产生语法错误。如果你的编译器不支持explicit,你不得不回到不使用成为隐式类型转换函数的单参数构造函数。
我前面说过复杂的规则决定哪一个隐式类型转换是合法的,哪一个是不合法的。这些规则中没有一个转换能够包含用户自定义类型(调用单参数构造函数或隐式类型转换运算符)。你能利用这个规则来正确构造你的类,使得对象能够正常构造,同时去掉你不想要的隐式类型转换。
再来想一下数组模板,你需要用整形变量做为构造函数参数来确定数组大小,但是同时又必须防止从整数类型到临时数组对象的隐式类型转换。你要达到这个目的,先要建立一个新类ArraySize。这个对象只有一个目的就是表示将要建立数组的大小。你必须修改Array的单参数构造函数,用一个ArraySize对象来代替int。代码如下:
1 | template<class T> |
这里把ArraySize嵌套入Array中,为了强调它总是与Array一起使用。你也必须声明ArraySize为公有,为了让任何人都能使用它。想一下,当通过单参数构造函数定义Array对象,会发生什么样的事情:
1 | Array<int> a(10); |
你的编译器要求用int参数调用Array<int>里的构造函数,但是没有这样的构造函数。编译器意识到它能从int参数转换成一个临时ArraySize对象,ArraySize对象只是Array<int>构造函数所需要的,这样编译器进行了转换。函数调用(及其后的对象建立)也就成功了。
事实上你仍旧能够安心地构造Array对象,不过这样做能够使你避免类型转换。考虑一下以下代码:
1 | bool operator==( const Array<int>& lhs, |
为了调用operator==函数,编译器要求Array<int>对象在”==”右侧,但是不存在一个参数为int的单参数构造函数。而且编译器无法把int转换成一个临时ArraySize对象然后通过这个临时对象建立必须的Array<int>对象,因为这将调用两个用户定义(user-defined)的类型转换,一个从int到ArraySize,一个从ArraySize到Array<int>。这种转换顺序被禁止的,所以当试图进行比较时编译器肯定会产生错误。
在你跳到条款33之前,再仔细考虑一下本条款的内容。让编译器进行隐式类型转换所造成的弊端要大于它所带来的好处,所以除非你确实需要,不要定义类型转换函数。
条款6:自增(increment)、自减(decrement)操作符前缀形式与后缀形式的区别
C++允许重载increment 和 decrement操作符的两种形式。C++规定后缀形式有一个int类型参数,当函数被调用时,编译器传递一个0做为int参数的值给该函数:
1 | class UPInt { // "unlimited precision int" |
这些操作符前缀与后缀形式返回值类型是不同的。前缀形式返回一个引用,后缀形式返回一个const类型。下面我们将讨论++操作符的前缀与后缀形式,这些说明也同样使用与–操作符。
1 | // 前缀形式:增加然后取回值 |
后缀操作符函数没有使用它的参数。它的参数只是用来区分前缀与后缀函数调用。很明显一个后缀increment必须返回一个对象(它返回的是增加前的值),但是为什么是const对象呢?假设不是const对象,下面的代码就是正确的:
1 | UPInt i; |
这组代码与下面的代码相同:
1 | i.operator++(0).operator++(0); |
很明显,第一个调用的operator++函数返回的对象调用了第二个operator++函数。
条款7:不要重载overload &&, ||, or ,.
C++使用布尔表达式简化求值法(short-circuit evaluation)。这表示一旦确定了布尔表达式的真假值,即使还有部分表达式没有被测试,布尔表达式也停止运算。例如:
1 | char *p; |
这里不用担心当p为空时strlen无法正确运行,因为如果p不等于0的测试失败,strlen不会被调用。同样:
1 | int rangeCheck(int index) { |
如果index小于lowerBound,它不会与upperBound进行比较。
C++允许根据用户定义的类型,来定制&&和||操作符。方法是重载函数operator&& 和operator||。如果你重载了操作符&&,对于编译器来说,等同于下面代码之一:
1 | if (expression1.operator&&(expression2)) ... |
这好像没有什么不同,但是函数调用法与简短求值法是绝对不同的。首先当函数被调用时,需要运算其所有参数,所以调用函数functions operator&& 和 operator||时,两个参数都需要计算,换言之,没有采用简短计算法。第二是C++语言规范没有定义函数参数的计算顺序,所以没有办法知道表达式1与表达式2哪一个先计算。完全与具有从左参数到右参数计算顺序的简短计算法相反。
因此如果你重载&&或||,就没有办法提供给程序员他们所期望和使用的行为特性,所以不要重载&&和||。存在一些限制,你不能重载下面的操作符:
1 | . .* :: ?: |
你能重载:
1 | operator new operator delete |
条款8:理解各种不同含义的new和delete
当你写这样的代码:
1 | string *ps = new string("Memory Management"); |
你使用的new是new操作符。它要完成的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new操作符为分配内存所调用函数的名字是operator new。
函数operator new 通常这样声明:
1 | void * operator new(size_t size); |
返回值类型是void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存。你一般不会直接调用operator new,但是一旦这么做,你可以象调用其它函数一样调用它:
1 | void *rawMemory = operator new(sizeof(string)); |
操作符operator new将返回一个指针,指向一块足够容纳一个string类型对象的内存。
就象malloc一样,operator new的职责只是分配内存。
1 | string *ps = new string("Memory Management"); |
它生成的代码或多或少与下面的代码相似:
1 | void *memory = operator new(sizeof(string)); // 得到未经处理的内存为String对象 |
注意第二步包含了构造函数的调用,
有时你确实想直接调用构造函数。有时你有一些已经被分配但是尚未处理的的(raw)内存,你需要在这些内存中构造一个对象。你可以使用一个特殊的operator new,它被称为placement new。
下面的例子是placement new如何使用,考虑一下:
1 | class Widget { |
这个函数返回一个指针,指向一个Widget 对象,对象在转递给函数的buffer里分配。当程序使用共享内存或memory-mapped I/O时这个函数可能有用,因为在这样程序里对象必须被放置在一个确定地址上或一块被例程分配的内存里。
在constructWidgetInBuffer里面,返回的表达式是:
1 | new (buffer) Widget(widgetSize) |
这是new操作符的一个用法,需要使用一个额外的变量(buffer),当new操作符隐含调用operator new函数时,把这个变量传递给它。被调用的operator new函数除了待有强制的参数size_t外,还必须接受void*指针参数,指向构造对象占用的内存空间。这个operator new就是placement new,它看上去象这样:
1 | void * operator new(size_t size, void *location) |
operator new的目的是为对象分配内存然后返回指向该内存的指针。placement new必须做的就是返回转递给它的指针。为了使用placement new,你必须使用语句#include <new>。
- 你想在堆上建立一个对象,应该用new操作符。它既分配内存又为对象调用构造函数。
- 如果你仅仅想分配内存,就应该调用operator new函数;它不会调用构造函数。
- 如果你想定制自己的在堆对象被建立时的内存分配过程,你应该写你自己的operator new函数,然后使用new操作符,new操作符会调用你定制的operator new。
- 如果你想在一块已经获得指针的内存里建立一个对象,应该用placement new。
为了避免内存泄漏,每个动态内存分配必须与一个等同相反的deallocation对应。函数operator delete与delete操作符的关系与operator new与new操作符的关系一样。当你看到这些代码:
1 | string *ps; |
你的编译器会生成代码来析构对象并释放对象占有的内存。operator delete用来释放内存,它被这样声明:
1 | void operator delete(void *memoryToBeDeallocated); |
因此,
1 | delete ps; |
导致编译器生成类似于这样的代码:
1 | ps->~string(); // call the object's dtor |
这有一个隐含的意思是如果你只想处理未被初始化的内存,你应该绕过new和delete操作符,而调用operator new 获得内存和operator delete释放内存给系统:
1 | void *buffer = // 分配足够的 |
这与在C中调用malloc和free等同。
如果你用placement new在内存中建立对象,你应该避免在该内存中用delete操作符。因为delete操作符调用operator delete来释放内存,但是包含对象的内存最初不是被operator new分配的,placement new只是返回转递给它的指针。谁知道这个指针来自何方?而你应该显式调用对象的析构函数来解除构造函数的影响:
1 | // 在共享内存中分配和释放内存的函数 |
如上例所示,如果传递给placement new的raw内存是自己动态分配的(通过一些不常用的方法),如果你希望避免内存泄漏,你必须释放它。(参见我的文章Counting objects里面关于placement delete的注释。)
到目前为止一切顺利,但是还得接着走。到目前为止我们所测试的都是一次建立一个对象。怎样分配数组?会发生什么?
1 | string *ps = new string[10]; // allocate an array of objects |
被使用的new仍然是new操作符,但是建立数组时new操作符的行为与单个对象建立有少许不同。第一是内存不再用operator new分配,代替以等同的数组分配函数,叫做operator new[](经常被称为array new)。它与operator new一样能被重载。这就允许你控制数组的内存分配,就象你能控制单个对象内存分配一样。
第二个不同是new操作符调用构造函数的数量。对于数组,在数组里的每一个对象的构造函数都必须被调用:
1 | string *ps = // 调用operator new[]为10个 |
同样当delete操作符用于数组时,它为每个数组元素调用析构函数,然后调用operator delete来释放内存。就象你能替换或重载operator delete一样,你也替换或重载operator delete[]。在它们重载的方法上有一些限制。
new和delete操作符是内置的,其行为不受你的控制,凡是它们调用的内存分配和释放函数则可以控制。当你想定制new和delete操作符的行为时,请记住你不能真的做到这一点。你只能改变它们为完成它们的功能所采取的方法,而它们所完成的功能则被语言固定下来,不能改变。
条款9:使用析构函数防止资源泄漏
我们可以把总被执行的清除代码放入局部对象的析构函数里,这样可以避免重复书写清除代码。因为当函数返回时局部对象总是被释放,无论函数是如何退出的。标准C++库函数包含一个类模板,叫做auto_ptr,每一个auto_ptr类的构造函数里,让一个指针指向一个堆对象(heap object),并且在它的析构函数里删除这个对象。下面所示的是auto_ptr类的一些重要的部分:
1 | template<class T> |
auto_ptr类的完整代码是非常有趣的,上述简化的代码实现不能在实际中应用。用auto_ptr对象代替raw指针,你将不再为堆对象不能被删除而担心,即使在抛出异常时,对象也能被及时删除。(因为auto_ptr的析构函数使用的是单对象形式的delete,所以auto_ptr不能用于指向对象数组的指针。如果想让auto_ptr类似于一个数组模板,你必须自己写一个。在这种情况下,用vector代替array可能更好)
隐藏在auto_ptr后的思想是:用一个对象存储需要被自动释放的资源,然后依靠对象的析构函数来释放资源,这种思想不只是可以运用在指针上,还能用在其它资源的分配和释放上。
条款10:在构造函数中防止资源泄漏
- C++保证删除null指针是安全的.
- 面对尚未完全构造好的对象, C++拒绝调用其对应的析构函数.
- C++不自动清理那些构造期间抛出异常(exceptions)的对象, 需要在构造函数中捕获可能存在的异常.
- 最好把共享代码抽出放进一个private的辅助函数内, 然后让析构或构造函数都调用它.
- 智能指针shared_ptr可以帮助构造函数处理构造过程中出现的异常.
条款11:禁止异常信息(exceptions)传递到析构函数外
在有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。第二种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。
在上述两种情况下,调用析构函数时异常可能处于激活状态也可能没有处于激活状态。遗憾的是没有办法在析构函数内部区分出这两种情况。因此在写析构函数时你必须保守地假设有异常被激活,因为如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用terminate函数。这个函数的作用正如其名字所表示的:它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放。
我们知道禁止异常传递到析构函数外有两个原因,第一能够在异常转递的堆栈辗转开解(stack-unwinding)的过程中,防止terminate被调用。第二它能帮助确保析构函数总能完成我们希望它做的所有事情。
条款12:理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异
从语法上看,在函数里声明参数与在catch子句中声明参数几乎没有什么差别:
1 | class Widget { ... }; //一个类,具体是什么类 |
传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。但是当你传递参数和异常时,系统所要完成的操作过程则是完全不同的。产生这个差异的原因是:你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。
有这样一个函数,参数类型是Widget,并抛出一个Widget类型的异常:
1 | // 一个函数,从流中读值到Widget中 |
当传递localWidget到函数operator>>里,不用进行拷贝操作,而是把operator>>内的引用类型变量w指向localWidget,任何对w的操作实际上都施加到localWidget上。这与抛出localWidget异常有很大不同。不论通过传值捕获异常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行lcalWidget的拷贝操作,也就说传递到catch子句中的是localWidget的拷贝。必须这么做,因为当localWidget离开了生存空间后,其析构函数将被调用。如果把localWidget本身(而不是它的拷贝)传递给catch子句,这个子句接收到的只是一个被析构了的Widget,一个Widget的“尸体”。这是无法使用的。因此C++规范要求被做为异常抛出的对象必须被复制。
即使被抛出的对象不会被释放,也会进行拷贝操作。例如如果passAndThrowWidget函数声明localWidget为静态变量(static),
1 | void passAndThrowWidget() |
当抛出异常时仍将复制出localWidget的一个拷贝。这表示即使通过引用来捕获异常,也不能在catch块中修改localWidget;仅仅能修改localWidget的拷贝。对异常对象进行强制复制拷贝,这个限制有助于我们理解参数传递与抛出异常的第二个差异:抛出异常运行速度比参数传递要慢。
当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数。比如以下这经过少许修改的passAndThrowWidget:
1 | class Widget { ... }; |
这里抛出的异常对象是Widget,即使rw引用的是一个SpecialWidget。因为rw的静态类型(static type)是Widget,而不是SpecialWidget。你的编译器根本没有主要到rw引用的是一个SpecialWidget。编译器所注意的是rw的静态类型(static type)。这种行为可能与你所期待的不一样,但是这与在其他情况下C++中拷贝构造函数的行为是一致的。
异常是其它对象的拷贝,这个事实影响到你如何在catch块中再抛出一个异常。比如下面这两个catch块,乍一看好像一样:
1 | catch (Widget& w) // 捕获Widget异常 |
这两个catch块的差别在于第一个catch块中重新抛出的是当前捕获的异常,而第二个catch块中重新抛出的是当前捕获异常的一个新的拷贝。如果忽略生成额外拷贝的系统开销,这两种方法还有差异么?
当然有。第一个块中重新抛出的是当前异常(current exception),无论它是什么类型。特别是如果这个异常开始就是做为SpecialWidget类型抛出的,那么第一个块中传递出去的还是SpecialWidget异常,即使w的静态类型(static type)是Widget。这是因为重新抛出异常时没有进行拷贝操作。第二个catch块重新抛出的是新异常,类型总是Widget,因为w的静态类型(static type)是Widget。一般来说,你应该用
1 | throw |
来重新抛出当前的异常,因为这样不会改变被传递出去的异常类型,而且更有效率,因为不用生成一个新拷贝。
让我们测试一下下面这三种用来捕获Widget异常的catch子句,异常是做为passAndThrowWidgetp抛出的:
1 | catch (Widget w) ... // 通过传值捕获异常 |
我们立刻注意到了传递参数与传递异常的另一个差异。一个被异常抛出的对象(刚才解释过,总是一个临时对象)可以通过普通的引用捕获;它不需要通过指向const对象的引用(reference-to-const)捕获。在函数调用中不允许转递一个临时对象到一个非const引用类型的参数里,但是在异常中却被允许。
让我们先不管这个差异,回到异常对象拷贝的测试上来。我们知道当用传值的方式传递函数的参数,我们制造了被传递对象的一个拷贝,并把这个拷贝存储到函数的参数里。同样我们通过传值的方式传递一个异常时,也是这么做的。当我们这样声明一个catch子句时:
1 | catch (Widget w) ... // 通过传值捕获 |
会建立两个被抛出对象的拷贝,一个是所有异常都必须建立的临时对象,第二个是把临时对象拷贝进w中。同样,当我们通过引用捕获异常时,
1 | catch (Widget& w) ... // 通过引用捕获 |
这仍旧会建立一个被抛出对象的拷贝:拷贝是一个临时对象。相反当我们通过引用传递函数参数时,没有进行对象拷贝。当抛出一个异常时,系统构造的(以后会析构掉)被抛出对象的拷贝数比以相同对象做为参数传递给函数时构造的拷贝数要多一个。
我们还没有讨论通过指针抛出异常的情况,不过通过指针抛出异常与通过指针传递参数是相同的。不论哪种方法都是一个指针的拷贝被传递。你不能认为抛出的指针是一个指向局部对象的指针,因为当异常离开局部变量的生存空间时,该局部变量已经被释放。Catch子句将获得一个指向已经不存在的对象的指针。这种行为在设计时应该予以避免。
对象从函数的调用处传递到函数参数里与从异常抛出点传递到catch子句里所采用的方法不同,这只是参数传递与异常传递的区别的一个方面,第二个差异是在函数调用者或抛出异常者与被调用者或异常捕获者之间的类型匹配的过程不同。比如在标准数学库(the standard math library)中sqrt函数:
1 | double sqrt(double); // from <cmath> or <math.h> |
我们能这样计算一个整数的平方根,如下所示:
1 | int i; |
毫无疑问,C++允许进行从int到double的隐式类型转换,所以在sqrt的调用中,i 被悄悄地转变为double类型,并且其返回值也是double。一般来说,catch子句匹配异常类型时不会进行这样的转换。见下面的代码:
1 | void f(int value) |
在try块中抛出的int异常不会被处理double异常的catch子句捕获。该子句只能捕获真真正正为double类型的异常;不进行类型转换。因此如果要想捕获int异常,必须使用带有int或int&参数的catch子句。
不过在catch子句中进行异常匹配时可以进行两种类型转换。第一种是继承类与基类间的转换。一个用来捕获基类的catch子句也可以处理派生类类型的异常。
捕获runtime_errors异常的Catch子句可以捕获range_error类型和overflow_error类型的异常,可以接收根类exception异常的catch子句能捕获其任意派生类异常。这种派生类与基类(inheritance_based)间的异常类型转换可以作用于数值、引用以及指针上:
1 | catch (runtime_error) ... // can catch errors of type |
第二种是允许从一个类型化指针(typed pointer)转变成无类型指针(untyped pointer),所以带有const void* 指针的catch子句能捕获任何类型的指针类型异常:
1 | catch (const void*) ... //捕获任何指针类型异常 |
传递参数和传递异常间最后一点差别是catch子句匹配顺序总是取决于它们在程序中出现的顺序。因此一个派生类异常可能被处理其基类异常的catch子句捕获,即使同时存在有能处理该派生类异常的catch子句,与相同的try块相对应。例如:
1 | try { |
与上面这种行为相反,当你调用一个虚拟函数时,被调用的函数位于与发出函数调用的对象的动态类型(dynamic type)最相近的类里。你可以这样说虚拟函数采用最优适合法,而异常处理采用的是最先适合法。如果一个处理派生类异常的catch子句位于处理基类异常的catch子句前面,编译器会发出警告。(因为这样的代码在C++里通常是不合法的。)不过你最好做好预先防范:不要把处理基类异常的catch子句放在处理派生类异常的catch子句的前面。象上面那个例子,应该这样去写:
1 | try { |
综上所述,把一个对象传递给函数或一个对象调用虚拟函数与把一个对象做为异常抛出,这之间有三个主要区别。
- 异常对象在传递时总被进行拷贝;当通过传值方式捕获时,异常对象被拷贝了两次。对象做为参数传递给函数时不需要被拷贝。
- 对象做为异常被抛出与做为参数传递给函数相比,前者类型转换比后者要少(前者只有两种转换形式)。
- catch子句进行异常类型匹配的顺序是它们在源代码中出现的顺序,第一个类型匹配成功的catch将被用来执行。当一个对象调用一个虚拟函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。
条款13:通过引用(reference)捕获异常
当你写一个catch子句时,必须确定让异常通过何种方式传递到catch子句里。你可以有三个选择:与你给函数传递参数一样,通过指针(by pointer),通过传值(by value)或通过引用(by reference)。
从throw处传递一个异常到catch子句是一个缓慢的过程,在理论上通过指针方式捕获异常的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象,例如:
1 | class exception { ... }; // 来自标准C++库(STL) |
这看上去很不错,但是实际情况却不是这样。为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存。全局与静态对象都能够做到这一点,但是程序员很容易忘记这个约束。如果真是如此的话,他们会这样写代码:
1 | void someFunction() |
这简直糟糕透了,因为处理这个异常的catch子句接受到的指针,其指向的对象已经不再存在。
另一种抛出指针的方法是在建立一个堆对象(new heap object):
1 | void someFunction() |
通过指针捕获异常也不符合C+ +语言本身的规范。四个标准的异常――bad_alloc(当operator new不能分配足够的内存时,被抛出),bad_cast(当dynamic_cast针对一个引用(reference)操作失败时,被抛出),bad_typeid(当dynamic_cast对空指针进行操作时,被抛出)和bad_exception(用于unexpected异常;参见条款14)――都不是指向对象的指针,所以你必须通过值或引用来捕获它们。
通过值捕获异常(catch -by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款 12)。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的sliced对象实际上是一个基类对象:它们没有派生类的数据成员,而且当调用它们的虚拟函数时,系统解析后调用的是基类对象的函数。
最后剩下方法就是通过引用捕获异常(catch-by-reference)。通过引用捕获异常能使你避开上述所有问题。不象通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有slicing problem,而且异常对象只被拷贝一次。
如果你通过引用捕获异常(catch by reference),你就能避开上述所有问题,不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。
条款14:审慎使用异常规格(exception specifications)
异常规格使得代码更容易理解,因为它明确地描述了一个函数可以抛出什么样的异常。但是它不只是一个有趣的注释。编译器在编译时有时能够检测到异常规格的不一致。而且如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数unexpected将被自动地调用。函数unexpected 缺省的行为是调用函数terminate,而terminate缺省的行为是调用函数abort,所以一个违反异常规格的程序其缺省的行为就是halt。
一个函数调用了另一个函数,并且后者可能抛出一个违反前者异常规格的异常,例如函数f1没有声明异常规格,这样的函数就可以抛出任意种类的异常:
1 | extern void f1(); // 可以抛出任意的异常 |
假设有一个函数f2通过它的异常规格来声明其只能抛出int类型的异常:
1 | void f2() throw(int); |
f2调用f1是非常合法的,即使f1可能抛出一个违反f2异常规格的异常:
1 | void f2() throw(int) |
当带有异常规格的新代码与没有异常规格的老代码整合在一起工作时,这种灵活性就显得很重要。一种好方法是避免在带有类型参数的模板内使用异常规格。例如下面这种模板,它好像不能抛出任何异常:
1 | // a poorly designed template wrt exception specifications |
这个模板为所有类型定义了一个操作符函数operator==。对于任意一对类型相同的对象,如果对象有一样的地址,该函数返回true,否则返回false。
这个模板包含的异常规格表示模板生成的函数不能抛出异常。但是事实可能不会这样,因为opertor&能被一些类型对象重载。如果被重载的话,当调用从operator==函数内部调用opertor&时,opertor&可能会抛出一个异常,这样就违反了我们的异常规格,使得程序控制跳转到unexpected。
能够避免调用unexpected函数的第二个方法是如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。这很容易理解,但是实际中容易被忽略。比如允许用户注册一个回调函数:
1 | // 一个window系统回调函数指针 |
这里在makeCallBack内调用func,要冒违反异常规格的风险,因为无法知道func会抛出什么类型的异常。
通过在程序在CallBackPtr typedef中采用更严格的异常规格来解决问题:
1 | typedef void (*CallBackPtr)(int eventXLocation, |
这样定义typedef后,如果注册一个可能会抛出异常的callback函数将是非法的:
1 | // 一个没有异常给各的回调函数 |
传递函数指针时进行这种异常规格的检查,是语言的较新的特性,所以有可能你的编译器不支持这个特性。如果它们不支持,那就依靠你自己来确保不能犯这种错误。
避免调用unexpected的第三个方法是处理系统本身抛出的异常。这些异常中最常见的是bad_alloc,当内存分配失败时它被operator new 和operator new[]抛出。
虽然防止抛出unexpected异常是不现实的,但是C++允许你用其它不同的异常类型替换unexpected异常,你能够利用这个特性。例如你希望所有的unexpected异常都被替换为UnexpectedException对象。你能这样编写代码:
1 | class UnexpectedException {}; // 所有的unexpected异常对象被 |
通过用convertUnexpected函数替换缺省的unexpected函数,来使上述代码开始运行:
1 | set_unexpected(convertUnexpected); |
当你这么做了以后,一个unexpected异常将触发调用convertUnexpected函数。Unexpected异常被一种UnexpectedException新异常类型替换。如果被违反的异常规格包含UnexpectedException异常,那么异常传递将继续下去,好像异常规格总是得到满足。
另一种把unexpected异常转变成知名类型的方法是替换unexpected函数,让其重新抛出当前异常,这样异常将被替换为bad_exception。你可以这样编写:
1 | void convertUnexpected() // 如果一个unexpected异常被 |
如果这么做,你应该在所有的异常规格里包含bad_exception(或它的基类,标准类exception)。你将不必再担心如果遇到unexpected异常会导致程序运行终止。任何不听话的异常都将被替换为bad_exception,这个异常代替原来的异常继续传递。
到现在你应该理解异常规格能导致大量的麻烦。编译器仅仅能部分地检测它们的使用是否一致,在模板中使用它们会有问题,一不注意它们就很容易被违反,并且在缺省的情况下它们被违反时会导致程序终止运行。异常规格还有一个缺点就是它们能导致unexpected被触发即使一个high-level调用者准备处理被抛出的异常,比如下面这个例子:
1 | class Session { // for modeling online |
session的析构函数调用logDestruction记录有关session对象被释放的信息,它明确地要捕获从logDestruction抛出的所有异常。但是logDestruction的异常规格表示其不抛出任何异常。现在假设被logDestruction调用的函数抛出了一个异常,而logDestruction没有捕获。我们不会期望发生这样的事情,凡是正如我们所见,很容易就会写出违反异常规格的代码。当这个异常通过logDestruction传递出来,unexpected将被调用,缺省情况下将导致程序终止执行。这是一个正确的行为,这是session析构函数的作者所希望的行为么?作者想处理所有可能的异常,所以好像不应该不给session析构函数里的catch块执行的机会就终止程序。如果logDestruction没有异常规格,这种事情就不会发生。
条款15:了解异常处理的系统开销
C++编译器必须支持异常,也就是说,当你不用异常处理时你不能让编译器生产商消除这方面的开销,因为程序一般由多个独立生成的目标文件(object files)组成,只有一个目标文件不进行异常处理并不能代表其他目标文件不进行异常处理。
使用异常处理的第二个开销来自于try 块,无论何时使用它,也就是无论何时你想能够捕获异常,那你都得为此付出代价。不同的编译器实现try块的方法不同,所以编译器与编译器间的开销也不一样。粗略地估计,如果你使用try块,代码的尺寸将增加5%-10%并且运行速度也同比例减慢。
现在我们来到了问题的核心部分,看看抛出异常的开销。事实上我们不用太关心这个问题,因为异常是很少见的,这种事件的发生往往被描述为exceptional (异常的,罕见的)。与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。
条款16:牢记80-20准则(80-20 rule)
软件整体的性能取决于代码组成中的一小部分。用profiler程序识别出令人讨厌的程序的20%部分。不是所有的工作都让profiler去做。你想让它去直接地测量你感兴趣的资源。
profiler 告诉你每条语句执行了多少次或各函数被调用了多少次,知道语句执行或函数调用的频繁程度,有时能帮助你洞察软件内部的行为。
条款17:考虑使用lazy evaluation(懒惰计算法)
当你使用了lazy evaluation(懒惰计算法)后,采用此种方法的类将推迟计算工作直到系统需要这些计算的结果。如果不需要结果,将不用进行计算。
引用计数
1 | class String { ... }; // 一个string 类 (the standard |
通常string拷贝构造函数让s2被s1初始化后,s1和s2都有自己的”Hello”拷贝。这种拷贝构造函数会引起较大的开销:只因为到string拷贝构造函数,就要制作s1值的拷贝并把它赋给s2。然而这时的s2并不需要这个值的拷贝,因为s2没有被使用。
懒惰能就是少工作。不应该赋给s2一个s1的拷贝,而是让s2与s1共享一个值。我们只须做一些记录以便知道谁在共享什么,就能够省掉调用new和拷贝字符的开销。事实上s1和s2共享一个数据结构,这对于client来说是透明的,对于下面的例子来说,这没有什么差别,因为它们只是读数据:
1 | cout << s1; // 读s1的值 |
仅仅当这个或那个string的值被修改时,共享同一个值的方法才会造成差异。仅仅修改一个string的值,而不是两个都被修改,这一点是极为重要的。例如这条语句:
1 | s2.convertToUpperCase(); |
这是至关紧要的,仅仅修改s2的值,而不是连s1的值一块修改。
为了这样执行语句,string的convertToUpperCase函数应该制作s2值的一个拷贝,在修改前把这个私有的值赋给s2。在convertToUpperCase内部,我们不能再懒惰了:必须为s2(共享的)值制作拷贝以让s2自己使用。另一方面,如果不修改s2,我们就不用制作它自己值的拷贝。
除非确实需要,不去为任何东西制作拷贝。我们应该是懒惰的,只要可能就共享使用其它值。在一些应用领域,你经常可以这么做。
区别对待读取和写入
来看看使用lazy evaluation的第二种方法。考虑这样的代码:
1 | String s = "Homer's Iliad"; // 假设是一个 |
读取reference-counted string是很容易的,而写入这个string则需要在写入前对该string值制作一个新拷贝。我们可以推迟做出是读操作还是写操作的决定,直到我们能判断出正确的答案。
Lazy Fetching(懒惰提取)
第三个lazy evaluation的例子,假设你的程序使用了一些包含许多字段的大型对象。这些对象的生存期超越了程序运行期,所以它们必须被存储在数据库里。每一个对都有一个唯一的对象标识符,用来从数据库中重新获得对象:
1 | class LargeObject { // 大型持久对象 |
现在考虑一下从磁盘中恢复LargeObject的开销:
1 | void restoreAndProcessObject(ObjectID id) |
这里仅仅需要filed2的值,所以为获取其它字段而付出的努力都是浪费。
当LargeObject对象被建立时,不从磁盘上读取所有的数据,这样懒惰法解决了这个问题。不过这时建立的仅是一个对象“壳“,当需要某个数据时,这个数据才被从数据库中取回。这种“demand-paged”对象初始化的实现方法是:
1 | class LargeObject { |
对象中每个字段都用一个指向数据的指针来表示,LargeObject构造函数把每个指针初始化为空。这些空指针表示字段还没有从数据库中读取数值。每个LargeObject成员函数在访问字段指针所指向的数据之前必须字段指针检查的状态。如果指针为空,在对数据进行操作之前必须从数据库中读取对应的数据。
Lazy Expression Evaluation(懒惰表达式计算)
有关lazy evaluation的最后一个例子来自于数字程序。考虑这样的代码:
1 | template<class T> |
通常operator的实现使用eagar evaluation:在这种情况下,它会计算和返回m1与m2的和。这个计算量相当大(1000000次加法运算),当然系统也会分配内存来存储这些值。
lazy evaluation方法说这样做工作太多,所以还是不要去做。而是应该建立一个数据结构来表示m3的值是m1与m2的和,在用一个enum表示它们间是加法操作。很明显,建立这个数据结构比m1与m2相加要快许多,也能够节省大量的内存。
考虑程序后面这部分内容,在使用m3之前,代码执行如下:
1 | Matrix<int> m4(1000, 1000); |
现在我们可以忘掉m3是m1与m2的和(因此节省了计算的开销),在这里我们应该记住m3是m4与m1运算的结果。不必说,我们不用进行乘法运算。因为我们是懒惰的。
实际上lazy evaluation就存在于APL语言中。APL是在1960年代发展起来语言,能够进行基于矩阵的交互式的运算。APL使用lazy evaluation 来拖延它们的计算直到确切地知道需要矩阵哪一部分的结果,然后仅仅计算这一部分。
总结
以上这四个例子展示了lazy evaluation在各个领域都是有用的:能避免不需要的对象拷贝通过使用operator[]区分出读操作,避免不需要的数据库读取操作,避免不需要的数字操作。
条款18:分期摊还期望的计算
这个条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。例如下面这个模板类,用来表示放有大量数字型数据的一个集合:
1 | template<class NumericalType> |
假设min, max和avg函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这三种函数。使用eager evaluation(热情计算法),当min,max和avg函数被调用时,我们检测集合内所有的数值,然后返回一个合适的值。使用lazy evaluation(懒惰计算法),只有确实需要函数的返回值时我们才要求函数返回能用来确定准确数值的数据结构。使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当min,max或avg被调用时,我们可以不用计算就立刻返回正确的数值。如果频繁调用min,max和avg,我们把跟踪集合最小值、最大值和平均值的开销分摊到所有这些函数的调用上,每次函数调用所分摊的开销比eager evaluation或lazy evaluation要小。
隐藏在over-eager evaluation后面的思想是如果你认为一个计算需要频繁进行。你就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求的开销。
采用over-eager最简单的方法就是caching(缓存)那些已经被计算出来而以后还有可能需要的值。
以下是实现findCubicleNumber的一种方法:它使用了标准模板库(STL)里的map对象。
1 | int findCubicleNumber(const string& employeeName) |
这个方法是使用local cache,用开销相对不大的内存中查询来替代开销较大的数据库查询。假如隔间号被不止一次地频繁需要,在findCubicleNumber内使用cache会减少返回隔间号的平均开销。
- 贯穿本条款的是一个常见的主题,更快的速度经常会消耗更多的内存。跟踪运行时的最小值、最大值和平均值,这需要额外的空间,但是能节省时间。
- Cache运算结果需要更多的内存,但是一旦需要被cache的结果时就能减少需要重新生成的时间。
- Prefetch需要空间放置被prefetch的东西,但是它减少了访问它们所需的时间。
自从有了计算机就有这样的描述:你能以空间换时间。
在本条款中我提出的建议,即通过over-eager方法分摊预期计算的开销,例如caching和prefething,这并不与在条款17中提出的有关lazy evaluation的建议相矛盾。当你必须支持某些操作而不总需要其结果时,可以使用lazy evaluation用以提高程序运行效率。当你必须支持某些操作而其结果几乎总是被需要或被不止一次地需要时,可以使用over-eager用以提高程序运行效率。它们对性能的巨大提高证明在这方面花些精力是值得的。
条款19:理解临时对象的来源
在C++中真正的临时对象是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆(non-heap)对象会产生临时对象。这种未命名的对象通常在两种条件下产生:为了使函数成功调用而进行隐式类型转换和函数返回对象时。
首先考虑为使函数成功调用而建立临时对象这种情况。当传送给函数的对象类型与参数类型不匹配时会产生这种情况。例如一个函数,它用来计算一个字符在字符串中出现的次数:
1 | // 返回ch在str中出现的次数 |
看一下countChar的调用。第一个被传送的参数是字符数组,但是对应函数的正被绑定的参数的类型是const string&。仅当消除类型不匹配后,才能成功进行这个调用,你的编译器很乐意替你消除它,方法是建立一个string类型的临时对象。通过以buffer做为参数调用string的构造函数来初始化这个临时对象。countChar的参数str被绑定在这个临时的string对象上。当countChar返回时,临时对象自动释放。
这样的类型转换很方便,但是从效率的观点来看,临时string对象的构造和释放是不必要的开销。通常有两个方法可以消除它。一种是重新设计你的代码,不让发生这种类型转换。另一种方法是通过修改软件而不再需要类型转换。
仅当通过传值(by value)方式传递对象或传递常量引用(reference-to-const)参数时,才会发生这些类型转换。当传递一个非常量引用(reference-to-non-const)参数对象,就不会发生。考虑一下这个函数:
1 | void uppercasify(string& str); // 把str中所有的字符改变成大写 |
在字符计数的例子里,能够成功传递char数组到countChar中,但是在这里试图用char数组调用upeercasify函数,则不会成功:
1 | char subtleBookPlug[] = "Effective C++"; |
没有为使调用成功而建立临时对象,为什么呢?
假设建立一个临时对象,那么临时对象将被传递到upeercasify中,其会修改这个临时对象,把它的字符改成大写。但是对subtleBookPlug函数调用的真正参数没有任何影响;仅仅改变了临时从subtleBookPlug生成的string对象。无疑这不是程序员所希望的。程序员传递subtleBookPlug参数到uppercasify函数中,期望修改subtleBookPlug的值。当程序员期望修改非临时对象时,对非常量引用(references-to-non-const)进行的隐式类型转换却修改临时对象。这就是为什么C++语言禁止为非常量引用(reference-to-non-const)产生临时对象。这样非常量引用(reference-to-non-const)参数就不会遇到这种问题。
建立临时对象的第二种环境是函数返回对象时。例如operator+必须返回一个对象,以表示它的两个操作数的和。例如给定一个类型Number,这种类型的operator+被这样声明:
1 | const Number operator+(const Number& lhs, |
这个函数的返回值是临时的,因为它没有被命名;它只是函数的返回值。你必须为每次调用operator+构造和释放这个对象而付出代价。
综上所述,临时对象是有开销的,所以你应该尽可能地去除它们,然而更重要的是训练自己寻找可能建立临时对象的地方。在任何时候只要见到常量引用(reference-to-const)参数,就存在建立临时对象而绑定在参数上的可能性。在任何时候只要见到函数返回对象,就会有一个临时对象被建立(以后被释放)。
条款20:协助完成返回值优化
一个返回对象的函数很难有较高的效率,因为传值返回会导致调用对象内的构造和析构函数,这种调用是不能避免的。考虑rational(有理数)类的成员函数operator*:
1 | class Rational { |
甚至不用看operator*的代码,我们就知道它肯定要返回一个对象,因为它返回的是两个任意数字的计算结果。这些结果是任意的数字。operator*如何能避免建立新对象来容纳它们的计算结果呢?这是不可能的,所以它必须得建立新对象并返回它。
以某种方法返回对象,能让编译器消除临时对象的开销,这样编写函数通常是很普遍的。这种技巧是返回constructor argument而不是直接返回对象,你可以这样做:
1 | // 一种高效和正确的方法,用来实现 |
仔细观察被返回的表达式。它看上去好象正在调用Rational的构造函数,实际上确是这样。你通过这个表达式建立一个临时的Rational对象,
1 | Rational(lhs.numerator() * rhs.numerator(), |
并且这是一个临时对象,函数把它拷贝给函数的返回值,这种方法还会给你带来很多开销,因为你仍旧必须为在函数内临时对象的构造和释放而付出代价,你仍旧必须为函数返回对象的构造和释放而付出代价。
C++规则允许编译器优化不出现的临时对象(temporary objects out of existence)。因此如果你在如下的环境里调用operator*:
1 | Rational a = 10; |
编译器就会被允许消除在operator*内的临时变量和operator*返回的临时变量。它们能在为目标c分配的内存里构造return 表达式定义的对象。如果你的编译器这样去做,调用operator*的临时对象的开销就是零:没有建立临时对象。
通过使用函数的return location(或者用一个在函数调用位置的对象来替代),来消除局部临时对象――是众所周知的和被普遍实现的。它甚至还有一个名字:返回值优化(return value optimization)。
条款21:通过重载避免隐式类型转换
以下是一段代码,如果没有什么不寻常的原因,实在看不出什么东西:
1 | class UPInt { // unlimited precision |
这里还看不出什么令人惊讶的东西。upi1 和upi2都是UPInt对象,所以它们之间相加就会调用UPInts的operator函数。
现在考虑下面这些语句:
1 | upi3 = upi1 + 10; |
这些语句也能够成功运行。方法是通过建立临时对象把整形数10转换为UPInts。
如果我们想要把UPInt和int对象相加,通过声明如下几个函数达到这个目的,每一个函数有不同的参数类型集。
1 | const UPInt operator+(const UPInt& lhs, // add UPInt |
一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:
1 | const UPInt operator+(int lhs, int rhs); // 错误! |
在C+ +中有一条规则是每一个重载的operator必须带有一个用户定义类型(user-defined type)的参数。int不是用户定义类型,所以我们不能重载operator成为仅带有此类型参数的函数。
条款22:考虑用运算符的赋值形式(op=)取代其单独形式(op)
大多数程序员认为如果他们能这样写代码:
1 | x = x + y; x = x - y; |
那他们也能这样写:
1 | x += y; x -= y; |
如果x和y是用户定义的类型(user-defined type),就不能确保这样。就C++来说,operator+、operator=和operator+=之间没有任何关系。
确保operator的赋值形式(assignment version)(例如operator+=)与一个operator的单独形式(stand-alone)(例如 operator+ )之间存在正常的关系,一种好方法是后者(指operator+)根据前者(指operator+=)来实现。这很容易:
1 | class Rational { |
在这个例子里,从零开始实现operator+=和-=,而operator+ 和operator- 则是通过调用前述的函数来提供自己的功能。使用这种设计方法,只用维护operator的赋值形式就行了。而且如果假设operator赋值形式在类的public接口里,这就不用让operator的单独形式成为类的友元。
如果你不介意把所有的operator的单独形式放在全局域里,那就可以使用模板来替代单独形式的函数的编写:
1 | template<class T> |
使用这些模板,只要为operator赋值形式定义某种类型,一旦需要,其对应的operator单独形式就会被自动生成。
第一、总的来说operator 的赋值形式(例如operator+=)比其单独形式效率更高,因为单独形式要返回一个新对象,从而在临时对象的构造和释放上有一些开销、第二、提供operator的赋值形式(例如operator+=)的同时也要提供其标准形式,允许类的客户端在便利与效率上做出折衷选择。
最后一点,涉及到operator单独形式的实现。再看看operator+ 的实现:
1 | template<class T> |
表达式T(lhs)调用了T的拷贝构造函数。它建立一个临时对象,其值与lhs一样。这个临时对象用来与rhs一起调用operator+= ,操作的结果被从operator+.返回。实现方法总可以使用返回值优化,所以编译器为其生成优化代码的可能就会更大。
条款23:考虑变更程序库
理想的程序库应该是短小的、快速的、强大的、灵活的、可扩展的、直观的、普遍适用的、具有良好的支持、没有使用约束、没有错误的。考虑iostream 和stdio程序库,iostream程序库与C中的stdio相比有几个优点,在效率方面,iostream程序库总是不如stdio,因为stdio产生的执行文件与iostream产生的执行文件相比尺寸小而且执行速度快。
让我们测试一个简单的benchmark 程序,只测试最基本的I/O功能。这个程序从标准输入读取30000个浮点数,然后把它们以固定的格式写到标准输出里。编译时预处理符号STDIO决定是使用stdio还是iostream。
1 |
|
cout远不如printf输入方便,但是操作符<<既是类型安全(type-safe)又可以扩展,而printf则不具有这两种优点。
应该注意到stdio的高效性主要是由其代码实现决定的,所以我已经测试过的系统其将来的实现或者我没有测试过的系统的当前实现都可能表现出iostream和stdio并没有显著的差异。一旦你找到软件的瓶颈应该知道是否可能通过替换程序库来消除瓶颈。
条款24:理解虚拟函数、多继承、虚基类和RTTI所需的代价
当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。大多数编译器是使用virtual table和virtual table pointers。virtual table和virtual table pointers通常被分别地称为vtbl和vptr。
一个vtbl通常是一个函数指针数组。在程序中的每个类只要声明了虚函数或继承了虚函数,它就有自己的vtbl,并且类中vtbl的项目是指向虚函数实现体的指针。例如,如下这个类定义:
1 | class C1 { |
C1的virtual table数组看起来如下图所示:
注意非虚函数f4不在表中,而且C1的构造函数也不在。非虚函数就象普通的C函数那样被实现,所以有关它们的使用在性能上没有特殊的考虑。如果有一个C2类继承自C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,
1 | class C2: public C1 { |
它的virtual table项目指向与对象相适合的函数。这些项目包括指向没有被C2重定义的C1虚函数的指针:
这个论述引出了虚函数所需的第一个代价:你必须为每个包含虚函数的类的virtual talbe留出空间。类的vtbl的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。每个类应该只有一个virtual table,所以virtual table所需的空间可能很大。
virtual table放在哪里呢?
- 为每一个可能需要vtbl的object文件生成一个vtbl拷贝。
- 连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个vtbl保留一个实例。
- 采用启发式算法来决定哪一个object文件应该包含类的vtbl。
- 要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。因此上述C1类的vtbl将被放置到包含C1::
C1定义的object文件里(不是内联的函数),C2类的vtbl被放置到包含C1::C2定义的object文件里(不是内联函数)。
- 要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。因此上述C1类的vtbl将被放置到包含C1::
Virtual table只实现了虚拟函数的一半机制,virtual table pointer来建立联系。每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:
虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。
假如我们有一个程序:
1 | void makeACall(C1 *pC1) |
通过指针pC1调用虚拟函数f1。仅仅看这段代码,你不会知道它调用的是那一个f1函数――C1::f1或C2::f1,因为pC1可以指向C1对象也可以指向C2对象。尽管如此编译器仍然得为在makeACall的f1函数的调用生成代码,它必须确保无论pC1指向什么对象,函数的调用必须正确。编译器生成的代码会做如下这些事情:
- 通过对象的vptr找到类的vtbl。
- 找到对应vtbl内的指向被调用函数的指针(在上例中是f1)。
- 调用第二步找到的的指针所指向的函数。
如果我们假设每个对象有一个隐藏的数据叫做vptr,而且f1在vtbl中的索引为i,此语句
1 | pC1->f1(); |
生成的代码就是这样的
1 | (*pC1->vptr[i])(pC1); //调用被vtbl中第i个单元指 |
这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。
在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令”,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数”。这是虚函数所需的第三个代价:你实际上放弃了使用内联函数。
如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,把基类定义为虚基类则可以消除这种复制。然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。虚函数能使对象变得更大,而且不能使用内联。
运行时类型识别(RTTI)能让我们在运行时找到对象和类的有关信息,你能通过使用typeid操作符访问一个类的type_info对象。我们保证可以获得一个对象动态类型信息,如果该类型有至少一个虚函数。RTTI被设计为在类的vtbl基础上实现。
使用这种实现方法,RTTI耗费的空间是在每个类的vtbl中的占用的额外单元再加上存储type_info对象的空间。下面这个表各是对虚函数、多继承、虚基类以及RTTI所需主要代价的总结:
| Feature | Increases Size of Objects | Increases Per-Class Data | Reduces Inlining |
|---|---|---|---|
| Virtual Functions | Yes | Yes | Yes |
| Multiple Inheritance | Yes | Yes | No |
| Virtual Base Classes | Often | Sometimes | No |
| RTTI | No | Yes | No |
条款25:将构造函数和非成员函数虚拟化
当你有一个指针或引用,但是不知道其指向对象的真实类型是什么时,你可以调用虚拟函数来完成特定类型(type-specific)对象的行为。虚拟构造函数是指能够根据输入给它的数据的不同而建立不同类型的对象。还有一种特殊种类的虚拟构造函数――虚拟拷贝构造函数――也有着广泛的用途。虚拟拷贝构造函数能返回一个指针,指向调用该函数的对象的新拷贝。因为这种行为特性,虚拟拷贝构造函数的名字一般都是copySelf,cloneSelf或者是象下面这样就叫做clone。很少会有函数能以这么直接的方式实现它:
1 | class NLComponent { |
正如我们看到的,类的虚拟拷贝构造函数只是调用它们真正的拷贝构造函数。因此“拷贝”的含义与真正的拷贝构造函数相同。如果真正的拷贝构造函数只做了简单的拷贝,那么虚拟拷贝构造函数也做简单的拷贝。如果真正的拷贝构造函数做了全面的拷贝,那么虚拟拷贝构造函数也做全面的拷贝。如果真正的拷贝构造函数做一些奇特的事情,象引用计数或copy-on-write,那么虚拟构造函数也这么做。
被派生类重定义的虚拟函数不用必须与基类的虚拟函数具有一样的返回类型。如果函数的返回类型是一个指向基类的指针(或一个引用),那么派生类的函数可以返回一个指向基类的派生类的指针(或引用)。这不是C++的类型检查上的漏洞,它使得又可能声明象虚拟构造函数这样的函数。
在NLComponent中的虚拟拷贝构造函数能让实现NewLetter的(正常的)拷贝构造函数变得很容易:
1 | class NewsLetter { |
遍历被拷贝的NewsLetter对象中的整个component链表,调用链表内每个元素对象的虚拟构造函数。我们在这里需要一个虚拟构造函数,因为链表中包含指向NLComponent对象的指针,但是我们知道其实每一个指针不是指向TextBlock对象就是指向Graphic对象。无论它指向谁,我们都想进行正确的拷贝操作,虚拟构造函数能够为我们做到这点。
虚拟化非成员函数
非成员函数也不能成为真正的虚拟函数。然而,既然一个函数能够构造出不同类型的新对象是可以理解的,那么同样也存在这样的非成员函数,可以根据参数的不同动态类型而其行为特性也不同。例如,假设你想为TextBlock和Graphic对象实现一个输出操作符。显而易见的方法是虚拟化这个输出操作符。但是输出操作符是operator<<,函数把ostream&做为它的左参数(left-hand argument)(即把它放在函数参数列表的左边 译者注),这就不可能使该函数成为TextBlock 或 Graphic成员函数。
1 | class NLComponent { |
类的使用者得把stream对象放到<<符号的右边,这与输出操作符一般的用法相反。为了能够回到正常的语法上来,我们必须把operator<<移出TextBlock 和 Graphic类,但是如果我们这样做,就不能再把它声明为虚拟了。)
另一种方法是为打印操作声明一个虚拟函数(例如print)把它定义在TextBlock 和 Graphic类里。但是如果这样,打印TextBlock 和 Graphic对象的语法就与使用operator<<做为输出操作符的其它类型的对象不一致了,定义operator<< 和print函数,让前者调用后者!
1 | class NLComponent { |
条款26:限制某个类所能产生的对象数量(上)
每次实例化一个对象时,我们很确切地知道一件事情:“将调用一个构造函数。”事实确实这样,阻止建立某个类的对象,最容易的方法就是把该类的构造函数声明在类的private域:
1 | class CantBeInstantiated { |
这样做以后,每个人都没有权力建立对象,我们能够有选择性地放松这个限制。把打印机对象封装在一个函数内,以便让每个人都能访问打印机,但是只有一个打印机对象被建立。:
1 | class PrintJob; // forward 声明 |
这个设计由三个部分组成:
- Printer类的构造函数是private。这样能阻止建立对象。
- 全局函数thePrinter被声明为类的友元,让thePrinter避免私有构造含函数引起的限制。
- thePrinter包含一个静态Printer对象,这意味着只有一个对象被建立。
客户端代码无论何时要与系统的打印机进行交互访问,它都要使用thePrinter函数:
1 | class PrintJob { |
使用静态函数,如下所示:
1 | class Printer { |
客户端使用printer时有些繁琐:
1 | Printer::thePrinter().reset(); |
另一种方法是把thePrinter移出全局域,放入namespace(命名空间)。命名空间从句法上来看有些象类,但是它没有public、protected或private域。所有都是public。如下所示,我们把Printer、thePrinter放入叫做PrintingStuff的命名空间里:
1 | namespace PrintingStuff { |
使用这个命名空间后,客户端可以通过使用fully-qualified name(完全限制符名)
1 | PrintingStuff::thePrinter().reset(); |
但是也可以使用using声明,以简化键盘输入:
1 | using PrintingStuff::thePrinter; // 从命名空间"PrintingStuff" |
单独的Printer是位于函数里的静态成员而不是在类中的静态成员,只有第一次执行函数时,才会建立函数中的静态对象,所以如果没有调用函数,就不会建立对象。与一个函数的静态成员相比,把Printer声明为类中的静态成员还有一个缺点,它的初始化时间不确定。
第二个细微之处是内联与函数内静态对象的关系。再看一下thePrinter的非成员函数形式:
1 | Printer& thePrinter() |
除了第一次执行这个函数时,其它时候这就是一个一行函数——return p;。记住一件事:带有内部链接的函数可能在程序内被复制(也就是说程序的目标(object)代码可能包含一个以上的内部链接函数的代码,这种复制也包括函数内的静态对象。如果建立一个包含局部静态对象的非成员函数,你可能会使程序的静态对象的拷贝超过一个!所以不要建立包含局部静态数据的非成员函数。
允许对象来去自由
使用thePrinter函数封装对单个对象的访问,以便把Printer对象的数量限制为一个,这样做的同时也会让我们在每一次运行程序时只能使用一个Printer对象。导致我们不能这样编写代码:
1 | 建立 Printer 对象 p1; |
这种设计在同一时间里没有实例化多个Printer对象,而是在程序的不同部分使用了不同的Printer对象。不允许这样编写有些不合理。我们必须把先前使用的对象计数的代码与刚才看到的伪构造函数代码合并在一起:
1 | class Printer { |
当需要的对象过多时,会抛出异常,如果你认为这种方式给你的感觉是unreasonably harsh,你可以让伪构造函数返回一个空指针。当然客户端在使用之前应该进行检测。除了客户端必须调用伪构造函数,而不是真正的构造函数之外,它们使用Printer类就象使用其他类一样:
1 | Printer p1; // 错误! 缺省构造函数是 |
这种技术很容易推广到限制对象为任何数量上。我们只需把hard-wired常量值1改为根据某个类而确定的数量,然后消除拷贝对象的约束。例如,下面这个经过修改的Printer类的代码实现,最多允许10个Printer对象存在:
1 | class Printer { |
或者把maxObjects作为枚举类型。
1 | class Printer { |
或者象non-const static成员一样初始化static常量:
1 | class Printer { |
一个具有对象计数功能的基类
我们很容易地能够编写一个具有实例计数功能的基类,然后让像Printer这样的类从该基类继承。Printer类的计数器是静态变量numObjects,我们应该把变量放入实例计数类中。然而也需要确保每个进行实例计数的类都有一个相互隔离的计数器。使用计数类模板可以自动生成适当数量的计数器,因为我们能让计数器成为从模板中生成的类的静态成员:
1 | template<class BeingCounted> |
从这个模板生成的类仅仅能被做为基类使用,因此构造函数和析构函数被声明为protected。注意private成员函数init用来避免两个Counted构造函数的语句重复。
现在我们能修改Printer类,这样使用Counted模板:
1 | class Printer: private Counted<Printer> { |
Printer使用了Counter模板来跟踪存在多少Printer对象。另一种方法是在Printer和counted<Printer>之间使用public继承,但是我们必须给Counted类一个虚拟析构函数。
当Printer继承Counted<Printer>时,它可以忘记有关对象计数的事情。编写Printer类时根本不用考虑对象计数,就好像有其他人会为它计数一样。Printer的构造函数可以是这样的:
1 | Printer::Printer() |
因为Counted<Printer>是Printer的基类,Counted<Printer>的构造函数总在Printer的前面被调用。如果建立过多的对象,Counted<Printer>的构造函数就会抛出异常,甚至都没有调用Printer的构造函数。
最后还有一点需要注意,必须定义Counted内的静态成员。对于numObjects来说,这很容易——我们只需要在Counted的实现文件里定义它即可:
1 | template<class BeingCounted> // 定义numObjects |
我们应该如何初始化Counted<Printer>::maxObjects?简单的方法就是什么也不做,让此类的客户端提供合适的初始化。Printer的作者必须把这条语句加入到一个实现文件里:
1 | const size_t Counted<Printer>::maxObjects = 10; |
同样FileDescriptor的作者也得加入这条语句:
1 | const size_t Counted<FileDescriptor>::maxObjects = 16; |
条款27:要求或禁止在堆中产生对象(上)
要求在堆中建立对象
为了执行这种限制,你必须找到一种方法禁止以调用“new”以外的其它手段建立对象。这很容易做到。非堆对象(non-heap object)在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制。
把这些调用变得不合法的一种最直接的方法是把构造函数和析构函数声明为private。这样做副作用太大。没有理由让这两个函数都是private。最好让析构函数成为private,让构造函数成为public。处理过程与条款26相似,你可以引进一个专用的伪析构函数,用来访问真正的析构函数。客户端调用伪析构函数释放他们建立的对象。
例如,如果我们想仅仅在堆中建立代表unlimited precision numbers(无限精确度数字)的对象,可以这样做:
1 | class UPNumber { |
然后客户端这样进行程序设计:
1 | UPNumber n; // 错误! (在这里合法,但是当它的析构函数被隐式地调用时,就不合法了) |
另一种方法是把全部的构造函数都声明为private。这种方法的缺点是一个类经常有许多构造函数,类的作者必须记住把它们都声明为private。否则如果这些函数就会由编译器生成,构造函数包括拷贝构造函数,也包括缺省构造函数;编译器生成的函数总是public。因此仅仅声明析构函数为private是很简单的,因为每个类只有一个析构函数。
通过限制访问一个类的析构函数或它的构造函数来阻止建立非堆对象:
1 | class UPNumber { ... }; // 声明析构函数或构造函数 |
这些困难不是不能克服的。通过把UPNumber的析构函数声明为protected(同时它的构造函数还保持public)就可以解决继承的问题,需要包含UPNumber对象的类可以修改为包含指向UPNumber的指针:
1 | class UPNumber { ... }; // 声明析构函数为protected |
判断一个对象是否在堆中
最根本的问题是对象可以被分配在三个地方,而不是两个。是的,栈和堆能够容纳对象,但是我们忘了静态对象。静态对象是那些在程序运行时仅能初始化一次的对象。静态对象不仅仅包括显示地声明为static的对象,也包括在全局和命名空间里的对象。这些对象肯定位于某些地方,而这些地方既不是栈也不是堆。
它们的位置是依据系统而定的,但是在很多栈和堆相向扩展的系统里,它们位于堆的底端。不仅没有一种可移植的方法来判断对象是否在堆上,而且连能在多数时间正常工作的“准可移植”的方法也没有。如果你实在非得必须判断一个地址是否在堆上,你必须使用完全不可移植的方法,其实现依赖于系统调用。
如果你发现自己实在为对象是否在堆中这个问题所困扰,一个可能的原因是你想知道对象是否能在其上安全调用delete。这种删除经常采用delete this这种声明狼籍的形式。不过知道“是否能安全删除一个指针”与“只简单地知道一个指针是否指向堆中的事物”不一样,因为不是所有在堆中的事物都能被安全地delete。再考虑包含UPNumber对象的Asset对象:
1 | class Asset { |
很明显*pa(包括它的成员value)在堆上。同样很明显在指向pa->value上调用delete是不安全的,因为该指针不是被new返回的。
幸运的是“判断是否能够删除一个指针”比“判断一个指针指向的事物是否在堆上”要容易。因为对于前者我们只需要一个operator new返回的地址集合:
1 | void *operator new(size_t size) |
这很简单,operator new在地址分配集合里加入一个元素,operator delete从集合中移去项目,isSafeToDelete在集合中查找并确定某个地址是否在集合中。如果operator new 和 operator delete函数在全局作用域中,它就能适用于所有的类型,甚至是内建类型。
在实际当中,有三种因素制约着对这种设计方式的使用。
- 第一是我们极不愿意在全局域定义任何东西,特别是那些已经具有某种含义的函数,象
operator new和operator delete。正如我们所知,只有一个全局域,只有一种具有正常特征形式(也就是参数类型)的operator new和operator delete。这样做会使得我们的软件与其它也实现全局版本的operator new和operator delete的软件(例如许多面向对象数据库系统)不兼容。 - 我们考虑的第二个因素是效率:如果我们不需要这些,为什么还要为跟踪返回的地址而负担额外的开销呢?
- 最后一点可能有些平常,但是很重要。实现isSafeToDelete让它总能够正常工作是不可能的。难点是多继承下来的类或继承自虚基类的类有多个地址,所以无法保证传给isSafeToDelete的地址与operator new 返回的地址相同,即使对象在堆中建立。
C++使用一种抽象基类满足了我们的需要。抽象基类是不能被实例化的基类,也就是至少具有一个纯虚函数的基类。mixin(mix in)类提供某一特定的功能,并可以与其继承类提供的其它功能相兼容。这种类几乎都是抽象类。因此我们能够使用抽象混合(mixin)基类给派生类提供判断指针指向的内存是否由operator new分配的能力。该类如下所示:
1 | class HeapTracked { // 混合类; 跟踪 |
这个类使用了list(链表)数据结构跟踪从operator new返回的所有指针,list标准C++库的一部分。operator new函数分配内存并把地址加入到list中;operator delete用来释放内存并从list中移去地址元素。isOnHeap判断一个对象的地址是否在list中。
HeapTracked类的实作很简单,调用全局的operator new和operator delete函数来完成内存的分配与释放,list类里的函数进行插入操作和删除操作,并进行单语句的查找操作。以下是HeapTracked的全部实作:
1 | // mandatory definition of static class member |
因为isOnHeap仅仅用于HeapTracked对象中,我们能使用dynamic_cast操作符的一种特殊的特性来消除这个问题。只需简单地放入dynamic_cast,把一个指针dynamic_cast成void*类型(或const void*或volatile void*),生成的指针指向“原指针指向对象内存”的开始处。但是dynamic_cast只能用于“指向至少具有一个虚拟函数的对象”的指针上。isOnHeap更具有选择性,所以能把this指针dynamic_cast成const void*,变成一个指向当前对象起始地址的指针。如果HeapTracked::operator new为当前对象分配内存,这个指针就是HeapTracked::operator new返回的指针。如果你的编译器支持dynamic_cast 操作符,这个技巧是完全可移植的。
使用这个类,即使是最初级的程序员也可以在类中加入跟踪堆中指针的功能。他们所需要做的就是让他们的类从HeapTracked继承下来。例如我们想判断Assert对象指针指向的是否是堆对象:
1 | class Asset: public HeapTracked { |
我们能够这样查询Assert*指针,如下所示:
1 | void inventoryAsset(const Asset *ap) |
象HeapTracked这样的混合类有一个缺点,它不能用于内建类型,因为象int和char这样的类型不能继承自其它类型。不过使用象HeapTracked的原因一般都是要判断是否可以调用”delete this”,你不可能在内建类型上调用它,因为内建类型没有this指针。
禁止堆对象
通常对象的建立这样三种情况:对象被直接实例化;对象做为派生类的基类被实例化;对象被嵌入到其它对象内。我们将按顺序地讨论它们。
禁止客户端直接实例化对象很简单,利用new操作符总是调用operator new函数来达到目的。例如,如果你想不想让客户端在堆中建立UPNumber对象,你可以这样编写:
1 | class UPNumber { |
现在客户端仅仅可以做允许它们做的事情:
1 | UPNumber n1; // okay |
如果你也想禁止UPNumber堆对象数组,可以把operator new[]和operator delete[]也声明为private。有趣的是,把operator new声明为private经常会阻碍UPNumber对象做为一个位于堆中的派生类对象的基类被实例化。因为如果operator new和operator delete没有在派生类中被声明为public,它们就会被继承下来,继承了基类private函数的类,如下所示:
1 | class UPNumber { ... }; // 同上 |
如果派生类声明它自己的operator new,当在堆中分配派生对象时,就会调用这个函数,必须得找到一种不同的方法防止UPNumber基类部分缠绕在这里。同样,UPNumber的operator new是private这一点,不会对分配包含做为成员的UPNumber对象的对象产生任何影响:
1 | class Asset { |
条款28:灵巧(smart)指针(上)
灵巧指针是一种外观和行为都被设计成与内建指针相类似的对象,不过它能提供更多的功能。当你使用灵巧指针替代C++的内建指针(也就是dumb pointer),你就能控制下面这些方面的指针的行为:
- 构造和析构。你可以决定建立灵巧指针时应该怎么做。通常赋给灵巧指针缺省值0,避免出现令人头疼的未初始化的指针。当指向某一对象的最后一个灵巧指针被释放时,一些灵巧指针负责删除它们指向的对象。
- 拷贝和赋值。你能对拷贝灵巧指针或设计灵巧指针的赋值操作进行控制。对于一些类型的灵巧指针来说,期望的行为是自动拷贝它们所指向的对象或用对这些对象进行赋值操作,也就是进行deep copy(深层拷贝)。
- Dereferencing(取出指针所指东西的内容)。当客户端引用被灵巧指针所指的对象,可以自行决定行为。
灵巧指针从模板中生成,因为要与内建指针类似,必须是strongly typed(强类型)的;模板参数确定指向对象的类型。大多数灵巧指针模板看起来都象这样:
1 | template<class T> //灵巧指针对象模板 |
拷贝构造函数和赋值操作符都被展现在这里。对于灵巧指针类来说,不能允许进行拷贝和赋值操作,它们应该被声明为private 。两个dereference操作符被声明为const,是因为dereference一个指针时不能对指针进行修改。最后,每个指向T对象的灵巧指针包含一个指向T的dumb pointer。这个dumb pointer指向的对象才是灵巧指针指向的真正对象。
采用不同的方法分别处理本地对象与远程对象是一件很烦人的事情。让所有的对象都位于一个地方会更方便。灵巧指针可以让程序库实现这样的梦想。
1 | template<class T> // 指向位于分布式 DB(数据库) |
程序员只需关心通过对象进行访问的元组,而不用关心如何声明它们,其行为就像一个内建指针。正如你所看到的,使用灵巧指针与使用dump pointer没有很大的差别。这表明了封装是非常有效的。
灵巧指针的构造、赋值和析构
灵巧指针的析构通常很简单:找到指向的对象(一般由灵巧指针构造函数的参数给出),让灵巧指针的内部成员dumb pointer指向它。如果没有找到对象,把内部指针设为0或发出一个错误信号(可以是抛出一个异常)。
看一下标准C++类库中auto_ptr模板。一个auto_ptr对象是一个指向堆对象的灵巧指针,直到auto_ptr被释放。auto_ptr模板的实作如下:
1 | template<class T> |
假如auto_ptr拥有对象时,它可以正常运行。但是当auto_ptr被拷贝或被赋值时,会发生什么情况呢?
1 | auto_ptr<TreeNode> ptn1(new TreeNode); |
如果我们只拷贝内部的dumb pointer,会导致两个auto_ptr指向一个相同的对象。这是一个灾难,因为当释放auto_ptr时每个auto_ptr都会删除它们所指的对象。这意味着一个对象会被我们删除两次。
另一种方法是通过调用new,建立一个所指对象的新拷贝。这确保了不会有许多指向同一个对象的auto_ptr,但是建立(以后还得释放)新对象会造成不可接受的性能损耗。并且我们不知道要建立什么类型的对象。如果auto_ptr禁止拷贝和赋值,就可以消除这个问题,但是采用当auto_ptr被拷贝和赋值时,对象所有权随之被传递的方法,是一个更具灵活性的解决方案:
1 | template<class T> |
注意赋值操作符在接受新对象的所有权以前必须删除原来拥有的对象。如果不这样做,原来拥有的对象将永远不会被删除。记住,除了auto_ptr对象,没有人拥有auto_ptr指向的对象。
因为当调用auto_ptr的拷贝构造函数时,对象的所有权被传递出去,所以通过传值方式传递auto_ptr对象是一个很糟糕的方法。因为:
1 | // 这个函数通常会导致灾难发生 |
当printTreeNode的参数p被初始化时,ptn指向对象的所有权被传递到给了p。当printTreeNode结束执行后,p离开了作用域,它的析构函数删除它指向的对象。然而ptr不再指向任何对象,所以调用printTreeNode以后任何试图使用它的操作都将产生不可定义的行为。只有在你确实想把对象的所有权传递给一个临时的函数参数时,才能通过传值方式传递auto_ptr。通过const引用传递可以传递,方法是这样的:
1 | // 这个函数的行为更直观一些 |
在函数里,p是一个引用,而不是一个对象,所以不会调用拷贝构造函数初始化p。当ptn被传递到上面这个printTreeNode时,它还保留着所指对象的所有权,调用printTreeNode以后还可以安全地使用ptn。
当拷贝一个对象或这个对象做为赋值的数据源,就会修改该对象。
灵巧指针的析构函数通常是这样的:
1 | template<class T> |
实作Dereference 操作符
让我们把注意力转向灵巧指针的核心部分,the operator*和operator->函数。理论上,这很简单:
1 | template<class T> |
注意返回类型是一个引用。必须时刻牢记:pointee不用必须指向T类型对象;它也可以指向T的派生类对象。如果在这种情况下operator*函数返回的是T类型对象而不是派生类对象的引用,你的函数实际上返回的是一个错误类型的对象。
operator->的情况与operator*是相同的,但是在分析operator->之前,让我们先回忆一下这个函数调用的与众不同的含义。再考虑editTuple函数,其使用一个指向Tuple对象的灵巧指针:
1 | void editTuple(DBPtr<Tuple>& pt) |
语句pt->displayEditDialog();被编译器解释为:(pt.operator->())->displayEditDialog();,这意味着不论operator->返回什么,它必须使用成员选择操作符(->)。因此operator->仅能返回两种东西:一个指向某对象的dumb pointer或另一个灵巧指针。多数情况下,你想返回一个普通dumb pointer。在此情况下,你这样实作operator-> :
1 | template<class T> |
这样做运行良好。因为该函数返回一个指针,通过operator->调用虚拟函数,其行为也是正确的。
测试灵巧指针是否为NULL
目前为止我们讨论的函数能让我们建立、释放、拷贝、赋值、dereference灵巧指针。但是有一件我们做不到的事情是“发现灵巧指针为NULL”:
1 | SmartPtr<TreeNode> ptn; |
这是一个严重的限制。
在灵巧指针类里加入一个isNull成员函数是一件很容易的事,但是仍然没有解决当测试NULL时灵巧指针的行为与dumb pointer不相似的问题。另一种方法是提供隐式类型转换操作符,允许编译上述的测试。一般应用于这种目的的类型转换是void* :
1 | template<class T> |
这与iostream类中提供的类型转换相同,所以可以这样编写代码:
1 | ifstream inputFile("datafile.dat"); |
象所有的类型转换函数一样,它有一个缺点,在一些情况下虽然大多数程序员希望它调用失败,但是函数还能够成功地被调用。特别是它允许灵巧指针与完全不同的类型之间进行比较:
1 | SmartPtr<Apple> pa; |
即使在SmartPtr<Apple> 和 SmartPtr<Orange>之间没有operator= 函数,也能够编译,因为灵巧指针被隐式地转换为void*指针,对于内建指针类型有一个内建的比较函数。这种进行隐式类型转换的行为特性很危险。
有一种两全之策可以提供合理的测试空值的语法形式,这就是在灵巧指针类中重载operator!,当且仅当灵巧指针是一个空指针时,operator!返回true:
1 | template<class T> |
客户端程序如下所示:
1 | SmartPtr<TreeNode> ptn; |
但是这样就不正确了:
1 | if (ptn == 0) ... // 仍然错误 |
仅在这种情况下会存在不同类型之间进行比较:
1 | SmartPtr<Apple> pa; |
通过成员模板来实现灵巧指针的类型转换有两个缺点。第一,支持成员模板的编译器较少,所以这种技术不具有可移植性。第二,这种方法的工作原理不很明了,要理解它必须先要深入理解函数调用的参数匹配,隐式类型转换函数,模板函数隐式实例化和成员函数模板。正如Daniel Edelson所说,灵巧指针固然灵巧,但不是指针。最好的方法是使用成员模板生成类型转换函数,在会产生二义性结果的地方使用casts。
灵巧指针和const
对于dumb指针来说,const既可以针对指针所指向的东西,也可以针对于指针本身,或者兼有两者的含义:
1 | CD goodCD("Flood"); |
我们自然想要让灵巧指针具有同样的灵活性。不幸的是只能在一个地方放置const,并只能对指针本身起作用,而不能针对于所指对象:
1 | const SmartPtr<CD> p = // p 是一个const 灵巧指针 |
好像有一个简单的补救方法,就是建立一个指向cosnt CD的灵巧指针:
1 | SmartPtr<const CD> p = // p 是一个 non-const 灵巧指针 |
现在我们可以建立const和non-const对象和指针的四种不同组合:
1 | SmartPtr<CD> p; // non-const 对象 |
但是美中不足的是,使用dumb指针我们能够用non-const指针初始化const指针,我们也能用指向non-cosnt对象的指针初始化指向const对象的指针;就像进行赋值一样。例如:
1 | CD *pCD = new CD("Famous Movie Themes"); |
但是如果我们试图把这种方法用在灵巧指针上,情况会怎么样呢?
1 | SmartPtr<CD> pCD = new CD("Famous Movie Themes"); |
SmartPtr<CD>与SmartPtr<const CD>是完全不同的类型。在编译器看来,它们是毫不相关的,所以没有理由相信它们是赋值兼容的。到目前为止这是一个老问题了,把它们变成赋值兼容的惟一方法是你必须提供函数,用来把SmartPtr
包括const的类型转换是单向的:从non-const到const的转换是安全的,但是从const到non-const则不是安全的。而且用const指针能的事情,用non-const指针也能做,但是用non-const指针还能做其它一些事情。同样,用指向const的指针能做的任何事情,用指向non-const的指针也能做到,但是用指向non-const的指针能够完成一些使用指向const的指针所不能完成的事情。
这些规则看起来与public继承的规则相类似。你能够把一个派生类对象转换成基类对象,但是反之则不是这样,你对基类所做的任何事情对派生类也能做,但是还能对派生类做另外一些事情。我们能够利用这一点来实作灵巧指针,就是说可以让每个指向T的灵巧指针类public派生自一个对应的指向const-T的灵巧指针类:
1 | template<class T> // 指向const对象的 |
使用这种设计方法,指向non-const-T对象的灵巧指针包含一个指向const-T的dumb指针,指向const-T的灵巧指针需要包含一个指向cosnt-T的dumb指针。最方便的方法是把指向const-T的dumb指针放在基类里,把指向non-const-T的dumb指针放在派生类里,然而这样做有些浪费,因为SmartPtr对象包含两个dumb指针:一个是从SmartPtrToConst继承来的,一个是SmartPtr自己的。
一种在C世界里的老式武器可以解决这个问题,这就是union,它在C++中同样有用。Union在protected中,所以两个类都可以访问它,它包含两个必须的dumb指针类型,SmartPtrToConst<T>对象使用constPointee指针,SmartPtr<T>对象使用pointee指针。因此我们可以在不分配额外空间的情况下,使用两个不同的指针。
利用这种新设计,我们能够获得所要的行为特性:
1 | SmartPtr<CD> pCD = new CD("Famous Movie Themes"); |
条款29:引用计数
引用计数允许多个有相同值的对象共享这个值的实现。这个技巧:
- 简化跟踪堆中的对象的过程。引用计数可以免除跟踪对象所有权的担子,对象自己拥有自己。当没人再使用它时,它自己自动销毁自己。因此,引用计数是个简单的垃圾回收体系。
- 让所有的对象共享这个值的实现。这么做不但节省内存,而且可以使得程序运行更快,因为不需要构造和析构这个值的拷贝。
保存当前共享/引用同一个值的对象数目的需求意味着我们必须增加一个计数值(引用计数)。
所以我们将创建一个类来保存引用计数及其跟踪的值。我们叫这个类StringValue,又因为它唯一的用处就是帮助我们实现String类,所以我们将它嵌套在String类的私有区内。另外,为了便于String的所有成员函数读取其数据区,我们将StringValue申明为struct。
1 | class String { |
这是StringValue的实现:
1 | class String { |
StringValue的主要目的是提供一个空间将一个特别的值和共享此值的对象的数目联系起来。接下来是String的成员函数,首先是构造函数:
1 | class String { |
第一个构造函数被实现得尽可能简单。我们用传入的char *字符串创建了一个新的StringValue对象,并将我们正在构造的string对象指向这个新生成的StringValue:
1 | String::String(const char *initValue) |
String的拷贝构造函数很高效:新生成的String对象与被拷贝的对象共享相同的StringValue对象:
1 | String::String(const String& rhs) |
这肯定比通常的string类高效,因为不需要为新生成的string值分配内存、释放内存以及将内容拷贝入这块内存。
String类的析构函数同样容易实现,因为大部分情况下它不需要做任何事情。只要引用计数值不是0,也就是至少有一个String对象使用这个值,这个值就不可以被销毁。只有当唯一的使用者被析构了,String的析构函数才摧毁StringValue对象:
1 | class String { |
这就是String的构造和析构,我们现在转到赋值操作:
1 | class String { |
当用户写下这样的代码:
1 | s1 = s2; // s1 and s2 are both String objects |
其结果应该是s1和s2指向相同的StringValue对象。对象的引用计数应该在赋值时被增加。并且,s1原来指向的StringValue对象的引用计数应该减少,因为s1不再具有这个值了。如果s1是拥有原来的值的唯一对象,这个值应该被销毁。在C++中,其实现看起来是这样的:
1 | String& String::operator=(const String& rhs) |
写时拷贝
围绕我们的带引用计数的String类,考虑一下数组下标操作([]),它允许字符串中的单个字符被读或写:
1 | class String { |
这个函数的const版本的实现很容易,因为它是一个只读操作,String对象的值不受影响:
1 | const char& String::operator[](int index) const |
非const的operator[]版本就是一个完全不同的故事了。它可能是被调用了来读一个字符,也可能被调用了来写一个字符:
1 | String s; |
我们必须保守地假设“所有”调用非const operator[]的行为都是为了写操作。为了安全地实现非const的operator[],我们必须确保没有其它String对象在共享这个可能被修改的StringValue对象。简而言之,当我们返回StringValue对象中的一个字符的引用时,必须确保这个StringValue的引用计数是1。这儿是我们的实现:
1 | char& String::operator[](int index) |
指针、引用与写时拷贝
大部分情况下,写时拷贝可以同时保证效率和正确性。只有一个挥之不去的问题。看一下这样的代码:
1 | String s1 = "Hello"; |
现在看增加一条语句:
1 | String s2 = s1; |
String的拷贝构造函数使得s2共享s1的StringValue对象,下面这样的语句将有不受欢迎的结果:
1 | *p = 'x'; // modifies both s1 and s2! |
String的拷贝构造函数没有办法检测这样的问题,因为它不知道指向s1拥有的StringValue对象的指针的存在。并且,这个问题不局限于指针:它同样存在于有人保存了一个String的非const operator[]的返回值的引用的情况下。
解决的方法是这样的:在每个StringValue对象中增加一个标志以指出它是否为可共享的。在最初(对象可共享时)将标志打开,在非const的operator[]被调用时将它关闭。一旦标志被设为false,它将永远保持在这个状态。
这是增加了共享标志的修改版本:
1 | class String { |
如上所见,并不需要太多的改变;需要修改的两行都有注释。当然,String的成员函数也必须被修改以处理这个共享标志。这里是拷贝构造函数的实现:
1 | String::String(const String& rhs) |
所有其它的成员函数也都必须以类似的方法检查这个共享标志。非const的operator[]版本是唯一将共享标志设为false的地方:
1 | char& String::operator[](int index) |
带引用计数的基类
将引用计数的代码写成与运行环境无关的,第一步是构建一个基类RCObject,任何需要引用计数的类都必须从它继承。RCObject封装了引用计数功能,如增加和减少引用计数的函数。它还包含了当这个值不再被需要时摧毁值对象的代码。最后,它包含了一个字段以跟踪这个值对象是否可共享,并提供查询这个值和将它设为false的函数。不需将可共享标志设为true的函数,因为所有的值对象默认都是可共享的。如上面说过的,一旦一个对象变成了不可共享,将没有办法使它再次成为可共享。RCObject的定义如下:
1 | class RCObject { |
RCObjcet可以被构造(作为派生类的基类部分)和析构;可以有新的引用加在上面以及移除当前引用;其可共享性可以被查询以及被禁止;它们可以报告当前是否被共享了。这就是它所提供的功能。对于想有引用计数的类,这确实就是我们所期望它们完成的东西。注意虚析构函数,它明确表明这个类是被设计了作基类使用的。同时要注意这个析构函数是纯虚的,它明确表明这个类只能作基类使用。
RCOject的实现代码:
1 | RCObject::RCObject() |
为了使用我们新写的引用计数基类,我们将StringValue修改为是从RCObject继承而得到引用计数功能的:
1 | class String { |
这个版本的StringValue和前面的几乎一样,唯一改变的就是StringValue的成员函数不再处理refCount字段。RCObject现在接管了这个工作。
实现引用计数不是没有代价的。每个被引用的值带一个引用计数,其大部分操作都需要以某种形式检查或操作引用计数。对象的值需要更多的内存,而我们在处理它们时需要执行更多的代码。此外,就内部的源代码而言,带引用计数的类的复杂度比不带的版本高。没有引用计数的String类只依赖于自己,而我们最终的String类如果没有三个辅助类(StringValue、RCObject和RCPtr)就无法使用。
总之,引用计数在下列情况下对提高效率很有用:
- 少量的值被大量的对象共享。这样的共享通常通过调用赋值操作和拷贝构造而发生。对象/值的比例越高,越是适宜使用引用计数。
- 对象的值的创建和销毁代价很高昂,或它们占用大量的内存。即使这样,如果不是多个对象共享相同的值,引用计数仍然帮不了你任何东西。
条款30:代理类(Proxy classes)
所谓代理类(proxy class),指的是它的每一个对象都是为了其他对象而存在的,就像是其他对象的代理人一般。某些情况下用代理类取代某些内置类型可以实现独特的功能,因为可以为代理类定义成员函数而但却无法对内置类型定义操作。
C++没有提供分配动态二维数组的语法,因此常常需要定义一些类(模板实现这些功能),像这样:
1 | template<class T> |
既然是二维数组,那么有必要提供使用”[][]”访问元素的操作,然而[][]并不是一个操作符,C++也就不允许重载一个operator[][],解决办法就是采用代理类,像这样:
1 | template<class T> |
那么以下操作:
1 | Array2D<float> data(10, 20); |
data[3][6]实际上进行了两次函数调用:第一次调用Array2D的operator[],返回Array1D对象,第二次调用Array1D的operator[],返回指定元素。
区分operator[]的读写动作
条款29用String类的例子讨论了引用计数,由于当时无法判断non-const版本oeprator[]返回的字符将被用于读操作还是写操作,因此保险起见,一旦调用non-const版本operator[],便开辟一块新内存并复制数据结构到新内存。在这种策略下,因此如果operator[]返回的字符被用于读操作,那么分配新内存并复制数据结构的行为其实是不必要的,由此会带来效率损失,使用proxy class便可以做到区分non-const operator[]用于读还是写操作,在 proxy 类上只能做三件事:
- 创建它,也就是指定它扮演哪个字符。
- 将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy 类扮演的是左值。
- 用其它方式使用它。这时,代理类扮演的是右值。
像这样:
1 | class String { |
对String调用operator[]将返回CharProxy对象,CharProxy通过重载oeprator char模拟char类型的行为,但它比char类型更有优势——可以为CharProxy定义新的操作,这样当对CharProxy使用operator=时,便可以得知对CharProxy进行写操作,由于CHarProxy保存了父对象String的一个引用,便可以在现在执行开辟内存并复制数据结构的行为,像这样:
1 | String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs) |
由于内存开辟和数据结构赋值任务交由CharProxy完成,String的operator[]相当简单,像这样:
1 | const String::CharProxy String::operator[](int index) const |
CharProxy实现的其他部分如下:
1 | String::CharProxy::CharProxy(String& str, int index): theString(str), charIndex(index) {} |
局限性.
就像智能指针永远无法完全取代内置指针一样,proxy class也永远无法模仿内置类型的所有特点.proxy class可以实现内置类型无法做到功能,但有利有弊——为了模仿内置类型的其他特点,它还要打许多”补丁”.
- 对proxy class取址.
- 条款29通过为StringValue类添加可共享标志(flag)来表示对象是否可被共享以防止外部指针的篡改,其中涉及到对operator[]返回值进行取址操作,这就提示CharProxy也需要对operator&进行重载,像这样:
1 | class String { |
const版本operator&实现比较容易:
1 | const char * String::CharProxy::operator&() const |
1 | char * String::CharProxy::operator&() |
- 将proxy class传递给接受”references to non-const objects”的函数.
假设有一个swap函数用于对象两个char的内容:
1 | void swap(char& a, char& b); |
那么将无法将CharProxy做参数传递给swap,因为swap的参数是char&,尽管CharProxy可以转换到char,但由于抓换后的char是临时对象,仍然无法绑定到char&,解决方法似乎只有对swap进行重载.
通过proxy cobjects调用真实对象的member function.
- 如果proxy class的作用是用来取代内置类型,那么它必须也应该对内置类型能够进行的操作进行重载,如++,+=等,如果它用来取代类类型,那么它也必须具有相同成员函数,使得对该类类型能够进行的操作同样也能够施行于proxy class.
隐式类型转换.
- proxy class要具有和被代理类型相同的行为,通常的做法是重载隐式转换操作符,正如条款5对proxy class的使用那样,proxy class可以利用”用户定制的隐式类型转换不能连续实行两次”的特点阻止不必要的隐式类型转换,proxy class同样可能因为这个特点而阻止用户需要的隐式类型转换.
proxy class的作用很强大,像上面所提到的实现多维数组,区分operator[]的读写操作,压抑隐式类型转换等,但是也有其缺点,如果函数返回proxy class对象,那么它生成一个临时对象,产生和销毁它就有可能带来额外的构造和析构成本,此外正如4所讲,proxy class无法完全代替真正对象的行为,尽管大多数情况下真正对象的操作都可由proxy class完成.
条款31:让函数根据一个以上的对象来决定怎么虚拟
问题来源:假设正在编写一个小游戏,游戏的背景是发生在太空,有宇宙飞船、太空船和小行星,它们可能会互相碰撞,而且其碰撞的规则不同,如何用C++代码处理物体间的碰撞。代码的框架如下:
1 | class GameObject{...}; |
正如上述代码所示,当调用processCollision()时,obj1和obj2的碰撞结果取决于obj1和obj2的真实类型,但我们只知道它们是GameObject对象。相当于我们需要一种作用在多个对象上的虚函数。这类型问题,在C++中被称为二重调度问题,下面介绍几种方法解决二重调度问题。
虚函数加RTTI
虚函数实现了一个单一调度,我们只需要实现另一调度。其具体实现方法:将processCollision()定义为虚函数,解决一重调度,然后只需要检测一个对象类型,利用RTTI来检测对象的类型,再利用if…else语句来调用不同的处理方法。具体实现如下:
1 | class GameObject{ |
该方法的实现简单,容易理解,其缺点是其扩展性不好。如果增加一个新的类时,我们必须更新每一个基于RTTI的if…else链以处理这个新的类型。
只使用虚函数
基本原理就是用两个单一调度实现二重调度,也就是有两个单独的虚函数调用:第一次决定第一个对象的动态类型,第二次决定第二个对象动态类型。其具体实现如下:
1 | class SpaceShip; |
与前面RTTI方法一样,该方法的缺点扩展性不好。每个类都必须知道它的同胞类,当增加新类时,所有的代码都必须更新。
模拟虚函数表
编译器通常创建一个函数指针数组(vtbl)来实现虚函数,并在虚函数被调用时在这个数组中进行下标索引。我们可以借鉴编译器虚拟函数表的方法,建立一个对象到碰撞函数指针的映射,然后在这个映射中利用对象进行查询,获取对应的碰撞函数指针,进行函数调用。具体代码实现如下:
1 | namespace{ |
如上述代码所示,使用非成员函数来处理碰撞过程,根据obj1和obj2来查询初始化之后映射表,来确定对应的非成员函数指针。利用模拟虚函数表的方法,基本上完成了基于多个对象的虚拟化功能。但是为了更方便的使用代码,更方便的维护代码,我们还需要进一步完善其实现过程。
将映射表和注册映射表过程封装起来
由于具体应用的过程,映射表的映射关系存在着增加和删除的操作,因而需要把映射表封装类体,提供增加,删除等接口。具体实现如下:
1 | class CollisionMap{ |
在应用中,我们必须确保在发生碰撞前将映射关系加入了映射表。一个方法是让GameObject的子类在构造函数中进行确认,这将导致在运行期的性能开销,另外一个方法创建一个RegisterCollisionFunction类,用于完成映射关系的注册工作。RegisterCollisionFunction相应的代码如下:
1 | class RegisterCollisionFunction{ |
条款32:在未来时态下开发程序
要在未来时态下开发程序,就必须接受事物会发生变化,并为此作了准备。这是应该考虑的:
- 新的函数将被加入到函数库中,新的重载将发生,于是要注意那些含糊的函数调用行为的结果;
- 新的类将会加入继承层次,现在的派生类将会是以后的基类,并已为此作好准备;
- 将会编制新的应用软件,函数将在新的运行环境下被调用,它们应该被写得在新平台上运行正确;
- 程序的维护人员通常不是原来编写它们的人,因此应该被设计得易于被别人理解、维护和扩充。
因为万物都会变化,要写能承受软件发展过程中的混乱攻击的类。应该判断一个函数的含意,以及它被派生类重定义的话是否有意义。如果是有意义的,申明它为虚,即使没有人立即重定义它。如果不是的话,申明它为非虚,并且不要在以后为了便于某人而更改;确保更改是对整个类的运行环境和类所表示的抽象是有意义的。
处理每个类的赋值和拷贝构造函数,即使“从没人这样做过”。他们现在没有这么做并不意味着他们以后不这么做。如果这些函数是难以实现的,那么申明它们为私有。这样,不会有人误调编译器提供的默认版本而做错事。
基于最小惊讶法则:努力提供这样的类,它们的操作和函数有自然的语法和直观的语义。和内建数据类型的行为保持一致:拿不定主意时,仿照int来做。
努力于可移植的代码。写可移植的代码并不比不可移植的代码难太多,只有在性能极其重要时采用不可移植的结构才是可取的。
将你的代码设计得当需要变化时,影响是局部的。尽可能地封装;将实现细节申明为私有。只要可能,使用无名的命名空间和文件内的静态对象或函数。避免导致虚基类的设计,因为这种类需要每个派生类都直接初始化它--即使是那些间接派生类。避免需要RTTI的设计,它需要if…then…else型的瀑布结构。
这是著名的老生常谈般的告戒,但大部分程序员仍然违背它。看这条一个著名C++专家提出忠告(很不幸,许多作者也这么说):
**你需要虚析构函数,只要有人delete一个实际值向D的B ***。这里,B是基类,D是其派生类。换句话说,这位作者暗示,如果你的程序看起来是这样时,并不需要B有虚析构函数:
1 | class B { ... }; // no virtual dtor needed |
然而,当你加入这么一句时,情况就变了:
1 | delete pb; // NOW you need the virtual |
这意味着,用户代码中的一个小变化--增加了一个delete语句--实际上能导致需要修改B的定义。如果这发生了的话,所有B的用户都必须重编译。采纳了这个作者的建议的话,一条语句的增加将导致大量代码的重编译和重链接。这绝不是一个高效的设计。
条款33:将非尾端类设计为抽象类
考虑下面的需求,软件处理动物,Cat与Dog需要特殊处理,因此,设计Cat和Dog继承Animal。Animal有copy赋值(不是虚方法),Cat和Dog也有copy赋值。考虑下面的情况:
1 | Cat cat1; |
思考*a1 = *a2会有什么问题? copy赋值不是虚方法,根据表面类型,调用Animal的copy赋值,这就导致所谓的部分赋值,cat2的Animal成分赋值给cat1的Animal成分,二者的Cat成分保持不变。
怎么解决上面的问题?将Animal的copy赋值声明为virtual方法,如下:
1 | virtual Animal& operator=(const Animal &rhs); |
Cat和Dog重写:
1 | virtual Cat& operator=(const Animal &rhs); |
这里使用了C++语言后期的一个特性,即协变,返回的引用更加具体。但是,对于形参表,重写必须保证保持一致。将copy赋值声明为virtual,解决了部分赋值的问题。但是,引入了一个新的问题。如下:
1 | Cat cat; |
这是异型赋值,左边是Cat,右边是Dog。C++是强类型语言,一般情况下,异型赋值不合法,不会造成问题。但是,这种情况下导致异型赋值合法。对于指针解引用的情况,我们期望同型赋值是合法的,异型赋值是非法的。容易想到的办法是,在重写的copy赋值中,使用dynamic_cast进行同型判断。比如Cat的copy赋值,首先判断rhs是不是Cat,如果是,就赋值,如果不是,抛出异常。
我们知道,使用dynamic_cast效率低,考虑下面的情况,cat1 = cat2; 即使cat1与cat2的表面类型就是Cat,也会调用Cat& operator=(const Animal &rhs)进行一次dynamic_cast的运算,这不是我们所期望的。解决办法是:增加一个过载方法,编译器编译时,根据表面类型确定方法的调用。如下:Cat& operator=(const Cat &rhs)。
同时对于重写的方法,可以调用前面的方法,如下:
1 | Cat& operator=(const Animal &rhs) |
运行期的类型检查,dynamic_cast的使用应该尽量避免。因为,首先效率低,其次,有些编译器还不支持dynamic_cast,不具有移植性。有没有更好的办法?导致问题的原因是,对于指针解引用的赋值,父类的copy赋值不是虚方法,导致部分赋值。
因此,解决办法是,提取一个抽象类AbstractAnimal,将copy赋值声明为protected,子类可以调用,表面类型是抽象类的指针解引用赋值,不能调用。增加一个Animal类,继承AbstractAnimal。
对于抽象类,内部至少要有一个纯虚方法,很自然地将析构方法声明为纯虚方法。对于纯虚方法,需要注意:
- 纯虚方法意味着当前类为抽象类,不能实例化。
- 纯虚方法要求子类必须重写。
- 特别注意,纯虚方法一般不提供实现,但是允许提供实现,子类也可以调用。如果析构方法为纯虚方法,必须要提供实现。因为子类调用自身的析构方法后,必定会去调用父类的析构方法。
考虑,具体基类没有字段,是不是就不需要上述的抽象类了?这有两个问题,首先现在没有字段,以后可能会有字段,其次如果一个类没有字段,一开始就应该是一个抽象类。
结论,对于继承体系中的非尾端类,应该设计为抽象类,如果使用外界的程序库,需要做一下变通。
条款34:如何在同一程序中混合使用 C++和 C
确保你的C++编译器和C编译器兼容,之后,还有四个要考虑的问题:名变换,静态初始化,内存动态分配,数据结构兼容。
名变换
名变换,就是C++编译器给程序的每个函数换一个独一无二的名字。重载不兼容于绝大部分链接程序,因为链接程序通常无法分辨同名的函数。名变换是对链接程序的妥协;链接程序通常坚持函数名必须独一无二。
如果你有一个函数叫drawline而编译器将它变换为xyzzy,你总使用名字drawLine,不会注意到背后的obj文件引用的是xyzzy的。但如果drawLine是一个C函数,obj文件中包含的编译后的drawLine函数仍然叫drawLine;没有名变换动作。当你试图将obj文件链接为程序时,将得到一个错误,因为链接程序在寻找一个叫xyzzy的函数,而没有这样的函数存在。
要解决这个问题,你需要一种方法来告诉C++编译器不要在这个函数上进行名变换。要禁止名变换,使用C++的extern 'C'指示:
1 | // declare a function called drawLine; don't mangle |
例如,如果不幸到必须要用汇编写一个函数,你也可以申明它为extern ‘C’:
1 | // this function is in assembler - don't mangle its name |
为每一个函数添加extern 'C'是痛苦的。extern ‘C’可以对一组函数生效,只要将它们放入一对大括号中:
1 | extern "C" { // disable name mangling for |
这样使用extern ‘C’简化了维护那些必须同时供C++和C使用的头文件的工作。当用C++编译时,你应该加extern ‘C’,但用C编译时,不应该这样。通过只在C++编译器下定义的宏__cplusplus,你可以将头文件组织得这样:
1 |
|
静态初始化
在main执行前和执行后都有大量代码被执行,静态的类对象和定义在全局的、命名空间中的或文件体中的类对象的构造函数通常在main被执行前就被调用。这个过程称为静态初始化。为了解决main()应该首先被调用,而对象又需要在main()执行前被构造的两难问题,许多编译器在main()的最开始处插入了一个特别的函数,由它来负责静态初始化。同样地,编译器在main()结束处插入了一个函数来析构静态对象。产生的代码通常看起来象这样:
1 | int main(int argc, char *argv[]) |
不要注重于这些名字。函数performStaticInitialization()和performStaticDestruction()通常是更含糊的名字,甚至是内联函数(这时在你的obj文件中将找不到这些函数)。要点是:如果一个C++编译器采用这种方法来初始化和析构静态对象,除非main()是用C++写的,这些对象将从没被初始化和析构。因为这种初始化和析构静态对象的方法是如此通用,只要程序的任意部分是C++写的,你就应该用C++写main()函数。
有时看起来用C写main()更有意义--比如程序的大部分是C的,C++部分只是一个支持库。然而,这个C++库很可能含有静态对象,所以用C++写main()仍然是个好主意。这并不意味着你需要重写你的C代码。只要将C写的main()改名为realMain(),然后用C++版本的main()调用realMain():
1 | extern "C" // implement this |
动态内存分配
通行规则很简单:C++部分使用new和delete,C部分使用malloc和free。唯一要记住的就是:将你的new和delete与mallco和free进行严格的隔离。
说比做容易。看一下这个粗糙(但很方便)的strdup函数,它并不在C和C++标准(运行库)中,却很常见:
1 | char * strdup(const char *ps); // return a copy of the |
要想没有内存泄漏,strdup的调用着必须释放在strdup()中分配的内存。但这内存这么释放?用delete?用free?如果你调用的strdup来自于C函数库中,那么是后者。如果它是用C++写的,那么恐怕是前者。在调用strdup后所需要做的操作,在不同的操作系统下不同,在不同的编译器下也不同。
数据结构的兼容性
没有可移植的方法来传递对象或传递指向成员函数的指针给C写的函数。想让你的C++和C编译器生产兼容的输出,两种语言间的函数可以安全地交换指向对象的指针和指向非成员的函数或静态成员函数的指针。自然地,结构和内建类型(如int、char等)的变量也可自由通过。
只有非虚函数的结构(或类)的对象兼容于它们在C中的孪生版本。增加虚函数将结束游戏,因为其对象将使用一个不同的内存结构。从其它结构(或类)进行继承的结构,通常也改变其内存结构,所以有基类的结构也不能与C函数交互。就数据结构而言,结论是:在C++和C之间这样相互传递数据结构是安全的--在C++和C下提供同样的定义来进行编译。在C++版本中增加非虚成员函数或许不影响兼容性,但几乎其它的改变都将影响兼容。
总结
如果想在同一程序下混合C++与C编程,记住下面的指导原则:
- 确保C++和C编译器产生兼容的obj文件。
- 将在两种语言下都使用的函数申明为extern ‘C’。
- 只要可能,用C++写main()。
- 总用delete释放new分配的内存;总用free释放malloc分配的内存。
- 将在两种语言间传递的东西限制在用C编译的数据结构的范围内;这些结构的C++版本可以包含非虚成员函数。
条款35:让自己习惯使用标准 C++语言
C++标准运行库的功能分为下列类别(参见Effective C++ Item 49):
- 支持标准C运行库。
- 支持string类型。
- 支持本地化。
- 支持I/O操作
- 支持数学运算。
- 支持通用容器和运算。
在介绍STL前,必须先知道标准C++运行库的两个特性。
- 第一,在运行库中的几乎任何东西都是模板。在本书中,我谈到过运行库中的string类,实际上没有这样的类。其实,有一个模板类叫basic_string来描述字符序列,它接受一个字符类型的参数来构造此序列,这使得它能表示char串、wide char串、Unicode char串等等。
- 我们通常认为的string类是从
basic_string<char>实例化而成的。用于它被用得如此广泛,标准运行库作了一个类型定义:
1 | typedef basic_string<char> string; |
这其实仍然隐藏了很多细节,因为basic_string模板带三个参数;除了第一个外都有默认参数。要全面理解string类型,必须面对这个未经删节的basic_string:
1 | template<class charT, |
另外需要知道的是:标准运行库将几乎所有内容都包含在命名空间std中。要想使用标准运行库里面的东西而无需特别指明运行库的名称,你可以使用using指示或使用(更方便的)using申明。幸运的是,这种重复工作在你#include恰当的头文件时自动进行。
标准模板库
STL基于三个基本概念:包容器(container)、选择子(iterator)和算法(algorithms)。包容器是被包容对象的封装;选择子是类指针的对象让你能如同使用指针操作内建类型的数组一样操作STL的包容器;算法是对包容器进行处理的函数,并使用选择子来实现的。
一个指向数组的指针可以正确地指出数组的任意元素或刚刚超出数组范围的那个元素。如果指向了那个超范围的元素,它将只能与其它指向此数组的指针进行地址比较;对其进行反引用,其结果为未定义。我们可以利用这条规则来实现在数组中查找一个特定值的函数。对一个整型数组,函数可能是这样的:
1 | int * find(int *begin, int *end, int value) |
这个函数在begin与end之间查找value,返回第一个值为value的元素;如果没有找到,它返回end。
返回end来表示没找到,看起来有些可笑。find()函数必须返回特别的指针值来表明查找失败,就此目的而言,end指针与NULL指针效果相同。但,如我们将要看到的,end指针在推广到其它包容器类型时比NULL指针好。
你可以这么使用find()函数:
1 | int values[50]; |
你也可以只搜索数组的一部分:
1 | int *firstFive = find(values, // search the range |
find()函数内部并没有限制它只能对int型数组操作,所以它可以实际上是一个模板:
1 | template<class T> |
在每次调用过程中,每个传值的参数都要有构造函数和析构函数的开销。通过传引用避免了这个开销
选择子就是被设计为操作STL的包容器的类指针对象。有了作为类指针对象的选择子的概念,我们可以用选择子代替find()中的指针。改写后的find()类似于:
1 | template<class Iterator, class T> |
STL中包含了很多使用包容器和选择子的算法,find()是其中之一。STL中的包容器有bitset、vector、list、deque、queue、priority-queue、stack、set和map,你可以在其中任一类型上使用find(),例如:
1 | list<char> charList; // create STL list object |
要对list对象调用find(),你必须提供一个指向list中的第一个元素的选择子和一个越过list中最后一个元素的选择子。如果list类不提供帮助,这将有些难,因为你无法知道list是怎么实现的。
当find()执行完成时,它返回一个选择子对象指向找到的元素或charList.end()。它提供了一个类型重定义,iterator就是list内部使用的选择子的类型。既然charList是一个包容char的list,它内部的选择子类型就是list<char>::iterator。同样的方法也完全适用于其它STL包容器。此外,C++指针也是STL选择子,所以,最初的数组的例子也能适用STL的find()函数:
1 | int values[50]; |