由C++虚析构函数风险性产生的思考

概述

virtual 关键字作为 C++ 多态特性的表现载体,在多态 base class 的析构当中对内存泄漏的避免具有相当重要的意义,但是与此同时却也存在一些难以窥探到的风险性,如果在开发过程中对 virtual 的使用稍有疏忽就很有可能走上弯路,甚至造成一些不必要的麻烦。这些麻烦轻则导致程序运行质量的降低,耗费了更多的内存,重则造成更加严重的问题,产生内存泄漏、影响程序的移植性。

Title


为多态基类声明虚析构函数

在许多程序当中需要使用到计时器,那么设计一个计时基类 Timer base class 及一些用来以不同的方式计时的派生类就显得十分正常。

1
2
3
4
5
6
7
8
9
10
class Timerbase	// 时钟基类
{
public:
Timerbase();
~Timerbase();
...
};
class AtomicClock: public Timebase {...}; // 原子钟
class WristWatch: public Timebase {...}; // 腕表
...

如果作为库程序的开发者,那么其用户通常不想关心库函数的具体实现,他们只是想要一个用以实现计时功能的计时器,那么一般情况下我们可以给出一个工厂函数(Factory),这个函数最终将返回一个被需求的计时器,一个 base class 指针,指向新生的派生对象。

1
Timerbase* makeTimer();	// 以 Timerbase 为基类动态分配一个计时对象

为了避免内存泄露,库程序的使用者就应该在计时对象使用完毕后通过 delete 将其销毁,以避免资源浪费。其中一个比较浅白的风险在于,makeTimer 函数返回一个指向派生对象的指针,最终却经由一个基类指针删除,而此时的基类中并未声明虚析构函数,这正是此风险的源头。因为在C++当中明确指出,一个派生对象经由一个基类指针 delete,而这个基类不存在一个虚的析构函数,那么此操作是无定义的,并且通常情况下派生对象并不会被销毁释放。

1
2
3
Timerbase* atomicClock = makeTimer();	// 返回一个指向派生对象 AtomicClock 的指针
...
delete atomicClock; // 派生成分 AtomicClock 并未被销毁

在通过无虚析构函数的基类指针销毁其派生类对象时,派生实例未被销毁,派生类的析构函数也未被执行,而其基类成分却被销毁了,这就产生了一个诡异的被“局部销毁”的对象,最终结果则是造成了资源泄露。

消除这一风险的办法很简单,将基类的析构函数定义为 virtual 即可,同时也可为基类的其他成员函数冠以 virtual 以实现派生对象的客制化。由此可见,对于一个除析构函数外拥有虚函数的类而言,将析构函数写作为虚析构函数这一举措是非常有必要的。

只为多态基类声明虚析构函数

通常情况下,一个不包含任何虚函数的类是不被希望作为基类的,那么将一个非基类的析构函数冠以 virtual 是否又合理呢?换句话说,为了避免多态特性下派生类的销毁带来的内存隐患,是否值得将所有类的析构函数都替为虚析构函数?

1
2
3
4
5
6
7
8
9
class Point	// Point 是一个独立的类,并不意图作为其他任何类的基类
{
public:
Point(int x, int y);
virtual ~Point(); // 但是却声明了 virtual 析构函数
private:
int m_iX;
int m_iY;
};

事实表明这种做法是比较糟糕的,就拿上面的 Point class 为例,在其析构函数还不是虚函数的情况下,其有两个 int 型成员,若 int 型在内存当中占用 32 bits,也就意味着一个 Point 对象是可以被装载进 64-bit 的缓存器中,甚至在考虑更广泛的情况下,这个对象可以被传给 C 或其他的语言所编写的函数。

可是如若 Point 的析构函数是虚函数,形式就产生了变化。为了实现虚函数带来的动态指向性,所有使用了 virtual 的类必然要携带一份 vptr,用以存放所有 virtual 函数的函数指针,以便于在编译时为编译器指明基类的成员函数被 call 时的实际被调函数。此处 vptr 的结构及其实现细节并不重要,重要的是 Point 携带了 vptr 后必然占有更大的空间,原本可以装载进 64-bit 缓存器的对象此时却可能需要 96 bits,这直接导致了 C++ 中的 Point 对象不再与 C 或其它语言内具有相同声明的结构相匹配了(因为其他语言当中并没有 vptr 的同型受体),也就意味着不能在其他语言直接进行 Point 对象的传递(与被传递),也就扼杀了 Point 本有的移植性。

当然,如果非要这样做的话,可以在其他语言内手动填充 vptr 那部分细节,可这不是自找麻烦吗?

由此可见,virtual 析构函数的使用是需要视具体情况而定的。

  • 如果正在编写的是带多态性质的 base class,在未来将派生出更多的类并加以控制,那么就需要一个虚析构函数;
  • 如果正在编写的类不欲作为其他类的基类,那么就应具有一个普通的析构函数而不该拥有一个虚析构函数。

总结为一句话就是,只为多态基类声明虚析构函数,只要遵循这一原则,就可以避免 virtual 带来的这部分麻烦。

谨慎选择派生基类

在确保只为多态基类声明虚析构函数的前提下,就不会产生任何问题了吗?问题依旧存在!就算为一个非多态类声明普通的析构函数,依旧有资源泄露的可能。问题就在于开发者是否错误地将一个非多态类作为 base class 并生成派生对象了。一般而言,这种错误可能会出现在合作开发或引用了外部库的情况下,无意间将带有非虚析构函数的类作为基类了,例如下面这种情况。

1
2
3
4
5
class MyString: public std::string	// 将 std::string 作为基类编写 MyString class
{
MyString();
~MyString();
};

注意,此处 std::string 具有非虚析构函数!那么就存在这样一种情况,如果在开发的过程中无意地将一个 MyString 类型指针转换成为了 string 类型的指针,不幸的是这又是合法的,而之后又将这个 string 指针 delete 了,那么就造成了 MyString 对象的部分销毁,产生了资源泄露。

1
2
3
4
5
6
MyString* pMyString = new MyString("Hello World!");
std::string* pString;
...
pString = pMyString; // 不小心将派生类指针 MyString* 转换成了无虚析构函数的基类指针 std::string*!
...
delete pString; // 将基类指针 delete,但 MyString 的析构函数未被调用,造成内存泄露。

这主要提醒了一点:在进行派生类的编写时,基类的选择值得留心(有可能是选择库中的类,有可能是同事开发的类,也可能是很久以前自己编写的类),如果对所选择的基类是否具有多态性,是否具有虚析构函数并不明确的话,一定要加以考察和确认,以避免产生不必要的麻烦。

总结

  • 务必为多态基类声明虚析构函数(避免内存泄露);
  • 且只为多态基类声明虚析构函数(减少内存占用,保护移植性);
  • 确保派生类的基类具有多态特性(避免内存泄露)。
--- END ---
如果你觉得文章内容对你有帮助,可以打赏支持一下哦!

0%