3.6 格式化输入和输出

如果你曾经用C语言编写过程序,那么你应该对那些按设计格式输出数据的库函数比较熟悉。这些函数包括向一个文件流输出数据的printf系列函数和从一个文件流读取数据的scanf系列函数。

3.6.1 printf、fprintf和sprintf函数

printf系列函数能够对各种不同类型的参数进行格式编排和输出。每个参数在输出流中的表示形式由格式参数format控制,它是一个包含需要输出的普通字符和称为转换控制符代码的字符串,转换控制符规定了其余的参数应该以何种方式被输出到何种地方。

printf函数把自己的输出送到标准输出。fprintf函数把自己的输出送到一个指定的文件流。sprintf函数把自己的输出和一个结尾空字符写到作为参数传递过来的字符串s里。这个字符串必须足够容纳所有的输出数据。

printf系列函数还有一些其他的成员,它们以各自不同的方式对其参数进行处理。更详细的资料请参考printf的手册页。

普通字符在输出时不发生变化。转换控制符让printf取出传递过来的其他参数并对它们的格式进行编排。转换控制符总是以%字符开头。下面是一个简单的例子:

它在标准输出上产生如下的输出:

要想输出%字符,你需要使用%%,这样就不会与转换控制符混淆了。

下面是一些常用的转换控制符。

❑ %d, %i:以十进制格式输出一个整数。

❑ %o, %x:以八进制或十六进制格式输出一个整数。

❑ %c:输出一个字符。

❑ %s:输出一个字符串。

❑ %f:输出一个(单精度)浮点数。

❑ %e:以科学计数法格式输出一个双精度浮点数。

❑ %g:以通用格式输出一个双精度浮点数。

让传递到printf函数里的参数数目和类型与format字符串里的转换控制符相匹配是非常重要的。整数参数的类型可以用一个可选的长度限定符来指定。它可以是h,例如%hd表示这是一个短整数(short int),或者l,例如%ld表示这是一个长整数(1ong int)。有的编译器能够对printf语句进行检查,但并非万无一失。如果你使用的是GNU编译器gcc,你可以在编译命令中添加-Wformat选项以实现这一功能。

下面是另外一个例子:

它的输出是:

你可以利用字段限定符对数据的输出格式做进一步的控制。它扩展了转换控制符的功能,使得转换控制符能够对输出数据的间隔进行控制。它的常见用法是设置浮点数的小数位数或设置字符串两端的空格数。

字段限定符是转换控制符里紧跟在%字符后面的数字。表3-5中列出了一些转换控制符示例及其输出情况。为了说明得更清楚,我们用垂直线字符来表示输出边界。

表3-5

上表中的所有示例都输出到一个10个字符宽的区域里。注意:负值的字段宽度表示数据在该字段里以左对齐的格式输出。可变字段宽度用一个星号(*)来表示。在这种情况下,下一个参数用来表示字段宽。%字符后面以0开头表示数据前面要用数字0填充。根据POSIX规范的要求,printf不对数据字段进行截断,而是扩充数据字段以适应数据的宽度。因此,如果你想打印一个比字段宽度长的字符串,数据字段会加宽,如表3-6所示。

表3-6

printf函数返回一个整数以表明它输出的字符个数。但在sprintf的返回值里没有算上结尾的那个null空字符。如果发生错误,这些函数会返回一个负值并设置errno。

3.6.2 scanf、fscanf和sscanf函数

scanf系列函数的工作方式与printf系列函数很相似,只是前者的作用是从一个文件流里读取数据,并把数据值放到以指针参数形式传递过来的地址处的变量中。它们也使用一个格式字符串来控制输入数据的转换,它所使用的许多转换控制符都与printf系列函数的一样。

scanf函数读入的值将保存到对应的变量里去,这些变量的类型必须正确,并且它们必须精确匹配格式字符串。否则,内存数据就可能会遭到破坏,从而使程序崩溃。编译器是不会对此做出错误提示的,但如果你运气够好,你可能会看到一个警告信息!

scanf系列函数的format格式字符串里同时包含着普通字符和转换控制符,就像printf函数中一样。但在scanf系列函数中,那些普通字符是用于指定在输入数据里必须出现的字符。

下面是一个简单的例子:

这个scanf调用只有在标准输入中接下来的五个字符匹配"Hello"的情况下才会成功。然后,如果后面的字符构成了一个可识别的十进制数字,该数字就将被读入并赋值给变量num。格式字符串中的空格用于忽略输入数据中位于转换控制符之间的各种空白字符(空格、制表符、换页符和换行符)。这意味着在下面两种输入情况下,这个scanf调用都会执行成功,并把1234放到变量num里:

输入的空白字符在进行数据转换时一般也会被忽略。这意味着,格式字符串%d将持续读取输入,忽略空格和换行符,直到找到一组数字为止。如果预期的字符没有在输入流里出现,转换将失败,scanf也将返回。


如果不注意,这会产生问题。如果用户在输入中应该出现一个整数的地方放的是一个非数字字符,就可能在程序里导致一个无限循环。


下面是一些其他的转换控制符。

❑ %d:读取一个十进制整数。

❑ %o、%x:读取一个八进制或十六进制整数。

❑ %f、%e、%g:读取一个浮点数。

❑ %c:读取一个字符(不会忽略空格)。

❑ %s:读取一个字符串。

❑ %[]:读取一个字符集合(见下面的说明)。

❑ %%:读取一个%字符。

类似于printf, scanf的转换控制符里也可以加上对输入数据字段宽度的限制。长度限定符(h对应于短,l对应于长)指明接收参数的长度是否比默认情况更短或更长。这意味着,%hd表示要读入一个短整数,%ld表示要读入一个长整数,而%lg表示要读入一个双精度浮点数。

以星号(*)开头的控制符表示对应位置上的输入数据将被忽略。这意味着,这个数据不会被保存,因此不需要使用一个变量来接收它。

我们使用%c控制符从输入中读取一个字符。它不会跳过起始的空白字符。

我们使用%s控制符来扫描字符串,但使用时必须小心。它会跳过起始的空白字符,并且会在字符串里出现的第一个空白字符处停下来,所以,你最好用它来读取单词而不是一般意义上的字符串。此外,如果没有使用字段宽度限定符,它能够读取的字符串的长度是没有限制的,所以接收字符串必须有足够的空间来容纳输入流中可能的最长字符串。较好的选择是使用一个字段限定符,或者结合使用fgets和sscanf从输入中读入一行数据,再对它进行扫描。这样可以避免可能被恶意用户利用的缓冲区溢出。

我们使用%[]控制符读取由一个字符集合中的字符构成的字符串。格式字符串%[A-Z]将读取一个由大写字母构成的字符串。如果字符集中的第一个字符是^,就表示将读取一个由不属于该字符集合中的字符构成的字符串。因此,读取一个其中带空格的字符串,并且在遇到第一个逗号时停止,你可以用%[^, ]。

给定下面输入行:

下面的scanf调用会正确读入4个数据项:

scanf函数的返回值是它成功读取的数据项个数,如果在读第一个数据项时失败了,它的返回值就将是零。如果在匹配第一个数据项之前就已经到达了输入的结尾,它就会返回EOF。如果文件流发生读错误,流错误标志就会被设置并且错误变量errno将被设置以指明错误类型。详细情况请参考本章3.6.4节中的内容。

一般来说,对scanf系列函数的评价并不高,这主要有下面3方面原因。

❑ 从历史来看,它们的具体实现都有漏洞。

❑ 它们的使用不够灵活。

❑ 使用它们编写的代码不容易看出究竟正在解析什么。

此外,你应尝试使用其他函数,如fread或fgets来读取输入行,再用字符串函数把输入分解成你需要的数据项。

3.6.3 其他流函数

stdio函数库里还有一些其他的函数使用流参数或标准流stdin、stdout和stderr,如下所示。

❑ fgetpos:获得文件流的当前(读写)位置。

❑ fsetpos:设置文件流的当前(读写)位置。

❑ ftell:返回文件流当前(读写)位置的偏移值。

❑ rewind:重置文件流里的读写位置。

❑ freopen:重新使用一个文件流。

❑ setvbuf:设置文件流的缓冲机制。

❑ remove:相当于unlink函数,但如果它的path参数是一个目录的话,其作用就相当于rmdir函数。

所有这些库函数在手册页的第三节中都有说明。

你可以使用文件流函数来重新实现前面的文件复制程序。请看下面的copy_stdio.c程序。

实验 第三个文件复制程序

这个程序与前面的版本很相似,但逐个字符的复制工作改为通过调用stdio.h头文件里定义的函数来完成:

像前面那样运行这个程序,你得到的结果是:

实验解析

这一次程序运行了0.11秒,虽然不如底层数据块复制版本快,但比那个一次复制一个字符的版本要快得多。这是因为stdio库在FILE结构里使用了一个内部缓冲区,只有在缓冲区满时才进行底层系统调用。读者可以利用stdio库函数自行编写出实现逐行复制和数据块复制的程序,将它们的执行性能与我们在本章里给出的3个示例程序进行比较。

3.6.4 文件流错误

为了表明错误,许多stdio库函数会返回一个超出范围的值,比如空指针或EOF常数。此时,错误由外部变量errno指出:

注意,许多函数都可能改变errno的值。它的值只有在函数调用失败时才有意义。你必须在函数表明失败之后立刻对其进行检查。你应该总是在使用它之前将它先复制到另一个变量中,因为像fprintf这样的输出函数本身就可能改变errno的值。


你也可以通过检查文件流的状态来确定是否发生了错误,或者是否到达了文件尾。

ferror函数测试一个文件流的错误标识,如果该标识被设置就返回一个非零值,否则返回零。

feof函数测试一个文件流的文件尾标识,如果该标识被设置就返回非零值,否则返回零。我们可以像下面这样使用它:

clearerr函数的作用是清除由stream指向的文件流的文件尾标识和错误标识。它没有返回值,也未定义任何错误。你可以通过使用它从文件流的错误状态中恢复。例如,在“磁盘已满”错误解决之后,继续开始写入文件流。

3.6.5 文件流和文件描述符

每个文件流都和一个底层文件描述符相关联。你可以把底层的输入输出操作与高层的文件流操作混合使用,但一般来说,这并不是一个明智的做法,因为数据缓冲的后果难以预料。

你可以通过调用fileno函数来确定文件流使用的是哪个底层文件描述符。它返回指定文件流使用的文件描述符,如失败就返回-1。如果你需要对一个己经打开的文件流进行底层访问时(例如,对它调用fstat),这个函数将很有用。

你可以通过调用fdopen函数在一个已打开的文件描述符上创建一个新的文件流。实质上,这个函数的作用是为一个已经打开的文件描述符提供stdio缓冲区,这样解释可能更容易理解一些。

fdopen函数的操作方式与fopen函数是一样的,只是前者的参数不是一个文件名,而是一个底层的文件描述符。如果你已经通过open系统调用创建了一个文件(可能是出于为了更好地控制其访问权限的目的),但又想通过文件流来对它进行写操作,这个函数就很有用了。fdopen函数的mode参数与fopen函数的完全一样,但它必须符合该文件在最初打开时所设定的访问模式。fdopen返回一个新的文件流,失败时返回NULL。