Last Updated: 2020-06-01
在阅读本文档前,请确保你已经掌握指针的基础知识,详见 Basics #01: Pointer 。
输入 (input) 意味着将一些数据从文件或命令行载入到程序中,输出 (output) 则意味着将一些数据从程序传出到文件或设备中存储或显示,二者并称为 输入输出 (input and output, IO, or I/O) 。大多数程序都需要 IO 的帮助,可以说 IO 是计算机理论中不可或缺的重要部分,和编程语言无关。
C 语言提供了一组内置函数来方便用户编写程序执行 IO 操作,例如 scanf
/ printf
等。然而这些函数虽然方便,却也囊括了大量的初学者常见错误。
不知读者是否曾想过,当我们使用 scanf
/ printf
或其它内置 IO 函数执行输入输出操作时,其过程中究竟发生了什么?几乎所有的初学者在 IO 方面都踩过无数的坑(尤其是涉及到字符类型的 IO 时),尤其是在最初的时期,被各种问题折磨得痛不欲生,这都是由对 C/C++ 语言和命令行的输入机制的不了解所致。因为对 IO 仅有极度浅显的理解,不深入了解相关原理,别说是初学者了,很多人写了几年的程序也没完全搞清楚为什么他输入 %c
的时候经常会出问题。本文将对相关内容进行详细的介绍。
顺带一提,计算机的 IO 操作不仅是在黑框里 scanf
/ printf
那么简单,还可以对文件执行 IO ,或是对缓存或字符串执行 IO ,等等。虽然初学者很少接触,但你应当知道它们的存在——除了 scanf
/ printf
、 gets
/ puts
、 getchar
/ putchar
等以外,C 标准库还提供了其它数十种不同的 IO 函数。如果深入学习 C/C++ 语言,你总会接触到它们,本文只对其中最通用和常见的几个进行简要介绍。
我们从一个简单却被许多初学者忽略的现象开始说起。考虑如下代码:
当我们在命令行中输入如下内容时:
除非你按下回车,否则程序并不会输出任何内容(实际上也没有将两个整数读入到程序中)。你甚至可以胡乱输入而不会导致什么后果,只要你在敲下回车之前把它们删掉:
而当我们在命令行中输入如下内容时:
虽然我们多输入了一个 3
,但这似乎并没有引起任何问题。如果程序运行了几分钟后又遇到一个 scanf
,这个 3
仍然会被正常地读入。
其实这些都是因为命令行和 C 语言的缓冲输入机制。
输入方式大体上可以分为直接输入(无缓冲输入)和缓冲输入两大类。如果是 直接输入 ,那么你每输入一个字符,程序就会读入一个字符,并可立即使用该字符。如果是 缓冲输入 ,那么你输入的内容并不会立刻传入到程序中,而是暂存在一类被称为 输入缓冲区 的特殊内存区域中。当满足某种条件后,这些输入才会生效。
直接输入的弊端是很明显的。一方面,该方式的容错性为零,一旦用户输入时出现打错字等意外错误,将没有任何更正的机会,只能由读入程序设法处理;另一方面,在一些情况下,把若干个字符作为一个块来传输比逐个发送单个字符的效率更高,具体原因涉及计算机组成原理相关知识,故本文不再赘述。因此,虽然缓冲输入的方式比直接输入复杂,却是非常常见的,不仅限于命令行交互输入。
缓冲输入根据条件的不同分为不同的类别,而在命令行输入的方式属于 行缓冲输入 ,即以“行”为缓冲单位,当你敲下 Enter 键确认一行的内容之后,这行内容才会被程序读入。还有以指定字符个数为分隔单位等其它缓冲输入方式,但这些不是本文讨论的重点。
但是,即便你敲下 Enter 键,这一行输入的内容被程序读入,也未必会立刻“生效”。命令行的输入缓冲区和 C 语言的输入缓冲区并不是同一个东西。
考虑如下代码:
如果你在命令行中输入如下内容:
敲下 Enter 键后,命令行的输入缓冲区中的内容 1 2 3
被程序读入,其中前面的 1 2
会立即被程序调用的 scanf
函数读取,并分别赋值给变量 和 ,而后面的 3
则被留在程序的输入缓冲区中。
还需等到程序执行各类输入指令(在 C 语言中如 scanf/gets/getchar 等输入函数)才会从输入缓冲区中依次取出字符进行输入,此前这些字符依旧存储于输入缓冲区中。
现在我们可以解释引言中的第一个现象——只要你没有敲下 Enter 键,你的输入始终是在命令行的输入缓冲区中增加和删减字符,并不会对程序的运行带来任何影响。
现在你已经了解了基本输入机制、 scanf
的常识和缓冲输入的原理,已经可以理解 C 语言输入缓冲区的陷阱了。
这是一个几乎每一个初学者都要踩一遍的坑。考虑如下代码:
当我们试图在命令行中输入如下内容时:
你会发现,在你输入 a
之前,屏幕上就已经出现了输出:
这就是所谓“输入缓冲区的陷阱”的最简单的存在形式(该现象不是一个专有名词,我瞎叫的,不要误会)。
前文中已经提到,换行只是特殊的字符 \n
,因此上述输入内容实际上可以视为字符串 1\na\n
。而 C 语言的 scanf
函数在读入 %c
类型时会将换行符也视为字符,这是 C 语言读入字符类型的特殊之处,诸如 getchar
和 gets
等函数也有类似的问题。
所以对于读入的字符串 1\na\n
,首先 1
会被 scanf
%d
取走并为 x
赋值,而对于接下来的 scanf
%c
,换行符将被取走,相当于令 ch = \n
,所以最终会看到 .
出现在输出的第二行。
因此在读入 %c
类型时我们需要手动地将空白字符取出,例如:
或者:
为便于后文的描述,我们先介绍一些关于 IO 的常见误区,以免读者在一些无关紧要的细节中陷入困惑。
理解了缓冲输入,现在读者不妨思考一个问题:当我们在命令行上输入一个整数(比如 233
)时,存在输入缓冲区的真的是数值为 的整数吗?
显然这是不合理的,因为在程序读入数据之前,不可能知道你输入的 233
是一个整数还是一个长度为 的字符串,只不过字符串里的字符恰好都是数字。事实上,你在命令行中输入的一切内容都是“字符”(类同 C 语言中的 char
类型),在 C 程序读入之后得到的是三个整数值(对应于 ASCII 码分别为 )而不是一个整数值 。
至于在输入时这几个字符是如何以数值 被赋值给一个整型变量的,则与 scanf
函数的内部实现有关,其处理 %d
具体过程的核心逻辑就像是把字符串转换为整数的过程:
当然库函数中 scanf
的实际实现要比这个复杂得多,因为需要支持很多额外的功能和机制,但是核心逻辑是一致的。
总之,无论 scanf
配上 %d
还是 %c
,亦或是 getchar
,在数据从命令行(或文件等其他来源)输入到程序中时,都是以字符类型被读入的。例如,当你使用 scanf
函数输入整型或其它类型时,只是该函数在背后为你执行了从字符到其他类型的转换过程,并非是计算机智能地将你在命令行上输入的内容直接视为整数读入了。
这是一个虽然很基础但仍可能有人不清楚的问题。在字符类型中,空格和换行都只是被赋予了特殊含义的整数值,例如空格字符和 \n
字符对应于 ASCII 码分别为 和 。除此以外还有其它类似的特殊字符,其中有些是不可见字符,例如文件结束符 EOF
等。这方面的具体细节不是本文的重点。
由于对输入输出流一无所知(详见下一节),一些初学者会在初次遇到那些要求处理多个测试点的编程题目时陷入误区,结果把 IO 写得很复杂。例如对于下面的 IO 要求:
输入 首先输入一个正整数 表示数据组数。 对于每组数据,首先输入两个正整数 和 ,接下来输入 行,每行输入 个正整数,表示 xxx 。 输出 对于每组数据,输出一行 个正整数,表示题目所求的 xxx 。 样例输入
样例输出
为了“让黑框里的输出看起来和样例输出一致”,一些初学者会写出类似如下的代码来处理 IO :
这样的程序的运行结果在黑框里看起来是这样,和样例“完全一致”:
尽管这没有错,但是这是完全没有必要的,因为黑框里显示的东西并不代表程序的输出,程序的输入和输出在屏幕上的显示顺序及方式丝毫不会影响程序的输出。如果不考虑程序内的演算过程的影响,我们可以认为 输入和输出是相互独立且隔离的两个概念 ,尽管输出由输入和程序的算法决定。
下面的代码完全不会导致 OJ 将你的程序判错,或者说,他和上面的代码事实上是等价的:
尽管它的输出和输入交织在一起,在黑框里看起来是这样,但这和输出结果毫无关系:
当你了解了输入输出流,以及这类编程题目判别程序是否正确的方式,你就会完全理解这其中的原因,这并不是什么复杂的事情。
(unfinished)
流 (stream) 是一个相当抽象的高层概念,代表文件、设备或进程的通信通道,它向用户屏蔽了输入输出的底层实现,在许多高级语言中都有输入输出流或其它类似的概念。对于初学者而言,无需过度深究 IO 的底层实现,只需先理清输入输出流的概念,便足以应对几乎所有你会遇到的输入输出问题了。
由于历史原因,在 C 语言中表示流的数据结构的类型称为 FILE
而不是 stream 。由于大多数库函数都处理 FILE *
类型的对象,因此在 C 语言中“文件指针”一词有时也用于指代流。
C 语言将所有设备视为“文件”,诸如显示器和键盘之类的设备将以与文件相同的方式进行寻址。当程序执行以提供对显示器和键盘的访问时,会自动打开stdin、stdout和stderr三个文件。
scanf
函数无疑是最常用的输入函数之一,然而很多初学者虽然每天都在使用 scanf
函数,但是对其最基本的机制都毫不了解,只是单凭经验在使用它。本节将对除 %c
读入的陷阱(前文已经介绍过)以外的相关内容进行简单的介绍。
首先需要理解的是, scanf
函数是一种 格式化输入函数 ,它会以函数调用者提供的字符串为格式依据对输入串进行格式化处理,类似于正则表达式的匹配过程。
这意味着在用户进行输入时,需要严格符合函数调用所用的格式串,否则将引起不可预料的结果,甚至触发未定义行为。这方面的问题随后会详细介绍。
空白字符包括空格字符、换行符 \n
和 Tab 制表符 \t
。
scanf
格式化输入时的分隔符默认为任意空白字符。例如对于如下代码:
下列几种输入方式都是合法的:
并且在 scanf
函数调用中所用的格式串中,当你显式地使用空白字符时,不同种类、任意个数的空白字符都是等价的,例如:
这些写法实质上是等价的,都可以按照上述的几种方式输入。
(unfinished : about %c)
我们先不考虑使用正则表达式匹配的情形。当你使用除说明符(如 %d
等)和空白符以外的普通字符时,需严格按照输入字符串的格式进行输入。例如:
应当严格地按照格式进行输入:
此时不宜使用空白符或其它字符对输入的整数进行分隔,极易引起错误或触发未定义行为。此外,初学者尤其需要注意,不要在输入时误用中文输入法的全角逗号,和半角逗号是不一样的。
我们知道在格式串中使用的空白符可以表示任意的空白符,那么:
就支持了下列输入格式:
实际上,在实际使用中,我们不建议在非必要的场合使用非空白字符(例如逗号)作为输入的分隔符,因为没有必要且容易产生问题。
但在有必要时,我们也可以善用格式输入的规则来简化输入的代码实现。例如当我们需要输入一个时钟时刻的时候,可能会选择先读入一个字符串,然后用额外的代码去处理:
但我们可以将时钟的分隔符 :
作为 scanf
格式化输入的分隔符,代码实现将变得非常简洁:
类似的有格式规范的分隔符的输入都可以用该技巧解决。但是需要注意的是,初学者最好不要用该技巧处理 %s
的输入,因为这与 %d
、 %c
等稍微有些不同,如果使用同样的代码,运行结果可能并不会像你想象的那样:
这部分内容超出了初学者的范畴,因而我只进行一些非常简单的介绍,有兴趣的读者可以自学正则表达式及其在 scanf
函数中的应用。
(unfinished)