15.11 析构函数和虚拟析构函数
构造函数是不能为虚函数的。但析构函数能够且常常必须是虚的。
构造函数有一项特殊工作,即一块一块地组合成一个对象。它首先调用基类构造函数,然后调用在继承顺序中的更晚派生的构造函数(同样,它也必须按此方法调用成员对象构造函数)。类似地,析构函数也有一项特殊工作,即它必须拆卸属于某层次类的对象。为了做这些工作,编译器生成代码来调用所有的析构函数,但它必须按照与构造函数调用相反的顺序。这就是,析构函数自最晚派生的类开始,并向上到基类。这是安全且合理的:当前的析构函数一直知道基类成员仍是有效的。如果需要在析构函数中调用某一基类的成员函数,进行这样的操作是安全的。因此,析构函数能够对其自身进行清除,然后它调用下一个析构函数,该析构函数又将执行它的清除工作,以此类推。每个析构函数知道它所在类从哪一个类派生而来,但不知道从它派生出哪些类。
应当记住,构造函数和析构函数是类层次进行调用的惟一地方(因此,编译器自动地生成适当的类层次)。在所有其他函数中,只有这个函数会被调用(非基类版本),而无论它是虚的还是非虚的。同一个函数的基类版本在普通函数中被调用(无论它是虚的还是非虚的)的惟一方法是显式地调用这个函数。
通常,析构函数的执行是相当充分的。但是,如果想通过指向某个对象基类的指针操纵这个对象(也就是,通过它的一般接口操纵这个对象),会发生什么现象呢?这在面向对象的程序设计中确实很重要。当我们想delete在栈中已经用new创建的对象的指针时,就会出现这个问题。如果这个指针是指向基类的,在delete期间,编译器只能知道调用这个析构函数的基类版本。这听起来很耳熟,虚函数被创建恰恰是为了解决同样的问题。幸运的是,就像除了构造函数以外的所有其他函数一样,析构函数可以是虚函数。
当运行这个程序时,将会看到delete bp只调用基类的析构函数。delete b2p调用了派生类的析构函数,然后调用了基类的析构函数。这正是我们所希望的。不把析构函数设为虚函数是一个隐匿的错误,因为它常常不会对程序有直接的影响。但要注意它不知不觉地引入存储器泄漏(关闭程序时内存未释放)。同样,这样的析构操作还有可能掩盖发生的问题。
即使析构函数像构造函数一样,是“例外”函数,但析构函数可以是虚的,这是因为这个对象已经知道它是什么类型(而在构造期间则不然)。一旦对象已被构造,它的VPTR就已被初始化,所以能发生虚函数调用。
15.11.1 纯虚析构函数
尽管纯虚析构函数在标准C++中是合法的,但在使用时有一个额外的限制:必须为纯虚析构函数提供一个函数体。这看起来有点违反常规;如果它需要一个函数体,那它又如何称之为“纯”?但如果我们记得构造函数和析构函数是具有特别意义的操作,特别是如果我们记得在一个类层次中总是会调用所有的析构函数,就会有所体会。如果我们不对一个纯虚析构函数进行定义,在析构期间将会调用什么函数体呢?因此,编译器和链接程序强迫纯虚析构函数一定要有一个函数体,这是十分必要的。
如果它是纯虚的,而且不得不有一个函数体,那么它的价值是什么呢?我们可以看到纯虚析构函数和非纯虚析构函数之间惟一的不同之处在于纯虚析构函数使得基类是抽象类,所以不能创建一个基类的对象(虽然如果基类的任何其他函数是纯虚函数,也是具有同样的效果)。
然而,当从某个含有虚析构函数的类中继承出一个类,情况变得有点复杂。不像其他的纯虚函数,我们不要求在派生类中提供纯虚函数的定义。下面的编译和链接便是证明。
一般来说,如果在派生类中基类的纯虚函数(和所有其他纯虚函数)没有重新定义,则派生类将会成为抽象类。但这里,看起来好像并不是这样。然而,如果不进行析构函数定义,编译器将会自动地为每个类生成一个析构函数定义。那就是这里所发生的—基类的析构函数被重写(重新定义),因此编译器会提供定义并且派生类实际上不会成为抽象类。
这会产生一个有趣的问题:纯虚析构函数的目的是什么?它不像普通的纯虚函数,我们必须提供一个函数体。在派生类中,由于编译器为我们生成了析构函数,所以我们并非一定要提供一个定义。那么,常规的析构函数和纯析构函数的差别是什么呢?
当我们的类仅含有一个纯虚函数时,就会发现这个惟一的差别:析构函数。在这一点上,析构函数的纯虚性的惟一效果是阻止基类的实例化。如果有其他的纯虚函数,则它们会阻止基类的实例化,但如果没有那些纯虚函数,则纯虚析构函数将会执行这项操作。所以,当虚析构函数是十分必要时,则它是不是纯虚的就不是那么重要了。
运行下面的程序,可以看到在派生类版本之后,随着任何其他的析构函数,调用了纯虚函数体。
作为一个准则,任何时候我们的类中都要有一个虚函数,我们应当立即增加一个虚析构函数(即使它什么也不做)。这样,我们保证在后面不会出现问题。