第5章 输入输出

5.1 流

流来源于英文单词“stream”,这里应该理解为一个名词。无论是从汉语还是英语,我们马上就能联想到水流,就像一条小溪,当需要取水的时候,从小溪里取一瓢,我们不知道水从何处来;当需要倒水的时候,直接把水倒入小溪中,我们也不知道水最终流向了那里。有点徐志摩了!这毕竟是一本技术书籍,不是诗集,所以下面我给出相对严谨一点的定义。

首先,你不能让真的水流流进你的计算机里,否则你会有大麻烦。所以第一步我们把水流转换成字节流,也就是说,这条小溪里面流淌的不是一个个水分子,而是一个个字节。

这样,流就变成了一种特殊的数据结构,它是动态和线性的。动态是指数据的内容和时间相关,比如在某个时刻你从一个流里读到的是一个字节,下一次你再读就不是原来的字节了。线性是指流只在纵向上有长度,在横向上没有宽度,具体地说,就是每次流只能读入一个字节,不可能一次同时并行读两个字节。举例来说,你在阅读这本书的时候,你实际上就是在操作一个流,因为你在阅读的时候,必然是按照从左到右的顺序来逐字阅读的。

那么说到底,流是什么呢?流主要是逻辑上的概念,是输入输出的一种高度抽象。流产生或消耗数据,产生数据的叫输入流,消耗数据的叫输出流。至于怎么产生,又怎么消耗,根据每种输入、输出物理设备的不同而不同,这与流无关。C 语言对各种输入和输出设备一视同仁,以一个“流”来抽象代表所有的设备。这种抽象的合理之处就在于,C 语言中输入输出的特点和水流的特点一样,都具有动态和线性两个特点。

既然是输入输出,那为什么不直接说键盘、屏幕呢?别忘了,C 语言可以应用在任何设备中,有的设备根本就没有键盘、屏幕,例如一个路由器,或者是一块显卡等。事实上,在C 语言标准文档中,键盘和屏幕这两个概念根本就没有被提及,它们都被抽象成了简单的I/O 字节流。毕竟,我们也是动态和线性地从键盘输入字节、再把字节动态和线性地输出到屏幕的。

C 语言中对流除了分为I/O 流之外,还分为文本流与二进制流。文本流的特点是流由文本行组成,每一文本行由0 个或多个某种字符集的字符组成,并以'\n'字符结束。一个文本流,读入与写出时可能会对其内容做更改,因为字符是有一定意义的,系统可以识别并在适当的时候解释。例如在输出文本流中碰到'\b'时,系统的操作是将输入流中的前一个字符删除,在终端上显示就是在它前面输出的这个字符被删除了。二进制流则全是由一些“生”的、未经处理的数据组成的,C 语言将它们看成由0 与1 组成的序列来读与写,不会对内容进行修改。

在抽象的层面,我们可以说程序从输入流中获得收据,并把处理后的数据输出到输出流。但是具体应用的时候,我们需要知道输入流的源头和输出流的目的地。所以对于流还有另外一种定义,那就是《C Programming Language》[1]给出的:“流是磁盘或其他外围设备中存储的数据的源点或终点”。其实它和我们前面的定义也不冲突,前面的定义突出了流的逻辑抽象层面,关注操作本身;而这里的定义突出了流的物理实现层面,关注操作的对象。

流和一个文件或设备通过opening 操作来关联,并通过closing 操作来断开这种关联。C 语言中用FILE*类型的指针来定义一个流,这个指针的结构包含了控制流所需的信息。也就是说,C 语言中文件的概念和流的概念有的时候可以互换。关于文件的概念,我们会在12章给予介绍。至少目前你可以这么认为,文件是流概念在C 语言中的具体实现。

5.2 stdin、stdout、stderr

任何输入流都是有源头的,任何输出流都是有目的地的。这是哲学的两个本源问题:“从何处来,到何处去。”在你只关注读写操作本身的时候,你可以抽象出流的概念,并忽略源头和目的地。但是一旦涉及具体的应用程序,你就应该指定它们,否则,一个没有输入输出的程序能干什么呢?难道是想模拟一个黑洞?

但是当我们使用printf 输出一个数据的时候,或者是利用scanf 输入一个数据的时候,我们并没有指定输入流和输出流!这是因为C 语言中默认存在三个流。一个输入流stdin,默认情况下指向键盘,也就是说,这个输入流的源头是键盘。两个输出流,分别为stdout 和stderr,默认情况下都指向屏幕,也就是说,这两个输出流的目的地是屏幕。任何一个输入或者输出函数,如果函数中的参数没有指定输入和输出流,那么内部默认就是从stdin 取字节,将字节输出到stdout。

scanf 函数默认就是从键盘读入的,如果我想从一个文件中读入字节,不想从键盘读入,这个时候该怎么办呢?别忘了,还有一个fscanf 函数啊,它的原型为int fscanf ( FILE stream, const char format, … ),函数的第一个参数可以让你指定一个输入流。如果你以fscanf (stdin, …)这种方式来调用fscanf 函数,那么就和调用scanf 函数是一样的了。

stdout 和stderr 默认情况下都是指向屏幕的。但是它们也有区别。输出到stdout 的内容,首先要保存到缓冲区内;而输出到stderr 的内容,直接输出到屏幕。这个很好理解,我们都是希望越快看到错误信息越好。如果希望马上看到输出的错误信息,可以用程序5-1 中的两种方法,第一种是利用printf 函数,然后马上调用fflush(stdout)将缓冲区内的内容输出到屏幕;另外一种是直接传入stderr 到fprintf(stderr,…)函数中。

程序5-1 字符输入输出

printf("run here now\n");

fflush(stdout)

fprintf(stderr, "run here now\n");

无论是stdin 还是stdout,他们都是常量,不能修改。C 语言中提供freopen 函数,可以将stdin 和stdout 重新定向。我们一般不推荐使用freopen,因为重新定义出去容易,但是很难再重新定义回来。如果想重定向,更常用的一个解决方法是在命令行中重新定向标准输入输出,这个技术与C 语言无关。例如你可以在命令行窗口写出a.exe <input 1>output 2>&1 命令,这是一个著名的重定向, stdin 被重定向为input 文件;数字1 代表stdout,stdout 被重定向到output 文件;数字2 代表的stderr 也被重定向到output 文件;重新定义文件标识符可以用i>&j 命令,表示把文件标识符i 重新定向到j,你可以把“&”符号理解为“取地址”。

正常输出和错误输出混到一起也不是很方便查看。这个时候,你可以通过下面的命令demo.exe 2>error.txt,把所有输出到stderr 的信息输出到error.txt 文件中去,以便单独对错误信息进行检查。

5.3 单个字符输入输出

首先介绍一下C 语言中关于空白字符的概念。这个概念在我们后面介绍的输入输出函数中被反复的提及,所以这里首先给出定义。空白字符并不等于空格,空白字符主要指本身没有显示、但是占据一定的水平和垂直距离的字符。能通过键盘输入的常见的空白字符有三个,分别为空格、“\t”表示的tab 键和“\n”表示的enter 键。

下面我们终于可以从小溪这个流里面取水了,后面主要介绍一些常用的取水工具。根据不同的任务使用不同的取水工具,第一条忠告就是:不能使用竹篮!首先我们开始介绍单字符的输入和输出函数。

5.3.1 字符输入输出函数

int getchar()函数顾名思义,就是从输入流中得到一个字符。这个函数并没有要求我们指定输入流,所以它从默认的stdin 输入流中得到一个字符,也就是说从键盘的输入获得一个字符,与之对应的输出函数就是putchar()。

这里需要解释的就是,无论get 还是put 都是以运行的程序为中心考虑的,而不是以你为中心考虑的。如果是以你为中心,那么get、put 就要正好反过来了。为了说明getchar 函数背后的一些问题,我们先编写一个很简单的程序5-2。

程序5-2 字符输入输出

int ch;

/ first /

ch = getchar();

putchar('1');putchar(ch); putchar('\n');

/ second /

ch = getchar();

putchar('2');putchar(ch); putchar('\n');

当我在控制台上输入“ab图”后(其中“图”代表回车键),输出的结果如图5-1所示,getchar 函数分别得到了字符a 和b,然后利用putchar 函数打印了出来,没有什么问题。

图

图5-1 getchar 函数输出结果1

再次运行程序5-2,当我在控制台上输入“a图”后,输出的结果如图5-2所示。

图

图5-2 getchar 函数输出结果2

这个输出的样子有点怪异,我需要解释一下,数字2 后面分别输出了两个回车,输入“a图”中的“图”被getchar 函数读入到ch 中,然后被putchar 函数输出。

从这两个例子可以看出,当我们在键盘上输入的时候,所有的输入都首先保存在输入缓存区内,直到我们输入一个回车,这个时候才告诉我们的程序:“输入暂时结束了,请去处理”,输入函数然后到输入缓冲区中读入数据。在第一个输入中,两个getchar 分别从缓冲区读入a,b;在第二次运行中,两个getchar()分别读入a 和回车“图”。整个过程如图5-3 所示。

从上面的例子中,可以得到一个结论:getchar()函数每次读入任意一个字符(包括回车“图”等空白字符)。

图

图5-3 getchar 函数的两次输入情况

5.3.2 getch 函数

读入单个字符的除了getchar 函数以外,还有另外两个非标准函数getch 和getche。注意,非标准的函数并不是所有的C 语言编译器都支持,所以你的程序如果要求有很高的移植性,请不要使用这两个函数。

三个函数之间的主要区别有以下几点。

• [getchar] 这是一个标准函数,从标准输入流取得一个字符。事实上,它的实现仅仅一个语句,那就是fgetc(stdin)。

• [getch] 这是一个非标准函数,getch 直接从键盘获取用户的输入,不等待用户按回车,只要用户按一个键,getch 就立刻返回,同时返回用户输入的ASCII 码。getch 函数不回显到屏幕。什么是回显呢?大家google 一下就知道了!

• [getche] 与getch 一样,只是它回显到屏幕。如果用它从键盘读入一串密码,可不太安全了。所以你需要先确定你背后是否有人在偷窥哦。

三个函数的返回值都是int,这是因为返回值要和EOF(-1)作比较,来判断是否结束。另外要切记,只有getchar 函数通吃所有编译器。

当用户在键盘上敲击一个字符后,如果你的程序想马上读入这个字符而不需要用户按回车的话,那就用getch 函数。一般情况下,getch 都用在程序的末尾,目的就是让程序窗口能够“定”下来,而不至于一闪而过。当用户按下任意键的时候,程序窗口才消失。

我的 C 语言课程的实验项目是让同学们做一个电子词典。在一个命令行窗口中,一个同学实现了查找单词的动态刷新功能。就是当你输入字母a 的时候,窗口中列出所有以a 开始的单词,当你继续输入b 的时候,窗口中马上刷新为所有以ab 开头的单词。这在图形界面中不算什么,但在命令行窗口下,差点没晃瞎我的双眼。这个功能的实现就是使用了getch 函数。

5.4 字符串输入输出

有了前面的基础知识,接下来讨论字符串的输入,见程序5-3。

程序5-3 gets 函数

include <stdio.h>

main(){

char str[30]; char c;

gets(str);

puts(str);

c = getchar();

putchar(c);

}

运行程序5-3 后,当我们在键盘上输入“hello world图”以后,程序会打印出“hello world”,然后命令行界面会暂停,这是由于第7 行的getchar()等待我们输入下一个字符。从这个实验中,可以得到三个结论。

1)键盘输入都被保存在输入缓冲区内,直到用户输入回车,输入函数才会去缓冲区读取。输入函数从缓冲区读取时,如果缓冲区为空,命令行界面会暂停,等待用户输入;否则输入函数会从缓冲区读入对应的数据。

2)利用gets 函数读入字符串时,空格和tab 都是字符串的一部分。

3)gets 函数读入字符串的时候,以回车或EOF 为字符串的终止符,它同时把回车从缓冲区读走。

结论 3)比较好解释,如果gets 函数没有把回车读走,回车就会被留在缓冲区内。根据5.3 节得到的结论,getchar 就会读入回车,从而整个程序会直接终止,而不会停下来等待用户的输入。怎么样,有点成就感了吧!你已经开始使用你刚刚学习到的知识了。

字符串输出函数可以用puts,这个函数没什么好说的。

5.5 格式化输入输出

5.5.1 scanf 函数的基本知识

scanf 这个函数比较复杂,但是大家不要担心,通过getchar 和gets 两个函数,我们已经学习并掌握了缓冲区的概念,并且也已经熟悉了常见的空白字符。让我们首先复习一下空白字符的概念,能通过键盘输入的空白字符主要有三个,分别为空格,tab 键和回车,下面我们也主要以这三个空白字符为例进行说明。为了显示方便,本书中用“图”符号表示一个空格,用“图”符号表示一个回车,用“图”符号表示一个tab。

在此基础上,我们就来说说scanf函数的基本用法。首先,让我们熟悉一下scanf 函数的原型定义:

int scanf("格式控制字符串",参数地址表);

从原型定义中可以看出,这个函数包含一个类型为int 的返回值,一个格式控制字符串以及参数地址列表,下面我们对这些不同内容分别进行解释。

• 格式控制字符串中通常包含三大类的内容。

  1. o [空白字符] 以空格和tab 为主。
  2. o [非空白字符] 除了空白字符和%开头的格式说明符。
  3. o [格式说明符] 以%开始的格式说明符遵循下面的格式:%[*][width][modifiers]type。可选的星号代表读入的数据将被忽略掉,可选的width 代表最多读入数据的宽度(列数),函数按此宽度从输入数据中截取所需数据;可选的modifiers 主要有h l,分别用来输入短整型、长整型和double 类型;type 主要包含c(代表字符),d(代表整数),s(代表字符串)等,当然,还有其他一些类型,大家可以参考本书网站中“扩展内容”网页中的“scanf 的官方定义”链接。

• 参数地址列表是由若干变量的地址组成的列表,与格式说明符相对应,读入的数据根据格式说明符的格式保存到对应的变量中去。

• 返回值代表成功匹配并被读入的变量的个数。如果没有数据被成功读入,函数返回零。

格式控制字符串是scanf 函数中比较重要的部分。为了巩固格式控制字符串的概念,我们先给出一个具体的例子,如程序5-4 所示。这个程序虽然短,但是已经覆盖了scanf 函数的主要方面,其中scnaf 函数中的格式控制字符串包含了前面介绍的三大类内容,图5-4 中给出了具体的标识。

程序5-4 格式控制字符串实例

include <stdio.h>

main(){

char str[5];

char c;

short int i;

int result;

result = scanf("%c,%hd %5s" ,&c, &i, str);

图

图5-4 格式控制字符串

在程序5-4 中,我们在键盘上输入“a,123图abc图”的时候,字符a被保存到变量c 中,123 被保存到变量i 中,字符串abc 被保存在字符数组str 中。因为有三个变量被成功读入,所以scanf 函数返回3。

在键盘输入a 以后,我们一定要输入逗号。这是因为根据上面的描述,格式控制字符串中的非空白字符一定要和输入流中下一个输入的字符匹配上,如果不匹配,scanf 函数会失败并且退出。例如,当我们在键盘上输入“a123图abc图”的时候,只有字符a 被保存到变量c 中,scanf 函数返回1,这时,变量i 和str 都没有被成功地赋值。

让我们继续这个例子。当我们输入“a,123图图abc图”的时候,由于格式字符串中有空白字符,scanf 函数会读入并忽略掉所有stdin 中的空白字符,所以输入中的空格和tab 键都被读入并被忽略了,最终还是abc 被保存到str 数组中。

5.5.2 scanf 函数的输入特点

根据程序5-4,我们归纳一下scanf 函数的特点。程序5-4 中,格式控制字符串中有非空白字符逗号“,”。当格式控制字符串中出现一个非空白字符的时候,scanf 函数会从stdin 中读取下一个字符,然后把读取的字符和非空白字符比较,如果比较一致,会舍弃读入的字符,函数继续从stdin 读入数据,如果不匹配,函数失败并返回,同时stdin 缓冲区中剩余的字符串不读入。

如果格式控制字符串遇到空白字符,那么scanf 函数会从stdin 缓冲区读入并忽略掉所有的空白字符(零个或多个),直到遇到一个非空白字符为止。

如果格式控制字符串遇到格式说明符,根据格式说明符的格式,scanf 函数会从stdin 缓冲区读入对应数据。例如,对于%c,读入任何一个字符,对于%d 和%s,根据对应格式读入数据,根据格式读入对应的数据时,有三种情况被认为是数据输入结束。

• 在stdin 缓冲区遇到空白符

• 遇到非法字符输入

• 达到输出域宽时

上面所说的非法字符输入是相对格式控制字符来说的,例如,对于格式控制字符%d,输入的如果不是一个数字而是一个字母,那就认为是非法输入了。

C 语言中函数遵循的是单向值传递,我们不能改变传入函数实参的值。但是很明显,scanf 函数必须要改变传入函数实参的值。为了解决这个问题,我们传入变量的地址,函数的形参声明为指针来接受这个地址,这也是指针的经典应用之一。这一点我们在第10章会进一步的介绍。

所以在程序5-4 中,我们传入scanf 函数的实参都是变量的地址,如&c 和&i 等。这里一定要注意,传入c 和i 是不对的,会引起程序运行的崩溃。同时str 这个变量并没有使用“&”符号,这是因为对于数组来说,数组名本身就是首地址,何时使用“&”,何时不需要使用“&”,需要引起高度注意。

是不是有点晕了?对不起!scanf 函数我个人认为是C 语言中用法最复杂的一个函数,同时也是非常常用的一个函数。这一节的内容如果你理解得并不透彻,没关系!先记住这些内容,下面我会通过一些具体的例子来详细说明。当你把后续的例子都输入电脑并运行一遍以后,一定会遇到问题或产生疑问,这个时候再回头仔细地看这一节,我相信你一定有一种豁然开朗的感觉。我就是这样的,所以我相信你也会这样。千万别忘了,把scanf 相关的程序在自己的电脑上实际编写、运行一遍。

最后告诉你们一个小秘密,你们看本书的时候,这一节在前面,但是当我写书的时候,本节却是我最后写的。这就是为什么我要求大家运行完下面的程序后,再后头重新看一遍本节的原因。

5.5.3 scanf 函数处理字符、数字和字符串

首先说明输入单个字符的情况,当我们调用scanf("%c",&c)读取一个字符的时候,输入中的任何字符,包括空白字符,都不会被忽略。也就是说,通过键盘输入的任何一个字符,包括空格,tab 和回车,都会被scanf 函数成功读入,并被保存到字符变量c 中。这一点与getchar 函数的行为是一样的。

当利用scanf 读入一个整数的时候,情况有些不同,输入中的空格、回车、tab 键会被忽略。例如,当我们运行程序5-5,在键盘上输入“12图32”或者是“12图32”,程序都会得到正确的结果。

程序5-5 scanf 输入数字

include <stdio.h>

main(){

int a, b;

printf("Please input a and b:");

scanf("%d%d", &a, &b);

printf("a=%d, b=%d, a+b=%d\n",a,b,a+b);

}

这里需要注意,读入数字时,只有空格、回车、tab 键等空白键会被忽略,如果在你的输入中用逗号分隔两个变量,如“12,34”,变量a 会被赋予12,而变量b 不会得到正确的结果,为什么呢?

这是因为格式控制字符串中有两个“%d%d”,输入中的12 被读入a,当读到逗号“,”时,对数字来说就是非法字符了,所以第一个%d 输入结束,12 被保存到变量a 中。对于第二个%d,逗号“,”也是非法字符,所以scanf 函数退出,变量b 并没有被成功赋值。如果一定要配合用户的“12,34”怎么办呢?这个时候,你就需要修改格式控制字符串为“%d,%d”。具体的原因我不解释了,大家可以参考5.5.2节中关于格式控制字符串中的非空白字符的介绍,就会明白了。

下面我们再说说利用scanf 输入一个字符串的情景,在程序5-6 中,分别输入“图abc图”和“图de图”后,程序输出如图5-5 所示。

程序5-6 scanf 输入字符串

include <stdio.h>

main(){

char str[100];

char str1[100];

scanf("%s%s",str,str1);

printf("%s|\n",str);

printf("%s|\n",str1);

}

图

图5-5 scanf 函数输入字符串结果

图 5-5 中第1 行和第2 行为键盘输入的字符串的回显,第3 行和第4 行才是程序5-6 的输出结果。我们可以看到,输出的字符串中没有空格和tab 键。与函数gets 不同,scanf 会忽略输入字符串前面的空格、tab 和回车等空白字符,并且把字符串后面的空白字符当成输入字符串数据的结束。同时,它也不把输入字符串后面的空白字符读入。

5.5.4 scanf 函数注意事项

上面的三个小节,我们简单介绍了scanf 函数的基本知识和输入特点。

但是,只知道这些基本的规则是不够的,还需要知道如何应用这些规则以及如何规避一些常见的错误。坦白地说,scanf 是一个比较难以驾驭的函数,很多初学者都会在使用scanf 函数的时候犯错。

当混合使用scanf 函数和其他输入函数的时候,我们需要考虑更多的因素,尤其是需要时刻关注输入的缓冲区。下面给出一个实例,如程序5-7 所示。

程序5-7 scanf 与输入缓冲区

1 int main(){

2 char str[100]; char c;

3 while(1){

4 printf("please input your choice…\n");

5 c=getchar();

6 if(c=='q') break;

7 else if(c=='w'){

8 scanf("%s",str);

9 /scanf(" "); 读入并忽略掉缓冲区内的空白字符/

10 printf("%s",str);

11 }

12 else

13 printf("you type wrong choice,\n");

14 }

15 }

程序 5-7 可以根据用户输入不同的选项执行不同的功能。Linux 下很多基于命令行的程序都采用这种模式。程序5-7 中支持两个选项,分别是q 和w。输入q 时整个程序退出,输入w 后,用户还需要输入一个字符串,然后程序打印出这个字符串。如果用户输入其他的选项,程序将会打印出一段警告提示,提示用户输入正确的选项。

这段程序非常简单,大家可以实验一下,运行以后,输入w,然后输入abc 字符串,看看最终发生了什么?开始运行后,你首先选择选项w,输入“abc”并回车后,屏幕上出现了“you type wrong choice”的提示。出现这个小bug 的主要原因在于输入的缓冲区中除了有字符串“abc”外还有一个回车,scanf 函数会读走“abc”;但是缓冲区内还有一个回车,这个时候,第5 行的getchar 函数由于缓冲区不空,会直接从缓冲区读走回车,并付给字符变量c,因为回车既不等于q 也不等于w,所以打印出了错误提示。

程序5-7 中的这个问题貌似不太严重,只是多打印了两行,搞得用户有点莫名其妙而已。不过如果不注意这个问题,相同的原因却会造成你的程序彻底地死机,如程序5-8 所示。

程序5-8 scanf 造成死循环

1 int i;

2 do{

3 printf("*\n");

4 scanf("%d",&i);

5 if(111==i) break;

6 }while(1);

运行程序5-8 后,如果你输入任意一个非数字字符,例如“a”,然后回车,程序会一直不断地打印出星号,并持续运行直到世界末日!

造成死循环的原因在于scanf 函数按照格式控制字符串给定的格式读取。如果读取失败,scanf 函数会退出,但是它不会从缓冲区内读走不匹配数据。这样,当我们输入一个非数字字符“a”的时候,scanf 不能正确地读入,所以i 没有被赋值,只是一个随机数。scanf 退出后,缓冲区内一直有字符“a”。由于缓冲区不为空,那么scanf 也不会把程序暂停下来等待你的输入,你也就没有机会再次输入“111”。scanf 会直接退出,然后判断,然后读缓冲区,退出,判断,读缓冲区,退出,判断……。这样你的程序会一直运行下去,你也永远没有机会输入任何数字。

为了解决这个问题,我们可以利用scanf 函数的返回值。scanf 会返回成功读取的数据的个数。所以在程序5-8 中,我们可以通过判断scanf 的返回值是否等于1 来判断scanf 是否成功读入一个整数,这样就很好地避免了输入“a”带来的死机问题。

程序5-7 和程序5-8 告诉我们,很多错误都来源于缓冲区中有我们上次输入剩余的字符或错误的字符。这个时候,只判断scanf 的返回值是不够的,还需要知道如何彻底解决。我们可以利用程序5-7 第9 行所用的语句,利用scanf 函数格式控制字符串中的空白字符来读入并忽略掉输入中的空白字符。

上面的方法只能清空缓冲区内的空白字符,一个更彻底的方法就是彻底清空缓冲区。例如,我们可以编写一个while 循环来读走所有的字符,如程序5-9第5~6行所示。

程序5-9 清空缓冲区的方法

1 int a;

2 do{

3 printf("*\n");

4 scanf("%d",&a);

5 while((c = getchar()) != '\n' && c != EOF)

6 ; /清空缓冲区内多余字符/

7 if(111==a){

8 break;

9 }

10 }while(1);

注意,有些书中提到使用fflush(stdin)来清空输入缓冲区,这个方法是不对的。这种方法不具有移植性,标准中说明对stdin 进行fflush 的时候,行为是未定义的。另外,程序5-9 第6 行有一个不太合群的分号,这并不是印刷错误,这一节的内容已经太多了,所以我把它放到6.1 节中给予介绍。

另外两个常见的错误如程序5-10 第2,3 行所示。当使用scanf 输入short 类型整数、long 类型整数、以及double 类型浮点数的时候,一定要使用与之对应的正确的格式修饰符,分别为“%hd”,“%ld”,以及“%lf”。这是因为scanf 函数接受的只是参数的地址,必须有正确的格式修饰符才能确定存储的格式。这也是初学者使用 scanf 函数常犯的错误之一。另外就是在scanf 函数中,不要使用“\n”。使用“\n”会发生非常讨厌的逻辑错误。这个逻辑错误会让你的程序编译通过,同时也会正常运行,就是被scanf 读入的数据与你输入的完全不一致,而你全然不知。

程序5-10 错误的格式控制字符串

int i; double d;

scanf("%lf",&d); / 正确,输入到double类型 /

scanf("%d\n",&i); / 错误,scanf中不使用\n /

总之,scanf 函数就像你的女朋友,突然间就不再工作,不给你任何提示,而且也不会告诉你为什么。在我指导学生做实验的时候,如果学生的实验有什么错误,我会第一眼去检查他的scanf 函数用得是否正确。使用scanf 函数的时候,一个好的习惯就是别忘了判断它的返回值。根据返回值的不同,你就可以判断这个函数是否成功地读入了你所希望的数据;同时,要保证格式控制符和保存输入的变量类型严格一致。

5.5.5 scanf 函数总结

学习了所有上面的内容,我相信你已经能够正确地使用scanf 函数了。下面我们再接再厉,讨论一点深入的话题。

首先 scanf 函数为什么不移走缓冲区内不匹配的字符呢?为了说明这个问题,你首先需要明白scanf 函数名字中的字母f 代表的是什么。

我一直认为f 是function 的缩写,直到我阅读了《C Programming FAQs》[3]才明白f 代表的是format 的意思,scanf 的函数本意是按照格式读取数据。假设你有一个格式规范的数据文件,例如,一个没有空格的数字和字母的字符串“123abc”,这个时候,我们就可以使用格式字符串“%d%s”来成功地分别读入数字和字符串。如果“%d”并没有把未匹配的字符a 留在输入流中,%s 将没有办法成功地读入后续的字符串了,这回明白了吧。

如果不了解scanf 函数的一些细节,知道它是如此容易出问题,你是否对这个函数产生了疑问?其实,scanf 这个函数最开始就是为了读取那些相对结构化和格式化输入的场景。例如,如果输入的数据是一个结构良好的3 行3 列的表格,每行中的数据用空格分隔,行与行之间用回车分隔。这个时候,我们用scanf 函数就比较舒服,而且不会产生错误。而对于一些用键盘进行输入的程序,用户一般不太会记得什么时候该用什么符号作为分隔,所以他会比较随意地输入9 个数,甚至有的时候是错误的输入,例如9 个数中也许还错误地包含了一个字符,这个时候scanf 函数就显得有点力不从心了。客观地说,这并不是scanf 函数的错。

虽然通过scanf 函数返回值,你能知道scanf 发生了错误,但是很少有机会改正这种错误。所以更好的办法是使用fgets 来读入用户输入的整个一行,然后利用一些函数来解析这一行。例如,我们可以使用sscanf、strtol、strtok 或者是atoi 等函数对这一行分别进行读取。还记得sscanf 函数吗?它从一个字符串中读入与指定格式相符的数据。这种方法的好处在于,一旦一种解析方法错误了,我们可以尝试其他不同的解析办法,毕竟输入的数据已经被fgets 读入到了内存并被保存了起来。这种方法允许我们对用户的输入错误有更好的包容性。

程序5-11 给出了这种思想的一个实例。如果我们并不确定用户输入日期的格式,我们可以利用sscanf 函数进行多次尝试,直到得到用户的输入日期。在这种用户输入比较随意的情况下,使用scanf 函数就是不恰当的了。因为一旦你发现读入得不对,此时输入缓冲区已经被改变了,所以想重新尝试是很困难的。

程序5-11 fgets 和sscanf 搭配使用

fgets(line, sizeof(line),stdin)

if(sscanf(line,"%d %s %d",…)==3)

printf("24 Dec 2012 form");

else if (sscanf(line,"%d %d %d",…)==3)

printf("12 24 2012 form");

else

printf("other form");

对 scanf 函数的批评,不能扩展到fscanf 和sscanf 这两个函数上。scanf 函数通常是从stdin 读入数据,用户通过键盘的输入通常比较随意,这就造成了一些问题。对于数据文件,通常有比较严格的格式,这个时候,我们使用fscanf 就比较合适了。对于用户输入的一行数据,使用sscanf 进行解析虽然也面临scanf 同样的问题,但是比起scanf 函数,sscanf 函数可以对内存中保存的数据重新进行解析,这就增加了程序的容错性。

好了,罗嗦了半天,谢天谢地,终于到了总结的时间。从上面的这些程序中,我们一共可以得到三个结论。

1)scanf 输入字符的时候,输入中的任何字符都不被忽略,与getchar 函数行为类似。

2)scanf 中输入数字“%d”或字符串“%s”的时候,输入中的空白字符被忽略或者被当成输入的结束。

3)如果缓冲区不空,scanf 按自己的格式提取,提取成功,从缓冲区提走数据;提取失败,不从缓冲区提走数据。scanf 函数返回成功提取的数据的个数。

5.5.6 格式化输出printf 函数

在我学习C 语言的时候,我一直把printf 函数和scanf 函数当成一对映像函数,一个负责输入,一个负责输出。确实,它们有很多相似的地方,包括很多一样的格式说明符,如“%c”,“%d”等。但是如果认为它们完全地对称映像,那你就错了。它们还是有一些差别的,主要反映在以下几个方面。

1)printf 可以用“%f”输出float 和double 两种类型;scanf 必须使用“%f”输入float,“%lf”输入double。

2)在两个函数的格式控制字符串中,星号“*”有不同的解释。

3)scanf 格式控制字符串中不使用“\n”。

对于“\n”,我们需要详细地解释一下。在c 语言中,“\”代表转义字符或逃脱字符(escape character)。当“\n”出现在字符串常量中的时候,在编译阶段,“\”和后面的字母“n”翻译成回车。也可以这么理解,通过“\”,从字母“n”的原始的含义中逃脱出来,变成了新的含义——回车。这种翻译只发生在编译阶断,不发生在运行阶段。

也就是说包含在源码常量字符串中的“\n”才被编译成回车符,而程序一旦运行起来,当你在键盘上敲击“\”键和“n”键,或者文件中包含字符“\”和字符“n”,都不会被程序翻译成回车,只会当成两个不同的字符。

5.5.7 选择合适的格式控制符

无论是printf 还是scanf,格式控制字符串都是非常重要的。我们学习过强制转换,但是printf 却不会根据格式控制符来对输入的数进行强转。如程序5-12 的第2 行,我们期望它能输出1,但是结果却是一个比较奇怪的整数。

程序5-12 printf 函数的格式错误

float f = 1.234f;

printf("f is %d",f);

printf 只会按照你给出的格式“%d”来解释变量f 对应的二进制表示,所以变量f 还是正确的值,不过是printf 的输出欺骗了你。如果你明白了printf 欺骗了你还好,如果不知道,就会沿着变量f 的线索一路查下去,直到自己抓狂为止,正所谓你的眼睛欺骗了你的心。

printf 的格式控制字符串中,比较值得注意的就是“m.n”修饰格式。它一般出现在“%”号后面,当作用于整型数d,浮点型数f 和字符串s 的时候,其作用不太一样。当作用于浮点数类型和字符串类型的时候,m 代表输出至少要占m 位。至少的定义是,如果要输出的数字大于m 位,那么就正常输出数字;如果要输出的数字小于m 位,那就输出m 位,其余的位用空格填充。如果要用数学的角度描述,要输出的数字的位数为k,那么最后输出的位数为MAX(m,k)。n 作用于浮点数的时候,代表小数点后面输出几位;当作用于字符串的时候,n 代表最多输出多少个字符,例如printf(:%6.2s:, "abcd"),会输出“:图ab:”这个结果。利用“m.n”这种修饰格式,我们的输出可以做到以小数点对齐,如程序5-13,结果如图5-6所示。

程序5-13 printf 格式控制符演示程序

float f1 = 123.456;

float f2 = 12.34567;

printf("%9.2f\t%9.2f\n",f1,f2);

printf("%9.2f\t%9.2f\n",f2,f1);

图

图5-6 程序输出结果

5.6 输入规则全真七子

有了上面的分析,下面进行总结。我们一共得到七个结论。七是一个吉利数,如果你能理解这七个规则,就可以利用这“全真七子”布下“北斗七星阵”,打败东邪西毒;可以种七个葫芦娃,打败蛇妖;可以有七个小矮人,保护公主;可以……。总之,可以规避很多输入带来的错误。

1)键盘输入都被保存在输入缓冲区内,直到用户输入回车,输入函数才去缓冲区读取。输入函数从缓冲区读取时,如果缓冲区为空,程序会暂停;否则输入函数会从缓冲区读入对应的数据。

2)getchar 函数每次读任意一个字符(包括回车“图”)。

3)利用gets 读入字符串时,空格和tab 都是字符串的一部分。gets 以回车和EOF 为字符串的终止符,同时把回车从缓冲区读走。

4)scanf 中输入数字“%d”或字符串“%s”的时候,scanf 忽略空格、tab、回车等空白字符。scanf 函数利用“%s”提取字符串时,空格、tab 作为字符串输入的截止。

5)如果缓冲区不空,scanf 按自己的格式提取,提取成功,从缓冲区提走数据;提取失败,不从缓冲区提走数据。scanf 函数返回成功提取的数据的个数。

6)掌握利用while 循环清空缓冲区的方法,但是不要用fflush(stdin)。

7)如果你的程序要求对用户输入的各种不规范格式或错误要求有很高的容错程度,尝试一下fgets 和sscanf 的组合来完成用户输入的读取。

5.7 字符串的安全输入方法

从上面介绍的内容可以看出,字符串有两种输入方法,分别为scanf 和gets。这两种输入方法都是不安全的,会产生溢出攻击。所谓的溢出攻击就在于C 语言在输入的时候,并不进行越界检查。

下面来看一个溢出攻击的实例,如程序5-14 所示。程序内部定义了一个密码字符串“pass”,当用户输入的字符串等于“pass”时,打印“yes”,否则打印“no”。程序5-14 在release 版下运行的时候,第一遍你可以输入“11111111zy”,程序当然提示你“no”;当你第二遍输入“zy”的时候,就会出现你期待的“yes”。你辛辛苦苦设置的密码保护,在有经验的黑客面前,都是浮云。

被攻击的原因很简单,在程序的内存中,in 和pw 两个数组是紧挨在一起的,而且in 在前面,占8 个字节。当你输入“11111111zy”的时候,8 个1 占据了in,随后的“zy”越界侵入了pw,由于C 语言不进行越界检查,最后的结果就是pw 里面保存的是字符串“zy”。然后当你再一次输入“zy”的时候,strcmp 就会返回0,整个密码保护被攻破。

程序5-14 溢出攻击实例

char pw[8] = "pass"; char in[8];

while(1){

scanf("%s", in);

if (strcmp(in, pw) == 0){

printf("yes\n");

break;

}

else{

printf("no");

}

}

为了避免scanf 和gets 的溢出攻击,C 语言中强烈推荐使用函数fgets 来完成字符串的输入。fgets 函数的定义如程序5-15 第1 行所示。

fgets 函数可以让用户指定输入的最大长度限制。所以一个安全的输入密码的方法如程序5-15 第2 行所示。这样,如果你输入超过8 个字符的一个字符串,fgets 只取出前面的7 个字符,然后在后面加上“\0”,保存到“in”这个数组中。fgets 一般是从文件中读取字符串,如果你想让其完成从键盘上输入,可以将标准输入stdin 传入这个函数中。

程序5-15 fgets 用法

char fgets ( char str, int num, FILE * stream );

fgets(in,8,stdin);

5.8 本章小结

本章首先从理论角度介绍了流这一抽象概念,然后介绍了三个标准的输入输出流,分别为stdin、stdout 和stderr。

在输入输出部分,分别介绍了字符输入输出函数、字符串输入输出函数和格式输入输出函数三个部分,尤其是scanf 函数,更是浓墨重彩地给予了介绍。最后给出了七个非常简洁明了、深入浅出并具有很高操作性的规则,可以说是本章、甚至是本书的精华所在,希望读者能够完全理解并掌握这七个规则。

最后介绍了使用fgets 避免溢出攻击的安全输入办法。希望你从此不再使用gets 函数,而只使用fgets(……,stdin)函数。