自定义类型全解(结构体,位段,枚举,联合)

x33g5p2x  于2021-11-21 转载在 其他  
字(7.0k)|赞(0)|评价(0)|浏览(217)

一、结构体

1.什么是结构体?

结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。

2.结构体的声明

例如描述一个学生,每个学生都有它自己的身高、年龄、性别、学号等等,因为它有多种属性,因此我们可以将学生当作一个结构体。

struct Stu
{
 char name[20];//名字
 int age;//年龄
 char sex[5];//性别
 char id[20];//学号
};//分号不能丢

注:一个结构体最后一个大括号的分号不能缺少,但在高级的编译器中它会自行补充。

3.特殊结构体的声明(匿名结构体)

特殊的结构体类型被称为匿名结构体,顾名思义,是没有定义名字或者省略了标签的结构体。例如:

//匿名结构体类型
struct
{
 int a;
 char b;
 float c; 
}x;
struct
{
 int a;
 char b;
 float c; 
}a[20], *p;

虽然上面两个结构体的成员变量相同,但是它们的结构体类型是相同的嘛?比如说写成p = &x;
答案是错误的,编译器会把上面的两个声明当成完全不同的两个类型。所以是非法的。

4.结构体的自引用(嵌套)

在结构体的自引用中,要注意的是自引用结构体的方式一定要正确。例如:

struct Node
{
 int data;
 struct Node* next;
};

许多人在一开始学习结构体自引用时,经常漏去struct Node* next;中的*,为什么漏掉 * 号就是错误的呢?原因很简单,在数据结构中(如果没有学到数据结构,可以参考作者的数据结构博客进行预习数据结构的顺序表与链表),链表的结点是无限的,取决于内存的大小,缺少 * 号则上面例子中的结构体会无限循环找到下一个结点,如果有 * 号则代表的是结构体指针,当查找到最后一个结点时的指针为NULL,则不再查找下去,不会陷入死循环。

下面这种情况也不行

//代码3
typedef struct
{
 int data;
 Node* next; 
}Node;

原因是该结构体虽然用typedef命名为Node,但是在调用Node* next时还没有遇到Node,因此编译器无法得知调用Node时它是什么类型。

正确的写法:

typedef struct Node
{
 int data;
 struct Node* next; 
}Node;

结论:1.结构体在自引用时要在结构体名字前加上* 。2.在自引用结构体时结构体的名字不能省略,并且自引用结构体时结构体类型也要写完整。

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

struct Point
{
 int x;
 int y; 
}p1; //声明类型的同时定义变量p1

struct Point p2; //也可以在此处定义结构体变量p2

注意:在结构体后面声明如p1,声明的变量为全局变量,而在main函数当中声明,如p2,声明的变量为局部变量。如果初始化结构体Point,则可以写为struct Ponit p3 ={2,3};,对于结构体中有char型数组,初始化时应该带有“ ”号,例如:

struct Stu        //类型声明
{
 char name[15];//名字
 int age;      //年龄
};
struct Stu s = {"zhangsan", 20};//初始化

也可以直接在结构体创建变量后面直接初始化,当然也可以嵌套初始化。如:

struct Node
{
 int data;
 struct Point p;
 struct Node* next; 
}n1 = {10, {4,5}, NULL};

struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化

6.结构体内存对齐

结构体的内存对齐是结构体中的重点,即计算一个结构体的大小,在许多人一开始学结构体内存对齐时觉得它的计算规则比较难。但我在此将相对复杂的内容简单化,让读者们更容易理解。

先来看看这个例子:

struct S1
{
 char c1;
 int i;
 char c2;
};
printf("%d\n", sizeof(struct S1));

计算一下上面结构体的大小。一开始都会认为,char c1占一个字节,int i占4个字节,char c2占一个字节,总共加起来就占6个字节。这种相加的方法大错特错。让我们来看看C语言中对结构体内存对齐是如何规定的。

首先得掌握结构体的对齐规则:

  1. 第一个成员在与结构体变量偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
    VS中默认的值为8
  3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

看了结构体的对齐规则,会产生几个疑问:(下面一一解答)
1.什么是偏移量?
2.什么是编译器默认对齐数?
3.如何判断哪个为最大对齐数?

再把上面例子的代码拿下来:

struct S1
{
 char c1;
 int i;
 char c2;
};
printf("%d\n", sizeof(struct S1));

由结构体内存对齐中得知,偏移量是从0开始的,并且第一个成员开始一定要放在0偏移量处,并且vs中默认对齐数为8与char c1的1个字节比较,选择小的数字作为该成员的对齐数。以此类推。因此将char c1放在0的位置处,又因为其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处,即4为int i的对齐数(int i 的对齐数为4)的整数倍。因此从偏移量为4处开始存放int的4个字节。存放完4个字节后到偏移量为7。char c2的对齐数为1,并且偏移量8为1的整数倍,再放入char c2的一个字节后,总偏移量为8。最后要进行检查,结构体总大小是否为最大对齐数(每个成员变量都有一个对齐数)的整数倍,因为上面例子中最大的对齐数为int i的对齐数4,而偏移量8实则为总字节大小为9,不是int i 对齐数4的整数倍,因此要偏移量为11(总字节大小为12)才能与对齐数4对齐。

图示如下:(对空白处的内存没有利用,造成内存浪费)

当然,结构体成员变量的顺序不同对结构体的大小也会有所差异。
例如以上的例子与此例子中的成员变量均相同,但是顺序不同,那么这个结构体的大小是多少呢?

struct S2
{
 char c1;
 char c2;
 int i;
};
printf("%d\n", sizeof(struct S2));

这个结构体的大小是8。而上面举的例子中的结构体是12。我再把此结构体的内存分配用图画出来。

对于嵌套的结构体,结构体内存对齐中规定嵌套的结构体对齐到自己的最大对齐数的整数倍处。

struct S3
{
 double d;//最大对齐数为8
 char c;
 int i;
};
printf("%d\n", sizeof(struct S3));//计算得S3结构体大小为16
//练习4-结构体嵌套问题
struct S4
{
 char c1;//对齐数为1
 struct S3 s3;//对齐数为8
 double d;//对齐数为8
};
printf("%d\n", sizeof(struct S4));//结构体S4大小为32

虽然这里嵌套了结构体,但是内存对齐的原理是相同的,S4中的每个成员变量都有它各自的对齐数,只要偏移量是该对齐数的整数倍,即可以放入每个成员变量所占字节的大小。

为什么存在内存对齐?

  1. 平台原因(移植原因):
    不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。(可能造成有些内存无法读取数据,比如以上画图中空白格为浪费的内存空间)
  2. 性能原因:
    数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
    原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。访问的效率也随之提升。(比如某个数据类型的内存存入以上空白格中,则每次都需要先读取空白格中的内存后,再读取空白格后面的内存,并不是连在一起读取,因为vs中默认对齐数为8)

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

为了节省内存空间,并且又需要内存对齐,则让占用空间小的成员尽量集中在一起。

//例如:
struct S1
{
 char c1;
 int i;
 char c2;
};//12个字节
struct S2
{
 char c1;
 char c2;
 int i;
};//8个字节

S1和S2类型的成员一模一样,但是S1和S2所占空间的大小有了一些区别。

7.修改默认对齐数

#pragma pack(数字) 能够修改vs中的默认对齐数,结构在对齐方式不合适的时候,我们可以自己更改默认对齐数。当然使用完修改后的默认对齐数后在末尾处用#pragma pack()能修改回默认对齐数。

#include <stdio.h>
#pragma pack(8)//设置默认对齐数为8
struct S1
{
 char c1;
 int i;
 char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
 char c1;
 int i;
 char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
    //输出的结果是什么?
    printf("%d\n", sizeof(struct S1));//12
    printf("%d\n", sizeof(struct S2));//6
    return 0;
}

此处介绍一个宏:offsetof,它能够返回一个成员变量的偏移量。那么如何使用这个宏呢?
我们查阅资料发现offsetof需要接收两个参数,第一个参数是结构体类型名,第二个参数是结构体成员名。使用宏offsetof需要的头文件为<stddef.h> 。

#include <stddef.h>
#include <stdio.h>
struct S2
{
    char c1;
    int i;
    char c2;
};
int main()
{
    printf("%d\n", offsetof(struct S2, c1));
    printf("%d\n", offsetof(struct S2, i));
    printf("%d\n", offsetof(struct S2, c2));
}

8.结构体传参

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; 2
}

对于结构体传参,我们可以选择传值与传址。但是哪一种更好呢?答案是传址。

  1. 函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
  2. 如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。

二、位段

1.位段的介绍

位段其实和结构体大致相同,不同的地方在于:

  1. 位段的成员必须是 int、unsigned int 或signed int 。
  2. 位段的成员名后边有一个冒号和一个数字。

比如:

struct A 
{
 int _a:2;
 int _b:5;
 int _c:10;
 int _d:30;
};

位段中每个成员冒号后面跟着的数字为系统为该成员变量开辟的位数。这里总共就开辟了47个位,每8位为一个字节,因此开辟了6个字节。但是我们打印该位段的大小发现,跟我们的结果并不像符。

2.位段的内存分配

  1. 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
  2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
  3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

为了方便读者理解,这里举char类型的位段内存分配更容易理解。例如:

struct S 
{
 char a:3;
 char b:4;
 char c:5;
 char d:4;
};
struct S s = {0};
s.a = 10; //赋值10
s.b = 12;
s.c = 3; 
s.d = 4;

结论:位段为1个字节(char类型)开辟时,是从低地址到高地址一个字节一个字节开辟的。并且当一个字节中存入几位后仍然有位可以存储数据,却又不够位存储时,会另外开辟一个字节来专门存储,以此类推。最后调试发现&s时s中的存储内容与我们计算的是一致的,为62 03 04 。

3.位段的跨平台问题

  1. int 位段被当成有符号数还是无符号数是不确定的。
  2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。
  3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
  4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。

总结:跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。

4.位段的应用

在网络对数据的分装中。在不同的区域需要多少位来处理数据都有明确的规定。如果此时没有位段,用结构体对其分装,则浪费的内存是很大的。

三、枚举

1.枚举类型介绍

枚举,顾名思义就是对某一个对象的属性能够一一列举。性别有男有女,可以列举。在生活中这些例子太多了。枚举就能够适用到这些场景中。

2.枚举类型的定义

颜色的枚举:

enum Color//颜色
{
 RED,
 GREEN,
 BLUE
};

enum Color称为枚举类型,而RED,GREEN,BLUE都是枚举常量。枚举中规定这些常量都是有值的,默认从0开始,依次增加1,在定义的时候也可以赋初值。以上枚举常量的值为:RED=0,GREEN=1,BLUE=2

enum Color//颜色
{
 RED=1,
 GREEN=2,
 BLUE=4
};

3.枚举的优点

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

枚举的优点:

  1. 增加代码的可读性和可维护性。
  2. 和#define定义的标识符比较枚举有类型检查,更加严谨。
  3. 防止了命名污染(封装),即重命名。
  4. 便于调试。
  5. 使用方便,一次可以定义多个常量。

4.枚举的应用

enum Color//颜色
{
 RED=1,
 GREEN=2,
 BLUE=4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
clr = 5; //error

还比如在一个菜单中,每个选项都有它自己的值。我们可以直接用枚举类型默认给它们赋值,而在switch语句中使用时不用case 数字,直接case 名字 来增加代码的可读性。

四、联合体(共用体)

1.联合体的介绍

对于联合体的名字我们或许会感觉陌生,但是共用体这个名字,顾名思义是该共用体内的成员变量共用同一块空间。比如:

//联合类型的声明
union Un
{
 char c;
 int i;
};
//联合变量的定义
union Un un;
//计算连个变量的大小
printf("%d\n", sizeof(un));

2.联合体的特点

联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。

union Un
{
 int i;
 char c;
};
union Un un;
// 下面输出的结果是一样的吗?
printf("%d\n", &un);
printf("%d\n", &(un.i));
printf("%d\n", &(un.c));

可见&un,&(un.i),&(un.c)是在同一块地址处,而un.c又占4个字节,因此可以用下图来表示。

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));
}

联合体的内存计算规则:

  1. 联合的大小至少是最大成员的大小。
  2. 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。

因此上面的例子中Un1的大小:因为char c的对齐数是1,int i的对齐数是4,又因为char c[5]的大小是5个字节,int i的大小是4个字节,因此要保证联合体的大小为字节数大的为联合体大小。但是字节大小5不是最大对齐数4的倍数,8才是它最大对齐数4的最小整数倍。Un2的大小也是这样分析,读者可以自己画图分析。

五、通讯录程序练习(利用结构体)

下面这篇博客是本人自己写的,大家可以参考一下,用结构体来实现通讯录的基本应用。

通讯录代码及分析

原创不易。如果此篇文章对你有帮助,麻烦点个赞支持一下谢谢!

相关文章