C++对象内存模型

来源:互联网 发布:项目管理 java开源 编辑:程序博客网 时间:2024/05/21 22:47

C语言中数据和处理数据的函数是分别定义,各个处理数据的函数实现相应各种算法。但是C++提供了类,可以实现较好的数据和处理数据的算法的封装性,这种封装性相比较C语言而言会带来一些成本,这主要受制于是C++对象为支持相应特性而实现的内存模型。
C++中分别有static和nonstatic两种数据成员,有static、nonstatic、virtual三种成员函数。对于一个类的对象的内存布局方式主要经过一下演变。

1、内存布局方式

简单对象模型

每个object是一系列的slots,每个slot保存的都是一个指针,指向每个member(包括所有的数据成员、成员函数)。所有的member按照声明的方式排列。

      / +-------+   -----> [non-static data member]      | +-------+   -----> [static data member]slots=| +-------+   -----> [static member function]      | +-------+   -----> [non-static member function]      \ +-------+   -----> [virtual member function]

这种简单模型中,可以避免不同member类型需要不同存储空间带来的问题,同时一个object的大小很容易计算出来。这种最简单的模型在成员指针的实现中有深入的应用。

表格内存模型

将data member全部放在一个表格中,包含data member的实际存储;member function放在另一个表格中,每一项为一个函数指针指向相应的成员函数。对于每个object的内存布局中,只包含两个指针分别指向这两个表格。
这种表格内存模型在实现C++的虚函数机制上是一个有效方案,目前的C++实现中也是基于这种方案。

2、C++对象内存布局

根据上述两种基本模型进行演变和发展,C++对象的内存布局的基本方式是:
- object包含所有non-static数据成员,static 数据成员和所有函数成员都不放在object对象内
- static数据成员放置在全局数据区
- non-virtual函数成员(分为static和non-static)与普通non-member函数相同,其中
- static直接调用
- non-static需要使用object的this指针调用
- virtual函数成员通过虚函数表格(virtual function table, vtbl)与虚函数表指针(virtual function pointer, vptr)来实现,bptr一般放置在object的首地址起始处

虚函数表指针(vptr)只有在类存在虚函数时才会由编译器添加,此时也会由编译器进行constructor、destructor和copy assignmeh运算符函数的设置。同时,存在虚函数的类生成的虚函数表,都会在第一项设置与该类相关联的type info信息,用来完成C++的RTTI特性。
上述内存布局的主要优点是空间利用率和存取时间的效率都较优。主要缺点就是,如果应用程序代码不曾改变,但是object的non-static数据成员有修改,那么由于object直接存储了这些数据成员,因此必须重新编译。
一个C++对象的内存布局大小主要由以下因素决定:
1. 类中定义的nonstatic数据成员的总和
2. 由于alignment需求编译器进行的padding
3. 为了支持virtual特性添加的额外负担(vptr和vbptr)

另外,C++对象为了支持多态特性,只能使用引用或者指针来完成,实际对象进行调用时是无法完成多态的。有三种实现方式:
1. 继承类指针隐式转换为基类指针
2. 基类指针调用虚函数
3. dynamic_cast和typeid运算符完成
下面分别针对不同使用情形下C++类对象的内存布局进行讨论。

无多态性的类

对于没有多态性质的类,可以是一个单独的类,或者private继承等为了实现“has-a”、“is implemented in terms of”,这种情况下,数据成员与函数成员与C语言的struct没有区别,数据成员访问相同,函数成员的调用也相同,效率也相同,因此,从这种意义上看C++没有为类的实现而引入额外的负担。

class A{int step = 0;};class B : private A{public:    void mf1();    void mf2();private:    int a;    int b;    //...其他数据成员};

B的对象的布局如下:
这里写图片描述
上述对象的布局与C语言的struct没有区别,但是支持了继承。同一个access level内的数据成员依照声明顺序排布。如果去掉继承的类A,B除了少了A中step这个数据成员无其他改变,也就是单独类的内存布局与C中的struct完全相同。

多态性的单继承

对于需要实现多态的单继承,基类需要定义多态的虚函数,此时就会引入虚函数表和虚函数指针。这里的原则是基类虚函数表包含了第一项为type info信息,后续为所有虚函数的地址。子类的虚函数表里每一项首先是从基类虚函数表拷贝过去,第一项type info信息换为子类的type info,对于后续的每一个虚函数,如果子类重新定义了,就用新定义的虚函数地址替换虚函数表中相应的表项,否则就不变。同时,如果子类还自定义了新的虚函数,那么会在当前虚函数表的基础上往后增加新的表项,填入新定义的虚函数地址。

struct B{    int v1;    virtual ~B(){cout << "B destructor\n";}    virtual void f(){cout << "f in B\n";}};struct D:public B{    int v2;    virtual void fd(){cout << "fd in D\n";}    void f(){cout << "f in D\n";}};///测试代码typedef void(*FUN)(); //函数指针用来调用成员函数f、fdD d;int * vptr = (int*)(*(int*)(&d));((FUN)*(vptr+2))();((FUN)*(vptr+3))();

上述对象d的内存布局如下:
这里写图片描述
上述测试代码首先定义的是函数指针类型,用来进行强制类型转换,这个函数指针类型要与成员函数f、fd匹配,那样强制转换后才能调用相应的函数。
获取对象地址即为编译器添加的虚表指针的地址,由于运行环境为32位,与int所占大小相同,通过将其转换为int类型的指针,通过索引2和3就可以找到对应虚函数表里的表项,也就是相应函数的地址。最后转换之后调用,得到的结果如下:

f in D
fd in D
B destructor

多态性的多继承

多继承与前面的单继承基本实现方式一样:父类会产生虚函数表,父类object会在首部插入vptr虚表指针。多个继承的父类在子类对象的内存布局中按照继承语句的顺序进行排布,这样每个父类的vptr都会被子类接收。紧接着子类将自己的数据成员添加到后续内存空间。
由于多继承可能出现菱形继承体系,也就是某个继承类进行多继承时多个父类共享同一个祖先,那么此时在继承类中会存在这个祖先的多份subobject,这样会造成二义性,因此提供了虚拟继承。

非菱形多继承

这种继承方式与多态单继承相似,仅仅是基类的数目不止一个,内存布局如下图所示,其余访问存取都类似,唯一不同的是。将父类的虚函数表和subobject的vptr拷贝后,替换规则是所有子类有同名的重新定义的虚函数都会替换为子类新函数的地址,这也表明了每个基类的虚拟析构函数表项会替换为当前子类的析构函数地址。另一个重要的不同时,子类新定义的虚函数的地址仅仅是在第一个父类的虚函数表尾部插入,第二个到后面的父类的虚函数表不会插入。

struct B1{    int v1;    virtual ~B1(){cout << "B1 destructor\n";}    virtual void f(){cout << "f in B1\n";}};struct B2{    int v2;    virtual ~B2(){cout << "B2 destructor\n";}    virtual void f(){cout << "f in B2\n";}};struct D2 : public B1, public B2{    int vd;    virtual void fd(){cout << "fd in D2\n";}    void f (){cout << "f in D2\n";}};///测试代码typedef void(*FUN)();D2 d;int * vptr = (int*)(*(int*)(&d));((FUN)*(vptr+2))(); //通过B1子对象的vptr调用虚函数f((FUN)*(vptr+3))(); //调用B1子对象的vptr虚函数fdvptr = (int*)*((int*)((char*)(&d)+sizeof(B1)));((FUN)*(vptr+2))(); //通过B2子对象的vptr调用虚函数f

上述测试执行的结果如下:

f in D2
fd in D2
f in D2
B2 destructor
B1 destructor

对象d的内存布局如下:
这里写图片描述
由于B1是声明在前,故对象排布在前。首先通过B1子对象的vptr找到虚函数表调用f和fd,f被D2中新定义的函数地址覆盖,fd为新定义的虚函数,故插入到表尾。
由于前面为B1,要找到B2的vptr,需要通过B1大小进行偏移,这里首先将对象d的首地址转为(char*)(&d)类型,然后用sizeof(B1)来获取偏移的字节数得到B2子对象的首地址,也就是B2的vptr。然后重新转换为int*之后就和前面操作类似了。这里调用第三项发现执行的函数f为D2中新定义的f,故B2中的f地址也被替换了。而且,第四项调用时会”Segment fault”,说明B2子对象的虚函数表的表尾没有插入D2定义的新的虚函数fd。

菱形虚拟多继承

菱形继承下,如果不用虚拟继承机制,那么将会按照前面的多继承方式进行,这样多个父类subobject内部都会含有公共的那个祖先subobject,这样使用子类引用公共祖先的成员时会产生歧义:

d2.memParent; //是B1还是B2中的memParent?//表面上的解决方法d2.B1::memParent; //调用B1 subobject内的成员d2.B2::memParent; //调用B2 subobject内的成员

上述解决方法虽然可以暂时消除歧义,但是并没有从本质上消除这个问题,逻辑上也很不方便程序的理解。而且,空间上也存在保存两份公共祖先成员的浪费。因此,虚拟继承就是为了解决这个问题而诞生的。
首先从虚拟单继承的机制来看看是如何排布的。

虚拟单继承

struct B{    int a;    virtual ~B(){cout << "B destructor\n";}    virtual void f(){cout << "f in B\n";}    virtual void nf(){cout << "nf in B\n";}};struct B1 : public virtual B{    int v1;    virtual ~B1(){cout << "B1 destructor\n";}    virtual void f(){cout << "f in B1\n";}    virtual void fb1(){cout << "fb1 in B1\n";}};//测试B1 b1;b1.v1 = 10; b1.a = 1;cout << "Size of b1 : " << sizeof(b1) << endl;int * pvptr = (int*)(&b1);cout << "B1::v1 = " << *(pvptr+1) << ",B::a = " << *(pvptr+3) << endl;int * vp = (int*)(*pvptr);((FUN)*(vp+2))();((FUN)*(vp+3))();vp = (int*)(*(pvptr + 2));((FUN)*(vp+2))();((FUN)*(vp+3))();

运行结果如下:

Size of b1 : 16
B1::v1 = 10, B::a = 1
f in B1 //通过B1的vptr调用
fb1 in B1 //通过B1的vptr调用
f in B1 //通过B的vptr调用
nf in B //通过B的vptr调用
B1 destructor
B destructor

从上述结果可以推断,虚拟继承如果子类有新定义的虚函数,子类会重新建立新的vptr和虚函数表,并且排布在对象首部,父类subobject放在了最末尾,包括父类vptr及其虚函数表。同时父类虚函数表拷贝到子对象之后依然会进行覆盖同名子类虚函数表项。

菱形虚拟继承

针对上述虚拟继承结果,虚拟继承的父类会放在子对象的末尾,其余按照多继承的方式依声明顺序依次排布,子类如果定义了新的虚函数,会在第一个父类的虚函数表末尾进行扩展。

struct B{    int a;    virtual ~B(){cout << "B destructor\n";}    virtual void f(){cout << "f in B\n";}    virtual void nf(){cout << "nf in B\n";}};struct B1 : public virtual B{    int v1;    virtual ~B1(){cout << "B1 destructor\n";}    virtual void f(){cout << "f in B1\n";}    virtual void fb1(){cout << "fb1 in B1\n";}};struct B2 : public virtual B{    int v2;    virtual ~B2(){cout << "B2 destructor\n";}    virtual void f(){cout << "f in B2\n";}};struct D2: public B1, public B2{    virtual void fd2(){cout << "fd2 in D2\n";}    virtual void f(){cout << "f in D2\n";}};//测试D2 d2;d2.v1 = 10; d2.v2 = 20; d2.a = 1;cout << "Size of d2 : " << sizeof(d2) << endl;int * pvptr = (int*)(&d2);cout << "B1::v1 = " << *(pvptr+1) << endl;cout << "B2::v2 = " << *(pvptr+3) << endl;cout << "B::a = " << *(pvptr+5) << endl;((FUN)*((int*)(*pvptr) + 2))();((FUN)*((int*)(*pvptr) + 3))();((FUN)*((int*)(*pvptr) + 4))();int * b2 = pvptr + 2;((FUN)*((int*)(*b2) +2))();b2 = pvptr + 4;((FUN)*((int*)(*b2) +2))();((FUN)*((int*)(*b2) +3))();

输出为:

Size of d2 : 24
B1::v1 = 10
B2::v2 = 20
B::a = 1
f in D2 //通过B1的vptr调用,B1中的f被替换
fb1 in B1 //通过B1的vptr调用,B1中的fb1没有被替换
fd in D2 //通过B1的vptr调用,D中的fd被添加到第一个基类虚函数表
f in D2 //通过B2 的vptr调用,B2中的f被替换
f in D2 //通过B的vptr调用,B中的f被替换
nf in B //通过B的vptr调用,D中没重新定义,没替换B中的nf

经过上述不同情况的分析和实例验证,可以对C++的内存布局有一个比较清晰的认识。特别是与C语言的实现相比在哪些地方引入了额外的负担,这对于进行C++编码时可以进行多方面的参考。


说明:上述所有代码均在ubuntu12.04 32bit系统上,C++编译器为g++4.6,不同编译器可能会有实现上的差别,同时如果为64位系统,对指针的强制转换也需要更正,特此说明。

0 0
原创粉丝点击