• C语言自定义类型详解 —— 结构体、枚举、联合体


    1. 结构体

    1.1 结构体的基本概念

    • 我们学过一种集合——数组,但是数组仅仅是同种类型元素的集合。结构体也是一类集合,但是它的元素类型可以不一样,我们把这些元素叫做结构体成员变量。我们通常把结构体类型称为自定义类型

    1.2 结构体的声明

    例如我们描述一个人:

    1. struct Person //这里的 Person 是结构体标签
    2. {
    3. char name[20];//姓名
    4. char sex[10];//性别
    5. int age;//年龄
    6. }person;//结构体变量,在 main 函数外定义的是全局变量

    这种做法是完全声明的结构体。

    1.3 结构体的特殊声明

     例如我们这样写,这是一种不完全声明的写法:

    1. //匿名结构体类型
    2. struct //省略结构体标签
    3. {
    4. char a;
    5. int b;
    6. float c;
    7. }s1;//结构体声明时顺带定义结构体变量 s1

    匿名结构体的变量只能定义一次,并且只能是在结构体声明的时候定义。

    如果我们非要定义第二次变量,或者是不在结构体声明时定义变量,那么编译器就会报错。

    1. #include
    2. //匿名结构体类型
    3. struct //省略结构体标签
    4. {
    5. char a;
    6. int b;
    7. float c;
    8. }s1;//结构体声明时顺带定义结构体变量 s1
    9. int main()
    10. {
    11. struct s2;//看似是定义第二次变量
    12. //实际上使用不了
    13. s2.a = 'b';
    14. s2.b = 2;
    15. s2.c = 2.0;
    16. return 0;
    17. }

    介绍了匿名结构体,我们来看一下下面这段代码:

    1. struct
    2. {
    3. int a;
    4. char b;
    5. float c;
    6. }x;
    7. struct
    8. {
    9. int a;
    10. char b;
    11. float c;
    12. }a[20], * p;
    13. p = &x;//这条语句合法吗?

     我们主观上会认为这两个结构体是一样的,但是在编译器看来,两个结构体就是两个不同的自定义类型,即使他们的成员变量都一样。所以变量 x 的地址不能存放在另一种类型的结构体指针变量 p 当中。

    1.4 结构体的自引用

    我们来判断一下这种写法是否合法:

    1. struct Node
    2. {
    3. int data;
    4. struct Node next;
    5. };

     很明显是不合法的,就比如要使用 sizeof 计算结构体的大小,那么这个大小能计算的出来吗?这就类似陷入了死递归。

    改进的方法:

    1. #include
    2. int main()
    3. {
    4. struct Node
    5. {
    6. int data;
    7. struct Node* next;
    8. };
    9. printf("%d", sizeof(struct Node));
    10. return 0;
    11. }

    这种写法就是链表的写法,一个结点包含数据域和指针域,这个大小是可算的,因为指针的大小很明确。

    至于大小为什么是 16 而不是 8 或 12 ,我们在后面的计算结构体大小中会详细解答。

    我们再来结合 typedef 关键字来分析一段代码是否正确:

    1. int main()
    2. {
    3. typedef struct Node
    4. {
    5. int data;
    6. Node* next;
    7. }Node;
    8. return 0;
    9. }

    这个问题就类似于是先有鸡还是先有蛋了。这里要说的是,成员变量的声明是先于类型重命名的。也就是说,我们使用类型重定义后的类型名来声明指针,那么在编译器看来是非法的。

     解决方案:

    1. int main()
    2. {
    3. typedef struct Node
    4. {
    5. int data;
    6. struct Node* next;//使用类型重命名之前的类型名称
    7. }Node;
    8. return 0;
    9. }

    结构体声明中不能引用自己,但是可以引用其他的结构体:

    1. struct Person
    2. {
    3. char name[20];
    4. char sex[10];
    5. int age;
    6. };
    7. struct Count
    8. {
    9. struct Person data[100];//struct Person 类型的数组
    10. int count;
    11. };
    12. int main()
    13. {
    14. return 0;
    15. }

    1.5 结构体变量的定义和初始化 

    我们先谈谈定义。

    1. struct Point
    2. {
    3. int a;
    4. int b;//注意结构体成员变量是不需要初始化的
    5. }p1;//这里是一个全局结构体变量
    6. struct Point p2;//这里也是一个全局结构体变量
    7. int main()
    8. {
    9. struct Point p3;//这里是一个局部结构体变量
    10. return 0;
    11. }

    定义就非常简单了,只需要一个类型+变量名即可。

    接下来我们看初始化。什么是初始化呢?初始化就是在定义变量的时候顺带赋值。 

    1. struct Person
    2. {
    3. char name[20];
    4. char sex[10];
    5. int age;
    6. };
    7. struct Person p1 = { "龙兆万","男",20 };
    8. int main()
    9. {
    10. struct Person p2 = { "龙亿万","男",21 };
    11. return 0;
    12. }

    赋值的方法与数组是一样的,只需要注意顺序、成员变量的类型即可。

    1.6 结构体变量的数据输出

    我们给结构体的变量存放了一些数据,现在我们想要通过 printf 函数来打印这些数据,应该如何操作呢?

    1. #include
    2. struct Person
    3. {
    4. char name[20];
    5. char sex[10];
    6. int age;
    7. };
    8. struct Person p1 = { "龙兆万","男",20 };
    9. int main()
    10. {
    11. struct Person p2 = { "龙亿万","男",21 };
    12. printf("%s %s %d\n", p1.name, p1.sex, p1.age);
    13. printf("%s %s %d\n", p2.name, p2.sex, p2.age);
    14. return 0;
    15. }

     可以看到一个全新的字符 '.' 。这就跟数组的原理是一样的,想要输出数组的某个元素,只需要引用这个元素的下标即可。只不过在结构体中,需要结构体变量.成员变量。 

    1.7 结构体的内存对齐 

    内存对齐决定了结构体类型占用内存多大的空间。就好比有这段代码:

    1. #include
    2. struct S1
    3. {
    4. char c1;
    5. int i;
    6. char c2;
    7. }s1;
    8. int main()
    9. {
    10. printf("%d\n", sizeof(s1));
    11. return 0;
    12. }

     这似乎是有一些违背常理的,因为结构体包含两个 char 类型,一个 int 类型,应该是 6 字节才对啊?为什么会是 12 个字节呢?这就涉及到结构体内存对齐了。

    我们来了解一下结构体内存对齐规则:

    • 第一个成员在结构体变量(内存)偏移量为 0 的地址处。
    • 其他成员要对齐到某个数字(对齐数)的整数倍的地址处。

                    对齐数 = 编译器默认对齐数 与 成员变量大小 的较小值

                    VS 编译器中的默认对齐数为 8 

    •         结构体的总大小为最大对齐数(每个成员都有一个对齐数)的整数倍。
    • 如果嵌套了结构体,那么嵌套的结构体的对齐数是自己的最大对齐数,并且结构体的大小为最大对齐数(包括嵌套结构体的对齐数)的数整数倍。

    就例如我们现在分析这个例题为什么是 12 :

    我们再看一个例题,也是求结构体的大小:

    1. #include
    2. struct S2
    3. {
    4. char c1;
    5. char c2;
    6. int i;
    7. };
    8. int main()
    9. {
    10. printf("%d\n", sizeof(struct S2));
    11. return 0;
    12. }

     

    接下来我们来学会计算结构体嵌套的问题:

    1. #include
    2. struct S2
    3. {
    4. char c1;
    5. char c2;
    6. int i;
    7. };
    8. struct S3
    9. {
    10. int i;
    11. struct S2 s2;
    12. char c3;
    13. };
    14. int main()
    15. {
    16. printf("%d\n", sizeof(struct S3));
    17. return 0;
    18. }

     我们上面已经计算了 struct S2 的大小为 8 。

     

      

    至于为什么存在结构体内存对齐,大部分的参考资料给出两个原因:

    1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
    2. 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

    总体来说:结构体的内存对齐是拿空间换时间的做法。

    不过我们需要注意,并不是所有的开发环境都有默认对齐数的。 例如在 gcc 平台下就没有默认对齐数这一说法。

    1.8 修改默认对齐数

    我们可以使用 #pragma 这个预处理指令修改默认对齐数:

    1. #include
    2. #pragma pack(1)//设编译器默认对齐数为 1
    3. struct S2
    4. {
    5. char c1;
    6. char c2;
    7. int i;
    8. };
    9. #pragma pack()//恢复编译器默认对齐数
    10. int main()
    11. {
    12. printf("%d\n", sizeof(struct S3));
    13. return 0;
    14. }

    那么这时候就如我们还不知道结构体内存对齐时所料,大小就为两个 char 类型大小 + 一个 int 类型大小。

    1.9 结构体传参

    这就跟我们普通数据传参一样的道理。如果我们想要在某个函数内改变外部变量的内容,就必须要传地址(使用指针)。 

    比如说,我们的结构体已经初始化好了,只需要封装一个打印函数打印结构体的内容,不涉及到改变内容,那我们可以不使用地址传参:

    1. #include
    2. typedef struct Person
    3. {
    4. char name[20];
    5. char sex[10];
    6. int age;
    7. }Person;
    8. void print(Person person)
    9. {
    10. printf("%s %s %d\n", person.name, person.sex, person.age);
    11. }
    12. int main()
    13. {
    14. Person person = { "张三","男",20 };//结构体已经初始化
    15. print(person);
    16. return 0;
    17. }

    现在我们想封装一个函数来专门初始化结构体的内容,那么这时候就需要传址调用:

    1. #include
    2. #include
    3. typedef struct Person
    4. {
    5. char name[20];
    6. char sex[10];
    7. int age;
    8. }Person;
    9. void Init(Person* per)
    10. {
    11. strcpy(per->name, "张三");//per->name 相当于 (*per).name ,找到的是数组的地址,
    12. strcpy(per->sex, "男");//如果 per->name = "张三"; 这样赋值的话是相当于在修改数组的地址
    13. per->age = 20;
    14. printf("%s %s %d\n", per->name, per->sex, per->age);
    15. }
    16. int main()
    17. {
    18. Person person;
    19. Init(&person);
    20. return 0;
    21. }

    2. 位段

    • 结构体是有能力实现位段的。位段的成员必须是整形家族。

    2.1 位段的声明

    我们先看看位段是如何声明的:

    1. struct A
    2. {
    3. char a : 2;
    4. char b : 3;
    5. char c : 4;
    6. };
    7. int main()
    8. {
    9. return 0;
    10. }

    2.2 位段的内存分配

    我们应该如何计算位段的大小呢?

    1. #include
    2. struct A
    3. {
    4. char a : 2;
    5. char b : 3;
    6. char c : 4;
    7. };
    8. int main()
    9. {
    10. printf("%d", sizeof(struct A));
    11. return 0;
    12. }

    首先,位段,就是控制位。就比如上面这段代码,我们定义了一个 char 类型变量 a 。这个 a 本来是有 8 个比特位的。但是我使用位段使 a 的比特位只有 2 个了,b 修改成只有 3 个了,c 修改成只有 4 个了。

    在定义 a 时就会开辟一个字节的空间大小,即 8 个比特位。但是 a 现在只有 2 个比特位,放进这一个字节当中还剩 6 个比特位,b 只有 3 个比特位还可以往里放,这时空间大小还剩 3 个比特位,这就不够 c 放了,因为 c 有 4 个比特位,那么此时又会单独开辟一块空间一字节的空间大小。所以位段 A 的大小是 2 字节。

    了解了如何计算大小,那么我们来研究位段如何存储数据:

    1. #include
    2. #include
    3. struct A
    4. {
    5. char a : 2;
    6. char b : 3;
    7. char c : 4;
    8. };
    9. int main()
    10. {
    11. char arr[2];
    12. struct A* p = (struct A*)arr;
    13. memset(arr, 0, 2);
    14. p->a = 2;
    15. p->b = 3;
    16. p->c = 4;
    17. printf("%02x %02x\n", arr[0], arr[1]);
    18. return 0;
    19. }

    分析一下这个程序:我们定义了一个位段指针 p 指向了 arr 强转为位段 struct A* 之后的空间,也就是说数组 arr 不能用 char 类型的方式查看了,而是要用 struct A 的位段形式查看。我们往 arr 数组里存放 2、3、4 这个几个数字。2 的二进制为 10,可以存放至 a 的两个比特位当中,3 的二进制为 11,可以存放至 b 的三个比特位当中,4 的二进制为 110,可以存放至 c 的四个比特位当中。

    这里在提一嘴,如果我们定义的数据的二进制位超过了我们定义的位段,那么就会发生截断。这与 整形数据放在字符类型空间里的道理一样。 

    3. 枚举

    3.1 枚举的基本概念

    • 顾名思义枚举就是列举,把可能的取值列举出来。 

    例如在我们的生活中,周一到周日是有限的七天,可以一一列举。

    3.2 枚举的定义

    1. enum Day
    2. {
    3. Mon,
    4. Tues,
    5. Wed,
    6. Thur,
    7. Fri,
    8. Sat,
    9. Sun
    10. };
    11. int main()
    12. {
    13. return 0;
    14. }

    以上就是枚举的定义。我们要补充的是,枚举的默认值从 0 开始,每往下走递增 1 。就好比上面这段代码,Mon 的值默认为 0 ,Tues 的值从 0 递增 1,为 1,Wed 为 2,Thur 为 3……

    当然我们可以自定义,不从 0 开始:

    1. enum Color
    2. {
    3. Red,
    4. Yellow,
    5. Green = 80,
    6. Brown,
    7. Black = 90,
    8. White
    9. };
    10. int main()
    11. {
    12. return 0;
    13. }

    Red 的值为 0 ,Yellow 为 1,但是 Green 我们给它赋了 80 ,那么 Brown 就应该为 81,同理 White 为 91 。

    3.3 枚举的使用 

    1. enum Color
    2. {
    3. Red,
    4. Yellow,
    5. Green = 80,
    6. Brown,
    7. Black = 90,
    8. White
    9. };
    10. enum Color co1 = Red;
    11. int main()
    12. {
    13. enum Color co2 = Brown;
    14. enum Color co3 = 66;//此种写法是不推荐的,因为存在类型差异
    15. return 0;
    16. }

    我们只需要注意,尽量把枚举成员赋给枚举变量即可,避免类型差异。

    3.4 枚举的优点

    我们本可以使用 #define 来定义常量,但为什么要使用枚举?

    • 增加代码的可读性和可维护性
    • 和 #define 定义的标识符比较,枚举有类型检查,更加严谨
    • 防止命名污染
    • 便于调试
    • 使用方便,一次可以定义多个常量

    4. 联合(共用体)

    4.1 联合的基本概念

    • 联合体也是一种特殊的自定义类型。
    • 这种类型定义的变量也包含一系列成员,特征是这些成员共用一块空间

    4.2 联合类型的声明

    1. union Un
    2. {
    3. char i;
    4. int c;
    5. };
    6. int main()
    7. {
    8. return 0;
    9. }

    这个就是最基本的联合体声明。

    4.3 联合的特点

    联合的成员是共用同一块内存空间的,联合的大小至少是最大成员的大小。 

    为了验证联合是共用同一块内存空间的,我们可以写这样一个程序:

    1. #include
    2. union Un
    3. {
    4. char i;
    5. int c;
    6. }Un;
    7. int main()
    8. {
    9. printf("%p\n%p\n", &(Un.i), &(Un.c));
    10. return 0;
    11. }

    可以看到,两个不一样的变量但是地址却一样,这就说明了联合的成员是共用同一块内存空间的。 

    那怎么计算联合的大小呢?非常简单:

    1. #include
    2. union Un
    3. {
    4. char i;
    5. int c;
    6. }Un;
    7. int main()
    8. {
    9. printf("%d", sizeof(Un));
    10. return 0;
    11. }

    成员里面谁的类型最大?int ,有 4 个字节,所以联合的大小为 4 。

     但是,我们同样不能忽略对齐数。

    1. #include
    2. union Un
    3. {
    4. char a[5];
    5. int c;
    6. }Un;
    7. int main()
    8. {
    9. printf("%d", sizeof(Un));
    10. return 0;
    11. }

    像这个程序,成员最大的是 char a[5]; ,5 个字节,但是 5 个字节显然不合理。所以联合也需要对齐最大对齐数,很明显,最大对齐数为 4 ,所以联合的大小为 8 。

  • 相关阅读:
    ES6 - 扩展运算符与Object.assign对象拷贝与合并
    python web框架django面试题收藏
    计算机视觉与人工智能在医美人脸皮肤诊断方面的应用
    webSocket的实现
    山与路远程控制 一个基于electron和golang实现的远控软件
    数据库 MySql快速导入外部数据库流程
    css实现六边形
    有趣简单的M2处理器性能实验:Swift与C代码执行速度的比较
    多任务联合训练,出现Nan的问题
    opencv python 深度学习垃圾图像分类系统 计算机竞赛
  • 原文地址:https://blog.csdn.net/weixin_59913110/article/details/125732217