小言_互联网的博客

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

291人阅读  评论(0)

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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  2. #include <string.h>
  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 <stdio.h>
  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 <stdio.h>
  2. #include <string.h>
  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 <stdio.h>
  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 <stdio.h>
  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 <stdio.h>
  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 。


转载:https://blog.csdn.net/weixin_59913110/article/details/125732217
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场