C++对象模型Data语意学分析、虚继承底层实现机制


1. Class 的大小


  • 一个空 class 的大小为 1 字节,因为编译器需要安插进去一个 char,使得这个 class 对象得以在内存中被配置独一无二的地址。虽然空 class 大小为 1 字节,但是假如某个类 A 继承该空 class,计算类 A 的大小时会优化父类空 class 的大小,如类 A 为空,sizeof(A) = 1,不空,则为类A真实数据大小。
  • 我们通常说某个 class 内部有 virtual function 尽管没有数据成员,它的大小仍为为 4 字节,因为它有一个 vptr,指向一个 vtbl ,所以按指针算大小就是 4 字节。实际上,就算没有 virtual function,如果某个类虚继承别的类,编译器仍生成有 vtbl ,因为它的 vtbl 还要用来保存 virtual base class object(虚基类子对象)的 offset 。
  • 对于 virtual 继承,所有的 virtual base class subobject 在派生类中只保留一份,所以计算派生类对象时,按一份计算即可。
  • 计算 class 的大小时,还要考虑内存对齐,标准就是一个 bus 的长度,32 位为 4 字节。不过要注意,派生类数据成员并不会填补基类花在内存对齐上的部分。

下面举出无数例子来验证结论 :)

case 1 :class A 为空时的普通继承、普通继承有成员、虚继承、有虚函数

class A { };
class B : public A { };
class C : public A { char ch; };
class X : virtual public A { };
class Y { virtual void fun(); };

std::cout<<sizeof(A)<<std::endl;    // 1
std::cout<<sizeof(B)<<std::endl;    // 1
std::cout<<sizeof(C)<<std::endl;    // 1
std::cout<<sizeof(X)<<std::endl;    // 4
std::cout<<sizeof(Y)<<std::endl;    // 4
输出结果分析:A 为空,除了它自己,别人都会优化掉它。虚继承和含有虚函数这两种情况都有 vptr,所指东西不同而已。 当然 vptr 在既有虚继承,又有虚函数的类中正负分别指向 virtual functions 和 virtual base class subobject 的 offset,只有一个 vptr。(Mircosoft 编译器有两个虚函数表 vtbl ,有两个 vptr;G++实际上只有一个 vtbl,一个vptr,并且 virtual funcions 和 virtual base class subobject 的 offset 按顺序存放)。

g++与虚继承

g++编译器生成的C++类实例,虚函数与虚基类地址偏移值共用一个虚表(vtable)。类实例的开始处即为指向所属类的虚指针(vptr)。实际上,一个类与它的若干祖先类(父类、祖父类、...)组成部分共用一个虚表,但各自使用的虚表部分依次相接、不相重叠。

g++编译下,一个类实例的虚指针指向该类虚表中的第一个虚函数的地址。如果该类没有虚函数(或者虚函数都写入了祖先类的虚表,覆盖了祖先类的对应虚函数),因而该类自身虚表中没有虚函数需要填入,但该类有虚继承的祖先类,则仍然必须要访问虚表中的虚基类地址偏移值。这种情况下,该类仍然需要有虚表,该类实例的虚指针指向类虚表中一个值为0的条目。

该类其它的虚函数的地址依次填在虚表中第一个虚函数条目之后(内存地址自低向高方向)。虚表中第一个虚函数条目之前(内存地址自高向低方向),依次填入了typeinfo(用于RTTI)、虚指针到整个对象开始处的偏移值、虚基类地址偏移值。因此,如果一个类虚继承了两个类,那么对于32位程序,虚继承的左父类地址偏移值位于vptr-0x0c,虚继承的右父类地址偏移值位于vptr-0x10.

一个类的祖先类有复杂的虚继承关系,则该类的各个虚基类偏移值在虚表中的存储顺序尊重自该类到祖先的深度优先遍历次序。

Microsoft Visual C++与虚继承

Microsoft Visual C++与g++不同,把类的虚函数与虚基类地址偏移值分别放入了两个虚表中,前者称为虚函数表vftbl,后者称虚基类表vbtbl。因此一个类实例可能有两个虚指针分别指向类的虚函数表与虚基类表,这两个虚指针分别称为虚函数表指针vftbl与虚基类表指针vbtbl。当然,类实例也可以只有一个虚指针,或者没有虚指针。虚指针总是放在类实例的数据成员之前,且虚函数表指针总是在虚基类表指针之前。因而,对于某个类实例来说,如果它有虚基类指针,那么虚基类指针可能在类实例的0字节偏移处,也可能在类实例的4字节偏移处(对于32位程序来说),这给类成员函数指针的实现带来了很大麻烦。

一个类的虚基类指针指向的虚基类表的首个条目,该条目的值是虚基类指针到整个类实例内存首地址的偏移值。即obj.vbtbl - &obj。虚基类第2、第3、... 个条目依次为该类的最左虚继承父类、次左虚继承父类、...的内存地址相对于虚基类表指针自身地址(即 &vbtbl)的偏移值。

如果一个类同时有虚继承的父类与祖父类,则虚祖父类放在虚父类前面。


case 2:class A 含有一个 char 时,还是上述种类:

class A { char ch; };
class B : public A { };

class C : public A { char ch; };
class C_ : public A { char ch; int val; };   // C_

class X : virtual public A { };

std::cout<<sizeof(A)<<std::endl;    // 1
std::cout<<sizeof(B)<<std::endl;    // 1
std::cout<<sizeof(C)<<std::endl;    // 2
std::cout<<sizeof(C_)<<std::endl;   // 8   // 1 + 3(对齐数值)+ sizeof(int) = 8 
std::cout<<sizeof(X)<<std::endl;    // 8   // 1 + 3(对齐数值)+ sizeof(vptr) = 8
输出结果分析:注意内存对齐就行了。

case 3:特殊一点的
class A { char ch; };
class B : virtual public A { char ch; };

std::cout<<sizeof(A)<<std::endl;    // 1
std::cout<<sizeof(B)<<std::endl;    // 8
输出结果分析:8 这个结果或许有点惊讶,并没有直接 char + char = 2,然后再对齐至 4 字节。因为实际上这是错误的。
派生类不会填补基类花在内存对齐上的无用空间!
看一下《深度探索C++对象模型》原话,其中 Concrete1 为基类,Concreae2 为派生类 :
也就是说发生 Concrete1 subobject 的复制操作时,就会破坏 Concrete2 members。即基类子对象复制到派生类,会覆盖掉派生类后来增加的成员。


case 4:(重点)关于虚基类有2个例子,这是第一个:

class A { };
class B : virtual public A { };
class C : virtual public A { };
class X : public B, public C { };

std::cout<<sizeof(A)<<std::endl;    // 1
std::cout<<sizeof(B)<<std::endl;    // 4
std::cout<<sizeof(C)<<std::endl;    // 4
std::cout<<sizeof(X)<<std::endl;    // 8
输出结果分析:A 为空,大小为 1。B 和 C 内部都有指向有关 virtual base class subobject 的 vptr,所以大小都为 4。但是虽然它们的 vptr 指向的内容最终都汇聚于 A,但是就像虚表一样,每个类都有自己的虚表,是不共享的。所以,尽管虚基类在派生类 X 中只保留一根,这两个 vptr 确是都要算上的。由于虚基类为空,被优化掉,派生类自己也为空,所以 sizeof(X) 的大小用伪码表示就是 sizeof(B.vptr) + sizeof(C.vptr) !

case 5:同样关于虚基类

class A { char ch; };
class B : virtual public A { };
class C : virtual public A { };
class X : public B, public C { };

std::cout<<sizeof(A)<<std::endl;
std::cout<<sizeof(B)<<std::endl;
std::cout<<sizeof(C)<<std::endl;
std::cout<<sizeof(X)<<std::endl;
这次就能更好体现虚基类只有一份的说法了。B 和 C 大小都是各自 vptr 大小 + 针对 char 的内存对齐 = 8。但是针对类 X,它的大小是仅有一份的A的大小,以及 B 和 C 的vptr的大小,所以是 4 + 4 + 4 = 12。

case 6:注意这种全为空

class A { };
class B : public A {}; 
class C : public A {}; 

class D : public B, public C {};

std::cout<<sizeof(D)<<std::endl;    // 2
这个大小为2哦,编译器至少要确定是从两路继承来的,所以至少需要两个字节。


2. Data member 的绑定

extern float x;  
  
class Point3d  
{  
    public:  
        point3d();  
        //问题:被传回和被设定的x是哪一个x呢?  
        float X() const  
        {  
            return x;  
        }  
          
    private:  
        float x, y, z;//Point3d::X()将返回内部的x。  
};  
在早期(2.0之前)C++的编译器上,将会指向global x object, 因为编译器还没有看到数据成员,就开始编译函数。这导致C++的两种防御性程序设计风格:
  1. 把所有的数据成员放在 class 声明的开头,而不是下面,以保证正确的绑定。这就是所谓的以数据为中心。
  2. 把所有的内联函数,不管大小都放在 class 的声明之外。
但是对于成员函数的参数列表同上面情况相反:
typedef int length;  
   
 class Point3d  
 {  
     public:  
         //length 将被决议为global  
         //_val将被决议为Point3d::_val  
         void mumbel(length val)  
         {      
             _val = val;  
         }  
           
         length mumble()  
         {  
             return _val;  
         }  
  
     private:  
         //导致 _val = val; return _val;不合法      
         typedef float length;  
         length _val;      
 };  
对于参数列表,除非typedef嵌套类型声明放在 class 的起始处,否则会认为不合法。


3. Data Member 的存取

Point3d origin *pt = &origion;
origin.x = 0.0;
pt->x = 0.0;
这两种方式差异?
对于 static 数据成员:一般情况,完全相同。原因是:每一个 static 成员只有一个实体,存放在成语的 data segment 之中, 成员并不在 class 对象之中。
对于 non-static 数据成员:一般情况,完全相同。原因是:
  • px->x = 0.0,事实上是经由一个”implicit class onject“(由 this 指针表达式)完成,即 this->x = pt.x = 0.0
  • origin.x = 0.0,事实上欲对一个 nonstatic data member 进行存取操作,编译器需要把 class object 的起始地址加上 data member 的偏移量。那么此表达式实际上等于;&origin + (&Point3d::_y - 1)
注意其中的 -1 操作。指向 data member 指针,其 offset 值总是被加上 1,这样可以使编译系统区分出”一个指向数据成员的指针,用以指出第一个成员” 和 “一个指向数据成员的指针,没有指出任何数据成员” 这两种情况。这个 offset 下面会说到。

上面说了一般情况完全相同,但是对于虚拟继承,则:


4. 继承与 Data Member


继承:
一个派生类所表现出来的东西,是其自己成员加上其基类成员的综合。

继承易犯的 错误
  1. 重复设计一些相同的操作函数
  2. 把一个 class 分解为两层或者更多层,有可能会为了“表现 class 体系之抽象画” 而膨胀所需空间。因为内存对齐
多态的缺点, 空间和存取时间的负担
  1. 导入一个 vtbl 和 一个 vptr 的负担
  2. constructor 和 destructor 要负责初始化和抹消 vtbl 和 vptr 的负担。
vptr 放置在 class object 的哪里最好?
  1. 放置在尾部保留 C struct 的对象布局,C++初期,这种方式被许多人采用。
  2. 放在最前端。对于 “在多重继承之下,通过指向 class members 的指针调用 virtual function" 会带来一些帮助。当然代价就是丧失 C 语言的兼容性。

多重继承:
多重继承的复杂度在于派生类和其上一个基类乃至于上上一个基类之间的非自然关系。

如图一个继承体系:
下列操作并不需要编译器去调停或修改地址,它很自然地可以发生,而且提供了最佳执行效率。
Point3d p3d;
Point2d *p = &p3d;           
多重继承的问题主要发生于derived class object 和其第二或后续的base class object之间的转换:
Vertex3d  v3d;
Vertex    *pv;
Point2d   *p2d;
Point3d   *p3d;

pv = &v3d;
// 需要这样的内部转换:
pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

// 下列转换只需要简单地拷贝地址就行了
p2d = &v3d;    
p3d = &v3d;
C++ Standard 并未要求Vertex3d中的base classes Point3d和Vetex有特定的排列次序。原始的cfont编译器是根据声明次序来排序它们。目前各编译器仍然是以此方式完成多重base classes的布局(但如果加上虚拟继承,就不一样了)

虚拟继承
多重继承的一个语意上的副作用就是,它必须支持某种形式的“shared subobject继承”。

对于多重启程,我们只需要一份基类 subobject 就可以了,所以引入虚拟继承。

虚拟继承的布局策略是先安排好派生类的不变部分(某些不是虚拟继承来的父类也是不变的,要按照顺序安排),然后再建立共享部分(即 virtual base class subobject)。

cfront 的布局是引入把 virtual base class subobject (即共享部分)的偏移量加入 vtbl,vtvl 索引为负值时,可以访问到 offset。
继承链如图:


布局模型如下:

当然 Mirsoft 和 G++ 的布局在前面已经说过了,大同小异。

5.指向 Data Members 的指针

 class Point
        {
            public:
                virtual ~Point();
                static Point origion;
                float x,y,z;
        };
如果vptr放在对象的尾端,则三个坐标值在对象布局中的offset分别是0,4,8。如果vptr放在对象的起头,则三个坐标值在对象布局中的offset分别是4,8,12.然而你若去取data members的地址, 传回的值总是多1, 就是1,5,9,或5,9,13。

打印 offset 可以通过面这句代码:
 std::cout<<&point::x<<std::endl;

编译器为了区分 “没有指向任何数据成员的指针“(其实就是指向了该对象地址,但指向不是第一个数据成员) 和 一个指向 “第一个数据成员的指针”,给所有指针偏移量都加上了 1。因此,无论编译器或者使用者都必须记住,在真正使用该值以指出一个 member 之前,请先减掉 1。

如何区分一个 “没有指向任何数据成员的指针” 和 一个指向 ”第一个数据成员“ 的指针:
float point::*p1 = 0;
float point::*p2 = &point::x;

if(p1 == p2){
    std::cout<<"same"<<std::endl;
相等即可,只不过不可能相等,除非减掉 1。

取一个非静态数据成员的地址,将会得到它在类中的偏移量(offset),取一个绑定与真正类对象身上的数据成员地址,将会得到该数据成员在内存中的真正地址。
二者区别,即 &point::x 和 point p; &p.x; 的区别而已。


            虚继承问题
          






              







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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值