目录
1.为什么使用文件
我们在之前实现通讯录的时候,程序运行起来后,对通讯录的增删查改都是在内存中,当程序退出时,内存释放,数据就不在了。如果我们希望即使程序退出这些数据还要保存在电脑中,下一次还可以用,这就涉及到了数据持久化的问题了,我们一般数据持久化的方法有:把数据存在磁盘文件,或者存放到数据库等形式。
2.什么是文件
简单来说,硬盘上的文件就是文件。
但是在程序设计中,我们一般谈的文件有两种:程序文件、数据文件。(以文件功能的角度来分类)
程序文件包括源文件(.c)、目标文件(.obj)、可执行程序(.exe)
数据文件:文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据(把文件的内容读到内存中),或者输出内容的文件(把内存中的数据写到文件中)。
我们在这篇文章里讲的就是数据文件
这么多文件怎么使用呢?这就涉及到了文件的唯一标识:文件名。一个文件要有一个唯一的文件标识,以便用户识别和使用,文件标识常被称为文件名。
文件名通常包括三个部分:文件路径+文件主干名+文件后缀
不同路径底下的文件可能重名,使用时加上文件路径之后文件名就唯一了。
3.文件的打开和关闭
我们操作文件首先要打开文件,再操作完之后要关闭文件,这是最基本的逻辑。
在缓冲文件系统中,关键的概念是“文件类型指针”或者叫“文件指针”。每个被使用的文件都在内存中开辟一个相应的文件信息区,用来存放文件的相关信息,这些信息是保存在一个结构体变量中的,该结构体类型是有系统声明的,取名FILE。
每个文件被使用时,系统会在内存中创建一个文件信息区(用一个FILE类型的结构体来维护),所以使用文件时我们要定义一个FILE*类型的指针来管理文件信息区。比如我们要fopen打开一个文件时,会在内存中创建一块文件信息区,同时会返回这块空间的起始地址,所以我们要用一个指针来接受这个地址,以便我们后续需的操作和维护。
打开文件 fopen
从fopen函数的介绍中可以知道,fopen函数有两个参数,第一个是文件名,第二个是打开文件的模式。而打开文件的的模式就是下面的几种 "r" 只读、 "w"只写、 "a"追加……,在这些后边加上b表示二进制形式的读写追加"rb'、"wb"、"ab"。而fopen函数的返回值则是一个FILE*类型的文件指针,如果打开成功,就返回起始地址,如果打开失败就返回空指针,所以我们在得到一个文件指针之后也要对其有效性进行判断。"r" 和"w"有一点区别就是,如果参数的文件名不存在,如果是以读的形式 "r" 打开的话,会返回一个空指针,如果是以写的形式打开 "w" 程序会在我们这个程序的工程目录底下创建一个该文件。
//如果工程目录下没有text.txt这个文件
FILE* pf = fopen("text.txt","r");//pf返回空指针
//如果工程目录下没有text.txt这个文件
FILE* pf = fopen("text.txt", "w");//会在工程目录底下创建一个该文件,打开并返回文件指针
注意,如果要打开的文件不在工程文件里,要在文件名前加上路径,组成绝对路径,还有一点要注意的就是要记得对文件名中的 \ 进行转义。
FILE* pf = fopen("C:\\Users\\ldh\\Desktop\\test.txt", "w");
当一个文件以 "w" 只写的模式打开时,文件原来的内容会被清空。
关闭文件fclose
关闭文件只需要将文件指针传给fclose就行了,然后把文件指针置为空指针,防止误操作对其解引用导致越界访问。
fclose(pf);
pf = NULL;
4.文件的顺序读写
文件的读和写都是按照一定的顺序进行的,也就是文件指针指向的位置。
在我们要写文件的时候如果以"w"形式打开,fopen会把文件的内容全部清除,重新写,如果不想删除,我们可以用"a"模式打开。
字符和字符串读写相关的函数
写入一个字符 fputc
两个参数,一个是要写入的字符,一个是文件流,也就是我们的文件指针。fputc函数会返回写入的字符的ASCII码值。
int main()
{
FILE*pf=fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
int ret=fputc('c', pf);
printf("%d\n", ret);
fclose(pf);
pf = NULL;
return 0;
}
在以写的形式打开时,程序在我们的工程目录下就创建了一个"test.txt"文件,并且我们写入了一个字符c。我们可以源文件添加现有项来查看字符c是否被写入文件中。
读取一个字符 fgetc
fgetc函数会在文件中读取一个字符,由于我们目前还没讲到对文件指针的操作,目前来说,我们是从前往后一个一个字符读取的。
FILE* pf = fopen("test.txt","r");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
char ch=fgetc(pf);
printf("%c\n", ch);
fclose(pf);
pf = NULL;
如图,我们便把刚才写入的c读取出来了。
写入一行数据 fputs
函数有两个参数,一个是要写入的数据的起始地址,应该是文件流。
如果成功写入,就返回一个非负数,如果鞋服失败,就会返回EOF并且在失败的地方设置一个标记(读取的时候遇到这个标记就会读取错误结束)。
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
fputs("abcdef", pf);
fclose(pf);
pf = NULL;
读取一行数据 fgets
fgets函数的三个参数,分别是读取字符放入的地址,读取的字符个数,文件指针。意思就是从文件中读取num个字符放到str所指向的空间中,但是要注意,其实只会读取num-1个字符,因为他会在后面加上一个'\0'。如果读取成功就会返回str,如果读取失败就返回NULL。
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
char str[20];
fgets(str, 5, pf);
printf("%s\n", str);
fclose(pf);
pf = NULL;
当我们给的参数num大于文件中的字符数时,fgets函数不会把多余的空间都改成\0 ,他只会有一个\0。
格式化输入输出函数
上面讲的四个函数都是针对字符和字符串的输入输出函数,下面的两个函数适用于所有类型的数据。
格式化输出函数 fprintf
当我们看到这个函数的介绍这么长时,先不要慌,我们很容易想到另一个函数printf,而printf函数我们是很熟悉的
通过比较我们可以发现,fprintf和和printf的差别就在于fprintf函数多了一个参数,输出流文件指针。
而printf函数叫做标准输出函数,他的默认输出流参数就是我们的屏幕(stdout),这样以来整个函数就很简单了,printf函数怎么用,这个函数只要加一个参数就行了。
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
fprintf(pf, "abcdfafsgss");
fprintf(stdout, "sfasfaf\n");
如图,我们也可以用fprintf来实现printf的功能。
格式化输入函数 fscanf
既然我们找到了fprintf和printf函数的用法,那么晚fscanf和scanf函数的用法也是类似的,scanf函数是从标准输入流(键盘)中读取数据,而fscanf函数则可以从所有流中读取数据。与scanf函数一样,他读到空白字符就会结束。
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
char str1[20];
char str2[20];
fscanf(pf,"%s",str1);
fscanf(stdin, "%s",str2);
printf("str1=%s\n", str1);
printf("str2=%s\n", str2);
这两个函数都适用于所有输入输出流。
任何一个C程序执行起来都会默认打开三个流:标准输入流(stdin),标准输出流(stdout),标准错误流(stderr)
二进制输出和读取
二进制输入(fread)和二进制输出(fwrite)这两个函数只适用于文件流,只能从文件中读取,只能写到文件中去。"wb"(二进制写) ,"rb"(二进制读),b就是binary二进制的意思。
二进制入文件 fwrite
在使用这个函数时,打开文件的模式要选"wb",
fwrite函数有四个参数,ptr是要写入的数据的起始地址,size是一个元素的大小(字节),count是要写入的元素的个数,最后一个参数就是文件流。返回的是写入的元素个数。
FILE* pf = fopen("test.txt","wb");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int ret=fwrite(arr, 4, 10, pf);
printf("%d\n", ret);
fclose(pf);
pf = NULL;
这样我们会发现和之前的文件写入不一样,我们好像看不懂文件的内容,这是因为fwrite函数是以二进制的模式写数据到文件中的,我们看不懂很正常。
二进制读取数据 fread
fread函数与fwrite函数的参数是一样的,第一个参数是读取出来的数据要存放的地址。
刚刚我们以二进制形式存到文件中的数据我们看不懂,我们就要用到这个fread函数来读取文件中的的二进制数据。
在读取上面我们存进去的数据时,我们可以一次性全部读取出来,也可以一个一个读
FILE* pf = fopen("test.txt", "rb");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
int arr[10];
fread(arr, 4, 10, pf);
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
fclose(pf);
pf = NULL;
下面是一个一个读取的
int i = 0;
int num = 0;
for (i = 0; i < 10; i++)
{
if (fread(&num, 4, 1, pf) == 1)
{
arr[i] = num;
}
else
{
perror("i=%d fread fail",i);
exit(-1);
}
}
那么上面两种方式哪一种更好呢?我本人是更喜欢第二种的,因为这样更能知道读取错误或者结束的位置在哪。
拓展sscanf和sprintf函数
前面我们已经学到了printf和scanf函数、fprintf和fscanf函数,第一对是针对标准输入输出流的,而第二对则适用于所有输入输出流。其实还有两个函数跟他们长得很像,那就是sscanf和sprintf,看名字我们大概就能知道这两个函数应该与字符串有关。而事实上,这两个函数确实是针对字符串的,他们不针对流,只针对字符串。
sprintf
sprintf函数是将一个格式化的数据转换成字符串,并将转换而来的字符串存储到str的位置中,同时会在字符串末尾自动加上一个\0;
int num = 4546412;
char str[20];
sprintf(str, "%d", num);
printf("%s\n", str);
sscanf
sscanf函数是从一个字符串中转换出一个格式化的数据,与scanf函数相比,就是多了一个要转换的字符串的地址。
char str[] = "451";
int num = 0;
sscanf(str, "%d", &num);
printf("%d\n", num);
当我们学完了这几个文件操作的函数后,我们又可以升级改造通讯录了,可以将通讯录存在一个文件中,每次打开通讯录时先从文件中把数据写到内存中,再退出通讯录之前把通讯录内容写到文件中去,主要就是一个相对完整的通讯录了。
5.文件的随机读写
我们知道,文件信息区的维护使用一个文件指针来完成的,那么按理来说文件指针也应该具有一些指针的特性,在前面我们使用的 getc 函数读取字符时,好像我们只能一个字符一个字符按顺序读,就好像是每一次读的是文件指针当前所指向的位置的字符,读完之后文件指针又跳到了下一个字符的位置,那么我们能不能自己来控制文件指针指向的位置呢?如果想要文件指针按照我们的想法来定位,我们就要用到下面的几个函数、
fseek
fseek函数需要三个参数,第一个就是要读取的文件流,offset是偏移量,我们前面在结构体中也讲过偏移量的概念,而origin则是选择偏移量为0的位置,对于origin有三个选项,一个是文件开头,一个是当前位置,一个是文件末尾,有了这三个参数,我们就能够定位我们想要文件指针指向的位置了。
我们可以先写入一个字符串到test.txt文件中来测试这个函数的功能
首先,默认情况下直接用fgetc读取字符的时候,文件指针默认是指向文件开头的。
如果我们直接读取的话,读到的是字符a。
我们可以用fseek对文件指针进行定位。
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen fail");
return 1;
}
fseek(pf, 3, SEEK_SET);
char ch = fgetc(pf);
printf("%c ", ch);
fclose(pf);
pf=NULL;
SEEK_SET
当我们将文件指针定位到一文件开头为偏移量起点,偏移量为3的位置时,pf指向的就是d的位置了,所以程序会打印 d 。
SEEK_CUR
当我们在此基础上,再以当前的pf指针位置为偏移量起点,偏移量为3对pf指针定位时,这时候pf就指向 g 的位置了,这时候读取字符读出来的就是字符g。
fseek(pf, 3, SEEK_SET);
char ch1 = fgetc(pf);
printf("%c ", ch1);
fseek(pf, 2, SEEK_CUR);
char ch2 = getc(pf);
printf("%c ", ch2);
因为再读取完第一个字符d的时候,文件指针就会指向下一个字符,也就是e的位置。
在此时我们在将文件指针移到当前偏移量为2的位置时,pf就指向了g。
SEEK_END
SEEK_END就是文件的末尾,也就是g的后面位置
如果我们以文件末尾为偏移量起点,那么偏移量就应该是负数,比如,SEEK_END模式下,偏移量为 -2 的位置就是 f 的位置。
fseek(pf, -2, SEEK_END);
char ch = fgetc(pf);
printf("%c ", ch);
这就是fseek函数的用法了
ftell
当我们想要知道当前文件指针指向的位置距离文件开头的偏移量时,我们就可以用ftell函数,如果失败了就会返回-1.
fseek(pf, -2, SEEK_END);
char ch = fgetc(pf);
printf("%c\n", ch);
int offset = ftell(pf);
if (offset != -1)
{
printf("%d\n", offset);
}
fclose(pf);
reind
当我们不记得当前文件指针的位置时,我们就可以用rewind函数来让文件指针返回到文件开头位置。
fseek(pf, -2, SEEK_END);
char ch = fgetc(pf);
printf("%c\n", ch);
int offset = ftell(pf);
if (offset != -1)
{
printf("%d\n", offset);
}
rewind(pf);
int ch1 = fgetc(pf);
printf("%c\n", ch1);
6.文本文件和二进制文件
数据在内存中是以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。
如果要求在外存上以ASCIImade形式存储,则需要在存储前转换,以ASCII字符形式存储的文件就是文本文件。
在内存中,字符在内存中一律以ASCII的形式存储,不加转换存储到二进制文件中我们也可以看到这些字符。而数值型数据在内存中我们既可以用字符串以ASCII码的形式存储,也可以用整型或者其他类型以二进制的形式存储。
比如一个整数10000,如果以ASCII形式输出到磁盘,就是一个字符1和四个字符0 ,如果以二进制的形式输出,就只占四个字节。
7.文件读取结束的判定
怎么判断文件是否读取结束?
1.文本文件判断是否读取结束,判断返回值是否为EOF(fgetc)或者NULL(fgets)。
2.二进制文件判断是否读取结束,判断返回值是否小于实际要读的个数(fread)。
因为前面我们说到了,fread函数返回值时成功读取到的元素个数,而fread函数要传的参数中也有一个要读取的元素个数,我们可以用这两个值比较来判断文件是否读取结束。
当文件读取结束的时候,我们不知道是因为遇到文件结尾读取结束的还是因为文件读取失败而结束的,这时候我们就要用 feof 函数和 ferror 函数来判断。如果是遇到文件结尾读取结束的时候,feof函数返回值为真,如果是因为文件读取失败而结束的话,ferror返回值为真,这两个函数的返回值只会有一个为真,不可能出现同时为真的情况,因为文件读取结束只能是两个原因中的一个,不吭你同时满足。
8.文件缓冲区
ANSIC标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存像磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果计算机从磁盘读取数据,则从磁盘文件中读取输入到内存缓冲区,充满缓冲区后,再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小是由编译器决定的。
为什么会有缓冲区的设计?
因为我们在文件中进行读和写的操作时,并不是程序直接读写硬件,而是驱动操作系统去调用接口去读写,而从用程序读写到磁盘中是要经过几道流程的,这之间会有系统开销。如果我们每写入或者读取一个数据就让操作系统去调动接口时,操作系统的性能就大幅度下降了,因为操作系统一直在为你的读写而区调动驱动程序来进行读写,而频繁调动的时候,这种开销会非常大,同时使得我们的程序的效率也会很低。这时候,如果我没有输入输出缓冲区的话,我们会将数据先放到缓冲区,等到缓冲区满,或者我们主动刷新缓冲区、或者文件关闭时、或者遇到换行或者或者结束时才会让操作系统区调动驱动进行读写,这样程序的性能就大大提升了。
我们可以设计下面这一个函数来验证缓冲区的存在?
int main()
{
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail");
exit(-1);
}
fputs("abcdefg", pf);//数据放入输出缓冲区
Sleep(5000);
printf("还在缓冲区,没有写到文件中\n");
Sleep(5000);
fclose(pf);
pf = NULL;
return 0;
}
如图所示,当printf函数都执行完了,数据还是没有输出到文件中,这就是因为缓冲区还没满而且我们没有去刷新缓冲区,
而当程序结束时会自动刷新缓冲区,这时候数据才写入文件。
fputs("123456789", pf);//数据放入输出缓冲区
Sleep(5000);
fflush(pf);
printf("刷新缓冲区,数据写入文件\n");
//printf("还在缓冲区,没有写到文件中");
Sleep(10000)
我们也可以用fflush函数来刷新pf的输出缓冲区,这时候即使缓冲区没放满,也会将缓冲区的数据写到文件中去。
当我们在程序还在运行时,且没有走到关闭文件的那一行代码时在我们的工程目录下打开test文件,我们会发现,fflush把缓冲区刷新之后,数据就写入到文件中去了。这就证明了文件缓冲区是确实存在的。
文件操作主要就是要记住前面的那些函数,知道他们的用法,对于目前的我们就已经足够了,后续关于文件的其他知识就不会大篇幅来解释了,而是会掺杂在其他的知识中间来讲。