参考来源:《More Effecitve C++》[美] Scott Meyers 著
Part One: Fundamental Agenda
条款(一):指针与引用的区别
条款(二):尽量使用 C++ 风格的类型转换
条款(三):不要对数组使用多态
条款(四):避免无用的缺省构造函数
Part Two: Assignment Operators
条款(五):谨慎定义类型转换函数
条款(六):自增、自减操作符前缀形式与后缀形式的区别
条款(七):不要重载 “&&”、“||” 和 “,”
条款(八):理解各种不同含义的 new 和 delete
Part Three: Exception
条款(九):使用析构函数防止资源泄漏
假设正在为一个小动物收容所编写软件,小动物收容所是一个帮助小狗小猫寻找主人的组织。每天收容所建立一个文件,包含当天它所管理的收容动物的资料信息,工作是写一个程序读出这些文件然后对每个收容动物进行适当的处理。完成这个程序一个合理的方法是定义一个抽象类,ALA(”Adorable Little Animal”),然后为小狗和小猫建立派生类:
class ALA { public: virtual void processAdoption() = 0; }; class Puppy: public ALA { public: virtual void processAdoption(); }; class Kitten: public ALA { public: virtual void processAdoption(); };
需要一个函数从文件中读去信息,然后根据文件中的信息产生一个 puppy 对象或者 kitten 对象:
ALA* readALA(istream& s);
程序的关键部分是这个函数:
void processAdoptions(istream& dataSource) { while (dataSource) { ALA *pa = readALA(dataSource); // 得到下一个动物 pa->processAdoption(); // 处理收容动物 delete pa; // 删除readALA返回的对象 } }
如果 pa->processAdoption() 抛出了一个异常,将会发生什么?processAdoptions 没有捕获异常,所以异常将传递给 processAdoptions 的调用者。传递中,processAdoptions 函数中的调用 pa->processAdoption() 语句后的所有语句都被跳过,这就是说 pa 没有被删除。
使用 try {} catch {} 语句堵塞泄漏很容易:
void processAdoptions(istream& dataSource) { while (dataSource) { ALA *pa = readALA(dataSource); try { pa->processAdoption(); } catch (...) { delete pa; throw; } delete pa; } }
此时必须写双份清除代码,一个为正常的运行准备,一个为异常发生时准备,象其它重复代码一样,这种代码写起来令人心烦又难于维护。
使用智能指针 auto_ptr 对象代替 raw 指针,你将不再为堆对象不能被删除而担心,即使在抛出异常时,对象也能被及时删除:
void processAdoptions(istream& dataSource) { while (dataSource) { auto_ptr<ALA> pa(readALA(dataSource)); pa->processAdoption(); } }
隐藏在 auto_ptr 后的思想是:用一个对象存储需要被自动释放的资源,然后依靠对象的析构函数来释放资源,这种思想不只是可以运用在指针上,还能用在其它资源的分配和释放上。
条款(十):在构造函数中防止资源泄漏
如果正在开发一个具有多媒体功能的通讯录程序。这个通讯录除了能存储通常的文字信息如姓名、地址外,还能存储照片和声音:
class Image { public: Image(const string& imageDataFileName); }; class AudioClip { public: AudioClip(const string& audioDataFileName); }; class BookEntry { public: BookEntry(const string& name, const string& address = "", const string& imageFileName = "", const string& audioClipFileName = "") : theName(name), theAddress(address), theImage(0), theAudioClip(0) { if (imageFileName != "") theImage = new Image(imageFileName); if (audioClipFileName != "") theAudioClip = new AudioClip(audioClipFileName); } ~BookEntry() { delete theImage; delete theAudioClip; } void addPhoneNumber(const PhoneNumber& number); private: string theName; // 姓名 string theAddress; // 地址 Image *theImage; // 图像 AudioClip *theAudioClip; // 声音片段 };
构造函数把指针 theImage 和 theAudioClip 初始化为空,然后如果其对应的构造函数参数不是空,就让这些指针指向真实的对象。析构函数负责删除这些指针,确保 BookEntry 对象不会发生资源泄漏。因为 C++ 确保删除空指针是安全的,所以 BookEntry 的析构函数在删除指针前不需要检测这些指针是否指向了某些对象。
如果 BookEntry 的构造函数正在执行中,建立 theAudioClip 对象时一个异常被抛出,会发生什么情况呢?这个异常将传递到建立 BookEntry 对象的地方。那么此时谁来负责删除 theImage 已经指向的对象呢?答案显然应该是由BookEntry来做,但是这个想当然的答案是错的。~BookEntry() 根本不会被调用,永远不会,因为 C++ 仅仅能删除被完全构造的对象(fully contructed objects),只有一个对象的构造函数完全运行完毕,这个对象才被完全地构造。
因为当对象在构造中抛出异常后 C++ 不负责清除对象,所以必须重新设计构造函数以让它们自己清除。经常用的方法是捕获所有的异常,然后执行一些清除代码,最后再重新抛出异常让它继续转递:
BookEntry::BookEntry(...) : ... { try { if (imageFileName != "") theImage = new Image(imageFileName); if (audioClipFileName != "") theAudioClip = new AudioClip(audioClipFileName); } catch (...) { delete theImage; delete theAudioClip; throw; // 继续传递异常 } }
假如 theImage 和 theAudioClip 是 const 指针类型,必须通过 BookEntry 构造函数的成员初始化列表来初始化这样的指针,因为再也没有其它地方可以给 const 指针赋值:
BookEntry::BookEntry(...) : theName(name), theAddress(address), theImage(imageFileName != "" ? new Image(imageFileName) : 0), theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName) : 0) {}
这样做导致我们原先一直想避免的问题重新出现,把 theImage 和 theAudioClip 指向的对象做为一个资源,交由 auto_ptr 管理:
class BookEntry { public: BookEntry(...) : ... { ... } // 构造函数同上成员初始化列表形式 ~BookEntry() {} private: const auto_ptr<Image> theImage; const auto_ptr<AudioClip> theAudioClip; };
综上所述,如果用对应的 auto_ptr 对象替代指针成员变量,就可以防止构造函数在存在异常时发生资源泄漏,也不用手工在析构函数中释放资源,并且还能象使用非 const 指针一样使用 const 指针,给其赋值。
条款(十一):禁止异常信息传递到析构函数外
有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。第二种是异常传递的 stack-unwinding 过程中,由异常处理系统删除一个对象。
在上述两种情况下,调用析构函数时异常可能处于激活状态也可能没有处于激活状态。遗憾的是没有办法在析构函数内部区分出这两种情况。因此在写析构函数时必须保守地假设有异常被激活。因为如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++ 将调用 terminate 函数,它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放。
一个 Session 类用来跟踪在线计算机的 sessions,每个Session 对象关注的是它建立与释放的日期和时间:
class Session { public: Session(); ~Session() { logDestruction(this); } private: static void logCreation(Session *objAddr); static void logDestruction(Session *objAddr); };
析构函数中如果 logDestruction 抛出一个异常,会发生什么事呢?异常没有被 Session 的析构函数捕获住,所以它被传递到析构函数的调用者那里。但是如果析构函数本身的调用就是源自于某些其它异常的抛出,那么 terminate 函数将被自动调用,彻底终止你的程序。在释放 Session 时必须忽略掉所有它抛出的异常:
Session::~Session() { try { logDestruction(this); } catch (...) { // do nothing } }
catch 表面上好像没有做任何事情,这是一个假象,实际上它阻止了任何从 logDestruction 抛出的异常被传递到 session 析构函数的外面。
不允许异常传递到析构函数外面还有第二个原因。如果一个异常被析构函数抛出而没有在函数内部捕获住,那么析构函数就不会完全运行(它会停在抛出异常的那个地方上),也就无法完成希望它做的所有事情。
例如,对 Session 类做一个修改,在建立 Session 时启动一个数据库事务(database transaction),终止 Session 时结束这个事务:
Session::Session() { logCreation(this); startTransaction(); // start database transaction } Session::~Session() { logDestruction(this); endTransaction(); // end database transaction }
如果在这里 logDestruction 抛出一个异常,在 Session 构造函数内启动的 transaction 就没有被终止。也许能够通过重新调整 Session 析构函数内的函数调用顺序来消除问题,但是如果 endTransaction 也抛出一个异常,除了回到使用 try 和 catch 外,别无选择。
条款(十二):理解 “抛出一个异常” 与 “传递一个参数” 或 “调用一个虚函数” 间的差异
先从相同点谈起。你传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。但是当你传递参数和异常时,系统所要完成的操作过程则是完全不同的。产生这个差异的原因是:调用函数时,程序的控制权最终还会返回到函数的调用处,但是当抛出一个异常时,控制权永远不会回到抛出异常的地方。
istream operator>>(istream& s, Widget& w); void passAndThrowWidget() { Widget localWidget; cin >> localWidget; //传递localWidget到operator>> throw localWidget; // 抛出localWidget异常 }
当传递 localWidget 到函数 operator>> 里,不用进行拷贝操作,而是把 operator>> 内的引用类型变量 w 指向localWidget,而抛出 localWidget 异常有很大不同,不论通过传值捕获异常还是通过引用捕获都将进行 localWidget 的拷贝操作。必须这么做,因为当 localWidget 离开了生存空间后,其析构函数将被调用。如果把 localWidget 本身(而不是它的拷贝)传递给 catch 子句,这个子句接收到的只是一个被析构了的Widget,这是无法使用的。因此 C++ 规范要求被做为异常抛出的对象必须被复制。
即使被抛出的对象不会被释放,即声明 localWidget为静态变量,也会进行拷贝操作。对异常对象进行强制拷贝操作表明:抛出异常运行速度比参数传递要慢。
当异常对象被拷贝时,拷贝操作是由对象的静态类型(static type)所对应类的拷贝构造函数完成的,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数:
class Widget { ... }; class SpecialWidget : public Widget { ... }; void passAndThrowWidget() { SpecialWidget localSpecialWidget; Widget& rw = localSpecialWidget; throw rw; };
这里抛出的异常对象是 Widget,即使 rw 引用的是一个 SpecialWidget。因为 rw 的静态类型是 Widget,而不是 SpecialWidget。
catch (Widget w) { ... } catch (Widget& w) { ... } catch (const Widget& w) { ... }
当通过传值捕获方式声明 catch 语句的时候,会建立两个被抛出对象的拷贝,一个是所有异常都必须建立的临时对象,第二个是把临时对象拷贝进 w 中;通过引用捕获异常时,仍旧会建立一个被抛出对象的拷贝,但比通过传值捕获方式少一个。
对象从函数的调用处传递到函数参数里与从异常抛出点传递到 catch 语句里所采用的方法不同,这只是参数传递与异常传递的区别的一个方面;第二个差异是在函数调用者或抛出异常者与被调用者或异常捕获者之间的类型匹配的过程不同。一般来说,catch 语句匹配异常类型时不会进行隐式类型转换,不过用来捕获基类的 catch 语句也可以处理派生类类型的异常,这种派生类与基类间的异常类型转换可以作用于数值、引用以及指针上;此外允许从一个类型化指针(typed pointer)转变成无类型指针(untyped pointer),所以带有const void* 指针的catch子句能捕获任何类型的指针类型异常。
传递参数和传递异常间最后一点差别是 catch 语句匹配顺序总是取决于它们在程序中出现的顺序,而当一个对象调用一个虚函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。
条款(十三):通过引用捕获异常
首先讨论通过指针方式捕获异常,在理论上这种方法的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象。
void someFunction() { static exception ex; throw &ex; } void doSomething() { try { someFunction(); } catch (exception *ex) { ... } }
为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存。全局与静态对象都能够做到这一点,但是程序员很容易忘记这个约束,如果去除上述的关键字 static,情况就糟透了,因为处理这个异常的catch子句接受到的指针,其指向的对象已经不再存在。若是抛出一个在堆中建立的对象,catch 语句内又面临一个令人头疼的问题:他们是否应该删除他们接受的指针?如果是在堆中建立的异常对象,那必须删除它,否则会造成资源泄漏。如果不是在堆中建立的异常对象,绝对不能删除它,否则程序的行为将不可预测,然而这是无法预知的。
通过指针捕获异常不符合C++语言本身的规范,四个标准的异常:bad_alloc(当operator new 不能分配足够的内存时被抛出)、bad_cast(当 dynamic_cast 针对一个引用操作失败时被抛出)、bad_typeid(当 dynamic_cast 对空指针进行操作时被抛出)和 bad_exception(用于unexpected异常)都不是指向对象的指针,所以必须通过值或引用来捕获它们。
通过值捕获异常则不仅导致抛出时系统将对异常对象拷贝两次,而且会产生 slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。
通过引用捕获异常(catch by reference),能避开上述所有问题:不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。
条款(十四):审慎使用异常规格(exception specifications)
如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数 unexpected 将被自动地调用。函数 unexpected 缺省的行为是调用函数 terminate,而 terminate 缺省的行为是调用函数 abort,所以一个违反异常规格的程序其缺省的行为就是 halt(停止运行)。栈中的局部变量没有被释放,因为 abort 在关闭程序时不进行这样的清除操作。
编译器仅仅部分地检测异常的使用是否与异常规格保持一致。一个函数调用了另一个函数,并且后者可能抛出一个违反前者异常规格的异常,编译器不对此种情况进行检测,并且语言标准也禁止编译器拒绝这种调用方式。
避免调用 unexpected 的三个方法:
- 模板和异常规格不要混合使用。
- 如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。
- 处理系统本身抛出的异常。
有时直接处理 unexpected 异常比防止它们被抛出要简单。例如正在编写一个软件,精确地使用了异常规格,但是必须从没有使用异常规格的程序库中调用函数,C++ 允许用其它不同的异常类型替换 unexpected 异常:
class UnexpectedException {}; void convertUnexpected() { throw UnexpectedException(); } set_unexpected(convertUnexpected);
通过用 convertUnexpected 函数替换缺省的 unexpected 函数,一个unexpected异常将触发调用 convertUnexpected 函数。另一种把 unexpected 异常转变成知名类型的方法是替换 unexpected 函数,让其重新抛出当前异常,这样异常将被替换为 bad_exception:
void convertUnexpected() { throw; }
如果这么做,应该在所有的异常规格里包含 bad_exception(或它的基类 exception),如此将不必再担心如果遇到 unexpected 异常会导致程序运行终止。
条款(十五):了解异常处理的系统开销
先从不使用任何异常处理特性也要付出的代价谈起,需要空间建立数据结构来跟踪对象是否被完全构造,也需要 CPU 时间保持这些数据结构不断更新。这些开销一般不是很大,但是采用不支持异常的方法编译的程序一般比支持异常的程序运行速度更快所占空间也更小。
使用异常处理的第二个开销来自于 try 块,粗略地估计,如果使用 try 块,代码的尺寸将增加 5%-10% 并且运行速度也同比例减慢。
问题的核心部分在于抛出异常的开销,因为异常是罕见的,80-20规则告诉我们这样的事件不会对整个程序的性能造成太大的影响,但与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。
为了使的异常开销最小化,只要可能就尽量采用不支持异常的方法编译程序,把使用 try 块和异常规格限制在确实需要它们的地方,并且只有在确为异常的情况下才抛出异常。
Part Four: Efficiency
条款(十六):牢记 80-20 准则
80-20 准则说的是大约 20% 的代码使用了 80% 的程序资源;大约 20% 的代码耗用了大约 80% 的运行时间;大约 20% 的代码使用了 80% 的内存;大约 20% 的代码执行 80% 的磁盘访问;80%的维护投入于大约 20% 的代码上。
正确的方法是用 profiler 程序识别出令人讨厌的程序的 20% 部分。
条款(十七):考虑使用 lazy evaluation
条款(十八):
条款(十九):
条款(二十):
条款(二十一):
条款(二十二):
条款(二十三):
条款(二十四):
Part Five: Techniques
条款(二十五):
条款(二十六):
条款(二十七):
条款(二十八):
条款(二十九):
条款(三十):
条款(三十一):
Part Six: Miscellany
条款(三十二):
条款(三十三):
条款(三十四):
条款(三十五):