c++虚函数的实现以及在类中的内存分布

   c++为了兼容c保留了struct类型,但是c++中的struct和c有明显的区别,c++中的struct可以继承,可以有成员函数,但是在c中却不行,在c++中struc和class更相似(还是有一些区别的,这里不再叙述),c中struct的内存分布很简单,那么c++中的class(struct)是怎么样的呢?
     首先没有虚函数的类其内存布局和c的struct没有什么根本的区别,其实例效率和c是一样的。当有虚函数时对象会有一个特殊的指针指向虚函数表(vpt),在最初的cfront(c++的第一个编译器)实现中,虚函数表指针(vpt)储存在对象的底部。这保持了与C结构布局的兼容性。但是,在C++中加入了多重继承和虚基类后,许多实现开始将vptr置于对象的顶部。在多重继承环境中,如果可以通过指向成员的指针调用虚函数,使用这个方案更加效率。但是,它破坏了C++对象与c结构的互操作性。目前,许多实现都将vptr置于对象的顶部。

 

class B{                                 class D1: public B{     
public:                                  public:   
 virtual void f()                            virtual void g();                  
 virtual void g();        
 virtual void h()                        private:  
private:                                     int y;   
 int i;                                  }
}


 

    

 

    这里的RTTI时c++的运行时类型识别信息,我们知道只有带有虚函数的类才会生成虚函数表,因此动态类型强制转换只用于多态类型,在进行动态类型转换时只要取虚函数表中的第-1个元素得到type_info类对象判断其真正的类型在进行操作(vptr实际上向后移动了一个位置,编译器取虚函数的时候vptr[0]是第一个虚函数指针,vptr[-1]就能取到指向type_info的指针,一般我们不会主动去取type_info指针).

     接下来看一个复杂的:

class B{
public:
    virtual void f()
    virtual void g()
    virtual void h()
private:
    int x;
}

cass D1{
public:
    void ff();
    virtual void f()
private:
    int y;  
}

class D3 : public B, public D1{
public:
    virtual void f();
    void hh()
private:
    int w;
}
public:
    virtual void f()
    virtual void g()
    virtual void h()
private:
    int x;
}

cass D1{
public:
    void ff();
    virtual void f()
private:
    int y;  
}

class D3 : public B, public D1{
public:
    virtual void f();
    void hh()
private:
    int w;
}

 

 

     考虑下面的例子:

B* pb= new B
pb->X=0;
D3* pd3= new D3
pd3->ff();//ff在D1中
pb->X=0;
D3* pd3= new D3
pd3->ff();//ff在D1中

 

 

     这是一个有效调用。但是,这里有一个问题。成员函数D1:ff()需要的参数是指向Dl的(this)指针,而不是指向D3的指针。D3类对象的地址与B类对象的地址相同一它们开始于相同的地址。但是,D3内的D1地址是D3(或者B)的地址加上D3到D1的偏移量。成员函数D1:ff()应该接收正确的this指针(指向D1对象的指针)。在编译时,已知D3内部Dl1的偏移量,所以可以在编译时很方便地处理这个问题,不会引入任何运行时开销。我们将D3内部D1的偏移量称为 offset D1。

 

 

     因此,pd3->ff()变为:((DI*)(((char*)pd3)+ offset D1 ))->ff();编译器只需将 offset D1加入到pd3中的地址上,然后将其强制转换为D1·。但是,在将 offset DI1与pd3相加之前,必须将pd3当作char指针,而不是D3指针,以确保正确进行指针运算。因此,pd3被强制转换为char*,然后加上偏移量,最后,将所得地址转换为D1*并调用ff(),这些都在编译时完成,没有运行时开销。

 

    继续看:

D3* pd3 = new D3;
D1* pd1 = new D3;//pd1必须指向D3中的D1部分
B*  pb  = new D3;
//3个指针都指向D3对象,这没问题,因为D3同时从B和D1派生。
pd3->f();//由于动态绑定,调用D3::f()
pd1->f();//还是调用D3::f(),但是pd1指向D3类对象中D1部分的开始位置。
pb->f();//D3::f()
D1* pd1 = new D3;//pd1必须指向D3中的D1部分
B*  pb  = new D3;
//3个指针都指向D3对象,这没问题,因为D3同时从B和D1派生。
pd3->f();//由于动态绑定,调用D3::f()
pd1->f();//还是调用D3::f(),但是pd1指向D3类对象中D1部分的开始位置。
pb->f();//D3::f()
    这里没有什么新内容。在进入D3::f()的入口时,this指针必须指向D3类对象的开始位置(与B类对象开始的位置相同),而不是D1类对象的开始位置。但是,上页的代码中,pd1指向D3类对象内部D1类对象的开始位置。如果编译器不进行其他的额外操作,D3::f()的this指针将接收D1类对象的地址。因此,必须设法调整地址。但问题是,在编译时任何对象内部D1的偏移量都是未知的,因为Dl可能会出现在不同的层次中。在这些层次中,每一个终派对象( complete object)内D1的偏移量都不同。所以,迫使编译器在运行时计算对象内部D1的偏移量。注意,只有调用虚函数,才需要在运行时计算偏移量,而调用非虚函数则不必这样,因为调用非虚函数时,已在编译时完成了相关偏移量计算。与前面的讨论相同,我们将D3类对象内部D1的偏移量称为 offset_D1。在调用虚函数时,必须知道对象内部D1的偏移量,所以,将 offset_D1放在虚函数表中非常合理。如上图是新的虚函数表,每个入口都有两个成员。第一个入成员是函数的地址,第二个入口成员是对象的偏移量,新的虚函数表是一个内含 vtb1_entry结构的数组。
struct vtb1_entry{
    void (*function)();
    int offset;
}
    void (*function)();
    int offset;
}
①这是在D3类对象内部被实际调用的函数,因为D3覆盖了B::()。因此D3的虚函数表包含了D3::()的地址。B和D3的地址相同,因此在调用D3:f0之前,无需加上任何偏移量。所以,偏移量是0
②在D3类对象内部,B::g()未被覆盖。因此,D3内g0的虚函数表入口是B::g()的地址。由于B和D3的地址相同,所以不需要调整this指针。所以,存储的偏移量是0
③与②相同,因为在D3中并未覆盖B::h()。
④用指向D3类对象的D1类型的指针调用f()时,被调用的函数仍然是D3::f(),因为D3覆盖了B::f()。需要注意的是,D1类型的指针指向的是D3内部的D1类对象(参见上面的pd1),然而,在进入D3::f()中时,this指针必须指向D3的开始位置,而不是D1的开始位置。D1类对象的地址是D3类对象的地址与D3类对象开始位置到D1类对象开始位置的偏移量的代数和。这个偏移量就是 offset_D1,也就是到D3类对象内部D1部分开始位置的偏移量。因此,为了获得D3的开始位置,需要加上- offset D1。这就是储存在(D3内部D1的)虚函数表中第二个位置的内容。
     储存在新虚函数表中的偏移量与对象的地址相加,该对象的地址被用来当作this指针。在这个例子中,必须从D3(或者B)内部D1的地址中减去 offset_D1,才能获得D3对象的地址。所以,要将- offset D1置于虚函数表中。这样修改后,“pd1->f():”应该是:(*(pdl->vptr[1]. function)((D1*)((char*) pd1 +  pd1->vptr[1]. offset)))
这里,(pd1->vptr[1]. function)给出了虚函数表中函数的地址,而剩下的部分是操控地址,以获得对象的开始位置。在这个例子中,偏移量是负值。
      这种方案增加了虚函数表的大小,而且,每次调用虚函数都要通过偏移量计算机制计算偏移量,即使地址是正确的(即,表格中储存的偏移量是0)也是如此。这浪费了内存(和CPU时间)。在多数情况下,并不需要调整偏移量。但是,虚函数表的每个位置仍然要储存偏移量。如果能避免这种浪费,再好不过了。
    有些编译器使用另一种方案,即 thunk模型来解决这些问题。这里使用带函数地址的旧虚函数表(每个函数只有一个储存位置),不会增加虚函数表的大小。如果不需要调整this指针,储存在虚函数表中的地址就是指向被执行函数的指针(像以前一样)。如果需
要调整this指针的偏移量,储存在虚函数中的地址则指向某段进行调整的代码( thunk),并调用合适的函数。现在,执行实际函数分成两个步骤。该方案的优点是,只有那些需要调整this指针偏移量的虚函数调用,才会有额外的开销,同时还能保持较小的虚函数表大
小。这两种方案,在计算偏移量方面的开销相同。在执行pd1->f()时,编译器像对待任何虚函数那样进行常规地操作:它在虚函数表中找到该函数,然后调用,然而这不是最终的结果,这个函数只是对this指针做出了调整,通过调整this指针的位置进而调用到实际的函数。如下图:
     
从以上的例子可以发现,在多重继承层次中:
(a)访问非虚成员函数和数据成员非常简单,不会导致任何额外的运行时开销
(b)但是,根据编译器的实现不同,在调用虚函数时,有些调用可能导致增加虚函数表大小的额外开销,或者只有那些需要调整this指针的调用才会发生额外的运行开销,但不会增加虚函数表的大小。
      所有这些问题都不能作为反对多重继承的论据。的确,这些问题涉及开销,但是,多重继承减少了编码的负担,同时也让问题的解决方案更加简洁,这当然要付出一些代价.
     总之,与n个基类的多重继承层次相关的额外虚函数表有n-1个。派生类和最左边的非虚基类共享同一个虚函数表。因此,带有2个基类的多重继承层次,有1个(2-1=1)基类的虚函数表和1个派生类的虚函数表(最左边的基类与派生类共享该虚函数表),总共有2个虚函数表。
 注意
     虚函数不仅会改变对象的内存布局,对编译器是否生成一些我们平时认为会生成的默认构造函数也会有影响: 对于某些不带虚函数的类,如果在类中未声明默认构造函数,实际上就根本不必生成默认构造函数。因为,即使该类有数据成员,在这个构造函数内部也不需要进行任何工作。大多数编译器不会生成这样的构造函数。用户总是相信生成了一个默认构造函数,但实际上没有。还有编译器是否应该生成默认复制构造函数并调用它?并非如此,因为如果类表现了逐位复制( bitwise copy)语义。我们只需要将Obj1中的位复制到Obj2中。不必为此调用复制构造函数,因此,编译器也不必合成一个复制构造函数,因为两者结构没有区别(只是位的集合)。对于大多数处理器,这可以通过一个单独的内存移动指令完成
     一般而言,逐位复制语义在下面这些情况时不适用:
(a)类包含内嵌对象(即,将另一个类的对象作为数据成员),这些内嵌对象中包含复制构造函数(编译器生成或程序员定义)。
(b)类从一个或多个包含复制构造函数(程序员定义或者编译器生成)的基类派生。
(c)类声明了虚函数。
(d)当类从虚基类继承时(与虚基类是否存在复制构造函数无关)。
 
拓展文章

   一个C++多继承带来的游戏开发陷阱

 

    

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值