12.1 面向文件的输入与输出
从某种意义上来说,计算机是处理输入产生输出的电子设备。完成这些工作离不开软件,因此,程序必然要面对输入与输出问题。
C语言本身并没有输入、输出的功能,C程序的输入输出一般是通过调用库函数完成的。在多数应用程序中,最常用的输入输出函数有printf()、scanf()等函数。
在前面各章的代码中,scanf()函数处理的输入数据大部分都来自键盘,printf()函数输出的数据流向显示器。用C语言的行话来说,这两个函数处理的数据从“stdin”流入和向“stdout”流出。“stdin”的数据一般是从键盘流入的,“stdout”通常是流向显示器的。
很显然,键盘不适宜大量数据的输入;显示器并不具备保存数据的能力。在7.4.4小节中可以看到,C代码可以通过freopen()函数实现把“stdin”的流入方向或“stdout”的流出方向重定向为某个磁盘上的文件。
用重定向的方法读写文件的好处是非常简单容易,然而这并不是最通用的做法,以这种方式实现面向文件的输入与输出事实上有许多限制。比如,用重定向的方法无法实现从多个文件中同时读入数据,也无法完成把数据写入多个文件,甚至无法同时读写同一个文件。
本节介绍一些代码实现面向文件输入输出的一般方法。
12.1.1 把程序输出写入文件
把程序输出写入文件,可以通过fprintf()函数实现。在C99中这个函数的原型是:
其中的“format”参数与printf()函数原型“int printf(const char * restrict format,…);”中“format”参数的写法一致,即表示输出的内容和格式要求。因此在使用方法上,两者也非常相似。只不过使用fprintf()函数比使用printf()函数要多两道手续。具体的步骤如下。
(1)打开文件。
(2)(向文件)输出。
(3)关闭文件。
在代码中的主要区别如图12-1所示。
图12-1 printf()与fprintf()使用的异同
1.什么是打开文件
所谓“打开”(Open),在计算机语境中的含义一般是指,打开内存与外存或其他外部设备之间的联系通道,容许彼此之间进行数据交换。
对于应用程序来说,打开文件意味着建立程序与文件之间的联系,并获得使用文件的手段,这个过程是通过函数调用在操作系统的协助下完成的。
具体来说,打开文件之后,应用程序来将获得一个可以用来进行读或写文件操作的指针。程序可以通过这个指针读写文件,其余的事情由标准库函数和操作系统共同完成。
2.打开的准备
程序所使用的对文件进行读写操作的指针类型是“FILE ”。在使用这个指针之前,首先需要为这个指针提供存储空间,因此正式打开文件前,通常要定义一个“FILE ”类型的变量,例如
这种指针的俗称是“指向文件的指针”,尽管这个指针根本不指向文件(1),而是指向一个“FILE”类型的数据。
“FILE”是一个自定义的结构体类型,这个类型的定义写在“stdio.h”中,因此在操作文件之前还必须在代码开头的部分加上预处理命令“#include <stdio.h>”(好在我们一直是这样做的)。
在不同的环境下,FILE的具体结构可能是不同的,下面是Dev C++中“FILE”类型的定义。
3.fopen()函数
C语言中打开文件可以通过调用fopen()函数完成。fopen()的功能是建立为文件读写所必需的“FILE”类型的数据,为文件的读写做必要的准备,然后返回给程序指向建立好了的“FILE”类型数据的指针。fopen()函数的函数原型如下。
它的第一个参数为将要读/写文件的名称,类型为“char *”。对于磁盘文件,写这个参数的实参时应该注意以下两点。
(1)文件名应该写全名,即要写出文件名及扩展名,还有文件所在的盘符、路径(文件夹)(2)。
(2)如果“filename”写做字符串常文字量,在Windows或DOS操作系统下,路径符号应写做'\'。
比如,如果要打开C盘根目录下“lianxi”文件夹下的文件“shuchu.txt”,对应的实参应写成"C:\lianxi\shuchu.txt"。
fopen()函数的第二个参数为打开模式。有三种最基本的打开模式:"r"、"w"、"a"。这是为了通知操作系统程序打算对磁盘文件做何种操作。操作系统将为对文件的操作做必要的准备。比如,在打开模式为"w"时,表示将要把数据写入文件,这时如果文件不存在,操作系统将首先建立这个文件;如果这个文件存在,那么原来的内容将被删除(truncate to zero length)。"a"和"w"的功能基本相同,但一点例外:如果要打开的文件存在,"a"并不删除文件原来的内容,而是在文件原有内容的后面写入。
4.写文件示例
打开文件后就可以把输出写到文件中了。fprintf()函数的用法和printf()基本一致,不同的地方在于多了一个“FILE ”参数,这个参数的实参就是通过调用fopen()所得到的与待输出文件对应“FILE ”类型的返回值。其余部分和调用printf()函数相同。例如:
5.关闭文件
与“打开”相反,关闭文件意味着关闭内存与外存之间的联系通道,切断文件与内存之间的关系。当确定一个文件不再使用之后,应当及时关闭文件(3)。关闭文件还意味着把内存缓冲区的内容及时写到文件中。
所谓内存缓冲区,是指当写文件时,一般先把要写入的内容放在内存中一块特定的区域,在适当的时机再把内存缓冲区的内容一起写入文件。这是因为内存与外存的数据交换速度很慢。从文件中读数据时与此类似,是先把文件中的部分数据读入到内存中,待需要新数据时再从文件中取一批数据。
关闭文件可以通过调用fopen()函数完成,fopen()函数的函数原型为:
如果成功地关闭了文件,函数fclose()的返回值为0,否则为“EOF”。“EOF”为“stdio.h”中定义的一个不等于0的“int”类型的符号常量。
6.打不开文件情况的处理
打开文件并不一定会成功,许多情况下文件可能无法被打开。比如磁盘不存在或禁止写入,这时fopen()返回的值是“NULL”。程序必须对这种情况进行处理,否则,继续运行向文件进行输出有导致程序崩溃的危险。因此,在真正读/写文件前,还必须确认一下文件是否正确地打开了。常见的写法如下。
最简单地处理文件没有打开的方法是直接退出程序,如果是在main()中,可以使用return语句实现。通常返回一个整数类型状态值(一般是非0的整数)表示程序是在存在异常的状态下退出的。如果是在其他函数中,因为无法直接通过return语句退出程序,可以调用标准库函数exit()退出。
exit()函数的原型在“stdlib.h”中说明,其函数原型为
exit()的“status”实参是返回给操作系统的程序结束状态标志,和在main()中return一个“int”类型的“status”值有类似的效果。但是在main()之外的函数中调用exit()函数并不意味着会返回main()函数。
7.示例
fprintf()、fputs()、fputc()是与printf()、puts()、putchar()对应的输出函数,其功能相同,所不同的是输出的目标是经过fopen()函数取得的指针所对应的文件,而后面三个函数的输出目标是“stdout”。如表12-1所示,列举了它们的函数原型。
表12-1 几个常用的输出函数的函数原型
1 另一个类似功能的函数是int putc(int c, FILE *stream);
从函数原型中可以看到,fprintf()、fputs()、fputc()比与之对应的printf()、puts()、putchar()函数多一个参数“FILE *”类型的“stream”,这个参数就是通过fopen()函数打开文件时获得的指针。
下面一段代码演示了fprintf()、fputs()、fputc()的使用,代码的功能是向文件C:\lianxi\shuchu.txt依次写入数据“123”、“"Hello\n"”、“'C'”。
程序代码12-1
当“C:”盘根目录下的“lianxi”子目录(文件夹)不存在时(4),程序显示:
“没有打开文件,程序退出”
表明当文件夹不存在时,fopen()没有打开文件。
如果“C:”盘根目录下的“lianxi”子目录(文件夹)存在,当正确打开文件后,可以发现在C:\lianxi文件夹中增加了一个文件“shuchu.txt”。用“记事本”程序打开这个文件,可以看到如图12-2所示的内容。
图12-2 写入“C:\lianxi\shuchu.txt”文件中的内容
这表明数据被正确地写入了文件。
此外应该注意到的是,fputs()并不像puts()那样把'\0'转换成'\n'输出。
如果是在DOS或Windows系统下,这个文件的大小应该是13字节。这是因为这时'\n'这个字符被写成了ASCII码值为0D和0A的两个字符,这两个字符分别表示“回车”和“换行”这两个字符。
12.1.2 C程序怎样读文件
从文件中读数据的步骤与向文件中写数据的步骤类似,在读文件之前同样需要打开文件,读完之后应及时关闭文件。
打开文件时,fopen()函数的第二个参数应为"r"。
如表12-2所示,读文件可以通过与scanf()、gets()、getchar()函数类似的几个函数进行。
表12-2 几个常用的输入函数的函数原型
1 类似的还有一个int getc(FILE *stream);
注意,这里的fgets()与gets()相比,多了一个“stream”参数,还多了另一个参数“n”(5),这个参数的意义是最多读多少个字符。但如果遇到'\n'、''、'\t',则视为字符串结束。
下面的示意代码演示了从文件中的输入,其中用到的文件为前一小节例题程序建立的文件。
程序代码12-2
代码中,特别值得注意的是在fscanf()式中的'\n',这在scanf()中是罕见的。
程序的输出结果如图12-3所示。
图12-3 从文件中的输入
这表明fgets()虽然把文件中的'\n'作为字符串的结束标志,但并不把这个'\n'转化为'\0'而是把这个字符存储在字符串中,程序得到的是"Hello\n"。
12.1.3 格式化输入、输出的格式
1.fprintf()与printf()函数
fprintf()与printf()函数名字末尾的“f”都是“formatted”的简写,表示这些函数都是所谓的“格式化”输出函数,意思是把内存中的二进制数据转化为指定格式的字符序列输出。
从这两个函数的函数原型
中可以看到,它们都有一个“char ”类型的“format”参数,函数输出内容的格式由这个参数控制。这个“char ”类型的“format”所“指向”的字符串一般由两种成分组成:普通的多字节字符(除了“%”)和转换说明(Conversion Specifications),前者被原样输出,后者一般是用来说明相应参数(0或多个)的转换格式。
例如“printf("%d\n", 0123)”中,“\n”这个字符会被原样输出,而“%d”表示把后面的“0123”这个参数转化成十进制格式的字符序列'8'和'3',插入到“%d”所指示的位置,形成"83\n"这样一个字符串输出。
2.格式化输出的转换说明
转换说明总是以“%”开头,可以对其加上一些必要的修饰。其一般的格式为
%[特征标志][域宽][.精度][长度修饰符]转换说明符
转换说明中各个部分的顺序必须严格遵守。“[]”表示是可选项,也就是可有可无。其中转换说明符由一个字符组成,如表12-3所示。
表12-3 格式化输出的转换说明符
表12-3 中大部分转换说明符都在前面出现过,下面的例子说明了“%n”的用法。
程序代码12-3
程序的输出如图12-4所示。
图12-4 转换符的使用
由于输出的“7.870000”恰好是8个字符,所以写入“n”的值为8。
格式化输出的特征标志符有如下几种:“+”、“-”、“”、“0”和“#”,其具体的含义如表12-4所示。
表12-4 格式化的特征标志符
“域宽”,规定的是输出的最少字符数目。如果输出结果多于这个数目,则按照实际输出;如果少于这个数目,则填充空格(或0,当特征标志为0时)。
“.精度”对于“d”、“i”、“o”、“u”、“x”、和“X”转换说明符来说,表示的是输出数字的最少位数;对于“a”、“A”、“e”、“E”、“f”、和“F”来说,表示的是小数点后面的位数;对于“g”和“G”来说,表示的是显示的最大的位数;对于“s”来说,是允许输出的最多字符数。
“域宽”和“精度”都可以写成“”,这时表示的含义是这两个参数由实参提供。例如,“printf("%8.5f\n",d)”的另一种实现方法是“printf("%.*f\n",8,5,d)”。这样无疑为更灵活的输出格式控制提供了一种手段。
长度修饰符有三种可能的用法:转换说明符没有说明到的情形,比如对应实参为“long”类型时,转换说明需要写成"%ld";输出把对应实参的数据类型进行类型转换后的值,比如“char”类型由于自动转化为“int”,如果需要输出“char”数据的值,需要再次进行类型转换,这时转换说明需要写成"%hhd";用于修饰“n”类型说明符,说明对应实参是何种指针。如表12-5所示,说明了其用法。
表12-5 格式化输出的长度修饰符
C的标准库中还有多个与fprintf()类似的函数,如fprintf()、printf()、vfprintf()、vprintf()、sprintf()、sprintf()函数等,其输出格式均遵守前面的说明。
3.格式化输入的转换说明
对于fscanf()函数和scanf()函数,两者的函数原型同样相当一致,所差只一个参数而已。在C99中,两者的函数原型分别如下。
其中的“format”的意义都是为了对输入进行控制。它规定了可以接受的输入序列和如何进行转化以赋值给后续指针所指向的对象。
对于格式化输入,其“format”字符串由3种可能的成分组成:空白字符(6)、普通字符(非空白字符、非'%')和转换说明。
空白字符(White-space Characters):这类字符在格式化输入中表达的意思是,读若干个空白字符直到第一个非空白字符或再无字符可读。注意,这和输出转换说明中的空白字符的意义大不相同。
普通字符:要求读入的数据与之匹配,一旦遇到不匹配的字符,则函数调用结束,但不匹配的字符并没有被读入。这意味着下面的语句一旦遇到的是输入不是字符“c”,将构成“死循环”:
这个问题在稍微复杂一点的输入设计中很容易遇到。
格式化输入的转换说明的一般形式为:
%[*][域宽][长度修饰符]转换说明符
其中的转换说明符及其含义如表12-6所示。
表12-6 格式化输入的转换说明符
与调用scanf()函数不同的是,调用fscanf()函数时,由于是从文件输入数据,所以其“format”参数中可能必须要有一些除了格式转换声明以外的非空白字符或空白字符。在调用scanf()函数时,编程者主要需要考虑的是让程序使用者如何能简便、快捷且不易出错地输入数据;而在调用fscanf()函数时,需要考虑的则是如何与文件中既有的数据相匹配。
在转换说明符前依次出现的*的意义表示读入匹配的输入项但并不将之存储到内存中。域宽应为一个非零的十进制整数,表示该项数据的宽度。长度修饰符的用法如表12-7所示。
表12-7 格式化输入的长度修饰符
12.1.4 fprintf()与printf()函数的等效性
fprintf()函数与printf()函数、fcanf()函数与scanf()函数从某种意义上来说其实是等效的,甚至是可以互相替换的。下面以fprintf()函数与printf()函数为例来说明这一点。
printf()函数的输出目标通常是标准输出设备,标准输出设备一般是指显示器。通过freopen()函数可以把标准输出设备重新定义为磁盘上的某个文件。这个过程如图12-5所示。
图12-5 重定向示意图
该图表明,printf()函数是流向“stdout”的,而“stdout”通常流向显示器,但是调用fopen()函数可以把“stdout”的流出方向改为磁盘文件。这就是在代码中实现输出重定向的基本原理。
fprintf()函数的输出目标通常是某个抽象的“FILE ”数据类型的目标,但实际上这个“FILE ”数据类型与“stdout”的数据类型是完全一样的,因此也可以把printf()的输出目标直接写为“stdout”,如果这个“stdout”处于流向显示器的状态,那么也就可以用fprintf()函数完全实现printf()函数的功能了。这个过程的原理如图12-5所示。
在图12-6中可以看到,由于fprintf()函数的一个参数是“FILE *”类型,而“stdout”恰恰是这种类型,在默认情况下这个“stdout”数据与标准输出设备相关,因此当fprintf()函数使用“stdout”作为实参时,就可以实现与printf()函数同样的功能。
图12-6 用fprintf()函数实现printf()函数的功能
从前面的分析可以看到,无论是fprintf()函数还是printf()函数,在本质上都是通过一个“FILE *”类型的参数完成输出的,它们等价的基础也就在于此。
C语言的输入输出都是通过“FILE ”类型的指针完成的。对于C代码来说,这种“FILE ”类型的指针是进行输入输出的唯一手段。这种处理方法的优点是在代码中统一了外部的磁盘文件和各种设备(显示器、键盘、打印机等),提供了统一的输入输出接口。