`
kanwoerzi
  • 浏览: 1635528 次
文章分类
社区版块
存档分类
最新评论

(原文)Effective C++条款7:为多态基类声明VIRTUAL析构函数

 
阅读更多
今天碰到一个bug,查了半天发现是某位虚基类没有定义虚析构函数,delete时导致派生类没有正确释放资源,遂从网上搜到一篇文章,留个记号
关于virtual desctructor的详细讨论。同样来自于《Effective C++》3rd Edition。

跟踪时间是很平常的任务,所以开发一个名为 TimeKeeper 的基类,并让不同的派生类来实现不同的计时方法是很合理的事情:

class TimeKeeper {

public :

TimeKeeper();

~TimeKeeper();

...

};

class AtomicClock: public TimeKeeper { ... };

class WaterClock: public TimeKeeper { ... };

class WristWatch: public TimeKeeper{ ... };

很多用户都希望直接用这些类来计数,而对于他们究竟是如何实现的并不关心。于是一个我们可以用一个 Factory function ——创建一个派生类对象并返回一个基类指针的函数——返回一个指向 TimeKeeper 的指针。

TimeKeeper* getTimeKeeper(); // returns a pointer to a dynamically

// allocated object of a class derived

// from TimeKeeper

通常, factory function 返回的对象都是创建在堆上的,当用户使用完计数器的时候把对象析构掉是很重要的:

TimeKeeper *ptk = getTimeKeeper(); // get dynamically allocated object

// from TimeKeeper hierarchy

... // use it

delete ptk; // release it to avoid resource leak

但是,依赖用户来执行删除是错误的重要来源。条款 18 介绍了如何修改 Factory function 的接口来避免这些常见的用户错误,但是,这些目前都是次要的,因为在上面的代码中还存在更为严重的问题:即使客户执行的正确的动作,你还是无法预期你的程序能够正确执行。

问题在于 getTimeKeeper 返回了一个派生类对象(例如 :AutoicClock ),但是这个对象却通过基类的指针来删除(一个指向 Timekeeper 的指针),并且这个基类没有虚析构函数。这种组合是制造灾难的良方,因为 C++ 规定:用不带有虚析构函数的基类的指针来删除一个派生类,其结果是未定的。通常在运行时发生的情况是这个对象的派生类部分没有被析构。如果 getTimeKeeper 返回一个指向 AtomicClock 对象的指针,那么 AtomicClock 中派生类的部分(例如在 AtomicClock 中声明的数据成员)将不会被正确的析构,实际上 AtomicClock 的析构函数都根本不会被调用。但是,基类的部分,却会被正确的清除,这就造就了一个“畸形”的 partially destroyed object 。这是一个非常棒的泄漏资源、破坏数据的方法,它会让你在调试器上花费大量的精力。

解决这个问题的方法很简单,给派生类加上一个虚析构函数。这样派生类对象就会如你所愿,被正确的清除:

class TimeKeeper {

public :

TimeKeeper();

virtual ~TimeKeeper();

...

};

TimeKeeper *ptk = getTimeKeeper();

...
delete ptk; // now behaves correctlhy

像 TimeKeeper 这样的基类,除了析构函数外,通常会包含其它的虚函数。因为虚函数的目标就是让派生类来订制基类的实现。例如, getCurrentTime ,在不同的派生类中就会有不同的实现(注:其实 getTimeKeeper 也可以是一个虚函数)。任何一个拥有虚函数的类都应该包含一个虚析构函数。

如果一个类没有虚函数呢,这也就意味着这个类并不是被当作基类来使用的。当遇到这种情况的时候,声明一个虚析构函数往往不是一个好主意。考虑一个用来表示二维空间中的某点的类:

class Point {// a 2D point

public :

Point(int xCoord, int yCoord);

~Point();

private :

int x, y;
};

如果一个 int 占 32 bits ,这样的一个 Point 可以被放到一个 64 位寄存器中。另外,这样的一个 Point 对象还可以被当作是一个整体被其它的语言使用,例如 C 或 FORTRAN 。但是,如果 Point 的析构函数是虚拟的,故事就完全不一样了。

虚函数的实现需要对象承载某些额外信息,这些信息用来在运行时对虚函数的调用进行正确的转发。这个额外的信息使通过一个 vtpr 来实现的。 Vptr 指向一个存放函数指针( vtbl )的数组,每一个具有虚函数的类都有一个对应的 vtbl 。当一个对象的虚函数被调用的时候,该对象的 vtpr 和 vtbl 组合来完成定位正确的函数调用的工作。

这里,虚函数如何实现的并不重要。重要的是如果 Point 包含了一个虚函数,对象将会长胖。在一个 32 bits 的机器上,它将会从 64 bits 长到 96 bits ;在 64 bit 的机器上,它将会从 64 bits 长到 128 bits 。这个额外的 vtpr 的存在让对象的体积增长了 50%~100% 。 Point 对象也不再能够放到一个 64-bit 寄存器中了。另外, Point 对象也不再和 C 语言的保持兼容,因为 C 语言中没有 vrpr 机制。结果是,你要想使用该 Point 对象,除非自己来实现 vtpr 和 vtpl 机制,而这样做,往往又会降低你的代码的可移植性。

也就是说,把所有的析构函数都不加思索的声明为虚拟的和从不把它们声明为虚拟的一样,都是不明智的行为。实际上,很多人得除了这样的结论:当且仅当一个类有至少一个虚函数的时候,才把析构函数声明为虚拟的。

实际上,即使你的类中没有虚函数,你还是有可能被非虚析构函数的问题咬上一口。例如 std::string 就没有虚函数,但是一些被误导的程序员有时会把它当作基类来使用:

class SpecialString: public std::string {

// bad idea! std::string has a

... // non-virtual destructor
}

乍一看,这可能没什么问题,但是一旦你把一个指向 SpecialString 的指针转换成一个 string ,并用这个指针来删除 SpecialString 对象的时候,你马上就被带进了未定义行为的深潭。

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 等等)。如果你曾经对于从一个标准容器或其它带有非虚析构函数的类继承,那么彻底打消这个想法。(不幸的是 C++ 没有提供像 C#(sealed) 和 Java(final) 类似的拒绝继承的语言机制)

有时候,把析构函数设定为 pure virtual 是非常方便的。一个 pure virtual 函数可以让一个类成为抽象类。有时,你可能需要让你的类成为一个 abstract class ,但是你一时又找不到合适的纯虚函数。怎么办呢?因为一个抽象类往往是要被作为基类的,而一个基类往往又应该有一个虚析构函数。这样一来:声明一个 pure virtual destructor 就是一个不错的主意。一箭双雕。

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

public :

virtual ~AWOV() = 0; // declare pure virtual destructor

};

这个类有一个纯虚函数,因此这是以个抽象基类,并且这个类有一个虚析构函数,这也使你远离了析构函数的问题,唯一要注意的,就是一定要为纯虚析构函数提供一份实现。

虚析构函数的工作方式是从最深的派生类的析构函数依次调用其基类的析构函数,编译器会生成生成一个从派生类到基类的 ~AWOV 的调用。如果你没有提供析构函数的实现,链接器就会抱怨错误。

所以,你只应该把多态基类的析构函数声明为虚拟的。只有你想通过基类接口来操作派生类的时候,一个基类才是多态的。 TimeKeeper 就是一个多态基类,因为我们需要用一个 TimeKeeper* 来操作 AtomicClock 和 WaterClock 对象。

另外,并不是所有的基类都要按照多态的方式来设计和使用。 Std::string 和 STL 中的容器类型就都不具备多态性。一些类被设计成基类,但是却不应该按照多态的方式来使用,例如 input_iterator_tag 就是一个例子,你并不需要用基类接口来操纵派生类。结果是,他们也不需要虚拟析构函数。

时时刻刻让自己记住

l 应该为多态基类声明虚拟析构函数。如果一个类有一个虚函数,那么它也应该有一个虚析构函数

l 如果一个类不是被设计为基类或者它们并不是按照多态的方式来使用的,不要为它们声明虚析构函数

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics