c++继承中的内存布局.docx
- 文档编号:6928868
- 上传时间:2023-01-12
- 格式:DOCX
- 页数:28
- 大小:34.51KB
c++继承中的内存布局.docx
《c++继承中的内存布局.docx》由会员分享,可在线阅读,更多相关《c++继承中的内存布局.docx(28页珍藏版)》请在冰豆网上搜索。
c++继承中的内存布局
c++继承中的内存布局收藏
今天在网上看到了一篇写得非常好的文章,是有关c++类继承内存布局的。
看了之后获益良多,现在转在我自己的博客里面,作为以后复习之用。
——谈VC++对象模型
(美)简.格雷
程化译
译者前言
一个C++程序员,想要进一步提升技术水平的话,应该多了解一些语言的语意细节。
对于使用VC++的程序员来说,还应该了解一些VC++对于C++的诠释。
InsidetheC++ObjectModel虽然是一本好书,然而,书的篇幅多一些,又和具体的VC++关系小一些。
因此,从篇幅和内容来看,译者认为本文是深入理解C++对象模型比较好的一个出发点。
这篇文章以前看到时就觉得很好,旧文重读,感觉理解得更多一些了,于是产生了翻译出来,与大家共享的想法。
虽然文章不长,但时间有限,又若干次在翻译时打盹睡着,拖拖拉拉用了小一个月。
一方面因本人水平所限,另一方面因翻译时经常打盹,错误之处恐怕不少,欢迎大家批评指正。
本文原文出处为MSDN。
如果你安装了MSDN,可以搜索到C++UndertheHood。
否则也可在网站上找到。
1前言
了解你所使用的编程语言究竟是如何实现的,对于C++程序员可能特别有意义。
首先,它可以去除我们对于所使用语言的神秘感,使我们不至于对于编译器干的活感到完全不可思议;尤其重要的是,它使我们在Debug和使用语言高级特性的时候,有更多的把握。
当需要提高代码效率的时候,这些知识也能够很好地帮助我们。
本文着重回答这样一些问题:
1*类如何布局?
2*成员变量如何访问?
3*成员函数如何访问?
4*所谓的“调整块”(adjusterthunk)是怎么回事?
5*使用如下机制时,开销如何:
*单继承、多重继承、虚继承
*虚函数调用
*强制转换到基类,或者强制转换到虚基类
*异常处理
首先,我们顺次考察C兼容的结构(struct)的布局,单继承,多重继承,以及虚继承;
接着,我们讲成员变量和成员函数的访问,当然,这里面包含虚函数的情况;
再接下来,我们考察构造函数,析构函数,以及特殊的赋值操作符成员函数是如何工作的,数组是如何动态构造和销毁的;
最后,简单地介绍对异常处理的支持。
对每个语言特性,我们将简要介绍该特性背后的动机,该特性自身的语意(当然,本文决不是“C++入门”,大家对此要有充分认识),以及该特性在微软的VC++中是如何实现的。
这里要注意区分抽象的C++语言语意与其特定实现。
微软之外的其他C++厂商可能提供一个完全不同的实现,我们偶尔也会将VC++的实现与其他实现进行比较。
2类布局
本节讨论不同的继承方式造成的不同内存布局。
2.1C结构(struct)
由于C++基于C,所以C++也“基本上”兼容C。
特别地,C++规范在“结构”上使用了和C相同的,简单的内存布局原则:
成员变量按其被声明的顺序排列,按具体实现所规定的对齐原则在内存地址上对齐。
所有的C/C++厂商都保证他们的C/C++编译器对于有效的C结构采用完全相同的布局。
这里,A是一个简单的C结构,其成员布局和对齐方式都一目了然
viewplaincopytoclipboardprint?
structA{
charc;
inti;
};
structA{
charc;
inti;
};
译者注:
从上图可见,A在内存中占有8个字节,按照声明成员的顺序,前4个字节包含一个字符(实际占用1个字节,3个字节空着,补对齐),后4个字节包含一个整数。
A的指针就指向字符开始字节处。
2.2有C++特征的C结构
当然了,C++不是复杂的C,C++本质上是面向对象的语言:
包含继承、封装,以及多态。
原始的C结构经过改造,成了面向对象世界的基石——类。
除了成员变量外,C++类还可以封装成员函数和其他东西。
然而,有趣的是,除非为了实现虚函数和虚继承引入的隐藏成员变量外,C++类实例的大小完全取决于一个类及其基类的成员变量!
成员函数基本上不影响类实例的大小。
这里提供的B是一个C结构,然而,该结构有一些C++特征:
控制成员可见性的“public/protected/private”关键字、成员函数、静态成员,以及嵌套的类型声明。
虽然看着琳琅满目,实际上,只有成员变量才占用类实例的空间。
要注意的是,C++标准委员会不限制由“public/protected/private”关键字分开的各段在实现时的先后顺序,因此,不同的编译器实现的内存布局可能并不相同。
(在VC++中,成员变量总是按照声明时的顺序排列)。
viewplaincopytoclipboardprint?
structB{
public:
intbm1;
protected:
intbm2;
private:
intbm3;
staticintbsm;
voidbf();
staticvoidbsf();
typedefvoid*bpv;
structN{};
};
structB{
public:
intbm1;
protected:
intbm2;
private:
intbm3;
staticintbsm;
voidbf();
staticvoidbsf();
typedefvoid*bpv;
structN{};
};
译者注:
B中,为何staticintbsm不占用内存空间?
因为它是静态成员,该数据存放在程序的数据段中,不在类实例中。
2.3单继承
C++提供继承的目的是在不同的类型之间提取共性。
比如,科学家对物种进行分类,从而有种、属、纲等说法。
有了这种层次结构,我们才可能将某些具备特定性质的东西归入到最合适的分类层次上,如“怀孩子的是哺乳动物”。
由于这些属性可以被子类继承,所以,我们只要知道“鲸鱼、人”是哺乳动物,就可以方便地指出“鲸鱼、人都可以怀孩子”。
那些特例,如鸭嘴兽(生蛋的哺乳动物),则要求我们对缺省的属性或行为进行覆盖。
C++中的继承语法很简单,在子类后加上“:
base”就可以了。
下面的D继承自基类C。
viewplaincopytoclipboardprint?
structC{
intc1;
voidcf();
};
structC{
intc1;
voidcf();
};
viewplaincopytoclipboardprint?
structD:
C{
intd1;
voiddf();
};
structD:
C{
intd1;
voiddf();
};
既然派生类要保留基类的所有属性和行为,自然地,每个派生类的实例都包含了一份完整的基类实例数据。
在D中,并不是说基类C的数据一定要放在D的数据之前,只不过这样放的话,能够保证D中的C对象地址,恰好是D对象地址的第一个字节。
这种安排之下,有了派生类D的指针,要获得基类C的指针,就不必要计算偏移量了。
几乎所有知名的C++厂商都采用这种内存安排(基类成员在前)。
在单继承类层次下,每一个新的派生类都简单地把自己的成员变量添加到基类的成员变量之后。
看看上图,C对象指针和D对象指针指向同一地址。
2.4多重继承
大多数情况下,其实单继承就足够了。
但是,C++为了我们的方便,还提供了多重继承。
比如,我们有一个组织模型,其中有经理类(分任务),工人类(干活)。
那么,对于一线经理类,即既要从上级经理那里领取任务干活,又要向下级工人分任务的角色来说,如何在类层次中表达呢?
单继承在此就有点力不胜任。
我们可以安排经理类先继承工人类,一线经理类再继承经理类,但这种层次结构错误地让经理类继承了工人类的属性和行为。
反之亦然。
当然,一线经理类也可以仅仅从一个类(经理类或工人类)继承,或者一个都不继承,重新声明一个或两个接口,但这样的实现弊处太多:
多态不可能了;未能重用现有的接口;最严重的是,当接口变化时,必须多处维护。
最合理的情况似乎是一线经理从两个地方继承属性和行为——经理类、工人类。
C++就允许用多重继承来解决这样的问题:
viewplaincopytoclipboardprint?
structManager...{...};
structWorker...{...};
structMiddleManager:
Manager,Worker{...};
structManager...{...};
structWorker...{...};
structMiddleManager:
Manager,Worker{...};
这样的继承将造成怎样的类布局呢?
下面我们还是用“字母类”来举例:
viewplaincopytoclipboardprint?
structE{
inte1;
voidef();
};
structE{
inte1;
voidef();
};
viewplaincopytoclipboardprint?
structF:
C,E{
intf1;
voidff();
};
structF:
C,E{
intf1;
voidff();
};
结构F从C和E多重继承得来。
与单继承相同的是,F实例拷贝了每个基类的所有数据。
与单继承不同的是,在多重继承下,内嵌的两个基类的对象指针不可能全都与派生类对象指针相同:
viewplaincopytoclipboardprint?
Ff;
//(void*)&f==(void*)(C*)&f;
//(void*)&f<(void*)(E*)&f;
Ff;
//(void*)&f==(void*)(C*)&f;
//(void*)&f<(void*)(E*)&f;
译者注:
上面那行说明C对象指针与F对象指针相同,下面那行说明E对象指针与F对象指针不同。
观察类布局,可以看到F中内嵌的E对象,其指针与F指针并不相同。
正如后文讨论强制转化和成员函数时指出的,这个偏移量会造成少量的调用开销。
具体的编译器实现可以自由地选择内嵌基类和派生类的布局。
VC++按照基类的声明顺序先排列基类实例数据,最后才排列派生类数据。
当然,派生类数据本身也是按照声明顺序布局的(本规则并非一成不变,我们会看到,当一些基类有虚函数而另一些基类没有时,内存布局并非如此)。
2.5虚继承
回到我们讨论的一线经理类例子。
让我们考虑这种情况:
如果经理类和工人类都继承自“雇员类”,将会发生什么?
viewplaincopytoclipboardprint?
structEmployee{...};
structManager:
Employee{...};
structWorker:
Employee{...};
structMiddleManager:
Manager,Worker{...};
structEmployee{...};
structManager:
Employee{...};
structWorker:
Employee{...};
structMiddleManager:
Manager,Worker{...};
如果经理类和工人类都继承自雇员类,很自然地,它们每个类都会从雇员类获得一份数据拷贝。
如果不作特殊处理,一线经理类的实例将含有两个雇员类实例,它们分别来自两个雇员基类。
如果雇员类成员变量不多,问题不严重;如果成员变量众多,则那份多余的拷贝将造成实例生成时的严重开销。
更糟的是,这两份不同的雇员实例可能分别被修改,造成数据的不一致。
因此,我们需要让经理类和工人类进行特殊的声明,说明它们愿意共享一份雇员基类实例数据。
很不幸,在C++中,这种“共享继承”被称为“虚继承”,把问题搞得似乎很抽象。
虚继承的语法很简单,在指定基类时加上virtual关键字即可。
viewplaincopytoclipboardprint?
structEmployee{...};
structManager:
virtualEmployee{...};
structWorker:
virtualEmployee{...};
structMiddleManager:
Manager,Worker{...};
structEmployee{...};
structManager:
virtualEmployee{...};
structWorker:
virtualEmployee{...};
structMiddleManager:
Manager,Worker{...};
使用虚继承,比起单继承和多重继承有更大的实现开销、调用开销。
回忆一下,在单继承和多重继承的情况下,内嵌的基类实例地址比起派生类实例地址来,要么地址相同(单继承,以及多重继承的最靠左基类),要么地址相差一个固定偏移量(多重继承的非最靠左基类)。
然而,当虚继承时,一般说来,派生类地址和其虚基类地址之间的偏移量是不固定的,因为如果这个派生类又被进一步继承的话,最终派生类会把共享的虚基类实例数据放到一个与上一层派生类不同的偏移量处。
请看下例:
viewplaincopytoclipboardprint?
structG:
virtualC{
intg1;
voidgf();
};
structG:
virtualC{
intg1;
voidgf();
};
译者注:
GdGvbptrG(InG,thedisplacementofG’svirtualbasepointertoG)意思是:
在G中,G对象的指针与G的虚基类表指针之间的偏移量,在此可见为0,因为G对象内存布局第一项就是虚基类表指针;GdGvbptrC(InG,thedisplacementofG’svirtualbasepointertoC)意思是:
在G中,C对象的指针与G的虚基类表指针之间的偏移量,在此可见为8。
viewplaincopytoclipboardprint?
structH:
virtualC{
inth1;
voidhf();
};
structH:
virtualC{
inth1;
voidhf();
};
viewplaincopytoclipboardprint?
structI:
G,H{
inti1;
void_if();
};
structI:
G,H{
inti1;
void_if();
};暂时不追究vbptr成员变量从何而来。
从上面这些图可以直观地看到,在G对象中,内嵌的C基类对象的数据紧跟在G的数据之后,在H对象中,内嵌的C基类对象的数据也紧跟在H的数据之后。
但是,在I对象中,内存布局就并非如此了。
VC++实现的内存布局中,G对象实例中G对象和C对象之间的偏移,不同于I对象实例中G对象和C对象之间的偏移。
当使用指针访问虚基类成员变量时,由于指针可以是指向派生类实例的基类指针,所以,编译器不能根据声明的指针类型计算偏移,而必须找到另一种间接的方法,从派生类指针计算虚基类的位置。
在VC++中,对每个继承自虚基类的类实例,将增加一个隐藏的“虚基类表指针”(vbptr)成员变量,从而达到间接计算虚基类位置的目的。
该变量指向一个全类共享的偏移量表,表中项目记录了对于该类而言,“虚基类表指针”与虚基类之间的偏移量。
其它的实现方式中,有一种是在派生类中使用指针成员变量。
这些指针成员变量指向派生类的虚基类,每个虚基类一个指针。
这种方式的优点是:
获取虚基类地址时,所用代码比较少。
然而,编译器优化代码时通常都可以采取措施避免重复计算虚基类地址。
况且,这种实现方式还有一个大弊端:
从多个虚基类派生时,类实例将占用更多的内存空间;获取虚基类的虚基类的地址时,需要多次使用指针,从而效率较低等等。
在VC++中,G拥有一个隐藏的“虚基类表指针”成员,指向一个虚基类表,该表的第二项是GdGvbptrC。
(在G中,虚基类对象C的地址与G的“虚基类表指针”之间的偏移量(当对于所有的派生类来说偏移量不变时,省略“d”前的前缀))。
比如,在32位平台上,GdGvptrC是8个字节。
同样,在I实例中的G对象实例也有“虚基类表指针”,不过该指针指向一个适用于“G处于I之中”的虚基类表,表中一项为IdGvbptrC,值为20。
观察前面的G、H和I,我们可以得到如下关于VC++虚继承下内存布局的结论:
1首先排列非虚继承的基类实例;
2有虚基类时,为每个基类增加一个隐藏的vbptr,除非已经从非虚继承的类那里继承了一个vbptr;
3排列派生类的新数据成员;
4在实例最后,排列每个虚基类的一个实例。
该布局安排使得虚基类的位置随着派生类的不同而“浮动不定”,但是,非虚基类因此也就凑在一起,彼此的偏移量固定不变。
3成员变量
介绍了类布局之后,我们接着考虑对不同的继承方式,访问成员变量的开销究竟如何。
没有继承:
没有任何继承关系时,访问成员变量和C语言的情况完全一样:
从指向对象的指针,考虑一定的偏移量即可。
viewplaincopytoclipboardprint?
C*pc;
pc->c1;//*(pc+dCc1);
C*pc;
pc->c1;//*(pc+dCc1);
译者注:
pc是指向C的指针。
a.访问C的成员变量c1,只需要在pc上加上固定的偏移量dCc1(在C中,C指针地址与其c1成员变量之间的偏移量值),再获取该指针的内容即可。
单继承:
由于派生类实例与其基类实例之间的偏移量是常数0,所以,可以直接利用基类指针和基类成员之间的偏移量关系,如此计算得以简化。
viewplaincopytoclipboardprint?
D*pd;
pd->c1;//*(pd+dDC+dCc1);//*(pd+dDc1);
pd->d1;//*(pd+dDd1);
D*pd;
pd->c1;//*(pd+dDC+dCc1);//*(pd+dDc1);
pd->d1;//*(pd+dDd1);
译者注:
D从C单继承,pd为指向D的指针。
a.当访问基类成员c1时,计算步骤本来应该为“pd+dDC+dCc1”,即为先计算D对象和C对象之间的偏移,再在此基础上加上C对象指针与成员变量c1之间的偏移量。
然而,由于dDC恒定为0,所以直接计算C对象地址与c1之间的偏移就可以了。
b.当访问派生类成员d1时,直接计算偏移量。
多重继承:
虽然派生类与某个基类之间的偏移量可能不为0,然而,该偏移量总是一个常数。
只要是个常数,访问成员变量,计算成员变量偏移时的计算就可以被简化。
可见即使对于多重继承来说,访问成员变量开销仍然不大。
viewplaincopytoclipboardprint?
F*pf;
pf->c1;//*(pf+dFC+dCc1);//*(pf+dFc1);
pf->e1;//*(pf+dFE+dEe1);//*(pf+dFe1);
pf->f1;//*(pf+dFf1);
F*pf;
pf->c1;//*(pf+dFC+dCc1);//*(pf+dFc1);
pf->e1;//*(pf+dFE+dEe1);//*(pf+dFe1);
pf->f1;//*(pf+dFf1);
译者注:
F继承自C和E,pf是指向F对象的指针。
a.访问C类成员c1时,F对象与内嵌C对象的相对偏移为0,可以直接计算F和c1的偏移;
b.访问E类成员e1时,F对象与内嵌E对象的相对偏移是一个常数,F和e1之间的偏移计算也可以被简化;
c.访问F自己的成员f1时,直接计算偏移量。
虚继承:
当类有虚基类时,访问非虚基类的成员仍然是计算固定偏移量的问题。
然而,访问虚基类的成员变量,开销就增大了,因为必须经过如下步骤才能获得成员变量的地址:
1.获取“虚基类表指针”;
2.获取虚基类表中某一表项的内容;
3.把内容中指出的偏移量加到“虚基类表指针”的地址上。
然而,事情并非永远如此。
正如下面访问I对象的c1成员那样,如果不是通过指针访问,而是直接通过对象实例,则派生类的布局可以在编译期间静态获得,偏移量也可以在编译时计算,因此也就不必要根据虚基类表的表项来间接计算了。
viewplaincopytoclipboardprint?
I*pi;
pi->c1;//*(pi+dIGvbptr+(*(pi+dIGvbptr))[1]+dCc1);
pi->g1;//*(pi+dIG+dGg1);//*(pi+dIg1);
pi->h1;//*(pi+dIH+dHh1);//*(pi+dIh1);
pi->i1;//*(pi+dIi1);
Ii;
i.c1;//*(&i+IdIC+dCc1);//*(&i+IdIc1);
I*pi;
pi->c1;//*(pi+dIGvbptr+(*(pi+dIGvbptr))[1]+dCc1);
pi->g1;//*(pi+dIG+dGg1);//*(pi+dIg1);
pi->h1;//*(pi+dIH+dHh1);//*(pi+dIh1);
pi->i1;//*(pi+dIi1);
Ii;
i.c1;//*(&i+IdIC+dCc1);//*(&i+IdIc1);
译者注:
I继承自G和H,G和H的虚基类是C,pi是指向I对象的指针。
a.访问虚基类C的成员c1时,dIGvbptr是“在I中,I对象指针与G的“虚基类表指针”之间的偏移”,*(pi+dIGvbptr)是虚基类表的开始地址,*(pi+dIGvbptr)[1]是虚基类表的第二项的内容(在I对象中,G对象的“虚基类表指针”与虚基类之间的偏移),dCc1是C对象指针与成员变量c1之间的偏移;
b.访问非虚基类G的成员g1时,直接计算偏移量;
c.访问非虚基类H的成员h1时
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- c+ 继承 中的 内存 布局