引言✨
我们知道C语言中存在着整形(int、short...),字符型(char),浮点型(float、double)等等内置类型,但是有时候,这些内置类型并不能解决我们的需求,因为我们无法用这些单一的内置类型来描述一些复杂的对象,如一个学生,一本书等等。出于这个原因,C语言还给我们提供了一些自定义的数据类型使我们可以自己来构建类型,如结构体、枚举、联合体。其中最常使用的就是我们本期的主题:结构体。
可能有很多人已经使用过结构体类型来解决一些实际问题了。但是对于结构体,还是有很多 细节值得我们去深挖的,下面就让我们来看看吧!
温馨提示: 可以通过目录进行快速定位哦 😍
结构体的声明💫
2.1 结构体的基础知识
在开启本期内容之前,我们先来回顾以下结构体的 基本概念:
结构体是C语言中一个非常重要的数据类型。该数据类型是由一组称为成员变量的数据组成,其中每个成员可以是不同类型的变量,甚至可以是另一个结构体变量。结构体通常用来表示类型不同但又相关的若干数据。
2.2 结构体的声明
结构体的声明格式如下:
struct tag
{
member-list;
}variable-list;
struct是结构体关键字,我们要定义结构体类型时必须使用它
tag是结构体标签,它用来区分不同的结构体类型
结构体关键词与标签共同组成了结构体的类型,与int,float这些是一个意思,我们可以使用struct tag+变量名来定义一个结构体变量。
member-list代表成员列表,它包含了结构体的成员变量。
variable-list表示变量列表,我们可以在声明结构体类型的同时创建结构体变量。当然我们也可以不写,仅声明一个结构体类型。
结构体大括号后面的分号必不可少。
例如,我们可以这样使用结构体来描述一个学生:
//声明一个学生类型
struct Student
{
char name[20];//姓名
char sex[5];//性别
char id[20];//学号
int age;//年龄
float score;//绩点
};
int main()
{
struct Student s1;//定义一个学生结构体变量s1
}
当然,如果你嫌结构体的类型名太长,写起来麻烦,可以使用typedef对类型进行重命名,如下:
//声明一个学生类型,并用typedef类型重定义为Stu
typedef struct Student
{
char name[20];//姓名
char sex[5];//性别
char id[20];//学号
int age;//年龄
float score;//绩点
}Stu;
int main()
{
Stu s1;//相当于sturuct Student s1
}
2.3 特殊的声明
除以上的声明方式,我们也可以使用不完全的声明。例如:
//声明匿名结构体类型
struct
{
int a;
char b;
float c;
}x;
struct
{
int a;
char b;
float c;
}a[20], *p;
上面两个结构体的声明 省略了结构体标签tag,我们把这样的结构体类型称作 匿名结构体类型。
但是,这样子的声明往往是 一次性的。由于我们省略了标签,我们就无法在其他地方使用这个类型来创建一个结构体变量。毕竟连名字都没有,怎么用来定义变量。
当然,如果 你只想用一次你创建的类型,或者 你不想要这个结构体类型被别人使用,你可以声明一个匿名结构体类型。
那么问题来了:
int main()
{
//在上面匿名结构体声明的基础上,下面的代码合法吗?
p = &x;
}
答案是编译器会报警告:
尽管两个匿名结构体的成员列表一模一样,但是编译器依然会将其 当作两个完全不同的类型,两个不透类型的指针相互赋值自然是 非法的。
结构体的自引用🌟
我们在创建链表时,往往用结构体来表示链表的结点。结构体的成员分为数据域与指针域:
数据域:用来存储当前结点的值
指针域:用来存储指向下一结点的地址
typedef int ListDataType;
struct ListNode
{
ListDataType val;//数据域
struct ListNode* next;//指针域
};
我们将上面这种 结构体中包含有指向自身结构体变量的指针的方式称作 结构体的自引用。其中val占4个字节,next是个指针,占4/8个字节, 结构体具有一个确定的大小。
那既然我们这样声明结点目的是为了能够找到下一结点的位置,那我们可不可以这样设计结点:
typedef int ListDataType;
struct ListNode
{
ListDataType val;//数据域
struct ListNode next;//保存下一结点
};
答案是 不行的。假如可以这样设计, 那么sizeof( struct ListNode)的大小该是多少呢?我们是求不出来的,因为假设我们用这个类型创建了一个结构体变量n,那么n中包含着next,next也是结构体变量,又包含着一个next变量,next又包含着next...,这样下去就变成了 无限套娃。既然不知道大小,我们又要如何分配空间给结构体变量呢?
注意:
//这样写代码,可行否?
typedef struct
{
int data;
Node* next;
}Node;
显然是不行的, 凡是都要讲究个 先来后到。当我们在成员列表中定义Node*类型的变量时,此时编译器还不知道Node是什么鬼东西,自然会报错。我们可以这样修改代码:
//解决方案:
typedef struct Node
{
int data;
struct Node* next;
}Node;
Node* pn;//定义一个结构体指针pn
4. 结构体变量的定义和初始化🌊
有了结构体类型,那我们要如何定义变量呢?实则很简单
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1
struct Stu //类型声明
{
char name[15];//名字
int age; //年龄
};
int main()
{
//定义结构体变量p2
struct Point p2;
//初始化:定义变量的同时赋初值。
struct Point p3 = { 3, 4 };
//初始化
struct Stu s = { "zhangsan", 20 };
}
结构体嵌套结构体的初始化方式如下:
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = { 10, {4,5}, NULL }; //结构体嵌套初始化
int main()
{
struct Node n2 = { 20, {5, 6}, NULL };//结构体嵌套初始化
}
5.结构体的内存对齐
🔉快醒醒,别睡了
终于到了本期的重点内容了,我们来看下面例题:
//练习
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
答案如下:
这里可能有人就纳闷了,欸,char类型占1个字节,int类型占4个字节,s1与s2的大小不应该都是1+1+4=6吗?怎么会是12和8呢?这就要谈到结构体在内存中的存储了,即 结构体的内存对齐。
实际上S1在内存中的存储方式是这样子的:
我们看到c1存放完后,i并不是紧挨着c1进行存放,而是从偏移量为4的地方开始存储,中间空出三个字节的空间。这就是结构体的内存对齐,下面我们来了解其内存对齐的规则:
结构体的第一个成员在与结构体变量偏移量为0的地址处
其他成员变量要对齐到某个数字(我们称作对齐数)的整数倍的地址处
对齐数=编译器默认的一个对齐数与该变量大小的较小值。vs的默认对齐数为8
结构体的总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
对于嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
我们可以模拟一下s1的内存对齐方式:
同样,S2的内存对齐方式如下:
如果你还是不确定,C语言给我们提供了offsetof宏来计算结构体成员的偏移量,原型如下:
需要注意:使用时我们需要先包含stddef.h头文件:
#include<stddef.h>
#include<stdio.h>
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("结构体S1中c1的偏移量为%zd\n",offsetof(struct S1,c1 ));
printf("结构体S1中i的偏移量为%zd\n", offsetof(struct S1, i));
printf("结构体S1中c2的偏移量为%zd\n", offsetof(struct S1, c2));
printf("结构体S2中c1的偏移量为%zd\n", offsetof(struct S2, c1));
printf("结构体S2中c2的偏移量为%zd\n", offsetof(struct S2, c2));
printf("结构体S2中i的偏移量为%zd\n", offsetof(struct S2, i));
return 0;
}
结果如下,与我们上述的分析过程如出一辙:
我们再来看一个例子:
//结构体嵌套问题
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
int main()
{
printf("%d\n", sizeof(struct S4));
return 0;
}
怎么样,你做对了吗 👀
步骤如下:
根据内存对齐算出s3所占的空间大小为16
根据对齐规则的第5点得出s3的要对齐到8的整数倍,即对齐到偏移量为8处
double d的对齐数为8,因此对齐到偏移量为24处
最终大小为最大偏移量8的整数倍,即为32。
想必有人会有疑问,内存对齐那么麻烦,为什么存在内存对齐?主要有以下两点原因:
平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器可能需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总的来说:
结构体的内存对齐是拿空间来换取时间的做法。
那在设计结构体的时候,我们既要满足内存,又要节省空间,我们要如何做到:
让占用空间小的成员尽量集中在一起
//例如:
//c1与c2不相邻
struct S1
{
char c1;
int i;
char c2;
};
//c1与c2相邻
struct S2
{
char c1;
char c2;
int i;
};
虽然S1和S2类型的成员一模一样,但是S1占12个字节,S2占8个字节,这就是合理安排位置所带来的好处。
6.默认对齐数的修改🌷
在C语言中,我们也可以修改结构体的默认对齐数,只需用#pragma这个预处理指令即可。如下:
#include<stdio.h>
#pragma pack(1) //修改默认对齐数为1
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
上面我们将默认对齐数设置成1,由于对齐数是默认对齐数和成员大小 较小者,因此默认对齐数为1相当于不对齐,S1与S2的结果相同都为6:
7. 结构体的传参
话不多说,我们直接上代码来说明:
#include<stdio.h>
struct S
{
int data[1000];
int size;
};
//传值
void print(struct S s)
{
printf("%d", s.size);
}
//传址
void print(struct S* sp)
{
printf("%d", sp->size);
}
int main()
{
struct S s1;
print1(s1);
print2(&s1);
}
print1()和print2()哪个函数好呢?
答案是print2()函数。
为什么呢?
print1()和print2()分别对应着 传值调用和 传址调用 。我们知道无论是传值还是传址,函数在将要调用时实参都会 形成临时拷贝并压入栈中 。 压栈 的这个过程是需要 成本 的, 成本体现在时间和空间上 。
如果传递一个结构体对象的时候,结构体过大(例如我们上面的s1),参数压栈的的成本比较大,就会 导致性能的下降 。所以我们传递像结构体这种数据量较大的变量时,一般 传地址 ,地址占4个或者8个字节,极大程度上减少了所需的成本。
综上所述,我们进行结构体传参时要传结构体的地址。
8.位段🌸
8.1 位段的特征与声明
讲完结构体后我们就必须再来讲讲结构体实现位段的能力,位段满足以下两点特征:
1.位段的成员必须是int、unsigned int或者char这些 整型家族的成员
2.位段的成员名后有一个冒号和数字,数字表示成员占多少个 二进制位(bit位)
3.位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
例如下面的A就是一个位段类型:
struct A
{
char a : 1;
char b : 4;
char c : 5;
char d : 5;
};
其中a占1个二进制位,b占4个二进制位,c占5个二进制位,d占5个二进制位。那么位段A的大小是多少呢?这就要来谈谈位段的内存分配了。
8.2 位段的内存分配
事实上, C语言并没有明确规定位段的内存分配方式,也就是说:
1.我们并不知道位段中的成员在内存中是 从左向右分配二进制位还是 从右向左分配二进制位
2.我们不清楚当一个结构包含两个以上位段,第二个位段成员比较大,第一个位段剩余的二进制位无法容纳第二个位段,是 舍弃剩余的位还是将其 利用,这是不确定的。
正因如此,位段在不同的编译环境下所展现出来的效果很可能会有所不同。我们可以探究一下A当其从右向左分配并且不足时舍去剩余位时的内存分配情况,如下(VS2022环境下):
struct A
{
char a : 1;
char b : 4;
char c : 5;
char d : 5;
}s={0};
int main()
{
s.a = 11;
s.b = 12;
s.c = 3;
s.d = 4;
printf("%d", sizeof(s));//计算s所占大小
return 0;
}
我们发现按照我们的假设计算出来的结果与vs2022监视器中内存的分配结果一模一样,因此我们可以得知 在vs2022编译器下位段是从右向左分配且不足时舍弃剩余位。
8.3 位段的跨平台问题
由于以下问题的存在,位段的可移植性很差,即存在着跨平台问题:
1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机 器会出问题。
3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
8.4 位段的应用
位段在网络中的应用比较多,例如以下ip数据包格式:
当我们在网络上给某人发送一个消息时,这个消息就会封装成如上图所示的一个数据包用于在网络上精确地找到接收人。我们可以看出每一行都恰好的被设计成了int型宽度,每个部分我们使用位段来进行排列封装,使得空间最大利用。而如果我们使用结构体来进行封装每个部分,由于内存对齐的原因,势必会额外浪费空间造成数据包变得巨大,从而使网络状态变差。
总的来说,跟结构体相比,位段也可以达到一样的效果,其可以帮助我们 节省空间,但是也带来了 跨平台性的问题。
以上,就是本期的全部内容啦🌸
制作不易,能否点个赞再走呢🙏