起因

前不久在知乎上闲逛的时候看到一个问题——在用C++进行面向对象编程的时候,什么时候需要为基类添加虚析构函数?虽然知乎的C++板块一直都挺水的,而且还是各个大牛吹水的地方,但是这个问题却值得好好讨论一下。

一般来说,你在学习C++的过程中有各种人,各种书告诉你,不添加虚析构函数会造成内存泄漏,相信时至今日,这一点早已经臭了街了,可谓妇孺皆知。但是深究其原因,我相信很多人都只能模模糊糊的说个大概,就算照搬Scott大神的说法来说,也大概是知其然不知其所以然,所以今天我还是把这个问题拿出来好好扒一扒,当然要理解这个知识点,你需要了解一些关于C++动多态的知识,建议先去阅读一下陈皓大牛所写的《C++ 对象的内存布局》,个人感觉这是入门类文章中写得相当通俗易懂的一篇了。

其实这个问题的答案Scott大神早已在他第一本神作《Effective C++》的条款7中严肃的声明了,一定要给多态继承的基类加上虚析构函数,原因是如果不这么做会造成delete其基类指针时造成诡异的对象局部销毁,而对象的销毁不完全,就是造成内存泄漏的原因,而且这样的内存泄漏一旦成型,若代码层次较深,这样的bug就像是造成了顽固脚气一般。而这篇文章就将重点讨论造成这个现象的原因,为了更加方便理解,还是把这个大问题拆分成几个小问题来一点点扒皮。

析构函数有什么特殊性?

在Java,C#这样的完全面向对象的语言中,每一个Object都默认拥有对象语义,而在C++中,情况完全不同。C++默认支持的是对象的值语义,也就是说C++原生支持的是数据抽象而不是面向对象(当然小小的做一些处理,我们可以很轻松的将C++类改造成对象语义)。所以,C++中的Object比其他语言多了那么一些东西,也就是我们经常说的rules of three

rules of three中的三个特殊的成员函数——拷贝构造函数,拷贝赋值函数,析构函数。它们都具有一些特殊性质,这里单独讲讲析构函数的特殊性。

析构函数是在对象被销毁时被调用的特殊成员函数,他具有如下特性:

  • 不存在任何参数和返回值,而且命名和声明规范特殊(以“~”开头并与类名保持一致)

  • 几乎不会也不需要被用户主动调用(placement new时除外)

  • 自己不手动声明时,编译器也会帮你自动生成一个。

记住这几条性质,这将会极大的帮助你理解!

虚析构函数和其他虚函数有什么区别?

按照C++的设计哲学来看,这本不应该有任何区别,而实际上,确实也并不存在区别,虽然表面上看起来让人那么疑惑。C++的多态实现方式标准并没有规定,但是主流编译器一般都选择了虚函数表的方式来实现C++多态继承,简单来说,就是在编译过程中给拥有虚成员函数的类创建一个虚函数表,这个表里包括了类型信息(typeid),一系列虚函数的函数指针,指向相应的重载函数,关于这个内存布局的具体知识还是根据之前所说的去读一读陈皓先生的文章,这里不过多的展开讨论。

而我们需要知道的是,虚析构函数和其他虚函数行为上是一致的,都是在运行时确定执行函数,所以你在执行delete操作的时候,delete operator会根据你继承体系的虚函数表来确定动态调用派生类还是基类的析构函数。这点非常重要,给析构函数加上virtual之后,就等于告诉编译器应该调用的是哪个析构函数的信息了,这样一来,当你的派生对象经由基类指针删除时,就会非常顺利的调用到派生类的析构函数,而不是调用到基类析构函数。而唯一看起来不一样的地方,大概是因为它的命名有特殊规范,导致基类和派生类的析构函数名并不一样,但是,它确实还是个虚函数。

派生类的析构函数究竟干了些什么?

这个问题可以用一句话说完,除了你自己定义的代码外,派生类析构函数还具有调用基类析构函数的责任!

好了,三个子问题回答完毕,现在我们综合起来看这个问题,经由基类指针删除派生类对象的时候发生了如下事件,delete operator根据基类的虚函数表调用了派生类的析构函数,派生类的析构函数调用了基类的析构函数,从而将分配于堆上的对象完全析构。

Q&A

Q:不给基类定义虚函数,并且继承派生,一定会造成内存泄漏吗?

A:并不一定,只要你正确调用到了派生类的析构函数,并不会造成局部销毁的情况。

Q:给基类定义了虚析构函数,是否就保证了析构的正确性。

A:对,因为在派生过程中C++规定了virtual可以不用手动声明,编译器会自动生成,同时,编译器也会给你自动生成析构函数,所以,这两点完全保证了析构的正确性。

Q:什么时候应该加,什么时候不应该加,加了会有什么副作用。

A:虚函数的副作用是有的,类中只要有大于等于一个虚函数时就会生成虚表,这个虚表会占用内存空间,所以,我认为当你完全了解了这个机制后,可以自己度量这个性能得失,并且做好注释工作。不过,一点点的性能损失来换取绝对的安全,这是值得的。