从【C语言课程-文件应用】的实验作业说起

放在前面的话

这一组实验作业练习,给了很多同学不小困惑。所以我希望能基于对它的讨论,展开更多内容,和大家交流交流。
以下内容我不会给出完整代码,只会讲一些思路和做法。
基于书本知识,我尽量不会引入太多硬知识,希望你能读懂。
文中有好多我的叨叨叨和扩展知识,可以看看感兴趣的内容。
也请各位佬指出我的不足之处:-)

写代码遇到问题了怎么办?

编程没有神学,产生的问题请冷静,因为这些都是人造成的
  • 遇到了问题,先应该仔细阅读报错信息(如果有的话)、仔细观察程序行为。这些内容往往能给你宝贵的指向性信息

  • 仔细阅读“我写了什么”,把自己写的代码可能存疑的部分盘一遍——RTFSC,读源码

  • 使用一些技巧进行debug。例如插入printf("[debug]%d\n", i);,可以用来查看变量此时的值、程序有没有运行到这儿这些信息,十分有助于缩小问题区域

    想进一步学习debug的可以了解一下gdbvalgrind等工具,不过,这可能需要一个Linux系统了:-)

  • 尝试将问题总结,进行描述,在网络上搜索(例如CSDN),尝试寻找解决办法——STFW,利用好网络搜索

  • 可以试试文心一言ChatGPT等工具忙你找找问题

  • 自己真的不能解决时,尝试寻求他人帮助,并提供全面的报错信息、你做了什么尝试。这将大大方便他人、加快解决。

  • 此外,追求代码格式的整齐、美观、加上恰当的注释、变量起名字起有意义的文字等 能大大提升编程效率和程序的维护性!!!也能极大便利他人。学习如何做到这一点,可以在网上搜索。

利用好互联网时代的知识获取途径

背景

文件操作,旨在通过对外存中文件的读写,做到运行时放在内存的数据和放在外存的数据进行交流,从而更好地实现我们的目标功能。

  • 内存
    暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。现在我们用的电脑,内存往往是16GB、32GB等等。
    我们所运行的程序本身都是在内存中的。而我们以前写的程序,所有的数据也都是在内存中的。
    拓展知识:内存分ROM、RAM,可以自行了解。此处我们指的是RAM,其特点是断电后数据会丢失
  • 外存
    我们保存的文档、视频、源码等等,都是放在外存的。常见的外存储器有硬盘、软盘、光盘、U盘等。现在我们用到的电脑,外存往往达到了TB级别,U盘也有256GB、512GB等等了。
    一个程序运行时,会从外存被拿到内存中进行运行。外存断电也不会丢失数据。
    编程时,把运行的数据从外存读取或存入外存,有时就有很大作用了。

题目1

已存在一个文件存放了10个整型数据,编程将其排序后存入另一个文件中。

题目要求很简单,需要我们做到:

  1. 读取文件
  2. 排序数据
  3. 写入文件
    其中,排序数据已经都很熟悉了。文件的读写,是本次实验的新点。

排序数据

对于数据排序,可以直接使用我们的老朋友——冒泡排序。例如:

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. 相对路径
    即通过描述和程序运行所在的位置相对位置,来定位文件。
    例如一个文件夹下有
  • 一个文件1.txt

  • 一个文件夹fliter,内含一个文件2.txt

    这样两项,那么对于1.txt2.txt的路径可以描述为./fliter/2.txt./表示”现在的位置“。

  1. 绝对路径
    即通过完整的路径来定位文件。例如D盘中的某文件可能可以被描述为D:\WeChat\WeChat.exe

知道如何表述文件的位置,那我们可以使用函数做到程序和外存的文件建立联系了。
我们使用函数fopen函数打开文件,得到文件指针FILE*
!注意,文件打开后一定记得用fclose关掉哦~

碎碎念&思考:

我们要打开的文件可能是用户指定的。那么不可避免地可能遇到用户犯错了,输入的文件路径根本无效。那么我们怎么“优雅地“指出这个错误呢?
fopen函数在遇到文件打开失败时,会返回一个空指针NULL。所以我们可以利用这个判断是不是正确打开了文件。
可以使用以下代码段:

if((f == NULL){
	printf("can not open file.\n");
	exit(1);
}

这么写,可以在打开失败时(即fNULL),告知用户打开失败,随后以1的退出码退出——表示程序异常(0是正常退出——我们通常写的return 0就表示程序正常退出)

文件读取

读取文件内容的函数很多。fgetsfgetc等等。这边我们可以试试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可以把字符串在指定字符处分开

文件写入

写入文件的函数也非常多。fputsfputc等等。这边我们可以用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中。

这题很类似的,也可以分几步进行

  1. 读取文件
  2. 排序数据
  3. 写入文件

不过也有很多不同之处。我分别讲述。

排序数据

其实,这个问题可以直接简单粗暴地两部解决:

  1. 合并(两个字符数组随便接一起)
  2. 排序(例如冒泡)

这里不在赘述。

碎碎念&思考:

不过这道题,要求两个已经有序的序列合并为一个有序的序列。这其实是一种归并排序。感兴趣的可以进行搜索。
可以提供另一种思路:
既然两个都已经有序,我们可以考虑把一个字符数组作为被插入的数组,然后从另一个数组中不断拿出元素,插入到被插入数组的适当位置。

  • 寻找位置很简单,只需要拿着第二个数组的一个元素,挨个和第一个数组的元素比较即可确认。而且,每插入一个,再插入的位置是逐步靠后的。
  • 插入到指定位置,我们可以这么操作:
    把被插入的序列从最后一个元素开始,挨个把元素往后移一个,这样不就空出来了一个位置吗?
    参考方法:
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下换行符的不同

数据写入

这很简单了,任意方法都很容易做到,不再赘述。

整合

只需依次进行

  1. 两个文件分别读取
  2. 合并、排序(或归并排序)
  3. 写入文件

就行

题目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自己构建一个类型——把我们的结构体变成一种intchar相似的**【类型】**。
例如:

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))都会放入缓冲区
而函数scanfgetchar等等,都会从缓冲区”拿出“内容,供程序使用。这就完成了从键盘到程序的输入。
这其中,我们特别关注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

来表示

学号姓名语文数学英语
2024114514TSHE19.019.081.0
2024191810LTS11.451.419.1

这种方式下,就如同从从键盘读入、输出到屏幕一样,可以使用fprintffscanf,就如同printfscanf一样操作。
使用循环进行多轮读或者写就行。把文件视为键盘/屏幕就行。不再赘述

方式二:二进制续写

> 简单粗暴
> 产生的文件几乎不可直接阅读

这个方法的思想就是:
直接把内存中的数据一股脑写入外存。
这个方法我们会用到两个函数fwritefread

写入

size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);

fwrite第一个参数就是内存的起始地址,第二个参数就是要写入的内存区单元的大小,第三个参数是要写入的内存区单元的数量,第四个参数是文件指针FILE*

存放单个结构体

比方说我们要把struct Stu one;写入,根据函数的参数,我们需要:

  1. 内存的起始地址:取地址即可,就会得到这个变量在内存中的起始地址——&one
    (注意:如果是数组指针,那么可以直接写,因为他们本身就代表了地址
  2. 一个内存块的大小:可以使用sizeof函数获取该变量/类型占用的内存大小——sizeof(struct Stu)或者sizeof(one)
    sizeof(int)可以获取int类型的占用大小,也可以用于struct Stustu(stu是啥?看看之前的关于结构体的内容)
    (如果我们要存数组,那么应该用每个数组元素的大小)
  3. 内存块的数量:此处我们只涉及单个结构体,所以是——1。如果是数组,那么应该写元素的数量。

于是就可以组成语句:

fwrite(&one, sizeof(one), 1, f)
存放结构体数组

在这里,我们还可以尝试把整个结构体数组存进去:

  1. 内存的起始地址:因为是数组,所以直接写就行——students
  2. 一个内存块的大小:应该写其中元素的大小——sizeof(struct Stu)(如果用了typedef,那么是sizeof(stu)
  3. 内存块的数量:此处是数组,那么应该写元素的数量——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_就会存入我们原先写入的内容了

整合

  1. 定义好结构体
  2. 创建结构体数组,利用循环,从键盘获取这10组数据数据并存入
  3. 写入文件
  4. 读出文件,把数据存到新结构体数组中
  5. 利用循环,把新结构体数组的数据输出

END

首先非常感谢你读到了这里。
希望你能有和在我的讨论中有点收获——这样我会很高兴的。
我也很欢迎能收到一些指正、改进、批评,和大家一起成长与学习。

  • 29
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值