12.1 识别类和类之间的关系(5)
在代码清单12-6中,虚函数的调用过程使用了间接寻址方式,而非直接调用一个函数地址。由于虚表采用间接调用机制,因此在使用父类指针pPerson调用虚函数时,没有依照其作用域调用CPerson类中定义的成员函数ShowSpeak。
对比第11章代码清单11-3中的虚函数调用后可以发现,当没有使用对象指针或者对象引用时,调用虚函数指令的寻址方式为直接调用方式,从而无法构成多态。由于代码清单12-6中使用了对象指针来调用虚函数,因此会产生间接调用方式,进而构成多态。代码清单11-3的代码片段如下:
- MyVirtual.SetNumber(argc);
- 00401050 mov eax,dword ptr [ebp+8]
- 00401053 push eax
- 00401054 lea ecx,[ebp-8]
- ; 这里是直接调用,无法构成多态
- 00401057 call @ILT+5(CVirtual::SetNumber) (0040100a)
当父类中定义有虚函数时,将会产生虚表。当父类的派生类产生对象时,根据代码清单12-2的分析,将会在调用子类构造函数前优先调用父类构造函数,并以子类对象的首地址作为this指针传递给父类构造函数。在父类构造函数中,会先初始化子类虚表指针为父类的虚表首地址。此时,如果在父类构造函数中调用虚函数,虽然虚表指针属于子类对象,但指向的地址却是父类的虚表首地址,这时可判断出虚表所属作用域与当前作用域相同,于是会转换成直接调用方式,从而造成构造函数内的虚函数失效。修改代码清单12-5,在CPerson类的构造函数中添加虚函数调用,如下所示。 - class CPerson{
- public:
- CPerson(){
- ShowSpeak(); // 调用虚函数,将失效
- }
- virtual ~CPerson(){}
- virtual void ShowSpeak(){
- printf("Speak No\r\n");
- }
- };
以上代码执行过程如图12-2所示。
|
| 图12-2 构造函数调用虚函数 |
图12-2演示了构造函数中使用虚函数的流程。按C++(www.cppentry.com)规定的构造顺序,父类构造函数会在子类构造函数之前运行,在执行父类构造函数时将虚表指针修改为当前类的虚表指针,也就是父类的虚表指针,因此导致虚函数的特性失效。如果父类构造函数内部存在虚函数调用,这样的顺序能防止在子类中构造父类时,父类会根据虚表错误地调用子类的成员函数。
虽然在构造函数和析构函数中调用虚函数会使其多态性失效,但是为什么还要修改虚表指针呢?编译器直接把构造函数或析构函数中的虚函数调用修改为直接调用方式,不就可以避免这类问题了吗?大家不要忘了,程序员仍然可以自己编写其他成员函数间接调用本类中声明的其他虚函数。假设类A中定义了成员函数f1( )和虚函数f2( ),而且类B继承自类A并重写了f2( )。根据前面的讲解我们可以知道,在子类B的构造函数执行前会先调用父类A的构造函数,此时如果在类A的构造函数中调用f1( ),显然不会构成多态,编译器会产生直接调用f1( )的代码。但是,如果在f1( )中又调用了f2( ),此时就会产生间接调用的指令,形成多态。如果类B的对象的虚表指针没有更换为类A的虚表指针,就会导致在访问类B的虚表后调用到类B中的f2( )函数,而此时类B的对象尚未完成构造,其数据成员是不确定的,这时在f2( )中引用类B的对象中的数据成员是很危险的。
同理,在析构类B的对象时,会先执行类B的析构函数,然后执行类A的析构函数。如果在类A的析构函数中调用f1( ),显然也不能构成多态,编译器同样会产生直接调用f1( )的代码。但是,如果f1( )中又调用了f2( ),此时会构成多态,如果这个对象的虚表指针没有更换为类A的虚表指针,同样也会导致访问虚表并调用类B中的f2( )。但是,此时B类对象已经执行过析构函数,所以B类中定义的数据已经不可靠了,对其进行操作同样是很危险的。
稍后我们会以IDA为分析工具将各个知识点串联起来一起讲解。
在析构函数中,同样需要处理虚函数的调用,因此也需要处理虚函数。按C++(www.cppentry.com)中定义的析构顺序,首先调用自身的析构函数,然后调用成员对象的析构函数,最后调用父类的析构函数。在对象析构时,首先设置虚表指针为自身虚表,再调用自身的析构函数。如果有成员对象,则按声明的顺序以倒序方式依次调用成员对象的析构函数。最后,调用父类析构函数。在调用父类的析构函数时,会设置虚表指针为父类自身的虚表。