两个类vptr指向的虚函数表(vtable)分别如下:
Base类 Derived类
vptr——>| &Base::vfun1 | vptr——>| &Derived::vfun1 |
| &Base::vfun2 | | &Base::vfun2 |
| &Base::vfun3 |
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个vtable(如上所示),在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址(在Derived的vtable中,vfun2的入口就是这种情况。)然后编译器在这个类中放置vptr。当使用简单继承时,对于每个对象只有一个vptr。vptr必须被初始化为指向相应的vtable,这在构造函数中发生。
一旦vptr被初始化为指向相应的vtable,对象就“知道”它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。
VPTR常常位于对象的开头,编译器能很容易地取到VPTR的值,从而确定VTABLE的位置。VPTR总指向VTABLE的开始地址,所有基类和它的子类的虚函数地址(子类自己定义的虚函数除外)在VTABLE中存储的位置总是相同的,如上面Base类和Derived类的vtable中 vfun1和vfun2的地址总是按相同的顺序存储。编译器知道vfun1位于vptr处,vfun2位于vptr+1处,因此在用基类指针调用虚函数时,编译器首先获取指针指向对象的类型信息(vptr),然后就去调用虚函数。如一个Base类指针pBase指向了一个Derived对象,那 pBase->vfun2()被编译器翻译为 vptr+1 的调用,因为虚函数vfun2的地址在vtable中位于索引为1的位置上。同理,pBase->vfun3()被编译器翻译为vptr+2的调用。这就是所谓的晚绑定。
我们来看一下虚函数调用的汇编代码,以加深理解。
void test(Base * pBase) { pBase->vfun2(); }; int main(int argc, char* argv[]) { Derived td; test(&td); return 0; } |
Derived td;编译生成的汇编代码如下:
mov DWORD PTR _td$[esp+24], OFFSET FLAT:??_7Derived@@6B@ ; Derived::`vftable' |
由编译器的注释可知,此时PTR_td$[esp+24]中存储的就是Derived类的vtable地址。
test(&td);编译生成的汇编代码如下:
lea eax, DWORD PTR _td$[esp+24] mov DWORD PTR __$EHRec$[esp+32], 0 push eax call ?test@@YAXPAVbase@@@Z ; test |
调用test函数时完成了如下工作:取对象td的地址,将其压栈,然后调用test。
pBase—>vfun2();编译生成的汇编代码如下:
mov ecx, DWORD PTR _pBase$[esp-4] mov eax, DWORD PTR [ecx] jmp DWORD PTR [eax+4] |
首先从栈中取出pBase指针指向的对象地址赋给ecx,然后取对象开头的指针变量中的地址赋给eax,此时eax的值即为VPTR的值,也就是 vtable的地址。最后就是调用虚函数了,由于vfun2位于vtable的第二个位置,相当于 vptr+1,每个函数指针是4个字节长,所以最后的调用被编译器翻译为 jmp DWORD PTR [eax+4]。如果是调用pBase->vfun1(),这句就该被编译为jmp DWORD PTR [eax]。