☀️写在前面
本篇文章主要讲解结构体的内存对齐知识和其他几种自定义类型的使用和特点,内存对齐部分较难,建议可以仔细阅读,好好消化。如果需要学习结构体的基础知识,下面的传送门已为您打开👀。
🌀结构体初阶传送门🌀
文章目录
1️⃣结构体内存对齐
我们知道,整型变量有自己的大小,浮点型变量有自己的大小,数组也有自己的大小,那么结构体有没有自己的大小呢?
回答是肯定的,结构体也有自己的大小,但是结构体的大小并不是简单地将每个结构体成员的大小相加就能得到。
所以需要进一步深入讨论:结构体的大小
同时也是一个难点和考点:结构体内存对齐
为什么存在内存对齐
- 平台原因(移植原因): 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 性能原因: 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
通俗理解:
为什么要对齐的本质原因是在硬件设计时连续的内存地址不能安排在同一个存储单元中,否则将导致存储效率极低,因为CPU运行速度太快,内存读取太慢。所以为了减少访问内存的次数,宁愿花费空间也要提高效率。
也就是说:结构体的内存对齐是拿空间来换取时间的做法。
内存对齐如何计算
结构体的对齐规则:
- 第一个成员在与结构体变量偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的最小整数倍的地址处。
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的最小整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整
体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。
以上规则可能不太好理解,接下来手把手教你进行内存对齐的计算
以下面的结构体为例:
struct S1
{
char a1;
int b1;
double c1;
};
struct S2
{
int a2;
double b2;
struct S1 s;
char c2;
int d2;
};
注:这里的编译环境是VS,默认对齐数为8,不是所有编译器都有默认对齐数,当编译器没有默认对齐数的时候,成员变量的大小就是该成员的对齐数。
这里分析的时候,就先不考虑编译器默认对齐数和成员变量的对齐数的大小比较,因为8位的对齐数基本就算大的了,如果设置了对齐数偏小的时候,就需要比较变量和设置对齐数取较小值作为变量的对齐数。
先看结构体S1
内存空间图:
- 变量a1是char类型,大小为1(字节),故自身的对齐数是1,第一个在起始偏移量为0的地指处,不用考虑起始偏移量/对齐数是否等于整数,因为0/任何数都能整除。
- 变量b1是int类型,大小是4,故自身的对齐数是4。前面有变量,大小为1字节,起始偏移量为1,1 / 4 = 0余1,不是4的整数倍。要满足起始偏移量/对齐数 = 最小整数,所以b1的存储位置需要往后移三个字节,此时偏移量为4,4 / 4 = 1满足要求,b1就从该处存放。
- 此时偏移量是8,变量c1是double类型,大小是8,对齐数也是8,8 / 8 = 1,满足要求,c1变量直接从此处存放。
- 成员变量存放完后,接下来就要估算结构体的大小。此时,结构的对齐数就是成员中的最大对齐数8,成员变量占据的空间大小为1 + 3 + 4 + 8 = 16,16 / 8 = 2(是最小整数倍),满足要求,结构体的大小就计算完毕,就是16个字节。
再看结构体S2
内存空间图:
- 变量a2的对齐数是4,放在起始偏移量为0的地方。
- 起始偏移量为4,变量b2的对齐数是8,4 / 8 = 0余4,不满足要求,再往后偏移四个字节,8 / 8 = 1,满足要求,存放。
- 起始偏移量为4 + 4 + 8 = 16,结构体变量S的大小为16,对齐数为8,再计算是否满足起始偏移量/对齐数 = 最小整数,16 / 8 = 2(是最小整数),满足要求,存放。
- 此时偏移量为32,变量c2的对齐数是1,32 / 1 = 32(是整数),满足要求,存放。
- 此时偏移量为33,变量d2的对齐数是4,33 / 4 = 8余1,不满足要求,再往后移三个字节,36 / 4 = 9,满足要求,存放。
- 此时总偏移量为40,变量b2的对齐数和内嵌结构体的对齐数都是8,结构体S2的对齐数就是所有成员变量的对齐数的最大者为8。40 / 8 = 5(是整数),满足要求。故结构体S2的大小就是40。
设计结构体的技巧
在设计结构体的时候,如果结构体成员的顺序设计得合理的话,是可以避免不必要的内存消耗的。
两个结构体的成员变量相同,但是成员变量的顺序不同,可能就会出现结构体的大小不同的情况:
//例如
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
测试大小结果:
我们可以看到,结构体1和结构体2的成员变量一模一样,可是当我们按照内存对齐规则来计算两个结构体的大小的时候,会发现两个结构体的大小不一样,在VS编译器下第一个结构体大小为12,第二个结构体大小为8。
可以见得,结构体成员变量的顺序不同,可能会造成内存不必要的损失。所以尽量将占用空间小的成员尽量集中在一起,可以有效地避免内存不必要的浪费。
修改默认对齐数
如果在对齐方式不适合的时候,我们可以自己修改对齐数来满足需求。
修改默认对齐数,需要用到以下预处理指令:
#pragma pack()
如果在该预处理命令的括号内填上数字,那么默认对齐数将会被改为对应数值大小;如果只使用该预处理命令,不在括号内填写数字,那么会恢复为编译器默认的对齐数。
注意:
参数大小应为 “1”、“2”、“4”、“8” 或者 “16”。
#include <stdio.h>
#pragma pack(8)//设置默认对齐数为8
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
//输出的结果是什么?
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
输出结果:
12
6
2️⃣位段
什么是位段?
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是
int
、unsigned int
或signed int
。 - 位段的成员名后边有一个冒号和一个数字用来分配比特位。
例如:
struct A
{
int a : 2;//a只需要2个比特位
int b : 5;//b只需要5个比特位
int c : 10;//c只需要10个比特位
int d : 30;//d只需要30个比特位
};
这里的A就是一个位段类型。
位段大小的计算
规则:位段的空间是按照需要,以四字节的int
或者是一字节的char
(属于整形家族)类型的方式来开辟的。
//一个例子
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
首先,该位段类型的成员类型是char类型,我们一次开辟1个字节(即8个bit位)的内存大小(不够再继续开辟)。
第一次开辟的8个bit位中,成员a用去了3个bit位,成员b用去了4个bit位,此时剩下1个bit位,而成员c需要5个bit位,于是又新开辟8个bit位,到这里便有两种情况:
- 情况一:成员c先把上次开辟的8个bit位中剩下的一个bit位用去,再在新开辟的8个bit位中用去4个bit位。
- 情况二:成员c之间使用新开辟的8个bit位中的5个bit位,而上次开辟的8个bit位中剩下的那个bit位被浪费。
关于这一点,C语言标准并没有定义,于是不同编译器就可能按照不同的方式来计算。我当前使用的VS2019是按照第二种情况进行计算的,所以此时成员c用去了新开辟的8个bit位中的5个bit位,剩下3个bit位,不够成员d使用,所以又新开辟8个bit位供成员d使用,一个开辟了3次,即24个bit位,即3个字节,所以该位段类型的大小在VS2019编译器下的大小为3个字节。
位段的内存分配
上面我们已经计算了该位段类型在VS2019编译器下的大小为3个字节,那么当我创建一个该位段类型的变量并对齐赋上一些值时,这些值在内存中是如何分配的呢?
在内存分配的过程中又可能遇到一个问题:字节存储有大小端的问题,但是位段存储是没有大小端的。所以当开辟好8个bit位,我们使用这8个bit位的时候,是从左向右使用还是从右向左使用的呢?
这又是一个C语言标准尚未定义的规则,取决于硬件厂商的设计,但是大多数情况默认从最右侧的bit位,向左使用。
//一个例子
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
struct S s = {0};
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
//空间是如何开辟的?
因为在赋值之前已经将创建的位段类型的s变量赋初值为0了,所以白色区域(即浪费了的区域)代表的数字就是0。
于是把赋的值分配进去后,这3个字节的内容用二进制表示就是:
01100010 00000011 00000100
将其化为十六进制就是:62 03 04
位段的跨平台问题
位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
主要原因:
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机
器会出问题。 - 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是
舍弃剩余的位还是利用,这是不确定的。
所以在考虑是否使用位段时要注意其缺点,因为跟普通的结构体相比,位段虽然可以达到同样的效果,并且可以很好地节省空间,但是存在跨平台的问题。
位段的应用
当我们用微信或是QQ向好友发送消息时,只需将要发送的内容写入对话框并点击发送即可,但你以为这其中的过程真的就那么简单吗?
其实比你想象的要复制得多,当你要发送一条消息给某人时,系统必须知道这条消息从哪里来,要到那里去以及这条消息的生存时间等等,如下图:
这时,我们运用位段就能节省大量空间,而且当我们发消息时,因为发出去的数据包越小,我们的信息传输效率就会越高,这好比在高速公路上如果全是小汽车(位段类型将空间运用到极致),那么交通会很流畅,而如果全是大卡车(普通结构体类型占用空间较大),那么就会造成交通堵塞。
3️⃣枚举
枚举顾名思义就是一一列举。
把可能的取值一一列举。
比如我们现实生活中
一周的星期一到星期日是有限的7天,可以一一列举。
性别有:男、女、保密,也可以一一列举。
颜色多种多样,也可以进行一一列举
枚举的定义
enum Day//星期
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex//性别
{
MALE,
FEMALE,
SECRET
};
enum Color//颜色
{
RED,
GREEN,
BLUE
};
以上定义的enum Day
, enum Sex
, enum Color
都是枚举类型。{}
中的内容是枚举类型的可能取值,也叫枚举常量。
-
枚举往往是一组具有相关性较强的一组常量,用枚举类型划分到一块使用。虽然也能用宏定义进行逐个定义,但是常量之间缺少联系,并且宏定义往往只做替换,并没有类型检查。用枚举类型划分常量,便于使用和维护。
-
枚举成员之间用
,
号隔开,这点比较特殊。枚举的值都是有具体值,如果不进行自己赋初值的话,默认是从0开始,并依次递增1。 -
可以在枚举成员之间赋不同的值,将枚举成员之间分出相应的段落。
例如:
enum Price
{
X = 1,
Y, //2
Z, //3
A = 250,
B, //251
C //252
};
枚举的优点
有了宏定义可以定义常量,还需要枚举类型干什么呢,这是因为枚举类型有比宏定义更多的优点。
- 增加代码的可读性和可维护性
- 和#define定义的标识符比较枚举有类型检查,更加严谨,而宏定义只做替换。
- 防止了命名污染(封装)
- 便于调试
- 使用方便,一次可以定义多个常量
枚举的使用
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
4️⃣联合(共用体)
联合类型的定义
联合也是一种特殊的自定义类型,这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。
//联合类型的声明
union Un
{
char c;
int i;
};
//联合变量的定义
union Un un;
//计算连个变量的大小
printf("%d\n", sizeof(un));
联合的特点
- 联合是需要考虑大小端的。
- 每个联合的成员的地址和联合变量的地址在数值上是相等的。联合的成员取值是根据起始地址加上类型的大小。
- 联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为
联合至少得有能力保存最大的那个成员)。
union Un
{
int i;
char c;
};
int main()
{
union Un un;
union Un s;
// 下面输出的结果是一样的吗?
printf("un.i:%p\n", &(un.i));
printf("un.c:%p\n", &(un.c));
printf("s.i:%p\n", &(s.i));
printf("s.c:%p\n", &(s.c));
//下面输出的结果是什么?
un.i = 0x11223344;
printf("%x\n", un.i);
un.c = 0x55;
printf("%x\n", un.i);
s.i = 0x10100101;
printf("%x\n", s.i);
s.c = 0x23;
printf("%x\n", s.i);
return 0;
}
联合也常用来判断机器的大小端,可以看到,对联合变量成员的c赋值时,c所处的位置是变量的低地址处,如果打印出来被修改的值还是在低权值位(11223355
),说明这台机器就是小端存储,如果打印出来被修改的值还是在高权值位(55223344
),说明这台机器就是大端存储。
联合大小的计算
- 联合的大小至少是最大成员的大小。
- 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
例如:
union Un1
{
char c[5];
int i;
};
union Un2
{
short c[7];
int i;
};
//下面输出的结果是什么?
printf("%d\n", sizeof(union Un1));
printf("%d\n", sizeof(union Un2));
运行结果:
联合的大小计算类比于结构体的内存对齐进行计算。