c++ 内存布局

来源:互联网 发布:seo在线培训哪家好 编辑:程序博客网 时间:2024/06/08 03:24

结构体和类的基础知识

结构体(c语言的)和类的区别

a. 结构体主要是c语言的特色 类是c++的基本机制
b. 结构体中的数据默认方式是public的,而类是private的
c. 结构体不提供继承机制,类提供继承机制,实现代码复用
d. 类可以实现多态,结构体不支持

以上几点在C++中只有b是成立的。

结构体的内存对齐

struct CStyle{    char c1;    int i;    char c2;};

这里写图片描述
从这个简单的例子简单的总结一下:
1. 结构体每个成员相对于结构体首地址的偏移量(offset)都是(这个)成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
2. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节(trailing padding)。
3. 对于结构体成员属性中包含结构体变量的复合型结构体,应当将其包含的子结构的成员拆出来看。

struct mystruct1{    int i;    char c;    double d;};struct mystruct2{    int c;    char a;    mystruct1 mst;};

上式cout << sizeof(mystruct2) << endl; 得到24。

4.在确定复合型结构体成员的偏移位置时则是将复合类型作为整体看待。

struct mystruct1{    char c1;    char c2;    double d;};struct mystruct2{    char c1;    char c2;    mystruct1 mst;};

上式cout << sizeof(mystruct2) << endl; 也是得到24,而不是16。

还有些关于对齐的注意点,大家可以查阅更多资料,这里不多说了。

其它一些知识

1.除了成员变量外,C++风格的还可以封装成员函数和其他东西。
2.除非 为了实现虚函数和虚继承引入的隐藏成员变量外,C++类实例的大小完全取决于一个类及其基类的成员变量!成员函数基本上不影响类实例的大小。
3.C++标准委员会不限制关键字“public/protected/private”分开的各段成员变量实现的先后顺序(VC++中,成员变量总是按照声明时的顺序排列)

struct CplusplusStyle{    public:        int m_i1;    protected:        int m_i2;    private:        int m_i3;        static int m_si;        void mf();        static void msf();        typedef void* mpv;        struct N{};};

上面的结构体 sizeof(CplusplusStyle) 为12个字节。 你会问为什么不是20个字节,或者不是16个字节吗? 思考一下。

类中虚函数问题

最简单的包含虚函数的类布局

X类的每一个非静态的成员函数都会接受一个特殊的隐藏参数,X* const this的指针。

struct P {     int p1;     void pf();      virtual void pvf(); };

这里写图片描述
    1.此处因为P中有虚成员函数,所以生成一个隐藏成员,虚函数表指针, 而声明非虚成员函数(存放在代码区,不占用对象内存)不会造成任何对象实例的内存开销。

虚函数表指针放做实例的第一个成员变量,是为了使虚函数调用能够尽量快一些。实际上,VC++的实现方式是,保证任何有虚函数的类的第一项永远是vfptr。

为了实现这个原则,我们会发现如下的一个情况:

class CA { int a;};class CB { int b;};class CL : public CB, public CA{ int c;};

内存布局:这里写图片描述

而当CA中有了虚函数时,为了保证子类和父类共享的虚函数指针放到实例第一个成员位置,内存布局变化了:

class CA{     int a;     virtual void seta( int _a ) { a = _a; }};

这里写图片描述

     2.编译器通常会把this指针缓存到寄存器中。访问局部变量,需要到SP寄存器中得到栈指针,再加上局部变量与栈顶的偏移。在没有虚继承的情况下,如果编译器把this指针缓存到寄存器中,访问成员变量的过程将与访问局部变量的开销类似。

如何根据地址偏移取出虚函数

这里写图片描述
对于这样一个类,取地址的操作见注释:
这里写图片描述
其实上类中的取地址操作可以有另一种写法:

    Base b;    typedef void(*Fun)(void);    Fun pFun = NULL;    //方式1    pFun = (Fun)*((int*)*(int*)&b + 1);    pFun();  //Base::g    //方式2    int** pVtab = (int**)&b;    pFun = (Fun)pVtab[0][0];       pFun(); //Base::f

因为可以这样直接通过虚函数表指针取到虚函数,故也带来了一些安全性问题:
I. 通过父类指针访问子类自己的虚函数,任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。
这里写图片描述

II. 如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数。
我们改造一下Base1如下:
这里写图片描述
这里写图片描述
对于private的g()函数,会发现可以通过这种方式访问到,破坏了其封装性。
这里写图片描述

类的各种继承关系下的布局

这一块将是本文的重点!!!

单一继承

struct Base{     int m_b1;     void m_bfun();}

这里写图片描述

struct Derived:Base{     int m_d1;     void m_dfun();}

这里写图片描述

之所以这样内存布局,并不是说基类的数据一定要放在子类之前,这样保证,有了派生类Derived的指针之后,要获得基类Base的指针就不需要计算地址偏移了。几乎所有的C++厂商,都是这样安排内存的,在单继承下,每一个新的派生类都简单的把自己的成员变量添加到基类的成员变量之后。

下面是发生覆盖的例子:

struct P {     int p1;     void pf();      virtual void pvf(); };struct Q : P {     int q1;     void pf(); // 隐藏     void qf(); // new     void pvf(); // overrides P::pvf     virtual void qvf(); // new};

这里写图片描述

覆盖是静态 (根据成员函数的静态类型在编译时决定)还是动态 (通过对象指针在运行时动态决定),依赖于成员函数是否被声明为“虚函数”。

int main(){    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);    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); (错误!)    return 1;}

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

a. 对于非虚的成员函数,调用那个函数是在编译时根据”->”操作符左边指针表达式的类型静态决定的。即使ppq 指向Q 的实例,ppq->pf()仍然调用的是P::pf(),此时出现了所谓的隐藏现象。【派生类的函数与基类的函数同名,并且参数也相同, 但是基类函数没有virtual 关键字。】【此也就解释了之所以虚函数需要一张虚函数表,是因为指针可能指向其基类或派生类内存空间,所以需要虚函数表定位。】

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

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

单一继承的复杂一点的例子:
这里写图片描述
main函数部分:
这里写图片描述
输出:
这里写图片描述

内存布局图:
这里写图片描述

1)虚函数表在最前面的位置。
2)成员变量根据其继承和声明顺序依次放在后面。
3)在单一的继承中,被overwrite 的虚函数在虚函数表中得到了更新。

多重继承

比如,有这样一个组织模型,有经理类(分任务),工人类(干活),对于一线经理,既要从上级经理那领取任务干活,又要给下级工人分配任务。为了实现多态和代码重用,这时候就需要同时继承这两个类。
这里写图片描述
内存布局:
这里写图片描述
多继承时,内嵌的两个基类的对象指针不可能全都与派生类对象的指针相同。注意main中的两行打印。

多重继承的成员变量偏移
将上例的main函数改成如下:
这里写图片描述
注意注释中dMMM表示类MM到M的偏移,其它同理。注释的内容说明了多重继承的成员变量的偏移关系。非虚的这种多重继承,直接简单偏移即可。
MM 继承自M 和W,mm 是指向MM 对象的指针。
a. 访问M 类成员m1 时,MM 对象与内嵌M 对象的相这里写代码片对偏移为0,可以接计算MM 和m1 的偏移;
b. 访问W 类成员w1 时,MM 对象与内嵌W 对象的相对偏移是一个常数,MM 和w1 之间的偏移计算也可以被简化;
c. 访问MM 自己的成员mm1 时,直接计算偏移量。

多重继承下的虚函数
1. 如果从多个有虚函数的基类继承,一个实例就有可能包含多个vfptr。

struct P {     int p1;     void pf();      virtual void pvf(); };struct R {     int r1;     virtual void pvf();      virtual void rvf(); };

这里写图片描述

struct S : P, R {     int s1;     void pvf(); // overrides P::pvf and R::pvf     void rvf(); // overrides R::rvf     void svf(); // new};

这里写图片描述

 S* ps = &s;((P*)ps)->pvf();  // (*(P*)ps)->P::vfptr[0])((S*)(P*)ps)((R*)ps)->pvf();  // (*(R*)ps)->R::vfptr[0])((S*)(R*)ps)ps->pvf();           

调用((P*)ps)->pvf()时,先到P 的虚函数表中取出第一项,然后把ps 转化为S*作为this 指针传递进去;
但是对于((R*)ps)->pvf() 我们会发现,虚函数必须把R*转化为S*,作为隐藏的this指针参数。但是R*和S*指向内存布局中的不同位置。所以在S 对R 虚函数表的拷贝中,pvf 函数对应的项,指向的是一个“调整块 ”的地址,该调整块使用必要的计算把R*转换为需要的S*。

在微软VC++实现中,对于有虚函数的多重继承,只有当派生类虚函数覆盖了多个基类的虚函数时,才使用调整块。当覆盖非最左边的基类虚函数时,一般不创建调整块,也不增加额外的函数项。(还有些更复杂的调整块以及this指针的问题,此处就不讲了。)

对于上面这种“调整块”不是很理解的可以简单理解如下:
这里写图片描述

这里写图片描述

Derive d;Base1 *b1 = &d;Base2 *b2 = &d;Base3 *b3 = &d;b1->f(); //Derive::f()b2->f(); //Derive::f()b3->f(); //Derive::f()b1->g(); //Base1::g()b2->g(); //Base2::g()b3->g(); //Base3::g()

2.而派生类中如果再有新的虚函数,附加到第一个基类的虚函数表后面:
这里写图片描述

这里写图片描述

多重继承的复杂一点的例子:
这里写图片描述
main函数部分:
这里写图片描述
输出:
这里写图片描述

内存布局图:
这里写图片描述

通过以上我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。
3) 内存布局中,其父类布局依次按声明顺序排列。
4) 每个父类的虚表中的f()函数都被overwrite 成了子类的f()。这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

重复继承与共享继承(钻石继承)

如上述场景中,经理类和工人类都继承自雇员类。

struct Employee { ... };struct Manager : Employee { ... };struct Worker : Employee { ... };struct MiddleManager : Manager, Worker { ... };

如果就简单这样实现多继承,一线经理类的实例中将含有两个雇员类实例,一是造成额外的开销,二是这两份不同的雇员实例可能分别被修改,造成数据的不一致。三是,子类在访问的时候,根本不能确定是访问的哪一份,编译器会告诉你访问对象不明确。
这里写图片描述
注意因为E的成员变量被拷贝了两次,故size = 20;

因此C++中出现了“共享继承”,被称为“虚继承”,语法如下:

struct Employee { ... };struct Manager : virtual Employee { ... };struct Worker : virtual Employee { ... };struct MiddleManager : Manager, Worker { ... };

这里写图片描述
此时共享e1之后为何size变大了?是因为在M和W中生成了两个隐藏成员虚基类表指针(后面会将)。 故16+8 = 24。

当然使用使用虚继承也有一些弊端:更大的实现开销、调用开销。因为其地址不再是简单的固定偏移量。

上述类结果的内存布局分析:
这里写图片描述
虚基类表,记录了虚基类表与自己和基类之间的地址偏移。 故MdMvbptrM=0,MdMvbptrE=8

在多继承的MM中:
这里写图片描述

发现在MM中和在M中M相对E的偏移量是不同的,当使用指针访问虚基类成员变量时,由于指针是可以指向派生类实例的基类指针,所以编译器不能根据声明的指针类型计算偏移。
这里写图片描述
都是M类型指针,此时根本无法判断E的偏移位置。

故需要使用一种间接的方法,从派生类指针计算虚基类的位置。对每个继承自虚基类的实例,将增加一个隐藏的“虚基类表指针”(vbptr)成员变量,从而达到间接计算虚基类位置的目的。该变量指向一个全类共享的偏移量表,表中项目记录了,对于该类而言,“虚基类表指针”与虚基类之间的偏移量。

虚继承的成员变量偏移
此时,访问非虚基类成员仍然是计算固定偏移量。然而访问虚基类成员时,就麻烦了:
I. 获取“虚基类表指针”;
II. 获取虚基类表中某一表项的内容;
III. 把内容中指出的偏移量加到“虚基类表指针”的地址上。
这里写图片描述

强制转化:
如果没有虚基类的问题,强制类型转换,仅仅加上或减去一个偏移量即可。如果有虚基类会开销比较大,和访问虚基类对象的开销相当。
所以为了提高效率,当在派生类中访问虚基类的成员时,先强制转换派生类指针为虚基类指针,然后一直使用虚基类指针访问虚基类成员函数。避免每次都要计算虚基类地址的开销。

然而如果不是通过指针访问,而是直接通过对象实例,则派生类的布局就可以在编译期间静态获得,偏移量也可以在编译时计算,因此也就不必要根据虚基类表的表项来间接计算了。
这里写图片描述

因为对象的布局在编译时就确定了,然而一个指针,可以指向其基类或者派生类,需要运行时确定其指向的对象布局。

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

虚继承下的虚函数

struct P {     int p1;     void pf();      virtual void pvf(); };struct T : virtual P {     int t1;     void pvf(); // overrides P::pvf     virtual void tvf(); // new};void T::pvf() {     ++p1; // ((P*)this)->p1++; // vbtable lookup!     ++t1; // this->t1++;}

内存布局如下:
这里写图片描述

1.在VC++中,为了避免获取虚函数表时,转换到虚基类P 的高昂代价,T 中的新虚函数通过一个新的虚函数表获取,而不是追加的基类尾端,从而带来了一个新的虚函数表指针。该指针放在T 实例的顶端。

2.虚析构函数与delete操作符:
A* p = new B(); //A是B的父类
如果析构函数不虚,那么必须这样删除才安全: delete (B*) p;
如果为虚,则可以 delete p; 可以动态绑定到B类的析构函数。
实际上,很多人这样总结:当且仅当类里包含至少一个虚函数的时候才去声明虚析构函数。

重复继承的复杂一点的例子:
这里写图片描述
main函数部分:
这里写图片描述
输出:
这里写图片描述
内存布局:
这里写图片描述

我们会发现重复继承导致数据重复拷贝,并且会导致二义性:
这里写图片描述

共享(钻石)继承的复杂一点的例子:
虚拟继承的出现就是为了解决重复继承中多个间接父类的问题的。
只需改造一下上面的结构:

class B {……};class B1 : virtual public B{……};class B2: virtual public B{……};class D : public B1, public B2{ …… };

在这里先分析一下,B1单一继承的内存结构:
main函数:
这里写图片描述
输出:
这里写图片描述
内存结构图:
这里写图片描述

最后关于D的内存布局留给读者自己尝试,如果你可以分析出来了,说明你读懂理解本帖。

简单总结内存布局如下:
1. 首先排列非虚继承的基类实例
2. 有虚基类时,为每个基类增加一个隐藏的vbptr,除非已经从非虚继承的类那里继承了一个vbptr
3. 排列派生类的新数据成员
4. 在实例最后,排列每个虚基类的实例

      这些内存布局之所以变的如此复杂原因是因为多态,所谓多态,就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类指针有“多种形态”,这是一种泛型技术。

由于c++内存结构着实不是很易理解,如果偏颇, 欢迎批评指正,切磋探讨。

0 0
原创粉丝点击