【C语言笔记】自定义类型全解
一、结构体
1、什么是结构体
在C语言中有一种数据类型称为“聚合数据类型”,它能同时存储一个或一个以上的单独数据,C中提供了两种类型的聚合数据类型,分别是数组和结构体。
我们知道数组一组相同类型的元素的集合,但结构体中的元素类型是可以不相同的。
所以数组可以用下标的方式进行反问(因为它每个元素类型相同),而结构体就不能用下标进行访问。
2、结构体的声明与定义
2.1、结构体的声明
结构体的声明形式如下所示:
其中tag表示的是结构体的标签名,即这个结构体叫什么名字,member-list是结构体成员列表,它包含了一个或一个以上的结构体成员,而variable-list是结构体变量列表,它列出了在创建结构体时同时创建的此类型的变量。比如我们现在可以定义一个学生类型的结构体:
这里的student就是tag,二大括号内部的id、age、name就是这个结构体的成员变量列表。后面的s1、s2就是结构体变量列表,而且像s1和s2这样的在结构体声明的时候就创建的变量都是全局变量。
在声明结构体时,必须列出它所包含的所有成员。这个列表包括每个成员的类型和名字。
当然这个variable-list也并不是必须要有的,甚至这个tag也是可以没有的。
这些后面都会讲到。
但最后那个结尾的分号;是千万不能省略掉的
2.2、对结构体成员的访问
对结构体成员的访问在之前将操作符的时候已经遇到过了,但今天既然已经讲到了结构体,那我们还是再来复习一下吧。
对结构体成员的访问方试有两种,分别是直接访问(.)和间接访问(->),点是对结构体变量使用的,所以称为直接访问箭头是对结构体指针使用的,所以称为间接访问。
比如我们可以再主函数创建了一个学生类型之后,直接用点的方式打印其成员变量:
也可以通过一个结构体指针指向s后通过指针来进行打印:
总之对结构体成员的访问方试有两种,具体使用哪一种还是要看场景。
2.3、对结构体成员的初始化
对结构体成员的初始化其实上面就已经用到了,和数组一样,也是用一个大括号括起来,里面再写成员变量的值,当然,我们也可以像数组一样对结构体成员进行不完全初始化,即大括号里只写一个0:
通过调试窗口我们可以看到,这里不仅是id被置成了0,连后面的age也默认置成了0,二名字也被置成了空字符串。
当然了,如果我们不想按顺序初始化也可以,比如说我就想把名字写在前面:
这时候就要在成员变量前面加上一个点。当然这样也是可以不完全初始化的,不完全初始化的情况下,其他未被赋值的成员默认为0。
2.4、结构体的不完整声明
在声明结构体的时候,也可以不完全声明,比如我们可以把结构体标签明给省略掉:
struct {
int id;
int age;
int name[10];
char sex[4];
} s1 ;
但这样的声明方式的缺陷是你只能像上面一样,在创建的时候就定义好全局变量,以后你在想用这个结构体来定义变量是不可能的啦,因为它连名字都没有,没人认识它,也没有人能找到它。
所以,匿名声明的结构体的使用都是一次性的。
2.5、结构体嵌套定义
结构体也可以嵌套定义,即在一个结构体中定义另一个结构体,例如:
struct Student {
int id;
int age;
struct Name {
char first_name[15];
char last_name[15];
};
struct Name name;
char sex[4];
};
当一个类型A只会在另一个类型B中被使用的时候,就可以吧类型A定义在类型B的定义体内,这样可以减少暴露在外面的用户自定义类型的个数。
3、结构体的自引用
其实结构体也可以包含一个结构体本身类型的成员的,就比如说各位的身怀绝技的老师一定也是另一个更身怀绝技的老师教出来的,所以在一个老师的结构体的成员变量中也可以含有一个老师类型的变量。
那我们应该怎样实现结构体的自引用呢?
3.1、错误的自引用
struct Teacher {
int id;
int age;
int name[20];
struct Teacher teacher;
};
上面的这种自引用方式有什么问题吗?
好像咋一看确实没有什么问题,但若是我问你这结构体的大小是多大呢?(我们这里先不管结构体大小的计算方法),那一定要包括所有变量的大小啦,但是这个teacher的大小又该怎么算呢?难道又是继续向里面算,又包括了id、age、name 而且还有一个teacher的大小吗?
这样算岂不是永无止尽吗。这难道真像人们所说“学无止境”了吗?
很显然,这种自引用方式是错误的。
正确的饮用方式应该像下面这样:
3.2、正确的自引用
struct Teacher {
int id;
int age;
int name[20];
struct Teacher *Te;
};
我们其实只需要通过老师找到老师的老师即可,所以我们只需要存储老师的老师的地址即可,这样我们在结构体中存储的就只是个地址,就并不会出现向上面一种方式那样的无限循环的情况了。
4、结构体大小的计算
这一块知识算是结构体中比较重要的知识了,也是相对来说比较难的知识。
比如我们现在有一个这样的结构体:
struct A {
char a;
int b;
double d;
};
我问你,sizeof(struct A)等于多少,或者A变量所占的内存空间是多大?
你能立刻回答我吗?
是不是a的1字节加上b的4字节再加上d的8字节等于13字节呢?
但最后如果我们在屏幕上打印出它的大小,就会发现并不是13,而是16:
这到底是怎么一个原理呢?
4.1、结构体的内存对齐
想要搞清楚结构体是怎样计算大小的,就一定要先搞清楚结构体在内存中是怎样“对齐”的。
其实结构体成员变量在内存空间中的存储并非是像我们想当然那样一个紧挨着一个存放的。结构体成员变量在存储的时候是按照一定的规则存储的,这些规则总结起来就以下四条:
- 第一个成员在结构体变量偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数 = “编译器默认的一个对齐数 与 该成员大小的较小值”。- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
这咋一看好像信息量有点儿大啊,但不没关系,我来带着大家一条一条来看一条一条地来解释,就拿上面的例子来举例:
struct A {
char a;
int b;
double d;
};
第一条规则的意思是不管你第一个成员变量的类型是什么,占用几个字节,都是从地址偏移量为0(也就是起始位置)的位置开始存放的。
第二条规则说的是,当你从第二个成员变量开始你就要考虑“对齐数”了,你的变量要放到“对齐数”的整数倍的地址偏移量处,该对齐数为**“编译器默认对齐数和该成员变量大小的较小值”** 。
我这里使用的编译器是vs,vs的默认对齐数为8,二这里的变量b的类型是int,4字节,所以对齐数自然就是4。所以我们在偏移量为4的地址开始存放b。
有了以上的规则,我们就可以一直存储我们的变量了,当我们所有的成员变量都按照以上规则存储完了之后:
是不是就完事了呢?我们的结构体是不是就是16字节了呢?
其实还没有,这时候就要用到我们的第3条规则了,通过规则2,我们可以算出这三个变量a、b、d的对齐数分别是:1、4、8,所以我们的最大对齐数是8,我们看看现在的结构体大小为16字节,正好是8的整数倍。所以我们的结构体A的大小就是16字节。
但要是我们活生生的在A中再加入一个成员变量e,那结果就大不同了:
当我们存完所有的成员变量之后,发现结构体的大小为20个字节,并不是最大对齐数8的整数倍,所以这时要多开辟出4个字节的空间,凑成24个字节才够8的整数倍。
但当我们结构体中又包含了其他结构体时呢?其大小又该怎么计算呢?
这时候就要用到第4条规则了:
例如我们现在有这两个结构体:
struct C {
char a;
int b;
char c;
};
struct D {
int e;
short s;
struct C c1;
int f;
};
通过上面的方法我们可以很容易的算出,C的大小为12字节:
但这个C放到D里面又是怎么放的呢?其实这里我们可以把C看成是一个整体(例如一个很大的变量)而它的对齐数就是其自身的成员变量中最大对齐数中的最大值。我们这里很容易得知C的对齐数就是4,所以我们就可以把C放到D中了:
我们可以计算得知,此时D所占的空间大小为24字节,而我们结构体D整体的大小是所有对对齐数(含嵌套结构体)的整数倍是否满足呢?
我们可以很容易得知,此时的整体最大对齐数其实就是4,所以是满足的。所以结构体D的大小就是24字节。
4.2、柔型数组
柔型数组这个概念可能你连听都没有听说过,但它确确实实是存在的,柔型数组指的是结构体中元素允许是未知大小的数组,它必须是结构体中的最后一个成员,且结构体中的柔性数组前面至少要有一个成员。
例如我们在之前的结构体C中再次增加一个数组成员:
struct C {
char a;
int b;
char c;
int arr[];
};
这里增加的arr就是一个柔性数组。
我们知道C的大小为12个字节,那增加了这么一个数组对它的大小有什么影响吗?
我们可以来看看:
我们可以看到,好像确实没有什么影响。
确实,sizeof返回的这种结构大小是不包括柔性数组的内存的。
还有一点就是包括柔性数组成员的结构体需要用malloc函数对其进行内存的动态分配,并且所分配的内存应该大于结构体的大小,以适应柔性数组的预期大小,例如我们可以为上面的结构体C开辟一块20字节的内存空间:
struct C* s1 = (struct C*)malloc(20 * sizeof(char));
有人可能会好奇了,这里给它开辟了20个字节的空间,那它的大小是不是就是20字节了呢?
我们可以验证一下:
我们会惊奇的发现,这里*s1的大小还是12字节,连一个字节都没有增加,这也恰好说明了结构体的大小其实不包括柔性数组在内。
5、结构体实现位段
5.1、什么是位段
位段的声明与结构体类似,但它的成员是一个或多个位的字段。这些不同长度的字段实际存储于一个或多个整型变量中。
一个位段的声明形式如下:
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
位段的声明和任何普通的结构体成员声明相同,出了两点以外:
1.位段的成员必须是 int unsigned int signed int 或者是 char (属于整形家族)类型。
2.在成员后面是一个冒号和一个整数。
位段成员冒号后面的整数表示的是该成员一共占用的字节数,也就是该成员最大可以占用的字节数,这就起到了一个限制成员大小和节省空间的作用。
5.2、位段的注意事项
位段的使用其实就是为了节省空间,限制数据的大小,但是位段的不确定性因素太多。所以注重可移植性的程序应该避免使用位段。
位段的不确定性因素大概有以下几点:
1.int位段应该被当做有符号数还是无符号数不确定。
2.位段中的成员在内存中是从左向右分配还是从右向左分配不确定。
3.当一个声明制定了两个或两个以上的位段,且存在后一个位段比较大,无法容纳于前一个位段的剩余位时,编译器到底是把后一个位段存放到下一个字节,还是直接从前一个位段的剩余部分开始放,从而在两个内存位置边界上形成重叠,这也是不确定的。
5.3、位段的内存分配
由于位段的不确定性因素太多,这里首先进行说明这里演示的环境是:
小端机器、int是有符号int、位段成员在内存中是从右向左分配的、这里编译器是把较大的位段放到后一个字节的。
比如说我们现在有这样一个位段:
struct A
{
char _a : 2;
char _b : 5;
char _c : 7;
char _d : 6;
};
int main() {
struct A a = { 0 };
// 我们给a中的成员赋一些值
a._a = 5;
a._b = 10;
a._c = 7;
a._d = 20;
return 0;
}
这里遇到了第一个成员_a,_a的类型是char,所以就会首先开辟一个字节的空间,但_a只占两个比特的位置所以在赋值5给它的时候就会发生截断,实际放的是01。
这里遇到的第二个成员_b只占5个比特,之前开辟出的1个字节的空间是足以存放它的,所以就不用开辟新的空间,因为10的二进制序列才4比特位,所以_b是完全容得下的,所以实际存储的就是10本身。
当遇到第3个成员_c时,剩余的一个比特位已不足以容纳_c了,所以要新开辟一个字节的空间,而_c的7个字节是足以存放下7这个整型的,所以_c里存放的就是7。
后面的操作也是和前面的一样了,当我们放完所有的成员之后,我们可以看到A的大小应该是3字节,而A中的序列应该是像下图这样:
我们可以到内存中去检验一下:
我们发现sizeof(struct A)确实是3,而且内存中的前三个字节也确实是29 07 14。
所以对于位段的内存分配方式我们也清楚了。
二、枚举
在现实生活中总有一些东西是能够一一列举出来的,比如人的性别就男女两种,一周就只有7天星期一到星期天。
这对应到C语言中就是枚举类型了。
1、枚举类型的定义
枚举类型的定义形式就像下面这样:
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
上面的enum Day就是一个枚举类型,大括号{}中的内容就是枚举类型可能的取值,也成为了枚举常量。
而这些枚举常量也都是有值的,在未进行初始化的情况下,它们的值从第一个常量开始默认为0,往下以1递增。
如果我们现在打印这些常量的值,就可以看到:
它们默认是0到6。
如果我们对中间一个赋初始值,的话就会看到:
也就是说,枚举中的常量如果不初始化的话,都是向下递增的,只是初始化后就会按照初始化的值为基准向下递增。
2、枚举的优点
枚举的优点可以总结为以下5点:
- 增加代码的可读性和可维护性
- 和#define定义的标识符比较枚举有类型检查,更加严谨。
- 防止了命名污染(封装)
- 便于调试
- 使用方便,一次可以定义多个常量
第一点增加代码的可读性和可维护性其实就是使代码读起来更直观,不用老是前后对照,比如我们想实现一个多功能的计算器,可以执行整数的加减乘除运算,那我们必定就要设计一个方案来根据输入判断你要执行的操作,我们可能会这样设计:
void menu() {
printf("***************0、exit***************\n");
printf("*******1、Add 2、Sub********\n");
printf("*******3、Mult 4、Div********\n");
printf("*************************************\n");
}
int main() {
int input = 0;
do {
menu();
printf("请选择:");
scanf("%d", &input);
switch (input) {
case 1:
// 执行加法运算
break;
case 2:
// 执行减法运算
break;
case 3:
// 执行乘法运算
break;
case 4:
// 执行除法运算
break;
case 0:
//退出程序
break;
default:
printf("输入有误,请重新输入\n");
break;
}
} while (input);
return 0;
}
这样确实没问题,但如果我们有时候忘记了那个数字对应的是哪一种运算,好像又要去查一查菜单,这样好像有点儿麻烦。
这时候我们就可以用枚举来帮我们来解决这个麻烦了:
enum Option {
Exit,
Add,
Sub,
Mult,
Div
};
这样我们就可以将我们的选项全都换成枚举了:
int main() {
int input = 0;
do {
menu();
printf("请选择:");
scanf("%d", &input);
switch (input) {
case Add:
// 执行加法运算
break;
case Sub:
// 执行减法运算
break;
case Mult:
// 执行乘法运算
break;
case Div:
// 执行除法运算
break;
case Exit:
//退出程序
break;
default:
printf("输入有误,请重新输入\n");
break;
}
} while (input);
return 0;
}
这样不就直观多了吗?
我们知道 #define定义的标识符做的工作只是“替换”,而非定义一个类型,所以 #define定义的标识符是没有类型可言的。但枚举是有类型的,这就意味着我们要想把非枚举类型的数据赋值给枚举类型是非法的。所以枚举更严格。
枚举定义的常量只能在枚举类型里使用,而 #define定义的符号是全局的,任何地方都可以使用,也就是说define定义了一个符号后,其他地方就不能出现与它同名的符号了,这就叫做命名污染。而枚举定义的常量就不存在这种问题,其他地方也可以有与枚举常量同名的符号。
而易于调试是相对于define定义的符号来说的,define定义的符号所做的工作是“替换”,而“替换”后我们是看不见define定义的那个符号的,比如:
所以在调试的时候,我们并不知道那个变量可能会是用X这个标识符算出来的,也就不便于我们调试。枚举是有类型的,它在调试中是可以清楚地看到其类型的:
三、联合体
联合体也是一种特殊自定义类型,它与结构体非常类似。但和结构体不同的是,联合体的所有成员引用的都是内存中的相同位置。
1、联合体类型的声明与定义
联合体的声明形式如下:
union Un
{
char c;
int i;
};
定义联合体的形式如下:
union Un un;
2、联合体大小的计算
联合体的特点就是所有成员共用一块空间,这就是的联合体的大小至少是联合体中最大的成员的大小,例如我们现在有下面这样的一个联合体:
union Un {
char c;
int i;
};
我们可以打印出它的大小来看看:
就会发现果然是4。
而且联合体内的成员在同一时间里,只能有一个相同的值。例如我们创建一个联合体a,将a.i赋值成1,然后打印a.c就会发现,a.c也是1:
当你有两个变量,而且每次时使用都只可能用到其中的某一个时,就可以考虑将这两个变量放到联合体中,所以联合体的存在事实上也是为了节省空间的。