C语言浅学-字符输入/输出和输入验证
我们在上一篇的博客中结束了对于控制语句的分支和跳转的学习,这一篇我们将开始学习字符输入/输出和输入验证,更加详尽的介绍输入、输出以及缓冲输入和无缓冲输入的区别。
在涉及计算机的话题中,我们经常会讨论输入和输出,我们这一篇主要介绍的是输入和输出的函数(简称I/O函数)。
I/O函数:如printf()、scanf()、getchar()以及putchar()。这些函数负责把信息传送到程序中,前面已经介绍过这些函数的用法了,这一篇将详细介绍它们的基本概念。
单字符I/O:getchar()和putchar()
在上面那一篇我们说过,getchar()和putchar()每次都只能处理一个字符。虽然我们人认为这样的方式有点死板,但是毕竟电脑和人还是存在一定的差距的。这样的方式很适合计算机的,而且这也是绝大多数文本处理程序的核心方法。
实例:
1 |
|
自从C标准发布之后,C就把stdio.h头文件和使用getchar()、putchar()相关联起来了,这就是为什么程序都要包含这个头文件的原因。其实getchar()和putchar()都不是真正的函数,它们被定义为供预处理器使用的宏。
输出效果:
那么字符是怎么显示在屏幕上的呢,用一个特殊的字符来结束输入,就无法在文本里使用这个字符了,是否有更好的方法来结束输入呢,我们需要了解缓冲和标准输入文件的概念。
缓冲区
对于上面的程序,若是在老式系统上运行,我们输入文本的时候,可能显示如下:
上图的这种行为是个例外,像这样回显示用户输入的字符立即重复打印该字符属于无缓冲(直接)输入,简单点说就是正在等待的程序可以立即使用输入的字符。对于这个例子来说,大部分的系统在用户按下回车键之前不会重复打印刚输入的字,这种形式就是缓冲输入。用户输入的字符会被收集并存储在一个名为缓冲区的临时存储区中,按下回车键后,程序才可以使用输入的字符。
具体差异可看下图:
缓冲区的作用:把若干个字符作为一个块进行传输比逐个发送这些字符节约不少的时间,其次,若是用户打错字符可以直接通过键盘修正错误,当最后按下回车键,传入的是正确的输入即可。
虽然缓冲输入好处多,但是任何事物都是具有两面性的,某些交互式程序也需要无缓冲输入。例如,在游戏中,就会希望按下一个键就执行相应的指令。所以缓冲输入和无缓冲输入都是有自己的用处。
缓冲主要也分为两类:完全缓冲I/O 和 行缓冲I/O,完全缓冲输入指的是当缓冲区被填满时才刷新缓冲区(内容会被发送到目的地)这样的方式一般用在文件输入中。缓冲区的大小主要取决于系统,常见的大小主要是512字节和4096字节。行缓冲指的就是在出现换行符的时候刷新缓冲区,键盘输入一般就是行缓冲输入,在按下回车键后才刷新缓冲区。
那么该如何选择是缓冲输入还是无缓冲输入,ANSI C 和后续的C标准都是规定输入是缓冲的,在一开始其实是把这个决定权交给编写者。
ANSI C 标准决定把缓冲输入作为标准的原因:一些计算机不允许无缓冲输入,若是计算机允许无缓冲输入,那么所使用的C 编译器就会提供无缓冲输入的选项,支持无缓冲输入的函数的原型大部分都在conio.h头文件中。在UNIX系统中使用另外一种方式来控制缓冲。在这里就不过多的赘述了。
结束键盘输入
在上面的程序中,只要输入的字符中不包含#,那么程序只有在读到#才会结束。但是#也是一个普通字符,有时候总是会用到,C语言中的确有这样的字符。
文件、流和键盘输入
文件是存储器中储存信息的区域,通常文件都会保存在某种永久存储器里,毫无疑问,文件对于计算机系统非常重要。就像我们编写的程序就是保存在文件中,用来编译C程序的程序也保存在文件中。当编译器处理完之后,会关闭这个文件,其他的程序还要把数据写入文件。
C 语言有许多用于打开、读取、写入和关闭文件的库函数。在较低层面上,C 语言可以使用主机操作系统的基本文件工具直接处理文件,这些直接调用操作系统的函数被称为底层I/O。但是由于计算机系统各不相同,所以不太可能为普通的底层I/O创建标准库,在较高的层面来说,C 语言是可以用标准的I/O包来处理文件。
采用标准I/O包,就不需要考虑这些差异了,因此可以使用 if 语句来检查换行符,即使系统实际用的是回车符来标记末尾,I/O函数会在两种表示法之间相互转换。从概念上来看,C语言的程序处理的是流而不是文件。流是一个实际输入和输出映射出来的理想化数据流,意味着不同属性和不同种类的输入使用属性更加统一的流表示,也由流来进行读写等操作。
上面的内容可以知道使用处理文件的方法来处理键盘输入,一般来说是使用文件结尾检测器来结束键盘输入。
文件结尾
一般PC系统都是要以某种方式来判断文件的开始和结束,最常用的是在文件末尾放一个特殊字符标记文件结尾。早期系统的文本文件都曾经使用过这种方法,现在都可以使用内嵌的Ctrl + Z 字符来标记文件结尾,这是曾经操作系统使用的唯一标记。
实例:
操作系统使用的另一种方法就是存储文件大小的信息,若是文件有3000字节,程序在读到3000字节时便达到文件的末尾,DOS系统使用这种方法来处理二进制文件,这种方法可以在文件中储存所有的字符,包括Ctrl + Z 。无论操作系统实际使用何种方法检测文件结尾,在C语言中,用getchar()读取文件检测到文件结尾时将返回一个特殊的值,即EOF。scanf()函数检测到文件结尾也如此。
EOF 的定义在stdio.h 头文件中:#define EOF (-1)。
一般来说,getchar()函数的返回值介于0~127之间,这些值对应标准的字符集,哪怕是使用扩展字符集,-1都不对应任何字符,可以拿来标记文件结尾。但是某些系统也会把EOF定义为 -1 之外的值,定义的值会与输入字符所产生的返回值不同,这些其实无伤大雅,只需要知道EOF是个值并且是检测文件结尾即可。
如何使用EOF:可以将getchar()的返回值和EOF进行比较,不同则是未达到文件结尾。若是正在读取的是键盘输入而不是文件会怎么样,大部分都有办法模拟文件结尾。对于上面的程序可以进行优化:
实例:
1 |
|
虽然没有什么特别大的更改,但是程序运行起来之后,我们可以不用担心EOF的实际值而不敢使用某些特殊符号。还有一个变化就是变量ch的类型从char 变为了int,因为char类型的变量只能表示0~255的无符号整数,但是EOF的值为 -1 ,还好getchar()的返回值的类型是int,所以它读取EOF字符,但也因为返回值是int类型,所以若是将返回值赋值给char类型的ch,一些编译器可能会出现问题,最好还是将ch定义为int类型最为稳妥。且ch是int类型不会影响putchar()的输出。
采用键盘输入,要设法输入EOF字符,不能只输入字符,也不能只输入 -1 ,一般来说采取的方法是按照当前系统的要求,按下快捷键来作为识别文件结尾的信号。在windows系统里一般是Ctrl + C。
这个程序可以把输入的内容拷贝到屏幕上,那么我们可以想想这个程序有没有别的什么用途,若是传输进来一个文件,将文件内容打印在屏幕上,到达文件结尾时返现EOF信号停止;其次若是用某种方法将程序的输出定向到一个文件,用键盘输入数据,将两种方法结合到一起,就可以得到将输入定向到程序中,且输出发送到另一个文件中,就相当于可以使用这个小程序来拷贝文件。
重定向和文件
输入和输出涉及函数、数据和设备。像是上面的程序使用函数getchar(),输出设备是屏幕,输入的数据流由字符组成,若是输入函数和数据类型不变,改变程序查找数据的位置,那么程序在哪里找输入。
一般来说,C程序会使用标准的I/O包查找标准输入作为源头就是前面介绍过的stdin流,是把数据读入计算机的常用方式。它可以是一些过时的设备进行输入,也可以从一个文件查找输入,不仅仅局限在键盘。
一个程序一般可以通过两种方式使用文件,第一种方法最简单,就像printf()一样,使用特定的函数对文件进行打开,关闭,读取,写入等操作。第二种方法就是自己设计一个键盘和屏幕互动的程序,通过不同的渠道重定向输入到文件和输出到文件,简单说就是把stdin流赋给文件,或者用getchar()从输入流获取数据。
重定向的主要问题是和操作系统有关,和C无关。来看看Unix、Linux和Windows的重定向。
Unix、Linux和DOS重定向
Unix(运行命令行模式)、Linux(ditto)和windows的命令行提示都能重定向输入、输出,可以使得程序使用的是文件而不是键盘,输出也是同理,可以输出到文件而不是屏幕。
重定向输入:
若是我们已经编译了程序,并将可执行版本放入一个exe文件中,要运行只需要输入可执行文件名。该程序运行情况和前面一样,获取用户从键盘的输入,但是若是需要使用程序处理文本文件。就不是直接对程序进行执行,要使用文本文件,就需要使用命令行将文件传入程序:
1 | Get-Content hello.txt | .\Helloworld | Out-File output.txt |
Windows里的powershell和其他的命令行不同,上面的命令就是将文本文件通过程序再输出给output的文件。在Unix系统中,< 是重定向运算符,这个运算符使得文本文件和stdin流相关联,程序本身其实不用关心输入的内容来自那,仅需要知道导入的是字符流即可。
这是重定向运算符的使用示例。
重定向输出:
若是要使用程序把键盘输入的内容发送到输出的文件中,可以输入命令进行输出:
在上面这个命令中,> 符号是第二个重定向运算符,它会创建一个名为mywords的新文件,然后将程序的输出重定向至该文件。重定向把stdout从显示设备赋给mywords文件。若是已经有一个mywords文件,则会先擦除再替换文件的内容。然后在下一行的开始处我们按系统的快捷键即可结束程序,在每一行的末尾单击回车键,才能把缓冲区的内容发送给程序。也可以使用系统的查看命令对文件内容进行检查,或者再次使用程序,把文件重定向到程序。
组合重定向:
若是我们现在要做一个mywords文件的副本,并且重新命名为savewords,下面的命令可以实现:
这句命令错误的原因是在输入之前就会导致mywords的长度被截断为0。总而言之,在Unix、Linux和Windows中使用了两个重定向运算符(<和>),需要遵循以下的规则:
1.重定向运算符可以连接一个可执行程序(包括标准操作系统命令)和一个数据文件,不能用于连接两个数据文件,也不能用于连接两个程序。
2.重定向运算符不能读取多个文件的输入,也不能把输出定向至多个文件。
3.一般来说,文件名和运算符之间的空格不是必须的,除非是在系统中有特殊含义的字符,类似于转义字符那种(\n)。
在Unix、Linux和Window还有 >> 运算符,该运算符可以把数据添加到现有的文件的末尾,而 | 运算符可以把一个文件的输出连接到另一个文件的输入。
注释:
重定位可以使我们使用键盘输入程序文件,若是要完成这一任务,程序需要测试文件的末尾,例如那个统计单词的程序,直到遇到 | 符号,可以把ch的类型改为int类型,将循环测试中的 | 符号更换成EOF,就能够统计单词量了。
重定向是一个命令行的概念,因为我们要在命令行输入特殊的符号发出指令。每个系统和每个集成开发环境都可以使用重定向,具体情况具体分析。在使用情况来说,Terminal比gcc这些好用许多。若是使用不了重定向,可以直接使用程序打开文件。
实例:
1 |
|
小结:
绝大部分的C系统都是可以使用重定向的,可以通过系统重定向所有的程序,或者只是在C编译器允许的情况下重定向C程序。
创建友好的用户界面
大部分人偶尔会写一些不实用的程序,但是C会提供大量的工具让输入更加的流畅,处理更加顺利,这一小节主要是让交互数据更方便,减少输入错误。
采用缓冲输入
缓冲输入用起来非常的方便,在将输入发送到程序之前,用户可以编辑输入。但是与此同时也会带来麻烦,缓冲输入要求用户必须按下回车键发送输入,这也传递了换行符需要程序进行处理。
实例:
1 |
|
这个程序的糟糕的算法先不谈,先选择一个数字,但是这个程序每次输入n时,都会打印两条消息主要是由于n作为用户否定了数字1,然后还读取换行符否定数字 2。那么一种解决方案是,使用while 循环丢弃输入行最后的剩余内容,包括换行符。这种方法的优点就是可以把 no 和 no way 这样的相应视为简单的 n 。
可以优化为:
1 | while (getchar() != 'y') |
这一小段的优化确实可以解决换行符的问题,但是这个程序会把除了y 之外的字母也识别为 n 。我们还是需要添加一个 if 语句进行判断。
1 | char response; |
从这上面的两次改进可以看出编写交互式程序的时候,应该事先预料到用户可能出现的错误,针对输入的错误进行处理并提醒再次输入。
混合数值和字符输入
若是一个程序要求使用getchar()处理字符输入,用scanf()处理数值输入,这两个函数都可以很好的完成任务,但是不能将两个进行混用,因为getchar()会读取每一个字符,包括空格、制表符和换行符,但是scanf()在读取数字的时候会跳过这些符号。
实例:
1 |
|
在这个程序中,main()函数负责获取数据,display()函数负责打印数据。但是在实际运行过程中会发现些许的小问题,就是输入完之后没有后续了。原因就是在输入行中紧跟3后面的换行符。scanf()函数把这个换行符留在输入队列里,在进入下一轮迭代时,就会把换行符进行读取赋给ch。而ch是换行符就是正式终止循环的条件。
想要解决这个问题,程序需要跳过一轮输入结束和下一轮输入开始之间的所有换行符或空格。
1 |
|
while循环实现了丢弃scanf()输入后面所有字符(包括换行符)的功能,为下一轮循环做准备。在if 语句中使用一个break语句,可以在scanf()返回值不等于2时终止了程序,在后面也将丢弃后面输入字符的功能加上。
输入验证
在实际的运用过程中,用户的输入由于各种原因不一定会按照指令来做,输入和程序的期望不匹配经常发生,这会导致程序运行失败,我们需要学会提前预测错误并做出应对。
实例:
1 | long n; |
这一小段就是处理非负数整数的循环,属于是输入的内容的范围错误。还有另外一种就是输入的类型错误的值,像是需要输入数字却输入字符。但是这个我们可以通过scanf()的返回值来进行判断,因为scanf()函数会规定读取值的类型。
1 | scanf("%ld", &n) == 1 |
将这个改进和上面的一小段进行结合:
1 | long n; |
可以将程序进行简化,while的循环条件可以解释为读取一个非负整数。若是用户输入错误类型的值时,程序结束,但是可以让程序友好一点,提示用户输入正确的类型。在这种情况下,要处理有问题的输入,这里要明确输入实际上字符流,可以使用getchar()逐字的读取输入。
1 | long get_long(void) |
这个函数是要把一个int类型的值读入变量input中,若是读取失败,函数进入外层while循环体,然后内层循环逐字符的读取错误输入,这个函数丢弃该输入行的所有内容;或者可以丢弃下一个字符,提示用户再次输入,外层循环重复运行,直到用户成功输入,此时的返回值为1。
在我们输入整数之后,程序还需要检查输入的值是否有效,比如要求用户输入一个上限和下限来定义值的范围,对于这个功能来说,我们还需要程序去检查第一个值是否大于第二个值(一般假设第一个值是比较小的那个值),另外还需要检查这些值是否在允许的范围内,比如查询曾经的档案不会在1958年之前以及2014年之后一样,是会有一个范围。
假定程序中包含了stdbool.h头文件,如果当前的系统不允许使用 _Bool,把bool替换成int,把 true 换成1,把false 换成0即可。注意,若是输入无效该函数会返回true,所以将函数名为bad limits():
1 | bool bad_limits(long begin, long end, long low, long high) |
上面这些片段都是为了下面这个程序作铺垫,程序主要是计算特定范围内所有的整数的平方和,上下限是±10000000。
1 |
|
程序的分析
虽然这个程序的核心部分是在 sum_square()函数,但是输入验证部分比以往的都要复杂,先主要看程序的主要结构。这个程序遵循模块化的编程思想,使用独立函数来验证输入和管理显示。程序越大越需要模块化。
main()函数管理程序流,为其他的函数委派任务。使用get_long()获取值,while循环来处理值,bad_limits()来检查获取的值是否在规定的范围之内,最后使用sum_squares()函数来处理实际的运算。
输入流和数字
在编写处理错误输入的代码时,我们应该要清楚C语言是如何处理输入的。如下输入:
is 28 12.4
正常来看,这是一个由字符、整数和浮点数构成的字符串,但是对于C语言来说,其实这就是一个字节流。第一个字节是 i 的字母编码,第二个字节是 s 的字母编码,以此类推。若是我们使用get_long()函数来处理这一行输入,由于第一个是非数字会导致整行输入都被抛弃。
虽然输入流由字符组成,但是也可以设置scanf()把它们转换成数值。对于下面的输入:
42
在scanf()中使用%c 转换说明,会存储在char类型的变量中;使用%s 转换说明,只会读取两个字符并存储在字符数组中;若是使用%d 转换说明,则是会存储在int类型的变量中;如果是 %f 的转换说明,并将其存储在float类型的变量里。简而言之,scanf()可以使用转换说明限制可接受输入的字符类型,而getchar()和%c 的scanf()接受所有的字符。
菜单浏览
不少的程序都会把菜单作为用户界面的一部分,虽然会给使用带来方便的同时,也会给编程带来一定的难度。
假设有下面这样的一个例子:
最理想的状态自然是根据用户选择的选项去完成任务,自然希望这个过程是顺利运行的,有两个目标:1.当用户遵循指令时顺利运行。2.当用户没有遵循指令时,程序也能顺利运行。当然,第二种情况实现的难度较大。
一般来说,应用程序通常使用图形界面,可以点击按钮、查看对话框、触摸图标等,而不是我们的命令行模式。这两者的处理过程基本上是一样的:1.提供选项给用户。2.检查并执行用户的响应。3.保护程序不受误操作的影响。相较来说图形界面更容易限制控制输入。
任务
一个菜单程序具体来说需要执行那些任务呢,一般来说需要获取用户的响应,然后根据响应来选择需要执行的操作。此外,程序还需要提供返回菜单的选项。这个需求我们最好的是采用switch语句,用户的每个选择都是对应一个特定的case标签,还可以使用while语句实现重复访问菜单的功能。
1 | //可以写出以下的伪代码 |
所以对应想要实现的功能来说,我们都可以先使用伪代码的形式来将框架给搭出来。
顺利的程序执行
当我们决定去实现这个程序的时候,就需要去考虑怎么让程序顺利运行(指的是无论程序是在正确输入还是错误输入时都可以顺利运行),那么我们需要做的就是在1.获取选项 时的代码筛选掉不合适的响应,只将正确的响应传给switch。这就说明需要为输入过程提供一个返回正确响应的函数。结合while循环和switch语句,可以简要的构建程序:
1 |
|
在这个程序中,缓冲输入依旧会带来一些麻烦,程序会把用户每一次按下回车键产生的换行符视为错误响应。为了使得程序的界面更加流畅,这个函数应该跳过这些换行符。
解决这个问题有很多的方法,第一种就是自己编一个程序来替代getchar()函数,读取第一个字符并丢弃剩余字符。优点在于可以将 类似 act 这样的输入视为简单的 a 。
简单的优化:
1 | char get_choice(void) |
在原函数中加入一个自我改编的函数可以使得整体程序的模块化更加明显, 当然在使用之前还是要在开头加上声明。
混合字符和数字的输入
在前面分析过混合字符和数值输入会产生一些问题,创建菜单也会有这样的问题。可以假设C 选项的count()函数如下:
1 | void count(void) |
但是若是输入3作为响应,scanf()函数会将3和换行符都留在输入队列里,在下一次调用get_choice()将会导致get_first()返回这个换行符,会导致错误的情况出现。有两种办法可以解决,一是重写get_first(),使得其返回下一个非空白字符而不仅仅是下一个字符,就能修复这个问题;还有另外一种方法,在count()函数中清理换行符,如下:
1 | void count(void) |
这里的改动主要是集中在get_int()这个函数,主要是借鉴了8.7程序里的get_long()的形式,将其中的long类型数据改换成int类型的数据。在完成这些修复之后,可以得到一个最终版的菜单程序:
1 |
|
这段程序对于最后一个问题的解决是在count()函数中的末尾添加上清理换行符的一段代码。那么我们若是不在count中进行消除,而是在get_first()函数中应当怎么改。
1 | char get_first(void) |
到此我们就完成了一个简单的菜单页面的开发,若是某一天需要用到,也可以在这个基础上进行完善和修补。这个实例的学习最重要的一点就是需要学到程序的模块化是有多么实用。
关键概念
C程序把输入作为传入的字节流。getchar()函数把每个字符解释成字符编码,scanf()函数以同样的方式看待输入,但是根据转换说明,它可以将字符输入转换成数值。许多操作系统都提供重定向,允许使用文件代替键盘输入,用文件代替显示器输出。
程序通常接受特殊形式的输入,在设计程序时需要考虑在输入时可能会犯的错误,在输入验证处理这些错误。对于一个小型程序,输入验证会是代码里最复杂的部分,处理这类问题有很多种方案。如果用户输入错误类型的信息,可以终止程序,也可以给用户提供机会重新输入。
小结
许多的程序使用getchar()逐字符的去读取输入。通常使用缓冲输入,也就是当用户按下回车键后输入才被传送给程序,但是与此同时也传送了一个换行符,需要对这个换行符进行处理。
通过标准的I/O包的函数,使用统一的方式来处理不同系统的不同文件形式,这是C语言的特性之一。getchar()和scanf()也属于这一行列。当检测到文件结尾时,这两个函数都会返回 EOF 。
在混合使用getchar()和scanf()的时候,在调用getchar()之前,scanf()会在输入的缓冲里留下一个换行符从而导致一些问题。