引言
C语言已经提供了内置类型,例如:short、char、int、long、float、double等,显然,只有这些内置类型肯定是不够的。当我们想要记录一个学生的名字、年龄、成绩等信息,这个时候,单一的数据类型肯定是不够的。C语言为了解决这类问题,增加了一类数据类型,让我们根据自己的需求来自行创造合适的类型,也就是我们今天要学习的内容——结构体
结构体
什么是结构体
结构体是C语言中一种重要的复合数据类型,它允许我们将多个不同类型的数据项组合成一个单一的数据结构。通过结构体,我们可以将相关的数据聚合在一起,形成一个逻辑上的整体
结构体的使用
1.结构体的声明
结构体是一些值的集合,这些值被称为成员变量。结构体中的每一个成员可以是不同类型的变量,如:标量、数组、指针,甚至可以是其他结构体
struct tag {
type1 member_name1;
type2 member_name2;
type3 member_name3;
......
}variable_name;
tag是结构体标签,定义你需要的结构体名,如book,student等
type1 member_name是标准的变量定义,根据需求设定合适的变量类型和变量名
variable_name是结构变量,定义在结构的末尾,最后一个分号之前,我们可以指定一个或多个结构变量,也可以省略
【注】在定义结构体的时候,大括号最后一定要记得加分号:‘;’
2.结构体的定义方式
既然结构体是一种数据类型,那它就像C语言基础的内置类型一样,可以去定义变量,下面就举个简单的例子:
(1)先声明结构体类型,再声明变量
struct student
{
char name[20];
int id;
char sex;
int age;
int score;
};
struct Student stu; //定义一个结构体变量
(2)直接声明结构体变量
struct Student
{
char name[20];
int id;
char sex;
int age;
int score;
}stu;
(3)嵌套结构体
在嵌套结构体中,一个结构体(外部结构体)可以包含另一个结构体(内部或嵌套结构体)作为其成员。这种结构体通常应用于一些比较高级的数据结构,例如链表
struct Student
{
int id;
char sex;
int age;
int score;
};
struct people
{
char name[20];
struct Student stu;
};
int main()
{
struct people stu = { "zhangsan",114514,'M' ,18,60 };
return 0;
}
我们可以通过监视来观察一下
(4)匿名结构体
在声明结构体的时候,可以不完全的声明
在C语言中,当我们在定义结构体变量时直接给出了结构体的成员,而没有为结构体类型本身指定一个名字,那么这个结构体就被称为匿名结构体
举个例子:
struct
{
char name[20];
int id;
char sex;
int age;
int score;
}x;
我们定义了一个结构体,并且直接创建了一个该类型的变量x。但是,由于我们并没有为这个结构体类型命名,所以它是一个匿名结构体。这意味着除了x之外,我们无法创建更多该类型的变量,除非我们再次定义整个结构体
因此,匿名结构体类型,如果我们没有对结构体类型重命名的话,基本上只能用一次,匿名结构体在定义它的作用域内是可见的,但在外部是不可见的
注:通常情况下,我们不使用匿名结构体
(5) typedef简化结构体
有的时候结构体的名称比较长,不利于我们阅读代码,这个时候我们可以用typedef对其进行简化
typedef struct Student
{
char name[20];
int id;
char sex;
int age;
int score;
}stu;
这样子我们就可以用stu代替struct student
我们需要注意:如果在结构体内部引用了该结构体的类型(例如,在链表中),你需要首先声明该结构体,然后再使用 typedef 定义别名,编译器需要知道结构体的存在才能处理其中的成员
3.结构体的创建和初始化
简要介绍了结构体的定义,接下来我们来学习结构体的创建和初始化
struct Student
{
int id;
char sex;
int age;
int score;
};
struct people
{
char name[20];
struct Student stu;
};
int main()
{
struct people stu = { "zhangsan",114514,'M' ,18,60 };
return 0;
}
(1)直接访问
结构体成员的访问可以通过点操作符(.
)进行访问操作
如下所示:
struct Point
{
int x;
int y;
int z;
};
int main()
{
struct Point p = { 1,2,3 };
printf("x: %d y: %d z: %d\n", p.x, p.y, p.z);
return 0;
}
输出结果为:
x: 1 y: 2 z: 3
(2)间接访问
除了通过(.
)操作符直接访问,我们也可以通过结构体地址,利用(->)操作符间接访问
struct Point
{
int x;
int y;
int z;
};
int main()
{
struct Point p = { 1, 3, 4 };
struct Point* ptr = &p;//结构体指针
ptr->x = 5;
ptr->y = 2;
ptr->z = 1;
printf("x = %d y = %d z= %d\n", ptr->x, ptr->y, ptr->z);
return 0;
}
输出结果为:
x = 5 y = 2 z= 1
结构体内存对齐
介绍完结构体的基本内容,接下来我们来学习一下结构体大小的计算,即结构体内存对齐
1.结构体的内存对齐规则
我们先来看一段代码:
#include <stdio.h>
struct example
{
int a; //4个字节
char b; //1个字节
float c; //4个字节
}s;
int main()
{
size_t sz = sizeof(s);
printf("大小为%zu\n", sz);
return 0;
}
输出结果为:
大小为12
如果结构体的大小只是简单地将各变量类型的字节数相加,输出结果应该为9才对,但是实际的输出结果为12,这是为什么?
这就涉及到今天我们需要学习的结构体内存对齐规则
C语言在分配结构体内存时遵循以下几条规则:
1.结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址
注:对齐数=编译器默认的一个对齐数 与 该成员变量大小的较小值
我使用的编译器为 VS2022,默认值为8
3.结构体总大小为最大对齐数(结构体中的每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍
4.如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍
我们来通过图表的形式来看看上面那段代码中结构体是如何存放的:
2.为什么存在内存对齐规则
平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分为在两个8字节内存块中
总的来说:结构体的内存对齐是拿空间来换取时间的做法
那么,在我们设计结构体的时候,我们应当尽量满足对齐,又要节省空间
为了实现这一需求,我们尽量将占用空间小的结构体成员放在一块
3.宏——offsetof
offsetof是一个在 C 语言标准库 <stddef.h> 中定义的宏,它用于计算结构体(struct)或联合体(union)中某个成员相对于结构体或联合体首地址的偏移量(以字节为单位)。offsetof 宏通常用于底层编程、内存操作或者与特定硬件接口的代码
原型:offsetof (type,member)
这里的 type 是结构体或联合体的类型,而 member-designator 是该类型中某个成员的名称
#include <stdio.h>
#include <stddef.h>
struct example
{
char c1;
int i;
char c2;
};
int main() {
printf("Offset of c1: %zu\n", offsetof(struct example, c1)); // 输出: 0
printf("Offset of i : %zu\n", offsetof(struct example, i)); // 输出可能是 4(取决于平台和编译器)
printf("Offset of c2: %zu\n", offsetof(struct example, c2)); // 输出可能是 8(取决于平台和编译器)
return 0;
}
输出结果为:
Offset of c1: 0
Offset of i : 4
Offset of c2: 8
我们来看个图片,或许更容易理解一点
1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置
2.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4
3.c2是char类型,默认对齐数为1的整数倍,偏移量为8
4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为12
4.示例
(1)示例1
struct example1
{
char c1;
int i;
char c2;
}s1;
struct example2
{
char c1;
char c2;
int i;
}s2;
int main()
{
printf("%zu\n", sizeof(s1));
printf("%zu\n", sizeof(s2));
return 0;
}
输出结果为:
12
8
解析:
1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置
2.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4
3.c2是char类型,默认对齐数为1的整数倍,偏移量为8
4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为12
1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置
2.c2是char类型,默认对齐数为1的整数倍,偏移量为1
3.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4
4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为8
(2)示例2
struct example3
{
double d;
char c;
int i;
}s3;
int main()
{
printf("%zu\n", sizeof(s3));
return 0;
}
输出结果为:
16
解析:
1.d是double类型,占八个字节,根据内存对齐规则1,d放在偏移量为0的位置
2.c是char类型,默认对齐数为1的整数倍,偏移量为1
3.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4
4.最大对齐数为8,结构体大小为8的整数倍,因此这个结构体的大小为16
(3)示例3
struct example3
{
double d;
char c;
int i;
}s3;
struct example4
{
struct example3 s3;
char c1;
double d;
}s4;
int main()
{
printf("%zu\n", sizeof(s4));
return 0;
}
输出结果为:
32
解析:
- 结构体S3的大小为16,放在偏移量为0的位置
- c1的默认对齐数为1,放在1的整数倍16的位置,大小为1
- d的默认对齐数为8,放在1的整数倍24的位置,大小为8
- 最大对齐数为8,结构体大小为8的整数倍,大小为32
5.修改默认对齐数
#pragma这个预处理指令,可以改变编译器的默认对齐数
#pragma pack(1) //设置默认对齐数为1
struct example5
{
char c1;
int i;
char c2;
}s5;
int main()
{
printf("%zu\n", sizeof(s5));
return 0;
}
输出结果为:
6
由于我们设置默认对齐数为1,因此结构体成员在内存中是连续储存的,这时结构体大小等于每个结构体成员大小之和
结构体传参
函数传参分为两种,一种是直接传参:直接传变量;一种是间接传参:通过传变量地址间接访问
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;
}
上面的print1和print2函数选择哪一个会比较好?
答案是:选择print2会比较好
原因是:传参时形参是实参的临时拷贝,也就是说系统会将参数复制一份,当一个参数的数量极大时会造成不必要的内存分配,而传址调用系统只用分配四个字节或八个字节,大大节约内存
位段
什么是位段
位段(或称“位域”)是C语言特有的数据结构,它允许开发者以位为单位来指定结构体成员所占的内存长度。利用位段,我们可以用较少的位数来存储数据,这在需要节省存储空间或处理特定控制信息时非常有用
1.位段的成员必须是int、unsigned int或者signed int,在C99中位段成员的类型也可以是选择其他类型
2.位段的成员名后面有一个冒号和一个数字
例如:
struct A
{
int _a : 1;
int _b : 2;
int _c : 3;
int _d : 4;
};
位段的几个成员有可能会共用一个字节,这样会导致有些成员的起始位置并不是在某个字节的起始位置,那么这些位置是没有地址的。内存中每个字节分配一个地址,一个字节内部的比特位是没有地址的
因此我们不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段成员赋值,只能是先输入放在一个变量中,再赋值给位段的成员
struct A
{
int _a : 1;
int _b : 2;
int _c : 3;
int _d : 4;
};
int main()
{
struct A sa = { 0 };
scanf("%d", &sa._b); //错误的示范
int b = 0;
scanf("%d", &b);
sa._b = b;
return 0;
}
位段的内存分配
1.位段的成员可以是int、unsigned int或者char类型
2.位段的空间是按照需要以4个字节(int)或者是1个字节(char)的方式进行开辟的
3.位段涉及许多不确定因素,位段是不跨平台的,注重可移植的程序应当避免使用位段
我们来看个例子:
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main()
{
printf("大小为%zd\n", sizeof(struct A));//输出什么?
return 0;
}
输出结果为:
8
2+5+10+30=47,而输出结果为8,即为64个比特位,可以看到,位段的节省空间的能力还是有限的
接下来我们来探讨一下位段的分配:
当一个结构体包含两个位段,第二个位段比较大,无法容纳于第一个位段剩余的位时, 是舍弃剩余的位还是利用呢?
我们来试着用一个具体的示例去演示一下:
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;
return 0;
}
我们假设:位段分配的内存中的比特位是从右向左使用的,分配剩余的比特位不够使用时,浪费掉剩余内存
则:
1.我们先定义位段,如图所示:
2.执行程序,将设定的值存入
3.将值转换:
4.最后我们来验证一下我们的猜测:
通过验证我们可以知道在VS2022环境下我们的猜想:位段分配的内存中的比特位是从右向左使用的,分配剩余的比特位不够使用时,浪费掉剩余内存,是正确的
位段的跨平台问题
1.int位段被当作有符号数还是无符号数是不确定的
2.位段中最大位的数目是不确定的
3.位段中的成员在内存中从左向右分配,还是从右向左分配的标准是未定义的
4.当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余位还是利用,这时不确定的
总结:
跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在
结束语
这篇文章简单的介绍了一下结构体和位段,希望看到这篇文章的友友们能点赞收藏加关注
十分感谢!!!