C++对象内存分析

来源:互联网 发布:网络直播红人瞳孔 编辑:程序博客网 时间:2024/06/10 15:53

一、问题的引出

对C++模型的认识可以从本质上提高对语言和各种机制的理解,如果对底层机制一无所知,那么很多高级的机制都只能通过死记硬背的方式来运用,而且有时候有错误,也很难找出原因。C++相对与C语言,编译器做了很多的对程序员透明的事情,而很多错误也是由于这些不透明所引起的。所以研究这个问题是很有必要的。

 

二、引起问题的原因

或者说是使问题复杂的原因,那些使得内存分配超乎想象的原因。大概有以下两个方面
1) C++的多态性,面向对象的多重继承,虚拟继承。(编译器为了支持这些特性而添加一定的内容)
2) alignment(为了CPU取地址方便,这在C中struct一样,多数编译器会优化对齐地址)
可能编译器和编译器对同一对象建立的模型也不一样,有的编译器提供优化,有的甚至连实现的方式都不一样,这里不与考虑,以下所有的结果都在Linux下的gcc(Red Hat 4.1.2)编译器下实验。

 

三、具体的问题分析

分以下几种情况来分析:alignment对对象影响、单一继承不含虚函数、单一继承含虚函数、多重继承、虚拟继承

1)alignment的影响

#include <stdio.h>#include <string>using namespace std;class Base{        private:                int a;                char b;};class Derived: public Base{        private:                int c;                char d;};int main(void){        Base b;        Derived d;        printf("base size is %d\n", sizeof(b));        printf("derived size is %d\n", sizeof(d));        return 0;}

结果为
base size is 8
derived size is 16

C++中对“出现在derived class中的base class subobject有其完整原样性”,原来基类的对象内存分布是什么样子的,在其派生类内还是原来的样子(包括因为对齐而填补的3byte)。这样在用一个基类给一个派生类中基类部分赋值的时候,不会影响到派生类除了基类的部分。


class Base
{
        private:
                char b;
};

class Derived: public Base
{
        private:
                char d;
};
大部分机器上聚合的结构体大小会受到aligment的影响,注意是“聚合的”,如果Base中只有一个char b,那么其大小为1,Derived中也是只有一个char d,那么这个Derived 类的对象也是只有2。它们都不算聚合的结构体。所谓的聚合是要有不同类型的数据,Base中有5个char类型的数据那么其大小为5。
class Base
{
        private:
                char b;
};

class Derived: public Base
{
        private:
                int d;
};

sizeof(Base)为1,sizeof(Derived)为8,因为Derived算一个聚合的结构体含有字符和整型。

注意如果
 class Base{};
 class Derived :public Base{};
 
那么sizeof(Base)为1,有一个隐藏的1 byte大小,目的是为了让Base不同的对象有独一无二的地址。sizeof(Derived)大小还为1,也是自动填充的1 byte,这种情况也可能没什么意义但是如果是
 class Base{};
 class Derived :virtual public Base{};
这时Base为一个空的虚基类(相当于java中的接口),(下面内容会有提及)就十分常见了。这时候如果没有编译器的优化,sizeof(Derived)应该为8(4 byte的指针,1 byte的空类自动填充,3 byte的对齐填补),但是大多数编译器sizeof(Derived)为4,可以理解成因为有了那个指针所以类不空就不用那个1 byte的自动填充了,也就省了后面的对齐填补。

 

2)单一继承不含虚函数
class Base
{
        private:
                int a;

         public:

                int fun();
};

class Derived: public Base
{
        private:
                char b;
};

这种情况较简单(以下不再考虑aligment的情况),sizeof(Base)为4,sizeof(Derived)为8。这里还要注意一点,如果类中有成员函数但是不是虚函数,那么这个成员函数是不占空间的。

 

3)单一继承含虚函数

虚函数的目的,实现多态,实现运行时的动态绑定,动态绑定它能使程序变得可扩展而不需要重编译已存代码(对于大型的程序很重要,类似于动态链接库),多态也可以统一接口(我觉的这个是本质作用,增加功能时需要修改接口,接口很重要)。
发生多态条件有派生,虚函数,基类类型的指针指向子类的对象。具体实现模型如下:编译器以一个类是否含有虚函数来判断这个类是否支持多态,如果一个类支持多态,那么编译器为这个类生成一个虚表,每个虚表项(slot)为一个虚函数地址(这个虚函数地址编译器已经安排好放在slot中),编译器还会在编译期间给支持多态的类的对象增加一个虚指针指向这个虚表。当运行的时候,通过基类的指针或引用(实际指向一个子类)调用虚函数时,这个指针去访问真正指向的子类对象的虚指针,根据虚指针找到对象的虚表,找到虚表中对应的虚函数的地址。
其实虚表,虚表中的地址,虚指针所指向虚表的地址,都在编译期间完成了。执行期间,只是找到这个虚表地址,并去调用函数,在执行期间才去绑定,这样,在执行期间确定函数,所谓的动态绑定。这种情况内存分布也不复杂,代码和内存分布如下:

 

#include <stdio.h>#include <string>using namespace std;class Base{        private:                int a;        public:                ~Base(){};                virtual int fun(){};};class Derived: public Base{        private:                int b;        public:                ~Derived(){};                virtual int act(){};};int main(void){        Base b;        Derived d;        printf("base size is %d\n", sizeof(b));        printf("derived size is %d\n", sizeof(d));        return 0;}

 

结果是:
base size is 8
derived size is 12

当时我看到这一结果第一反映就是,“怎么Derived就一个虚指针呢?它不是有自己的虚函数act()么?不是Base一个虚指针,Derived一个虚指针么?”。最终结果只有一个属于Base的虚指针,我觉得这个虚指针是属于整个对象的(为什么划分在子类的空间呢?到多重继承就知道了),而且这个虚表也是子类与父类共享的。因为Derived的虚函数地址也在这个指针指向的虚表里面。完全没有必要弄两个虚表,如果一个函数是一个虚函数,那么就直接去虚指针指向的表里找虚表就好了,两个虚表实现更麻烦。内存分布如下图所示:
注:在派生类Derived中,虽然有自己的虚函数act()但是也是只有一个虚指针,它指向的虚表里面既包含Base的虚函数fun(),也包含Derived的虚函数act(),还有就是Base()有的Derived中也有的虚函数,此时由于Derived中也有,对象类型为Derived,所以调用时会调用Derived的虚函数。

 

 

Base的内存模型

 

Derived的内存模型

 

 

#include <iostream>#include <list>#include <set>using namespace std;class A{        public:                virtual int fun(){return 1;}                int a;};class B:public A{        public:                int fun(){return 0;}                int b;};int main(){        B *p = new B;        p->a = 1;        p->b = 2;        int *p2 = (int *)p;        p2++;        cout<<*p2<<endl;p2++;cout<<*p2<<endl;        return 0;}

上面代码是想说明一个问题,即真正内存vptr和数据的相对位置,一般而言一个对象如果有虚指针,那么这个虚指针一定是在对象开始的地方。上面的代码中p2++后为数据1,也就是基类的数据成员,然后是2,为派生类的数据成员。所以真正的位置为vprt,基类数据,派生类数据。

4)多重继承

#include <stdio.h>#include <string>using namespace std;class Base1{        private:                int a;        public:                virtual int fun(){};                virtual int eat(){};};class Base2{        private:                int b;        public:                virtual int fun(){};                virtual int drink(){};};class Derived: public Base1, public Base2{        private:                int c;        public:                virtual int fun(){};};int main(void){        Base1 b1;        Derived d;        printf("base size is %d\n", sizeof(b1));        printf("derived size is %d\n", sizeof(d));        return 0;}


 

结果
base size is 8
derived size is 20

因为Base1和Base2可能有自己的虚函数(在Derived中没有被重写过,所以要各自的保留,例如eat()和drink()两个虚函数)所以一个Derived中就有两个虚表,对象d也就有两个虚指针。如果其中的一个Base1不含虚函数,那么Derived中还是一个虚指针,所以,Derived对象中所含虚指针的个数为其上层还有虚函数的类的个数(单一继承也适合)。本质上是Base1与Derived共享一个虚表,Base2与Derived共享一个虚表,但是各自的虚指针都划在Base1和Base2中,否则如果划在derived 中的话就会出现连续多个虚指针的情况,而且不能清楚表示它们的关系。
还有一个问题,Base2 *b2 = new Derive(); b2->drink()的时候,其实b2指向的是Derived类对象的起始部分,也就是Base1对象的部分,如果按照原则会找到Base1的对象虚指针,去Base1的虚表里面找drink函数,但是因为drink函数在Base1中根本就没声明过,所以也找不到。这时候编译器有两种方案,一种是如果检查到是这种情况,就使得指针偏移指向Base2的对象,还有一种就是在Base1的表中加上那个drink函数的偏移地址,通过Base1的虚表来找到drink函数。所以对于第二种情况,内存模型如下

打*是因为两个虚表的slot内容并不一样,以一个是偏移,一个是实际的地址

 

#include <iostream>#include <string>using namespace std;class Base1{                     public:                virtual int fun(){return 0;};                virtual int eat(){return 0;};int a;};class Base2{                   public:                virtual int fun(){return 0;};                virtual int drink(){return 0;};int b;};class Derived: public Base1, public Base2{                 public:                virtual int fun(){return 0;};int c;};int main(void){Derived *pd = new Derived;pd->a = 1;pd->b = 2;pd->c = 3;int *p = (int *)pd;cout<<*p<<endl;p++;cout<<*p<<endl;p++;cout<<*p<<endl;p++;cout<<*p<<endl;p++;cout<<*p<<endl;        return 0;}

依次输出:

4632616
1
4632604
2
3


5)虚拟继承

一种复杂的情况,如果virtual base class 也是从另外一种virtual base class继承而来,那么其“共享”的策略将会实现的相当复杂,其中非静态的变量使用的时候也会相当复杂,所以lippman在书上建议,“不要在virtual 中声明nonstatic date members”。
内存模型的实现上,如果两个类Derived1,Derived2都派生自一个虚基类Base,那么以这两个派生类为基类派生的子类Derived3模型中,虚基类数据将共享。因为虚基类共享所以Derived1和Derived2需要直到Base在Derived3中的具体位置(一般是一个偏移量),所以需要一个位置来记录这个偏移量,为了方便一般编译器是在虚表的最上面加上这个偏移量。所以一种极端的情况,即是一个虚基类中,没有虚函数,virtual 派生的子类中也没有虚函数,但是这个派生的子类中也有一个虚指针指向虚表,记录这个虚基类对象在子类对象中的偏移
class Base
{
        private:
                int a;
};

class Derived1: virtual public Base
{
        private:
                int b;
};
结果
base size is 4
derived size is 12


如果虚基类中也有一个虚函数,那么虚基类对象也需要一个虚表来记录虚函数(这点特殊)。derived类与虚基类不再在共享一个虚指针(虚表了),derived类需要一个唯一的字段(虚基类的偏移地址)来确定虚基类的位置,而这个是放在自己的虚表中。

注意两个类共享虚基类数据情况(两个类Derived1,Derived2都虚拟派生自一个虚基类Base,以这两个派生类为基类派生的子类Derived3),这时候,如果有虚函数,例如虚基类有虚函数,fun(),两个Derived1,Derived2也都分别重写fun(),这时候Derived3中就乱了,一个Derived3类型的对象,会调用哪个fun()呢?造成的原因是因为Derived1和Derivde2是平等的,没有继承的关系。

#include <stdio.h>#include <string>using namespace std;class Base{        private:                int a;        public:                virtual int fun(){};};class Derived1: virtual public Base{        private:                int b;};class Derived2: virtual public Base{        private:                int c;};class Derived3: public Derived1, public Derived2{        private:                int d;};int main(void){        Base b;        Derived1 d1;        Derived3 d3;        printf("base size is %d\n", sizeof(b));        printf("derived1 size is %d\n", sizeof(d1));        printf("derived3 size is %d\n", sizeof(d3));        return 0;}

结果是
base size is 8
derived1 size is 16
derived3 size is 28

(6.13补充)

发现一个奇怪的问题,可能是编译器的问题。上面说的如果Derived1,Derived2都虚拟派生自一个虚基类Base,以这两个派生类为基类派生的子类Derived3,如果Base、Derived1、Derived2都有同一个虚函数fun(),那么编译器会说“错误:‘virtual int Base::fun()’ 的最终重载在 ‘Derived3’ 中不唯一”,OK这说的还容易理解,如果Derived3中也有重写函数fun(),那么就不会有这个问题。奇怪的是,如果,Derived1和Derived2都是派生,而不是虚拟派生,没有那个virtual关键字,那么如果Base、Derived1、Derived2都有同一个虚函数fun(),编译的时候编译器可能不会报错的。只有在代码中有函数调用的时候例如,Derived3 d3; d3.fun();编译的时候才会告诉你“对成员fun 的请求有歧义”。不算什么问题,但是编译器竟然有不同的行为,可能还是自己没有彻底的明白,这个虚继承底层实现的玄机。

 

虚继承时所说的没有“共享虚表”的情况只是所的是基类Base和他的派生类Derived1和Derived2没有共享,而Derived3和Derived2(Derived1)还是共享虚表的。还有补充下,就是没有虚继承时,就是没有那个virtual关键字的时候,sizeof(Derived2)大小就是28,很容易,这个时候就是Derived1和Derived2中有Base的成员,Derived1和Derived2大小都为12,在加上那个Derived3的成员一个4字节整型,就是12+12+4=28了。这个时候可以看出来,Derived3和Derived2是共享虚表的(一般划在Derived2)中,而这个虚表也是Derived2和Base所共享的。其实这个时候问题就退化成了一种比较特殊的多重继承的情况了。

(6.13补充结束)

 

Derived1 内存模型

 

Derived3 内存模型

 

四、总结

如果笔试遇到这类问题主要考虑四点:聚合结构时对齐、虚函数、虚继承、多重继承

一般而言派生的子类会与含有虚函数的父类共享一个虚指针(虚表),有虚函数单一继承和多重继承的时候都是,特殊的就是虚继承,单继承为虚继承的时候,父类一直自己保存着一个虚指针,并不与自己派生类共享一个虚表。这个问题原因我也不清楚,这个也是与其它模型不同的一个地方。

 

五、参考:

Stanley B. Lippman  inside the c++ object model

 

原创粉丝点击