基类的 virtual 析构函数是为了解决一个问题:当用基类类型的指针去delete一个派生类的实例的时候,可以让派生类的析构函数被调用。
class B { virtual ~B(){} } class A : public B { ~A(){} } B *p = new A(); delete p;
如果在 class B 的 析构函数不是虚函数,那么当 delete p 时候 ~A() 是不会被调用的,如果需要在A对象析构时做些必要操作,那么就必须把 ~B()定义成虚函数。
那么这时候 ~B()还有没有机会执行呢,有的,析构函数的机制就是子类的析构会自动调用父类的析构函数,~B()实际是被 ~A()调用的,这就形成一个完美的析构链条。
嗯……你早上是怎么穿衣服的?
是这个顺序:
1、穿上内裤;
2、穿上裤子。
还是这个顺序:
1、穿上裤子;
2、穿上内裤。
除了超人,都应该是顺序1吧?
那,如果你是按顺序1穿裤子的,晚上你怎么脱衣服?
1、先脱内裤;
2、再脱长裤。
能这样脱吗?是不是得和穿的时候反序?
C++的类构造/析构是类似过程。你可以认为基类-派生类的构造/析构动作被压进了一个(逻辑上存在但看不到的)栈里;构造时先穿内裤……哦不,先构造基类对象,后穿……构造派生类对象;脱……析构的时候正好是相反顺序。
不按这个顺序会如何?
你晚上不脱裤子先脱内裤试试不就知道了。
注意这个“逻辑上存在但看不到”的栈:事实上,这个栈也是看得到的。
构造函数里,你可以给一个初始化列表,把类中成员对象全部初始化(按对象声明顺序,和初始化列表顺序无关;其中基类对象总是最先初始化的);而在析构函数里,虽然你看不见,但类中所有成员对象也会被自动析构(当然,你自己new出来的必须自己delete,谁申请谁释放、如何申请就如何释放,这是基本原则)。
这个对象,就包括了基类对象。
换句话说,派生类析构过程是:
1、调用用户自己写的析构函数,完成相应操作;
2、按照类中成员对象的初始化顺序的逆序,逐个析构成员对象;
3、基类对象是第一个初始化的、隐含的“成员对象”,因此最后被析构;
4、析构基类对象自然就触发了它的析构函数。
你看,虽然没有刻意实现,但这就是一个栈逻辑——所以说数据结构一定得好好学,不然人家玩的千变万化,随随便便一个数据结构就嵌逻辑里面了,你哪找得到。
注意仅仅是栈逻辑,实际上却是一个逻辑上的树结构;这个树结构的根节点是基类,子节点是派生类。并且,这个树是单向的,所有派生类都可以回溯到基类、但无法从基类找派生类——说起来很复杂,听起来好多黑体字要背,背完更糊涂了;但只要稍微动动脑筋、关注功能、自己完成设计(而不是被动学习),这事就是随便谁,随手一写都差不多。
程序领域,这种正向的“自己完成设计”比逆向的“分析学习别人现成实现、琢磨出对方思路”简单很多倍的案例比比皆是。因此我才一再强调,一定要自己独立实现一些东西,不要只看现成的项目。
至于为何析构函数一定要声明为virtual,否则就等于禁止继承……
很简单,virtual成员函数会通过一定方式保留一份跟踪记录(目前来说绝大多数编译器是用虚函数表实现的),这样才能不受指向该对象的指针类型的迷惑、正确找到对应函数。
换句话说,我们可以通过正确的对象类型找到正确的析构函数,对编译器来说这是基本操作。
但是,当使用基类指针指向派生类对象时,此时通过指针取到的对象类型显然是错误的;那么我们就必须通过虚函数表之类机制才能找到“继承栈”的“栈顶”——然后才能调用到正确的派生类析构函数。
换句话说,virtual这个声明,就等于为析构函数栈搞了个栈顶指针。
而一旦找到了正确的析构函数(栈顶指针),把整个栈清空就很简单了,并不需要额外的东西。
具体来说,在正确析构函数里,编译器自动添加的类内部对象析构相关代码是硬编码的,是绝对正确的;换句话说,它必须知道(除了用户指定的析构代码外)类内部成员对象的正确析构顺序和析构方法,否则就无法正确完成其功能——而基类对象的析构不过是这个析构逻辑的一部分而已,甚至都不用为它开特例。
再换句话说,“析构函数栈”本身是物理存在的,我们仅仅是不知道它的栈顶而已;一旦栈顶确定,剩下自然顺理成章。