《Effective C++》笔记:IV
条款09:Never call virtual functions during construction or destruction。
译:绝不在构造和析构过程调用virtual函数
考虑如下代码:
class BaseClass { public: BaseClass() { vfun(); } ~BaseClass() { } virtual void vfun() const = 0; private: }; class DerivedClass : public BaseClass { public: DerivedClass() { } ~DerivedClass() { } virtual void vfun() const { printf("Derived vfun"); } private: }; int main() { DerivedClass a; return 0; }
实际上,这段代码无法通过编译,编译器会告诉你在BaseClass中调用一个没有定义的pure virtual函数(纯虚函数)是不合法的。
我们稍微改改
class BaseClass { public: BaseClass() { init(); } ~BaseClass() { } void init(); virtual void vfun() const = 0; private: }; class DerivedClass : public BaseClass { public: DerivedClass() { } ~DerivedClass() { } virtual void vfun() const { printf("Derived vfun"); } private: }; int main() { DerivedClass a; return 0; }
通过间接地调用虚函数,这回编译器不向你抱怨了,但程序是正确无误的吗?声明一个DerivedClass对象时,会发生什么?
事实上,在pure virtual被调用的同时,程序会被中止。如果BaseClass中的vfun非纯虚函数并带有实现代码,在构建DerivedClass对象时编译器会调用BaseClass中的vfun而非DerivedClass中的vfun,
也就是说,在构造和析构期间调用虚函数,虚函数失去了其本身的性质,这也是C++本身的初始化次序所决定的:基类的构造在派生类之前,派生类的构造函数调用之前,其成员变量都处于未定义状态。
解决方法:不管基于什么理由,需要在构造和析构函数中调用虚函数,这本身就是个错误的设计。所以,从根源上避免它,保证构造函数和析构函数中不出现虚函数才是正确的做法。
但是怎么确保BaseClass继承体系上的对象被创建,就有相应的vfun被调用呢?既然没办法在BaseClass中构造函数向下调用DerivedClass中的虚函数vfun,那么把vfun声明成非虚函数,在DerviedClass的构造函数中传递构造信息给BaseClass可以替换并加以弥补。(这句话可能有点难以理解,因本例没有代入书中的场景,具体可查阅书中给出的代码)
这让我想到设计模式中的一个原则:依赖倒置原则。即高层不应该依赖于低层,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
基类比作抽象,派生类比作细节,这样就很容易理解C++为什么要这样设计了。
在构造和析构期间不要调用virtual函数,因为这类调用无法下降至派生类。可以通过依赖倒置解决这类问题。
条款10:Have assignment operators return a reference to *this
译:令operator=返回一个reference to *this
令赋值(assignment)操作符返回一个reference to *this.
条款11:Handle assignment to self in operator=
译:在operator=中处理“自我赋值”
class BitMap { … }; class Widget { public: Widget() { } ~Widget() { } Widget& operator=(const Widget& rhs) { delete pb; pb = new BitMap(*rhs.pb); return *this; } private: BitMap *pb; }; int main() { Widget a; a = a; return 0; }
这段代码试图在赋值的时候删除pb所指内容,问题是当operator=的*this和rhs是同一对象的时候。delete pb不但销毁了当前对象的Bitmap,还销毁了rhs的bitmap,因此在函数的末尾,返回的是一个指向已经删除的Bitmap的指针,这样的指针无论放在那里都是有害的。
解决办法也很简单,在operator=函数内加一个“证同测试”,
Widget& operator=(const Widget& rhs) { If(this == rhs) return *this; delete pb; pb = new BitMap(*rhs.pb); return *this; }
新版本在传入的对象与当前对象为同一对象时什么也不做,直接返回当前对象。
这当然可以解决问题,但考虑到new Bitmap这一语句,其实会执行new 申请内存及Bitmap构造函数两个动作。而在new申请内存这一阶段,如果没有足够的内存或其它情况导致分配不成功,new Bitmap就会抛出一个bad_alloc异常,其结果就是赋值后pb指向一块已经被删除的Bitmap而不是预想中新的Bitmap对象。
其实只要注意不要删除原有pb,而是复制pb的一份副本,在完成所有操作之后,再删除原bitmap就可以解决问题,代码如下
Widget& operator=(const Widget& rhs) { Bitmap* pOrig = pb; pb = new BitMap(*rhs.pb); delete pOrig; return *this; }
现在new Bitmap如果不成功,pb仍保留原状(其实指向复制于自己的新的副本),同样的,也可以解决自我赋值的问题。
但为了解决这个异常安全问题,我们付出了复制一份新副本的代价,这在提高性能上看起来确实是毫无意义的,同样的,证同测试也需要一个判断分支的成本,但有时候我们更多的不是只有性能上的考虑。怎么衡量安全与性能之间的平衡,我们首先要弄清楚,这份代码发生异常的可能性多大及自我赋值的频率多高?然后再依据具体情况采取具体的应对方法。
最后,书中提到了在opeartor=函数内手工排列语句(确保异常安全及自我赋值安全)的替代方案,使用所谓的copy and swap技术,这个技术在条款29有详细介绍,这里就暂时不介绍了。
其实,我理想中的解决方案,是直接操作内存、交换两个对象的内存地址或者是编译器优化直接帮助我们完成这一步,而且在C++11标准中也有转移语义这一利器,实现这一点应该不难。
确保当对象自我赋值时operator=有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap.
确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确