【手把手带你进阶】C语言中的自定义类型
在前面对C语言的学习中,我们知道C语言有以下这些内置类型:
char
short
int
long
float
double
那么除了这些内置的类型,C语言还为我们提供了一些自定义类型(构造类型),让我们可以自己创造自己需要的类型。
其中包含我们结构体、枚举、联合体。
那么今天我们就来详细地学习一下这三种类型。
结构体
在看到这类代码,别再说你不认识了!手把手带你认识初阶结构体(结构体类型的声明、初始化、成员访问与传参,全在这篇文章里)一文中,我们已经对结构体有了一个初步的认识,学习了结构体类型的声明、定义和初始化,(忘记了的同学赶紧点击文章链接去复习一下!!)那么今天,我们将在这个基础上,对结构体的使用进行更多的讲解。
结构体类型的特殊声明
在初阶结构体中,我们已经将了结构体类型是如何进行声明的,那么在这里,我们将讲一些特殊的结构体声明——不完全的声明。
比如:当我们进行结构体声明的时候,没有声明结构体的名字。
下面我们写了两个内容相同的匿名结构体,在第一个结构体中创建了结构体变量,在第二个结构体中创建了指针变量,那么第一个结构体的结构体变量的地址能否放到第二个结构体创建的指针变量呢?
其实这个问题的本质就是,这两个匿名的结构体虽然内容是一样的,但是编译器会认为它们是相同的类型吗?
从上图的结果中我们可以看到,虽然程序跑起来了,但是编译会报警告,这是因为编译器把这两个内容完全相同的结构体类型当成是完全不同的两个类型。
所以我们在把&a1赋值给p1时,实际上是非法的。
结构体的自引用
在初阶结构体中,我们提到了结构体中可以包含结构体,那么今天我们再讲一下结构体包含结构体的进阶——结构体的自引用。即结构体中不仅可以包含别的结构体,还可以包含自己的结构体。
那么它有什么意义呢?
相信大家应该听过数据结构的概念。
数据结构其实描述的是数据在内存中存储的结构,它在内存中可能是按顺序存放的,称为顺序表。
也可能是在内存中散乱地存放着,但是我们可以把这些数据依次链接起来,通过一个数据来找到另一个数据,这种结构我们称为链表。
所以链表其实就是通过链条把数据串联起来。
所以,在链表中,我们可以把1看做一个节点,通过节点就能找到2。
那么在结构体中,我们是否就可以通过自引用来从一个节点出发,找到下一节点,依次把所有数据都找到呢?
答案好像是肯定的,那么落到代码中,又应该如何写呢?
我们看上面这段代码,我们在结构体Node中放入了一个数据,表示这个节点中的数据,然后又放入了该结构体的变量n,这样我们就能通过一个结构体找到另一个结构体了。
但是大家思考一下,上面这段代码有什么问题吗?
是不是如果一个结构体包含另一个结构体,另一个结构体又包含另外一个结构体,如此嵌套下去,那么这个结构体就会变得非常大。所以这种写法是不正确的。
那么正确的应该是怎样的呢?
我们要访问下一个节点,其实并不一定要把整个结构体的信息都存起来,我们只需要把这个节点的地址存起来,通过地址就能访问它的信息了,这样就可以大大减少空间的浪费了。
所以我们应该这样写:
通过指针来找到下一个同类型结构体的写法,我们就称之为结构体的自引用。而这实际上就是我们实现链表的思路。
我们再看看下面这种写法:
虽然我们写了一个匿名的结构体,但是我们给它类型重定义为了Node,即给它重新进行了命名,但是要注意,上面这种写法是不可行的!!
因为我们对结构体类型重定义时,实际上是在后面重定义的,而成员变量是定义在了类型重定义之前的,即这里成员变量Node*是找不到Node类型的。
所以,正确的写法还是要求我们老老实实把结构体的名字写在前面,并把结构体指针的类型名写全,不要再悄悄匿名啦!!
结构体内存对齐
下面我们讨论一个很重要的问题:结构体的大小是如何计算的?
这实际上是结构体中一个非常重要的问题,因为计算结构体大小的时候涉及到内存对齐的问题,所以它也是一个特别热门的考点。
首先,我们通过sizeof()操作符计算一下一个结构体的大小是多少。
在运行代码之前,我们先猜测一下,s的大小应该是多少呢?会是6吗?
从屏幕上打印的结果中,我们可以看到,结构体s的大小是12个字节,比我们猜测的6打了整整一倍,这是为什么呢?
带着疑问,我们把结构体类型中的成员变量做一个微调,再计算一下它的大小。
再次运行程序,我们得到了如下结果:
屏幕上打印出来的结构体s的大小变成了8。
那么为什么成员一样,在仅改变顺序的情况下结构体的大小就发生了变化呢?下面我们就带着疑惑来找答案。
实际上,出现上述情况是因为在结构体中,存在着内存对齐的情况。
由于内存对齐在结构体这一块中体现得特别明显,所以我们也经常称其为结构体内存对齐。
那么什么是结构体内存对齐呢?
结构体内存对齐的规则
首先我们通过第一个结构体来了解一下结构体内存对齐的规则:
- 结构体的第一个成员永远放在结构体起始位置偏移量为0的位置。
- 结构体成员从第二个开始,总是放在偏移量为一个对齐数的整数倍处。
其中:对齐数=编译器默认的对齐数和变量自身大小的较小值
ps:在Linux环境下,没有默认对齐数;在VS环境下,默认对齐数为8
那么在这个结构体中,第二个成员是a,类型为int,大小是4个字节,比VS环境下的默认对齐数8小,所以第二个成员的对齐数是4。
那么按照第二条规则(第二个及以后的成员变量放在偏移量为对齐数整数倍处),我们画出第二个成员变量a在内存中的存放。
同理,第三个成员c2的对齐数是1,而a的后面是偏移量为8的位置,8是1的整数倍数,所以c2就放在偏移量为8的位置。
- 结构体的总大小必须是各个成员的对齐数中最大的那个对齐数的整数倍。
那么我们看在结构体s中,按个成员变量的对齐数分别是1、4、1,所以最大的对齐数是4。所以结构体的总大小就应该是4的整数倍。
而刚才我们画完三个成员在内存中的存放之后,内存总大小是9,不是4的整数倍数,所以结构体的大小应该是一个大于9并且是4的整数倍的数,即12。
我们可以看到,结构体s的成员变量只用了6个字节的空间,而其余的6个字节的空间实际上是被浪费掉了 。
那么同理,我们再来看看调换了顺序之后的结构体s的大小。
(这里大家快动手自己算一下这个结构体s的总大小吧~)
由于内存对齐,调换位置之后的结构体s的总大小变为了8,浪费的空间为2个字节。
那么当结构体中包含结构体时,我们应该如何计算呢?
这时候,我们就要引入结构体内存对齐的第四条规则:
- 如果存在嵌套,则嵌套结构体对齐到自己内部的成员变量的最大对齐数的整数倍数,结构体的整体大小就是所有成员的最大对齐数(包含嵌套结构体的对齐数)的整数倍数。
所以我们可以写出上面结构体的对齐数。
那么根据以上4条结构体内存对齐规则,我们可以画处并计算出结构体s4的总大小。
运行下面程序,可以验证结构体s4的大小确实是32。
ps:当变量的大小超过编译器默认的对齐数时,对齐数为编译器默认的对齐数。(因为对齐数取的是这二者中的较小值)
例如:在VS环境下(默认对齐数为8),如果成员变量的大小超过了8,则该成员变量的对齐数仍然是8。
以上就是结构体内存对齐的规则,你弄懂了吗??
内存对齐的意义
从上面的计算中我们知道,结构体内存对齐实际上浪费了很多内存的空间,那么为什么我即使浪费了内存空间,也要内存对齐呢?
虽然这个对于结构体内存对齐的意义并没有一个官方的解释,但是我们我们可以从两个角度来思考内存对齐的原因。
- 平台原因(移植原因)
不是所有平台都能访问任意地址上的任意数据的,某些硬件平台只能在某些地址处访问某些特定类型的数据,否则就会抛出硬件异常。
即一些硬件可能只能在一些特定的位置上读取数据(比如4的整数倍数),那么如果我们把数据放在了偏移量为2或者3的位置的时候,它就读不到我们的数据了,所以为了保证硬件能够读到我们的数据,我们最好把数据对齐放在硬件能读取的位置上。
2.性能原因
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
如果内存未对齐,则处理器访问内存时可能需要访问两次,而对齐的内存则仅需要一次访问。
ps:我们通常认为的自然边界通常是4、8、16等等4的倍数。
举例说明:
struct s
{
char c;//1
int a;//4
};
如上图,我们可以很直观地看到内存对齐对于读取数据的好处,它实际上提高了程序运行的效率。
所以总体来说:我们可以认为结构体内存对齐相当于是拿空间换取时间的做法。
一个设计结构体变量的原则
但是,虽然有时候我们会为了换取更高的效率而牺牲掉一些空间,但是从前面两个内容相同而大小不同的结构体中,我们也可以发现,通过设计,我们是可以在尽量节省空间的情况下做到内容对齐的。
也就是说,我们在设计结构体的时候,应该既要满足内存对齐,又要节省空间。
那么我们应该如何做呢?
答案就是:让占用空间小的成员尽量集中在一起。
这样我们就能尽量把可能会被浪费掉的空间利用上。
修改默认对齐数
我们知道,VS的默认对齐数是8,那我们能不能对默认对齐数进行修改呢?
其实是可以的。
下面我们就来看看修改的方法。
那么如果我们把默认对齐数设置为1,那么结构体中每个成员的对齐数都将变为1,相当每个成员都是依次存在内存中,没有内存对齐的情况。而结构体的大小就是每个成员的大小之和。
所以,如果在对齐方式不合适的时候,我们可以根据自己的需要来修改默认对齐数。
当然,内存对齐是为了提升效率,所以尽管我们可以设置默认对齐数,但是我们也要合理地对它进行应用,一般我们设置的默认对齐数为2n。
结构体实现位段
最后我们再讲一个跟结构体相关的内容:结构体实现位段的能力。
什么是位段
那么究竟什么是位段呢?我们先来看看它的“真容”。
位段的声明和结构体类似,但是有两点不同:
- 位段的成员必须是int、unsigned int或signed int。
- 位段的成员名后边有一个冒号和一个数字。
如上图中的A就是一个位段类型。
虽然我们说位段的成员必须是int、unsigned int、或signed int。但是写在代码的过程中,位段的成员也可以是char,所以实际上位段的成员可以是整形家族中的成员。
那么位段到底是什么意思呢?
首先我们先计算一下struct A的空间大小。
我们发现A的大小是8个字节,而A中包含4个int类型,按我们平常的计算A的大小应该是16个字节,而屏幕输出的却是8。
这说明了位段是可以节省空间。
那么它到底是怎么节省空间的呢?我们再来看。
其实位段中的位指的是二进制位,位段成员后面数量表示分给该成员的二进制位数。
那么为什么我们这样分配呢?
位段的意义
在生活生我们并不是所有的数据都需要占用那么大的内存空间,如果我们根据需要给数据分配合理适用的内存,那么就可以节省很多空间。
举个栗子:假如我们现在想表示性别,而性别只有男、女、其他三种状态,那我们其实可以用两个bit位来表示:
男 - 00
女 - 01
其他 - 11
我们会发现,我们只需要两个bit位就足以表示性别,而并不需要一块很大的空间,那么这时候如果我们用位段来实现,就能给内存节省很大的一块空间。
位段的内存分配
那么我们再回过头来看前面位段A,我们一共给A中的成员分配了47个bit位,大约占6个字节的空间,那struct A的大小应该是6个字节才对啊?可是屏幕上输出的大小是8,这又是为什么呢?
在这里我们要注意,位段只是在一定程度上节省了空间,但并不意味着它一点也不浪费。
下面我们就来看看位段在内存中到底是怎么分配的。
- 位段的成员可以是int、unsigned int、signed int或者是char(属于整型家族)类型
- 位段的空间上是按照需要以4字节(int)或者1个字节(char)的方式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注意可移植的程序应该避免使用位段。
那么我们就以前面的struct A作为例子来讲。
首先我们看到A中的成员都是int,所以它在空间上是以4字节(int)的方式来开辟的。即一次开辟一个int的大小,当空间不够的时候,再开辟一个int大小的空间。
所以这里我们可以看到,A的大小是8个字节。
那么这里有一个问题:_d的30个bit位是怎么存放在内存中的呢?
是其中15个bit位存放在第一个int中剩下的位置,另外的15个bit位存放在新开辟的int空间上吗?
还是_d的30个bit位都存放在新开辟的int空间上呢?
对于这个问题,标准并没有给出说明,但是我们不妨对它进行一个猜测。
首先我们拿下面这段代码来作为例子进行猜测。
我们用位段S创建了一个变量s,那么内存是怎么为s分配空间的呢?
首先,内存开辟了一个char的大小的空间,然后我们先分配其中的3个bit给a。
但是问题又来了,在分配的时候,是从高地址端开始分配,还是从低地址端开始分配呢?
这个问题标准仍然没有说明,那我们就先假设是从右边(低地址)开始分配的内存,那么分配了3个bit给a之后,剩下了5个bit,其中又分了4个bit给b。
现在还剩下1个bit,而c需要5个bit位,所以我们再开辟一个char的空间,
那么问题又又又来了,第一块开辟的空间中剩余的1个bit是浪费掉了呢?还是存了c中的一个bit呢?
这个问题标准仍然是没有告诉我们,所以我们还是继续猜测:这块空间被浪费掉了,c中的5个bit全部放在新开辟的空间上。
这时候我们第二块开辟的空间还剩下3个bit,不足以放d,所以紧接着我们又开辟了一块空间来存放d。
那么现在我们把a、b、c、d的值放进去。
那么内存中到底是不是我们猜测的样子呢?
现在我们把它转换成16进制,得到62 03 04。
然后再回到程序中,一边调试一边查看内存。
程序进一步调试,我们看看s的内存空间上的值是不是我们刚刚得到的62 03 04。
我们可以看到,这和我们得到的数是完全吻合的,说明我们之前的猜测都是正确的。
位段在内存中是先开辟了一块空间,然后把数据从低位向高位存放,如果高位中的空间不够用了,那我们再继续开辟下一块空间,然后再在新的空间中存放数据,并且每次剩余的不够存放一个数据的空间是被浪费掉了。
但是注意,我们的猜测仅仅是在VS环境下得到了验证,这并不意味着在其他环境下我们也可以得到这样的结论,因为标准并没有给出一个统一的说明。
所以我们要注意位段在内存分配的第三条,即位段中存在许多模糊地带,我们在使用位段的时候应该十分谨慎,避免在可移植程序上使用位段,以防程序移植到另一个平台上时出现错误。
位段的跨平台问题
从前面我们已经知道,位段中有许多标准未定义的地方,所以它是不能跨平台,具体我们总结了以下几点。
- int位段是有符号数还是无符号数是模糊的。
- 位段中的最大数目是模糊的。不同机器上允许存放的位段的最大bit数是不同,这有可能导致位段从一个平台移植到另一个平台上后出现错误。(16位机器上最大数是16,32位机器上最大数是32)
- 位段中开辟的空间是从左向右使用爱是从右向左使用是模糊的。
- 当一个结构包含两个或以上的位段成员时,第二个位段成员比较大,无法被第一块开辟的空间容纳时,第一块空间中剩余的空间是被舍弃还是继续利用,这也是模糊的。
所以,对比结构,位段可以和结构达到相同的效果。
理论上讲,结构可以出现的地方,位段也可以出现。相比结构,位段有它的优点——节省空间,也有它的缺点——存在跨平台问题。
位段的应用
那么我们认识了位段之后,它到底是怎么用的呢?它通常在什么时候用呢?
我们可以看看下面这张图。
我们在网络上使用数据的时候,需要对数据进行封装,这时候就需要存放一些与数据相关的信息,我们暂且把它成为一个包。
假如我们不使用位段,那么这个包相对来说就会比较大, 而如果网络上所有的数据都背着一个大包,那么网络上那么庞大的数据量,就会造成许多空间的浪费,整个网络也会显得很拥挤。
而如果我们使用位段,包中的每个信息我们只需要给它分配足够的空间,那么这个包的就会小很多,这个数据背着小包在网络上通行的时候也会走的更加轻快。
所以实际上,利用位段可以很好地搭建网络底层的一些架构。
枚举
在我们的生活中,有一些值是有限的,我们可以一一列举出来,比如性别、月份等等,我们把这些值一一列出来,就是枚举。
枚举类型的定义
枚举中我们要使用到一个关键字enum,我们以一周的星期来举例。
上面这个day就是一个枚举类型,而{ }内的内容就是我们之前提到的枚举常量。
我们把上面的值用整型的形式打印出来。
我们可以看到,这些枚举常量是有值的,它默认从0开始,依次递增。
那么如果我们不想让它的值从0开始,我们可以在定义枚举类型的时候给它进行赋值。
我们要注意,day是一个枚举类型,其中放的是枚举常量,而我们给它赋的值是未来我们创建了一个枚举常量之后可能的取值。我们只能在定义枚举类型的时候给它们赋初值,但是不能再后面创建变量的时候修改它的值。
注意:当我们创建了一个枚举常量之后,我们应该给它赋相应的枚举类型的可能取值,而不是直接赋上一个数值。
上面的代码虽然在C中可能通过,但是在.cpp文件中是非法的。
枚举的优点
那么我们为什么要使用枚举呢?
- 为什么我们在定义一个值的时候,不直接给它赋一个值,而是要赋枚举常量的可能取值呢?
这是因为,利用枚举相当于给这些值赋上了一些相应的意义,当我们比如上面的1赋给了Mon,那么我们就能知道1表示的就是星期一。这实际上增加了代码的可读性和可维护性。
那么我们之前也学过#define定义的标识符常量,枚举和它相比又有上面区别呢?
实际上,#define定义的是一个标识符,即用一个符号来代表某个有特定含义的数,这样未来在这个数发生变化时,可以通过#define来进行快速修改。
-
而枚举不仅可以提供这样的功能,枚举的定义让这些常量具有了类型——枚举类型,相比只下枚举会更加严谨。
-
不仅如此,#define定义的常量是直接暴露在全局范围内的,而我们定义的枚举常量是被封装在一个类型里面的,它能防止命名的污染。
-
并且我们可以通过枚举类型创建变量,于是我们可以在程序中通过调试来观察,而#define定义的标识符常量实际上只是一种等价替换,我们是无法通过调试来观察它的。
-
枚举可以一次定义多个常量,而#define一次仅能定义一个值,所以从使用的角度来讲,枚举更便于使用。
枚举的使用
拿我们之前写过的一个简易计算机来举例:
如果我们这样写,数值和选项要通过菜单一一对应,比较别扭。
但是如果我们定义了一个枚举类型,我们就可以直接这样写:
枚举的大小
我们知道枚举类型是一种自定义类型,我们根据这个类型创建了一个变量才在内存上分配了空间,而枚举常量的值都是一些整数,所以我们可以得到,枚举类型的大小就是一个整型的大小。
联合
接下来我们再讲一种特殊的自定义类型——联合体。
联合体也叫共用体,它的变量包含一系列的成员,而这些成员是共用同一块空间的。(具体如何共用,别急,后面会讲到~)
联合类型的定义
那么联合类型是如何定义的呢?
首先联合的关键字是union。
联合的特点
前面我们已经说到,联合的特点就是成员共用一块空间。
下面我们先计算一下联合变量的大小。
我们可以看到,联合体Un中包含一个char c,一个int i,它们加起来应该是5,而屏幕上输出的联合体的大小是4,这里就体现了联合体的特点,因为c和i是共用一块空间的,所以联合体Un的大小是联合体中最大成员的大小。
接下来,我们再来看一下联合体和其中的成员的地址。
我们可以看到,联合体和它的成员的地址是一样的,进一步体现了联合体中的成员是共用一块空间的。
所以这里我们也要注意,我们改变i的时候,可能会改变c,改变c的时候,也会改变了 i 的值。
同时,一个联合变量的大小,至少是最大成员的大小,因为联合体得有能力保存最大的那个成员。
那么利用联合体,我们也可以判断当前机器的大小端。(关于机器大小端字节序的内容,忘记的同学可以翻看这篇噢C语言进阶第一问:数据在内存中是如何存储的?(手把手带你深度剖析数据在内存中的存储,超全解析,码住不亏))
那么我们什么时候适合用联合体呢?
当联合体中的所有成员在同一时间中只使用一个,并且它们可以共用同一块的空间时候,我们就可以使用联合体。
比如学校要开发一个教务管理系统,那么教务管理系统中的人的身份就分为学生和老师,而一个人不可能既是学生,又是老师,而我们可以用同一块空间来表示学生和老师,这时候我们就可以用结构体来描述教务系统中的人的身份。
联合大小的计算
那么联合大小应该如何计算呢?
- 联合体的大小至少是最大成员的大小
- 联合体中也存在内存对齐。当最大成员的大小不是最大对齐数的大小时,联合体的大小就要对齐到最大对齐数的整数倍。
我们通过例子来看看。
以上就是C语言中的自定义类型,有了这些自定义类型,我们就可以根据自己的需要写出自己想要的类型啦!
好啦,今天的文章就到这里啦!如果你喜欢博主的文章,记得点赞评论收藏一波哦!
你的鼓励将是我继续努力码字的巨大动力!!!!
关注我,一起精进C语言吧!~