首先我们要了解为什么会有结构体以及结构体到底是什么,在前面的学习中我们已经知道C语言提供了内置类型,如:char、short、int、long、float、double……,但是只有这些内置类型还是不够的,比如说我想描述一名学生的信息,或者描述⼀本书的信息,那么这时单⼀的内置类型就不行了。因为在描述⼀个学生需要名字、年龄、学号、体重等等,而描述⼀本书也需要作者、出版社、定价等等。于是乎C语言为了解决这个问题,因此增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型便于自己使用,所以结构体其实就是⼀些值的集合,我们将这些值称为成员变量。结构的每个成员可以是不同类型的变量,比如:标量、数组、指针,甚至于是其他结构体等等都是可以作为成员变量的。
声明以及初始化
声明
那么接下来我们就讲讲结构体的声明:
struct tag//tag是一个标签用于区分不同的结构体,struct tag相当于就是一个数据类型
{
member-list;//member-list是成员变量,可以为不同数据类型,指针,结构体都行
}variable-list;//variable-list是结构体变量名,重点:变量名后面有一个分号不要忽略了
例如描述⼀个学生时:
struct Stu
{
char name[20];//名字
int age; //年龄
char sex[5]; //性别
char id[20]; //学号
}; //切记切记切记分号不能丢!
特殊声明
匿名结构体类型:在声明结构的时候,有时候我们也可以不完全的声明,即不写结构体标签,那么我们就称这个结构体为匿名结构体类型
struct
{
int a;
char b;
float c;
}x;
要注意的是匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使用⼀次。
结构体的自引用:其实如果我们直接自引用那么因为⼀个结构体中再包含⼀个同类型的结构体变量,我们就会发现这样会无限嵌套,一个套一个,一直套下去那么这样最终的结果就是结构体变量的大小就会无穷的大,那么这时我们应该如何完成自引用呢?那么这这时我们想到前面学过的指针,让这个结构体包含一个自身类型的指针变量不就完成了自引用吗,同时这样一个指针指向下一个结构体就不会造成上述问题了。另外结构体以及自引用在后续数据结构的学习中很重要,格式如下:
struct Node
{
int data;
struct Node* next;
};
初始化
在声明之后我们还要初始化,这里我们直接以上述结构体声明为例描述一个学生:
#include <stdio.h>
int main()
{
//按照结构体成员的顺序初始化
struct Stu s = { "张三", 20, "男", "20230818001" };
printf("name: %s\n", s.name);
printf("age : %d\n", s.age);
printf("sex : %s\n", s.sex);
printf("id : %s\n", s.id);
//按照指定的顺序初始化
struct Stu s2 = { .age = 18, .name = "lisi", .id = "20230818002", .sex = "⼥
printf("name: %s\n", s2.name);
printf("age : %d\n", s2.age);
printf("sex : %s\n", s2.sex);
printf("id : %s\n", s2.id);
return 0;
}
这里有些人可能会对初始化有些难以理解,其实我认为:
1. 首先其实你就可以想象定义一个int类型的变量a,那么这里的struct Stu就是和int类型一样的数据类型,而s1,s2就是和a一样的变量;
2. 其次因为上面的声明对姓名,性别,学号的定义是字符串的形式,所以在初始化的时候需要采用引号将字符串各自的初始化内容引起来;
3. 最后你有没有发现,结构体和数组有点类似,但是不同的是结构体存放不同数据类型的内容,而数组则是存放相同数据类型的内容,所以在结构体初始化时同样以花括号将数据括起来,同时用逗号隔开的形式初始化。
结构体成员访问操作符
在初始化之后我们不能就这样不管了,不可能就是玩玩罢了是不是,我们还要对其进行使用,因此下面我们来讲讲结构体成员访问操作符。对于结构体成员的访问操作我们可分为两类:一是直接访问,二是间接访问。
直接访问
结构体成员的直接访问是通过点操作符(.)访问的。点操作符接受两个操作数。
使用方式:结构体变量.成员名,例如:
#include <stdio.h>
struct Point
{
int x;
int y;
}p = {1,2};//声明一个结构体变量
int main()
{
printf("x: %d y: %d\n", p.x, p.y);//直接访问
return 0;
}
间接访问
有时候我们得到的不是⼀个结构体变量,而是得到了⼀个指向结构体的指针,那么这时我们就可以采用间接访问来访问各个成员了。
使用方式:结构体指针->成员名,例如:
#include <stdio.h>
#include <string.h>
struct Stu
{
char name[15];//名字
int age; //年龄
};
void print_stu(struct Stu s)
{
printf("%s %d\n", s.name, s.age);
}
void set_stu(struct Stu* ps)
{
strcpy(ps->name, "李四");//将李四放到ps结构体变量下的成员name中
ps->age = 28;//将28赋值给ps结构体变量下的成员age中
}
int main()
{
struct Stu s = { "张三", 20 };
print_stu(s);//一开始是张三20岁
set_stu(&s);
print_stu(s);//最后是李四28岁
return 0;
}
结构体的大小及内存对齐
所有的数据类型都有其大小,那么上面我们完成了一个结构体的声明后,由于其是由不同的数据类型拼凑而成,那么这时我们该如何计算其大小呢,那么就不得不提结构体的对齐规则了。对齐规则如下:
1. 结构体的第⼀个成员对齐到和结构体变量起始位置偏移量为0的地址处,对于偏移量的解释,我们就可以把内存想象成一排房子,从左到右(从0开始)依次编号,然后我们把起始位置偏移量设置为第一个房子的左边墙壁,那么对于第一个房子,它对于自己左边的墙壁而言中间就没有房子就只有它自己,就相当于没有进行偏移,那么偏移量就是0了,以此类推第二个房子中间隔了第一个房子那么它对于墙壁偏移了一个房子,那么它的偏移量就是1了……这就是偏移量,就是对于起始位偏移量中间隔了多少个字节单元就是偏移了多少,例如:
起始0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
2. 其他成员变量要对齐到对齐数的整数倍的地址处。
对齐数 = 编译器默认的⼀个对齐数与该成员变量大小的较小值。
整数倍:比如假设默认对齐数为8,此时第一个为char类型那么就直接放在第一个位置,但是再来一个int类型的,那么由于int类型大小是4<8,那么因为要对齐到对齐数整数倍地址的原因,那么它就不能直接放在char类型后面,中间应该隔3个字节,然后放到偏移量为4的位置了,这时就对齐到了对齐数1倍的地址了,这个对齐数的整数倍其实就是偏移量对于对齐数的倍数,其图如下;
起始位置char类型 | 1 | 2 | 3 | int类型 | int类型 | int类型 | int类型 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
注意:不同编译器下默认对齐数都不同,VS 中默认的值为 8,而Linux中 gcc 没有默认对齐数,对齐数就是成员自身的大小,而既然都有了默认对齐数我们是不是就可以对其进行修改了对不对,于是就有了修改默认对齐数的操作,通过#pragma 这个预处理指令,来修改编译器的默认对齐数,例如:
#pragma pack(1)//设置默认对⻬数为1
#pragma pack()//取消设置的对⻬数,还原为默认
3. 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的 整数倍。直接举例来说吧:
struct S1
{
char c1;//对齐数为1
int i; //对齐数为4
char c2;//对齐数为1
};
//本来由于上面两条规则大小应该为9,但是由于第三条规则需要对齐到最大对齐数的整数倍,这里不难看出最大对齐数为4,而9不是4的整数倍,所有需要对齐到整数倍,那么此时的大小就是12了
struct S2
{
char c1;//对齐数为1
char c2;//对齐数为1
int i; //对齐数为4
};
//同理其大小为8,是4的整数倍不需要再对齐了,所有它的大小就是12
可以看出S1和S2类型的成员⼀模⼀样,但是S1和S2所占空间的大小却不一样,所以在设计结构体时我们可以让占用空间小的成员尽量集中在⼀起,这样我们就可以在满足对齐的同时节省空间。
4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构 体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
struct S1
{
double d;//对齐数为8
char c;//对齐数为1
int i;//对齐数为4
};
//大小为16是最大对齐数的整数倍,8+1+(3)+4=16,()里面表示浪费的字节数
struct S2
{
char c1;//对齐数为1
struct S1 s1;//大小为16,自己成员中的最大对齐数为8
double d;//对齐数为8
};
//大小为32是最大对齐数的整数倍,1+(7)+16+8=32,同样()里面表示浪费的字节数
在了解上面结构体内存对齐规则后我们就可以去计算结构体的大小了,另外我们不难看出结构体的内存对齐就是一种拿空间来换取时间的做法。
结构体传参
在了解这些之后,要是这时来一个函数,需要结构体作为参数,那么这时结构体在函数调用时我们又该如何对其传参呢?一般来说,我们可直接传参,当然还有一种方式,那就是通过指针传地址间接传参,代码如下:
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;
}
那么对比上面两种不同的传参方式,因为函数在传参的时候,其参数是需要压栈操作的,而这会有时间和空间上的系统开销。所以如果传递⼀个结构体对象的时候,如果结构体过大,那么参数压栈的的系统开销比较大,于是就会导致性能的下降。所以我们在结构体传参时,一般都是采用指针传结构体的地址,以提高性能。
结构体实现位段
上面的结构体讲完就得讲讲结构体实现位段的能力。
位段定义
首先我们先了解一下什么是位段,其实很简单就是结构体中以比特位为单位来指定其成员所占内存长度,而这种以位为单位的成员我们就称之为“位段”。
位段的声明和结构
位段的声明和结构是类似的,但是有两个不同:一,位段的成员必须是 int、unsigned int 或signed int ,在C99中位段成员的类型也可以选择其他类型;二,位段的成员名后边有⼀个冒号和⼀个数字,这个数字就代表要申请的多少位bit位。
例如:
struct A
{
int _a:2;//两个bit位
int _b:5;//五个bit位
int _c:10;
int _d:30;
};
位段大小和跨平台问题
上面的A就是⼀个位段类型。 那位段A所占内存的大小又是多少呢?上面程序不难看出一共申请了47bit位,而一个整型是4字节也就是32bit位,所以一个int还不够还需要再开辟一个int类型来存储,于是就是两个int类型的字节数也就是8字节。
这是因为位段的内存分配:位段的成员需要是 int unsigned int signed int 或者是char等类型;且位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的,上面的程序就是以int型开辟的。
最后位段又涉及很多不确定因素,且不跨平台,因为在不同平台:int 位段被当成有符号数还是无符号数是不确定的;位段中最大位的数目不能确定,16位机器最大为16,32位机器最大为32,那么写成27,在16位机器会出问题;位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义;当⼀个结构包含两个位段,第二个位段成员比较大,无法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的……所以在注重可移植的程序应该避免使用位段。