本章重点
- 为什么使用文件
- 什么是文件
- 文件的打开和关闭
- 文件的顺序读写
- 文件的随机读写
- 文本文件和二进制文件
- 文件读取结束的判定
- 文件缓冲区
🍑 为什么使用文件?
从本专栏第一篇博客起到现在,我们写过的所有的程序,包括扫雷、三子棋、通讯录,它们的运行都是一次性的。当运行程序时,我们所写入和输出的内容都是存储于计算机内存中的,当程序运行结束就会消失。当重启程序,我们还得重新开始操作,这无疑是不合理的。
纵观我们计算机上的所有东西,都是以文件形式保存在计算机硬盘中。C语言中的文件操作就可以让我们把目标内容、数据存储到计算机硬盘上,这样程序结束运行时数据仍然保留在硬盘。当重启程序,可以再从指定硬盘位置读取数据内容。
通过文件操作,我们实现了数据的存储与读取,实现了内容的持久化!
🍑 什么是文件?
磁盘上的文件是文件。 但是在程序设计中,我们一般谈的文件有两种:程序文件、数据文件(从文件功能的角度来分类的)。
1.程序文件: 包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)。
2.数据文件: 文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
本章讨论的是数据文件。 在以前各章所处理数据的输入输出都是以终端为对象的,即从终端的键盘输入数据,运行结果显示到显示器上。其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上文件。
3.文件名: 一个文件要有一个唯一的文件标识,以便用户识别和引用。 文件名包含3部分:文件路径+文件名主干+文件后缀 例如: c:\code\test.txt 为了方便起见,文件标识常被称为文件名。
🍑 文件的打开和关闭
🍌 文件指针
缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针”。
每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的。该结构体类型是由系统声明的,取名FILE。
例如,VS编译环境提供的 stdio.h 头文件中有以下的文件类型申明:
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;
不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异。 每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息,使用者不必关心细节。一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便。
下面举例创建一个文件指针变量:
FILE* pf;
定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变量)。通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够找到与它关联的文件。
🍌 文件的打开和关闭
文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。
在编写程序的时候,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系。
ANSIC 规定使用fopen函数来打开文件,fclose来关闭文件。
fopen和fclose函数原型:
FILE *fopen( const char *filename, const char *mode );
fopen,其中: filename表示文件名,可以是本项目内的文件名,活或者项目外硬盘其他地方某个文件的文件路径+文件名。当使用文件路径的时候,需注意文件路径中的\符号,单个\会被识别为转义字符,应该用两个\\! mode表示文件打开的模式。详见后表。
int fclose( FILE *stream );
stream就是打开文件时用来接收指向文件结构体的地址的指针。
文件打开模式:
表源:https://www.cnblogs.com/kangjianwei101/p/5220021.html
文件操作示例:
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen:");
return -1;
}
fclose(pf);
pf = NULL;
return 0;
}
🍑 文件的顺序读写函数
文件的顺序读写函数列表: 各函数介绍:
🍌 fgetc函数
函数原型:
int fgetc( FILE *stream );
该函数作用是从文件流或者标准输入流stdin(键盘)中读取一个字符。如果正常读取返回该字符的ASCII码。所以是int类型函数。 如果读取错误或者读取结束则会返回EOF,EOF是-1,因此也说明这里必须要用int类型返回值!
使用举例:
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf != NULL)
{
printf("%c", fgetc(pf));
fclose(pf);
pf = NULL;
}
return 0;
}
输出结果: 因为我们文件中存放的是: 所以读出来一个h字符。
注意:当我们使用一次这个函数后,指向这个文件的指针会自动向后偏移一位 。所以在第一次使用后再使用一次,则读取到的是’e’字符!
🍌 fputc函数
函数原型:
int fputc( int c, FILE *stream );
该函数的作用是将一个ASCII码值为c的字符,输出写入到流stream中,这个流可以是文件,也可以是标准输出流stdout(屏幕)。
如果写入成功,则返回写入字符的ASCII码; 如果写入失败,返回EOF。
使用举例:
int main()
{
FILE* pf = fopen("test.txt", "w");
if (pf != NULL)
{
int ret = fputc('a', pf);
printf("%c\n", ret);
fclose(pf);
pf = NULL;
}
int ret = fputc('b', stdout);
return 0;
}
屏幕输出结果: 文件输出结果: 可以看到,以只读“w”形式打开文件后,文件中的内容被清空,重新写入了a字符。
🍌 fgets函数
函数原型:
char *fgets( char *string, int n, FILE *stream );
这个函数的作用是从流中读取一个字符串。
其中: string:类型char*,存储数据的地址 n:类型int,最大读取的数量 stream:类型FILE*,为想要从中写入数据的文件指针,指针类型为FILE*
该函数将从stream流中读取n个字符放入到string中。
注意: 虽然该函数的第二个参数为n,表示会读取n个字符,但是在实际读取中,只会读取n-1个字符,因为会自动将第n个字符换为’\0’。
该函数的返回值类型为char*,返回这个字符串的首地址,如果读取错误或者读到文件末,则返回NULL,可用feof函数来进行判断是读取错误还是读到文件末。
使用举例:
我们事先在文件中写入hello单词。
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf != NULL)
{
char str[10] = { 0 };
char* ret = fgets(str,6, pf);
printf("%s\n", str);
printf("%s\n", ret);
fclose(pf);
pf = NULL;
}
return 0;
}
输出结果: 文件中的内容: 可以看到,hello有5个字符,所以当fgets函数第二个参数为6时,才将5个字符都打出来。 如果改成fgets(str,5, pf); ,则输出结果为:
注意: 1、当文件内容少于要读写的内容时候,只会读取文件中有的内容 2、读取n个字符的时候,实际上只会读取n-1个,n地方会自动放入’\0’ 3、假如读取到换行符,也会提前结束读取 4、当我们使用之后,FILE*会自动向后移动n-1个位置
🍌 fputs函数
函数原型:
int fputs( const char *string, FILE *stream );
该函数的作用是向文件流stream输出写入一个string字符串。
其中: 返回值:int,如果写入成功会返回非负数,如果写入失败返回EOF。 参数: string:类型char*,想要输入字符串的地址。 stream:类型FILE*,为想要从中写入数据的指针,指针类型为FILE。
使用举例:
int main()
{
FILE* pf = fopen("test.txt", "w");
if (pf != NULL)
{
int ret = fputs("i am ironman\nwho are u?",pf);
printf("%d\n", ret);
fclose(pf);
pf = NULL;
}
return 0;
}
屏幕输出结果:因为写入成功,返回一个非负数。 文件输出结果:
🍌 fprintf函数
函数原型:
int fprintf( FILE *stream, const char *format [, argument ]...);
int printf( const char *format [, argument]... );
对比fprintf和printf两个函数,我们发现其实二者大同小异,不同的仅仅是fprintf函数多了一个参数FILE *stream,这个参数就是表示的要输出写入的文件的指针或者说是文件输出流。
因此,fprintf函数的使用是可以直接类比printf函数的。
其实,printf函数只是将输出流这个参数默认为stdout,如果我们使用fprintf函数时,将第一个参数写为stdout,那它和printf函数的功能是一模一样的。可以参看下面的例子。
使用举例: 用一个结构体来举例:
struct Stu
{
char name[20];
int num;
float score;
};
int main()
{
struct Stu s = { "zhangsan",20220405,73.66 };
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail:");
return -1;
}
fprintf(pf, "%s %d %.2f", s.name, s.num, s.score);
fprintf(stdout, "%s %d %.2f", s.name, s.num, s.score);
fclose(pf);
pf = NULL;
return 0;
}
屏幕输出结果: 文件输出结果:
🍌 fscanf函数
函数原型:
int fscanf( FILE *stream, const char *format [, argument ]... );
int scanf( const char *format [,argument]... );
和上面一样,我们这里也是拿scanf和fscanf函数对比。发现,两个函数的不同之处也仅仅是fscanf函数多了一个指向文件指针的参数。那么同样的,如果将这个参数改为stdin(标准输入流–键盘),那么其效果和scanf函数应该是一样的 。
所以,fscanf函数的使用方式同样可以直接参照scanf的使用。
下面还是举个例子来讲解,我们将从4.5节中写好的文件来读取数据放入结构体中。
使用举例:
struct Stu
{
char name[20];
int num;
float score;
};
int main()
{
struct Stu s = { 0 };
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen fail:");
return -1;
}
fscanf(pf, "%s %d %f", s.name, &s.num, &s.score);
fprintf(stdout, "%s %d %.2f", s.name, s.num, s.score);
fscanf(stdin, "%s %d %f", s.name, &s.num, &s.score);
fprintf(stdout, "%s %d %.2f", s.name, s.num, s.score);
fclose(pf);
pf = NULL;
return 0;
}
输出结果:
🍌 fwrite函数
函数原型:
size_t write( void *buffer, size_t size, size_t count, FILE *stream );
该函数的作用是将数据以二进制形式写入文件。 返回值: 如果写入成功,返回写入的个数,即count; 如果返回值不等于count,则会显示一个错误。 参数解释:将buffer内的count个大小为size的数据写入到文件指针stream中去。
使用举例:
struct Stu
{
char name[20];
int num;
float score;
};
int main()
{
struct Stu s = { "wangwu",20212022,88.88};
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
perror("fopen fail:");
return -1;
}
fwrite(&s, sizeof(struct Stu), 1, pf);
fclose(pf);
pf = NULL;
return 0;
}
文件输出结果: 我们发现文件中的数据除了字符串,其他数字我们看不懂。这是因为以二进制形式写到文件中的数据,我们是无法理解的,只有计算机可以理解!
也就是说,只有fread函数可以理解!下面来看fread函数。
🍌 fread函数
函数原型:
size_t fread( void *buffer, size_t size, size_t count, FILE *stream );
通过与fwrite函数对比,我们发现它们的参数完全相同,但意思完全相反。 fread函数的作用是:从文件流stream中读取count个大小为size的数据存入到buffer中! 返回值: 如果读取成功,返回写入的完整的元素的个数,即count; 如果读取到的完整元素的个数比指定的(count)要小,则这是最后一次读取,说明文件读取结束了,返回值比count要小。
使用举例: 这里我们读取4.7中写入到文件中的数据:
struct Stu
{
char name[20];
int num;
float score;
};
int main()
{
struct Stu s = { 0};
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen fail:");
return -1;
}
fread(&s, sizeof(struct Stu), 1, pf);
printf("%s %d %.2f", s.name, s.num, s.score);
fclose(pf);
pf = NULL;
return 0;
}
输出结果: 可以看到,这里的数据和4.7中我们初始化结构体的数据一模一样。
fwrite和fread函数是一对用来以二进制形式写入、读取文件的函数。
🍑 文件的随机读写
上面我们介绍的文件顺序读写函数,在从文件读取或者向文件写入信息时,都是按照从前到后的顺序一条一条完成的。 但如果我们想要从文件的头尾中间的位置读取一个数据该如何做呢? 本节来介绍文件随机读写函数,来完成我们从文件的任意位置进行读写的操作。
🍌 fseek函数
函数原型:
int fseek( FILE *stream, long offset, int origin );
该函数的作用是:根据文件指针的位置和偏移量来定位文件指针。 从origin位置,将stream的文件指针移动offset个字字节长度。也就是让文件指针偏移offset个字节,这样文件读写函数读写时的位置就会发生变化,自然独写的内容也会变化。
返回值:如果定位成功,返回0,若失败则返回非0数。 参数: stream:指向目的文件的指针; offset:距离origin的偏移量,单位是字节; origin:你想从哪里开始算这个偏移量。一共有三个选项:
1.SEEK_CUR:文件指针当前的位置 2.SEEK_END:文件结尾的位置 3.SEEK_SET:文件开始的位置
使用举例:
我们在文件test.txt文件中提前写好abcdefg
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen");
return -1;
}
printf("%c\n", fgetc(pf));
fseek(pf, 1, SEEK_CUR);
printf("%c\n", fgetc(pf));
fseek(pf, 5, SEEK_SET);
printf("%c\n", fgetc(pf));
fseek(pf, -1, SEEK_END);
printf("%c\n", fgetc(pf));
fclose(pf);
pf = NULL;
return 0;
}
分析: 这里的1234分别对应从前往后4次printf中fgetc函数获取文件内容的位置。
- 第一次fgetc时,从文件开头读取,所以应该是a,读取后,文件指针向后移一位,指向b;
- 第一次fgetc后,执行
fseek(pf, 1, SEEK_CUR); fseek函数将文件指针从当前位置向后偏移一位,也就是从b的位置向后偏移一位,指向c,所以第二次printf的结果应该是c; - 第二次fgetc后,执行
fseek(pf, 5, SEEK_SET); fseek函数将文件指针从文件开头位置向后偏移5位,算一下,也就是指向了f,所以第三次printf的结果应该是f; - 第三次后,执行
fseek(pf, -1, SEEK_END); fseek函数又将文件指针从文件结尾位置向前偏移一位。也就是从g的后面移一位,移到了g的位置。所以第四次fgetc读取到的是g,打印出来应该是g。
输出结果: 可以看到结果和我们所预期的一样。
🍌 ftell函数
函数原型:
long ftell( FILE *stream );
该函数的作用是返回文件指针相对于起始位置的偏移量。 参数:stream:要操作的文件指针。
使用举例: 我们用fseek中的例子来验证。
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen");
return -1;
}
printf("%c\n", fgetc(pf));
fseek(pf, 1, SEEK_CUR);
printf("%c\n", fgetc(pf));
fseek(pf, 5, SEEK_SET);
printf("%c\n", fgetc(pf));
fseek(pf, -1, SEEK_END);
printf("%c\n", fgetc(pf));
long ret = ftell(pf);
printf("%ld\n", ret);
fclose(pf);
pf = NULL;
return 0;
}
我们之前分析到,最后一次使用fgetc读取的时候,文件指针指向g的位置,也就是途中④的位置。当使用完后,文件指针自动向后移一位,到g的后面,也就是⑤的位置。这个位置距离文件开头,偏移量为7,因此ftell返回的数应该是7。
输出结果:
🍌 rewind函数
函数原型:
void rewind( FILE *stream );
该函数的作用是将文件指针重置到文件起始处。这很像(void) fseek( stream, 0, SEEK_SET ); 即fseek函数将文件指针从文件起始处移位0个字节。 但和fseek不同的是,rewind函数会清除错误的文件指示,而且也没有返回值。
使用举例: 我们依然使用fseek和ftell使用的例子来举例:
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
perror("fopen");
return -1;
}
printf("%c\n", fgetc(pf));
fseek(pf, 1, SEEK_CUR);
printf("%c\n", fgetc(pf));
fseek(pf, 5, SEEK_SET);
printf("%c\n", fgetc(pf));
fseek(pf, -1, SEEK_END);
printf("%c\n", fgetc(pf));
long ret = ftell(pf);
printf("%ld\n", ret);
rewind(pf);
printf("%c\n", fgetc(pf));
fclose(pf);
pf = NULL;
return 0;
}
输出结果:
🍑 文本文件和二进制文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。 数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。 如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。
一个数据在文件中是怎么存储的呢? 字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节(0x 00 00 27 10,10000的十六进制形式,每两个十六进制位为一个字节)。
我们在前面文件的顺序读写中学习了fwrite和fread函数,它们就是进行读写二进制文件的函数,下面我们来测试一下将10000这个整数以二进制形式保存在文件中,观察文件情况:
int main()
{
int a = 10000;
FILE* pf;
pf = fopen("bin.txt", "w");
if (pf == NULL)
{
perror("fopen");
return 1;
}
fwrite(&a, sizeof(int), 1, pf);
fclose(pf);
pf = NULL;
return 0;
}
生成的二进制文件bin.txt我们直接打开是无法识别的一些符号。 我们将bin.txt文件放到VS2022中,使用二进制编辑器打开: 可以看到,该文件中存入了十六进制数据10 27 00 00,4个字节。因为机器使用小端存储,所以在内存中由低到高是10 27 00 00,故而输出的顺序是10 27 00 00。
🍑 文件读取结束的判定—不要乱用feof函数
当我们使用文件读取函数的时候,想要知道是不是已经读取完毕,可能大家都会想到feof函数,首先我们来介绍一下feof函数: 函数原型:
int feof( FILE *stream );
该函数的参数就是要操作的文件指针,它会去判断这个文件指针是不是已经在文件末尾,如果没有在文件末尾会返回0;如果已经在文件末尾,则会返回非零数。
我们先来看经常使用的一些读取函数的返回值如何:
- fgetc函数在读取发生错误或者读取到文件末尾的时候,就结束读取,会返回EOF;正常读取的时候,返回的是读取到的字符的ASCII码值
- fgets函数在读取发生错误或者读取到文件末尾的时候,就结束读取,会返回NULL ;正常读取的时候,返回存放字符串的空间起始地
- fread函数在读取的时候,返回的是实际读取到的完整元素的个数 ;如果发现读取到的完整的元素的个数小于指定的元素个数,这就是最后一次读取了。
我们可以看到,当读取结束的时候,并不一定是因为读取到文件末尾而结束,也有可能是因为发生读取错误而结束读取。当我们需要判断是什么情况而导致的读取结束时,就需要用到feof函数。
所以需要注意的是: 在文件读取过程中,不能用feof函数的返回值直接来判断文件的读取是否结束。因为我们不知道是什么原因导致的读取结束。有可能读取出错导致已经读取结束了,但是用feof就会认为还没有结束。
feof函数应该用于当文件读取结束的时候,判断是读取失败结束,还是遇到文件尾结束。
对于读取时是否因为读取错误而结束,可以用ferror()来判断,当其为真时表示有错误发生。在实际的程序中,应该每执行一次文件操作,就用ferror()函数检测是否出错。
我们来看看ferror函数:
ferror函数原型:
int ferror( FILE *stream );
该函数的返回值:当文件读写出现错误,就会返回非0值。如果文件读写正常,返回0。
下面来举个例子,看看feof和ferror的配合使用: 这里,我们首先在test_1.txt文件中存储了一些内容,让我们来将test_1.txt文件中的内容完整复制到test_2.txt文件中吧。
int main()
{
FILE* pf1 = fopen("test_1.txt", "r");
if (pf1 == NULL)
{
perror("pf1 error");
return 1;
}
FILE* pf2 = fopen("test_2.txt", "w");
if (pf2 == NULL)
{
perror("pf2 error");
pf1 = NULL;
return 1;
}
int ret = 0;
while ((ret = fgetc(pf1)) != EOF)
{
fputc(ret, pf2);
}
if (feof(pf1))
{
printf("读取到文件末尾,正常读取结束\n");
}
else if (ferror(pf1))
{
printf("读取过程遇到错误,文件读取未完成\n");
}
fclose(pf1);
pf1 = NULL;
fclose(pf2);
pf2 = NULL;
}
输出结果: 屏幕上:可以看到是正常读取结束。 文件中:可以看到确实将test_1.txt文件中的内容复制到了test_2.txt文件中,二者内容完全相同。
🍑 文件缓冲区
ANSIC 标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。
从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。
如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。
缓冲区的大小根据C编译系统决定的。 缓冲区的存在的意义是为了提高计算机运行效率,试想一下,如果没有缓冲区,那数据向硬盘的读写将直接由系统一个比特位一个比特位的操作,频繁的读写操作将消耗计算机性能。如果能存在一起,一次性发过去很多数据的话,那么在缓冲的过程中,计算机可以做其他的事。
🍑 总结
以上就是C语言在文件操作部分的主要内容。注意文件读写函数的使用。
|