1.结构体
1.1 概述:
C 语言允许用户自己指定这样一种数据结构,它由不同类型的数据组合成一个整体,以便引用,这些组合在一个整体中的数据是互相联系的,这样的数据结构称为结构体,它相当于其它高级语言中记录。结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
1.2 结构的声明:
以描述 “ 学生 ”为例:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> //结构体的声明: struct student { char name[20]; int age; char sex[5]; float score; }s1,s2; //定义结构体变量s1、s2 //此处定义的结构体变量是全局的 struct student s3, s4; //定义结构体变量s3、s4 //此处定义的结构体变量等同于声明时定义,也是全局的 int main() { struct student s5, s6; //定义结构体变量s5、s6 //此处定义的结构体变量是局部的 return 0; }
1.3 特殊声明:
关于结构体的不完全声明,即匿名结构体类型
:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> struct //没有声明结构体标签,即为匿名结构体类型 { char name[20]; int age; char sex[5]; float score; }student = { "Zhang",21,"Man",91.7 }; //匿名结构体类型必须在生声明的同时进行定义 int main() { printf("%s %d %s %.1f\n", student.name, student.age, student.sex, student.score); return 0; }
我们把这种在声明时省略掉结构体标签的结构体称为匿名结构体类型,在使用这种方式进行声明时,由于没有声明结构体标签,导致一旦该结构体结束声明,将无法再次进行定义,所以对于该类型的结构体来说,就必须在声明结构体的同时进行定义(可以不初始化)。
再来看下面这个例子:
//结构体类型1: struct { char name[20]; int age; char sex[5]; float score; }x; //结构体类型2: struct { char name[20]; int age; char sex[5]; float score; }*p;
在这个示例中,虽然两个结构体类型内的结构体成员完全一样,但因为两者都使用了匿名结构体的声明方式,编译器会把上面的两个声明当成完全不同的两个类型
于是在下面的代码中将被视为非法
:
p = &x; //一种类型的指针指向另一种不同类型,将被视为非法
1.4 结构的自引用:
结构的自引用就是指结构体在自己的声明中引用了自己的一种声明方式。
struct Test { int data; struct Test n; }; int main() { struct Test n; return 0; }
我们说这种引用方式是非法
的。这是因为,当我们这样进行引用后,在我们定义结构体变量时,会进行自引用,但在自引用中又嵌套了对自身的引用,如此循环往复,而编译器并不知道该在何时停止自引用。
正确的自引用形式:
struct Test { int data; struct Test* NEXT; //使用指针指向确定的引用空间 }; int main() { struct Test n; return 0; }
当我们在进行结构体变量的定义时同样进行了自引用,不同的是这一次我们使用了一个指针,指向了下一个结构体变量的空间,而在这次指向之后,指针指向的空间被固定,不再指向其它空间,如此就实现了真正的结构体自引用。
同时,我们还可以结合关键字 typedef
进行使用:
typedef struct Test { int data; struct Test* NEXT; //但在这里必须仍使用struct Test //在结构体声明结束后才会进行重命名 }Test; //使用tepydef关键字,将struct Test类型重命名为Test类型 int main() { Test n; //经过重命名,在进行定义时可以直接使用重命名后的类型名进行定义 return 0; }
我们可以结合关键字 typedef 来将我们声明的结构体变量进行重命名,方便我们对结构体变量定义与初始化。但要注意的是,在使用 typedef 时,在结构体声明内部进行自引用时,仍需写成完全形式,这是因为,只有在结构体声明结束后才会对我们声明的结构体类型进行重命名
。
1.5 结构的定义与初始化:
举个例子:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> struct student { char name[20]; int age; char sex[5]; float score; }s1 = { "Zhang",21,"Man",98.4 }; //初始化结构体变量s1,此处的结构体变量是全局的 struct student s2 = { "Wang",20,"Woman",99.5 }; //初始化结构体变量s2,此处初始化的结构体变量等同于声明时初始化,也是全局的 int main() { struct student s3 = { "Jiao",21,"Man",67.2 }; //初始化结构体变量s3,此处的结构体变量是局部的 printf("%s %d %s %.1lf\n", s1.name, s1.age, s1.sex, s1.score); printf("%s %d %s %.1lf\n", s2.name, s2.age, s2.sex, s2.score); printf("%s %d %s %.1lf\n", s3.name, s3.age, s3.sex, s3.score); return 0; }
1.6 重点
结构体内存对齐:
经过上面的学习,我们就已经基本掌握了结构体的使用了。接下来我们将要深入研究结构体大小的计算过程,即结构体内存对齐,而这也是近年来的热门考点。先来看看下面这段计算结构体变量大小的代码:
#include<stdio.h> struct test1 { char a; int b; char c; }test1; struct test2 { char d; char e; int f; }test2; int main() { printf("The size of test1 is %d\n", sizeof(test1)); printf("The size of test2 is %d\n", sizeof(test2)); return 0; }
我们将其编译运行起来看看结果:
The size of test1 is 12
The size of test1 is 8
我们看到,实际的计算结果与我们的猜想大相径庭,那么到底是哪里出现了问题呢?这就是我们在这里需要研究的内容:结构体内存对齐
。
要想弄清楚究竟是如何进行结构体变量大小计算的,我们首先得掌握
结构体的对齐规则:
第一个成员在与结构体变量偏移量为0的地址处。(偏移量:该成员的存放地址与结构体空间起始地址之间的距离)
其他成员变量要对齐到对齐数的整数倍的地址处。
对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。
对齐数在VS中的默认值为8
结构体总大小为最大对齐数的整数倍
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数的整数倍。
知晓了结构体的对齐规则,我们再回过头来分析上面的结构体变量大小计算过程。
分析如图:但是我们发现,这样的方式造成了很大程度上的空间浪费,以 test1 为例,12个字节的大小中有六个字节的空间申请了但却没有被使用。那么为什么还要采用这样的办法呢?
主要有以下两个原因:
平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。通俗来说结构体的内存对齐就是一种用空间换时间的处理方法。而我们能做的,就只有以上面的 test1 与 test2 为例,尽可能的选取 test2 这样,使占用空间小的成员尽可能的集中在一起。
1.7 修改默认对齐数:
在我们的代码编写过程中,默认的对齐数可能会不够合适。而当这个时候,我们就可以通过使用下面这个预处理指令来修改我们的默认对齐数:
#pragma pack(8) //修改默认对齐数为8 • 1 • 2
我们也可以通过该指令在修改过默认对齐数之后,取消设置的默认对齐数,将其还原:
#pragma pack() //取消设置的默认对齐数,还原为默认
1.8 结构体传参:
结构体传参与函数传参类似,我们直接来看下面的示例:
#include<stdio.h> struct TEST { int data[1000]; int num; }; struct TEST test = { {1,2,3,4}, 1000 }; //结构体传参 void Print1(struct TEST test) { printf("%d\n", test.num); } //结构体地址传参 void Print2(struct TEST* p) { printf("%d\n", p->num); } int main() { Print1(test); //传结构体 Print2(&test); //传地址 return 0; }
而在上面这段代码中,我们一般认为 Print2 函数更为优秀。原因是当函数传参的时候,参数是需要压栈的,在这个过程中就会产生时间和空间上的系统开销。如果传递一个结构体对象时结构体过大,那么将会导致参数压栈的的系统开销较大,最终将会导致程序性能的下降。
2.位段
结构体实现位段
2.1 位段概述:
位段(bit-field)以位为单位来定义结构体(或联合体)中的成员变量所占的空间。含有位段的结构体(联合体)称为位段结构。采用位段结构既能够节省空间,又方便于操作
。
位段的声明和结构体十分相似,但同时有两个不同点:
- 位段的成员必须是
int
、signed int
、unsigned int
或char
类型。- 位段的成员名后边有一个冒号和一个数字(该成员所占内存空间大小,单位为 bit位)。
#include<stdio.h> struct test { int _a : 2; //成员 a 只占用 2 个比特位 signed int _b : 5; //成员 b 只占用 5 个比特位 unsigned int _c : 10; //成员 c 只占用 10 个比特位 char _d : 4; //成员 d 只占用 4 个比特位 }; int main() { printf("The size of struct test is %d\n", sizeof(struct test));//4 return 0; }
优点:能够节省大量的空间,通过有条件地(根据实际使用需求)限制每个变量所占内存空间的大小,从而减少了整体结构的空间占用
2.2 位段的内存分配:
位段存在的意义便是最大程度上去减少空间的浪费,所以在进行存储时,位段不会进行内存对齐操作。那么位段的内存空间是如何进行分配的呢?
注意
位段的内存分配并没有严格的规则,在不同的编译器上产生的结果可能不同,我们今天的讲解,将以Visual Studio 2019
为例进行研究。
首先需要知道位段进行内存分配的规则: 1. 位段的成员可以是 int 、unsigned int 、signed int 或者是 char(属于整形家族)类型 2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。 3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。 4. 位段的内存分配是逐4字节(一个 int 类型的大小)进行分配的。 5. 当字节内空间不足以放下下一个成员变量时,剩余的空间不再使用,而是再从内存中申请一个字节的空间继续分配。 6. 不同类型(char 与 int类型)数据进行存储时将会另起4个字节(一个 int 类型的大小)进行存储。
#include<stdio.h> struct test { char a : 3; char b : 4; char c : 5; char d : 4; }; int main() { struct S s={0}; s.a=10; s.b=12; s.c=3; s.d=4; return 0; }
分析如图: