首页 > 代码库 > 虚函数的小秘密

虚函数的小秘密

本文分析虚函数的小秘密,通过几个case说明为了支持虚函数,应该有什么样的约定,生成什么样的代码。

C++中虚函数用于实现多态:即方法调用和对象的动态类型绑定。具体地说对A*类型指针p指向A的公有派生类B的对象,A中有虚函数foo,B中给定foo的另一份实现,p->foo应该和B中的新实现绑定,而不是和A中的实现绑定。

一般而言,会在对象布局中插入一个虚函数表指针,在表中列出了所有的虚函数。下面以这种模型为基础讨论。

先看基类A,假定有数据成员dataA,虚函数表指针vptrA,虚函数fooA()。

从fooA本身的实现来看,在thiscall的约定下,认为ecx作为输入参数,其中的值是this指针,this指针的类型当然是A*了。要调用虚函数则要满足如下条件:
1. 能够找到fooA的实现地址。
2. ecx中含有this,this的类型是A*(即this确实指向了A的对象,而不会是A的派生类的对象)。

在此,引入一个不变式:
如果有A* ptr;那么ptr指向的内存数据的理解应该完全由A这个类型决定。无论ptr指向的确实是是一个A的对象,或者A的派生类的对象。既然如此,派生类对象应该存在某段区域,这段区域可以看到一个A的对象,ptr指向派生类时,应该指向派生类对象的这段区域。
[不变式使得向上向下转型时有指针重设,使得在ptr->foo的代码中可以断言ptr指向的对象是什么,使得可以用不变的几步操作来实现对foo的调用]

case 1

如果我们有A* ptr = new A();虚函数的调用实现应该是,从ptr指向的数据拿到虚表指针,根据偏移,进而拿到A::fooA的具体地址。将ptr直接放到ecx中。所以满足fooA的调用条件可以满足。


如果有B继承自A,B中引入虚函数fooB,数据dataB,没有override了fooA。一个可能的内存布局是:
vptrB dataB vptrA dataA。

case 2
如果有A* ptr = new B();根据前面的不变式ptr会指向vptrA的位置,如果在vptr中fooA的位置放上fooA实现的地址,这个时候调用方法没问题,和前面讨论的一样。

case 3
如果有B* ptr = new B();根据类型信息B,通过偏移量拿到vptrA,进而能拿到fooA的地址。此外,拿到vptrA的同时,也拿到了A-sub-object的地址,将这个sub object的地址放到ecx中,于是虚函数调用条件满足。

再考虑B中override了fooA的情况,不妨设这个override的函数名为fooA_override_by_B:在构造B的时候,将fooA_override_by_B填入了vptrA中对应位置。

case 4
对于A* ptr = new B();传入的ecx指向的是A这个sub-object,实际调用到的函数是fooA_override_by_B。而根据虚函数调用条件,fooA_override_by_B会认为传入的是B*。所以fooA_override_by_B分为两部分,其中一部分fooA_override_by_B_impl是具体实现,会认为传入的this是B*的。另一部分fooA_override_by_B_adjust会将传入的A*调整为B*,然后跳转到fooA_override_by_B_impl。vptrA中放的应该是fooA_override_by_B_adjust。

case 5
对于B* ptr = new B();在外部将ecx指向了A-sub-object,在fooA_override_by_B_adjust中又将指向A-sub-object的对象调整为指向B的对象。

所以,在发生override时,一方面提供对应的实现函数,这个函数接受正在override的类的指针。另一方面在被override的类的虚函数表中,放上调整函数,将父类指针调整为子类指针。这样做是可行的,因为被override的类和正在override的类的信息是编译时确定的。

讨论到这里,我们在有T* ptr时,调用虚函数的条件是这样满足的:
1. 根据T这个类型,及其继承关系,考察被调用的虚函数,找到对应的虚表指针,然后根据虚函数在虚表中的位置,确定虚函数的地址。其中T的类型,继承关系,被调用的虚函数,某个类引入的虚函数在虚表中的位置,ptr的值,这5个量是已知的。未知的是虚函数的位置。
2. 同样被调用的虚函数所属的类在T中的偏移也是个已知量,加上ptr就得到对应的sub-object的地址。

再看个多继承的例子,如果A,B作为基类,都有虚函数fooX,C继承自A,B,override了fooX。这个时候C中有三个虚函数表,在A的虚函数表中fooX应该是fooX_A_override_by_C_adjust。在B的虚函数表中foo应该是fooX_B_override_by_C_adjust。和前面的不同,我们用fooX_A表示fooX是A中的,fooX_B是B中的,两者名字相同,我们用这个后缀以作区别。两个adjust函数可以分别将A和B的指针调整为C的指针,然后分别都跳转到fooX_A_override_by_C_impl,fooX_B_override_by_C_impl实际上,这两个impl函数是同一个。当然,第三个虚函数表是C自己可以引入的虚函数,在此不影响讨论。

虚函数有什么秘密?
1.[指针约定、对象布局]形如T* ptr;应当认为指向的内存开始sizeof(T)个字节确实是一个T的对象。这就要求向上或向下转型时,有指针重设。在对象布局中子类中存在某段区域,这段区域正好是基类对象。
2.[引入虚函数表]对象中有指针指向虚函数表,使得在指向不同的虚函数表的时候,虚函数调用有不同的表现。
3.[虚函数表中的项对this的需求]在call虚函数表的某一项时,ecx中保存的是虚函数表对应的对象的this指针。
4.[override后的this修改]在子类override时,对于每一处需要修改的虚函数表(一般只有一个),由于3满足,所以可以插入将这个对象转为子类对象的转换代码,然后跳转到override后的实现。

5.[可能override多个虚函数表中的项]有多个需要修改的虚函数表时说明通过该对象同时override多个实现。


注:虚函数表共享在里没有被提及,但是并不影响分析。
所有的这些结果,根据经验推算而来,不代表实现中一定是这样。