虚函数的实现机制

先说一下类继承的内存分布和this指针:
子类的对象内存布局包括两部分:父类和子类派生部分,所以执行父类的构造函数只不过是在构造子类对象的父类部分。因此子类对象的this指针当然是指向子类对象自己了。所谓基类子类是一个大结构体,this指向了该大结构体的地址,其中this指针开头的四个字节存放虚函数表头指针。
理解虚函数( virtual function )的几个关键点:
1. 理解早绑定(early binding)、晚绑定(late binding)。所谓early binding:On compile time,就能明确一个函数调用是对哪个对象的哪个成员函数进行的,即编译时就晓得了确定的函数地址;所谓late binding:On compile time,对函数(虚函数)的调用被搞成了:pObj->_vptr->vtable[],从而导致不到runtime,完全不知道实际函数地址。直到程序运行时,执行到这里,去vtable里拿到函数地址,才晓得。其实,原理很简单,只是单看这些名词的话会觉得好像很magic一样。
2. 理解虚函数赖以生存的底层机制:vptr + vtable。虚函数的运行时实现采用了VPTR/VTBL的形式,这项技术的基础:
①编译器在后台为每个包含虚函数的类产生一个静态函数指针数组(虚函数表),在这个类或者它的基类中定义的每一个虚函数都有一个相应的函数指针。
②每个包含虚函数的类的每一个实例包含一个不可见的数据成员vptr(虚函数指针),这个指针被构造函数自动初始化,指向类的vtbl(虚函数表)

③当客户调用虚函数的时候,编译器产生代码反指向到vptr,索引到vtbl中,然后在指定的位置上找到函数指针,并发出调用。


可以看到子类class2的vfun2,在虚函数表中覆盖了,class1中的vfunc2。具体实现过程:
基类构造函数中填入基类的vptr,vptr指向虚函数,内容是class1:vfunc1,class1:vfunc2,class1:vfunc3,然后回到子类的构造函数,填入子类的vptr,子类的方法会继承父类的方法所以,vptr指向的虚函数表内容是,class1:vfunc1,class2:vfunc2(被子类覆盖了),class1:vfunc3,最后覆盖基类填入的vptr,这个vptr就是父类子类整个大存储空间的虚函数表指针(this指针开头的四个字节)。如此以来完成vptr的初始化。
下面的这个例子能够很好说明,上面讲的这个vptr初始化的过程。

#include <iostream>
using namespace std;
 
class Base1 {
public:
            virtual void f() { cout << "Base1::f" << endl; }
            virtual void g() { cout << "Base1::g" << endl; }
            virtual void h() { cout << "Base1::h" << endl; }
 
};
class Derive : public Base1 {
public:
            virtual void f() { cout << "Derive::f" << endl; }
            virtual void g1() { cout << "Derive::g1" << endl; }
};
typedef void(*Fun)(void);
 
int main()
{
            Fun pFun = NULL;
            Derive d;
            int** pVtab = (int**)&d; 
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);
            pFun = (Fun)pVtab[0][0];
            pFun();
      //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);
            pFun = (Fun)pVtab[0][1];
            pFun();
 //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);
            pFun = (Fun)pVtab[0][2];
            pFun();
        //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);
            pFun = (Fun)pVtab[0][3];
            pFun();
}
运行打印:
Derive::f
Base1::g
Base1::h
Derive::g1
知道了虚函数表分布,再分析一下,虚函数是怎么被调用的,再看两个个实例,
无虚函数覆盖:
#include <iostream>
using namespace std;
 
class Base1 {
public:
            virtual void f() { cout << "Base1::f" << endl; }
            virtual void g() { cout << "Base1::g" << endl; }
            virtual void h() { cout << "Base1::h" << endl; }
 
};
class Derive : public Base1 {
public:
            virtual void f1() { cout << "Derive::f1 << endl; }
            virtual void g1() { cout << "Derive::g1" << endl; }
virtual void h1() { cout << "Base1::h1" << endl; }

};


对于实例:Derive d; 的虚函数表如下:

我们可以看到下面几点:
1)虚函数按照其声明顺序放于表中。
2)父类的虚函数在子类的虚函数前面。
其中&d是子类实例(按照上面讲的子类实例其实也包含父类部分)的首地址。

一般继承(有虚函数覆盖)

覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:

我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。

这样,我们就可以看到对于下面这样的程序,

Base *b = new Derive();

b->f();

由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

VPTR 常常位于对象的开头,编译器能很容易地取到VPTR的值,从而确定VTABLE的位置。VPTR总指向VTABLE的开始地址,所有基类和它的子类的虚函数地址(子类自己定义的虚函数除外)在VTABLE中存储的位置总是相同的,如上面base1类和derive类的VTABLE中f和g的地址总是按相同的顺序存储。编译器知道f位于VPTR处,g位于VPTR+1处,因此在用基类指针调用虚函数时,编译器首先获取指针指向对象的类型信息(VPTR),然后就去调用虚函数。如一个base1类指针pBase指向了一个derive对象,那pBase->g()被编译器翻译为 VPTR+1 的调用,因为虚函数g的地址在VTABLE中位于索引为1的位置上。同理,pBase->h ()被编译器翻译为 VPTR+2的调用。这就是所谓的晚绑定。
我们来看一下虚函数调用的汇编代码,以加深理解。

void test(base1* pBase)
 {
  pBase->f();
 }
 int main(int argc, char* argv[])
 {
  derive 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->f();编译生成的汇编代码如下:
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]。

每个含有虚函数的类有一张虚函数表(vtbl),表中每一项是一个虚函数的地址, 也就是说,虚函数表的每一项是一个虚函数的指针,子类会继承父类的虚函数表,如果有相同的虚函数子类也会在虚函数表中覆盖。
没有虚函数的C++类,是不会有虚函数表的。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值