一.结构体类型和结构体变量
结构体和结构体变量是两个不同的概念(一个抽象、一个具体)。结构体类型是一种数据类型,它和int、char、double是一样的,只不过这个类型需要我们人为进行定义(比如结构体名称、结构体里面变量名、变量类型)。
结构体变量是我们根据我们先创建的结构体类型来创建的,它里面存放的是具体数据,这些数据和结构类型里面的数据类型是相对应的。
| 结构体类型 | 结构体变量 |
|---|---|
| 不同类型的数据组合形成一个整体 | 基于结构体类型创建的具体变量,具体实例 |
| 不占用实际的内存空间 | 会占用实际的内存空间 |
二.结构体类型声明
类型声明就是定义结构体类型的过程,定义结构体需要用到关键字struct。
2.1 结构体类型的一般声明
c
//定义一个名为student的结构体类型
struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
}; //;一定不能忘
2.2 结构体的特殊声明
特殊声明有两种:匿名结构体、嵌套结构体
2.2.1 匿名结构体声明
c
struct
{
int a;
char b;
float c;
}x; //这里的x就是结构体变量,并且是全局变量
对于匿名结构体,如果类型重命名的话,基本上只能使用一次。
c
struct
{
int a;
char b;
float c;
}x;
struct
{
int a;
char b;
float c;
}* p;
int main()
{
p = &x;
return 0;
}
运行结果:

编译器会把上面的两个声明当成完全不同的两个类型(显然p和&x的类型应该是一样的),所以是非法的。
2.2.2 嵌套结构体声明
c
//嵌套结构体,顾名思义,就是在定义一个结构体类型时,里面包括另外类型的结构体变量
//先定义一个名为Date的结构体类型
struct Date
{
int year;
int month;
int day;
};
//再定义一个名为person的结构体类型
struct person
{
char name[20];//名字
int age;//年龄
float height;//身高
float weight;//体重
struct Date birthday;//名为birthday的结构体变量,类型是struct Date
};
2.2.3 结构体自引用
结构体自引用是指在定义结构体类型时,使用该结构体本身的类型作为成员类型,从而形成一种递归或者循环的数据结构。包括直接自引用和间接自引用两种形式,但是直接自引用通常不可以,因为编译器要确定结构体的大小,直接自引用会让结构体无限递归下去。
c
struct Node
{
int data;
struct Node next;//无法确定大小
};

c
//修改后:
struct Node
{
int data;
struct Node* next;//指针大小为4/8个字节
};
三、结构体变量创建和初始化
c
//全局变量
struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
} x = {{"xiao ming"}, {18}, {170.25}, {59.6}}; //x是结构体变量名,等号右边是初始值
struct
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
} y = {{"yao ming"}, {20}, {181.2}, {60.7}};
//y是结构体变量名,等号右边是初始值
//局部变量
struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
};
int main()
{
struct student s = {{"xiao ming"}, {18}, {170.25}, {59.6}};
//s是结构体变量名,等号右边是初始值
return 0;
}
//如果嫌结构体类型名字太长
//采用typedef重新定义
typedef struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
} Su;
Su s = {{"xiao ming"}, {18}, {170.25}, {59.6}}; //s是结构体变量,且为全局变量
三、结构体成员访问
两种访问形式:
结构体变量名.成员变量名
c
#include <stdio.h>
struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
};
int main()
{
struct student s = {{"xiao ming"}, {18}, {170.25}, {59.60}};
printf("%s",s.name); //xiao ming
printf("%d",s.age); //18
printf("%.2f",s.height); //170.25
printf("%.2f",s.weight); //59.60
return 0;
}
运行结果:

结构体指针->成员变量名
c
#include <stdio.h>
struct student
{
char name[20];//姓名
int age;//年龄
float height;//身高
float weight;//体重
};
int main()
{
struct student s = { {"xiao ming"}, {18}, {170.25}, {59.6} };
struct student* ps = &s;
printf("%s\n", ps->name);//xiao ming
printf("%d\n", ps->age);
printf("%.2f\n", ps->height);
printf("%.2f\n", ps->weight);
return 0;
}
运行结果:

四、结构体内存对齐
c
struct S1
{
char c1;//1
char c2;//1
int n;//4
};
struct S2
{
char c1;
int n;
char c2;
};
int main()
{
printf("%zu\n", sizeof(struct S1));
printf("%zu\n", sizeof(struct S2));
return 0;
}
运行结果:

按照我们的理解:结构体的大小是里面成员大小之和,也就是6个字节,但是结果相差太多。
问题出现在哪里?这说明结构体的成员在内存中不是连续存放。
4.1 偏移量
偏移量是相对于某个基准地址的距离或者偏移值,比如,我们将内存的第一个字节记为基准地址,那么后续每个字节的偏移量为n-1(n为第n个字节)。
偏移量如何计算?
在C语言库中有一个专门用来计算偏移量的函数 --- offsetof。
c
#include <stddef.h>
struct stu
{
char c1;
int n;
char c2;
};
int main()
{
struct stu s;
int ret = offsetof(struct stu, c1);
printf("%d\n",ret);
return 0;
}
结构体变量中c1、n、c2偏移量:



求得偏移量后,可以知道,c1从基准地址处开始存放,n从偏移量4的位置开始存放,c2从偏移量为8的位置开始存放。此时,结构体各成员在内存中的布局如下:

按照上面的内存布局,结果应该是9个字节,还是和结果对不上啊。别着急,结构体内存对齐可不止这么点内容。
4.1 结构体内存对齐规则
1.结构体的第一个成员对齐到相对结构体变量起始位置偏移量为0的地址。
2. 其他成员变量要对齐到对齐数的整数倍地址处。
PS:什么是对齐数?
对齐数 = 编译器默认的一个对齐数与该成员变量大小的较较小值。
VS编译器的默认对齐数是8。
3.结构体总大小为结构体最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的那一个)的整数倍。
4. 如果存在嵌套结构体的情况,嵌套的结构体成员对齐到自己成员中最大对齐数的整数倍处,结构体的整体总大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
回过头再看前面的代码:struct S2的最大对齐数是4,9显然不是4的倍数,还需要3个字节的空间,所以结果是12个字节。
4.2 为什么存在结构体内存对齐
平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐(最大对齐数的整数倍)。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
假设一个处理器总是从内存中取8个字节,则它读取的时候是从偏移量为8的倍数的地址处开始访问。如果我们能保证将所有的double类型的数据的地址偏移量都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两 个8字节内存块中。
可以用下面两幅图进行演示:

如果数据按照上面这种方式存放,通过一次内访问就能拿到8个字节的内容。

如果按照上面这种方式存放数据,需要访问内存操作需要进行两次,第一次只能拿到3个字节的数据,第二次拿到5个字节的数据,再进行拼接,效率比较低。
总结:结构体内存对齐是一种牺牲空间换取效率的操作(空间换时间)。
那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到?
让占用空间小的成员尽量集中在一起。
4.3 修改默认对齐数
如果我们想让结构体再内存中连续存放,可不可以呢?
答案是可以的,这就需要我们修改编译器的默认对齐数。
修改编译器的对齐数需要用到指令:#pragma pack();
c
#pragma pack()
struct stu
{
char c1;
int n;
char c2;
};
int main()
{
printf("%zd\n",sizeof(struct stu));
}
运行结果:

修改后结构体大小就为6,跟我们想的一样,但是不建议这样操作,因为会让程序运行效率降低。
五、结构体传参
c
#include <stdio.h>
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/8个字节。
六、结构体实现位段
什么是位段?
位段是一种特殊的数据结构,用于按位存储和操作数据,能有效节省内存。
位段基于结构体,位段的成员必须是 int、unsigned int或signed int,在C99标准中位段成员的类型也可以选择其他类型(如:char,char类型变量在内存中存放的是它的ASCII码)。
位段的成员名后边有一个冒号和一个数字。
c
//位段举例
#include <stdio.h>
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};//数字表示这个成员要占用的比特位数量
struct B
{
int _a;
int _b;
int _c;
int _d;
};
int main()
{
printf("%zu\n", sizeof(struct A));
printf("%zu\n", sizeof(struct B));
return 0;
}
运行结果:

设计成位段,结构体在内存中占用空间明显变小。相应的,里面能存放的数据也会变小。
什么时候用位段?
能够明确知道数据占用几个比特位!
将结构体A中成员所占内存空间大小相加得到47 bit,最多需要6个字节空间就够了,为什么编译器要分配8个字节的空间?
说明位段在内存中也不是连续存放,有自己的分配规则。
6.1 位段内存分配
1)位段的成员可以是 int、unsigned int、signed int、char类型数据。
2)位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。 (也就是是说,先申请一块4个字节或1个字节的内存空间)
3)位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
c
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
int main()
{
struct S s = { 0 };
s.a = 10;//00000000 00000000 00000000 00001010 ------ 010
s.b = 12;//00000000 00000000 00000000 00001100 ------ 100
s.c = 3;//000000000 00000000 00000000 00000011 ------ 011
s.d = 4;//000000000 00000000 00000000 00000100 ------ 100
return 0;
}
内存布局:

分析:

char类型每次创建一个字节(8bit),在VS编译器中,一个字节(或int)内部从右向左边存入数据,一个字节(或int)内部的空间存不下,直接浪费掉,所以结构体s需要3个字节大小的空间,内存中以16进制形式存储:62 03 04。
不确定的方面:
(1)一个字节(整形)的内部,到底是从左向右使用还是从右向左使用不确定。
(2)一个字节(整形)内部剩下的空间不能满足下一个成员的时候,是否浪费不确定。
前面代码结果是8个字节原因:

6.2 位段跨平台问题
1.int 位段被当成有符号数还是无符号数是不确定的。
2.位段中最大位的数目不能确定,比如,16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。
3.一个字节(整形)的内部,到底是从左向右使用还是从右向左使用不确定。
4.一个字节(整形)内部剩下的空间不能满足下一个成员的时候,是否浪费不确定
6.3 位段的应用
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小一些,对网络的畅通是有帮助的。

6.4 位段注意事项
位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的。 所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输⼊值,只能是先输入放在变量中,然后赋值给位段的成员。
c
//错误示范:
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
int main()
{
struct A sa = { 0 };
scanf("%d", &sa.b);
return 0;
}
//正确示范
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
int main()
{
struct A sa = { 0 };
int b = 0;
scanf("%d", &b);
sa.b = b;
return 0;
}