C语言---文件操作万字详细分析(6)
文件操作
到这里,
C语言所有知识点,
就告已段落了,
虽然知识点到这里结束了,
但我想,
我们的编程之路也可能刚刚开始,
这些知识,
是我们在创造伟大事物时,
必不可少的基础,
是我们未来财富自由之路,
必不可少的垫脚之石。
相信大家会变得越来越牛逼!
不废话了,
Let’s start!
一、文件指针
C 语言提供了一个 FILE 数据结构,
记录了操作一个文件所需要的信息。
该结构定义在头文件stdio.h
,
所有文件操作函数都要通过这个数据结构,
用来获取文件信息。
开始操作一个文件之前,
就要定义一个指向该文件的 FILE 指针,
相当于获取一块内存区域,
用来保存文件信息。
FILE* fp;
上面示例定义了一个 FILE 指针fp
。
来看一个读取文件的完整示例。
#include <stdio.h>
int main(void) {
FILE* fp;
char c;
fp = fopen("hello.txt", "r");
if (fp == NULL) {
return -1;
}
c = fgetc(fp);
printf("%c\n", c);
fclose(fp);
return 0;
}
上面示例中,新建文件指针fp
以后,
依次使用了下面三个文件操作函数,
分成三个步骤。
其他的文件操作,
大致上也是这样的步骤。
第一步,使用fopen()
打开指定文件,返回一个 File 指针。
如果出错,返回 NULL。
-
它相当于将指定文件的信息与新建的文件指针
fp
相关联,在 FILE 结构内部记录了这样一些信息:文件内部的当前读写位置、读写报错的记录、文件结尾指示器、缓冲区开始位置的指针、文件标识符、一个计数器(统计拷贝进缓冲区的字节数)等等。后继的操作就可以使用这个指针(而不是文件名)来处理指定文件。 -
同时,它还为文件建立一个缓存区。由于存在缓存区,也可以说
fopen()
函数“打开一个了流”,后继的读写文件都是流模式。
第二步,使用读写函数,从文件读取数据,或者向文件写入数据。
上例使用了fgetc()
函数,从已经打开的文件里面,读取一个字符。
-
fgetc()
一调用,文件的数据块先拷贝到缓冲区。不同的计算机有不同的缓冲区大小,一般是512字节或是它的倍数,如4096或16384。随着计算机硬盘容量越来越大,缓冲区也越来越大。 -
fgetc()
从缓冲区读取数据,同时将文件指针内部的读写位置指示器,指向所读取字符的下一个字符。所有的文件读取函数都使用相同的缓冲区,后面再调用任何一个读取函数,都将从指示器指向的位置,即上一次读取函数停止的位置开始读取。 -
当读取函数发现已读完缓冲区里面的所有字符时,会请求把下一个缓冲区大小的数据块,从文件拷贝到缓冲区中。读取函数就以这种方式,读完文件的所有内容,直到文件结尾。不过,上例是只从缓存区读取一个字符。当函数在缓冲区里面,读完文件的最后一个字符时,就把 FILE 结构里面的文件结尾指示器设置为真。于是,下一次再调用读取函数时,会返回常量 EOF。EOF 是一个整数值,代表文件结尾,一般是
-1
。
第三步,fclose()
关闭文件,同时清空缓存区。
- 上面是文件读取的过程,文件写入也是类似的方式,先把数据写入缓冲区,当缓冲区填满后,缓存区的数据将被转移到文件中。
二、fopen()
fopen()
函数用来打开文件。
所有文件操作的第一步,
都是使用fopen()
打开指定文件。
这个函数的原型定义在头文件stdio.h
。
FILE* fopen(char* filename, char* mode);
它接受两个参数。
第一个参数是文件名(可以包含路径),
第二个参数是模式字符串,
指定对文件执行的操作,
比如下面的例子中,r
表示以读取模式打开文件。
fp = fopen("in.dat", "r");
成功打开文件以后,
fopen()
返回一个 FILE 指针,其他函数可以用这个指针操作文件。
如果无法打开文件(比如文件不存在或没有权限),
会返回空指针 NULL。
所以,
执行fopen()
以后,
最好判断一下,有没有打开成功。
fp = fopen("hello.txt", "r");
if (fp == NULL) {
printf("Can't open file!\n");
exit(EXIT_FAILURE);
}
上面示例中,如果fopen()
返回一个空指针,程序就会报错。
`fopen()`的模式字符串有以下几种。
- `r`:读模式,只用来读取数据。如果文件不存在,返回 NULL 指针。
- `w`:写模式,只用来写入数据。如果文件存在,文件长度会被截为0,然后再写入;如果文件不存在,则创建该文件。
- `a`:写模式,只用来在文件尾部追加数据。如果文件不存在,则创建该文件。
- `r+`:读写模式。如果文件存在,指针指向文件开始处,可以在文件头部添加数据。如果文件不存在,返回 NULL 指针。
- `w+`:读写模式。如果文件存在,文件长度会被截为0,然后再写入数据。这种模式实际上读不到数据,反而会擦掉数据。如果文件不存在,则创建该文件。
- `a+`:读写模式。如果文件存在,指针指向文件结尾,可以在现有文件末尾添加内容。如果文件不存在,则创建该文件。
前面提到,fopen()
函数会为打开的文件创建一个缓冲区。
读模式下,创建的是读缓存区;
写模式下,创建的是写缓存区;
读写模式下,会同时创建两个缓冲区。
C 语言通过缓存区,以流的形式,向文件读写数据。
数据在文件里面,都是以二进制形式存储。
但是,
读取的时候,有不同的解读方法:
以原本的二进制形式解读,叫做“二进制流”;
将二进制数据转成文本,以文本形式解读,叫做“文本流”。
写入操作也是如此,分成以二进制写入和以文本写入,后者会多一个文本转二进制的步骤。
fopen()
的模式字符串,
默认是以文本流读写。
如果添加b
后缀(表示 binary),就会以“二进制流”进行读写。
比如,rb
是读取二进制数据模式,wb
是写入二进制数据模式。
模式字符串还有一个x
后缀,表示独占模式(exclusive)。如果文件已经存在,则打开文件失败;如果文件不存在,则新建文件,打开后不再允许其他程序或线程访问当前文件。比如,wx
表示以独占模式写入文件,如果文件已经存在,就会打开失败。
三、标准流(了解)
Linux 系统默认提供三个已经打开的文件,它们的文件指针如下。
stdin
(标准输入):默认来源为键盘,文件指针编号为0
。stdout
(标准输出):默认目的地为显示器,文件指针编号为1
。stderr
(标准错误):默认目的地为显示器,文件指针编号为2
。
Linux 系统的文件,不一定是数据文件,也可以是设备文件,
即文件代表一个可以读或写的设备。
文件指针stdin
默认是把键盘看作一个文件,读取这个文件,就能获取用户的键盘输入。
同理,stdout
和stderr
默认是把显示器看作一个文件,
将程序的运行结果写入这个文件,用户就能看到运行结果了。
它们的区别是,stdout
写入的是程序的正常运行结果,stderr
写入的是程序的报错信息。
这三个输入和输出渠道,是 Linux 默认提供的,
所以分别称为标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
因为它们的实现是一样的,都是文件流,所以合称为“标准流”。
Linux 允许改变这三个文件指针(文件流)指向的文件,这称为重定向(redirection)。
如果标准输入不绑定键盘,而是绑定其他文件,
可以在文件名前面加上小于号<
,跟在程序名后面。
这叫做“输入重定向”(input redirection)。
$ demo < in.dat
上面示例中,demo
程序代码里面的stdin
,将指向文件in.dat
,即从in.dat
获取数据。
如果标准输出绑定其他文件,而不是显示器,
可以在文件名前加上大于号>
,跟在程序名后面。
这叫做“输出重定向”(output redirection)。
$ demo > out.dat
上面示例中,demo
程序代码里面的stdout
,将指向文件out.dat
,即向out.dat
写入数据。
输出重定向>
会先擦去out.dat
的所有原有的内容,然后再写入。
如果希望写入的信息追加在out.dat
的结尾,可以使用>>
符号。
$ demo >> out.dat
上面示例中,demo
程序代码里面的stdout
,将向文件out.dat
写入数据。
与>
不同的是,写入的开始位置是out.dat
的文件结尾。
标准错误的重定向符号是2>
。其中的2
代表文件指针的编号,
即2>
表示将2号文件指针的写入,重定向到err.txt
。
2号文件指针就是标准错误stderr
。
$ demo > out.dat 2> err.txt
上面示例中,demo
程序代码里面的stderr
,会向文件err.txt
写入报错信息。
而stdout
向文件out.dat
写入。
输入重定向和输出重定向,也可以结合在一条命令里面。
$ demo < in.dat > out.dat
// or
$ demo > out.dat < in.dat
重定向还有另一种情况,就是将一个程序的标准输出stdout
,
指向另一个程序的标准输入stdin
,这时要使用|
符号。
$ random | sum
上面示例中,random
程序代码里面的stdout
的写入,
会从sum
程序代码里面的stdin
被读取。
四、fclose()
fclose()
用来关闭已经使用fopen()
打开的文件。
它的原型定义在stdio.h
。
int fclose(FILE* stream);
它接受一个文件指针fp
作为参数。
如果成功关闭文件,fclose()
函数返回整数0
;
如果操作失败(比如磁盘已满,或者出现 I/O 错误),
则返回一个特殊值 EOF(详见下一小节)。
if (fclose(fp) != 0)
printf("Something wrong.");
不再使用的文件,
都应该使用fclose()
关闭,否则无法释放资源。
一般来说,系统对同时打开的文件数量有限制,及时关闭文件可以避免超过这个限制。
五、freopen()
freopen()
用于新打开一个文件,
直接关联到某个已经打开的文件指针。
这样可以复用文件指针。
它的原型定义在头文件stdio.h
。
FILE* freopen(char* filename, char* mode, FILE* stream);
它跟fopen()
相比,就是多出了第三个参数,表示要复用的文件指针。
其他两个参数都一样,分别是文件名和打开模式。
freopen("output.txt", "w", stdout);
printf("hello");
上面示例将文件output.txt
关联到stdout
,
此后向stdout
写入的内容,都会写入output.txt
。
由于printf()
默认就是输出到stdout
,
所以运行上面的代码以后,文件output.txt
会被写入hello
。
freopen()
的返回值是它的第三个参数(文件指针)。
如果打开失败(比如文件不存在),会返回空指针 NULL。
freopen()
会自动关闭原先已经打开的文件,
如果文件指针并没有指向已经打开的文件,则freopen()
等同于fopen()
。
下面是freopen()
关联scanf()
的例子。
int i, i2;
scanf("%d", &i);
freopen("someints.txt", "r", stdin);
scanf("%d", &i2);
上面例子中,一共调用了两次scanf()
,
第一次调用是从键盘读取,
然后使用freopen()
将stdin
指针关联到某个文件,
第二次调用就会从该文件读取。
某些系统允许使用freopen()
,改变文件的打开模式。
这时,freopen()
的第一个参数应该是 NULL。
freopen(NULL, "wb", stdout);
上面示例将stdout
的打开模式从w
改成了wb
。
六、fgetc(),getc()
fgetc()
和getc()
用于从文件读取一个字符。
它们的用法跟getchar()
类似,区别是getchar()
只用来从stdin
读取,
而这两个函数是从任意指定的文件读取。
它们的原型定义在头文件stdio.h
。
int fgetc(FILE *stream)
int getc(FILE *stream);
fgetc()
与getc()
的用法是一样的,都只有文件指针一个参数。
两者的区别是,
getc()
一般用宏来实现,
而fgetc()
是函数实现,
所以前者的性能可能更好一些。
注意,虽然这两个函数返回的是一个字符,
但是它们的返回值类型却不是char
,
而是int
,这是因为读取失败的情况下,
它们会返回 EOF,这个值一般是-1
。
#include <stdio.h>
int main(void) {
FILE *fp;
fp = fopen("hello.txt", "r");
int c;
while ((c = getc(fp)) != EOF)
printf("%c", c);
fclose(fp);
}
上面示例中,getc()
依次读取文件的每个字符,
将其放入变量c
,直到读到文件结尾,
返回 EOF,循环终止。
变量c
的类型是int
,而不是char
,
因为有可能等于负值,所以设为int
更好一些。
七、fputc(),putc()
fputc()
和putc()
用于向文件写入一个字符。
它们的用法跟putchar()
类似,区别是putchar()
是向stdout
写入,
而这两个函数是向文件写入。
它们的原型定义在头文件stdio.h
。
int fputc(int char, FILE *stream);
int putc(int char, FILE *stream);
fputc()
与putc()
的用法是一样,都接受两个参数,
第一个参数是待写入的字符,
第二个参数是文件指针。
它们的区别是,
putc()
通常是使用宏来实现,
而fputc()
只作为函数来实现,
所以理论上,putc()
的性能会好一点。
写入成功时,它们返回写入的字符;写入失败时,返回 EOF。
八、fprintf()
fprintf()
用于向文件写入格式化字符串,
用法与printf()
类似。
区别是printf()
总是写入stdout
,
而fprintf()
则是写入指定的文件,
它的第一个参数必须是一个文件指针。
它的原型定义在头文件stdio.h
。
int fprintf(FILE* stream, const char* format, ...)
fprintf()
可以替代printf()
。
printf("Hello, world!\n");
fprintf(stdout, "Hello, world!\n");
上面例子中,指定fprintf()
写入stdout
,
结果就等同于调用printf()
。
fprintf(fp, "Sum: %d\n", sum);
上面示例是向文件指针fp
写入指定格式的字符串。
下面是向stderr
输出错误信息的例子。
fprintf(stderr, "Something number.\n");
九、fscanf()
fscanf()
用于按照给定的模式,从文件中读取内容,
用法跟scanf()
类似。
区别是scanf()
总是从stdin
读取数据,
而fscanf()
是从文件读入数据,
它的原型定义在头文件stdio.h
,
第一个参数必须是文件指针。
int fscanf(FILE* stream, const char* format, ...);
下面是一个例子。
fscanf(fp, "%d%d", &i, &j);
上面示例中,fscanf()
从文件fp
里面,读取两个整数,放入变量i
和j
。
使用fscanf()
的前提是知道文件的结构,
它的占位符解析规则与scanf()
完全一致。
由于fscanf()
可以连续读取,直到读到文件尾,
或者发生错误(读取失败、匹配失败),
才会停止读取,
所以fscanf()
通常放在循环里面。
while(fscanf(fp, "%s", words) == 1)
puts(words);
上面示例中,fscanf()
依次读取文件的每个词,
将它们一行打印一个,直到文件结束。
fscanf()
的返回值是赋值成功的变量数量,
如果赋值失败会返回 EOF。
十、fgets()
fgets()
用于从文件读取指定长度的字符串,
它名字的第一个字符是f
,就代表file
。
它的原型定义在头文件stdio.h
。
char* fgets(char* str, int STRLEN, File* fp);
第一个参数str
是一个字符串指针,用于存放读取的内容。
第二个参数STRLEN
指定读取的长度,
第三个参数是一个 FILE 指针,指向要读取的文件。
fgets()
读取 STRLEN - 1 个字符之后,
或者遇到换行符与文件结尾,就会停止读取,
然后在已经读取的内容末尾添加一个空字符\0
,
使之成为一个字符串。
注意,fgets()
会将换行符(\n
)存储进字符串。
如果fgets
的第三个参数是stdin
,
就可以读取标准输入,等同于scanf()
。
fgets(str, sizeof(str), stdin);
读取成功时,
fgets()
的返回值是它的第一个参数,
即指向字符串的指针,
否则返回空指针 NULL。
fgets()
可以用来读取文件的每一行,下面是读取文件所有行的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
char s[1024]; // 数组必须足够大,足以放下一行
int linecount = 0;
fp = fopen("hello.txt", "r");
while (fgets(s, sizeof s, fp) != NULL)
printf("%d: %s", ++linecount, s);
fclose(fp);
}
上面示例中,每读取一行,都会输出行号和该行的内容。
下面的例子是循环读取用户的输入。
char words[10];
puts("Enter strings (q to quit):");
while (fgets(words, 10, stdin) != NULL) {
if (words[0] == 'q' && words[1] == '\n')
break;
puts(words);
}
puts("Done.");
上面的示例中,如果用户输入的字符串大于9个字符,
fgets()
会多次读取。
直到遇到q
+ 回车键,才会退出循环。
十一、fputs()
fputs()
函数用于向文件写入字符串,
和puts()
函数只有一点不同,
那就是它不会在字符串末尾添加换行符。
这是因为fgets()
保留了换行符,
所以fputs()
就不添加了。
fputs()
函数通常与fgets()
配对使用。
它的原型定义在stdio.h
。
int fputs(const char* str, FILE* stream);
它接受两个参数,
第一个参数是字符串指针,
第二个参数是要写入的文件指针。
如果第二个参数为stdout
(标准输出),
就是将内容输出到计算机屏幕,等同于printf()
。
char words[14];
puts("Enter a string, please.");
fgets(words, 14, stdin);
puts("This is your string:");
fputs(words, stdout);
上面示例中,先用fgets()
从stdin
读取用户输入,
然后用fputs()
输出到stdout
。
写入成功时,fputs()
返回一个非负整数,否则返回 EOF。
十二、fwrite()
fwrite()
用来一次性写入较大的数据块,
主要用途是将数组数据一次性写入文件,
适合写入二进制数据。
它的原型定义在stdio.h
。
size_t fwrite(
const void* ptr,
size_t size,
size_t nmemb,
FILE* fp
);
它接受四个参数。
ptr
:数组指针。size
:每个数组成员的大小,单位字节。nmemb
:数组成员的数量。fp
:要写入的文件指针。
注意,fwrite()
原型的第一个参数类型是void*
,
这是一个无类型指针,
编译器会自动将参数指针转成void*
类型。
正是由于fwrite()
不知道数组成员的类型,
所以才需要知道每个成员的大小(第二个参数)和成员数量(第三个参数)。
fwrite()
函数的返回值是成功写入的数组成员的数量(注意不是字节数)。
正常情况下,
该返回值就是第三个参数nmemb
,
但如果出现写入错误,
只写入了一部分成员,
返回值会比nmemb
小。
要将整个数组arr
写入文件,可以采用下面的写法。
fwrite(
arr,
sizeof(arr[0]),
sizeof(arr) / sizeof(arr[0]),
fp
);
上面示例中,sizeof(a[0])
是每个数组成员占用的字节,
sizeof(a) / sizeof(a[0])
是整个数组的成员数量。
下面的例子是将一个大小为256字节的字符串写入文件。
char buffer[256];
fwrite(buffer, 1, 256, fp);
上面示例中,数组buffer
每个成员是1个字节,
一共有256个成员。
由于fwrite()
是连续内存复制,
所以写成fwrite(buffer, 256, 1, fp)
也能达到目的。
fwrite()
没有规定一定要写入整个数组,
只写入数组的一部分也是可以的。
任何类型的数据都可以看成是1字节数据组成的数组,
或者是一个成员的数组,
所以fwrite()
实际上可以写入任何类型的数据,
而不仅仅是数组。
比如,fwrite()
可以将一个 Struct 结构写入文件保存。
fwrite(&s, sizeof(s), 1, fp);
上面示例中,s
是一个 Struct 结构指针,
可以看成是一个成员的数组。
注意,如果s
的属性包含指针,
存储时需要小心,因为保存指针可能没意义,
还原出来的时候,并不能保证指针指向的数据还存在。
fwrite()
以及后面要介绍的fread()
,比较适合读写二进制数据,
因为它们不会对写入的数据进行解读。
二进制数据可能包含空字符\0
,
这是 C 语言的字符串结尾标记,
所以读写二进制文件,
不适合使用文本读写函数(比如fprintf()
等)。
下面是一个写入二进制文件的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
unsigned char bytes[] = {5, 37, 0, 88, 255, 12};
fp = fopen("output.bin", "wb");
fwrite(bytes, sizeof(char), sizeof(bytes), fp);
fclose(fp);
return 0;
}
上面示例中,写入二进制文件时,fopen()
要使用wb
模式打开,
表示二进制写入。
fwrite()
可以把数据解释成单字节数组,
因此它的第二个参数是sizeof(char)
,
第三个参数是数组的总字节数sizeof(bytes)
。
上面例子写入的文件output.bin
,
使用十六进制编辑器打开,会是下面的内容。
05 25 00 58 ff 0c
fwrite()
还可以连续向一个文件写入数据。
struct clientData myClient = {1, 'foo bar'};
for (int i = 1; i <= 100; i++) {
fwrite(&myClient, sizeof(struct clientData), 1, cfPtr);
}
上面示例中,fwrite()
连续将100条数据写入文件。
十三、fread()
fread()
函数用于一次性从文件读取较大的数据块,
主要用途是将文件内容读入一个数组,
适合读取二进制数据。
它的原型定义在头文件stdio.h
。
size_t fread(
void* ptr,
size_t size,
size_t nmemb,
FILE* fp
);
它接受四个参数,与fwrite()
完全相同。
ptr
:数组地址。size
:每个数组成员的大小,单位为字节。nmemb
:数组的成员数量。fp
:文件指针。
要将文件内容读入数组arr
,可以采用下面的写法。
fread(
arr,
sizeof(arr[0]),
sizeof(arr) / sizeof(arr[0]),
fp
);
上面示例中,数组长度(第二个参数)和每个成员的大小(第三个参数)的乘积,
就是数组占用的内存空间的大小。
fread()
会从文件(第四个参数)里面读取相同大小的内容,
然后将ptr
(第一个参数)指向这些内容的内存地址。
下面的例子是将文件内容读入一个10个成员的双精度浮点数数组。
double earnings[10];
fread(earnings, sizeof(double), 10, fp);
上面示例中,每个数组成员的大小是sizeof(double)
,
一个有10个成员,就会从文件fp
读取sizeof(double) * 10
大小的内容。
fread()
函数的返回值是成功读取的数组成员的数量。
正常情况下,
该返回值就是第三个参数nmemb
,
但如果出现读取错误或读到文件结尾,
该返回值就会比nmemb
小。
所以,检查fread()
的返回值是非常重要的。
fread()
和fwrite()
可以配合使用。
在程序终止之前,使用fwrite()
将数据保存进文件,
下次运行时再用fread()
将数据还原进入内存。
下面是读取上一节生成的二进制文件output.bin
的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
unsigned char c;
fp = fopen("output.bin", "rb");
while (fread(&c, sizeof(char), 1, fp) > 0)
printf("%d\n", c);
return 0;
}
运行后,得到如下结果。
5
37
0
88
255
12
十四、rewind()
rewind()
函数可以让文件的内部指示器回到文件开始处。它的原型定义在stdio.h
。
void rewind(file* stream);
它接受一个文件指针作为参数。
rewind(fp)
基本等价于fseek(fp, 0l, seek_set)
,唯一的区别是rewind()
没有返回值,而且会清除当前文件的错误指示器。
十五、remove()
remove()
函数用于删除指定文件。
它的原型定义在头文件stdio.h
。
int remove(const char* filename);
它接受文件名作为参数。
如果删除成功,remove()
返回0
,否则返回非零值。
remove("foo.txt");
上面示例删除了foo.txt
文件。
注意,删除文件必须是在文件关闭的状态下。
如果是用fopen()
打开的文件,
必须先用fclose()
关闭后再删除。
十六、rename()
rename()
函数用于文件改名,也用于移动文件。
它的原型定义在头文件stdio.h
。
int rename(const char* old_filename, const char* new_filename);
它接受两个参数,
第一个参数是现在的文件名,
第二个参数是新的文件名。
如果改名成功,rename()
返回0
,否则返回非零值。
rename("foo.txt", "bar.txt");
上面示例将foo.txt
改名为bar.txt
。
注意,改名后的文件不能与现有文件同名。
另外,如果要改名的文件已经打开了,
必须先关闭,然后再改名,对打开的文件进行改名会失败。
下面是移动文件的例子。
rename("/tmp/evidence.txt", "/home/beej/nothing.txt");
综上所述,
文件重点是需要记住一些常用函数,
知道如何打开,编辑,保存,删除等等操作
其他的算法部分,
离不开之前学习内容。
虽然知识点到这里结束了,
后续开始不定期更新这些知识点的练习来加深大家的理解。
也会扩展一些好用的工具,
来方便编程和学习。