目录
为什么使用文件
当我们在编写一个项目的时候,自然而然想到要把之前写入的数据保存起来。而只有我们自己选择删除数据的时候,数据才不复存在。这就涉及到了数据持久化的问题,我们一般数据持久化的方法有,把数据存放在磁盘文件、存放到数据库等方式。此处我们就讲到如何将数据放入到磁盘文件当中。
什么是文件
磁盘上的文件就是文件。例如电脑当中的C盘内放入的文件夹内的内容就是文件。
但是在程序设计中,我们一般谈的文件有两种:程序文件、数据文件(从文件功能的角度来分类的)。
程序文件
包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)。
数据文件
文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
本篇主要谈了数据文件。
在以前各章所处理数据的输入输出都是以终端为对象的,即从终端的键盘输入数据,运行结果显示到显示器终端上。
其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上文件。
文件名
一个文件要有一个唯一的文件标识,以便用户识别和引用。
文件名包含3部分:文件路径+文件名主干+文件后缀
例如: c:\code\test.txt
为了方便起见,文件标识常被称为文件名。
二进制文件和文本文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。
如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。(如整数10000,需要以ASCII码输出到磁盘上,则在磁盘中的存储形式就是10000)。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节(VS2013测试)。
再用整数10000举例。如果以二进制的形式输出到磁盘上,则在磁盘上是以二进制的形式存储。但是我们到文件底下去看二进制形式的文本时,都是乱码无法看懂(但机器能够看懂)。此时我们再将该文本文件移到编译器(VS2022)中。
而编译器内有一个二进制编辑器能够将该乱码翻译为二进制数显示出来。
比如下面代码我们看存储的信息:
int main()
{
int a = 10000;
FILE* pf = fopen("text.txt", "wb");
fwrite(&a, 4, 1, pf);//⼆进制的形式写到⽂件中
fclose(pf);
pf = NULL;
return 0;
}
打开刚刚写的文件存的是乱码,我们把他拿到编译器里面,以二进制文本信息打开
选择打开方式,二进制编辑器打开
我们先将10000的二进制序列写出来,为:00000000 00000000 00100111 00010000
,每四位则为一个16进制数字。则结果为00 00 27 10,但是我们的编译器是以小端的形式存储的。即数据的低位存储到内存的低地址中,数据的高位存储到高地址中。则存储的形式就为:10 27 00 00 。
文件的打开与关闭
流和标准流
流
流是一个抽象概念
我们程序的数据需要输出到各种外部设备,也需要从外部设备获取数据,不同的外部设备的输入输出操作各不相同,为了方便程序员对各种设备进行方便的操作,我们抽象出了流的概念,我们可以把流想象成流淌着字符的河。
C程序针对文件、画面、键盘等的数据输入输出操作都是同流操作的。
⼀般情况下,我们要想向流里写数据,或者从流中读取数据,都是要打开流,然后操作。
可以发现上面的概念读了依然不懂。
其实流是对IO设备操作的封装。 根据对象的不同会有文件流、网络流等。根据操作的不同会有输入流和输出流。
相当于写的接口函数,用来对io设备进行读写操作。可以学到后面就慢慢懂了什么是流了吧。
为了屏蔽计算机最底层的操作,抽象就变得尤其重要。很多概念被一层层抽象,封装。硬件层之上会有很多驱动程序,然后操作系统会进一步的抽象,编程语言会和复杂的操作系统交流,最后我们对文件的操作就是通过File或者Stream这种接口。
标准流
为C语言程序在启动的时候,默认打开了3个流:
stdin:标准输入流,在大多数的环境中从键盘输入。
stdout:标准输出流,大多数的环境中输出至显示器界面。
stderr:标准错误流,大多数环境中输出到显示器界面。
这是默认打开了这三个流,我们使用scanf、printf等函数就可以直接进行输入输出操作的。
stdin、stdout、stderr三个流的类型是:FILE*
,通常称为文件指针。
C语言中就是通过FILE*
的文件指针来维护流的各种操作的。
文件指针
缓冲文件系统中,关键的概念是“文件类型指针”,简称 “文件指针”。
每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的。该结构体类型是有系统声明的,取名FILE。
例如,VS2013编译环境提供的 stdio.h 头文件中有以下的文件类型申明:
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;
FILE* pf;//文件指针变量
但是在VS2022下面封装的层数较多,已经看不到了。
可以看出,我们定义一个FILE*
就是定义了一个结构体指针,这个指针指向我们的文件信息区的地址,里面存了文件的各种信息。
不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异。
每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息,使用者不必关心细节。
一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便。
定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变量)。通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够找到与它关联的文件。
而C语言提供的接口函数就是用来读取这些信息的,我们并不需要管。
文件的打开与关闭
文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。
在编写程序的时候,在打开文件的同时,都会返回一个FILE*
的指针变量指向该文件,也相当于建立了指针和文件的关系。
ANSIC 规定使用fopen函数来打开文件,fclose来关闭文件。
要记住的是当打开文件后对数据进行处理完一定要关闭文件,因为我们每打开一个文件就会创建一块文件信息区,关闭文件就把这块文件信息区释放掉,如果只打开不关闭就可能导致内存泄漏。可能会造成数据的丢失。
//打开文件
FILE * fopen ( const char * filename, const char * mode );
//关闭文件
fclose ( FILE * stream );
我们可以转到fopen的定义,可以看到是一个FILE*返回值的函数。
fopen:
FILE * fopen ( const char * filename, const char * mode );
fopen返回一个FILE* 类型的指针,指向文件信息区,第一个参数filename是我们需要打开的文件名,第二个文件是打开的模式,就是读操作打开还是写操作打开等等一些其他操作。如果文件不存在会出错返回空或者新建一个文件名为filename的文件。
fopen的部分参数及功能如下(输入就是读,输出就是写,都是相对于程序的,读取文件内容就是输入数据,向程序中输入数据,写入文件内容就是程序输出数据。从这里也可以感受一下流的概念,我们流出数据到文件,从文件流入数据到程序。)
文件使用方式(mode) | 含义 | 如果指定文件不存在 |
---|---|---|
“r”(只读) | 打开一个用于读取的文件。该文件必须存在。 | 出错 |
“w”(只写) | 创建一个用于写入的空文件。如果文件名称与已存在的文件相同,则会删除已有文件的内容,文件被视为一个新的空文件。 | 建立一个新的文件 |
“a”(追加) | 追加到一个文件。写操作向文件末尾追加数据。 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾添加数据 | 建立一个新的文件 |
fclose:
int fclose ( FILE * stream );
fclose只需要单独写fclose就可以了,不需要写前面的int,传入的是一个FILE*的指针,就是关闭此指针所指向的文件信息区维护的文件。不要忘记fclose后要记得给指针置空。
实例:
#include <stdio.h>
int main()
{
FILE* pFile; // 打开⽂件
pFile = fopen("myfile.txt", "w");
if (pFile != NULL)
{
fputs("fopen example", pFile);
fclose(pFile); // 关闭⽂件
pFile = NULL;
}
return 0;
}
绝对路径与相对路径
我们fopen打开文件的时候,第一个参数可以是绝对路径的文件也可以是相对路径的问价
fopen("myfile.txt", "w");
这个就是相对当前路径下,本函数路径下所在位置
fopen("./../myfile.txt", "w");
这也是相对当前,本函数当前路径下的上一级路径下的某个文件
fopen("C:\\Users\\20338\\Desktop\\myfile.txt", "w");
这就是绝对路径,直接从磁盘指定到文件,把路径锁死。两个\表示路径分隔符,其实路径分割符是一个\,但是一个\有代表转义字符。
文件的顺序读写
文件的顺序读写就是先写入的在前面,后写入的在后面。
主要是几个函数,如下
函数名 | 功能 | 适用于 |
---|---|---|
fgetc | 字符输入函数(单个) | 所有输入流 |
fputc | 字符输出函数(单个) | 所有输出流 |
fgets | 文本行输入函数(一串) | 所有输入流 |
fputs | 文本行输出函数(一串) | 所有输出流 |
fscanf | 格式化输入函数 | 所有输入流 |
fprintf | 格式化输出函数 | 所有输出流 |
fread | 二进制输入 | 文件 |
fwrite | 二进制输出 | 文件 |
看一个实例:
int main()
{
fputc('w', stdout);
fputc('o', stdout);
fputc('r', stdout);
fputc('l', stdout);
fputc('d', stdout);
return 0;
}
我们不仅可以向文件中打印字符,我们同样可以向stdout这个标准输出流中打印,打印到屏幕上,标准输出流的类型也是FILE*。我们向文件中输出,就是文件流,stdout就是标准输出流,这就是适用于所有流的概念。
同样的我们可以从标准输入流中读取字符
int main()
{
int ch = fgetc(stdin);
printf("%c", ch);
return 0;
}
fgetc的返回值是读到的字符的ASCII码值。如果读取失败则返回EOF为-1
举例:
创建一个文件,并向其中写入小写字母a到z,然后关闭文件。接着再以读的形式打开这个文件,并将这些字符输出到终端。
#include<stdio.h>
int main()
{
// 向文件中输入数据
FILE* pf = fopen("text.txt", "w");
if (pf == NULL)
{
perror("fopen :w");
return 1;
}
else
{
char ch = 0;
for (ch = 'a'; ch <= 'z'; ch++)
{
fputc(ch, pf);
}
}
fclose(pf);
pf = NULL;
// 向文件中读取数据
FILE* pf2 = fopen("text.txt", "r");
if (pf2 == NULL)
{
perror("fopen: r");
return 1;
}
else
{
int ch = 0; // ch 为int类型,以便接收fgetc的返回值
while ((ch = fgetc(pf2)) != EOF)
{
putchar(ch); // 直接输出字符
}
}
fclose(pf2);
pf2 = NULL;
return 0;
}
更多顺序读写函数可见此博主文章:fput/getc;fput/gets;fscanf,fprintf;fwrite,fread
对比三组函数:
scanf:针对标准输入流(stdin)的格式化输出函数
printf:针对标准输出流(stdout)的格式化输出函数
fscanf:针对所有输入流的格式化输出函数
fprintf:针对所有输出流的格式化输出函数
sscanf:从字符串中读取格式化数据
sprintf:把格式化数据转换成字符串
仅演示sscanf和sprintf
struct S {
int a;
char b;
char arr[20];
};
int main()
{
struct S s = { 100, 'a', "Hello World" };
char str[40] = { 0 };
sprintf(str, "%d %c %s\n", s.a, s.b, s.arr);
printf("%s", str);
struct S t = { 0 };
sscanf(str, "%d %c %s", &t.a, &t.b, t.arr);
printf("%d %c %s\n", t.a, t.b, t.arr);
return 0;
}
因为Hello World中间有空格,所以读取的时候只读了前面的字符。
sprintf函数就是将结构体里面的带格式的数据转化为字符串
sscanf函数就是读取字符串里面的数据转化为带格式的数据
文件的随机读写
fseek
第一个参数是文件指针的名字(流),第二个参数是文件指针向后偏移数,第三个参数是fseek函数中规定的三个选项之中的其一。
fseek的作用是给定起始位置和偏移量,移动文件指针到指定位置。
SEEK_SET是文件的开头
SEEK_CUR是文件指针当前的位置
SEEK_END是文件指针结尾的位置,此时偏移量应该为负数
ftell
ftell 函数可以返回当前文件指针相对于起始位置的偏移量,这里说的起始位置,默认指的是文件开始的位置。
下面代码对fseek与ftell演示
int main()
{
FILE* pf = fopen( "test.txt", "r" );
if (pf == NULL)
{
perror("fopen");
return - 1;
}
fseek(pf, 3, SEEK_SET);
int ch = fgetc(pf);
printf("%c\n", ch);
int pos = ftell(pf);
printf("%d\n", pos);
fclose;
pf = NULL;
return 0;
}
rewind
功能:让文件指针的位置回到文件的起始位置。
在刚刚的代码上加上rewind函数,回到起始位置读到的就是a。
int main()
{
FILE* pf = fopen( "test.txt", "r" );
if (pf == NULL)
{
perror("fopen");
return - 1;
}
fseek(pf, 3, SEEK_SET);
int ch = fgetc(pf);
printf("%c\n", ch);
int pos = ftell(pf);
printf("%d\n", pos);
rewind(pf);
ch = fgetc(pf);
printf("%c\n", ch);
fclose;
pf = NULL;
return 0;
}
文件读取结束的判定
feof
feof函数在第一次读取操作后返回一个非零值,该操作试图读取文件的末尾。如果当前位置不是文件结尾,则返回0。如果当函数对文件流的操作失败时,它是不能正确抛出错误的,它仅仅只能判断文件指针是否指向文件结尾。
if (feof(pf))
{
printf("文件指针使用时,读取到文件末尾\n");
}
即在文件读取过程中,避免用feof函数的返回值来判断文件读取是否结束,而是应用于当前文件读取结束时,判断时读取结束还是遇到文件尾结束
所以,应当牢记:在文件读取过程中,不能用feof函数的返回值直接来判断文件的是否结束
feof的作用是:当文件读取结束的时候,判断是读取结束的原因是否是:遇到文件尾结束。
如何判断文件是否读取结束,文件读取结束不意味着就是读到了文件结尾,而是我们指定的位置。
方法:
使用对应函数的返回值进行判断:
文本文件读取结束的判断:
fgetc判断返回值是否为EOF
fgets判断返回值是否为NULL
二进制文件的读取结束判断,判断返回值是否小于实际要读的个数。
fread判断返回值是否小于实际要读的个数。
因为fread函数参数有要读取的个数,而返回值是实际读取的个数。判断二者是否相等。
我们用ferror检查文件操作是否出现错误
ferror() 函数通常与文件读取和写入操作一起使用,以检测是否发生了错误。例如,由于磁盘空间不足、权限问题或其他原因而无法写入文件,ferror() 可用于检测这样的错误。
如果操作文件过程中发生了错误,则函数返回一个非零值;否则,返回零。
我们可以用ferror和feof搭配使用来检测读取文件是因为什么结束的
例如:
int main(void)
{
int c; // 注意:int,⾮char,要求处理EOF
FILE* fp = fopen("test.txt", "r");
if (!fp) {
perror("File opening failed");
return EXIT_FAILURE;
}
//fgetc当读取失败的时候或者遇到⽂件结束的时候,都会返回EOF
while ((c = fgetc(fp)) != EOF) // 标准C I / O读取⽂件循环
{
putchar(c);
}
//判断是什么原因结束的
if (ferror(fp))
puts("I/O error when reading");
else if (feof(fp))
puts("End of file reached successfully");
fclose(fp);
}
文件缓冲区
ANSIC标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才⼀起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。
即当一个字符一个字符从键盘上输入时,并不是直接输入到磁盘内,而是先放到输入缓冲区,而当输入缓冲区内的字符放满后,文件缓冲区才向磁盘内输入字符。
#include <stdio.h>
#include <windows.h>
//VS2013 WIN10环境测试
int main()
{
FILE*pf = fopen("test.txt", "w");
fputs("abcdef", pf);//先将代码放在输出缓冲区
printf("睡眠10秒-已经写数据了,打开test.txt文件,发现文件没有内容\n");
Sleep(10000);
printf("刷新缓冲区\n");
fflush(pf);//刷新缓冲区时,才将输出缓冲区的数据写到文件(磁盘)
//注:fflush 在高版本的VS上不能使用了
printf("再睡眠10秒-此时,再次打开test.txt文件,文件有内容了\n");
Sleep(10000);
fclose(pf);
//注:fclose在关闭文件的时候,也会刷新缓冲区
pf = NULL;
return 0;
}
我们可以测试一下这个代码,在程序第一个到fgets函数处时,立刻去打开test.txt文本文件,我们会发现里面没有内容,而我们用刷新文件缓冲区的fflush函数时再次打开test.txt文本文件时,会发现里面已经有输入的内容。则能够证实的确有文件缓冲区的存在。
因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文件。如果不做,可能导致读写文件的问题。
另外程序结束也会刷新缓冲区,这就是为什么不加fclose也能对文件写入的原因。