前面已经向大家介绍过一点结构体的知识了,这次我们再来深度了解一下结构体。结构体是能够方便表示一个物体具有多种属性的一种结构。物体的属性可以转换为结构体中的变量。
1.结构体类型的声明
1.1 结构体的声明
struct tag
{
member-list;//结构体成员变量
}variable-list;
举例:
struct Stu
{
int age; //年龄
char name[20]; //名字
char sex; //性别
};
上述结构体就是一个关于学生的一个结构体,里面包含了年龄,名字,性别的信息。
1.2 结构体变量的创建和初始化
struct Stu
{
int age; //年龄
char name[20]; //名字
char sex[5]; //性别
};
int main()
{
struct Stu s1 = { 18,"zhangsan","男" }; //有序的初始化
//有序打印
printf("%d\n", s1.age);
printf("%s\n", s1.name);
printf("%s\n", s1.sex);
printf("\n");
struct Stu s2 = {.age = 18,.sex = "男",.name = "lisi" }; //无序的初始化
printf("%s\n", s2.name);
printf("%d\n", s2.age);
printf("%s\n", s2.sex);
return 0;
}
结构体的初始化用到了 点操作符( . )。
1.3 结构体的特殊声明
结构体在声明时可以不完全声明,也就是省略了struct后面的结构体的名字。如下面代码
struct
{
member-list;//结构体成员变量
}variable-list;
虽然在写匿名结构体可以省一点功夫,但是匿名结构体也是有弊端的。我们先看一段代码。
struct
{
char c;
int age;
}s;
struct
{
char c;
int age;
}*p;
int main()
{
*p = &s;
return 0;
}
当我们运行上面代码的时候就会报错。
什么原因呢?
是因为编译器会将上面的两个声明当成完全不同的两个类型,所以是非法的。
由此我们得出结论:如果结构体在匿名的并且没有重命名的情况下,匿名结构体基本就只能使用一次。
1.4 结构体的自引用
在结构体中包含一个该结构本身的成员是否可以呢?
我们也是先来看一段代码
struct Node
{
int age;
struct Node next;
};
int main()
{
return 0;
}
当我们运行上面代码的时候是会报错的。为什么呢?
我们可以从struct Node 的大小这个点出发。当我们在一个结构体中包含一个该本身的结构体时,我们会发现它会一直循环调用本身结构体,可以当成陷入死递归了,无限开发空间,这样就会导致内存非常大,这样看实际上就不合理了。
我们可以写成一下形式
struct Node
{
int age;
struct Node* next;
};
int main()
{
return 0;
}
无非就是要找下一个结构体的内容嘛,我们只需知道其地址就行了嘛,所以我们可以用一个结构体指针。
当我们使用结构体的自引用时,需要注意用typedef对匿名结构体类型重命名的情况。如下面代码
typedef struct
{
int age;
Node* next;
}Node;
int main()
{
return 0;
}
我们第一眼看到这个代码可能觉得没什么问题,我们已经将匿名结构体重命名为Node,里面也就可以写成Node* next了。但是后来发现Node是对前面匿名结构体的重命名,我们已经在结构体提前使用Node类型来创建变量了,这样明显是不行的。
2.结构体内存对齐
结构体内存对齐也是结构体里边一个比较热门的知识点,结构体内存对齐涉及到计算结构体的大小。
将这个内容之前,我们先来看一道题
struct S
{
char a;
int b;
char c;
}s1;
int main()
{
printf("%d", sizeof(s1));
return 0;
}
这道题就是计算一个结构体的的大小,它的值是多少呢?
答案是该结构体的大小为12个字节。
这就很奇怪了,char类型大小明明为一个字节,int类型为4个字节,按道理来说不应该是6个字节大小吗?
其实结构体大小的计算规则并没有那么简单,接下来让我向大家介绍下结构体大小的计算规则。
2.1 对齐规则
我们首先得掌握结构体的内存对齐规则
1, 结构体成员里面的第一个成员必须对齐到结构体变量起始位置偏移量为0的地址处。
上面是对结构体已经设计好了的一块内存,右边的数字就是相对于结构体起始位置的偏移量。
由于结构体的第一个成员要放在偏移量为0的地址处,所以对于上面的char a 要放在对应0的方格处。一个方格代表一个字节。 如下图
2.其它结构体成员要对应到对齐数的整数倍的地址处。
对齐数的介绍
对齐数=编译器默认的对齐数与该成员变量大小的一个较小值。
VS中默认的对齐数为8,Linux的gcc中没有默认的对齐数,对齐数就是成员变量大小。
了解了这些我们就可以开始分析剩下的int b和 char c了。以vs编译器来讲。
先分析a,a为int型,大小为4个字节,而vs默认的对齐数为8,4比8小,所以放置a时的对齐数为4。则要将a对应到偏移量为4的整数倍处,并且占4个字节。
在分析c,c为char型,大小为1个字节,vs默认的对齐数为8,1比8小,所以放置c时的对齐数为1,则要将c对应到偏移量为1的整数倍处,并且占一个字节。
如图
可按上图不还是只有10个字节吗?怎么有12个字节。则就涉及到第3条对齐规则。
3.结构体的总大小为所有成员中最大对齐数的整数倍。
a的对齐数为1,b的对齐数为4,c的对齐数为1,所以最大对齐数为4。
所以最终结构体的大小要是4的整数倍,所以要对齐到4的倍数。
如图
打X的地方是为了符合对齐规则而浪费的内存,也属于结构体的大小。
根据上面的分析,我们最终就得到了该结构体的大小为12个字节。
4. 结构体中嵌套了一个结构体的大小,嵌套的结构体就要对齐到自己成员中最大对齐数的整数倍处,结构体的总大小要是包括嵌套结构体成员之内的最大对齐数的整数倍。
看题
struct S
{
char a;
int b;
char c;
}s1;
struct S2
{
char d;
struct S s1;
char e;
}s2;
int main()
{
printf("%zd", sizeof(s2));
}
画出下图
得出该结构体的大小为20个字节。
2.2 为什么存在内存对齐
1.平台原因
不是所有的硬件平台能够随意访问任意地址上的任意内容,有些硬件平台只能访问特定地址上的数据内容,否则就会显示硬件异常。
2.性能原因
数据结构(尤其是栈)要尽可能的对齐内存的自然边界。原因是硬件访问不对齐的数据时有可能需要访问2次或两次以上,访问次数越多就有可能出现差错。而访问对齐的内存的时候只访问1次就够了。
如上图,上图就是对齐与不对齐的分别。
假设我们的硬件一次能访问4个字节的空间,则内存不对齐时,当c占了1个字节后,会紧接着c存放完后放置i,这样当当我们访问内存时,一次访问4个字节,访问完一次之后,发现i还有一个字节的内容没被访问,要再一次访问4个字节的空间才能访问i剩下的一个字节的内容。这样读取i时就访问了2次。
内存对齐时,当我们存完c之后,先跳过3个字节再存放i,由于一次能够访问4个字节,这样一次访问就能取到一个内容。
总体来说:结构体的内存对齐是用空间来换取效率。
所以当我们在设计结构体的时候要尽量把内存比较小的成员变量集中在一起。
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
如上面的代码,虽然成员变量一样,但是s2的内存要比s1的存小。
2.3 修改默认对齐数
#pragma能够修改默认对齐数
看代码
#pragma pack(1) //将对齐数改为1
struct S
{
char a;
int b;
char c;
}s1;
struct S2
{
char d;
struct S s1;
char e;
}s2;
#pragma pack() //取消修改对齐数,还原为默认对齐数
int main()
{
printf("%zd", sizeof(s2));
}
因为修改了对齐数,所以导致之前的20变为了8。
3.结构体传参
我们知道传参有传值和传地址的两种形式。那么结构体也有传值和传地址的两种形式。
看一下代码
struct S
{
int data[1000];
int num;
};
struct S s = {{1,2,3,4}, 1000};
//结构体传参
void print1(struct S s)
{
printf("%d\n", s.num);
}
//结构体地址传参
void print2(struct S* ps)
{
printf("%d\n", ps->num);
}
int main()
{
print1(s); //传结构体
print2(&s); //传地址
return 0;
}
以上代码就实现了结构体传参的传值和传地址的两种形式,那么那种传参形式更好呢?
我们学过函数栈帧,当我们传值时,要实现压栈的操作,并为其形参创建空间,这样就在一定成都上浪费了空间和时间。
当我们将地址传过去的时候,我们就可以直接通过地址对结构体进行访问和使用,这样就可以节约时间和空间了。
所以,结构体传参的时候,传地址是一个更好的选择。
4.结构体实现位段
当我们介绍完结构体的内存对齐规则,我们会发现有时候这样会很浪费空间,有没有一些解决方法呢?
答案就是位段。
4.1 什么是位段
位段的声明和结构体是类似的,主要有两个不同。
1.位段的成员必须是 int,signed int和unsigned int 类型的。但是在C99中位段的成员可以是其他类型的。
2.位段成员的后面必须要有一个冒号且冒号后面要有一个数字。
举例
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
上述代码就是一个位段的声明。
既然我们说合理的位段设计可以节省空间了,那么其是如何节省空间的呢?紧接着来介绍位段的内存分配原则
4.2 位段的内存分配原则
首先我们要知道位段中的位是二进制位的意思,冒号后面的数字就是该位段成员所占的二进制位数。
1.位段上的空间是按需以1个字节或者4个字节来分配空间。
2.位段涉及很多不确定因素,位段的空间开辟存在跨平台的问题,注重可移植的程序要注意位段的使用。
说了那么多,我们可能还是迷迷糊糊的,我们直接来看一道题
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = { 0 };
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
printf("%zd ", sizeof(s));
}
上面的位段内存大小是多少呢?
我们慢慢分析,由于位段是char类型的,内存按需分配,分配1个字节,也就是8个比特位
假设上面是一段设计好了的空间,按需分配一个字节,也就是8个字节。
分析a为10,其二进制写法为1001,又由于在位段中a占3个字节,所以再分配的一个字节的空间中占3个比特位,那么是先从左开始占还是从有边开始占呢?很可惜,这没有一个明确的规定。在不同的平台中,占法可能就不同,这就是我们前面提到的位段的跨平台行。vs中规定是从右向左开始占。如下图
分析完a,再来分析b,b为12,二进制写法为1011,在位段设计中占了4个字节,这时我们发现之前分配的1个字节的空间还剩有足够的空间来存储位段成员b,所以接着a继续向右存。如下图
紧接着来分析c,c的值为3,转换为二进制101,在位段中又占了5个字节,但这时发现之前分配的一个字节的空间已经不够 存储c了,那么是先把剩下的一个字节的空间占了,在从又分配的一个字节的空间占够剩下的内存呢?还是把之前剩下的空间直接浪费掉,直接在重新分配的一个空间占狗5个空间呢?
答案是直接浪费掉之前剩下的空间。
通过分析得到上图
剩下的也是一样的原则。4的二进制位100
那些浪费的空间会放置0。
由上述分析得知,该位段的大小位3个字节。
总结:虽然位段也会浪费一点空间,但只要设计的合理,相较于之前的结构体,位段还是比较节省空间的,不过存在跨平台的问题。
4.3 位段的应用
上图是网络协议中,IP数据的格式报,我们看到格式报里面有很多功能,且很多属性都占据了几个bit的空间。根据各自属性所占的空间,使用位段为其分配空间,能够更好的节省空间并且使运行效率更高。
4.4 位段使用的注意事项
位段的成员中有可能几个成员共同占据一个字节的内容,所以有些成员的起始位置并不似内存的起始位置,那么有些位置处是没有地址的。因为内存中一个字节分配一个地址,一个字节里的bit位是没有地址的。所以不能对位段成员使用&操作符。也就是说我们不能使用scanf对位段成员赋值。
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main()
{
struct A sa = {0};
scanf("%d", &sa._b);//这是错误的
//正确的⽰范
int b = 0;
scanf("%d", &b);
sa._b = b;
return 0;
}