从 Effective C++ 中讲到的虚析构函数想开去
已经有两年主要用高级语言,如 ABAP、Python、Java 等编写程序了。对 C/C++ 程序看上去还是很熟悉,特别是纯 C 程序和只用类和继承而不用多少模版的 C++ 程序。最近又在重温 Effective C++。对于这本书我大学时候也没有从头到底看过,但是觉得它讲的很多东西非常有道理,所以就把它列作必读书目之一。这次从头重新看起,然后就看到了现在讲的条款 14,要求基类有虚析构函数的这一条。
看到这一条时,首先想到的是未定义行为。C++ 对于基类没有虚析构函数的情况,当一个指针指向其子类对象后 delete 此指针时,行为是未定义的。“未定义行为”是什么概念呢?书中说得比较有趣:可能会格式化你的磁盘,可能给你的老板发送邮件,可能把你的程序代码传真给对手,。。。当然实际上这些事情发生的概率如同让猴子在键盘上打出几个有意义的英文单词的概率差不多。但不管怎样,C/C++ 中的未定义行为总是让人比较担心的。有时,一个因数据不一致或堆内存破坏而引起的 bug,其根本原因往往是一段时间以前不小心执行的一个未定义操作,例如两次 delete 指向同一个地址的指针,读写了已经被释放的内存块,指针或数据没有被初始化,使用引用来指向栈上的一个临时变量,运行了多次析构函数,向一块内存写入超过它容量的数据等等。正因为这个原因,在 C/C++ 中对于异常的处理就非常重要,但是由于语言支持的关系,C 语言中对异常处理基本没有语言支持,C++ 中虽然有异常和栈开解等机制,功能上完整了,但却用了个“没有强制公共基类”以及“没有堆栈跟踪”的设计,导致实际调试异常成了非常艰巨的任务,非得有可重现的步骤以及用“人工二分查找”,对于类库抛的异常还得有文档,这三个条件同时满足才行。因此实际使用此机制的程序很少。i386 保护模式和一些外部工具如 AppVerifier 和 Valgrind 等能够检测一部分问题,配合 checked build 等方法,的确能消除很多 bug,这才相对有所放心。
然后想到的是基类和子类的构造和析构问题。每次构造一个子类对象,其基类的构造函数会先被调用,然后再调用子类的构造函数。每次析构一个子类对象,其子类的析构函数会先被调用,然后再调用基类的析构函数。这在析构函数为虚函数的情况下成立。那么析构函数为什么要被声明为虚函数,而不是直接默认为虚函数呢?C++ 这方面的考虑肯定是出于性能。虚函数调用时需要通过对象的 vptr 指到 vtbl,再从 vtbl 中指到一个固定偏移的函数指针,然后调用此函数。类似于 call this->vptr[offset] 这种概念,因此会比较耗时。那么虚析构函数执行的过程,很可能是调用对象实际所在类的析构函数,然后该子类析构函数的尾部会调用上一层基类的析构函数,上一层基类析构函数的尾部再调用更上一层,以此类推。也就是说,我们在 ~SubClass() {…} 这样的函数中看到的代码之后实际还隐含了一个调用 SuperClass::~SuperClass();,除非是最根本的那个基类。
最后想到的是两年前看过的文章 Ian Joyner: a critique of c++ and programming and language trends of the 1990s。里面说到 Java 的方法默认都是虚函数(除非 final)。C++ 的虚函数则要显式声明,这样的话,如果一个类的某个函数没有显式声明为虚函数,那么将来如果要在子类中用同名的函数重写这个函数的话,就必须修改基类代码,这在一个类库中是很不合适的事情,特别是那个类库的源代码无法被应用者修改的情况下。此处我想,最好的解决方法自然是修改源代码,如果可能的话。如果不行,并且找类库主人不方便,则应考虑使用组合代替继承,然后在组合的类中再使用函数指针等方法显式解决此问题。如果还不行,比如有其他类直接使用此基类指针,在此情况下还需要有虚拟特性的,那就只有找类库主人了,这个没什么话好说,谁让他是主人呢。人家用 C++ 的目的和 Java 本来就不一样,所以不应该用对 Java 的要求去要求 C++。但是异常处理除外,异常本来就不是一个小开销的东西,而且是一个调试利器,应该索性做得好些。