第8章:字符输入输出和输入验证
用户输入的数字、命令和句子,到达程序时首先都是字符流。程序再根据规则,把字符解释成数据,并决定下一步如何处理。
本章的重点是建立完整的输入流程:读取、判断、处理、消费剩余内容,再准备下一次读取;遇到输入结束时,有明确的退出路径。
1. 从输入流理解字符与数字
1.1 输入先是文本,再被解释
假设用户输入:
42
按下回车后,程序通常会陆续读到 '4'、'2' 和 '\n'。
| 读取方式 | 如何解释这些输入 |
|---|---|
getchar() |
读取下一个字符,第一次得到 '4' |
scanf("%c", &ch) |
读取一个字符,不自动跳过空白 |
scanf("%s", text) |
按非空白字符序列读取字符串,实际使用时应限制宽度 |
scanf("%d", &number) |
把数字文本转换成整数 42 |
scanf("%f", &value) |
把文本转换成浮点值 42.0f |
输入流本身不会预先给每段内容贴上“整数”“浮点数”的标签。转换规则来自程序。
1.2 文件与流是两个层面的概念
- 文件是实际保存信息的对象,例如磁盘上的文本文件。
- 流是程序访问输入输出时使用的抽象接口。
同一套标准 I/O 函数可以通过流处理键盘输入或文件内容,不必在每个程序中重新处理底层设备差异。
| 标准流 | 常见用途 | 常见默认去向 |
|---|---|---|
stdin |
标准输入 | 终端输入 |
stdout |
标准输出 | 终端显示 |
stderr |
错误或诊断信息 | 终端显示 |
默认去向不是流的固定身份。标准输入可以来自文件,标准输出也可以写入文件。
2. 单字符 I/O 的接口
2.1 getchar 读取,putchar 输出
int ch = getchar();
if (ch != EOF) {
putchar(ch);
}
两者的接口都由 <stdio.h> 提供:
getchar从标准输入读取下一个字符,以int返回;失败或输入结束时返回EOF。putchar把字符写入标准输出,成功时返回写出的字符值,写入失败时返回EOF。
它们每次处理一个字符,但不意味着每次调用都会触发一次真实的设备读取。标准库可能一次取入一批数据,再逐次交给程序。
2.2 字符值应先用 int 保存
char 用来保存字符数据;int 则用于接收字符输入函数的返回值,保留区分普通字符与 EOF 的能力。
不要先把 getchar() 的结果缩窄到 char,再尝试判断文件是否结束。无论普通 char 默认有符号还是无符号,都可能因此丢失正确区分的信息。
使用组合表达式时,赋值部分需要括号:
while ((ch = getchar()) != EOF) {
putchar(ch);
}
否则,ch = getchar() != EOF 会把比较结果 0 或 1 赋给 ch。
3. 缓冲、终端回显与按键响应
3.1 为什么常常要按回车
典型的终端输入过程中,用户先编辑一行,按下回车后,这一行才变成程序可以读取的数据。回车还会产生行结束信息,程序通常读到 \n。
缓冲使数据可以成批传输,也允许用户在提交之前修正输入。
键盘输入与行编辑 → 终端输入机制 → 标准输入流 → 程序读取
终端本身的行处理与 C 标准库的流缓冲不是完全相同的一层机制,理解时不要把它们全部归结为某个函数的行为。
3.2 回显不等于程序已经读取
终端可能在用户按键时就把文字显示出来,这是终端回显。程序之后再用 putchar 输出一遍,是另一件事。
因此,屏幕上已经出现输入文字,并不能证明程序已经完成 getchar 调用。类似地,文件重定向时没有键盘回显,也不影响读取内容。
3.3 标准库中的缓冲模式
| 模式 | 基本认识 |
|---|---|
| 无缓冲 | 标准库不按较大数据块积累该流的内容 |
| 行缓冲 | 与换行等事件相关地传输内容 |
| 全缓冲 | 以较大的缓冲块组织传输,文件操作中很常见 |
不能把全缓冲理解为“必须等文件内容刚好填满整个缓冲区,程序才允许读取”。到达输入末尾时,已有的数据仍然可以被交给程序。
输出可能暂存在缓冲区中。没有换行的交互提示,可以明确刷新:
printf("Enter a value: ");
fflush(stdout);
fflush(stdout) 用于输出刷新,不应把 fflush(stdin) 当作标准 C 中清理键盘输入的通用方法。
3.4 无回显、即时按键不是换一个普通读取函数就能保证
getchar 不保证按下一个键就立即返回。游戏按键、密码输入等需求,可能需要终端或操作系统专用接口。
某些实现提供 getch、getche 等非标准接口,不能把它们当成所有 C 环境都存在的库函数。仅调整标准库缓冲,也不必然改变终端的行编辑规则。
4. EOF 是读取状态,不是普通字符
4.1 不用占用正文中的特殊符号
用 # 作为结束标记很简单,但会让正文中的 # 无法正常输入。检测输入结束,则不必为程序保留一个正文字符。
| 内容或状态 | 含义 |
|---|---|
字符 '#' |
普通字符,只有程序约定后才代表停止 |
字符 '\0' |
数值为零的字符,不等于文件结束 |
字符序列 EOF |
用户输入的三个字母,不会自动结束输入 |
文本 -1 |
两个字符,不等于函数返回的特殊状态 |
宏 EOF |
<stdio.h> 定义的负整数常量,表示相关输入输出失败状态 |
应使用宏名,不把它写死为 -1。文件结束也不要求磁盘文件的最后一定保存一个特殊“EOF 字符”。
4.2 结束与错误需要进一步区分
getchar 返回 EOF,可能是读到末尾,也可能是发生读取错误。
读取结束后,可以检查:
feof(stdin):流的文件结束指示器是否已置位。ferror(stdin):流的错误指示器是否已置位。
这些函数报告已经发生的状态,不负责预测下一次读取是否成功。因此,不应先用 while (!feof(stdin)) 再无条件处理读取结果。
正常写法是先执行读取,根据返回值判断是否获得数据,再处理。
4.3 从终端产生输入结束条件
在常见的 macOS、UNIX、Linux 终端中,可以在空行处按 Ctrl+D,让等待的读取看到输入结束;若当前行已有内容,可能先把已有内容交给程序。
传统 Windows 控制台环境常使用 Ctrl+Z 后按回车。具体按键和行为取决于终端、IDE 和运行环境,不属于 C 语言统一规定。
Ctrl+C 通常用于中断程序,和正常读取到输入结束是不同的路径。
5. 一个可以用于重定向的字符复制程序
#include <stdio.h>
int main(void)
{
int ch;
while ((ch = getchar()) != EOF) {
if (putchar(ch) == EOF) {
return 1;
}
}
if (ferror(stdin)) {
fprintf(stderr, "Input error.\n");
return 1;
}
if (fflush(stdout) == EOF) {
return 1;
}
return 0;
}
保存为 stream_echo.c,在已经提供 cc 的 macOS 或类 UNIX 命令行环境中构建:
cc -std=c11 -Wall -Wextra stream_echo.c -o stream_echo
它把标准输入的数据送到标准输出,不额外输出交互提示。输入错误信息则写到标准错误流。
最后检查输出刷新结果,是因为部分写入错误可能直到缓冲区真正提交时才暴露。
这个程序按标准文本流工作。在会转换文本换行等表示的环境中,不能直接把它当作保证逐字节不变的任意二进制文件复制工具。
6. 重定向:让同一个程序处理不同输入输出
6.1 重定向由命令行环境处理
以下以支持相应语法的类 UNIX shell 为例:
| 命令 | 效果 |
|---|---|
./stream_echo |
使用当前标准输入、标准输出 |
./stream_echo < input.txt |
从文件读取,结果显示到标准输出 |
./stream_echo > output.txt |
把标准输出写入文件 |
./stream_echo < input.txt > copy.txt |
从一个文件读取,写到另一个文件 |
./stream_echo >> output.txt |
把标准输出追加到文件末尾 |
<、> 等符号由 shell 解释,不是传给 getchar 的字符串。程序只需要遵守标准输入输出接口。
单独写 ./stream_echo input.txt,并不会自动等价于输入重定向;除非程序另外实现了命令行参数处理。
6.2 > 通常会先截断已有文件
不要把输入和输出设为同一实际文件,例如:
错误示例:./stream_echo < input.txt > input.txt
shell 通常在程序开始读取前就为输出打开文件并截断内容。即使两个路径名称不同,若它们实际指向同一文件,也可能遇到相同问题。
对于分别作用于输入和输出的简单重定向,调换书写顺序通常不影响目的;但更一般的重定向存在顺序规则,不能推导为所有重定向都与顺序无关。
6.3 管道连接程序
./stream_echo < input.txt | wc -c
管道把前一个程序的标准输出连接到后一个程序的标准输入。这里由 wc -c 统计接收到的字节数。
< 通常为程序选择输入文件,> 为程序选择输出文件,管道则连接两个程序的数据流。
提示文字若也写入标准输出,会混入被处理的数据。设计可用于管道的工具时,应区分正常数据与交互提示、诊断信息。
7. 在程序里打开文件
除了外部重定向,程序也可以显式打开一个文件,取得关联的流,再通过这个流读取。
#include <stdio.h>
int main(void)
{
FILE *input = fopen("input.txt", "r");
if (input == NULL) {
fprintf(stderr, "Cannot open input.txt.\n");
return 1;
}
int ch;
int status = 0;
while ((ch = fgetc(input)) != EOF) {
if (putchar(ch) == EOF) {
status = 1;
break;
}
}
if (ferror(input)) {
status = 1;
}
if (fclose(input) == EOF) {
status = 1;
}
if (fflush(stdout) == EOF) {
status = 1;
}
return status;
}
这里先了解几个接口即可,文件处理的细节在后续章节继续展开:
| 部分 | 作用 |
|---|---|
FILE * |
标准 I/O 用来引用流的接口类型 |
fopen(..., "r") |
以文本读取模式打开文件 |
NULL |
打开失败时返回的空指针值 |
fgetc(input) |
从指定流读取字符,getc 也提供相应读取接口 |
fclose(input) |
关闭程序打开的流 |
getchar 对应从 stdin 读取;fgetc 则允许明确指定流。
相对路径 input.txt 通常根据进程的当前工作目录解析,不是必然根据可执行文件所在目录解析。
8. 混合数值输入与字符输入
8.1 换行留下来,不等于读取函数出错
int age = 0;
scanf("%d", &age);
int next = getchar();
如果输入 20 后按回车:
最初输入:'2' '0' '\n'
scanf 后: '\n'
getchar:读取这个换行
数值转换读取了数字,换行不是该整数的一部分,因而留给后续读取。getchar 又不自动跳过空白,所以会立即取得它。
这不是两个函数不能混用,而是必须清楚每次调用消费了哪些字符。
8.2 一个按键回复可能被误读成两次回答
如果用户输入 n 再回车,流里有 'n' 和 '\n'。若程序每读一个字符就当作一次否定回复,就可能把猜测连续推进两次。
输入 no 时,还会多出 'o'。所以,无条件再调用一次 getchar,不能普遍解决整行残留问题。
应该先确定交互约定:读取整行中的第一个有效命令后,是保留剩余内容,还是明确丢弃本行其余内容。
8.3 三种处理方式
| 需求 | 处理思路 |
|---|---|
| 只要下一个非空白字符 | 可以用 scanf(" %c", &ch),并检查返回值 |
| 每行只接受一个回复 | 取出回复后,消费到换行或输入结束 |
| 需要验证整行内容 | 先按行读取,再解析这一行 |
scanf(" %c", &ch) 会跳过前导空白,但不会自动丢弃这个字符后面的其他输入。它也不能用于把空格本身作为有效回复的场景。
在数值格式后追加空格或 \n,也不等于“只吃掉一个回车”;那可能继续等待后面的非空白内容。
9. 消费本行剩余内容
9.1 同时检查换行和 EOF
void discard_line(void)
{
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
/* 消费当前行剩余字符 */
}
}
这个函数会消费换行本身;如果最后一行没有换行,也能在输入结束处停止。
只写 while (getchar() != '\n'),在输入已结束时可能永远等不到换行,形成重复读取失败的循环。
9.2 什么时候可以调用
适合调用的前提是:程序已经决定不再使用本行剩下的内容。
例如,菜单约定每行只取一个命令,或者某行数值格式错误,需要丢弃整行后让用户重试。
如果用户允许在同一行输入多个待处理值,无条件丢弃整行就会丢失有效数据。恢复方式必须与输入协议一致。
这个函数不是清空所有未来输入,更不是操作系统层面的键盘重置。它只是按当前输入流顺序消费到行边界。
10. 输入验证分成几层
| 层次 | 要检查什么 | 例子 |
|---|---|---|
| 是否得到输入 | 有数据、输入结束还是读取错误 | 读取函数是否返回成功状态 |
| 能否解析 | 字符是否能按指定形式转换 | hello 不能作为 %d 的整数 |
| 是否符合完整格式 | 是否允许多余字符、多个值 | 72abc 是否应当拒绝 |
| 是否满足范围 | 数值是否落在业务区间 | 分数应在 0 到 100 |
| 数据间关系 | 多个值能否共同构成有效任务 | 下限不能大于上限 |
仅仅读取成功,不代表其他层次全部成立。
10.1 scanf 的状态判断
对一次普通单整数读取:
- 返回
1:完成该项转换并赋值。 - 返回
0:发生匹配失败,没有完成赋值。 - 返回
EOF:首次转换尚未完成,就遇到输入结束或读取错误。
多项读取还可能返回部分成功数量。不能简单认为“只要非零,就读到了全部数据”。
10.2 数值前缀不等于整行有效
输入 72abc,%d 可以成功取得整数 72,把 abc 留在流中。
如果后面直接丢弃余下内容,就相当于接受了数值前缀,而不是证明用户输入了一整行纯整数。
同样,数值过大而无法由目标类型表示时,不能指望用 scanf 返回值来完成可靠的范围检测。需要严格解析时,可以采用 fgets 读取一行,再配合 strtol 等函数检查范围、尾部内容与转换状态。
这里先建立区别,相关接口的完整用法在后续继续展开。
10.3 失败后必须改变输入状态
读取失败
↓
判断是否输入结束或错误
├─ 是 → 退出或交给上层处理
└─ 否 → 消费不匹配内容 → 给出提示 → 重新读取
如果第一次读取在 'x' 处失败,既不消费 'x',也不结束,就会在同一位置再次失败。
输入结束也不能被当成“请重试的普通错误”。没有新数据可读时反复提示,只会制造无效循环。
11. 数值本身与多个数值之间的约束
假设任务接受一个闭区间,允许端点落在 [-1000, 1000] 内:
bool valid_limits(long first, long last)
{
return first >= -1000L && first <= last && last <= 1000L;
}
包含 <stdbool.h> 后,这个函数返回区间是否满足要求。由于已经要求 first <= last,这些条件也限制了两个端点的另一侧范围。
如果区间有效,再交给计算函数:
double sum_squares(long first, long last)
{
double total = 0.0;
for (long value = first; value <= last; ++value) {
total += (double)value * value;
}
return total;
}
这个实现按前述范围和顺序约定使用。例如,区间 [2, 4] 返回 29.0。
(double)value 在乘法之前转换操作数,避免先按整数类型完成平方,再把结果转换为浮点数。限定端点也避免让循环更新走到整数类型极限之外。
如果用 (0, 0) 表示退出,需要判断“两者同时为零”,或者把继续条件写成 first != 0 || last != 0。只要求其中一个为零时也退出,是另一种协议。
输入函数负责取得数据,验证函数负责检查约束,计算函数负责按约定完成任务。把这些工作分开,能避免菜单与具体计算互相缠绕。
12. 输入函数也需要返回“没有数据”的信息
只设计一个“不断重试、最终返回整数”的函数,容易遗漏输入结束的情况。
若正常结果允许是任意整数,就不能拿某个同样可能有效的数值表示失败。可以采用独立状态与输出参数等接口,后续学习指针后会更方便实现。
如果函数的合法值域明确受限,则可以选择值域外的状态。例如下面的菜单只接受 0 到 100 的分数,所有合法结果都非负;负值 EOF 可以明确表示输入已经不能继续。
这是一项函数接口约定,不应推广为所有整数输入函数都能安全用 EOF 代替结果。
13. 综合示例:菜单与数值输入相互配合
13.1 先约定每一行怎样使用
- 菜单忽略前导空白,使用每行第一个非空白字符,其余部分丢弃。
- 菜单支持
h、s、q,大小写均可。 - 分数输入接受能由
int表示的数值前缀,之后丢弃本行剩余内容。 - 成功读取后,再检查分数是否在
0到100。 - 输入结束时退出,不再反复要求重试。
因此,score 可以作为以 s 开头的菜单回复;分数行 72abc 在这个协议中会接受为 72。若需求要求拒绝它,应改用整行解析,而不是声称这里已经验证了纯整数格式。
13.2 完整程序
#include <stdio.h>
#include <ctype.h>
void discard_line(void);
int read_first(void);
int get_choice(void);
int read_score(void);
int main(void)
{
int choice;
while ((choice = get_choice()) != EOF && choice != 'q') {
switch (choice) {
case 'h':
printf("Choose s to enter a score, or q to quit.\n");
break;
case 's': {
int score = read_score();
if (score == EOF) {
printf("Input ended.\n");
return ferror(stdin) ? 1 : 0;
}
printf("Accepted: %d\n", score);
break;
}
default:
printf("Unexpected choice.\n");
return 1;
}
}
printf("Bye.\n");
return ferror(stdin) ? 1 : 0;
}
void discard_line(void)
{
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
/* 本行剩余内容不再作为下一次输入 */
}
}
int read_first(void)
{
int ch;
do {
ch = getchar();
} while (ch != EOF && isspace(ch));
if (ch == EOF) {
return EOF;
}
discard_line();
return tolower(ch);
}
int get_choice(void)
{
for (;;) {
printf("h=help, s=score, q=quit:\n");
int choice = read_first();
if (choice == EOF || choice == 'h' ||
choice == 's' || choice == 'q') {
return choice;
}
printf("Unknown command. Try again.\n");
}
}
int read_score(void)
{
for (;;) {
int score = 0;
printf("Enter an integer score from 0 to 100:\n");
int status = scanf("%d", &score);
if (status == EOF) {
return EOF;
}
discard_line();
if (status != 1) {
printf("Not an integer. Try again.\n");
continue;
}
if (score < 0 || score > 100) {
printf("Out of range. Try again.\n");
continue;
}
return score;
}
}
13.3 函数之间的分工
| 函数 | 承担的任务 |
|---|---|
discard_line |
消费当前行剩余内容,处理没有换行的输入末尾 |
read_first |
得到第一个非空白命令字符,统一为小写 |
get_choice |
展示菜单,只交回有效选项或结束状态 |
read_score |
处理数值匹配与分数范围,必要时重试 |
main |
根据有效选项安排动作,管理退出路径 |
菜单不必猜测数值读取之后是否还留着换行,因为数值输入函数按约定已经消费该行其余内容。数值输入函数也不需要知道后面有哪些菜单项。
read_first 的字符变量是 int,能够返回 EOF。传给 isspace 和 tolower 的值,来自未缩窄的 getchar 返回结果,并已按需要判断输入结束。
13.4 一次正常与错误交织的输入
依次输入:
s
hello
120
72
q
程序会依次提示格式错误、范围错误,接受 72,然后回到菜单并退出。
如果在要求分数时输入结束,也会退出。输入的最后一行没有换行时,消费行尾的函数同样能够停止。
保存为 input_menu.c,可以在已有 cc 的 macOS 或类 UNIX 环境中编译运行:
cc -std=c11 -Wall -Wextra input_menu.c -o input_menu
./input_menu
这个例子展示的是基本格式恢复和接口组织。更严格的整行校验、超长输入检测、数值转换范围检查,可以在保持这些职责划分的基础上继续改进。

