条款14 基类的析构函数一定要定义为虚拟函数(From Effective C++)

类别:VC语言 点击:0 评论:0 推荐:

    有时一个类需要知道当前有多少个该类的对象,达到这个目的最直接的方式是定义一个用于统计对象个数的静态成员变量。该变量被初始化为0,调用类构造函数时增加1,调用析构函数时减少1。

假设你正在编写一个军用程序,其中一个表示敌军目标的类定义如下:

class EnemyTarget {

public:

  EnemyTarget() { ++numTargets; }

  EnemyTarget(const EnemyTarget&) { ++numTargets; }

  ~EnemyTarget() { --numTargets; }

  static size_t numberOfTargets()

  { return numTargets; }

  virtual bool destroy();    //当摧毁敌军目标成功时返回true

private:

  static size_t numTargets;   //对象数量

};

//类静态成员变量必须在类外定义;

//缺省将其初始化为0

size_t EnemyTarget::numTargets;

当然,这个类的功能远远达不到国防部的要求,所以不可能为你赢得政府国防合同,但在这里已经足够我们说明问题的了。

假定在你的模拟中有一种特殊的敌军目标是敌军坦克,而敌军坦克(EnemyTank)是从EnemyTarget公有继承而来。由于你不仅要知道所有敌军目标的数量,而且对敌军坦克的数量也感兴趣,所以你在派生类中使用与基类相同的技巧:

class EnemyTank: public EnemyTarget {

public:

  EnemyTank() { ++numTanks; }

  EnemyTank(const EnemyTank& rhs)

  : EnemyTarget(rhs)

  { ++numTanks; }

  ~EnemyTank() { --numTanks; }

  static size_t numberOfTanks()

  { return numTanks; }

  virtual bool destroy();

private:

  static size_t numTanks;         // 坦克的数量

};

现在,在添加两个不同类的代码之后,你已经理解了条款M26(条款M26 限制一个类的对象个数——译注)所介绍的这类问题的常规解决方案,并可能已经向正确的方向迈进了一步。

最后,假定在程序的某个地方,你使用new动态的创建了一个EnemyTank对象,然后你用delete删除它:

EnemyTarget *targetPtr = new EnemyTank;

...

delete targetPtr;

到目前为止,好像一切正常。不仅两个类的析构函数分别做了与其构造函数一致的“撤销”操作,而且在你的程序中一定没有任何错误——因为你将用new创建的对象很小心地delete掉了。但是,这里隐藏着非常烦人的问题:该程序的行为是未定义的(undefined)——你不知道可能发生什么事情。

在这一点上,C++语言标准的叙述异乎寻常的清楚:当你试图使用基类的指针删除派生类的对象时,如果基类没有将析构函数定义为虚拟函数(就象EnemyTarget一样),那么结果是未定义的。这就是说编译器可以随心所欲生成代码,格式化你的磁盘,向你的老板发密信,将源代码传真给你的竞争对手,什么事都干得出来。(运行程序时最可能发生的是派生类的析构函数从来不被调用。在这个例子中,这意味着当targetPtr被删除时EnemyTank的数量并没有改变,当然你所统计的敌军坦克的数量就是错误的,依赖准确战场信息的战士们就惨了!)

为了避免这个问题,你只能将EnemyTarget的析构函数定义为虚拟的。将析构函数定义为虚拟的将确保该类的行为是充分定义的(well-defined),从而让它依你的意愿行事:不论是EnemyTank还是EnemyTarget,当存放它们对象的内存被释放时,对应的析构函数将被正确地调用。

这里,EnemyTarget类拥有一个虚拟函数,这是定义基类的常规方式。毕竟虚拟函数的目的是允许在派生类中重新定制该函数的行为,几乎所有的基类均拥有虚拟函数。

如果一个类没有定义任何虚拟函数,通常表示它不准备用作基类。当一个类不准备用作基类时,定义虚拟析构函数通常是个馊主意。请看一个例子,这个例子来源于ARM(The Annotated C++ Reference Manual , Margaret Ellis 和 Bjarne Stroustrup著 Addison-Wesley, 1990——译注)

//表示2D点的类

class Point {

public:

  Point(short int xCoord, short int yCoord);

  ~Point();

private:

  short int x, y;

};

如果一个short int占16位,一个Point对象正好适合一个32位寄存器。此外,一个Point对象可以作为32位的量传给其他语言(如C或FORTRAN等)写的函数。但是,如果将Point的析构函数改为虚拟的,情形就不同了。

实现虚拟函数需要该对象附带一些附加信息,从而使该对象在运行时能够确定应该调用那个虚拟函数。在大多数编译其中,这个附加信息以一个叫做vptr(virtual table point)指针的形式存在,vptr指向一个称为vtbl(virtual table)的函数指针数组,每个拥有虚拟函数的类中都有相应的vtbl。当一个对象的虚拟函数被调用时,利用指向vtbl的vptr在vtbl中查询适当的函数指针,从而确定哪个实际函数被调用。

如何实现虚拟函数的细节并不重要,重要的是如果Point类中如果存在虚拟函数,该类对象的实际大小就会翻倍,从两个16位short变成两个16位short加上一个32位vptr!Point对象就不能刚好存放在32位寄存器中了。此外,在C++中声明的Point对象与其他语言(如C)声明的类似结构不是一回事了,因为其他语言声明的结构中并没有vptr。这样就不可能在C++函数与其它语言的函数之间传递Point对象,除非你显式地添加vptr,而这样要考虑到实现细节,当然不可移植。

总是将析构函数声明为虚拟的与总是不将其声明为虚拟的一样不妙。事实上,许多人得出这个规律:当且仅当一个类中包含至少一个虚拟函数时定义该类的虚拟析构函数。

这是一个很好的规律,可以在大多数情况下工作,但是不幸的是,即使没有任何虚拟函数,它也很可能由于析构函数非虚拟而出现问题。举个例子,条款13(条款13:与声明同样的顺序初始化成员变量——译注)设计了一个用于实现用户定义范围的数组的类模板,假设你想为其派生类写一个模板,使派生类能够代表命名数组,也就是说,派生类实例化所得到的每个数组都有自己的名字:

template<class T>                // 基模板类

class Array {                    // (来自条款13)

public:

  Array(int lowBound, int highBound);

  ~Array();

private:

  vector<T> data;

  size_t size;

  int lBound, hBound;

};

template<class T>

class NamedArray: public Array<T> {

public:

  NamedArray(int lowBound, int highBound, const string& name);

  ...

private:

  string arrayName;

};

如果在程序的任何地方你将指向NamedArray的指针用某种方式转换为指向Array的指针,并且对Array指针使用delete操作,你会立即陷入未定义程序行为的境地中:

NamedArray<int> *pna =

  new NamedArray<int>(10, 20, "Impending Doom");

Array<int> *pa;

...

 

pa = pna;                // NamedArray<int>* -> Array<int>*

...

delete pa;               // 未定义! 实际上,pa->arrayName 的内存通

//常被泄漏了,因为*pa指向的NamedArray根

//本没有被释放

这类情形比你想象中发生的更频繁,因为人们希望一个现存的类(如Array)及其派生类(NamedArray)做同样的事情,这种情况并不少见。在上例中,NamedArray没有重新定义Array的任何行为,它继承了Array的函数但没有改变它们,只是添加了额外的功能。然而,析构函数非虚拟所带来的问题依然存在。

最后,值得一提的是在一些类中声明纯虚函数是很有好处的。我们知道定义纯虚函数的类是抽象类——不能被实例化的类(也就是说你不能创建该类型的对象)。然而,有时你希望你的类是一个抽象类,但却碰巧没有任何函数是纯虚拟函数,你该怎么办?好办!因为这个抽象类必然要作为一个基类,而基类应该拥有一个虚拟析构函数,同时纯虚函数的定义产生一个抽象类,所以答案非常简单:如果你想将某个类定义为抽象类,只需为该类定义一个纯虚拟析构函数。

请看这个例子:

class AWOV {                // AWOV = "Abstract w/o Virtuals"

public:

  virtual ~AWOV() = 0;         //声明纯虚拟析构函数

};

这个类有一个纯虚拟函数,所以它是抽象类;而且这个虚拟函数是其析构函数,所以可以保证你不必担心析构函数会带来问题。但是,以上只是其一,你还必须为纯虚拟析构函数提供定义:

AWOV::~AWOV() {}           // 纯虚拟析构函数的定义

这个定义是必须的,因为虚拟析构函数的工作顺序是这样的:派生类的析构函数首先被调用,然后基类的析构函数被调用。这意味着即使AWOV是个抽象类,编译器仍要产生一个对~AWOV的调用,因此你必须为这个函数提供函数实现。如果没有提供的话,连接器将提示你缺少符号,此时你只有回去加上它。

你可以在这个函数中做任何喜欢做的事,但就如上个例子所示,一般不让它做任何事情。如果是这样的话,你可能会将析构函数声明为内联的(inline),从而避免调用空函数体带来的额外开销。这是一个非常明智的策略,但是你应该知道这个策略的实质。

因为你的析构函数是虚拟的,它的地址必须放到该类的vtbl中,但是内联函数假设不是以独立函数存在的(这就是内联的意思,明白吗?),所以必须求助于特殊措施来获得它们的地址。条款33(条款33 明智地使用内联函数——译注)提供了详尽的解释,但简单地说真相是这样的:如果声明了一个内联虚拟析构函数,你可能避免了调用该函数的额外开销,但你的编译器仍然会在某个地方为这个函数生成一个外联(out-of-line)拷贝。

本文地址:http://com.8s8s.com/it/it3415.htm