第08章 字符输入输出与输入验证
这一章在干嘛?
程序和外界打交道全靠输入输出(I/O,Input/Output)。本章聚焦最细粒度的 I/O——一次一个字符:搞懂为什么打的字要先存进”缓冲区”,学会用 EOF 让程序优雅地读完所有输入,用重定向把键盘程序变成文件工具,最后解决两个实战难题——字符数字混合输入的陷阱和”用户不按套路出牌”的输入验证。
8.1 单字符 I/O:getchar() 与 putchar()
一次读一个字符看似笨拙,却正是文本处理程序的核心。看最简单的回显(echo)程序——从键盘读字符、原样打印到屏幕:
/* echo.c -- 重复输入(程序清单 8.1) */
#include <stdio.h>
int main(void)
{
char ch;
while ((ch = getchar()) != '#')
putchar(ch);
return 0;
}Hello, there. I would
Hello, there. I would
like a #3 bag of potatoes.
like a程序把输入回显出来,直到遇到 # 才停。运行后你可能有两个疑问:为什么必须敲完整行程序才有反应?用 # 结束输入意味着文本里不能再用 #,有没有更好的办法?要回答它们,得先弄懂缓冲(buffering)。
8.2 缓冲区
多数系统上程序会等你按回车才开始处理;但在某些系统上字符立即回显(HHeelllloo.. 这种交错效果)。前者叫缓冲输入(buffered input):敲的字符先收集到临时存储区——缓冲区(buffer)——按回车才整块交给程序;后者叫无缓冲(直接)输入(unbuffered input),字符一敲就立刻可用。


为什么要有缓冲?一、整块传输比逐个传省时间;二、打错了可在按回车前修改。但无缓冲输入也有用武之地——游戏程序希望一按键就响应。缓冲分两种:
- 完全缓冲(fully buffered):缓冲区满时才刷新(flush),常用于文件输入,常见 512 或 4096 字节;
- 行缓冲(line-buffered):遇到换行符就刷新,键盘输入通常如此。
ANSI C 规定输入应该是缓冲的,且没有提供无缓冲输入的统一方法(PC 编译器常通过 conio.h 提供 getche()/getch(),Unix 用 ioctl() 控制)。本书假设你使用缓冲输入。
8.3 终止键盘输入:文件、流与 EOF
用 # 结束输入,代价是文本里不能出现它。理想方案是找”正常文本绝不会出现”的结束信号——这要从 C 处理文件的方式说起。
文件与流
文件(file)是存储信息的一块内存区域。不同系统存文件的细节千差万别:换行有的用一个字符、有的用回车+换行两个字符……C 在高层面用标准 I/O 包(standard I/O package)屏蔽这些差异:程序面对的是一个理想化的数据流——流(stream),打开文件就是把流和文件关联起来。
关键结论:C 把键盘和显示器也当成文件,程序启动时自动打开——键盘输入对应流 stdin,屏幕输出对应流 stdout。getchar()、putchar()、printf()、scanf() 都工作在这两个流上。所以读文件用的”文件结尾检测器”,键盘输入同样能用。
文件结尾 EOF
操作系统标记文件末尾有两种方式:内嵌特殊字符(如 Ctrl+Z),或记录文件大小(读够字节数即到头,Unix 对所有文件都这么干)。C 不关心底层用哪种——getchar() 检测到文件结尾时返回特殊值 EOF(end of file),stdio.h 里通常这样定义:
#define EOF (-1)为什么是 -1?getchar() 正常返回 0~127(标准字符集)或 0~255(扩展字符集),-1 不对应任何字符,可安全充当结束信号。scanf() 检测到文件结尾时同样返回 EOF。注意 EOF 只是信号值,文件里并不真的存在一个叫 EOF 的字符。于是有了这个几乎能读”无限”输入的回显程序:
/* echo_eof.c -- 重复输入直到文件结尾(程序清单 8.2) */
#include <stdio.h>
int main(void)
{
int ch;
while ((ch = getchar()) != EOF)
putchar(ch);
return 0;
}三个要点:不用自己定义 EOF;不要写依赖 EOF 具体数值的代码;ch 必须声明成 int 而不是 char。
用 int 接 getchar() 的返回值,别用 char
char 变量在部分系统上是无符号的,取值范围 0~255,根本装不下 -1(EOF)!getchar() 的返回类型本来就是 int,把 ch 声明成 char 后
ch != EOF的判断在某些系统上会失效,程序陷入死循环。用 int 接,putchar() 照样能正确打印对应字符——这是本章乃至全书最重要的细节之一。
从键盘模拟 EOF:Unix/Linux 在行首按 Ctrl+D,DOS/Windows 在行首按 Ctrl+Z。每按一次回车缓冲区里那行就被处理并回显一次,发出 EOF 信号后程序结束。(图形界面下 Ctrl+Z 的行为依编译器设置而异,可能只结束输入,也可能结束整个程序。)
8.4 重定向与文件
echo_eof.c 只干一件事:把一个字符流原样搬到另一个流。如果输入能来自文件、输出能写进文件,它就同时变成”显示文件内容""记录键盘输入""复制文件”三件工具——而程序一行都不用改!这就是重定向(redirection)。它是操作系统(Unix、Linux、DOS 等)的功能,不是 C 的一部分。假设可执行文件叫 echo_eof:
重定向输入——让程序从文件 words 读而不是从键盘读:
echo_eof < words< 运算符把 words 接到 stdin 流上,echo_eof 从流里读字符,它根本不知道(也不关心)流后面接的是键盘还是文件。
重定向输出——把键盘输入存进文件 mywords:echo_eof > mywords。> 会创建(或清空重写)mywords,把 stdout 从屏幕改接到这个文件。
组合重定向——复制文件只需一条命令:
echo_eof < mywords > savewords重定向的三条禁忌
- 别把同一个文件既当输入又当输出:
echo_eof < mywords > mywords是错的——> mywords会先把原文件清空成零长度,再用它做输入,什么都读不到!- 重定向符只能连接”程序+数据文件”,不能文件连文件、程序连程序(程序连程序用管道
|)。- 输入只能来自一个文件,输出也只能到一个文件。
如果系统不支持重定向,可以让程序自己打开文件——程序清单 8.3(file_eof.c)提前亮了个相,细节留到第 13 章:把 getchar() 换成 getc(fp)(从指定文件读),再加 fp = fopen(fname, "r") 打开、fclose(fp) 关闭,打开失败时 exit(1) 退出。有了 EOF,第 7 章的字数统计程序只要把 ch 改成 int、STOP '|' 改成 EOF,就能统计任意文本文件了。
8.5 创建更友好的用户界面
缓冲输入带来的麻烦:回车也是字符
缓冲输入要求按回车提交,但回车本身也变成换行字符 \n 留在输入里——程序必须处理它。看一个猜数字程序(你心里想个数,电脑来猜):
/* guess.c -- 又慢又有毛病的猜数程序(程序清单 8.4) */
#include <stdio.h>
int main(void)
{
int guess = 1;
printf("Pick an integer from 1 to 100. I will try to guess ");
printf("it.\nRespond with a y if my guess is right and with");
printf("\nan n if it is wrong.\n");
printf("Uh...is your number %d?\n", guess);
while (getchar() != 'y') /* 读回答,与 y 比较 */
printf("Well, then, is it %d?\n", ++guess);
printf("I knew I could do it!\n");
return 0;
}毛病来了:每输入一个 n,程序竟然猜了两次!原因是程序把 n 当成”不是 1”处理后,还把紧跟的换行符当成”不是 2”又处理了一遍。修复:内层循环把本行剩余字符(含换行符)全部丢弃:
while (getchar() != 'y') /* 读回答 */
{
printf("Well, then, is it %d?\n", ++guess);
while (getchar() != '\n')
continue; /* 跳过本行剩余输入 */
}这样输入 no、no way 和 n 效果相同。想进一步区分”回答了 n”和”胡乱输入”,加一个 if 筛选:先 response = getchar() 存下首字符,是 ‘n’ 就继续猜,否则提示 Sorry, I understand only y or n.。
交互程序的设计心法
写交互程序时,先设想用户所有可能的”不按套路出牌”,再设计程序优雅应对:错了就提示、给机会重试。指令写得再清楚,也总会有人理解错然后把锅甩给程序——健壮的输入处理是程序员的自我修养。
字符与数字混合输入的陷阱
getchar() 每个字符都读(包括空格、制表符、换行符),而 scanf() 读数字时会跳过空白——两者混用就容易出事。清单 8.5(showchar1.c)想读入一个字符和两个整数,按行列打印该字符,结果第一轮正常、第二轮还没等输入就退出了:scanf() 读完 3 之后把换行符留在了输入队列,下一轮 getchar() 不跳过换行符,把它读进 ch——而 ch 是换行符正是 while 的退出条件。
修复版(程序清单 8.6 showchar2.c):scanf() 之后把本行剩余字符全部丢弃,并让 scanf() 读不到两个整数(含遇到 EOF)时也终止:
/* showchar2.c -- 按行列打印字符(程序清单 8.6) */
#include <stdio.h>
void display(char cr, int lines, int width);
int main(void)
{
int ch; // 要打印的字符
int rows, cols; // 行数和列数
printf("Enter a character and two integers:\n");
while ((ch = getchar()) != '\n')
{
if (scanf("%d %d", &rows, &cols) != 2)
break; // 数字输入失败就退出
display(ch, rows, cols);
while (getchar() != '\n')
continue; // 丢弃本行剩余字符
printf("Enter another character and two integers;\n");
printf("Enter a newline to quit.\n");
}
printf("Bye.\n");
return 0;
}
void display(char cr, int lines, int width)
{
int row, col;
for (row = 1; row <= lines; row++)
{
for (col = 1; col <= width; col++)
putchar(cr);
putchar('\n'); // 结束一行,开始新的一行
}
}现在随便怎么输入都没问题了:c 1 2 打出 1 行 2 列的 cc,! 3 6 打出 3 行 6 列的 !。
8.6 输入验证
用户并不总照说明输入:该输非负整数时输个负数,该输数字时输个 q。输入验证(input validation)就是提前预料错误并妥善处理,共三层防线。
第一层:检查取值范围。 用关系表达式把越界值挡在循环外:
while (n >= 0) // 检测越界值
{
// 处理 n
scanf("%d", &n); // 读下一个值
}第二层:检查输入类型。 用 scanf() 的返回值——它返回成功读取的项目数,scanf("%d", &n) == 1 为真当且仅当用户真的输入了整数。两层可合并:while (scanf("%d", &n) == 1 && n >= 0) { ... }。这样输错类型程序就终止;但更友好的做法是给用户重试机会——注意 scanf() 失败时坏输入还留在输入队列里,必须先用 getchar() 清走。打包成可复用函数:
int get_int(void)
{
int input;
char ch;
while (scanf("%d", &input) != 1)
{
while ((ch = getchar()) != '\n')
putchar(ch); // 逐字符回显并丢弃坏输入
printf(" is not an integer.\nPlease enter an ");
printf("integer value, such as 25, -178, or 3: ");
}
return input;
}第三层:检查值的合理性。 比如要求输入一个范围(下限不大于上限、且都在允许区间内),程序清单 8.7(checking.c)为此写了验证函数(返回 true 表示有问题,所以叫 bad_limits):
bool bad_limits(int begin, int end, int low, int high)
{
bool not_good = false;
if (begin > end)
{
printf("%d isn't smaller than %d.\n", begin, end);
not_good = true;
}
if (begin < low || end < low)
{
printf("Values must be %d or greater.\n", low);
not_good = true;
}
if (begin > high || end > high)
{
printf("Values must be %d or less.\n", high);
not_good = true;
}
return not_good;
}checking.c 用这两个函数配合 sum_squares()(累加区间内整数平方,返回 double),计算指定区间平方和,上下限限定 ±1000,输入两个 0 退出:
start = get_int();
stop = get_int();
while (start != 0 || stop != 0)
{
if (bad_limits(start, stop, MIN, MAX))
printf("Please try again.\n");
else
{
answer = sum_squares(start, stop);
printf("The sum of the squares of the integers ");
printf("from %d to %d is %g\n", start, stop, answer);
}
...
}lower limit: low
low is not an integer.
Please enter an integer value, such as 25, -178, or 3: 3
upper limit: a big number
a big number is not an integer.
Please enter an integer value, such as 25, -178, or 3: 12
The sum of the squares of the integers from 3 to 12 is 645计算核心 sum_squares() 只有一行循环,篇幅全花在输入验证上——真实程序往往如此。程序采用模块化(modular)结构:main() 管流程,get_int() 管拿数,bad_limits() 管检查,sum_squares() 管计算,各司其职。
想写好坏输入处理,必须明白 C 眼中的输入是什么。看这行输入 is 28 12.4:我们眼里是”字符串、整数、浮点数”,C 程序眼里只是一串字节(字母 i 的字符码、字母 s 的字符码、空格的字符码……),所以 get_int() 会把整行当字符全部丢弃。虽然输入本质是字符流,scanf() 能按格式说明符把字符转换成数值:同样是输入 42,%c 只读到字符 ‘4’,%s 读到字符串 “42”,%d 算出 4×10+2 存入 int,%f 转成浮点表示存入 float。输入永远是字符流,scanf() 只是转换器。
8.7 菜单浏览
菜单(menu)让程序好用,却让程序员头疼——难就难在”用户不按套路出牌”时也要平稳运行。程序要做两件事:获取选择;根据选择执行动作,然后回到菜单。switch 天生适合分发动作(每个选项对应一个 case),while 提供反复回到菜单的能力。关键设计:把”获取选择”封装成函数,保证它只返回合法值,switch 就不用操心垃圾输入:
while ((choice = get_choice()) != 'q')
{
switch (choice)
{
case 'a': printf("Buy low, sell high.\n");
break;
case 'b': putchar('\a'); /* 响铃(ANSI) */
break;
case 'c': count();
break;
default: printf("Program error!\n");
break;
}
}default 分支是调试好帮手:如果 get_choice() 失职放过了非法值,default 立刻报警。
get_choice() 与 get_first() 函数
get_choice() 的设计:显示选项 → 读取回应 → 不合格就提示重读 → 直到合格。第一版实现的毛病:缓冲输入下每个回车产生的换行符都被当成”非法回应”,提示刷屏。解决:换成 get_first()——只取每行第一个字符,其余丢弃;附带好处是输入 act 等价于 a,而不是”一个 a 加一个 c”:
char get_choice(void)
{
int ch;
printf("Enter the letter of your choice:\n");
printf("a. advice b. bell\n");
printf("c. count q. quit\n");
ch = get_first();
while ((ch < 'a' || ch > 'c') && ch != 'q')
{
printf("Please respond with a, b, c, or q.\n");
ch = get_first();
}
return ch;
}
char get_first(void)
{
int ch;
ch = getchar(); /* 读下一个字符 */
while (getchar() != '\n')
continue; /* 跳过本行剩余字符 */
return ch;
}菜单里的混合输入问题
count() 函数要用 scanf() 读整数——又踩到老陷阱:scanf() 读完数字后换行符留在队列里,下一次 get_first() 会把它当输入,程序行为错乱。两种修法:改造 get_first() 让它返回第一个非空白字符(留作本章编程练习);或让 count() 自己收拾残局——读完数字后清空本行。程序清单 8.8(menuette.c)采用后者,把所有部件组装成最终菜单程序:
void count(void)
{
int n, i;
printf("Count how far? Enter an integer:\n");
n = get_int(); // 带验证的整数输入
for (i = 1; i <= n; i++)
printf("%d\n", i);
while (getchar() != '\n') // 清掉数字后面的换行符
continue;
}运行效果:选 a 打印建议、选 b 响铃、选 c 先验证输入再计数(输 two 会被要求重输)、选 d 提示合法选项、选 q 退出。
菜单程序的复利效应
让菜单界面丝滑运转确实费工夫,但一旦开发出可行方案(get_choice + get_first + get_int 这套组合拳),就能在各种程序里反复复用。注意书中的设计模式:每个函数遇到复杂任务就委派给另一个函数——层层委派造就了高度模块化的程序。
本章小结
- 系统通常使用行缓冲输入:按回车才把整行(连同换行符)交给程序,ANSI C 把缓冲输入定为标准。
- C 通过标准 I/O 包统一处理各种文件与设备,键盘和屏幕也是自动打开的”文件”(流 stdin/stdout)。
- getchar() 和 scanf() 检测到文件结尾时返回 EOF(stdio.h 中定义),Unix 用行首 Ctrl+D、DOS/Windows 用行首 Ctrl+Z 从键盘模拟。
- 重定向(
<、>)是操作系统能力:输入来自文件、输出去往文件,读输入到 EOF 的程序天然可配合文件使用。 - getchar() 与 scanf() 混用时,scanf() 留下的换行符是最大隐患,
while (getchar() != '\n') continue;清行是万能解法。 - 输入验证三层防线:类型对不对(scanf 返回值)→ 范围对不对(关系表达式)→ 值合不合理(专用验证函数)。
本章自测
自测题(5 题,点击展开答案)
1.
putchar(getchar())是有效表达式吗?它做什么?getchar(putchar())也有效吗?答案:有效。getchar() 读一个字符,putchar() 立刻把它打印出来,合起来就是”回显一个字符”。
getchar(putchar())无效——getchar() 不带参数,不能往里塞东西。2. EOF 是什么?为什么接收 getchar() 返回值的变量要声明成 int?
答案:EOF 是 stdio.h 中定义的符号常量(通常为 -1),表示检测到文件结尾;它不是文件里真实存在的字符。因为 char 可能是无符号类型(0~255),存不下 -1;而 getchar() 的返回类型本来就是 int,用 int 变量接才能可靠地与 EOF 比较,否则可能死循环。
3. 可执行程序 count 能统计输入的字符数。写出统计文件 essay 的字符数并存入文件 essayct 的命令行;再判断
count essay是否正确。答案:
count < essay > essayct(输入来自 essay,输出写进 essayct)。count essay不对——重定向靠</>符号,直接把文件名当参数传给程序,需要程序里写好处理命令行参数的代码(那是第 11 章的内容)。4. 为什么程序清单 8.4 的猜数程序每输入一个 n 会连续猜两次?怎么修?
答案:缓冲输入下按 n 再按回车,输入队列里是
n和换行符\n两个字符。程序把 n 当一次否定,又把换行符当另一次否定,于是猜了两次。修法:处理完首字符后,用while (getchar() != '\n') continue;把本行剩余字符(含换行符)全部丢弃。5. 程序先 scanf(“%d”, &n) 读到一个数,接着调用 getchar() 想读用户的下一个命令字符,结果拿到的是换行符——发生了什么?至少说出两种解决办法。
答案:scanf() 读数字时停在数字之后,把换行符留在输入队列;getchar() 不跳过空白,直接读到了它。解决办法:(1) 在 scanf() 之后用
while (getchar() != '\n') continue;清掉本行剩余字符;(2) 改用只返回第一个非空白字符的 get_first() 式函数;(3) 让产生数字输入的那个函数自己负责清行(如 menuette.c 中 count() 的做法)。