前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C++:28 --- C++内存布局(上)

C++:28 --- C++内存布局(上)

作者头像
用户3479834
发布2021-02-03 12:26:51
9920
发布2021-02-03 12:26:51
举报
文章被收录于专栏:游戏开发司机游戏开发司机

了解你所使用的编程语言究竟是如何实现的,对于C++程序员可能特别有意义。

首先,我们顺次考察C兼容的结构(struct)的布局,单继承,多重继承,以及虚继承; 接着,我们讲成员变量和成员函数的访问,当然,这里面包含虚函数的情况; 再接下来,我们考察构造函数,析构函数,以及特殊的赋值操作符成员函数是如何工作的,数组是如何动态构造和销毁的;

操作系统为一个C++程序的运行所分配的内存分为四个区域,如图4.3 程序在内存中的区域所示:

(1)代码区(Code area):存放程序代码,即程序中各个函数的代码块;

(2)全局数据区(Data area):存放全局数据和静态数据;分配该区时内存全部清零。

(3)栈区(Stack area):存放局部变量,如函数中的变量等;分配栈区时内存不处理。

(4)堆区(Heap area):存放与指针相关的动态数据。分配堆区时内存不处理。

1 类布局

本节讨论不同的继承方式造成的不同内存布局。

由于C++基于C,所以C++也“基本上”兼容C。特别地,C++规范在“结构”上使用了和C相同的,简单的内存布局原则:成员变量按其被声明的顺序排列,按具体实现所规定的对齐原则在内存地址上对齐。 所有的C/C++厂商都保证他们的C/C++编译器对于有效的C结构采用完全相同的布局。这里,A是一个简单的C结构,其成员布局和对齐方式都一目了然

代码语言:javascript
复制
struct A
{
    char c;
    int i;
};
代码语言:javascript
复制

从上图可见,A在内存中占有8个字节,按照声明成员的顺序,前4个字节包含一个字符(实际占用1个字节,3个字节空着,补对齐),后4个字节包含一个整数。A的指针就指向字符开始字节处。

有C++特征的C结构

当然了,C++不是复杂的C,C++本质上是面向对象的语言:包 含 继承、封装,以及多态 。原始的C结构经过改造,成了面向对象世界的基石——类。除了成员变量外,C++类还可以封装成员函数和其他东西。然而,有趣的是,除非 为了实现虚函数和虚继承引入的隐藏成员变量外,C++类实例的大小完全取决于一个类及其基类的成员变量!成员函数基本上不影响类实例的大小。

这里提供的B是一个C结构,然而,该结构有一些C++特征:控制成员可见性的“public/protected/private”关键字、成员函数、静态成员,以及嵌套的类型声明。虽然看着琳琅满目,实际上,只有成员变量才占用类实例的空间 。要注意的是,C++标准委员会不限制由“public/protected/private”关键字分开的各段在实现时的先后顺序,因此,不同的编译器实现的内存布局可能并不相同。( 在VC++中,成员变量总是按照声明时的顺序排列)。

代码语言:javascript
复制
 struct B
 {
 public:
     int bm1;
 
 protected:
     int bm2;
 
 private:
     int bm3;
     static int bsm;
     void bf();
     static void bsf();
     typedef void* bpv;
     struct N { };
};

B中,为何static int bsm不占用内存空间?因为它是静态成员,该数据存放在程序的数据段 中,不在类实例中。

2 成员变量

没有任何继承关系时,访问成员变量和C语言的情况完全一样:从指向对象的指针,考虑一定的偏移量即可。

代码语言:javascript
复制
struct C
{
    int c1;
    void cf();
};
struct D : C
{
    int d1;
    void df();
};
代码语言:javascript
复制
C* pc;  
pc->c1; // *(pc + dCc1);

pc是指向C的指针。 a. 访问C的成员变量c1,只需要在pc上加上固定的偏移量dCc1(在C中,C指针地址与其c1成员变量之间的偏移量值),再获取该指针的内容即可。

单继承: 由于派生类实例与其基类实例之间的偏移量是常数0,所以,可以直接利用基类指针和基类成员之间的偏移量关系,如此计算得以简化。

代码语言:javascript
复制
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,然而,该偏移量总是一个常数。只要是个常数,访问成员变量,计算成员变量偏移时的计算就可以被简化。可见即使对于多重继承来说,访问成员变量开销仍然不大。

代码语言:javascript
复制
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成员那样,如果不是通过指针访问,而是直接通过对象实例,则派生类的布局可以在编译期间静态获得,偏移量也可以在编译时计算,因此也就不必要根据虚基类表的表项来间接计算了。

代码语言:javascript
复制
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);  
I i;  
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时,直接计算偏移量; d. 访问自身成员i1时,直接使用偏移量; e. 当声明了一个对象实例,用点“.”操作符访问虚基类成员c1时,由于编译时就完全知道对象的布局情况,所以可以直接计算偏移量。

当访问类继承层次中,多层虚基类的成员变量时,情况又如何呢?比如,访问虚基类的虚基类的成员变量时?一些实现方式为:保存一个指向直接虚基类的指针,然后就可以从直接虚基类找到它的虚基类,逐级上推。VC++优化了这个过程。VC++在虚基类表中增加了一些额外的项,这些项保存了从派生类到其各层虚基类的偏移量。

3 强制转化

如果没有虚基类的问题,将一个指针强制转化为另一个类型的指针代价并不高昂。如果在要求转化的两个指针之间有“基类-派生类”关系,编译器只需要简单地在两者之间加上或者减去一个偏移量即可(并且该量还往往为0)。

代码语言:javascript
复制
F* pf;  
(C*)pf; // (C*)(pf ? pf + dFC : 0); // (C*)pf;   
(E*)pf; // (E*)(pf ? pf + dFE : 0);

C和E是F的基类,将F的指针pf转化为C*或E*,只需要将pf加上一个相应的偏移量。转化为C类型指针C*时,不需要计算,因为F和C之间的偏移量为 0。转化为E类型指针E*时,必须在指针上加一个非0的偏移常量dFE。C ++规范要求NULL指针在强制转化后依然为NULL ,因此在做强制转化需要的运算之前,VC++会检查指针是否为NULL。当然,这个检查只有当指针被显示或者隐式转化为相关类型指针时才进行;当在派生类对象中调用基类的方法,从而派生类指针在后台被转化为一个基类的Const “this” 指针时,这个检查就不需要进行了,因为在此时,该指针一定不为NULL。

正如你猜想的,当继承关系中存在虚基类时,强制转化的开销会比较大。具体说来,和访问虚基类成员变量的开销相当。

代码语言:javascript
复制
I* pi;  
(G*)pi; // (G*)pi;   
(H*)pi; // (H*)(pi ? pi + dIH : 0);   
(C*)pi; // (C*)(pi ? (pi+dIGvbptr + (*(pi+dIGvbptr))[1]) : 0);

pi是指向I对象的指针,G,H是I的基类,C是G,H的虚基类。 a. 强制转化pi为G*时,由于G*和I*的地址相同,不需要计算; b. 强制转化pi为H*时,只需要考虑一个常量偏移; c. 强制转化pi为C*时,所作的计算和访问虚基类成员变量的开销相同,首先得到G的虚基类表指针,再从虚基类表的第二项中取出G到虚基类C的偏移量,最后根据pi、虚基类表偏移和虚基类C与虚基类表指针之间的偏移计算出C*。

一般说来,当从派生类中访问虚基类成员时,应该先强制转化派生类指针为虚基类指针,然后一直使用虚基类指针来访问虚基类成员变量。这样做,可以避免每次都要计算虚基类地址的开销。 见下例。

/* before: */ ... pi->c1 ... pi->c1 ... /* faster: */ C* pc = pi; ... pc->c1 ... pc->c1 ... 前者一直使用派生类指针pi,故每次访问c1都有计算虚基类地址的较大开销;后者先将pi转化为虚基类指针pc,故后续调用可以省去计算虚基类地址的开销。

4 成员函数

一个C++成员函数只是类范围内的又一个成员。X类每一个非静态的成员函数都会接受一个特殊的隐藏参数——this指针,类型为X* const。 该指针在后台初始化为指向成员函数工作于其上的对象。同样,在成员函数体内,成员变量的访问是通过在后台计算与this指针的偏移来进行。

代码语言:javascript
复制
struct P {  
   int p1;  
   void pf(); // new   
   virtual void pvf(); // new   
};

P有一个非虚成员函数pf(),以及一个虚成员函数pvf()。很明显,虚成员函数造成对象实例占用更多内存空间,因为虚成员函数需要虚函数表指针。这一点以后还会谈到。这里要特别指出的是,声明非虚成员函数不会造成任何对象实例的内存开销。现在,考虑P::pf()的定义。

代码语言:javascript
复制
void P::pf() { // void P::pf([P *const this])   
   ++p1;   // ++(this->p1);   
}

这里P:pf()接受了一个隐藏的this指针参数 ,对于每个成员函数调用,编译器都会自动加上这个参数。同时,注意成员变量访问也许比看起来要代价高昂一些,因为成员变量访问通过this指针进行,在有的继承层次下,this指针需要调整,所以访问的开销可能会比较大。然而,从另一方面来说,编译器通常会把this指针缓存到寄存器中,所以,成员变量访问的代价不会比访问局部变量的效率更差。 访问局部变量,需要到SP寄存器中得到栈指针,再加上局部变量与栈顶的偏移。在没有虚基类的情况下,如果编译器把this指针缓存到了寄存器中,访问成员变量的过程将与访问局部变量的开销相似。

5 覆盖成员函数

和成员变量一样,成员函数也会被继承。与成员变量不同的是,通过在派生类中重新定义基类函数,一个派生类可以覆盖,或者说替换掉基类的函数定义。覆盖是静态 (根据成员函数的静态类型在编译时决定)还是动态 (通过对象指针在运行时动态决定),依赖于成员函数是否被声明为“虚函数”。

Q从P继承了成员变量和成员函数。Q声明了pf(),覆盖了P::pf()。Q还声明了pvf(),覆盖了P::pvf()虚函数。Q还声明了新的非虚成员函数qf(),以及新的虚成员函数qvf()。

代码语言:javascript
复制
struct Q : P {  
   int q1;  
   void pf(); // overrides P::pf   
   void qf(); // new   
   void pvf(); // overrides P::pvf   
   virtual void qvf(); // new   
};

对于非虚 的成员函数来说,调用哪个成员函数是在编译 时,根据“->”操作符左边指针表达式的类型静态决定 的。特别地,即使ppq指向Q的实例,ppq->pf()仍然调用的是P::pf(),因为ppq被声明为“P*”。(注意,“->”操作符左边的指针类型决定隐藏的this参数的类型。)

代码语言:javascript
复制
P p; P* pp = &p; Q q; P* ppq = &q; Q* pq = &q;  
pp->pf(); // pp->P::pf(); // P::pf(pp);   
ppq->pf(); // ppq->P::pf(); // P::pf(ppq);   
pq->pf(); // pq->Q::pf(); // Q::pf((P*)pq); (错误!)   
pq->qf(); // pq->Q::qf(); // Q::qf(pq);

标记“错误”处,P*似应为Q*。因为pf非虚函数,而pq的类型为Q*,故应该调用到Q的pf函数上,从而该函数应该要求一个Q* const类型的this指针。

对于虚函数 调用来说,调用哪个成员函数在运行时 决定。不管“->”操作符左边的指针表达式的类型如何,调用的虚函数都是由指针实际指向的实例类型所决定 。比如,尽管ppq的类型是P*,当ppq指向Q的实例时,调用的仍然是Q::pvf()。

代码语言:javascript
复制
pp->pvf(); // pp->P::pvf(); // P::pvf(pp);   
ppq->pvf(); // ppq->Q::pvf(); // Q::pvf((Q*)ppq);   
pq->pvf(); // pq->Q::pvf(); // Q::pvf((P*)pq); (错误!)

标记“错误”处,P*似应为Q*。因为pvf是虚函数,pq本来就是Q*,又指向Q的实例,从哪个方面来看都不应该是P*。

为了实现这种机制,引入了隐藏的vfptr 成员变量。 一个vfptr被加入到类中(如果类中没有的话),该vfptr指向类的虚函数表(vftable)。类中每个虚函数在该类的虚函数表中都占据一项。每项保存一个对于该类适用的虚函数的地址。因此,调用虚函数的过程如下:取得实例的vfptr;通过vfptr得到虚函数表的一项;通过虚函数表该项的函数地址间接调用虚函数。 也就是说,在普通函数调用的参数传递、调用、返回指令开销外,虚函数调用还需要额外的开销。

回头再看看P和Q的内存布局,可以发现,VC++编译器把隐藏的vfptr成员变量放在P和Q实例的开始处。这就使虚函数的调用能够尽量快一些。实际上,VC++的实现方式是,保证任何有虚函数的类的第一项永远是vfptr。 这就可能要求在实例布局时,在基类前插入新的vfptr,或者要求在多重继承时,虽然在右边,然而有vfptr的基类放到左边没有vfptr的基类的前面(如下)。

代码语言:javascript
复制
class CA  
{   int a;};  
class CB  
{   int b;};  
class CL : public CB, public CA  
{   int c;};

对于CL类,它的内存布局是: int b; int a; int c; 但是,改造CA如下:

代码语言:javascript
复制
class CA  
{  
   int a;  
   virtual void seta( int _a ) { a = _a; }  
};

对于同样继承顺序的CL,内存布局是: vfptr; int a; int b; int c; 许多C++的实现会共享或者重用从基类继承来的vfptr。比如,Q并不会有一个额外的vfptr,指向一个专门存放新的虚函数qvf()的虚函数表。Qvf项只是简单地追加 到P的虚函数表的末尾。如此一来,单继承的代价就不算高昂。一旦一个实例有vfptr了,它就不需要更多的vfptr。新的派生类可以引入更多的虚函数,这些新的虚函数只是简单地在已存在的,“每类一个”的虚函数表的末尾追加新项。

6 特殊成员函数

构造函数和析构函数

正如我们所见,在构造和析构过程中,有时需要初始化一些隐藏的成员变量。最坏的情况下,一个构造函数要执行如下操作: 1 * 如果是“最终派生类”,初始化vbptr成员变量,调用虚基类的构造函数; 2 * 调用非虚基类的构造函数 3 * 调用成员变量的构造函数 4 * 初始化虚函数表成员变量 5 * 执行构造函数体中,程序所定义的其他初始化代码

(注意:一个“最终派生类”的实例,一定不是嵌套在其他派生类实例中的基类实例) 所以,如果你有一个包含虚函数的很深的继承层次,即使该继承层次由单继承构成,对象的构造可能也需要很多针对虚函数表的初始化。 反之,析构函数必须按照与构造时严格相反的顺序来“肢解”一个对象。 1 * 合成并初始化虚函数表成员变量 2 * 执行析构函数体中,程序定义的其他析构代码 3 * 调用成员变量的析构函数(按照相反的顺序) 4 * 调用直接非虚基类的析构函数(按照相反的顺序) 5 * 如果是“最终派生类”,调用虚基类的析构函数(按照相反顺序)

在VC++中,有虚基类的类的构造函数接受一个隐藏的“最终派生类标志”,标示虚基类是否需要初始化。对于析构函数,VC++采用“分层析构模型”,代码中加入一个隐藏的析构函数,该函数被用于析构包含虚基类的类(对于“最终派生类”实例而言);代码中再加入另一个析构函数,析构不包含虚基类的类。前一个析构函数调用后一个。

7 数组

堆上分配空间的数组使虚析构函数进一步复杂化。问题变复杂的原因有两个: 1、 堆上分配空间的数组,由于数组可大可小,所以,数组大小值应该和数组一起保存。因此,堆上分配空间的数组会分配额外的空间来存储数组元素的个数; 2、 当数组被删除时,数组中每个元素都要被正确地释放,即使当数组大小不确定时也必须成功完成该操作。然而,派生类可能比基类占用更多的内存空间,从而使正确释放比较困难。

代码语言:javascript
复制
struct WW : W { int w1; };  
pv = new W[m];  
delete [] pv; // delete m W's (sizeof(W) == sizeof(V))   
pv = new WW[n];  
delete [] pv; // delete n WW's (sizeof(WW) > sizeof(V))

WW从W继承,增加了一个成员变量,因此,WW占用的内存空间比W大。然而,不管指针pv指向W的数组还是WW的数组,delete[]都必须正确地释放WW或W对象占用的内存空间。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 游戏开发司机 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档