第7条:
要将多态基类的析构函数声明为虚函数
现在考虑一个计时器的问题,我们首先创建一个名为
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
”(“虚函数表指针”)。
vptr
指向一个包含函数指针的数组,这一数组称为“
vtbl
”(“虚函数表”),每个包含虚函数的类都有一个与之相关的
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
如果一个类不用作基类或者不需具有多态性,便不应该为它声明虚析构器。