1. 继承体系中关于对象释放遇到的问题描述
1.1 手动释放
关于时间记录有很多种方法,因此为不同的计时方法创建一个TimeKeeper基类和一些派生类就再合理不过了:
1 class TimeKeeper { 2 3 public: 4 5 TimeKeeper(); 6 7 ~TimeKeeper(); 8 9 ... 10 11 }; 12 13 class AtomicClock: public TimeKeeper { ... }; 14 15 class WaterClock: public TimeKeeper { ... }; 16 17 class WristWatch: public TimeKeeper { ... }; 18 19
许多客户端只想访问时间而不想知道关于时间计算的细节,所以可以创建一个工厂方法,这个工厂方法返回一个指向新创建的派生类对象的基类指针,这个指针用来指向一个计时对象:
1 TimeKeeper* getTimeKeeper(); // returns a pointer to a dynamic- 2 3 // ally allocated object of a class 4 5 // derived from TimeKeeper
为了和工厂方法的约定保持一致,getTimeKeeper返回一个堆上的对象,因此为了避免泄露内存和其他资源,每个返回的对象被合理的释放掉(deleted)是很重要的:
1 TimeKeeper *ptk = getTimeKeeper(); // get dynamically allocated object 2 3 // from TimeKeeper hierarchy 4 5 ... // use it 6 7 delete ptk; // release it to avoid resource leak
Item13中解释到依赖客户执行deletion比较容易出错,在Item18中解释了如何改变工厂函数的接口来预防一般的客户端错误,但是这些关注点在这里都是次要的,因为在这个条款中,我们为上面的代码提出一个更基本的弱点:即使客户端把一切都做对了,根本没有方法去知道程序如何运转。
1.2非虚析构函数引入的问题
问题在于getTimeKeeper返回一个指向派生类对象的指针(AtomicClock),这个对象通过一个基类指针(一个TimeKeeper*指针)来进行释放(delete),基类中(TimeKeeper)有一个非虚析构函数。这是造成灾难的一个因素,因为c++指出:通过一个基类的指针来释放一个派生类的对象,如果基类的析构函数是非虚的,那么结果未定义。在运行时有可能发生以下状况:对象的派生类部分永远不会被释放掉。如果对getTimeKeeper的调用恰巧返回一个指向AtomicClock对象的指针,对象的AtomicClock部分(也就是在AtomicClock类中声明的数据成员)可能不会被释放掉,AtomicClock类的析构函数也不会被执行。然而,基类部分(也就是TimeKeeper部分)是会被释放掉的,这会导致产生一个古怪的“部分被释放的”对象。这是使资源泄露,破坏数据结构和在debugger上花费大把时间的绝佳方法。
2.如何解决问题-声明虚析构函数
消除这个问题很简单:为基类提供一个虚析构函数。这时如果delete一个派生类对象将会做到你想要的。它会释放掉整个对象,包括派生类的所有部分:
1 class TimeKeeper { 2 3 public: 4 5 TimeKeeper(); 6 7 virtual ~TimeKeeper(); 8 9 ... 10 11 }; 12 13 TimeKeeper *ptk = getTimeKeeper(); 14 15 ... 16 17 delete ptk; // now behaves correctly
基类中(TimeKeeper)除了析构函数外一般情况下会包含虚函数,因为虚函数存在的目的是为了函数在派生类中的定制化实现(Item34)。举个例子,TimeKeeper会有一个虚函数,getCurrentTime,这个函数在不同的派生类中会有不同的实现。任何有虚函数的类应该肯定有一个虚析构函数。
3.不要在不当作基类的类中声明虚析构函数
如果类中不包含虚函数,这通常表明它不会被用作基类,如果并没有打算将一个类作为一个基类,将析构函数声明为虚是一个坏的想法。考虑一个表示二维空间的点的类:
1 class Point { // a 2D point 2 3 public: 4 5 Point(int xCoord, int yCoord); 6 7 ~Point(); 8 9 private: 10 11 int x, y; 12 13 };
如果int占用32Bits,那么一个Point对象可被放入一个64-bit的缓存器中。并且,这个Point对象可以以一个”64-bit quantity”传给用其他语言编写的函数,例如c语言和Fortran。如果将Point的析构函数声明成虚的,状况就会发生变化。
虚函数的实现需要对象带一些信息,根据这些信息在运行时能够决定对象的哪个虚函数会被触发。这些信息表现为一个被叫做vptr(virtual table pointer)指针的形式。我们把指向一个函数指针数组的vptr指针叫做vtbl(virtual table);每个有虚函数的类都有一个关联的vtbl.当虚函数在一个对象上被触发,实际调用的函数是由对象的vtbl中的vptr来决定的,在vtbl中会查找到合适的函数指针。
关于虚函数是如何实现的细节并不重要。重要的是如果Point类中包含一个虚函数,这个类型的对象会在占用空间上有所增加:在32位机器中,空间会从64bits(两个int)增加到96bits;在64位机器中,空间会从64bits增加到128bits,因为64位机器上的指针在空间上占用64bits.Point额外增加了一个vptr而致使内存空间增加50-100%。Point将不能在放进64bits的缓存中。并且,c++中的Point也不再同其他语言(如C语言)中声明的对象有类似的结构了,因为其他语言没有vptr,因此你不再能够向(从)其他语言编写的函数中传进(传出)指针了,除非你对vptr进行明确的补偿,这属于实现细节,代码因此也不能够被移植了。
因此,无缘无故的将所有析构函数声明成虚函数同永远不将其声明为虚函数犯了一样的错误。事实上,许多人将上面的情形其总结如下:在类中声明虚析构函数当且仅当类中至少包含一个虚函数。
4.不要继承析构函数为非虚的类
在虚函数完全缺席的情况下,非虚析构函数的问题同样会导致只释放部分内存的问题。举个例子,标准string类型不包含虚函数,但是一些被误导的程序员有时会将其当作基类:
1 class SpecialString: public std::string { // bad idea! std::string has a 2 3 ... // non-virtual destructor 4 5 };
乍一看这么实现也许无伤大雅,但是如果在一个应用中的某个地方,你以某种方式将指向SpecialString的指针转换成指向string的指针,然后你在string指针上使用delete,你马上会被转到未定义行为的领地:
1 SpecialString *pss =new SpecialString("Impending Doom"); 2 3 std::string *ps; 4 5 ... 6 7 ps = pss; // SpecialString* ⇒ std::string* 8 9 ... 10 11 delete ps; // undefined! In practice, 12 13 // *ps’s SpecialString resources 14 15 // will be leaked, because the 16 17 // SpecialString destructor won’t 18 19 // be called
同样的分析适用于任何缺少虚析构函数的类,包含所有的STL容器类型(例如 vector,list set,tr1::unordered_map(Item54))。如果你曾经受到诱惑,从一个标准容器类或其他没有虚析构函数的类中继承,你需要抵抗这种诱惑!(不幸的是,c++没有提供不能继承的机制,java中有final类,c#中有sealed类)。
5.纯虚析构函数
偶尔情况下为类提供一个纯虚析构函数是很方便的。有纯虚函数的类是一个抽象类,其不能够被实例化。然而有时候,你想将一个类变成一个抽象类,但是没有任何纯虚函数。该怎么办?因为一个抽象类将来会被用作基类,并且基类应该有一个虚析构函数,同时一个纯虚函数产生一个抽象类,所以解决方案很简单:在你想要其变成抽象的类中声明一个纯虚析构函数。看下面的例子:
1 class AWOV { // AWOV = “Abstract w/o Virtuals” 2 3 public: 4 5 virtual ~AWOV() = 0; // declare pure virtual destructor 6 7 };
这个类有一个纯虚函数,所以它是抽象类。因为它有一个虚析构函数,所以你不必担心因为析构函数出现的问题。这里有个窍门,你必须为纯虚函数提供一份定义:
1 AWOV::~AWOV() {} // definition of pure virtual dtor
析构函数工作的方法是最底部的派生类先被调用,然后析构函数的每一个基类会被依次调用。编译器会从派生类的析构函数中生成一个对~AWOV的调用,因此你必须确保为这个函数提供一个函数体。如果不提供会有链接错误。
6.其他一些需要注意的地方
为基类提供虚析构函数的法则只适用于多态基类,多态基类也就是将基类设计成允许通过基类接口来操作派生类型的类。TImeKeeper是一个多态基类,因为我们想能够操作AtomicClokc和WaterClock对象,在即使只有TimeKeeper指针指向这些派生类对象的情况下。
并不是所有的基类都被设计成能够使用多态。举个例子,标准string类型还有STL容器类型并没有被设计成基类,更不用说多态了。一些类被设计成当基类使用,但是没有被设计成使用多态。举个例子,Item6中的UnCopyable和来自标准库中的input_iterator_tag(Item47),这样的类没有被设计成通过基类接口操作派生类。因此,也不需要虚析构函数。