结合sizeof浅谈C++中的指针和字节对齐

本文是我在阅读了“ sizeof用法”(http://blog.csdn.net/ymd378362996/article/details/7634343 )、“c++字节对齐与结构体大小”(http://pppboy.blog.163.com/blog/static/30203796201082494026399/)以及“转载学习结构体和union大小的问题”(http://blog.csdn.net/vincent_1011/article/details/4479965)后,经过自己一番实践之后写的,主要是记录并分享一下心得与收获,有不正确的地方欢迎指正交流。本文中有部分内容源自以上三篇文章。


先说说sizeof,sizeof是C语言的一种单目操作符(但有人也不这么以为,认为它是一种特殊的宏),如C语言的其他操作符++、--等。它并不是函数(这是必须的)。sizeof操作符以字节形式给出了其操作数的存储大小。操作数可以是一个表达式或括在括号内的类型名。操作数的存储大小由操作数的类型决定,简单的说其作用就是返回一个对象或者类型所占的内存字节数。而且,sizeof在程序编译阶段就会被处理,因此在sizeof作用域范围内的表达式,也就是sizeof()括号内的表达式也不能被编译,而是被转换成相应的类型。

例如:

int a=0;
cout<
该段程序执行后,a的值仍然保持为0,因为由于=操作符返回操作符左边的数据类型,所以在编译阶段,sizeof(a=3)等价于sizeof(int),而这个赋值操作由于未被系统实际执行,所以a的值没有改变。

更多有关sizeof的特性可以参看我在前面给出的文章。


在sizeof的实际使用中,像sizeof(int),sizeof(char)等等简单的运用往往不是最令人头疼的。但当sizeof和指针、数组打交道的时候,结果往往与我们所预想的不同,接下来就让我们一步步探索。


首先说明,我用的虽然是64位系统,但操作系统分配的地址仍然是32位,因此任何类型的指针的长度都将是4个字节。

还要说明的是,本文中将数组名理解为一种独特的数据类型,而非一个常量指针,具体可以参考:http://blog.csdn.net/yby4769250/article/details/7294718


根据我自己的理解:当我们声明变量 int a 时,a代表一个int类型的数据,而“a”这个标识符则是这个数据的“名称”,标识符“a”不占空间,而在编译成汇编代码的时候,“a”将被替换成该int类型数据的地址。所以当我们声明数组 int b[5] 的时候,相当于声明 int[5] b, b代表一个int[5]类型的数据,而“b”是这个数据的名称,“b”不占空间,不是大多数人所说的“常量指针”(所以当你对数组名取址时,编译是不通过的),在编译阶段,对数组的操作将转换为对数组首位地址+偏移量的操作。因此,当对数组名使用sizeof的时候,我们就可以正确得到数组的大小,至于 *b == &b[0],个人推测是为了方便程序员对数组进行操作,c++将*操作符对数组名这一类数据类型进行了重载。


接下来进入正题:


从最简单的开始:

double a[5] ;
cout << "size of a:" <

输出:


size of a:40
size of *a:8
请按任意键继续. . .


a是一个double类型的数组,长度为5,则sizeof(a) 等价于sizeof(double[5]) = 5*8 = 40,而sizeof(*a)相当于sizeof(double) = 8



接下来考虑 double *a[5]

double *a[5];
cout << "size of a:" <


输出: 

size of a:20
size of *a:4
size of **a:8
请按任意键继续. . .


从输出结果可以看出,* 优先与[5]结合,即形成的数组是一个指针数组,里面存储着 double*类型的元素,而a则是这个数组的数组名。

因此,sizeof(a) = sizeof ( double *[5] ) = 5 * sizeof(double *) = 5 * 4 = 20,

sizeof(*a) = sizeof(double *) = 4,sizeof(**a) = sizeof(double) = 8 



上面的代码中,* 优先和数组内的元素进行了结合,接下来我们改变一下优先级,让 * 与 数组名优先结合。

double (*a)[5];
cout << "size of a:" <

输出结果:

size of a:4
size of *a:40
size of **a:8
请按任意键继续. . .


如果将*a看作b,即 double b[5];

那么上面的输出结果也就等价于 : 

size of b : 40 ,sizeof  *b : 8,与我们最初讨论的最简单情况一样,所以当遇到类似 type (***a)[] 的情况的时候,无论括号内有多少重指针,都将他看作一个新的变量,用b来代替,则可以简化为 type b[],其中b = ***a,这样就好理解了,也就是说a指针的第三重就是b,而b代表的就是这整个数组。

a --> *a --> **a --> ***a(b) 


所以上面的代码的输出也就非常容易理解了,

a --> *a == array ,故a指向一个指针,大小为4字节,*a是一个double类型长度为5的数组,大小为8*5=40,

*array == **a == array[0],故**a中就存储着一个double类型的数据,大小为8.



最后看看组合情况: double *(*a)[5]

double *(*a)[5];
cout << "size of a:" <

输出:

size of a:4
size of *a:20
size of **a:4
size of ***a:8
请按任意键继续. . .


首先, 括号外的 * 与 数组内元素结合,即当前数组内元素为 double * 类型指针。

随后 (*a) 代表这个指针数组,即可以表示成:

a --> *a(double []*)

a是一个指针,则sizeof(a) = 4.

*a 是一个指针数组,则sizeof(*a) = 4 * 5 = 20

**a 为数组中的元素,类型为double *,是指针,故sizeof(**a) = 4

***a 为数组中元素所指向的内存空间,类型为double,故sizeof(***a) = 8


------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


接下来让我们来了解一下一个类的大小与其成员变量的数据类型的关系,首先我们看看代码:

class A{
	char a;
	double b;
	int c;
};
	
class B{
	char a;
	int c;
	double b;

};

cout << "size of A:" << sizeof(A) << endl;

cout << "size of B:" << sizeof(B) << endl;



在大多数初学者(包括我)眼里,A和B是完全一样的两个类,因此输出结果理应是完全一样的,只不过 ....


size of A:24
size of B:16
请按任意键继续. . .


输出结果却是这样的!

char类型长度为1字节,double类型为8字节,int类型为4字节,1+8+4 = 13,和上述两种情况都不符合!


这正是字节对齐的结果!大致介绍一下字节对齐的原因和准则:

现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特 定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。


字节对齐的细节和具体编译器实现相关,但一般而言,满足三个准则:
1. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
2. 结构体每个成员相对于结构体首地址的偏移量都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节;
3. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。


也就是说,字节对齐是计算机系统通过修改数据的存储方式来提高数据的读写效率所进行的操作,看完三条准则或许有点抽象,结合上面的实例来谈谈准则:

先看class A:

class A{
	char a;
	double b;
	int c;
};
根据准则2,则char类型的首地址可以满足任何偏移量(char长度为1字节),double类型数据首地址只能是0,8,16 ...等8的倍数,而int类型数据首地址只能是0,4,8 ... 等4的倍数。系统按照成员变量的声明进行数据存储,如果将该类存储地址的首地址看作[0],则char存在[0]的位置,接下来存double类型数据,根据准则2,double存在[8] -- [15],接下来存int类型,根据准则2,int存储在[16] -- [19],根据准则3,该类的总大小应该能被double类型的数据长度整除,故[20] -- [23]将会被系统补齐。也就是说,此时类A的存储情况是:

|char|----|----|----|----|----|----|----|

|----------------double---------------|

|----------int-------|----|----|----|----| ,整个类占用24字节的大小。


再看class B

class B{
	char a;
	int c;
	double b;

};
char类型仍然存在[0],接下来是int类型数据,根据准则2,应存在[4]--[7],再看double,应存在[8] -- [15],此时类总长度正好是16,是最长类型double的整数倍,无需再补位,所以类B的存储完成。此时,类B在内存中的存储情况是:

|char|----|----|----|--------int--------|

|----------------double---------------|,共16字节。




接下来考虑另一种情况:

class C{
	char a;
	int b;
};
class D{
	char d;
	C classC;
};
cout << "size of C:" << sizeof(C) << endl;
cout << "size of D:" << sizeof(D) << endl;

输出结果:

size of C:8
size of D:12
请按任意键继续. . .

首先说说当类成员变量中含有类变量时,计算空间的准则:

1.类的大小应该是其最宽数据类型的整数倍,其中最宽数据类型可以来自它的类成员变量。

2.类的类成员变量首地址应该是该类成员变量最宽数据类型的整数倍。

3.类的其他基本数据类型变量不会和类的类成员变量中的基本数据类型“拼凑”。


class C的大小都非常好理解,接下来开始分析class D 

class D{
	char d;
	C classC;
};
按变量声明顺序,char类型变量存储在[0],紧接着是一个C类的类成员变量,由于D中最宽基本数据类型是char,而C中最宽基本数据类型是int,所以根据准则1,class D大小为4的倍数,同时由于准则2,D中的成员变量classC的首地址应该是4的非负整数倍,所以此处classC存储在[4] - [11]中,即[1] - [3]的存储空间是操作系统补齐的,D类大小为12字节。


我们如果交换一下C、D中成员变量的顺序:

class C{
	int a;
	char b;
};
class D{
	C classC;
	char  c;
};

输出结果依然不变:

size of C:8
size of D:12
请按任意键继续. . .

注意在考虑存储时不可以将D类理解成

class D{

int a;

char b;

char c;

}

因为由于准则3,classC中的基本数据类型不会与D类中的数据类型拼接,即 char b和char c将不会共用4个字节。


再看看声明成员函数对类大小的影响:

class A{
	int a;
	void f(){};
};
class B{
	int b;
	virtual void vf(){};
};
class C{
	int c;
	virtual void vf1(){};
	virtual void vf2(){};
	virtual void vf3(){};
	virtual void vf4(){};
	virtual void vf5(){};
};


cout << "size of A:" << sizeof(A) << endl;
cout << "size of B:" << sizeof(B) << endl;
cout << "size of C:" << sizeof(C) << endl;

输出结果:

size of A:4
size of B:8
size of C:8
请按任意键继续. . .


可见,非虚函数不占用存储空间,而当类声明虚函数时,占用4字节的空间来存储虚函数表,关于虚函数表的详解可以参考:http://blog.csdn.net/haoel/article/details/1948051 。 由于虚函数表是以链表结构存储,类只需要存储该表表头结点即可,因此无论声明多少个虚函数,都只占用类4字节的空间。



最后来看看继承、虚拟继承,类A,B,C沿用上面的类,接着来看代码:

class D :A{
	char d;
	short e; 
};
class E :A{
	double e;
};

cout << "size of D:" << sizeof(D) << endl;
cout << "size of E:" << sizeof(E) << endl;

输出:

size of D:8
size of E:16
请按任意键继续. . .


从输出结果可以看出,普通继承时,可以看作先在子类中声明了父类成员变量,后声明自身特有的成员变量,再按照本文之前的规则进行补位即可得到子类的大小。(本例显示不出父类成员变量的声明是被放在子类成员变量声明之前还是之后,有兴趣的读者可以自行写个小测试,就可以验证我的说法了)。



接下来我们来继承B、C类,这两个具有虚函数的类。

class F :B{
	
};
class G :C{
)

class H :B, C{
};
cout << "size of F:" << sizeof(F) << endl;
cout << "size of G:" << sizeof(G) << endl;
cout << "size of H:" << sizeof(H) << endl;


输出: 
  

size of F:8size of G:8size of H:16请按任意键继续. . .

F好理解,类G自身声明的虚函数将加在其第一个父类的虚表表尾,因此此例中其其大小等于类F,而类H多重继承了B、C,因此存储了两个父类的虚函数表,而并不是自己生成一个虚函数表,存储两个父类的虚函数。

最后看看虚拟继承:

class I :virtual B{

};
class J :virtual B, C{
	
};
class K :virtual B, virtual C{

};
cout << "size of I:" << sizeof(I) << endl;
cout << "size of J:" << sizeof(J) << endl;
cout << "size of K:" << sizeof(K) << endl;
输出:

size of I:12
size of J:20
size of K:20
请按任意键继续. . .


从输出结果可以看出,只要继承中出现了虚继承,子类就会多出4个字节的空间,而且不论是多少重虚继承,子类都是多出4字节的空间,这4个字节是子类指向父类的虚类指针vptr,有关vptr的更多细节,可以参考:http://www.cnblogs.com/BeyondAnyTime/archive/2012/06/05/2537451.html




文章写到这里也就差不多了,当初写这篇文章是因为面试的时候面试官问了我一些有关sizeof的问题,而我答不上来,于是在网上找有关sizeof的解释和用法,最后发现自己对指针、字节对齐等知识也了解甚少,在不断的查阅和实验后写出这篇总结,由于我并不常用c++编程,对内存分配管理也很不熟悉,文章内容或许还有一些概念偏差之处,写出来主要是为了记录一下自己的思考过程,也希望能帮到读了这篇文章的读者。


你可能感兴趣的:(心得,c++,sizeof,指针,字节对齐)