第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

重定向的三条禁忌

  1. 别把同一个文件既当输入又当输出echo_eof < mywords > mywords 是错的——> mywords 会先把原文件清空成零长度,再用它做输入,什么都读不到!
  2. 重定向符只能连接”程序+数据文件”,不能文件连文件、程序连程序(程序连程序用管道 |)。
  3. 输入只能来自一个文件,输出也只能到一个文件。

如果系统不支持重定向,可以让程序自己打开文件——程序清单 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 返回值)→ 范围对不对(关系表达式)→ 值合不合理(专用验证函数)。

本章自测