传送门: C语言-第五章-加餐:冒泡排序与二维数组
传送门:C语言-综合案例:扫雷小游戏
目录
第一节:认识结构体
之前我们讲过C语言的基本类型:int、char、float等,也讲过用于存储相同数据类型的数组。那么除了基本类型,C语言还有一种自定义类型——结构体,它可用于存储不同的数据类型
1-1.结构体的声明
结构体作为一种自定义类型,我们先需要把它的存储结构和名字声明出来,这需要使用 struct 关键字:
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
};
例如上述代码就定义了一个名为 student 的类型,它可以用来定义 student 类型的结构体变量,就像 int 可以定义 int 类型的变量一样;{ } 里的是它存储的数据类型,每个数据类型称为结构体 student 的成员。
结构体的声明是结构体变量定义的前提。
1-2.结构体的定义
使用结构体也需要先用它定义一个变量(故也分为局部变量和全局就变量,作用域和生命周期同普通变量),和基本类型不同的是,它的定义也需要 struct 关键字:
struct student Eric;
上述代码就是用 student 这个自定义类型,定义了一个名为Eric(这是一个外国名字)的变量。这和用 int 这个基本类型,定义一个名为 i 的变量如出一辙。
当它在 { } 之外,就属于全局变量,当它在 { } 之内,就属于局部变量。
对于结构体的全局变量,还可以在结构体声明处定义:
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
}Eric; // 定义了一个全局变量
1-2.结构体的初始化:
结构体可以存储多个不同的数据类型,它的初始化和赋值与数组类似:
// 指定成员的初始化
struct student Eric = {.name="Eric",.age=18,.id="20240327"};
// 按照默认顺序的初始化
struct student Eric = {"Eric",18,"20240327"};
第一种初始化指定了哪个成员被赋值为哪一个数据,所以 { } 内成员的赋值顺序可以打乱,而第二种初始化就是按照声明中的成员顺序进行赋值。
定义在声明处的全局变量初始化方法如上:
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
}Eric={"Eric",18,"20240327"}; // 定义了一个全局变量
第二节:成员访问
我们定义一个结构体,到底还是要使用这个结构体中的成员,结构体的成员有两种访问方式:变量访问和指针访问。
2-1.变量访问
变量访问是我自己起的名字,它就是使用结构体的变量名和 . 操作符访问结构体成员,它的使用方法如下:
[变量名].[成员名];
以下是一个使用实例:
#include <stdio.h>
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
};
int main()
{
// 指定成员的初始化
struct student Eric = { .name = "Eric",.age = 18,.id = "20240327" };
// 打印成员
printf("%s %d %s\n",Eric.name,Eric.age,Eric.id);
return 0;
}
除了单纯的打印外,我们还可以给成员重新赋值:
#include <stdio.h>
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
};
int main()
{
// 指定成员的初始化
struct student Eric = { .name = "Eric",.age = 18,.id = "20240327" };
// 成员重新赋值
Eric.age = 20;
// 打印成员
printf("%s %d %s\n",Eric.name,Eric.age,Eric.id);
return 0;
}
2-2.指针访问
指针访问也是我自己起的名字,它就是使用指向结构体变量的指针和 -> 操作符访问结构体成员,使用方法如下:
struct student* ptr = &Eric; // 定义指向Eric的指针
ptr->name; // 访问成员
以下是一个使用实例:
#include <stdio.h>
struct student // student 是这种结构体的名字
{
// 成员
char name[20]; // 姓名
int age; // 年龄
char id[20]; // 学号
};
int main()
{
// 指定成员的初始化
struct student Eric = { .name = "Eric",.age = 18,.id = "20240327" };
// 定义指针
struct student* ptr = &Eric;
// 打印成员
printf("%s %d %s\n",ptr->name,ptr->age,ptr->id);
return 0;
}
指针访问的方式也可以修改成员的值,方法和上面一样,这里不再做演示。
第三节:结构体嵌套
3-1.嵌套结构体的声明和定义
一个结构体的可以是另一个结构体的成员,例如:
struct A
{
int n;
};
struct B
{
int m;
struct A a; // 成员a的类型是结构体A
};
上述代码中的结构体B就包含了结构体A,但是需要注意:结构体A的声明必须在结构体B之前,否则B不知道A是什么类型,就不行给它开辟好合适的空间。
此时如果用B定义一个结构体变量,就需要嵌套 { }:
// A、B都使用默认初始化
struct B b = { 0, {1}};
// B指定成员初始化,A使用默认初始化
struct B b = { .m = 0,.a = {1} };
// A、B都指定成员初始化
struct B b = { .m = 0,.a = {.n=1} };
// B指定成员,再通过a指定a中的成员进行初始化
struct B b = { .m = 0,.a.n=1 };
看起来方式很多,只需要记住第一种即可,其他三种了解即可。
3-2.嵌套结构体的成员访问
要想访问嵌套结构体,比如访问上述结构体变量 b 中的成员 a 的成员 n,分为两步:
1、访问到成员a
用变量访问或者指针访问均可:
b.a;
2、再访问到成员n
用变量访问或者指针访问均可:
b.a.n;
以下是一个具体用例:
#include <stdio.h>
struct A
{
int n;
};
struct B
{
int m;
struct A a; // 成员a的类型是结构体A
};
int main()
{
struct B b = { .m = 0,.a.n=1 };
// 从b到a再到n
printf("%d\n", b.a.n);
return 0;
}
这种方式自然也可以修改n的值,这里不再赘述。
第四节:结构体的存储
结构体在定义时首先根据其成员计算出结构体的大小(不是成员大小的简单相加,计算规则见第五节),示意图如下:
可以看到,成员之间是有没有使用的空间的,为什么呢?这和计算机的内存读取有关。
4-1.计算机的内存读取
计算机每次从内存中读取数据时不是1字节,而是从0开始,每次大于等于1字节:
在现代64位处理器中,通常情况下,内存访问是以4字节(32位)或8字节(64位)为基本单位进行的。具体的读取大小取决于处理器的架构和指令集。例如,x86-64架构的处理器通常能够进行8字节的内存读取,但也支持较小单位的读取(如1字节、2字节和4字节)。
下面我们用32位平台,每次读取4字节为例:
在这种情况下,每个被定义的变量的地址都是0或者4的整数倍,当读取大于4字节的数据时,只需要的读取+拼接即可,例如读取double类型(8字节)的数据时:
对于小于4字节的数据,只需要读取+剪切即可,例如读取char类型(1字节)的数据:
假如数据不按照地址为0或4的整数倍进行存储,那么可能就需要多次读取+剪切+拼接 ,例如肚读取int类型(32位为4字节)的数据:
只是读取4字节的数据就进行了两次读取+两次剪切+一次拼合,如果不按照规则存储,对于体量更大的结构体来说,计算机需要消耗的时间就非常多了。
变量按照0或者4的整数倍存储的意义就是适应计算机的读取方式 ,结构体作为一个复杂存储结构就需要制定一个规则来适应计算机的读取方式,这个规则已经被制定好了。
4-2.结构体的存储规则
结构体的存储规则如下:
假设一个结构体的起始地址为x,偏移量为y。
1、计算机有一个默认对齐数,它的值一般为8
2、第一个成员要对齐到x的偏移量为0的地方,即y为0。
3、每个成员要把自己的大小和默认对齐数比较,取最小值为自己的对齐数,然后对齐到自己的对齐数的整数倍的地方,即y为对齐数的整数倍。
4、结构体的总大小为最大对齐数的整数倍
5、如果嵌套了结构体,作为成员的结构体的对齐数为它自己的最大对齐数
例如在32位平台下,有下列一个结构体变量 s:
struct S
{
char c;
int i;
double d;
}s;
它的存储情况为:
我们可以验证一下:
#include <stdio.h>
struct S
{
char c;
int i;
double d;
}s;
int main()
{
printf("%d\n", sizeof s);
return 0;
}
假如上述结构体是其他结构体的一个成员,那么它的对齐数就是他自己的成员中的最大对齐数8,以下是一个问题,可以自己推导结果是多少:
#include <stdio.h>
struct S
{
char c;
int i;
double d;
}s;
struct SS
{
char a;
short b;
struct S c;
}ss;
int main()
{
printf("%d\n", sizeof ss);
return 0;
}
总结:
目前而言,
基本类型的大小都不超过默认对齐数8,所以它们的对齐数就是自己的大小;
成员结构体的对齐数就是他自己的成员类型的中的最大对齐数,所以不超过8;
ps:默认对齐数也是可以修改的,用 #progma pack(size) 可以修改成其他的值,但是并不推荐。
根据以上规则,定义结构体时把小的成员放前面可以减少无用空间的产生,请看以下的例子:
#include <stdio.h>
struct S
{
char c;
int i;
double d;
}s;
struct F
{
int i;
double d;
char c;
}f;
int main()
{
printf("%d\n", sizeof s);
printf("%d\n", sizeof f);
return 0;
}
它们的成员都是 char+int+double 只是顺序不同,但是它们的大小不同,可以自己推导一下这两个结构体的存储情况。
第五节:结构体传参
5-1.形参与实参
我们知道函数在使用时需要传递参数,函数定义处的参数叫做形参,我们传入的参数叫做实参,例如我们之前写过的 Add 函数:
int Add(int x,int y) // x、y为形参
{
return x+y
}
int main()
{
int a = 5;
int b = 4;
Add(a,b); // a、b为实参
return 0;
}
调用函数时,形参会拷贝实参,即:x = a, y = b,而形参是实参的拷贝。
形参和实参的空间是独立的,所以:
1、对形参的改变不会影响实参
2、形参也要使用内存空间
对于基本类型,它的大小不超过 8 字节,传参对内存的消耗比较小,不需要拷贝时间也少。但是对于结构体而言,它的大小可能有几十甚至上百字节,传参对内存的消耗比较大,需要拷贝时间也多。
为了解决这个问题,传递结构体时可以像数组一样传递自己的指针过去(指针的大小为4或8):
#include <stdio.h>
struct S
{
char a;
int b;
int c;
int d;
double e;
};
void Print(struct S* ptr)
{
printf("%c %d %d %d %lf\n",ptr->a,ptr->b, ptr->c, ptr->d, ptr->e);
}
int main()
{
struct S s = { 'A',0,1,2,3.0 };
Print(&s);
return 0;
}
这样的话调用 Print 函数时只消耗了构建一个指针的空间和时间,传递结构体的地址是比较好的代码习惯。
下面是一个这两种方式的直观对比:
#include <stdio.h>
#include <ctime> // clock 函数的头文件
struct S
{
char a;
int b;
int c;
int d;
double e;
};
void Print_ptr(struct S* ptr)
{
// 不作任何处理
//printf("%c %d %d %d %lf\n",ptr->a,ptr->b, ptr->c, ptr->d, ptr->e);
}
void Print_var(struct S s)
{
// 不作任何处理
}
int main()
{
struct S s = { 'A',0,1,2,3.0 };
int i = 10000000; // 一千万
clock_t start_1 = clock(); // clock函数,记录调用它的当前时间
while (i--)
{
Print_ptr(&s); // 传指针
}
clock_t end_1 = clock();
int j = 10000000; // 一千万
clock_t start_2 = clock();
while (j--)
{
Print_var(s); // 传变量
}
clock_t end_2 = clock();
printf("ptr time is %d\n", end_1 - start_1); // 传指针消耗的时间
printf("var time is %d\n", end_2 - start_2); // 传变量消耗的时间
printf("结构体大小:%d\n",sizeof s);
return 0;
}
上述结果的单位是毫秒,换算过来执行一千万次传指针只消耗了31ms,执行一千万次传变量却消耗了85ms,提升了两倍有余的效率,这还只针对24字节的结构体,结构体越大,效率就提升越大。
由此可见传指针的效率提升。
下期预告:
下期是本章的加餐,将介绍一下内容:
1、其他存储结构:位段、联合体、枚举类型
2、结构体的柔性数组
3、利用成员计算结构体变量的地址