Item 7: 在多态基类中将析构函数声明为虚拟(virtual)
有很多方法可以跟踪时间的轨迹,所以有必要建立一个 TimeKeeper 基类,并为不同的计时方法建立派生类:
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... };
class WaterClock: public TimeKeeper { ... };
class WristWatch: public TimeKeeper { ... };
很多客户只是想简单地取得时间而不关心如何计算的细节,所以一个 factory 函数——返回一个指向新建派生类对象的基类指针的函数——被用来返回一个指向计时对象的指针:
TimeKeeper* getTimeKeeper(); // returns a pointer to a dynamic-
// ally allocated object of a class
// derived from TimeKeeper
按照 factory 函数的惯例,getTimeKeeper 返回的对象是建立在堆上的,所以为了避免泄漏内存和其他资源,最重要的就是要让每一个返回的对象都可以被完全删除。
TimeKeeper *ptk = getTimeKeeper(); // get dynamically allocated object
// from TimeKeeper hierarchy
... // use it
delete ptk; // release it to avoid resource leak
Item 13 解释了为什么依赖客户执行删除任务是一种错误的倾向,Item 18 解释了 factory 函数的接口应该如何改变以防止普通的客户错误,但这些都是次要的,因为在这个 Item 中,我们将精力集中于上面的代码中一个更基本的缺陷:即使客户做对了每一件事,也无法预知程序将如何运转。
问题在于 getTimeKeeper 返回一个指向派生类对象的指针(比如 AtomicClock),那个对象通过一个基类指针(也就是一个 TimeKeeper* 指针)被删除,而且这个基类(TimeKeeper)有一个非虚的析构函数。祸端就在这里,因为 C++ 指出:当一个派生类对象通过使用一个基类指针删除,而这个基类有一个非虚的析构函数,则结果是未定义的。运行时比较有代表性的后果是对象的派生部分不会被销毁。如果 getTimeKeeper 返回一个指向 AtomicClock 对象的指针,则对象的 AtomicClock 部分(也就是在 AtomicClock 类中声明的数据成员)很可能不会被销毁,AtomicClock 的析构函数也不会运行。然而,基类部分(也就是 TimeKeeper 部分)很可能已被销毁,这就导致了一个古怪的“部分析构”对象。这是一个泄漏资源,破坏数据结构以及消耗大量调试时间的绝妙方法。
排除这个问题非常简单:给基类一个虚析构函数。于是,删除一个派生类对象的时候就有了你所期望的正确行为。将销毁整个对象,包括全部的派生类部分:
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // now behaves correctly
类似 TimeKeeper 的基类一般都包含除了析构函数以外的其它虚函数,因为虚函数的目的就是允许派生类定制实现(参见 Item 34)。例如,TimeKeeper 可能有一个虚函数 getCurrentTime,在各种不同的派生类中有不同的实现。几乎所有拥有虚函数的类差不多都应该有虚析构函数。
如果一个类不包含虚函数,这经常预示不打算将它作为基类使用。当一个类不打算作为基类时,将析构函数声明为虚拟通常是个坏主意。考虑一个表现二维空间中的点的类:
class Point { // a 2D point
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 位架构中,它们将从 64 位(相当于两个 int)长到 96 位(两个 int 加上 vptr);在一个 64 位架构中,他们可能从 64 位长到 128 位,因为在这样的架构中指针的大小是 64 位的。为 Point 加上 vptr 将会使它的大小增长 50-100%!Point 对象不再适合 64 位寄存器。而且,Point 对象在 C++ 和其他语言(比如 C)中,看起来不再具有相同的结构,因为其它语言缺乏 vptr 的对应物。结果,Points 不再可能传入其它语言写成的函数或从其中传出,除非你为 vptr 做出明确的对应,而这是它自己的实现细节并因此失去可移植性。
这里的基准就是不加选择地将所有析构函数声明为虚拟,和从不把它们声明为虚拟一样是错误的。实际上,很多人总结过这条规则:当且仅当类中至少包含一个虚拟函数时,则声明一个虚析构函数。
但是,当完全没有虚函数时,就可能和非虚析构函数问题发生撕咬。例如,标准 string 类型不包含虚函数,但是被误导的程序员有时将它当作基类使用:
class SpecialString: public std::string { // bad idea! std::string has a
... // non-virtual destructor
};
一眼看上去,这可能无伤大雅,但是,如果在程序的某个地方因为某种原因,你将一个指向 SpecialString 的指针转型为一个指向 string 的指针,然后你将 delete 施加于这个 string 指针,你就立刻被送入未定义行为的领地。
SpecialString *pss = new SpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* => std::string*
...
delete ps; // undefined! In practice,
// *ps's SpecialString resources
// will be leaked, because the
// SpecialString destructor won't
// be called.
同样的分析可以适用于任何缺少虚析构函数的类,包括全部的 STL 容器类型(例如,vector,list,set,tr1::unordered_map(参见 Item 54)等)。如果你受到从标准容器类或任何其他带有非虚析构函数的类派生的诱惑,一定要挺住!(不幸的是,C++ 不提供类似 Java 的 final 类或 C# 的 sealed 类的防派生机制。)
偶尔地,给一个类提供一个纯虚析构函数能提供一些便利。回想一下,纯虚函数导致抽象类——不能被实例化的类(也就是说你不能创建这个类型的对象)。有时候,你有一个类,你希望它是抽象的,但没有任何纯虚函数。怎么办呢?因为一个抽象类注定要被用作基类,又因为一个基类应该有一个虚析构函数,又因为一个纯虚函数产生一个抽象类,好了,解决方案很简单:在你希望成为抽象类的类中声明一个纯虚析构函数。这是一个例子:
class AWOV { // AWOV = "Abstract w/o Virtuals"
public:
virtual ~AWOV() = 0; // declare pure virtual destructor
};
这个类有一个纯虚函数,所以它是抽象的,又因为它有一个虚析构函数,所以你不必担心析构函数问题。这是一个螺旋。你必须为纯虚析构函数提供一个定义:
AWOV::~AWOV() {} // definition of pure virtual dtor
析构函数的工作方式是:最底层的派生类(most derived class)的析构函数最先被调用,然后调用每一个基类的析构函数。编译器会产生一个从派生类的析构函数对 ~AWOV 的调用,所以你不得不确实为函数提供一个函数体。如果你不这样做,连接程序会提出抗议。
为基类提供虚析构函数的规则仅仅适用于多态基类——基类被设计用来允许派生类型通过基类的接口进行操作。TimeKeeper 就是一个多态基类,因为我们期望能操作 AtomicClock 和 WaterClock 对象,甚至当我们仅有指向他们的类型为 TimeKeeper 的指针的时候。
并非所有的基类都被设计用于多态。例如,无论是标准 string 类型,还是 STL 容器类型都被完全设计成基类,可没有哪个是多态的。一些类虽然被设计用于基类,但并非被设计用于多态。这样的类——例如 Item 6 中的 Uncopyable 和标准库中的 input_iterator_tag(参见 Item 47)——没有被设计成允许通过基类的接口操作派生类对象。所以它们就不需要虚析构函数。
Things to Remember
多态基类应该声明虚析构函数。如果一个类有任何虚函数,它就应该有一个虚析构函数。如果不是设计用于做基类或不是设计用于多态,这样的类就不应该声明虚析构函数。