C++虚函数探讨 深信服笔试题

本文深入探讨C++中虚函数的工作原理,包括虚函数表(vtable)和虚指针(vptr)的作用机制,解释了不同类型的虚函数如何影响类的大小,并通过实例演示了动态绑定的实现过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

首先看如下程序:#include <iostream>
using namespace std;
class no_virtual
 {
 public:
     void fun1() const{}
     int  fun2() const { return a; }
 private:
     int a;
 };
 class one_virtual
 {
 public:
     virtual void fun1() const{}
     int  fun2() const { return a; }
 private:
     int a;
 };
 class two_virtual
 {
 public:
     virtual void fun1() const{}
     virtual int  fun2() const { return a; }
 private:
     int a;
 };
 void main(){
	 cout<<"no_virtual:"<<sizeof(no_virtual)<<endl;
	 cout<<"one_virtual:"<<sizeof(one_virtual)<<endl;
	 cout<<"two_virtual:"<<sizeof(two_virtual)<<endl;
 }

输出结果为:

no_virtual:4
one_virtual:8
two_virtual:8
Press any key to continue

1. 从sizeof 的运行结果可以看出类不包含函数它的成员函数那一个对象怎么调用该函数的呢?其实是使用this 指针。
    那么怎么知道this 指针的呢?
     我们来看一下内存结构:
     
class     A     
  {     
          int     a;     
              public:     
                  op(int   value){a=value;};     
                  int     read(){return     a     ;}     
              public 
                  virtual   int   as(){return   a*a;} 
  };  

对于以上的类的说明,当创建一个实例时,其内存结构如下(不同的C++实现略有不同): 
+0000:   成员变量   int   a   
+0004:   $A$vtable指针     -->         +0000:   成员函数指针   as 


为什么op函数和read函数不需要在类中体现呢?因为没有必要。 


当C++编译以上的类时,它会产生类似以下的成员函数(不同的C++实现略有不同): 
//op函数 
int   $A$op@i_(A*   this,   int   value)   {this-> a   =   value;} 
//read函数 
int   $A$read@_(A*   this)   {return   this-> a;} 
//as函数 
int   $A$as@_(A*   this)   {return   this-> a   *   this-> a;} 

也就是说,函数名本身就指明了函数是属于哪个类的(连参数类型都有),因此,编译器在编译代码时,可以直接调用该类的成员函数,而不需要类本身提供任何信息。 

2. 当一个类拥有虚函数时,由于需要动态绑定,该类的对象需要有额外的4个字节指针记住一个与虚函数有关的地址信息。
    所以one virtual :8 
3.  no_virtual的长度就是其成员变量整型a的长度;
有一个虚函数和两个虚函数的类的长度没有区别,其实它们的长度就是no_virtual的长度加一个void指针的长度,它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针( V P T R)。在one_virtual 和 two_virtual之间没有区别。这是因为V P T R指向一个存放地址的表,只需要一个指针,因为所有虚函数地址都包含在这个表中。
所以 two virtual:8

下面我们来看看编译器是怎么建立VPTR指向虚函数表的。
 class base
 {
 public:
     void bfun(){}
     virtual void vfun1(){}
     virtual int vfun2(){return 0;}
 private:
     int a;
 }
 class derived : public base
 {
 public:
     void dfun(){}
     virtual void vfun1(){}
     virtual int vfun3(){return 0;}//不是纯虚函数所以要加return 0;不然报错。纯虚函数 virtual int vfun3()=0;
 private:
     int b;
 }
两个类VPTR指向的虚函数表(VTABLE)分别如下:
 base类
                       ——————
 VPTR——> |&base::vfun1 |
                       ——————
                  |&base::vfun2 |
                   ——————
       
 derived类
                       ———————
 VPTR——> |&derived::vfun1 |
                       ———————
                   |&base::vfun2    |
                   ———————
                   |&derived::vfun3 |
                    ———————
     
  每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如上图所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在derived的VTABLE中,vfun2的入口就是这种情况。)然后编译器在这个类中放置VPTR。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE,这在构造函数中发生。
 一旦VPTR被初始化为指向相应的VTABLE,对象就"知道"它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。
个人总结如下:
1、从包含虚函数的类派生一个类时,编译器就为该类创建一个VTABLE。其每一个表项是该类的虚函数地址。
2、在定义该派生类对象时,先调用其基类的构造函数,然后再初始化VPTR,最后再调用派生类的构造函数(从二进制的视野来看,所谓基类子类是一个大结构体,其中this指针开头的四个字节存放虚函数表头指针。执行子类的构造函数的时候,首先调用基类构造函数,this指针作为参数,在基类构造函数中填入基类的vptr,然后回到子类的构造函数,填入子类的vptr,覆盖基类填入的vptr。如此以来完成vptr的初始化。)
3、在实现动态绑定时,不能直接采用类对象,而一定要采用指针或者引用。因为采用类对象传值方式,有临时基类对象的产生,而采用指针,则是通过指针来访问外部的派生类对象的VPTR来达到访问派生类虚函数的结果。
 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]。
对于第3点 在实现动态绑定时,不能直接采用类对象,而一定要采用指针或者引用。因为采用类对象传值方式,有临时基类对象的产生,而采用指针,则是通过指针来访问外部的派生类对象的VPTR来达到访问派生类虚函数的结果。
可以通过程序来验证一下,把上面代码修改一下:
#include <iostream>
using namespace std;
class base  
{  
public:  
    void bfun(){}  
    virtual void vfun1(){}  
    virtual int vfun2(){return 0;}
	base(){
		cout<<"base constructor"<<endl;
	}
private:  
    int a;  
};  
class derived : public base  
{  
public:  
    void dfun(){}  
    virtual void vfun1(){}
	int  vfun2(){cout<<"fun2()"<<endl;return 0;}
    virtual int vfun3(){return 0;} 
	derived(){
		cout<<"derived constructor"<<endl;
	}
private:  
    int b;  
};
void test(base *pBase)  
 {  
    pBase->vfun2();  
	//pBase.vfun2();
 }  
 int main(int argc, char* argv[])  
 {  
  derived td;  
  test(&td);  
  return 0;  
 }



执行结果为:
base constructor
derived constructor
fun2()
Press any key to continue
如果把指针改为对象:
void test(base pBase)  
 {  
  //pBase->vfun2();  
   pBase.vfun2();
 }  
 int main(int argc, char* argv[])  
 {  
  derived td;  
  test(td);  
  return 0;  
 }
运行结果为:base constructor
derived constructor如果把指针改为引用:
void test(base &pBase)    
 {    
    //pBase->vfun2();    
    pBase.vfun2();  
 }    
 int main(int argc, char* argv[])    
 {    
  derived td;    
  test(td);    
  return 0;    
 } 
运行结果为:
base constructor
derived constructor
derived vfun2()
Press any key to continue//用引用也可以实现多态

比较一下两个程序就可以看出使用指针才能实现多态(因为调用test()生成了临时基类所以实际this 还是base 类了(临时base 对象))。另外说几点注意事项:1.
 base b1=(base)new derived;  //cannot convert from 'class derived *' to 'class base'
 b1.vfun2();
事实上c++ 里只能用指针接收new 出来的东西: 如  int a[]=new int[4]; 就会报错 cannot convert from 'int *' to 'int []',而int *a=new int[4];是可以的。
要想基类指向子类只能用指针 这点跟java不一样,java 里可以 base b1=(base) new derived;(java把指针隐藏了)
base *b=new derived;//这里new 返回的是指针,也可以 b=&d (其中derived d;)  
  b->vfun2();//同样b->vfun3();'vfun3' : is not a member of 'base'会出现与java中 基类引用调用基类没有函数一样错误。
这个可以输出:base constructor
derived constructor
fun2()3.
 derived td;  
  td.vfun2(); 
这个可以但不是基类指针指向子类。所以多态本质是基类指针指向子类,并且在运行时确定实际对象类型。(函数调用里用对象的话生成临时基类也不是多态了)。关于vtable 具体怎么实现看下一节讲解。















                
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值