现在考虑一个计时器的问题,我们首先创建一个名为TimeKeeper的基类,然后在它的基础上创建各种派生类,从而用不同手段来计时。由于计时有很多方式,所以这样做是值得的:
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... }; // 原子钟
class WaterClock: public TimeKeeper { ... }; // 水钟
class WristWatch: public TimeKeeper { ... }; // 腕表
许多客户希望在访问时间时不用关心计算的细节,所以在此可以使用一个工厂函数来返回一个指向计时器对象的指针,工厂函数会返回一个基类指针,这个指针将指向一个新创建的派生类对象:
TimeKeeper* getTimeKeeper(); // 返回一个继承自TimeKeeper的动态分配的对象
为了不破坏工厂函数的惯例,getTimeKeeper返回的对象将被放置在堆上,所以必须要在适当的时候删除每一个返回的对象,从而避免内存或者其他资源发生泄漏:
TimeKeeper *ptk = getTimeKeeper(); // 从TimeKeeper层取得
// 一个动态分配的对象
... // 使用这个对象
delete ptk; // 释放它,以防资源泄漏
把释放工作推卸给客户将会带来出错的隐患,条目13中解释了这一点。关于如何修改工厂函数的接口从而防止一般的客户端错误发生,请参见条目18。但是这些议题在此都不是主要的,这一条目中我们主要讨论的是上文中的代码存在着的一个更为基本的弱点:即使客户把每一件事都做得很完美,我们仍无法预知程序会产生怎样的行为。
现在的问题是:getTimeKeeper返回一个指向某个派生类对象的指针(比如说AtomicClock),这个对象最终会通过一个基类指针得到删除(比如说TimeKeeper*指针),而基类(TimeKeeper)有一个非虚析构函数。这里埋藏着灾难,这是因为C++有明确的规则:如果希望通过一个指向基类的指针来删除一个派生类对象,并且这一基类有一个非虚析构函数,结果将是未定义的。典型的后果就是,在运行时,派生类中新派生出的部分得不到销毁。如果getTimeKeeper返回了一个指向AtomicClock对象的指针,那么这一对象中AtomicClock的部分(也就是AtomicClock类中新声明的数据成员)有可能不会被销毁掉,AtomicClock的析构函数也可能不会得到运行。然而,这一对象中基类那一部分(也就是TimeKeeper的部分)很自然的会被销毁掉,这样便会产生一个古怪的“部分销毁”的对象。用这种方法来泄漏资源、破坏数据结构、浪费调试时间,实在是“再好不过”了。
排除这一问题的方法很简单:为基类提供一个虚拟的析构函数。这时删除一个派生类对象,程序就会精确地按你的需要运行了。整个对象都会得到销毁,包括所有新派生的部分:
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // 现在,程序正常运转
通常情况下,像TimeKeeper这样的基类会包含除析构函数以外的虚函数,这是因为虚函数的目的是允许派生类实现中对它们进行自定义(参见条目34)。比如说,TimeKeeper类中可能存在一个虚函数getCurrentTime,它在不同的派生类中将有不同的实现方式。任何有虚函数的类几乎一定都要包含一个虚析构函数。
如果一个类不包含虚函数,通常情况下意味着它将不作为基类使用。当一个类不作为基类时,将它的析构函数其声明为虚拟的通常情况下不是个好主意。请看下面的示例,这个类代表二维空间中的点:
class Point { // 2D的点
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
在一般情况下,如果一个int占用32比特,一个Point对象便可以置入一个64位的寄存器中。而且,这样的一个Point对象可以以一个64位数值的形式传给其他语言编写的函数,比如C或者FORTRAN。然而如果Point的析构函数是虚拟的,那么就是另一种情况了。
虚函数的实现需要它所在的对象包含额外的信息,这一信息用来在运行时确定本对象需要调用哪个虚函数。通常,这一信息采取一个指针的形式,这个指针被称为“vptr”(“虚函数表指针”,virtual table pointer)。vptr指向一个包含函数指针的数组,这一数组称为“vtbl”(“虚函数表”,virtual table),每个包含虚函数的类都有一个与之相关的vtbl。当一个虚函数被一个对象调用时,就用到了该对象的vptr所指向的vtbl,在vtbl中查找一个合适的函数指针,然后调用相应的实函数。
虚函数实现的细节并不重要。重要的仅仅是,如果Point类包含一个虚函数,这一类型的对象将会变大。在一个32位的架构中,Point对象将会由64位(两个int大小)增长至96位(两个int加一个vptr);在64位架构中,Point对象将由64位增长至128位。这是因为指向64位架构的指针有64位大小。可以看到,为Point添加一个vptr将会使对象增大50-100%!这样,一个64位的寄存器便容不下一个Point对象了。而且,此时C++版本的Point对象便不再与其它语言(比如C语言)有同样的结构,这是因为其它语言很可能没有vptr的概念。于是,除非你显式增补一个vptr的等价物(但这是这种语言的实现细节,而且不具备可移植性),否则Point对象便无法与其它语言编写的函数互通。
不得不承认,无故将所有的析构函数声明为虚拟的,与从不将它们声明为虚函数一样糟糕,这一点至关重要。实际上,许多人总结出一条解决途径:当且仅当类中至少包含一个虚函数时,要声明一个虚析构函数。
甚至在完全没有虚函数的类里,你也可能会被非虚拟的构造函数所纠缠。比如说,虽然标准的string类型不包含虚函数,但是误入歧途的程序员有些时候还是会将其作为基类:
class SpecialString: public std::string {
// 这不是个好主意!
// std::string 有一个非虚拟的析构函数
...
};
乍一看,这样的代码似乎没什么问题,但是如果在某应用程序里,你不知出于什么原因希望将一个SpecialString指针转型为string指针,同时你又对这个string指针使用了delete,你的程序会立刻陷入未定义行为:
SpecialString *pss = new SpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* ⇒ std::string*
...
delete ps; // 未定义行为!在实践中*ps的SpecialString
// 部分资源将会泄漏,这是因为SpecialString
// 的析构函数没有被调用。
对于任意没有虚析构函数的类而言,上面的分析都成立,包括所有的STL容器类型(比如vector、list、set、tr1::unordered_map(参见条目54),等等)。如果你曾经继承过一个标准容器或者其他任何包含非虚析构函数的类,一定要打消这种想法!(遗憾的是,C++没有提供类似Java中的final类或C#中的sealed类那种防止继承的机制)
在个别情况下,为一个类提供一个纯虚析构函数是十分方便的。你可以回忆一下,纯虚函数会使其所在的类成为抽象类——这种类不可以实例化(也就是说,你无法创建这种类型的对象)。然而某些时刻,你希望一个类成为一个抽象类,但是你有没有任何纯虚函数,这时候要怎么办呢?因为抽象类应该作为基类来使用,而基类应该有虚析构函数,又因为纯虚函数可以造就一个抽象类,那么解决方案就显而易见了:如果你希望一个类成为一个抽象类,那么在其中声明一个纯虚析构函数。下边是示例:
class AWOV { // AWOV = "Abstract w/o Virtuals"
public:
virtual ~AWOV() = 0; // 声明纯虚析构函数
};
这个类有一个纯虚函数,所以它是一个抽象类,同时它拥有一个虚析构函数,所以你不需要担心析构函数的问题。然而这里还是有一个别扭的地方:你必须为纯虚析构函数提供一个定义:
AWOV::~AWOV() {} // 纯虚析构函数的定义
析构函数的工作方式是这样的:首先调用最后派生出的类的析构函数,然后依次调用上一层基类的析构函数。由于当调用一个AWOV的派生类的析构函数时,编译器会自动调用~AWOV,因此你必须为~AWOV提供一个函数体。否则连接器将会报错。
为基类提供虚析构函数的原则仅对多态基类(这种基类允许通过其接口来操控派生类的类型)有效。我们说TimeKeeper是一个多态基类,这是由于即使我们手头只有TimeKeeper指向它们的指针,我们仍可以对AtomicClock和WaterClock进行操控。
并不是所有的基类都要具有多态性。比如说,标准string类型、STL容器都不用作基类,因此它们都不具备多态性。另外有一些类是设计用作基类的,但是它们并未被设计成多态类。这些类(例如条目6中的Uncopyable和标准类中的input_iterator_tag(参见条目47))不允许通过其接口来操控它的派生类。因此,它们并不需要虚析构函数。
时刻牢记
l 应该为多态基类声明虚析构函数。一旦一个类包含虚函数,它就应该包含一个虚析构函数。
l 如果一个类不用作基类或者不需具有多态性,便不应该为它声明虚析构函数。