C 箴言:多态基类中将析构函数声明为虚拟

2008-02-23 05:40:45来源:互联网 阅读 ()

新老客户大回馈,云服务器低至5折

  有很多方法能够跟踪时间的轨迹,所以有必要建立一个 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

  现在我们精力集中于上面的代码中一个更基本的缺陷:即使客户做对了每一件事,也无法预知程式将如何运转。

  问题在于 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,

标签:

版权申明:本站文章部分自网络,如有侵权,请联系:west999com@outlook.com
特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系,版权归原作者所有

上一篇: C 箴言:防止因异常而离开析构函数

下一篇: C 箴言:拒绝不想用的编译器产生的函数