目录
《C Primer Plus》学习笔记
第13章 文件输入/输出
1.与文件进行通信
有时,需要程序从文件中读取信息或把信息写入文件。这种程序与文件交互的形式就是文件重定向(第8章介绍过)。这种方法很简单,但是有一定限制。例如,假设要编写一个交互程序,询问用户书名并把完整的书名列表保存在文件中。如果使用重定向,应该类似于:
books > bklist
用户的输入被重定向到bklist中。这样做不仅会把不符合要求的文本写入bklist,而且用户也看不到要回答什么问题。
C提供了更强大的文件通信方法,可以在程序中打开文件,然后使用特殊的I/O函数读取文件中的信息或把信息写入文件。
1.文件是什么
文件(file)通常是在磁盘或固态硬盘上的一段已命名的存储区。对我们而言,stdio.h就是一个文件的名称,该文件中包含一些有用的信息。然而,对操作系统而言,文件更复杂一些。例如,大型文件会被分开储存,或者包含一些额外的数据,方便操作系统确定文件的种类。然而,这都是操作系统所关心的,程序员关心的是C程序如何处理文件(除非你正在编写操作系统)。
C把文件看作是一系列连续的字节,每个字节都能被单独读取。这与UNIX环境中(C的发源地)的文件结构相对应。由于其他环境中可能无法完全对应这个模型,C提供两种文件模式:文本模式和二进制模式。
2.文本模式和二进制模式
首先,要区分文本内容和二进制内容、文本文件格式和二进制文件格式,以及文件的文本模式和二进制模式。
所有文件的内容都以二进制形式(0或1)储存。但是,如果文件最初使用二进制编码的字符(例如,ASCII或Unicode)表示文本(就像C字符串那样),该文件就是文本文件,其中包含文本内容。如果文件中的二进制值代表机器语言代码或数值数据(使用相同的内部表示,假设,用于long 或double类型的值)或图片或音乐编码,该文件就是二进制文件,其中包含二进制内容。
UNIX用同一种文件格式处理文本文件和二进制文件的内容。鉴于C是作为开发UNIX的工具而创建的,C和UNIX在文本中都使用\n (换行符)表示换行。UNIX目录中有一个统计文件大小的计数,程序可使用该计数确定是否读到文件结尾。然而,其他系统在此之前已经有其他方法处理文件,专门用于保存文本。也就是说,其他系统已经有一种与UNIX模型不同的格式处理文本文件。例如,以前的OS X Macintosh文件用\r(回车符)表示新的一行。早期的MS-DOS文件用\r\n组合表示新的一行,用嵌入的Ctrl+Z字符表示文件结尾,即使实际文件用添加空字符的方法使其总大小是256的倍数(在Windows中,Notepad仍然生成MS-DOS格式的文本文件,但是新的编辑器可能使用类UNIX格式居多)。其他系统可能保持文本文件中的每一行长度相同,如有必要,用空字符填充每一行, 使其长度保持一致。或者,系统可能在每行的开始标出每行的长度。
为了规范文本文件的处理,C提供两种访问文件的途径:二进制模式和文本模式。在二进制模式中,程序可以访问文件的每个字节。而在文本模式中,程序所见的内容和文件的实际内容不同。
程序以文本模式读取文件时,把本地环境表示的行末尾或文件结尾映射为C模式。例如,C程序在旧式Macintosh中以文本模式读取文件时,把文件中的\r转换成\n;以文本模式写入文件时,把\n转换成\r。或者,C文本模式程序在MS-DOS平台读取文件时,把\r\n转换成\n;写入文件时,把\n转换成\r\n。在其他环境中编写的文本模式程序也会做类似的转换。
除了以文本模式读写文本文件,还能以二进制模式读写文本文件。如果读写一个旧式MS-DOS文本文件,程序会看到文件中的\r和\n字符,不会发生映射(图13.1演示了一些文本)。如果要编写旧式Mac格式、MS-DOS格式或UNIX/Linux格式的文件模式程序,应该使用二进制模式,这样程序才能确定实际的文件内容并执行相应的动作。
C提供了二进制模式和文本模式,但是这两种模式的实现可以相同。前面提到过,因为UNIX使用一种文件格式,这两种模式对于UNIX实现而言完全相同。Linux也是如此。
3.I/O的级别
除了选择文件的模式,大多数情况下,还可以选择I/O的两个级别(即处理文件访问的两个级别)。底层I/O(low-level I/O)使用操作系统提供的基本I/O服务。标准高级I/O(standard high-level I/O)使用C库.的标准包和stdio.h头文件定义。因为无法保证所有的操作系统都使用相同的底层I/O模型,C标准只支持标准I/O包。有些实现会提供底层库,但是C标准建立了可移植的I/O模型,接下来主要讨论这些I/O。
4.标准文件
C程序会自动打开3个文件,它们被称为标准输入(standard input)、标准输出(standard output)和标准错误输出(standard error output)。在默认情况下,标准输入是系统的普通输入设备,通常为键盘;标准输出和标准错误输出是系统的普通输出设备,通常为显示屏。
通常,标准输入为程序提供输入,它是getchar()和scanf()使用的文件。程序通常输出到标准输出,它是putchar()、puts()和printf()使用的文件。第8章提到的重定向把其他文件视为标准输入或标准输出。标准错误输出提供了一个逻辑上不同的地方来发送错误消息。例如,如果使用重定向把输出发送给文件而不是屏幕,那么发送至标准错误输出的内容仍然会被发送到屏幕上。这样很好,因为如果把错误消息发送至文件,就只能打开文件才能看到。
2.标准I/O
与底层I/O相比,标准I/O包除了可移植以外还有两个好处。第一,标准I/O有许多专门的函数简化了处理不同I/O的问题。例如,printf()把不同形式的数据转换成与终端相适应的字符串输出。第二,输入和输出都是缓冲的。也就是说,一次转移一大块信息而不是一字节信息(通常至少512字节)。
例如,当程序读取文件时,一块数据被拷贝到缓冲区(一块中介存储区域)。这种缓冲极大地提高了数据传输速率。程序可以检查缓冲区中的字节。缓冲在后台处理,所以让人有逐字符访问的错觉(如果使用底层I/O,要自己完成大部分工作)。程序count.c演示了如何用标准I/O读取文件和统计文件中的字符数。该程序使用命令行参数,如果是Windows用户,在编译后必须在命令提示窗口运行该程序;或者,如第11章所述,如果在IDE中运行该程序,可以使用Xcode的Product菜单提供命令行参数。或者也可以用puts()和fgets()函数替换命令行参数来获得文件名。
1.程序count.c
/* count.c 使用标准I/O */
#include <stdio.h>
#include <stdlib.h> // exit()函数原型
int main(int argc, char *argv[])
{
int ch; // 读取文件时,存储每个字符的地方
FILE *fp; // 文件指针
unsigned long count = 0;
if (argc != 2)
{
printf("Usage: %s filename\n", argv[0]);
exit(EXIT_FAILURE);
}
if ((fp = fopen(argv[1], "r")) == NULL)//打开文件,r表示读模式
{
printf("Can't open %s\n", argv[1]);
exit(EXIT_FAILURE);
}
while ((ch = getc(fp)) != EOF)
{
putc(ch,stdout); //与putchar(ch)相同
count++;
}
fclose(fp);//关闭文件
printf("File %s has %lu characters\n", argv[1], count);
return 0;
}
1.检查命令行参数
首先,程序count.c中的程序检查argc的值,查看是否有命令行参数。如果没有,程序将打印一条消息并退出程序。字符串**argv[0]**是该程序的名称。显式使用argv[0]而不是程序名,错误消息的描述会随可执行文件名的改变而自动改变。这一特性在像UNIX这种允许单个文件具有多个文件名的环境中也很方便。但是,一些操作系统可能不识别argv[0],所以这种用法并非完全可移植。
exit()函数关闭所有打开的文件并结束程序。exit()的参数被传递给一些操作系统,包括UNIX、Linux、Windows和MS-DOS,以供其他程序使用。通常的惯例是:正常结束的程序传递0,异常结束的程序传递非零值。不同的退出值可用于区分程序失败的不同原因,这也是UNIX和DOS编程的通常做法。但是,并不是所有的操作系统都能识别相同范围内的返回值。因此,C标准规定了一个最小的限制范围。尤其是,标准要求0或宏EXIT_SUCCESS用于表明成功结束程序,宏EXIT_FAILURE用于表明结束程序失败。这些宏和exit()原型都位于stdlib.h头文件中。
根据ANSI C的规定,在最初调用的main()中使用return与调用exit()的效果相同。因此,在main(),下面的语句:
return 0;
和下面这条语句的作用相同:
exit(0);
但是要注意,我们说的是“最初的调用”。如果main()在一个递归程序中,exit()仍然会终止程序,但是return只会把控制权交给上一级递归,直至最初的一级。然后return结束程序。return和exit()的另一个区别是,即使在其他函数中(除main()以外)调用exit()也能结束整个程序。
2.fopen()函数
继续分析程序count.c,该程序使用fopen()函数打开文件。该函数声明在stdio.h中。它的第1个参数是待打开文件的名称,更确切地说是一个包含该文件名的字符串地址。第2个参数是一个字符串,指定待打开文件的模式。表13.1列出了C库提供的一些模式。
像UNIX和Linux这样只有一种文件类型的系统,带b字母的模式和不带b字母的模式相同。
新的C11新增了带x字母的写模式,与以前的写模式相比具有更多特性。第一,如果以传统的一种写模式打开一个现有文件,fopen()会把文件的长度截为0,这样就丢失了该文件的内容。但是使用带x字母的写模式,即使fopen()操作失败,原文件的内容也不会被删除。第二,如果环境允许,x模式的独占特性使得其他程序或线程无法访问正在被打开的文件。
警告:
如果使用任何一种"w"模式(不带x字母)打开一个现有文件,该文件的内容会被删除,以便程序在一个空白文件中开始操作。然而,如果使用带x字母的任何一种模式,将无法打开一个现有文件。程序成功打开文件后,fopen()将返回文件指针(file pointer),其他I/O函数可以使用这个指针指定该文件。文件指针(该例中是fp)的类型是指向FILE的指针,FILE是一个定义在stdio.h中的派生类型。文件指针fp并不指向实际的文件,它指向一个包含文件信息的数据对象,其中包含操作文件的I/O函数所用的缓冲区信息。因为标准库中的I/O函数使用缓冲区,所以它们不仅要知道缓冲区的位置,还要知道缓冲区被填充的程度以及操作哪一个文件。标准I/O函数根据这些信息在必要时决定再次填充或清空缓冲区。fp指向的数据对象包含了这些信息(该数据对象是一个C结构,将在第14章中介绍)。
3.getc()和putc()函数
getc()和putc()函数与getchar()和putchar()函数类似。所不同的是,要告诉getc()和putc()函数使用哪一个文件。下面这条语句的意思是“从标准输入中获取一个字符”:
ch = getchar();
然而,下面这条语句的意思是“从fp指定的文件中获取一个字符”:
ch = getc(fp);
与此类似,下面语句的意思是“把字符ch放入FILE指针fpout指定的文件中”:
putc(ch,fpout);
在putc()函数的参数列表中,第1个参数是待写入的字符,第2个参数是文件指针。
程序count.c把stdout作为putc()的第2个参数。stdout作为与标准输出相关联的文件指针,定义在stdio.h中,所以putc(ch,stdout)与putchar(ch)的作用相同。实际上,putchar()函数一般通过putc()来定义。与此类似,getchar()也通过使用标准输入的getc()来定义。
4.文件结尾
从文件中读取数据的程序在读到文件结尾时要停止。如何告诉程序已经读到文件结尾?如果getc()函数在读取一个字符时发现是文件结尾,它将返回一个特殊值EOF。所以C程序只有在读到超过文件末尾时才会发现文件的结尾(一些其他语言用一个特殊的函数在读取之前测试文件结尾,C语言不同)。
为了避免读到空文件,应该使用入口条件循环(不是do while循环)进行文件输入。鉴于getc()(和其他C输入函数)的设计,程序应该在进入循环体之前先尝试读取。如下面设计所示:
//设计范例#1
int ch;//用int类型的变量储存EOF
FILE * fp;
fp = fopen("wacky.txt", "r");
ch = getc(fp);//获取初始输入
while(ch != EOF)
{
putchar(ch); //处理输入
ch = getc(fp);//获取下一个输入
}
以上代码可简化为:
//设计范例#2
int ch;
FILE * fp;
fp = fopen("wacky.txt", "r");
while (( ch = getc(fp)) != EOF)
{
putchar(ch); //处理输入
}
由于ch = getc(fp)是while测试条件的一部分,所以程序在进入循环体之前就读取了文件。
其他输入函数也会用到这种处理方案,它们在读到文件结尾时也会返回一个错误信号(EOF或NULL指针)。
5.fclose()函数
fclose(fp)函数关闭fp指定的文件,必要时刷新缓冲区。对于较正式的程序,应该检查是否成功关闭文件。如果成功关闭,fclose()函数返回0,否则返回EOF:
if (fclose(fp) != 0)
printf("Error in closing file &s \n", argv[1]);
如果磁盘已满、移动硬盘被移除或出现I/O错误,都会导致调用fclose()函数失败。
6.指向标准文件的指针
stdio.h头文件把3个文件指针与3个标准文件相关联,C程序会自动打开这3个标准文件。如表13.2所示:
这些文件指针都是指向FILE的指针,所以它们可用作标准I/O函数的参数,如fclose(fp)中的fp。
7.count.c输出示例
C-Free编译运行后输出:
Usage: D:\桌面\C\第十三章\count.exe filename
在count.c的目录下创建一个1.txt文件,其中随意存储一些信息,在该目录下运行cmd,输入count.exe 1.txt,得到结果:
D:\桌面\C\第十三章>count 1.txt
hello
open
File 1.txt has 11 characters
open后有个换行符,所以文件一共有11个字符。
3.一个简单的文件压缩程序
下面的程序示例把一个文件中选定的数据拷贝到另一个文件中。该程序同时打开了两个文件,以"r"模式打开一个,以"w"模式打开另一个。程序reducto.c以保留每3个字符中的第1个字符的方式压缩第1个文件的内容。最后,把压缩后的文本存入第2个文件。第2个文件的名称是第1个文件名加上.red后缀(此处的red代表reduced)。使用命令行参数,同时打开多个文件,以及在原文件名后面加上后缀,都是相当有用的技巧。这种压缩方式有限,但是也有它的用途(很容易把该程序改成用标准I/O而不是命令行参数提供文件名)。
1.程序reducto.c
// reducto.c -- 把文件压缩成原来的1/3
#include <stdio.h>
#include <stdlib.h> // exit()原型
#include <string.h> // strcpy(), strcat()原型
#define LEN 40
int main(int argc, char *argv[])
{
FILE *in, *out; // 声明两个指向FILE的指针
int ch;
char name[LEN]; // 存储输出文件名
int count = 0;
// 检查命令行参数
if (argc < 2)
{
fprintf(stderr, "Usage: %s filename\n", argv[0]);
exit(EXIT_FAILURE);
}
// 设置输入 eddy
if ((in = fopen(argv[1], "r")) == NULL)
{
fprintf(stderr, "I couldn't open the file \"%s\"\n",
argv[1]);
exit(EXIT_FAILURE);
}
// 设置输出 eddy.red
strncpy(name,argv[1], LEN - 5); // 拷贝文件名
name[LEN - 5] = '\0';
strcat(name,".red"); // 在文件名后面添加.add
if ((out = fopen(name, "w")) == NULL)
{
// 写模式打开文件
fprintf(stderr,"Can't create output file.\n");
exit(3);
}
// 拷贝数据
while ((ch = getc(in)) != EOF)
if (count++ % 3 == 0)
putc(ch, out); //打印3个字符中的第1个字符
// 收尾工作,关闭文件
if (fclose(in) != 0 || fclose(out) != 0)
fprintf(stderr,"Error in closing files\n");
return 0;
}
在C-Free中编译运行,输出如下:
Usage: D:\桌面\C\第十三章\reducto.exe filename
假设可执行文件名是reducto,待读取的文件名为eddy(可以使用Notepad++打开该文件,这两个文件在同一目录下),该文件中包含下面一行内容:
So even Eddy came oven ready.
在目录下运行cmd,命令如下:
reducto eddy
待写入的文件名为eddy.red(同一目录)。该程序把输出显示在eddy.red中,而不是屏幕上。打开eddy.red, 内容如下:
Send money
fprintf()和printf()类似,但是fprintf()的第1个参数必须是一个文件指针。程序中使用stderr指针把错误消息发送至标准错误,C标准通常都这么做。
为了构造新的输出文件名,该程序使用strncpy()把名称eddy拷贝到数组name中。参数LEN-5为.red后缀和末尾的空字符预留了空间。如果argv[2]字符串比LEN-5长,就拷贝不了空字符。出现这种情况时,程序会添加空字符。调用strncpy()后,name中的第1个空字符在调用strcat()函数时,被.red的.覆盖,生成了eddy.red。程序中还检查了是否成功打开名为eddy.red的文件。这个步骤在一些环境中相当重要,因为像strange.c.red这样的文件名可能是无效的。例如,在传统的DOS环境中,不能在后缀名后面添加后缀名(MS-DOS使用的方法是用. red替换现有后缀名,所以strange.c将变成strange.red。例如,可以用strchr()函数定位(如果有的话),然后只拷贝点前面的部分即可)。
该程序同时打开了两个文件,所以要声明两个FIFL指针。注意,程序都是单独打开和关闭每个文件。同时打开的文件数量是有限的,这个限制取决于系统和实现,范围一般是10~20。相同的文件指针可以处理不同的文件,前提是这些文件不需要同时打开。
4.文件I/O:fprintf()、fscanf()、fgets()和fputs()
前面章节介绍的I/O函数都类似于文件I/O函数。它们的主要区别是,文件I/O函数要用FILE指针指定待处理的文件。与getc()、putc()类似,这些函数都要求用指向FILE的指针(如,stdout) 指定一个文件,或者使用fopen()的返回值。
1.fprintf()和fscanf()函数
文件I/O函数fprintf()和fscanf()函数的工作方式与printf()和scanf()类似,区别在于前者需要用第1个参数指定待处理的文件。在前面用过fprintf()。程序addaword.c演示了这两个文件I/O函数和rewind()函数的用法。
1.程序addaword.c
/* addaword.c -- 使用fprintf(),fscanf(),和rewind() */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX 41
int main(void)
{
FILE *fp;
char words[MAX];
if ((fp = fopen("wordy", "a+")) == NULL)
{
fprintf(stdout,"Can't open \"wordy\" file.\n");
exit(EXIT_FAILURE);
}
puts("Enter words to add to the file; press the #");
puts("key at the beginning of a line to terminate.");
while ((fscanf(stdin,"%40s", words) == 1) && (words[0] != '#'))
fprintf(fp, "%s\n", words);
puts("File contents:");
rewind(fp); /* 返回到文件开始处 */
while (fscanf(fp,"%s",words) == 1)
puts(words);
puts("Done!");
if (fclose(fp) != 0)
fprintf(stderr,"Error closing file\n");
return 0;
}
该程序可以在文件中添加单词。使用"a+"模式, 程序可以对文件进行读写操作。首次使用该程序,它将在addaword.c所在的目录下创建wordy文件,以便把单词存入其中。
第一次编译运行输出示例:
Enter words to add to the file; press the #
key at the beginning of a line to terminate.
hello who
are you
#
File contents:
hello
who
are
you
Done!
随后再使用该程序,可以在wordy文件后面添加单词。虽然"a+"模式只允许在文件末尾添加内容,但是该模式下可以读整个文件。rewind()函数让程序回到文件开始处,方便while循环打印整个文件的内容。注意,rewind()接受一个文件指针作为参数。第二次编译运行输出示例:
Enter words to add to the file; press the #
key at the beginning of a line to terminate.
i am fine
#
File contents:
hello
who
are
you
i
am
fine
Done!
2.fgets()和fputs()函数
第11章时介绍过fgets()函数。它的第1个参数和gets()函数元一样,也是表示储存输入位置的地址(char *类型);第2个参数是一个整数,表示待输入字符串的大小(字符串大小和字符串长度不同。前者指该字符串占用多少空间,后者指该字符串的字符个数),最后一个参数是文件指针,指定待读取的文件。下面是一个调用该函数的例子:
cfgets(buf, STLEN, fp);
这里,buf是char类型数组的名称,STLEN 是字符串的大小,fp是指向FILE的指针。
fgets()函数读取输入直到第1个换行符的后面,或读到文件结尾,或者读取STLEN-1个字符(以上面的fgets()为例)。然后,fgets()在末尾添加一个空字符使之成为一个字符串。字符串的大小是其字符数加上一个空字符。如果fgets()在读到字符上限之前已读完一整行,它会把表示行结尾的换行符放注在空字符前面。fgets()函数在遇到EOF时将返回NULL值,可以利用这一机制检查是否到达文件结尾;如果未遇到EOF则之前返回传给它的地址。
fputs()函数接受两个参数:第1个是字符串的地址;第2个是文件指针。该函数根据传入地址找到的字符串写入指定的文件中。和puts()函数不同,fputs()在打印字符串时不会在其末尾添加换行符。
下面是一个调用该函数的例子:
fputs(buf, fp);
这里,buf是字符串的地址,fp用于指定目标文件。
由于fgets()保留了换行符,fputs()就不会再添加换行符。如第11章的程序fgets2.c所示,即使输入行比STLEN长,这两个函数依然处理得很好。
5.随机访问: fseek()和ftell()
有了fseek()函数,便可把文件看作是数组,在fopen()打开的文件中直接移动到任意字节处。创建一个程序reverse.c演示fseek()和ftell()的用法。注意,fseek()有3个参数,返回int类型的值; ftell()函数返回一个long类型的值,表示文件中的当前位置。
1.程序reverse.c
/* reverse.c -- 倒序显示文件的内容 */
#include <stdio.h>
#include <stdlib.h>
#define CNTL_Z '\032' /* DOS文本文件中的文件标记结尾 */
#define SLEN 81
int main(void)
{
char file[SLEN];
char ch;
FILE *fp;
long count, last;
puts("Enter the name of the file to be processed:");
scanf("%80s", file);//2.txt
if ((fp = fopen(file,"rb")) == NULL)
{
/* 二进制只读模式 */
printf("reverse can't open %s\n", file);
exit(EXIT_FAILURE);
}
fseek(fp, 0L, SEEK_END); /* 定位到文件末尾 */
last = ftell(fp);
for (count = 1L; count <= last; count++)
{
fseek(fp, -count, SEEK_END); /* 回退 */
ch = getc(fp);
if (ch != CNTL_Z && ch != '\r') /* MS-DOS文件 */
putchar(ch);
}
putchar('\n');
fclose(fp);
return 0;
}
输出示例:
Enter the name of the file to be processed:
2.txt
reverse can't open 2.txt
必须在目录下创建2.txt才行,因为二进制只读模式不会创建文件。创建2.txt,文件内容为:this is reverse.c。运行程序,输出示例:
Enter the name of the file to be processed:
2.txt
c.esrever si siht
该程序使用二进制模式,以便处理MS-DOS文本和UNIX文件。但是,在使用其他格式文本文件的环境中可能无法正常工作。
注意:
如果通过命令行环境运行该程序,待处理文件要和可执行文件在同一个目录(或文件夹)中。如果在IDE中运行该程序,具体查找方案序因实现而异。例如,默认情况下,Microsoft Visual Studio 2012在源代码所在的目录中查找,而Xcode4.6则在可执行文件所在的目录中查找。
2.fseek()和ftell()的工作原理
fseek()的第1个参数是FILE指针,指向待查找的文件,fopen()应该已打开该文件。fseek()的第2个参数是偏移量(ffset)。 该参数表示从起始点开始要移动的距离(参见表13.3列出的起始点模式)。该参数必须是一个long类型的值,可以为正(前移)、负(后移)或0(保持不动)。fseek()的第3个参数是模式,该参数确定起始点。根据ANSI标准,在stdio.h头文件中规定了几个表示模式的明示常量(manifest constant),如表13.3所示。
旧的实现可能缺少这些定义,可以使用数值0L、1L、2L分别表示这3种模式。L后缀表明其值是long类型。或者,实现可能把这些明示常量定义在别的头文件中。如果不确定,请查阅实现的使用手册或在线帮助。
下面是调用fseek()函数的一些示例,fp是一个文件指针:
fseek(fp, 0L, SEEK_SET);//定位至文件开始处
fseek(fp, 10L, SEEK_SET); //定位至文件中的第10个字节
fseek(fp, 2L, SEEK_CUR); //从文件当前位置前移2个字节
fseek(fp, 0L, SEEK_END);//定位至文件结尾
fseek(fp, -10L, SEEK_END); //从文件结尾处回退10个字节
如果一切正常,fseek()的返回值为0;如果出现错误(如试图移动的距离超出文件的范围),其返回值为-1。
ftell()函数的返回类型是long,它返回的是当前的位置。ANSI C把它定义在stdio.h中。在最初实现的UNIX中,ftell()通过返回距文件开始处的字节数来确定文件的位置。文件的第1个字节到文件开始处的距离是0,以此类推。ANSIC规定,该定义适用于以二进制模式打开的文件,以文件模式打开文件的情况不同。这也是程序reverse.c以二进制模式打开文件的原因。
下面来分析程序reverse.c中的基本要素。首先,下面的语句:
fseek(fp, 0L, SEEK_END);
把当前位置设置为距文件末尾0字节偏移量。也就是说,该语句把当前位置设置在文件结尾。下一条语句:
last = ftell(fp);
把从文件开始处到文件结尾的字节数赋给last。
然后是一个for循环:
For (count = 1L; count <= last; count++)
{
fseek(fp, -count, SEEK_END); /* go backward */
ch = getc(fp);
}
第1轮迭代,把程序定位到文件结尾的第1个字符(即,文件的最后一个字符)。然后,程序打印该字符。下一轮迭代把程序定位到前一个字符,并打印该字符。重复这一过程直至到达文件的第1个字符,并打印。
3.二进制模式和文本模式
程序reverse.c在UNIX和MS-DOS环境下都可以运行。UNIX只有一种文件格式,所以不需要进行特殊的转换。然而MS-DOS要格外注意。许多MS-DOS编辑器都用Ctrl+Z标记文本文件的结尾。
以文本模式打开这样的文件时,C能识别这个作为文件结尾标记的字符。但是,以二进制模式打开相同的文件时,Ctrl+Z字符被看作是文件中的一个字符,而实际的文件结尾符在该字符的后面。文件结尾符可能紧跟在Ctrl+Z字符后面,或者文件中可能用空字符填充,使该文件的大小是256的倍数。在DOS环境下不会打印空字符,程序reverse.c中就包含了防止打印Ctrl+Z字符的代码。
二进制模式和文本模式的另一个不同之处是: MS-DOS用\r\n组合表示文本文件换行。以文本模式打开相同的文件时,C程序把\r\n“看成”\n。但是,以二进制模式打开该文件时,程序能看见这两个字符。
因此,程序reverse.c中还包含了不打印\r的代码。通常,UNIX文本文件既没有Ctrl+Z,也没有\r,所以这部分代码不会影响大部分UNIX文本文件。
ftell()函数在文本模式和二进制模式中的工作方式不同。许多系统的文本文件格式与UNIX的模型有很大不同,导致从文件开始处统计的字节数成为一个毫无意义的值。ANSIC规定,对于文本模式,ftell()返回的值可以作为fseek()的第2个参数。对于MS-DOS,ftell()返回的值把\r\n当作一个字节计数。
4.可移植性
理论上,fseek()和ftell()应该符合UNIX模型。但是,不同系统存在着差异,有时确实无法做到与UNIX模型一致。因此,ANSI对这些函数降低了要求。下面是一些限制。
1.在二进制模式中,实现不必支持SEEK_END模式。因此无法保证程序reverse.c的可移植性。移植性更高的方法是逐字节读取整个文件直到文件末尾。C预处理器的条件编译指令(第16章介绍)提供了一种系统方法来处理这种情况。
2.在文本模式中,只有以下调用能保证其相应的行为。
不过,许多常见的环境都支持更多的行为。
5.fgetpos()和fsetpos()函数
fseek()和ftell()潜在的问题是,它们都把文件大小限制在long类型能表示的范围内。也许20亿字节看起来相当大,但是随着存储设备的容量迅猛增长,文件也越来越大。鉴于此,ANSI C新增了两个处理较大文件的新定位函数: fgetpos()和fsetpos()。这两个函数不使用long类型的值表示位置,它们使用一种新类型: **fpos_t(**代表file position type, 文件定位类型)。fpos_t类型不是基本类型,它根据其他类型来定义。fpos_t类型的变量或数据对象可以在文件中指定一个位置,它不能是数组类型,除此之外,没有其他限制。实现可以提供一个满足特殊平台要求的类型,例如,fpos_t可以实现为结构。
ANSIC定义了如何使用fpos_t类型。fgetpos()函数的原型如下:
int fgetpos(FILE * restrict stream, fpos_t * restrict pos);
调用该函数时,它把fpos_t类型的值放在pos指向的位置上,该值描述了文件中的一个位置。如果成功,fgetpos()函数返回0;如果失败,返回非0。
fsetpos()函数的原型如下:
int fsetpos(FILE *stream, const fpos_t *pos) ;
调用该函数时,使用pos指向位置上的fpos_t类型值来设置文件指针指向该值指定的位置。如果成功,fsetpos()函数返回0;如果失败,则返回非0。fpos_t类型的值应通过之前调用fgetpos()获得。
6.标准I/O的机理
前面学习了标准I/O包的特性,本节研究一个典型的概念模型,分析标准I/O的工作原理。
1.通常,使用标准I/O的第1步是调用fopen()打开文件(前面介绍过,C程序会自动打开3种标准文件。fopen()函数不仅打开一个文件,还创建了一个缓冲区(在读写模式下会创建两个缓冲区)以及一个包含文件和缓冲区数据的结构。另外,fopen()返回一个指向该结构的指针,以便其他函数知道如何找到该结构。假设把该指针赋给一个指针变量fp,就说fopen()函数“打开一个流”。如果以文本模式打开该文件,就获得一个文本流;如果以二进制模式打开该文件,就获得一个二进制流。
这个结构通常包含一个指定流中当前位置的文件位置指示器。除此之外,它还包含错误和文件结尾的指示器、一个指向缓冲区开始处的指针、一个文件标识符和一个计数(统计实际拷贝进缓冲区的字节数)。
2.我们主要考虑文件输入。通常,使用标准I/O的第2步是调用一个定义在stdio.h中的输入函数,如fscanf()、getc()或fgets()。 一调用这些函数,文件中的数据块就被拷贝到缓冲区中。缓冲区的大小因实现而异,一般是512字节或是它的倍数,如4096或16384(随着计算机硬盘容量越来越大,缓冲区的大小也越来越大)。最初调用函数,除了填充缓冲区外,还要设置fp所指向的结构中的值。尤其要设置流中的当前位置和拷贝进缓冲区的字节数。通常,当前位置从字节0开始。
3.在初始化结构和缓冲区后,输入函数按要求从缓冲区中读取数据。在它读取数据时,文件位置指示器被设置为指向刚读取字符的下一个字符。由于stdio.h系列的所有输入函数都使用相同的缓冲区,所以调用任何一个函数都将从上一次函数停止调用的位置开始。
当输入函数发现已读完缓冲区中的所有字符时,会请求把下一个缓冲大小的数据块从文件拷贝到该缓冲区中。以这种方式,输入函数可以读取文件中的所有内容,直到文件结尾。函数在读取缓冲区中的最后一个字符后,把结尾指示器设置为真。于是,下一次被调用的输入函数将返回EOF。输出函数以类似的方式把数据写入缓冲区。当缓冲区被填满时,数据将被拷贝至文件中。
7.其他标准I/O函数
ANSI标准库的标准I/O系列有几十个函数。这里列出函数的原型,表明函数的参数和返回类型。我们要讨论的这些函数,除了setvbuf(),其他函数均可在ANSI之前的实现中使用。
1.int ungetc(int c, FILE *fp)函数
int ungetc()函数把c指定的字符放回输入流中。如果把一个字符放回输入流,下次调用标准输入函数时将读取该字符(见图13.2)。例如,假设要读取下一个冒号之前的所有字符,但是不包括冒号本身,可以使用getchar() 或getc()函数读取字符到冒号,然后使用ungetc()函数把冒号放回输入流中。ANSI C标准保证每次只会放回一个字符。如果实现允许把一行中的多个字符放回输入流,那么下一次输入函数读入的字符顺序与放回时的顺序相反。
2.int fflush(FILE *fp)函数
调用fflush()函数引起输出缓冲区中所有的未写入数据被发送到fp指定的输出文件。这个过程称为刷新缓冲区。如果fp是空指针,所有输出缓冲区都被刷新。在输入流中使用fflush()函数的效果是未定义的。只要最近一次操作不是输入操作,就可以用该函数来更新流(任何读写模式)。
3.int setvbuf()函数
int setvbuf(FILE * restrict fp, char * restrict buf, int mode, size_t size);
setvbuf()函数创建了一个供标准I/O函数替换使用的缓冲区。在打开文件后且未对流进行其他操作之前,调用该函数。指针fp识别待处理的流,buf指向待使用的存储区。如果buf的值不是NULL,则必须创建一个缓冲区。例如,声明一个内含1024个字符的数组,并传递该数组的地址。然而,如果把NULL作为buf的值,该函数会为自己分配一个缓冲区。变量size告诉setvbuf()数组的大小(size_t是一种派生的整数类型,第5章介绍过)。mode的选择如下:_ IOFBF表示完全缓冲(在缓冲区满时刷新);_ IOLBF表示行缓冲(在缓冲区满时或写入一个换行符时);_IONBF表示无缓冲。如果操作成功,函数返回0,否则返回一个非零值。
假设一个程序要储存一种数据对象,每个数据对象的大小是3000字节。可以使用setvbuf()函数创建一个缓冲区,其大小是该数据对象大小的倍数。
4.二进制I/O:fread()和fwrite()
之前用到的标准I/O函数都是面向文本的,用于处理字符和字符串。如何要在文件中保存数值数据?用fprintf()函数和%f转换说明只是把数值保存为字符串。例如,下面的代码:
double num = 1./3;
fprintf(fp, "%f", num);
把num储存为8个字符:0.333333。使用%.2f转换说明将其储存为4个字符:0.33,用%.12f转换说明则将其储存为14个字符: 0.3333333333。改变转换说明将改变储存该值所需的空间数量,也会导致储存不同的值。把num储存为0.33后,读取文件时就无法将其恢复为更高的精度。一般而言**,fprintf()把数值转换为字符数据,这种转换可能会改变值**。
为保证数值在储存前后一致,最精确的做法是使用与计算机相同的位组合来储存。因此,double类型的值应该储存在一个double大小的单元中。如果以程序所用的表示法把数据储存在文件中,则称以二进制形式储存数据。不存在从数值形式到字符串的转换过程。对于标准I/O,fread()和fwrite()函数用于以二进制形式处理数据(见图13.3)。
实际上,所有的数据都是以二进制形式储存的,甚至连字符都以字符码的二进制表示来储存。如果文件中的所有数据都被解释成字符码,则称该文件包含文本数据。如果部分或所有的数据都被解释成二进制形式的数值数据,则称该文件包含二进制数据(另外,用数据表示机器语言指令的文件都是二进制文件)。
二进制和文本的用法很容易混淆。ANSI C和许多操作系统都识别两种文件格式:二进制和文本。能以二进制数据或文本数据形式存储或读取信息。可以用二进制模式打开文本格式的文件,可以把文本储存在二进制形式的文件中。可以调用getc()拷贝包含二进制数据的文件。然而,一般而言,用二进制模式在二进制格式文件中储存二进制数据。类似地,最常用的还是以文本格式打开文本文件中的文本数据(通常文字处理器生成的文件都是二进制文件,因为这些文件中包含了大量非文本信息,如字体和格式等)。
5.size_t fwrite()函数
size_t fwrite(const void * restrict ptr, size_t size, size_t nmemb, FILE * restrict fp);
fwrite()函数把二进制数据写入文件。size_t是根据标准C类型定义的类型,它是sizeof运算符返回的类型,通常是unsigned int,但是实现可以选择使用其他类型。指针ptr是待写入数据块的地址。**size表示待写入数据块的大小(**以字节为单位),nmemb表示待写入数据块的数量。和其他函数一样,fp指定待写入的文件。例如,要保存一个大小为256字节的数据对象(如数组),可以这样做:
char buffer[256];
fwrite(buffer, 256, 1, fp);
以上调用把一块256字节的数据从buffer写入文件。另举一例,要保存一个内含10个double类型值的数组,可以这样做:
double earnings[10];
fwrite(earnings, sizeof (double), 10, fp);
以上调用把earnings数组中的数据写入文件,数据被分成10块,每块都是double的大小。
注意fwrite()原型中的const void * restrict ptr声明。fwrite()的一个问题是,它的第1个参数不是固定的类型。例如,第1个例子中使用buffer,其类型是指向char的指针;而第2个例子中使用earnings,其类型是指向double的指针。在ANSIC函数原型中,这些实际参数都被转换成指向void的指针类型,这种指针可作为一种通用类型指针(在ANSI C之前,这些参数使用char *类型,需要把实参强制转换成char *类型).
fwrite()函数返回成功写入项的数量。正常情况下,该返回值就是nmemb, 但如果出现写入错误,返回值会比nmemb小。
6.size_t fread()函数
size_t fread(void * restrict ptr, size_t size, size_t nmemb, FILE * restrict fp);
fread()函数接受的参数和fwrite()函数相同。在fread()函数中,ptr是待读取文件数据在内存中的地址,fp指定待读取的文件。该函数用于读取被fwrite()写入文件的数据。例如,要恢复上例中保存的内含10个double类型值的数组,可以这样做:
double earnings[10];
fread(earnings, sizeof (double), 10, fp);
该调用把10个double大小的值拷贝进earnings数组中。
fread()函数返回成功读取项的数量。正常情况下,该返回值就是nmemb,但如果出现读取错误或读到文件结尾,该返回值就会比nmemb小。
7.int feof(FILE *fp)和int ferror(FILE *fp)函数
如果标准输入函数返回EOF,则通常表明函数已到达文件结尾。然而,出现读取错误时,函数也会返回EOF。feof()和ferror()函数用于区分这两种情况。当上一次输入调用检测到文件结尾时,feof()函数返回一个非零值,否则返回0。当读或写出现错误,ferror()函数返回一个非零值,否则返回0。
8.一个程序示例
用一个程序示例说明这些函数的用法。该程序把一系列文件中的内容附加在另一个文件的末尾。该程序存在一个问题:如何给文件传递信息。可以通过交互或使用命令行参数来完成,先采用交互式的方法。下面列出了程序的设计方案。
1.询问目标文件的名称并打开它。
2.使用一个循环询问源文件。
3.以读模式依次打开每个源文件,并将其添加到目标文件的末尾。
为演示setvbuf()函数的用法,该程序将使用它指定一个不同的缓冲区大小。下一步是细化程序打开目标文件的步骤:
1.以附加模式打开目标文件;
2.如果打开失败,则退出程序;
3.为该文件创建一个4096字节的缓冲区;
4.如果创建失败,则退出程序。
与此类似,通过以下具体步骤细化拷贝部分:
1.如果该文件与目标文件相同,则跳至下一个文件;
2.如果以读模式无法打开文件,则跳至下一个文件;
3.把文件内容添加至目标文件末尾。
最后,程序回到目标文件的开始处,显示当前整个文件的内容。
使用fread()和fwrite()函数进行拷贝。程序append.c给出了这个程序。
1.程序append.c
/* append.c -- 把文件附加到另一个文件末尾 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define BUFSIZE 4096
#define SLEN 81
void append(FILE *source, FILE *dest);
char * s_gets(char * st, int n);
int main(void)
{
FILE *fa, *fs; // fa指向目标文件,fs指向源文件
int files = 0; // 附加的文件数量
char file_app[SLEN]; // 目标文件名
char file_src[SLEN]; // 源文件名
int ch;
puts("Enter name of destination file:");
s_gets(file_app, SLEN);
if ((fa = fopen(file_app, "a+")) == NULL)
{
fprintf(stderr, "Can't open %s\n", file_app);
exit(EXIT_FAILURE);
}
if (setvbuf(fa, NULL, _IOFBF, BUFSIZE) != 0)
{
//文件流+缓冲区
fputs("Can't create output buffer\n", stderr);
exit(EXIT_FAILURE);
}
puts("Enter name of first source file (empty line to quit):");
while (s_gets(file_src, SLEN) && file_src[0] != '\0')
{
if (strcmp(file_src, file_app) == 0)
fputs("Can't append file to itself\n",stderr);
else if ((fs = fopen(file_src, "r")) == NULL)
fprintf(stderr, "Can't open %s\n", file_src);
else
{
if (setvbuf(fs, NULL, _IOFBF, BUFSIZE