放在前面的话
这一组实验作业练习,给了很多同学不小困惑。所以我希望能基于对它的讨论,展开更多内容,和大家交流交流。
以下内容我不会给出完整代码,只会讲一些思路和做法。
基于书本知识,我尽量不会引入太多硬知识,希望你能读懂。
文中有好多我的叨叨叨和扩展知识,可以看看感兴趣的内容。
也请各位佬指出我的不足之处:-)
写代码遇到问题了怎么办?
编程没有神学,产生的问题请冷静,因为这些都是人造成的
-
遇到了问题,先应该仔细阅读报错信息(如果有的话)、仔细观察程序行为。这些内容往往能给你宝贵的指向性信息
-
仔细阅读“我写了什么”,把自己写的代码可能存疑的部分盘一遍——RTFSC,读源码
-
使用一些技巧进行debug。例如插入
printf("[debug]%d\n", i);
,可以用来查看变量此时的值、程序有没有运行到这儿这些信息,十分有助于缩小问题区域想进一步学习debug的可以了解一下
gdb
、valgrind
等工具,不过,这可能需要一个Linux系统了:-) -
尝试将问题总结,进行描述,在网络上搜索(例如CSDN),尝试寻找解决办法——STFW,利用好网络搜索
-
可以试试
文心一言
、ChatGPT
等工具忙你找找问题 -
自己真的不能解决时,尝试寻求他人帮助,并提供全面的报错信息、你做了什么尝试。这将大大方便他人、加快解决。
-
此外,追求代码格式的整齐、美观、加上恰当的注释、变量起名字起有意义的文字等 能大大提升编程效率和程序的维护性!!!也能极大便利他人。学习如何做到这一点,可以在网上搜索。
利用好互联网时代的知识获取途径
背景
文件操作,旨在通过对外存中文件的读写,做到运行时放在内存的数据和放在外存的数据进行交流,从而更好地实现我们的目标功能。
- 内存:
暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。现在我们用的电脑,内存往往是16GB、32GB等等。
我们所运行的程序本身都是在内存中的。而我们以前写的程序,所有的数据也都是在内存中的。
拓展知识:内存分ROM、RAM,可以自行了解。此处我们指的是RAM,其特点是断电后数据会丢失 - 外存:
我们保存的文档、视频、源码等等,都是放在外存的。常见的外存储器有硬盘、软盘、光盘、U盘等。现在我们用到的电脑,外存往往达到了TB级别,U盘也有256GB、512GB等等了。
一个程序运行时,会从外存被拿到内存中进行运行。外存断电也不会丢失数据。
编程时,把运行的数据从外存读取或存入外存,有时就有很大作用了。
题目1
已存在一个文件存放了10个整型数据,编程将其排序后存入另一个文件中。
题目要求很简单,需要我们做到:
- 读取文件
- 排序数据
- 写入文件
其中,排序数据已经都很熟悉了。文件的读写,是本次实验的新点。
排序数据
对于数据排序,可以直接使用我们的老朋友——冒泡排序。例如:
void sort(int *array, int size) {
int i, j;
int temp;
for (i = 0; i < size - 1; i ++) {
for (j = 0; j < size - i - 1; j ++) {
if (*(array + j) < *(array + j + 1)) {
temp = *(array + j);
*(array + j) = *(array + j + 1);
*(array + j + 1) = temp;
}
}
}
}
这是一个函数,接受一个整型的数组以及它的大小。效果为实现数据的降序排序(这一点可以这么思考:函数中,要发生交换,符合的条件是前一个小于后一个,即升序是不符合效果的,所以为降序)。
注意,C语言给函数传入数组时,相当于传入了指针(指向这个数组起始内存位置)。所以我们不能用sizeof在函数内获取其大小了。
故我们需要多传入一个数组的规模的整数,来限定冒泡的界限。
文件打开关闭
首先,我们需要考虑下文件的打开。
我们先要找到这个文件。在外存中定位一个文件,有两种方式:
- 相对路径
即通过描述和程序运行所在的位置的相对位置,来定位文件。
例如一个文件夹下有
-
一个文件
1.txt
-
一个文件夹
fliter
,内含一个文件2.txt
这样两项,那么对于
1.txt
,2.txt
的路径可以描述为./fliter/2.txt
。./
表示”现在的位置“。
- 绝对路径
即通过完整的路径来定位文件。例如D盘
中的某文件可能可以被描述为D:\WeChat\WeChat.exe
。
知道如何表述文件的位置,那我们可以使用函数做到程序和外存的文件建立联系了。
我们使用函数fopen
函数打开文件,得到文件指针FILE*
。
!注意,文件打开后一定记得用fclose
关掉哦~
碎碎念&思考:
我们要打开的文件可能是用户指定的。那么不可避免地可能遇到用户犯错了,输入的文件路径根本无效。那么我们怎么“优雅地“指出这个错误呢?
fopen
函数在遇到文件打开失败时,会返回一个空指针NULL
。所以我们可以利用这个判断是不是正确打开了文件。
可以使用以下代码段:
if((f == NULL){
printf("can not open file.\n");
exit(1);
}
这么写,可以在打开失败时(即f
为NULL
),告知用户打开失败
,随后以1
的退出码退出——表示程序异常(0是正常退出——我们通常写的return 0
就表示程序正常退出)
文件读取
读取文件内容的函数很多。fgets
、fgetc
等等。这边我们可以试试fscanf
,这个的使用方法和scanf
差不多。
在操作前,我们可以先假设文件的结构。
这个数据的文件,可以是
1,55,56,2,57,9,24,6,2,9
也可以是
1 55 56 2 57 9 24 6 2 9
在之前的学习中,我们学习了使用scanf
从键盘接受用空格或逗号分割的多个数字,这边可以类比使用。以空格分割为例:
for (i = 0; i < 9; i ++) {
fscanf(file, "%d,", &lst[i]);
}
fscanf(file, "%d", &lst[i]);
lst
是一个整型数组,用来放读到的数。我们用循环,进行了9次读取,每次读取的格式为"%d,"
,来符合文件的分割形式。而我们还要注意到最后一个不带后面的逗号,所以我们补一个fscanf即可:-)
碎碎念&思考:
fscanf
不是一个安全的函数,如同scanf
一样,可能会因为读取的内容太大而溢出,特别是在读取字符串(%s
)时。此时需要考虑一些方法限制这个问题…提供的一些想法:
- 函数
fgets
可以限制读取的内容的大小 - 函数
strtok
可以把字符串在指定字符处分开
文件写入
写入文件的函数也非常多。fputs
、fputc
等等。这边我们可以用fprintf
,它和printf
用起来差不多。
我们可以写:
fprintf(file, "%d,%d,%d,%d,%d,%d,%d,%d,%d,%d\n", lst[0], lst[1], lst[2], lst[3], lst[4], lst[5], lst[6], lst[7], lst[8], lst[9]);
…好…吗??
显然,这很蠢
所以这样吧~
fprintf(file, "%d", lst[i]);
for (i = 1; i < 10; i ++) {
fprintf(file, ",%d", lst[i]);
}
思路类似读取,想必是很简单的:-)
整合
下面我们只需要把各个部分整合起来就行。
整体思路就是:
- 只读打开文件、读取数据、关闭文件
- 排序数据
- 只写打开新文件、写入数据、关闭文件
整合方式大家都不相同,自己发挥就行。
题目2
已知2个磁盘文件如ml.txt、m2.txt,这两个文件中存放着已排好序的若干字符,
要求将两个文件有序合并,合并后存放在文件m3.txt中。
这题很类似的,也可以分几步进行
- 读取文件
- 排序数据
- 写入文件
不过也有很多不同之处。我分别讲述。
排序数据
其实,这个问题可以直接简单粗暴地两部解决:
- 合并(两个字符数组随便接一起)
- 排序(例如冒泡)
这里不在赘述。
碎碎念&思考:
不过这道题,要求两个已经有序的序列合并为一个有序的序列。这其实是一种归并排序
。感兴趣的可以进行搜索。
可以提供另一种思路:
既然两个都已经有序,我们可以考虑把一个字符数组作为被插入的数组,然后从另一个数组中不断拿出元素,插入到被插入数组的适当位置。
- 寻找位置很简单,只需要拿着第二个数组的一个元素,挨个和第一个数组的元素比较即可确认。而且,每插入一个,再插入的位置是逐步靠后的。
- 插入到指定位置,我们可以这么操作:
把被插入的序列从最后一个元素开始,挨个把元素往后移一个,这样不就空出来了一个位置吗?
参考方法:
void insert(char *target, char new_char, int place) {
int l = (int)strlen(target);
int i;
for (i = l + 1; i > place; i --)
target[i] = target[i - 1];
target[i] = new_char;
}
target
是目标字符数组,new_char
是要插入的字符,place
是插入的位置。
数据读取
这里两个文件一样操作。而且就只是读取字符数组,所以可以直接使用fscanf
操作:
fscanf(f,"%s",str);
即可
碎碎念&思考:
这个问题,可以再就之前提出的fscanf
不安全(可能读到的内容比你开辟的内存大,造成溢出)做文章。
我们使用fgets
的限制读取长度来解决这个问题。
fgets(str, 1144, file);
if (str[strlen(str) - 1] == '\n')
str[strlen(str) - 1] = '\0';
if (str[strlen(str) - 1] == '\r') // !!!
str[strlen(str) - 1] = '\0';
此处,我们先读取内容,最大1144长度。然后再考虑字符串最后一个可能是换行\n
,那我需要把它改成\0
来覆盖掉换行符,相当于删除。下面标了感叹号的地方是我考虑到了Windows系统下,换行是\r\n
的问题。可以自行了解Windows和Linux下换行符的不同
。
数据写入
这很简单了,任意方法都很容易做到,不再赘述。
整合
只需依次进行
- 两个文件分别读取
- 合并、排序(或归并排序)
- 写入文件
就行
题目3
将10名学生的信息(包括学号、姓名、成绩等)从键盘输入,
并存入文件student.rec 中;
再从文件中读出,显示在屏幕上。
这一题综合了之前的知识,新的内容还是一个文件读写,不过内容更多了。
数据的结构
我们先要思考我们如何盛装输入的数据。例如我定义结构体来装这些数据:
struct Stu {
long int id; // 学号(学号比较长,int放不下了)
char name[114]; // 姓名
float ch; // 语文成绩
float mth; // 数学成绩
float en; // 英语成绩
};
题目要求我们要有10组数据,那么我们可以创建10个元素的结构体数组:
struct Stu students[10];
其中,struct Stu
指定了类型,是我们刚刚定义好的的结构体。students
是变量名。
碎碎念&思考:
struct Stu students
的写法未免有点烦了,而且如果我们想尝试给自定义的函数传入结构体变量的话,我们也要这么写(例如:void function(struct Stu a);
)。
这时,我们可以考虑使用typedef
来自己构建一个类型——把我们的结构体变成一种和int
、char
相似的**【类型】**。
例如:
typedef struct Stu {
long int id; // 学号(学号比较长,int放不下了)
char name[114]; // 姓名
float ch; // 语文成绩
float mth; // 数学成绩
float en; // 英语成绩
} stu;
这段代码表示,我们把这个结构体样式定义为了一个叫stu
的类型。我们在使用时,可以类似写int a;
那样进行书写。
例如创建10个元素的结构体数组:
stu students[10];
这和之前我们做的是等价的。
数据的输入
这题的数据是从键盘输入的。我们可以利用printf
(告诉用户“我要输入什么”)和scanf
(接受用户的输入)的组合做到。这比较简单,有耐心就行。
碎碎念&思考1:
为了讲清楚这一块内容,我们先讲一个缓冲区
的概念。
简单说,我们键盘输入的内容(包括符号、字母等等以及回车(\n))都会放入缓冲区
。
而函数scanf
、getchar
等等,都会从缓冲区”拿出“内容,供程序使用。这就完成了从键盘到程序的输入。
这其中,我们特别关注scanf
——这个老朋友时不时跳出来捅刀子
scanf的工作原理是从缓冲区读内容,直到遇到回车符\n
,做到一行的读取。但是!它并不会把\n
给拿出来——\n
被剩在了缓冲区!
这就会导致下一次我们使用如scanf
的函数的时候,会被那个剩下的\n
搪塞过去(对于scanf
,会造成啥都没读的尴尬😓)。
这时候,我们可以使用getc
——从缓冲区中拿出一个字符的函数,把那个\n
吃掉,避免后患。
于是可以形成一个组合:
scanf(......);
getchar(); // eat \n
对于这一点,感兴趣也可以进行搜索。
碎碎念&思考2:
还是就scanf
的安全性问题。在这里,我们需要从键盘读入内容,而不是文件。但是我们仍然可以使用fscanf
。
我们#include <stdin.h>
,其中有一个特殊的东西叫stdin
,它代表了标准输入/键盘输入的内容。我们可以使用fscanf读取其中内容。
例如:
fgets(str, 114, stdin);
表示我们从键盘读取了字符,放到str
变量中,且最多读取114个。这样可以避免输入太长造成的问题。
文件读写
我们要把数据写入文件还要读出来,那肯定要先解决怎么有效地记下它们的问题。
方式一:以文本读写
> 产生的文件是可以直接阅读的
> 读写比较符合从键盘输入、输出到屏幕的习惯
我们可以选择每一行存放一组数据的方式进行。
例如用
2024114514 TSHE 19.0 19.0 81.0
2024191810 LTS 11.4 51.4 19.1
来表示
学号 | 姓名 | 语文 | 数学 | 英语 |
---|---|---|---|---|
2024114514 | TSHE | 19.0 | 19.0 | 81.0 |
2024191810 | LTS | 11.4 | 51.4 | 19.1 |
这种方式下,就如同从从键盘读入、输出到屏幕一样,可以使用fprintf
和fscanf
,就如同printf
和scanf
一样操作。
使用循环进行多轮读或者写就行。把文件视为键盘/屏幕就行。不再赘述
方式二:二进制续写
> 简单粗暴
> 产生的文件几乎不可直接阅读
这个方法的思想就是:
直接把内存中的数据一股脑写入外存。
这个方法我们会用到两个函数fwrite
和fread
写入
size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
↑ fwrite
第一个参数就是内存的起始地址,第二个参数就是要写入的内存区单元的大小,第三个参数是要写入的内存区单元的数量,第四个参数是文件指针FILE*
存放单个结构体
比方说我们要把struct Stu one;
写入,根据函数的参数,我们需要:
- 内存的起始地址:取地址即可,就会得到这个变量在内存中的起始地址——
&one
(注意:如果是数组或指针,那么可以直接写,因为他们本身就代表了地址) - 一个内存块的大小:可以使用
sizeof
函数获取该变量/类型占用的内存大小——sizeof(struct Stu)
或者sizeof(one)
(sizeof(int)
可以获取int
类型的占用大小,也可以用于struct Stu
和stu
)(stu是啥?看看之前的关于结构体的内容)
(如果我们要存数组,那么应该用每个数组元素的大小) - 内存块的数量:此处我们只涉及单个结构体,所以是——
1
。如果是数组,那么应该写元素的数量。
于是就可以组成语句:
fwrite(&one, sizeof(one), 1, f)
存放结构体数组
在这里,我们还可以尝试把整个结构体数组存进去:
- 内存的起始地址:因为是数组,所以直接写就行——
students
- 一个内存块的大小:应该写其中元素的大小——
sizeof(struct Stu)
(如果用了typedef
,那么是sizeof(stu)
) - 内存块的数量:此处是数组,那么应该写元素的数量——
10
于是就可以组成语句:
fwrite(students, sizeof(struct Stu), 10, f)
读取
size_t fread(void* ptr, size_t size, size_t count, FILE* stream);
↑ fread
第一个参数就是读到的数据存到哪,第二个参数就是要读入的二进制数据的大小,第三个参数是要读入的内存区单元的数量,第四个参数是文件指针FILE*
与写入类似。我直接给出如何读取结构体数组:
fread(students_, sizeof((struct Stu) , 10, f);
其中students_
与原先students
区分开,是一个新定义的、未赋初值的结构体数组(即:struct Stu students_[10]
)
这样,我们的students_
就会存入我们原先写入的内容了
整合
- 定义好结构体
- 创建结构体数组,利用循环,从键盘获取这10组数据数据并存入
- 写入文件
- 读出文件,把数据存到新结构体数组中
- 利用循环,把新结构体数组的数据输出
END
首先非常感谢你读到了这里。
希望你能有和在我的讨论中有点收获——这样我会很高兴的。
我也很欢迎能收到一些指正、改进、批评,和大家一起成长与学习。