一.结构体
1.结构体的声明
在讲结构体之前我们需要先讲讲什么是结构?
结构是一些值的集合,这些值被称为成员变量,且这些成员变量之间的类型可以不同
结构体类型是用来定义一个复杂对象的,下面是结构体类型的创建流程
1.结构体关键字 + 结构体标签名 = 结构体类型名
2.两个花括号以及其中的成员变量 + 最后一个花括号右边的分号 ;
(结构体类型的建立)
3.结构体变量(复杂对象)的申请有两种方式 ,一种是直接通过结构体类型名+变量名申请结构体变量(这种方式申请的结构体变量是局部变量)
另一种则是在我们创建的结构体类型的最后一个花括号和分号;之间输入变量名创建(这种方式申请的结构体变量是全局变量)
成员变量可以是数组,也可以是普通变量,同时成员变量间也可以是不同的类型
类型就是用来创建变量的,而我们创建的结构体类型就是用来创建复杂变量的(结构体变量)
第二种申请结构体变量的方法, 通过这种方式申请多个结构体变量的时候,变量间要用逗号隔开
不要忘了通过这种方式申请的变量是全局变量
在花括号内创建的是局部变量,花括号就是它的作用域
在所有花括号之外创建的变量就是全局变量,整个程序都是它的作用域
除了上面这些正常的结构体声明,还有一些特殊声明
1.匿名结构体类型声明 --- 无结构体类型标签名 --- 创建之后只能用于创建全局变量,不能用于创建局部变量
而全局结构体变量s的类型就是它所依靠的匿名结构体类型
上面这个其实就和 int a是一个格式
结构体类型 变量名 ; --- 这就是一个变量创建了
结构体指针就是 结构体类型* 变量名;
注意一个结构体关键字就代表了一个结构体类型的创建,哪怕创建的成员变量一模一样,这也是两个不同的结构体类型
计算机会认为每次创建的结构体类型都是独一无二的,是不会相等的,哪怕成员变量一模一样也不相等
上面和下面是两个不同的结构体类型 --- 类型不同
结构体的自引用 --- 能够通过自己找到与自己同类型的下一个元素
当我们想要将一个结构体变量作为一个结构体类型的成员变量的时候,我们必须保证这个结构体变量对应的结构体类型的大小已知,否则的话该结构体变量不能成为这个结构体类型的成员变量。
原因是一个结构体类型如果想要创建的话就必须具有具体的内存大小,而其内存大小则是由其成员变量决定的,如果直接用不知道大小的结构体变量作为类型的成员变量的话就会导致现有鸡还是先有蛋的死循环 ---
要知道结构体类型的大小就必须知道成员变量的大小 ,若直接用不知道大小的成员变量的话,我们就需要知道其结构体类型的大小.....就这样形成了一个衔尾蛇死循环。
(补充知识:什么是数据结构?数据结构是数据在内存中存储的结构体)
链表中的节点分为两个域,数据域用来存储节点中的数据,指针域用来存储下一个节点的地址,通过这个地址我们就能够从一个节点跳到另一个节点了
而一个节点只有一个变量,所以一个变量要包含两个类型,所以这个变量是结构体变量,所以我们要创建一个结构体类型来声明这个结构体变量
这个结构体类型中包含节点需要的数据域的信息和指针域的信息,指针域中存的是下一个同类型的结构体变量(节点)的指针
上面这种创建就称为结构体的自引用,这种创建是成立的,因为指针的大小固定为4(32) / 8(64),所以结构体的大小也是确定的
注意typedef这种写法是错误的,因为类型重命名的前提是我们在创建了类型之后才能对类型重命名,重命名之后才能使用新的名字
上面这个则是在类型还没创建的时候就使用了重命名后的新名字,这就导致了逻辑错误,所以上面这种结构体类型的创建是非法的
结构体变量的定义(创建)和初始化
结构体变量的创建就是我在上面讲到的那两种方式 --- 1.全局变量创建 2.局部变量创建
结构体变量的初始化
结构体变量用大括号来初始化,大括号内初始化的数据顺序与结构体类型中的成员变量顺序一致
如果结构体类型中有结构体变量的话我们又该如何初始化呢? --- 答案是把握关键,遇到结构体变量初始化都要用大括号,如上图
然后是结构体变量的访问 ,它的访问可以用两种操作符分别实现,分别是:
1.点操作符 . ---- 针对结构体变量使用
2. -> 箭头操作符 --- 针对结构体指针使用
多重结构体变量就用多个点操作符来进行深层访问
结构体变量如果进行不完全初始化的话,未被人工初始化的成员变量会被自动初始化为 对应的"0",如指针的0就是 NULL
关于结构体内存大小的探讨 -- 结构体内存对齐规则
比如上面这个结构体类型的内存大小是多少呢?如果你说是6的话,那就错了
答案应该是12 ,为什么会是这个答案呢?
这就得讲到结构体内存对齐规则了
第一句话解读:“第一个成员在与结构体变量偏移量为0的地址处”
首先我们创建一个结构体变量时会在根据其类型大小开闭一块内存空间,这个内存空间的第一个内存单元的地址就是这个结构体变量的地址
然后我们规定偏移量概念:
偏移量就是在结构体内存空间中成员变量地址到结构体变量地址之间差的内存单元的个数
然后我们开始给成员变量分配内存空间
首先就是我们的第一句话:
“第一个成员的地址在与结构体变量偏移量为0的地址处”
然后就是第二句话:
2.其它成员变量要对齐到偏移量为某个数字(对齐数)的整数倍的地址处
(尽量选偏移量较小的位置)
对齐数 = 编译器默认的一个对齐数 与 该成员变量类型的大小 之间的较小值
其中vs编译器的默认对齐数是 8 , Linux平台 - 没有默认对齐数
上面就是根据对齐数规则计算得到的成员变量内存分配
int -- 大小为4 , 默认对齐数为8,4小,所以第二个成员变量的对齐数是4,从第一个成员变量开始找到离其最近的偏移量为4的倍数的内存单元,并将其作为int首内存单元的地址
偏移量从第一个内存单元开始,属于从0往下加1
然后从这个内存单元开始分4个内存单元给 int 占据,再接着是第三个成员变量 --- 1 ,找倍数,占据 --- 以此类推,完成成员变量在内存空间中的分配。
但是!!!做到这一步后我们还没有得到整个结构体类型的大小
1.接下来是第三句话:“结构体的总大小为最大对齐数(不考虑vs的默认对齐数,而只考虑每个成员变量本身所具有的对齐数)的整数倍”
另外:2.结构体的类型大小必须大于我们已经分配好了的内存空间的大小。
同时,我们应该在满足条件1和2的前提下尽量选择小的内存空间分配给结构体类型,避免内存空间的浪费
综上我们可以知道上面这个结构体类型的大小为 12 . 12 > 9 ,且12为最接近9的4的倍数 符合要求
最后结构体内存空间的计算还有一个特殊情况:
翻译过来就是如果出现了一个结构体类型a中嵌套了结构体b的情况的话,则在对结构体类型a在进行空间分配时,嵌套进去的结构体b应该对齐到偏移量等于它的最大对齐数的整数倍处,然后它所占的内存空间的大小为其结构体类型对应的内存空间的大小。
然后这个结构体类型a的内存大小就等于其成员变量中最大对齐数(其中嵌套进去的结构体变量的对齐数是其对应结构体类型中的成员变量中的最大对齐数)的整数倍。
嵌套进去的结构体变量b的对齐数是它对应的结构体类型中的所有成员变量中的最大对齐数
为什么会存在内存对齐呢?
这张图是对第二句话的解释:
如果数据对齐的话,假设计算机一次读取四个字节 --- 那么上面那种情况下 i 只需要计算机读取一次就可以读取到
而下面那种情况依然是一次读四个字节,如果没有对齐的话,计算机读取 i 就需要读取两次才能读到 --- 第一次读到 i 的前三个字节 ,第二次读到 i 的最后一个字节
第二种方式省了内存空间,但是浪费了读取时间
而第一种方式则是通过空间换时间的方式提高读取效率
通过上面的学习我们知道了在我们构建结构体时,编译器会根据内存对齐规则来分配结构体的内存空间,而内存对齐的目的就是通过空间换时间,提高计算机读取效率。
但是!!这并不代表着我们能够无限制的用内存空间来换取时间
正确的做法应该是在设计结构体时,我们既要满足对齐,同时又要尽可能的减少空间的浪费
那么我们该如何做到呢? --- 方法是
让占用空间小的成员变量尽量集中在一起
s1是12个字节 ,s2是8个字节
四.修改默认对齐数
修改方法是使用预处理指令
预处理指令: #pragma pack( unsigned int num ) --- 这个指令允许我们修改默认对齐数
如果括号里什么都没有的话,则是将对齐数改为默认对齐数
如果括号里输入对应无符号整型的话,则是将默认对齐数改为括号里的无符号整型
默认对齐数就是最大对齐数,大于它的则取默认对齐数,小于它的则取小于它的数,所以说它是最大对齐数
但我们觉得默认对齐数不合适的时候,我们就可以通过#pragma pack()指令去修改
介绍一个用来计算结构体中某变量相对于首地址的偏移量的 宏
offsetof 宏 --- offset就是偏移量 ,of就是谁的
#include <stddef.h>
offsetof(type , member)
type : 结构体变量名
member:是对应结构体变量中的成员变量名
返回值是成员变量地址到结构体变量地址之间的的偏移量
五.结构体传参
结构体传参有两种方式,一种是直接传结构体,一种是传结构体的地址并用指针接收
第一种方式就是直接创建一个临时的结构体变量来接收传过来的结构体参数,并且通过点操作符来访问结构体中的成员,但是这种方式传输参数时消耗的时间,存储参数时消耗的空间都非常大,是一种十分低效的传输方式
所以我们一般都选用第二种结构体传参方式 ---- 传结构体变量的地址
通过传地址的方式我们就能够高效的利用内存空间,同时节省传址时间
同时如果想通过结构体变量的地址来访问其成员的话我们需要用到箭头操作符
箭头操作符(仅对结构体指针适用)的本质是两个指令:假设 a(结构体指针) -> b(成员指针)
首先执行第一个指令:就是将a的地址加上合适的步长得到b的地址
然后执行第二个指令:将上一步得到的b的地址解引用。
六.位段
补充:第一个不同中的位段的成员类型还要多一个char类型
左边那个是结构体类型的声明,右边那个是位段的声明
(多冒号,多数字,且只能是固定的四个类型)
那么这个数字是什么呢?
答案是:这些数字是我们规定的左边那个成员变量占据的内存大小 --- 以字节为单位
注意:后面这个分配给成员变量的字节数不能够大于成员变量类型的大小
然后我们要回答的是位段是如何开辟空间的:
首先一个位段中的成员变量都只能是同一类型的变量,如都是int / unsigned in / signed int /char等等,若位段中都是int(包括有无符合)型的话,则位段以int的大小(即4个字节)的大小来开辟空间,如果是char类型的话则是一个字节一个字节的开辟空间大小。
那么问题来了,当我们知道了开辟的空间大小后,我们又该怎样确定开辟多少个空间呢?
答案就是由成员的总bit位来确定 ---- 我们开辟的空间大小必须大于成员的比特位之和
如上面这个位段A
首先我们先开辟4个字节的空间 -- 32个bit位 < 成员变量的总bit位,所以我们需要再开辟4个字节的空间来装上一个int装不下的字节。
来到这的时候问题又出现了 --- 已知位段A中溢出一个int型 15 个字节,造成的溢出的成员变量是_d ,为了能够装下所有成员变量我们又开辟了一个int型空间来处理溢出内容
那么问题来了,请选择你的处理方式:
1.将第一个int的32位塞满,然后再让多出来的15位塞到第二int中
2.直接将造成溢出的成员变量_d放到新开辟的int中
你会选那个呢?这对于我来说显然是不确定的,而计算机在工作时亦是如此
不同平台的位段就可能有不同的方式来处理多出来的bit,所以我们要引出下面这一句话:
位段涉及很多不确定因素,所以文段是不跨平台的,注重可移植性的程序应该避免使用位段
在vs编译器下的位段内存空间创建
首先先创建一个与成员变量大小一致的内存空间
然后分配bit位给成员变量的顺序是按照由上到下的顺序来排序的
其次内存空间中从右往左分配bit位
最后如果内存空间中可分配的bit位小于待分配的内存变量的bit位需求的话,就直接在原有内存空间的右边创建一个新的内存空间(大小和第一个内存空间一致)
就这样按照上面的步骤将所有成员变量的bit位都分配好后,总的内存空间的大小就是位段的大小
注意这个位段的分配步骤只在vs编译器环境中起作用,在别的环境中可能有不同的步骤
ps:大小端讨论的是字节与字节的录入顺序
上面这些是位段在跨平台的时候可能遇到的一系列问题
第一个关于int的有无符号问题 --- c语言对于int的有无符号并没有做明确的规定,如今在大多数平台上都规定int是有符号的int ,部分平台可能规定成无符号的int
第二个则是因为在16位机上 int只有2个字节(16位) ,而32位机上则有4个字节(32位),最大值不同影响了我们能够规定给成员变量的字节数
位段与结构体相比 --- 位段能够在实现同样效果的前提下节省空间,但是由于上面的种种问题,位段不具有跨平台性。
位段的存在意义就是实现在有限的空间中存储进尽可能多的信息的目的