📝个人主页:@Sherry的成长之路
🏠学习社区:Sherry的成长之路(个人社区)
📖专栏链接:C语言进阶
🎯长路漫漫浩浩,万事皆有期待
文章目录
- 1.结构体
- 1.1 概述:
- 1.2 结构的声明:
- 1.3 特殊声明:
- 1.4 结构的自引用:
- 1.5 结构的定义与初始化:
- 1.6 `重点`结构体内存对齐:
- 1.7 修改默认对齐数:
- 1.8 结构体传参:
- 2.位段
- 2.1 位段概述:
- 2.2 位段的内存分配:
- 2.3 位段的跨平台问题:
- 3.枚举
- 3.1 定义:
- 3.2 枚举类型的优点:
- 3.3 枚举类型的使用:
- 4.联合(共用体)
- 4.1 联合类型的定义:
- 4.2 联合类型的特点:
- 4.3 联合类型大小的计算:
- 5.总结:
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
我们也可以通过该指令在修改过默认对齐数之后,取消设置的默认对齐数,将其还原:
#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;
}
分析如图:
至此,该位段结构的内存分配结束,共占据3个char 类型数据的大小,即 3 个字节
2.3 位段的跨平台问题:
我们上面说过,位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段,并且在未来位段结构的使用过程中,我们一定要提前仔细地研究好位段在不同编译器下使用时,究竟是如何进行内存分配的,再结合我们的实际需求实现跨平台使用。
而在位段进行跨平台使用时,我们通常需要注意以下四个关键点:
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题)
- 位段中的成员在内存中从左向右分配还是从右向左分配的标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
总结下来,跟结构相比,位段可以达到跟结构相同的效果,并且可以更好的利用空间,但同时存在着跨平台问题
。
3.枚举
枚举是列出某些有穷序列集的所有成员的程序,或者是一种特定类型对象的计数。这两种类型经常但不总是重叠。是一个被命名的整型常数的集合。简单来说就将某种特定类型的对象一一进行列举。
枚举的声明与结构和联合相似, 其形式为:
enum 枚举名{
标识符(=整型常数),
标识符(=整型常数),
…
标识符(=整型常数)
} 枚举变量;
3.1 定义:
#include<stdio.h>
//枚举类型1:
enum Sex
{
MALE,
FEMALE,
SECRET
}s1 = MALE;
//声明时进行定义与初始化(全局)
enum Sex s2 = FEMALE;
//枚举类型2:
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
int main()
{
enum Day s3 = Mon;
//定义与初始化(局部)
return 0;
}
我们可以看到,枚举类型的声明、定义与初始化与结构十分类似。然后我们再来看一看枚举类型内部各成员的值,我们以日期为例:
#include<stdio.h>
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
int main()
{
//打印各成员的值:
printf("The value of Mon is %d\n", Mon);
printf("The value of Tues is %d\n", Tues);
printf("The value of Wed is %d\n", Wed);
printf("The value of Thur is %d\n", Thur);
printf("The value of Fri is %d\n", Fri);
printf("The value of Sat is %d\n", Sat);
printf("The value of Sun is %d\n", Sun);
return 0;
}
将上面这个示例编译运行起来看看结果的反馈:
我们看到,枚举类型内部各成员的默认值是从 0 开始依次递增的。
但是成员的值不仅限于默认值,同时也允许我们在定义时给各成员附合适的初值:
#include<stdio.h>
enum Day
{
Mon,
Tues=5,
Wed,
Thur,
Fri,
Sat=15,
Sun
};
int main()
{
printf("The value of Mon is %d\n", Mon);
printf("The value of Tues is %d\n", Tues);
printf("The value of Wed is %d\n", Wed);
printf("The value of Thur is %d\n", Thur);
printf("The value of Fri is %d\n", Fri);
printf("The value of Sat is %d\n", Sat);
printf("The value of Sun is %d\n", Sun);
return 0;
}
我们可以依照上面这种方式对枚举类型成员的初值进行修改:
我们看到,经过修改本应按序赋值为 1 的枚举成员 Tues 被赋值成了 5 ,于是接下来的成员就从 5 开始依次赋值,直到成员 Sat 被赋值为 15 后,接下来的成员就从 15 开始依次递增。
3.2 枚举类型的优点:
枚举类型的成员均为常量,不可在使用中被修改,那么我们同样可使用宏 #define 去定义常量,为什么非要使用枚举类型呢?
这是因为,相比于宏,枚举类型具有很多优点:
优点:
- 增加代码的可读性和可维护性。
- 和 #define 定义的标识符相比较,枚举有类型检查,更加严谨。
- 防止了命名污染(通过封装实现)。
- 便于调试。
- 使用方便,一次可以定义多个常量。
3.3 枚举类型的使用:
同时我们要注意,在使用枚举类型时只能用枚举常量给枚举变量赋值,只有这样才不会出现类型差异:
#include<stdio.h>
//声明枚举类型
enum TEST
{
test1,
test2,
test3
};
//其中test1、test2、test3为枚举常量
int main()
{
//定义枚举变量:
enum TEST t;
//使用枚举常量给枚举变量赋值:
t = test3;
//验证赋值结果:
printf("The value of t is %d\n", t);
return 0;
}
4.联合(共用体)
在进行某些算法的编程的时候,需要将几种不同类型的变量存放到同一段内存单元中。也就是使用覆盖技术使几个变量互相覆盖。这种几个不同的变量共同占用一段内存的结构,在C语言中,被称作 “ 联合体 ” 类型结构,简称联合,也叫共用体。
4.1 联合类型的定义:
联合是一种特殊的自定义类型,这种类型定义的变量也包含有一系列的成员,但不同的是这些成员共用同一块空间(也被称作共用体)
它的定义也基本与结构体一致:
#include<stdio.h>
union TEST
{
char a;
int b;
}test;
//在定义联合体的同时定义联合体变量test
int main()
{
//查看联合体的占用空间:
printf("The size of test is %d\n", sizeof(test));
//查看联合体成员的存储地址:
printf("The address of test is %p\n", &test);
printf("The address of a is %p\n", &test.a);
printf("The address of b is %p\n", &test.b);
return 0;
}
但不同的是,我们编译运行后发现,联合体成员 char 类型变量 a 与 int 类型变量 b 共同占用同一片空间(一个 int 类型所占的空间):
这种方式定义的联合体结构,是三种结构中最节省空间
的一种,但同时,极致的空间节省能力导致了它在使用时需要满足的条件极为苛刻。
4.2 联合类型的特点:
联合体最大的特点就是,联合体的成员是共用同一块内存空间的,则联合至少得有足够的空间容纳最大的成员,这样一个联合变量的大小就至少得是最大成员的大小。
既然联合体的大小会随着内部成员大小的变化而变化,那么是不是联合体类型也可以通过判断内容大小,来帮助我们判断机器的大小端存储模式
呢?
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int check_sys()
{
union CHECK
{
char check1;
int check2;
}check;
check.check2 = 1;
return check.check1;
}
int main()
{
if (1 == check_sys())
{
printf("您的机器采用小端存储模式!\n");
}
else
{
printf("您的机器采用大端存储模式!\n");
}
return 0;
}
我们将其编译运行发现,该思路可以帮助我们检查机器的大小端存储模式
4.3 联合类型大小的计算:
联合体类型的大小计算需要按照以下规则进行计算:
- 联合的大小至少是最大成员的大小。
- 当最大成员大小不是最大对齐数的整数倍时,要对齐到最大对齐数的整数倍。
#include<stdio.h>
//联合体1:
union TEST1
{
char c[5];
int i;
};
//联合体2:
union TEST2
{
short c[7];
int i;
};
int main()
{
//检查联合体的大小:
printf("The size of TEST1 is %d\n", sizeof(union TEST1));
printf("The size of TEST2 is %d\n", sizeof(union TEST2));
return 0;
}
1.在联合体 TEST1 中,占用空间最大的成员是 char 类型数组 c ,且其中含有 5 个元素,则其所占空间大小为 5 个字节,而我们都知道 VS 的对齐数默认为 8 ,则将会对齐至默认对齐数的整数倍,即 8 个字节。
2.而联合体 TEST2 中,占用空间最大的成员是 short 类型数组 c ,且其中含有 7 个元素,则其所占空间的大小为 14 个字节,那么就将会对齐至对齐数的整数倍,即 16 个字节
5.总结:
今天我们对结构体的相关原理与使用等知识又有了新的了解,学习了结构体、位段、枚举、以及联合(共用体)的相关知识,完成了通过联合体类型判断机器的大小端存储模式,希望我的文章和讲解能对大家的学习提供一些帮助。
当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~