C++类实例内存结构分析(Boolan笔记第四周)

2/22/2017来源:ASP.NET技巧人气:1466

我们来看一下C++类实例化的时候,它的各个成员在内存中的分布是怎么样的。这个问题看似简单,其实还是有许多情形需要考虑的:比如说类中是否有虚函数,子类与基类的实例内存结构有何区别,C/C++的内存对齐(比如4字节对齐或8字节对齐)对类的实例大小及内存分布有何影响? 一个空类的大小又是多少呢?

我们先看一个没有虚函数的类Fruit及它的子类Apple:

class Fruit{ int _no; double _weight; char _key; public: Fruit(int no, double weight, char key): _no(no), _weight(weight), _key(key) {} }; class Apple: public Fruit{ int _size; char _type; public: Apple(int no, double weight, char key, int size, char type): Fruit(no, weight, key), _size(size), _type(type) {} };

在Code::Blocks 8.02中运行如下代码:

Fruit f1(1, 2.3, 'F'); Apple a1(2, 3.4, 'B', 5, 'A'); cout<<"sizeof(Fruit)="<<sizeof(f1)<<endl; cout<<"sizeof(Apple)="<<sizeof(a1)<<endl;

结果如下: sizeof(Fruit)=24 sizeof(Apple)=32

通过查看memory,可知f1和a1内存结构如下图:

这里写图片描述

可以看出这里编译器默认采用8字节对齐。在Apple对象中,Fruit的部分刚好位于其顶部。Apple的成员size跟Fruit的成员key及填充共用一个8字节。

我们可以看出一下几点: 1. 子类的实例包含了基类的部分,并且基类的部分位于子类实例的开始部分; 2. 在没有虚函数的时候,C++类的大小只与其数据成员有关, 它有没有声明函数或者在类中实现函数都不影响类的大小。这个其实很好理解,因为类里面函数的代码对于该类的每个实例都是一样的,所以它不是放在类的实例中,而是放在代码段中,否则同一个类的每个实例都会额外占用大量内存。


下面我们再看一下有虚函数的情况。我们都知道C++的类有虚函数的时候, 类的object的会多一个vptr指针,指向vtbl。那么是不是一个类有了虚函数之后, 它的size就会增加4呢?

我们把上面两个类都加上虚函数PRocess(),新的代码如下:

class Fruit{ int _no; double _weight; char _key; public: Fruit(int no, double weight, char key): _no(no), _weight(weight), _key(key) {} virtual void process(){cout<<"Fruit::process()"<<endl;} }; class Apple: public Fruit{ int _size; char _type; public: Apple(int no, double weight, char key, int size, char type): Fruit(no, weight, key), _size(size), _type(type) {} virtual void process(){cout<<"Apple::process()"<<endl;} }; `

重新编译。在Code::Blocks 8.02下运行结果为:

sizeof(Fruit)=24 sizeof(Apple)=32

那为什么加了vptr,类实例的大小不变呢? 根据查看memory,可知Fruit和Apple(有虚函数)的实例内存分配如下图:

这里写图片描述

我们可以看出,Fruit和Apple类的实例的vptr位于最开始的位置,并且vptr和Fruit类的no合在一起组成一个8字节。

下面谈谈怎么敢断定头4个字节就是vptr呢? 事实上我们可以通过f1或a1的头4个字节,看它指向什么地址,它指向的地址我们猜想应该是第一个虚函数的函数指针,我们通过这个函数指针来调用这个函数,看看是不是会打印出相应信息。

以f1为例: &f1 - 0x28ff10, f1的地址。 (int *)(&f1) - 0x28ff10, f1的地址转换为int指针。 *(int *)(&f1) - 0x4452b0, f1的地址转换为int,这就是vptr的值。 (int*)*(int*)(&f1) - 0x4452b0, vptr指向的内容转换为int指针,它指向vtbl的第一项。 *(int*)*(int*)(&f1) - 0x41596c, vtbl第一项对应的值,它是一个指针,指向一个函数。我们下面会把它转换成函数指针。

通过我们上面得到的指针,我们就可以调用这个函数了,看它是不是真的打印出了Fruit::process(),测试代码如下:


typedef void(*FunPt)(void); FunPt pf; pf = (FunPt)*(int*)*(int*)(&f1); pf();

重新编译,果然打印出了”Fruit::process()”。证明Fruit的头4个字节就是它的vptr。

注意,我们也可以通过(*pf)()来调用这个函数。因为调用fun()和(*fun)()是等价的。这里为什么函数指针加不加*都可以调用呢?其实编译器这里很清楚知道是要调用fun这个函数,所以两种写法都可以。但是如果是指针指向一个变量就不一样了,编译器不知道你是要访问这个变量还是它的地址。

用同样的方法(只需将上面的f1换成a1),我们也可以直接通过a1的头4个字节得到其vptr,从而call a1的虚函数。结果打印出了”Apple::Process()”。这样我们也验证了a1的头4个字节确实是它的vptr。


再考虑一下,如果Fruit有虚函数,Apple没有定义新的虚函数,也没有override Fruit的虚函数,那Apple的实例的内存分布如何呢?还会有vptr吗?

class Fruit{ int _no; double _weight; char _key; public: Fruit(int no, double weight, char key): _no(no), _weight(weight), _key(key) {} virtual void process(){cout<<"Fruit::process()"<<endl;} }; class Apple: public Fruit{ int _size; char _type; public: Apple(int no, double weight, char key, int size, char type): Fruit(no, weight, key), _size(size), _type(type) {} };

重新编译,发现a1的size仍然为32。通过查看memory发现其内存分布与上图是一样的。通过头4个字节vptr我们找到vtbl的第一项,将其转换为函数指针后调用,我们发现其打印出了”Fruit::process()”。可见,如果基类有虚函数,子类没有定义新的虚函数,也没有对基类虚函数override的话,子类的实例仍然会有vptr,其指向一个vtbl,该vtbl的每一项都从基类的vtbl的相应项拷贝而来。


再思考一个问题,如果是一个空类,其实例的size是否为0呢?如果非0,其内容是什么?

class A{ }; A a; cout<<"sizeof(a)"<<siszeof(a)<<endl;

测试发现a的大小为1,通过查看memory发现该字节内容为0。可见C++编译器对于空类为了能让其实例化,仍然会给它分配一个字节的内存。注意单独对于a而言,C/C++编译器的sizeof()不会考虑内存对齐问题,但是如果a又是其他类的一部分,则就要考虑内存对齐了。

class B{ A a; int c; }; B b; cout<<"sizeof(b)"<<siszeof(b)<<endl;

通过测试,sizeof(b)=8。可见空类仍然参与了字节对齐。