目录
1 关于文件
i)文件的基本知识
ii)数据文件的分类
2 文件打开和关闭
i)流和标准流
ii)文件指针
iii)文件打开和关闭
3 文件的顺序读写
i) fgetc fputc
ii) fgets fputs
iii) fscanf fprintf
iv) fwrite fread
4 对比一组函数 scanf/fscanf/sscanf/printf/fprintf/sprintf
5 文件的随机读写
6 文件读取结束的判定
7 文件缓冲区
1 关于文件
i)文件的基本知识
在电脑中文件是随处可见的,那你思考过为什么存在“文件”吗?当我们运行程序的时候,程序一旦结束,在内存中存储的数据也会被销毁,我们如果想要保存数据,以方便下一次使用的话,就需要用到文件,也就是说,文件是可以用来保存数据的。
那什么是文件呢?从文件的功能性分类的话,文件可以分为程序性文件和数据文件,程序性文件包括源文件(.c后缀),目标文件(windows后缀为.obj),可执行程序(windows后缀为.exe),我们本章着重介绍的是数据文件,程序文件会在后面的预编译章节介绍。在之前的章节我们处理数据的输入输出都是在终端处理的,如键盘,电脑屏幕,但有点时候我们会把信息输入到磁盘里面,读取数据的时候让磁盘输入数据给内存,在从内存中读取数据,计算机读取数据分为好几个等级,从速度快慢分为寄存器,缓存,内存,硬盘等等,那么本场要学习的就是如何从磁盘从读取数据。
要使用文件,我们肯定要知道文件标识是由什么组成的,⽂件名包含3部分:⽂件路径+⽂件名主⼲+⽂件后缀,例如c:\code\test.txt,就是一个完整的文件名,文件路径是c:\code\,文件名主干是test,文件后缀就是txt,为了方便起见,我们一般把文件标识称作为文件名。
ii)数据文件的分类
数据文件被分为二进制文件和文本文件,有的文件创建好了之后不是给使用者看的,是给计算机看的,但是计算机只能识别二进制的1 0,所以会有二进制文件,那么同理可得,文本文件就是给使用者看的,如果当你使用了记事本打开一个文件发现全是乱码,那么它八九不离十的是二进制文件了。
二进制文件是数据在内存中不加转化,直接输出到外存的文件,数据文件需要经过ASCII码值的转化,再输出到外存,所以以ASCII码值存储的文件都是文本文件。
那么一个数据是怎么在内存中存储的呢?
字符一律以ASCII码值的形式存储,数值类型的数据可以用二进制的形式存储也可以用ASCII码值的形式存储,以10000来举个例子,10000的二进制是这样的,(VS2019测试)如果直接以二进制的形式存储,就只会占4个字节,如果是ASCII码值的形式存储,那么就会占5个字节,1占一个字节,其余的每个0占一个字节
00000000 00000000 00100111 00010000
当我们使用二进制存储的时候,10000的二进制存储就会变为00 00 27 10,那么因为VS是小端存储,所以屏幕上看到的就是10 27 00 00
int main()
{
int a = 10000;
FILE* pf = fopen("data.txt", "wb");
fwrite(&a, 4, 1, pf);
fclose(pf);
pf = NULL;
return 0;
}
使用fwrite函数创建该文件,并且以二进制的形式存入,最后我们使用二进制的方式打开该文件,得到的就是:
2 文件打开和关闭
i)流和标准流
计算机输出数据和输入数据的时候需要外接到同设备,而通过不同的外接设备输入输出的数据的时候操作都不一样,那么计算机为了简化这一过程,就引用了流的概念,可以理解为计算机将数据不管三七二十一的一股脑给丢进去一个地方,计算机需要数据的时候就去里面拿就行了,那个地方就是流,C语言一般画面,文件,键盘的输入输出的时候都是通过流完成的,那么我们要使用流或者是输入数据到流里面,就需要打开流,使用完再关闭流。
可是实际上我们之前写代码的时候并没有过打开流关闭流这个操作,这是因为C语言默认打开了三个流,这三个流被称为标准流,stdin,stdout,stderr
stdin被称为标准输入流,大多数情况从键盘输入,scanf使用的时候就需要使用到这个流。
stdout被称为标准输出流,大多数情况从屏幕输出,printf使用的时候就需要使用到这个流。
stderr被称为标准错误流,大多数情况写也是从屏幕输出的。
当然,流也是由类型的,这三个流的类型是FILE*,FILE的英文意思就是文件,也就是文件指针,C语言中,FILE*的使用也是用来维护各种流的使用的。
ii)文件指针
文件类型指针就是FILE*,简称文件指针,而每个使用的文件在内存中都开辟了一块文件信息区,文件的相关信息,而这些信息保存在一个结构体变量里面,这个结构体变量就是FILE*,在vs2013关于FILE*的声明是这样的:
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;
创建了一个结构体变量_iobuf,最后重命名为了FILE,而在VS2022声明就没有那么详细了,VS2022的的声明如下:
当然我们只需要关心怎么用FILE*就行,至于文件指针的源码,是不用了解那么多的,我们使用FILE*访问文件信息区的时候就可以找到文件的相关信息,也就是说我们可以利用文件指针间接找到与该文件相关的文件。
iii)文件打开和关闭
文件打开的时候会返回一个指针指向该文件的文件信息区,也就是建立了指针和文件的关系,那么我们使用完之后就应该关闭文件,C语言中打开文件使用的是fopen函数,关闭文件使用的是fclose函数,f就是file,open就是打开的意思,close就是关闭的意思。
//打开⽂件
FILE * fopen ( const char * filename, const char * mode );
//关闭⽂件
int fclose ( FILE * stream );
这是他们的参数,fopen的第一个参数就是文件名,第二个是打开模式,fclose参数就是文件指针,也就是用来接受fopen函数返回的文件指针。
文件的打开模式有许多种,如下:
模式有很多种,感兴趣可以自行使用一下。注意如果文件不存在的话,就会创建一个这样的文件放在该代码的路径下,这是相对路径,如果想要指定路径放的话,就是绝对路径:
FILE* pf = fopen("C:\\Users\\users\\OneDrive\\data.txt", "w");
只需要在文件名前面加上文件路径就行了,但是要注意的是\要写两个,这是前面转义字符的知识,为了防止\和其他字符结合形成转义字符。
但是多次是用"w"打开文件的时候,每次打开都会先让文件里面的内容清空,这个小现象需要注意一下。
3 文件的顺序读写
i) fgetc fputc
fgetc是字符输入函数,fputc是字符输出函数,均适用于所有文件输入输出流。
int main()
{
FILE* pf = fopen("data.txt", "w");
assert(pf);
char array[26] = { 0 };
for (int i = 0; i < 26; i++)
{
fputc('a' + i, pf);
}
for (int j = 0; j < 26; j++)
{
array[j] = fgetc(pf);
}
for (int j = 0; j < 26; j++)
{
printf("%c ", array[j]);
}
fclose(pf);
pf = NULL;
return 0;
}
我们创建好文件指针之后,需要判断一下指针是不是空指,最后还要关闭指针,并且置为空指针,这里和动态开辟函数类似,我们给文件里面存好数据之后,第一遍是打印不出来我们想要的26个字符的,因为这里的文件打开模式是w,是写入,那么运行第二次时,我们把w换成r就fgetc函数就开始操作了。
如果fgetc函数读取失败返回或者是读取到了文件尾就会返回eof,也就是文件结束标志,那么函数就结束了,如果fputc写入失败的话也是返回的eof。
在读取文件的时候,字符函数都是一个字符一个字符的读取或者写入的,当读取完一个字符后,光标往后移动,指向下一个字符,所以如果不用for循环的话,想要打印就需要重复写这两行代码:
int ch = fgetc(pf);
printf("%c ", ch);
实际上fgetc函数等效于getc函数,只是getc函数在库中可以等效为宏。
ii) fgets fputs
fgets是文本行输入函数,fputs是文本行输出函数,均适用于所有输入输出流,文本行其实就是字符串。
fgets和gets函数是非常不像的,fgets函数有三个参数:
char * fgets ( char * str, int num, FILE * stream );
fgets函数会从流里面读取num -1个字符,并将这些字符存到指针str里面,且最后一个元素置为\0,读取过程中如果碰到了换行符 文件末尾 或者是读取了num - 1个字符,以先到者为准,读取num - 1个字符,第num个字符是\0,如果读取失败,返回的是空指针,如果读着读着莫名读取到了文件尾,那么返回的就是eof。
fputs就没有什么好介绍的了,就是将字符串放进去而已,puts输出之后会自动换行,fputs不会,这是他们的区别。
参考代码:
int main(){
FILE* pf = fopen("data.txt", "r");
assert(pf);
char arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
fputs("xxxxxxx", pf);
fgets(arr, 6, pf);
for (int i = 0; i < 6; i++)
{
printf("%c ", arr[i]);
}
return 0;
}
iii) fscanf fprintf
fscanf fprintf这两个函数是格式化输入输出函数,也就是要用到占位符,适用于所有流,可以这么说scanf能做的,fscanf都可以做,fprintf同理,fscanf就比scanf多了一个参数,fscanf是从文件中读取数据,如果第一个参数是stdin,也就是标准输入流的话,就是从键盘里面输入数据了,fprintf同理可得,如果第一个参数是stdout,也就是标准输出流的话,就是从屏幕上打印,这是函数原型:
int fscanf ( FILE * stream, const char * format, ... );
int fprintf ( FILE * stream, const char * format, ... );
int main()
{
FILE* pf = fopen("data.txt", "r");
assert(pf);
char ch = 0;
//fputs("1 2 3 4 5 6 7 8 9 10", pf);
fscanf(pf, "%c",&ch);
printf("%c", ch);
fclose(pf);
pf = NULL;
return 0;
}
这就是从文件里面读取数据,如果我们想要从键盘读取,只需把流换成stdin就行了,那么这是读文件的操作,如果是写文件,就可以用到fprintf:
int main()
{
struct St
{
int age;
char name[20];
double score;
};
FILE* pf = fopen("data.txt", "r");
assert(pf);
struct St st = { 10,"zhangsan",99.9};
fscanf(pf, "%d %s %lf", &(st.age), st.name, &(st.score));
fprintf(stdout, "%d %s %.1f", st.age, st.name, st.score);
fclose(pf);
pf = NULL;
return 0;
}
iv) fwrite fread
fwrite fread是二进制输入输出函数,只适用于文件
这是cplusplus里面的记述,fwrite有四个参数,意思就是从流里面写进去count个大小为size的元素,用到的是指针,文章最开始就用了这个函数,存的是10000,因为只用存一个10000,所以第三个参数是1,fread同理可得。
因为是二进制输入输出函数,所以写入的时候用到的是wb,读取的时候用到的是rb:
int main()
{
FILE* pf = fopen("data.txt", "wb");
struct St
{
int age;//10
char name[20];//zhangsan
double score;//99.9
}st = {10,"zhangsan",99.9};
fwrite(&st,sizeof(st),1,pf);
fclose(pf);
pf = NULL;
return 0;
}
这是我们用二进制的方式写进了文件里面:
文本文件写进去是这样的:
可以看到有明显的差别,因为字符都是以ASCII码值的形式存储,所以结果不变,但是其他数据就变化大了,当然我们是看不懂的,计算机看得懂,比如用fread函数就看懂了:
int main()
{
FILE* pf = fopen("data.txt", "rb");
struct St
{
int age;//10
char name[20];//zhangsan
double score;//99.9
}st;
fread(&st, sizeof(st), 1, pf);
printf("%d %s %lf", st.age, st.name, st.score);
fclose(pf);
pf = NULL;
return 0;
}
4 对比一组函数 scanf/fscanf/sscanf/printf/fprintf/sprintf
scanf printf都是标准流的格式化函数,fscanf fprintf是所有流的格式化函数,scanf printf能做的
fscanf fprintf都可以做,可以理解为fscanf fprintf包含了scanf printf,前面介绍了这两组函数,这里就不介绍了
sscanf sprintf:
sprintf的作用是将格式化的数据放到指针指向的空间里面,sscanf的作用是从指针指向的空间种读取格式化的数据(代码如下):
int main()
{
struct XY
{
char name[10];
int age;
float score;
}xy = {"ziyou",18,100.8};
char arr[100] = { 0 };
sprintf(arr, "%s %d %f", xy.name, xy.age, xy.score);
printf("%s", arr);
return 0;
}
创建好一个结构体变量之后,再创建一个字符数组用来存放sprintf输出的数据,那么格式化的占位符是必不可少的,因为数组名是首元素地址,也就是指针,所以写上了arr,最后打印出来如下:
因为我在fprintf写参数的时候已经空格了,空格也会输出进去,所以打出来也是带空格的。
int main()
{
struct XY
{
char name[10];
int age;
float score;
}xy = { "zhangsan",12,12.2 }, tem = { 0 };
char arr1[100] = { 0 };
sprintf(arr1, "%s %d %f", xy.name, xy.age, xy.score);
sscanf(arr1, "%s %d %f", tem.name, &(tem.age), &(tem.score));
printf("%s %d %f", tem.name,tem.age,tem.score);
return 0;
}
sscanf是从arr1里面读取数据,然后放在了结构体里面,sprintf是输出到arr1里面,相较于前面的两组函数,这组函数是没有用到文件指针的,但是指针是用到了,由此可见指针的重要性。
5 文件的随机读写
文件的随机读写介绍三个函数 fseek ftell rewind:
int main()
{
FILE* pf = fopen("data.txt", "r");
assert(pf);
for (int i = 0; i < 26; i++)
{
fputc('a' + i, pf);
}
char ch = fgetc(pf);
printf("%c ", ch);//a
ch = fgetc(pf);
printf("%c ", ch);//b
ch = fgetc(pf);
printf("%c ", ch);//c
ch = fgetc(pf);
printf("%c ", ch);//d
ch = fgetc(pf);
printf("%c ", ch);//e
fclose(pf);
pf = NULL;
return 0;
}
这段代码的意思就是文件里面有英文字母26个,使用fgetc函数一个一个读取,最后的打印结果应该是a b c d e,运行到d的时候文件指针,也就是光标,指向的是e,那么如果我们想要让文件指针回到最开始的位置,使得最后的打印结果还是a,就可以用到rewind:
int main()
{
FILE* pf = fopen("data.txt", "r");
assert(pf);
for (int i = 0; i < 26; i++)
{
fputc('a' + i, pf);
}
char ch = fgetc(pf);
printf("%c ", ch);//a
ch = fgetc(pf);
printf("%c ", ch);//b
ch = fgetc(pf);
printf("%c ", ch);//c
ch = fgetc(pf);
printf("%c ", ch);//d
rewind(pf);
ch = fgetc(pf);
printf("%c ", ch);//e
fclose(pf);
pf = NULL;
return 0;
}
rewind的参数就是一个文件指针,所以想要让文件指针回到起始位置用到一个rewind就行了。
那么如果我们想指定位置开始读取呢?这里用到的函数就是fseek函数,随机读取函数,可以
使文件指针指向最开始到结尾的任意位置:
它有3个参数,第一个参数是文件指针,第二个是偏移量,第三个是计算偏移量的起始位置,偏移量很好理解,光标指向第一个字符的时候偏移量就是0,往后一次偏移量增加1,那么第三个参数写的时候有三个选择,SEEK_SET,SEEK_CUR,SEEK_END,CUR就是当前位置的意思,SET就是起始位置的意思,END就是末尾的意思,所以移动光标的时候需要先确定好计算偏移量的起始位置,然后确定偏移量,从起始位置开始,往左计算偏移量就是负数,往右计算就是正数。
那么如果我懒我不想计算偏移量,我想直接知道现在的偏移量呢?只需要用ftell函数就是,参数就是文件指针,这个函数的返回值就是当前的偏移量。
如下:
int main()
{
FILE* pf = fopen("data.txt", "r");
assert(pf);
for (int i = 0; i < 26; i++)
{
fputc('a' + i, pf);
}
char ch = fgetc(pf);
printf("%c ", ch);//a
ch = fgetc(pf);
printf("%c ", ch);//b
ch = fgetc(pf);
printf("%c ", ch);//c
ch = fgetc(pf);
printf("%c ", ch);//d
int num = ftell(pf);
printf("%d ", num);
//rewind(pf);
fseek(pf, -2, SEEK_CUR);
ch = fgetc(pf);
printf("%c ", ch);//c
fclose(pf);
pf = NULL;
return 0;
}
6 文件读取结束的判定
文件读取结束分为正常读取到了结尾和读到一半遇到错误了,那么我们如何判断文件是不是正常结束呢?这里用到的函数是feof,但是使用feof的前提是文件已经读取完了(不管是哪种结束),需要注意的是feof不能用来判断文件是不是读取结束了,这是前提,不要搞混淆了。
得feof 的作⽤是:当⽂件读取结束的时候,判断是读取结束的原因是否是:遇到⽂件尾结束。
同理得ferror的作用是:当⽂件读取结束的时候,判断是读取结束的原因是否是:遇到错误结束。
当读取文本文件的时候,fgetc如果正常读取结束返回的是eof,fgets如果正常读取结束返回的是NULL,这也是它们的区别。
当读取二进制文件的时候,判断fread返回的值是否小于文件中的实际字符数,因为fread函数的返回值是读取到的个数。
文本文件代码示范:
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);
}
二进制代码示范:
enum { SIZE = 5 };
int main(void)
{
double a[SIZE] = {1.,2.,3.,4.,5.};
FILE *fp = fopen("test.bin", "wb"); // 必须⽤⼆进制模式
fwrite(a, sizeof *a, SIZE, fp); // 写 double 的数组
fclose(fp);
double b[SIZE];
fp = fopen("test.bin","rb");
size_t ret_code = fread(b, sizeof *b, SIZE, fp); // 读 double 的数组
if(ret_code == SIZE) {
puts("Array read successfully, contents: ");
for(int n = 0; n < SIZE; ++n) printf("%f ", b[n]);
putchar('\n');
} else { // error handling
if (feof(fp))
printf("Error reading test.bin: unexpected end of file\n");
else if (ferror(fp)) {
perror("Error reading test.bin");
}
}
fclose(fp);
pf = NULL;
}
7 文件缓冲区
前面提到程序从计算机中读取数据的时候分为了好几个层次,其中包括看文件缓冲区,那么缓冲区的作用是是什么呢?
程序运行的时候内存会为每个正在使用的文件开辟缓冲区,读取数据的时候,数据就会往先往里面放,直到缓冲区装满了,才会一并送到磁盘中,相同的,如果程序需要输入数据,系统会将输入的数据放到输入缓冲区里面区,直到输入缓冲区满了,才会从磁盘输入到程序里面去。
那么缓冲区存在的意义是什么呢?
有人会说为什么不输出一个就给磁盘一个,实际上调用函数的时候,计算机底层也会被调用起来,所以看似工作的有程序,内存等,实际上还有计算机各个部分都是调用起来的,如果输出一个就传送一个出去,那其他部分的工作就是进行不下去,所以缓冲区存在的意义就是为了提高工作效率。
int main()
{
FILE*pf = fopen("test.txt", "w");
fputs("abcdef", pf);//先将代码放在输出缓冲区
printf("睡眠10秒-已经写数据了,打开test.txt⽂件,发现⽂件没有内容\n");
Sleep(10000);
printf("刷新缓冲区\n");
fflush(pf);//刷新缓冲区时,才将输出缓冲区的数据写到⽂件(磁盘)了
printf("再睡眠10秒-此时,再次打开test.txt⽂件,⽂件有内容了\n");
Sleep(10000);
fclose(pf);
//注:fclose在关闭⽂件的时候,也会刷新缓冲区
pf = NULL;
return 0;
}
我们先給文件一串字符串,然后让程序休眠10秒,也就是Sleep,Sleep需要引用到的头文件是windows,程序休眠的这10秒,我们打开文件就会发现abcdef是没有被写进去的,这个时候我们使用fflush刷新一下缓冲区,再去打开文件,就会发现abcdef写进去了,这个时候关闭文件,需要注意的是关闭文件的时候缓冲区也会被刷新,验证的方法就是把fclose提前,不使用fflush,这个时候也会发现文件过了10秒之后abcdef出现在了文件里面。
正因为缓冲区的存在,在进行关于文件类操作的时候,需要刷新缓冲区或者是关闭文件,不然很可能导致读写文件出现问题。
感谢阅读!