文章目录
前言
路漫漫其修远兮,吾将上下而求索;
一、结构体类型的声明
1、结构体的基础知识
结构体是一些成员变量的集合,每个成员可以是不同类型的变量;
2、结构体的声明
可以在创建结构体类型的时候同时捎带着创建结构体变量,也可以在有了结构体类型之后再创建结构体变量;
3、特殊声明
在声明的时候,可以不完全声明;
例如:匿名结构体类型(即无结构体标签 tag)
在匿名结构体中,即使有两个成员变量的类型、大小都一样的结构体类型,只要该结构体变量不是在同一个匿名结构体创建的变量,编译器会认为它们的类型不同;例子如下图:
二、结构体的自引用
在结构中包含一个类型为该结构本身的成员是否可以呢?
例子如下:
以上例子是不可行的,因为将struct Node 作为类型定义的成员又作为结构体类型struct Node 的成员,就好比”先有鸡还是先有蛋“的问题,是个无厘头问题;
在讲述结构的自引用之前,我们先来了解一下一些相关的基础知识;
如果你想在内存中存放数据 1 2 3 4 5,想让其像数组一样在内存中连续存放,这便是顺序表;即顺序表这种结构中的数据在内存中连续存放;
那如果数据不在内存中连续存放呢?
如上图所示,如果你找到了1,1可以找到2,2可以找到3,3可以找到4,4可以找到5,即找到1后面的数字就都可以找到了;这种方法就像一个链条,将这些数据串起来了,故而叫做链表;
那么如何实现链表呢?
你可能会这样想,在1的位置包含1的数值和2的结点,1便可以找到2;在2的位置包好2的数值和3的结点,2便可以找到3;在3的位置包含3的数值和4的结点,3便可以找到4;在4的位置上包含4的数值和5的结点,4便可以找到5;
那么这个结点该如何设计呢?
呈现出来就是上图这个样子,无法利用sizeof(struct Node) 的大小,因为结构体类型中包含用结构体类型创建的变量的这种写法本身就存在问题;
所以就有人想到,让上一个结点包含下一个结点的地址;
写作以下方式:
struct Node
{
int date;
struct Node* next;
};在结构体类型 struct Node中包含的结构体指针next 能找到自己同类型的一个结点,故而叫作自引用;
匿名结构体是否可以自引用?显然也是可以的。
请判断以下代码是否正确?
typedef struct
{
int date;
Node* next;}Node;
答案:此代码是错误的。因为首先匿名结构体类型首先要存在,才能对此类型进行类型重命名,而其成员Node* next ; 中的类型不存在;--> 在创建结构体类型的时候其成员使用了利用typedef 重定义的类型,非常矛盾;(也是先有鸡还是先有蛋这种无厘头的问题)
解决方案:
typedef struct Node //将结构体类型重定义为 Node
{
int date;
struct Node* next; //结构体类型 struct Node 的指针类型}Node;
其实上面的这个代码还可以这样写:
扩展:在实现链表的时候通常有这么一种写法;
三、结构体变量的定义和初始化
1、结构体的定义分为两类
一是声明其类型的时候创建结构体变量;二是在其类型声明好之后再创建结构体变量;
2、结构体的初始化
在结构体变量创建的时候就给其一个初始值;然而在创建结构体变量的时候就有两种情况,当然,初始化时也有两种情况;
如若初始化嵌套结构体:
当然,也可以不按照顺序来初始化(一般人不会这么做,所以了解就行):
四、结构体内存对齐
1、结构体在内存中是怎样存放的呢?
我们先来看下面的例子:
在上图中,明明两个结构体类型的成员都一模一样,只是成员的顺序不同,两者创建的变量所占内存空间的大小就截然不同; 说明结构体在内存中的存储别有天地;即结构体并中的成员并不都是在内存中紧挨着存放的;
上述问题就涉及到了结构体的对齐规则;
2、结构体的对齐规则:
1、第一个成员总是存放在结构体变量申请的空间偏移量为0的地址处;
2、其他成员变量(除第一个成员变量以外的成员变量)要对齐到其对齐数的整数倍偏移量的地址处;
对齐数的计算原则:对齐数 = 在该成员所占空间的大小 与 编译器默认对齐数中间取一个较小值;
注:在VS编译器中,其默认对齐数为8(默认对齐数可进行修改,文章下面会讲述);可以说,只有在VS编译器上有默认对齐数的概念,而在其他编译器上,没有默认对齐数的概念,那么在其他编译器上,其对齐数就是该数据自身的大小;
3、结构体的总大小为其成员的对齐数中最大对齐数的整数倍;
4、如果是嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数 (该结构体最大对齐数就是其成员中对齐数最大的那个数) 的整数倍偏移量的地址处;而此嵌套结构体的结构体的整体大小是其成员对齐数中最大那个数的整数倍;
分析规则:
以 struct S1 为例;(所画的内存图解假设其一个格子为一个内存单元)
1、偏移量:以字节为单位算当前内存单元与起始位置的偏移;
由于规则1中所述,第一个成员总是存放在结构体变量申请的空间偏移量为0的地址处;而c1 为char 类型,故而所占内存空间的大小为1byte ,如上图所示;
2、对齐数,计算如下:
按照结构体的对齐规则,成员c2 、成员 i (第一个成员以外的成员)需要存放的偏移量为其对齐数整数倍的地址处;如下图所示;
而结构体变量s1 的总大小为在成员中最大对齐数的整数倍,即在成员c1、c2、i中,对齐数分别为1、1、4, 其中最大值为4,所以结构体变量 s1 的大小必然是4 的整数倍;而在上图中,s1 的三个成员占了8byte 的空间,是4的整数倍;所以 sizeof( s1 ); 的结果为 8 ;
为了验证每个成员是不是如上图所画一样存放在内存中,我们可以使用一个库函数 offsetof ,此函数具体使用如下:
- size_t offsetof ( structName,memberName);
- offsetof 可返回一个结构体成员在此结构体类型中创建时存放在为多少偏移量的地址处;
可见,我们的分析是对的;
同理,我们分析一下 s2 的对齐;如下图;
看到此处,你可能会有疑问,嵌套结构体的结构体变量的对齐规则呢?它在内存中又是如何存放的?例子如下:
如果是嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数 (该结构体最大对齐数就是其成员中对齐数最大的那个数) 的整数倍偏移量的地址处;而此嵌套结构体的结构体的整体大小是其成员对齐数中最大那个数的整数倍;
用库函数 offsetof 进行验证:
3、为什么存在内存对齐?
1、平台原因( 也称为移植原因)
并不是所有平台都能访问任意地址上的任意数据;某些硬件平台只能在某些地址处取得某些特定类型的数据,否则就会抛出硬件异常;
基于此原因,所以在结构体变量中要将某些数对齐到能被取出的地址处,这样才能访问到我们想要访问的数据;
2、性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问存放在未对齐内存中的数据,处理器需要作两次内存访问才能将此数据读取完整;而对齐的内存访问仅仅只需要一次访问;--> 结构体变量的内存对齐减少了处理器访问内存空间的次数而提高了效率;
综上,结构体变量在内存中的存储存在内存对齐,会浪费空间,但是减少了处理器访问内存空间的次数,而提高了处理的效率;
注:我们常说的32位机器、63位机器指的是32位CPU、64位CPU;而其中32位和64位指的是CPU对应的字长,字长决定了CPU在读写数据的时一次会读多大的内存空间;
32 位的机器在处理数据的时候一次可以操作4 byte 即32 比特位 的数据;64位机器在处理数据的时候一次可以操作8byte 即64 比特位的数据;
显然如上图所示,存在结构体对齐规则,虽会浪费空间,但计算机在访问内存中的数据的时候,极大地提高了访问的效率;
4、注意
如果想在设计结构体的时候,既满足对齐,又要节省空间,那么就得让占用空间小的成员尽量集中在一起,以便利用好零碎的空间;
5、修改默认对齐数
细心的你,可能就发现了之前在头文件中有 #pragma 这个预处理指令,在此处我们可以使用这个指令来改变我们的默认对齐数;
注:#pargma once 一般在头文件中使用,以防止该头文件被多次引用;
VS的默认编译器的对齐数为 8 :
利用 #pragma pack(4) 将默认对齐数修改为4:
注意:如上图所示,此处的 #pragma pack(4) 并不是只生效一次而是与 #pragma pack() 配合得以实现对一定范围的控制;下图便可以清楚地体会到:
上图的输出结果分析如下图所示:
修改默认对齐数的意义:
当你觉得此对齐数不合适的时候;例如,按照8byte 默认对齐数时浪费的空间太多的时候,便可以利用 #pragma pack(修改的对齐数) 将默认对齐数修改地小一点;
或者你不想注重效率而不想浪费空间,便可以将默认对齐数设置为1,即 #pragma pack(1);
五、结构体传参
其实结构体传参与函数传参一样的道理,结构体变量作为一个参数,存在传值调用与传址调用;
在函数中,我们知道传值调用,形参是对实参的临时拷贝,改变形参并不会影响实参;
而传址调用,就是将该变量的地址传过去,形参接收实参的地址,改变形参实际上也会改变实参;
可见,无论时传值调用还是传址调用,均可以完成打印的任务;经过之前的学习,你一定能够感受到,传值调用能做到的,传址调用也能做到;但是传值调用不能做到的,传址调用还是可以做到;相较而言,传址调用的功能要更加强大;
实际上对于结构体传参来说,最好采用传址调用的形式;因为传值调用中形参是对实参的临时拷贝,倘若此结构体所占用的空间内存较大,那么就会消耗空间与时间来进行拷贝;
同样的,还可以利用函数栈帧的知识来解释,在函数传参时会有参数压栈,在上图中体现为,结构体变量s 传参给形参 ss 时,会在栈区开辟一块较大的空间来接收传过去的数据;而参数压栈存在对系统空间内存的开销,况且在拷贝数据时还需要时间,即在时间与空间上均有开销而导致其效率低下;
用更加专业的语术来讲就是,函数传参时,参数是需要压栈的,于是会有时间与空间上的开销;如若采用传值调用而此结构体过大,参数压栈时的系统开销会比较大,故而导致性能下降;
传址调用能达到传值调用带来的同样的效果且在效率上更为高效;故而对于结构体传参做好采用传址调用的形式;
六、结构体位段的实现
位段只能在结构体中使用;
1、什么是位段
位段的声明与结构体的声明是相似的,但存在两个不同点:
1、位段的成员必须是整型家族的,即为 char ,unsigned char , short , unsigned short , int , unsigned int , long , unsigned long……
2、位段的成员名后边有一个冒号和一个数字;
例如:
struct A
{
int a : 2;
int b : 25;
int c : 15;
int d : 4;};
在变量后面有了冒号和数字就代表着此变量非结构体变量而是位段;
- question1:其中的数字代表了什么呢?
位段中“位” 的意思是比特位的意思;以上为例即 a 只占了 2 bit , b 只占了 25 bit ,c 只占了 15 bit , d 只占了 4 bit ;
- question 2: int 类型的数据不是在内存空间中占4byte 即 32 bit 吗?那为什么 int a : 2 ; 意为 a 只占了 2 bit ?
此a 为整型确实在内存中占 32 bit ,但是现在 a 可能不需要这么多的比特位来存放其数据2,而只需要 2个比特位便已足够(因为十进制2写作二进制 :10 );故而 :2 的意思是为变量a分配 2 bit 的空间就可以了;
- question 3: 为什么不需要那么多空间呢?
在实际写代码的过程中,一些变量可能并不需要很大的取值范围;
例如,在结构体有一个成员 flag --> int flag ; 利用flag 来表示真假,那么此flag 的取值范围只有两个,一个为 0 一个为1;而若想要存放一个取值范围在0~1的变量并不需要32 bit 的空间来存放,仅需要1 bit 大小的空间即可;
所以在有一些成员的取值范围有限的情况下,存放这些成员变量的空间在一定程度上可以减小;
在结构体中,结构体成员在内存中存放时要遵循对齐原则,浪费了空间但是提高了数据读取的效率--> 用时间来换取空间;
而位段就是一种节约空间的语法;
2、位段的大小
位段的大小是否就是比特的单纯相加?答案是否。
上图中的A是一个位段类型,位段A 的大小为 8byte ;
可能你会有疑惑,位段不是节省内存空间吗,为什么此处还会浪费空间?
实际上,在此处的 8byte 确实浪费了一定的空间,但是位段所指的节省空间是相较于不用位段时所占的内存空间,此处不用位段便会占用16 byte 的内存空间,用的位段便节约了 8byte 的空间;
question 1: 此 8yte 是怎么来的?
我们有必要了解一下位段的内存分配:
3、位段的内存分配
规则:
- 位段的成员必须是整型家族
- 位段的空间是按照需要以 4byte 或者 1 byte 等的方式来开辟的(看其成员类型所占内存空间的大小);
很少会出现位段成员类型不同的位段类型;因为位段本身就是不稳定的存在,一般情况下位段的成员是统一类型的,如果将不同的类型放在一起就会使得此位段非常复杂反而更加不稳定;
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植性的程序应该避免使用位段;但是倘若非要使用位段的方式来节省空间,那么便要在面对不同的平台,机器上写出相应的代码;
所以,在上例中,看到位段A 的成员的类型为均int 类型,于是乎便会先开辟4 byte 的空间,当内存空间不够了再开辟4 byte 的空间;
这里存在两个问题,
一是申请得到内存空间之后,是先使用低地址处的空间还是先使用高地址处的空间;
二是,当已申请的空间不够放不下下一个变量的时候,向内存再次申请空间的时候,会先将未用完的空间使用完再使用新空间还是直接使用新空间;
上图是假设先使用低地址中继续使用未使用完的空间以及直接使用新空间的情况;下图是先使用高地址空间的情况;
显然,这三种方式所得到的结果都是 8byte,例子不够典型;
接下来,我们再看一个例子;
显然,由上图我们可以得知,当内存不够时,位段中的成员会直接使用新空间,并不会继续使用未被利用的空间;
注:计算机在开辟内存空间的时候时一次性开辟的;此处为了便于理解故而分开分析;
经以上推到,我们大致可以得到位段存储在内存空间中,内存空间不够时多余的空间会被浪费掉;但是存储顺序仍然不确定,即到底是从低地址往高地址存储还是高地址往低地址存储;
我们可以调式看一下;
显然,数据在内存中存放的数据与先使用高地址处空间这一情形中的数据相吻合;
所以位段在内存中存储时,会先使用高地址处的空间而后使用地址处的空间,并且不会跨字节存储一个数据(当这个数据所占空间的大小小于1byte时);
4、位段不跨平台的原因
1、int 位段被当作有符号数还是无符号数是不确定的
当你位段中的成员为int 类型的时候,位段会将因 int 所开辟的4byte 的空间当成signed int 还是 unsigned int 这是不确定的(于是就无法判断该数据最高位为有效位还是符号位),C语言中并没有明确的规定,故而体现不可跨平台性;
2、位段中最大位的数目不能确定;
(在16位机器上,最大位为16;在32位机器上,最大位为32;如果在32位机器上位段成员的大小为20,在32位机器上不会出问题,但是转移到16位机器上就会出问题)
3、位段中的成员时从左向右分配,还是从右向左分配,标准尚未定义;
只不过在vs编译器下,是从右向左使用的;但是在其他编译器上就不清楚了,因为标准未定义;
4、当一个结构包含两个位段的时候,第二个位段成员较大无法放入第一个位段成员剩余的空间的时候,是舍弃还是继续利用未被使用的空间,这是不确定的;
综上,跟结构体相比,位段可以达到同样的效果,位段可以节省空间,但是却存在跨平台的问题;
看到这,你可能还会有疑问,既然位段不可跨平台,那么为什么位段还没有被淘汰掉?
位段这种语法的设计本身就具有不可跨平台的属性,但是并不意味着不能利用位段写出可跨平台的代码,只不过会稍微麻烦一些;只要针对不同的平台写出不同的代码便就可以了;
总结
戳目录~