一、结构体
1.结构体的基础知识
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
和数组相比较,数组是一些值的集合,这些值的类型是相同的
2.结构体的声明
struct tag
{
member-list;
}variable-list;
例如:
#include<stdio.h> struct Stu { char name[20]; int age; }s1, s2;//全局变量 int main() { struct Stu s3, s4;//局部变量 return 0; }
当然也可以使用typedef来重命名结构体
#include<stdio.h> typedef struct Stu { char name[20]; int age; }Stu; int main() { Stu s3, s4;//局部变量 return 0; }
3.特殊的声明(匿名结构体)
有时候我们会遇见这样的声明结构体
#include<stdio.h> struct { char name[20]; int age; }s1; int main() { return 0; }
像这种没有结构体标签的定义结构体,我们称之为匿名结构体,匿名结构体只能在声明的时候就定义好结构体变量。否则之后也没有办法再进行定义了
关于匿名结构体,我们还需要注意的一个点是这个
#include<stdio.h> struct { int a; char b; double c; }x; struct { int a; char b; double c; }*p; int main() { p = &x; return 0; }
在这段代码中我们要注意的是p和&x的结构体看上去好像一样,但是实际上他们是不一样的,当我们进行编译的时候,就会报警告
因为编译器会把上面的两个声明当成完全不同的两个类型。所以是非法的。
4.结构体的自引用
我们先思考一个一个问题,在一个结构体里面是否可以包含一个该结构体的本身的成员呢?如下代码所示
struct Node { int data; struct Node next; };
其实我们不难推出,这样是不可以的,因为如果是这样的话,假如说我们要对其进行计算它的大小,那么我们会发现它的大小是无穷大。这是肯定不行的
如果想要正确的自引用的话,我们应该这样做,使用一个结构体指针
struct Node { int data; struct Node* next; };
那么我们如果这样自引用是否合理呢?
typedef struct { int data; Node* next; }Node;
其实这样做也是不合理的。这里有两处错误,首先是我们是使用了typedef,但是我们在结构体里面使用Node是在我们typedef之前进行的。我们编译器此时还不认识Node,所以这里错了,而且对于一个匿名结构体,他是无法自引用的。所以我们正确的代码应该是这样的
typedef struct Node { int data; struct Node* next; }Node;
5.结构体变量的定义和初始化
有了结构体变量,我们想要定义和初始化其实就很简单了,下面就是结构体变量的定义了
#include<stdio.h> struct Point { int x; int y; }p1; struct Point p2; int main() { struct Point p3; return 0; }
定义好以后,我们使用{}进行初始化
#include<stdio.h> struct Point { int x; int y; }p1 = { 10,20 }; struct Point p2 = { 0,0 }; int main() { struct Point p3 = { 1,2 }; return 0; }
如果结构体里面嵌套一个结构进行初始化的话,我们也仍然按照相同的方法进行赋值
#include<stdio.h> struct Point { int x; int y; }p1 = { 10,20 }; struct S { int num; char c; struct Point p; float d; }; struct Point p2 = { 0,0 }; int main() { struct Point p3 = { 1,2 }; struct S s = { 1,'w',{1,2},3.14f }; return 0; }
当然除此以外,我们也可以进行乱序初始化,并且打印出来,如下所示
我们想要乱序赋值,就得先使用 " ."这个操作符,然后输入我们要赋值的变量名即可,同样打印也是靠这个点操作符的
#include<stdio.h> struct Point { int x; int y; }p1 = { 10,20 }; struct S { int num; char c; struct Point p; float d; }; struct Point p2 = { 0,0 }; int main() { struct Point p3 = { 1,2 }; struct S s = { 1,'w',{1,2},3.14f }; struct S s2 = { .d = 3.14f,.p.x = 20,.c = 'a',.num = 100,.p.y = 55 }; printf("%d %c %d %d %f\n", s.num, s.c, s.p.x, s.p.y, s.d); printf("%d %c %d %d %f\n", s2.num, s2.c, s2.p.x, s2.p.y, s2.d); return 0; }
6.结构体的内存对齐
当我们了解了结构体以后,我们还有一个事情我们还需要了解的是如何计算结构体的大小。
我们先看这段代码,并猜测输出结果
#include<stdio.h> 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; }
我们看到后,肯定会想当然的认为是两个6,但是答案其实是12和8
要了解这个我们得先了解一下结构体对齐规则
1. 第一个成员在与结构体变量偏移量为0的地址处。
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的值为8,Linux环境下无对齐数
3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
所以我们便可以得知S1结构体对齐图如下
我们也可以验证一下这些对齐数的偏移量
我们要使用offsetof这个宏来计算,这个宏是用来计算偏移量的,当然他需要一个头文件stddef.h
#include<stdio.h> #include<stddef.h> 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)); printf("%d\n", offsetof(struct S1, c1)); printf("%d\n", offsetof(struct S1, i)); printf("%d\n", offsetof(struct S1, c2)); return 0; }
可见我们的想法是正确的
我们也根据上面的规则来画一下S2结构体的内存图
我们在来看一下这个结构体的大小
struct S3 { double d; char c; int i; };
我们再看一个,计算S4的大小
struct S3 { double d; char c; int i; }; struct S4 { char c1; struct S3 s3; double d; };
那么为什么要进行对齐呢?
1. 平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
2. 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总的来说:
结构体的内存对齐是拿空间来换取时间的做法
那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到:
让占用空间小的成员尽量集中在一起
//例如: struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; };
S1和S2类型的成员一模一样,但是S1和S2所占空间的大小有了一些区别
7.修改默认对齐数
我们知道在vs下默认对齐数是8,但是8一定是最合适的吗,未必,有时候我们就需要自己去修改这个默认对齐数
我们修改默认对齐数是使用一条预处理指令来实现的
//将默认对齐数修改为1 #include<stdio.h> #pragma pack(1) struct S { char c1; int i; char c2; }s; //恢复默认对齐数 #pragma pack() int main() { printf("%d\n", sizeof(s)); return 0; }
8.结构体传参
#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; }
我们结构体传参有两种方式可以实现,一种是直接传结构体另外一种是传地址
上面的 print1 和 print2 函数哪个好些?
答案是:首选print2函数
原因:
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
结论:
结构体传参的时候,要传结构体的地址。
二、位段
1.什么是位段
位段的声明和结构是类似的,有两个不同:
1.位段的成员必须是 int、unsigned int 或signed int 。(其实char类型也可以)
2.位段的成员名后边有一个冒号和一个数字。
我们先看这一个代码
#include<stdio.h> struct A { int _a : 2; int _b : 5; int _c : 10; int _d : 30; }; int main() { printf("%d\n", sizeof(struct A)); return 0; }
为什么是8呢?理论上来说,四个整型应该是16才对。
其实所谓的位段,这个位指的是二进制位。
也就是说,a占2个二进制位,b占五个二进制位。c占10个二进制位,d占30个二进制位
因为在我们实际应用结构体的时候,我们发现有些变量不需要那么多的二进制位,有点浪费空间,我们就对他做了更精细的划分,这个可以更加节省我们的空间
2.位段的内存分配
1. 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
我们这里先说一下第二点,也就是位段的空间是按照四个字节或者一个字节一个字节的方式来开辟的
接下来我们来验证一下在vs2022上位段的内存开辟
#include<stdio.h> 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; }
下图是我们的分析过程
最终的十六进制就是62 03 04
在内存中也确实这样分配的
注意:位段是不需要对齐的,因为他本身就是为了节省空间的
3.位段的跨平台问题
1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。
3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
三、枚举
枚举顾名思义就是一一列举。
把可能的取值一一列举。
比如我们现实生活中:
一周的星期一到星期日是有限的7天,可以一一列举。
性别有:男、女、保密,也可以一一列举。
月份有12个月,也可以一一列举
1.枚举类型的定义和使用
枚举类型的定义与结构体是非常类似的,他的关键词是enum
//星期 enum Day { Mon, Tues, Wed, Thur, Fri, Sat, Sun }; //性别 enum Sex { MALE, FEMALE, SECRET }; //三原色 enum Color { RED, GREEN, BLUE };
以上定义的 enum Day , enum Sex , enum Color 都是枚举类型。
{}中的内容是枚举类型的可能取值,也叫 枚举常量 。
这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值
比如说我们可以打印出来这些值
#include<stdio.h> //性别 enum Sex { MALE, FEMALE, SECRET }; int main() { printf("%d\n", MALE); printf("%d\n", FEMALE); printf("%d\n", SECRET); return 0; }
当然这些都是默认值,我们可以修改这些默认值
这些枚举常量,我们也可以定义一个枚举类型的变量去接受这些值
如下图所示,在c语言中,我们可以直接对其直接赋值1,也是可以的,但是这是由于c语言检查不够严格导致的,这样写其实不好。在c++中就直接报错了
还有一点应该注意的是,一个枚举的大小应该是四个字节,也就是一个整下,下面是验证
2.枚举的优点
我们可以使用 #define 定义常量,为什么非要使用枚举?
枚举的优点:
1. 增加代码的可读性和可维护性
2. 和#define定义的标识符比较枚举有类型检查,更加严谨。
3. 防止了命名污染(封装)
4. 便于调试
5. 使用方便,一次可以定义多个常量
四、联合(共用体)
1.联合类型的定义
联合也是一种特殊的自定义类型
这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。
#include<stdio.h> union UN { char c; int i; }; int main() { union UN un; return 0; }
2.联合类型的特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。
#include<stdio.h> union UN { char c; int i; }; int main() { union UN un; printf("%d\n", sizeof(un)); printf("%p\n", &un); printf("%p\n", &(un.c)); printf("%p\n", &(un.i)); return 0; }
也就是说当我们使用c和i共用这四个字节的空间,当然我们一般平时使用的时候只是单独使用其中一个变量。
我们再来分析一下这个代码,假设我们是小端机器
#include<stdio.h> union UN { char c; int i; }; int main() { union UN un; un.i = 0x11223344; un.c = 0x55; printf("%x\n", un.i); return 0; }
首先这个联合体的大小是4个字节,然后存放一个十六进制的数赋值给i
然后我们存放一个0x55 给c,内存就变为了
所以最终打印结果为11 22 33 55
3.联合体大小的计算
联合的大小至少是最大成员的大小。
当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍
#include<stdio.h> union Un1 { char c[5]; int i; }; union Un2 { short c[7]; int i; }; int main() { //下面输出的结果是什么? printf("%d\n", sizeof(union Un1)); printf("%d\n", sizeof(union Un2)); return 0; }
1.
Un1中,最大的大小是5个字节
但是c的大小是1,与默认对齐数8取得最小值还是1
i的大小是4,与默认对齐数8取得最小值是4
最大对齐数就是1和4的最大值,也就是4
5不是4的倍数,所以应该是八个字节的大小
2.
c数组的大小是14个字节,i的大小是4个字节,最大成员大小是14个字节
c一个元素的大小是2个字节,默认对齐数是8,取得2
i的大小是4个字节,默认对齐数是8,取得4
14不是4的倍数,所以最终大小是16个字节
4.利用联合体判断大小端
我们可以利用联合体来实现判断大小端,代码如下
#include<stdio.h> union UN { char c; int i; }; int main() { union UN un; un.i = 1; if (un.c == 1) { printf("小端\n"); } else { printf("大端\n"); } return 0; }
总结
本小节讲解了结构体,位段,枚举,联合的详细知识点。
如果对你有帮助的话,不要忘记点赞加收藏哦!!!
想获得更多优质内容, 一定要关注我哦!!!