用 "%d\n" 要多读一个数? —— scanf() 函数的那些坑

C 语言温故而知新(二)

Posted by Shao Guoji on May 23, 2017

系列文章目录

数据的存储与表示 —— 所见非所得 - C 语言温故而知新(一)

用 “%d\n” 要多读一个数? —— scanf() 函数的那些坑 - C 语言温故而知新(二)



内容简介

今天在用 "%d\n" 进行 scanf() 数据读取时出现了很诡异的现象:必须多输入一个数才能结束!看了一下网上的分析都讲的比较简单,想起之前因为 scanf() 使用不当导致的死循环问题,才觉得 scanf() 真是一个很「奇妙」的函数,不妨来详细分析一下这两个案例。



格式串结尾空白符引发的血案

数组连续赋值程序

功能:通过 for 循环给数组连续赋值,用回车键作为分隔。代码如下:

#include <stdio.h>

int main()
{
    int a[5];
    int i;
    
    for (i = 0; i < 5; i++)
        scanf("%d\n", &a[i]);
    
    for (i = 0; i < 5; i++)
        printf("%d ", a[i]);
    
    return 0;
}

运行结果:

1
2
3
4
5
6
1 2 3 4 5

发现输入了五个数据后程序并没有结束,而是要再输入第六个数才会执行下面的打印输出代码,这是怎么回事?虽然从最后的输出结果来看,数据被确正确写入了,但这多的一次输入是从哪冒出来的呢?

scanf() 与输入缓冲区

我们不时会听到「缓冲区」的概念,说白了,输入缓冲区就是键盘按下后键值的直接去处,也是 scanf()getchar() 等函数的数据来源。而且「函数从缓冲区中读走字符」和「键盘把键值存到缓冲区」这两个过程相互独立、互不干扰,即「你存你的,我取我的」。当缓冲区为空时,调用 scanf() 函数会使程序处于等待输入状态(即调用函数后啥也没输时),而如果缓冲区中有内容,则直接读取,而不用等待。

scanf() 工作流程

scanf() 函数看似与 printf() 函数的用法差不多,但实际上 scanf() 要更为复杂。scanf() 函数的大致工作流程如下:

  1. 从左到右处理格式字符串
  2. 遇到转换说明符(如 %d)后,scanf() 尝试从输入的数据(来自输入缓冲区)中定位相应类型的项,并且跳过必要的空格
  3. 读取数据项,并且在遇到不可能属于此数据项类型的字符停止
  4. 若数据项读取成功,将其写入对应变量(通过指针访问变量空间)
  5. 继续处理格式字符串的剩余部分
  6. 格式字符串处理完毕,结束调用

注意到 scanf() 读入数据时会跳过必要的空白符(如空格、换行、制表符等),准确来说是在读取非字符型数据时才跳过。也就是说在多个输入数据之间本来就可以随便加空白字符,对于下面的常见代码:

#include <stdio.h>

int main()
{
    int i, j;
    
    scanf("%d%d", &i, &j);
    
    printf("i = %d, j = %d\n", i, j);
    
    return 0;
}

在输入了第一个数后,可以先随便乱敲任意个空格、回车或换行键后再输入第二个数,程序照样正常运行,即使格式字符串中的 %d%d 是紧挨着的。这么说来我在格式化字符中写的 \n 纯属画蛇添足……

震惊!格式串中的空白符竟然……

喂喂喂等一下,不对啊,我明明见过 scanf("%d\n%d", &i, &j); 或者 scanf("%d %d", &i, &j); 这种格式串中带空白符的写法啊!是的,当然可以,但这里的空白符并没有想象中的那么简单:

可能令人吃惊,\n在scanf格式串中不表示等待换行符,而是读取并放弃连续的空白字符。(事实上,scanf格式串中的任何空白字符都表示读取并放弃空白字符。而且,诸如%d这样的格式也会扔掉前边的空白,因此你通常根本不需要在scanf格式串中加入显式的空白。) —— 《你必须知道的495个C语言问题》- 第12章标准输入输出库

何止令人吃惊,简直毁三观呐!原来 scanf() 格式串中的空白符并不表示匹配此字符,而是重新读取一个非空白字符。scanf("%d\n%d", &i, &j); 或者 scanf("%d %d", &i, &j); 这种写法中空白符位于两个数据转换符之间,所以体现不出这个奇葩特性(空白符后本来就要读取一个非空白的 %d 数据)。但是如果空白符位于格式串的末尾,问题就来了 —— 在按指定方式读取后要求多读一个非空白字符,下面这段简单的代码就展示了这种现象:

#include <stdio.h>

int main()
{
    int i;
    
    scanf("%d\n", &i);
    
    printf("i = %d\n", i);
    
    return 0;
}

运行结果:

520
1314
i = 520

在输入第一个数据 520 后,格式串遇到了空白符 \n,此时scanf() 仍然处于等待状态,继续读取下一个非空白符后才会结束。

进一步验证

在另一本书《C语言程序设计现代方法》中也提到了类似的注意事项:

虽然 printf 格式串经常以 \n 结尾,但是在 scanf 格式串末尾放置换行符通常是一个坏主意。对 scanf 函数来说,格式串中的换行符等价于一个空格,两者都会引发 scanf 函数提前进入到下一个非空白的字符。例如,如果有格式串 “%d\n”,那么 scanf 函数将跳过空白字符,读取一个整数,然后跳到下一个非空白字符处。像这样的格式串可能会导致交互式程序一直「挂起」直到用户输入一个非空白字符为止。 —— 《C语言程序设计现代方法》- 3.2.3 混淆 printf 函数和 scanf 函数

有点不可思议是吧……半信半疑的我又写了一个程序,更直观有力地证明了这一点:

#include <stdio.h>

int main()
{
    int i = 0, j = 0;
    
    scanf("%d\n", &i);
    
    printf("Pause...\n");
    printf("1: i = %d, j = %d\n", i, j);
    
    scanf("%d", &j);
    
    printf("2: i = %d, j = %d\n", i, j);
    
    return 0;
}

运行结果:

520
1314
Pause...
1: i = 520, j = 0
2: i = 520, j = 1314

可见在输入完第二个数据 1314 之前,程序一直停在第一条 scanf() 语句处,正是因为格式串中的 \n 导致的「挂起」,当非空白字符 1314 输入完成后第一条 scanf() 才结束,并将第一个数据 520 写入变量 i,随后打印提示信息 Pause, 变量 j 也还没有写入值。这时缓冲区中剩下字符 1314,所以当第二个 scanf() 执行时可以直接从缓冲区读取数据并写入变量 j,不需要再手动输入。

再回头看文章开始处的数组连续赋值代码,将 for 循环中的 5 次 scanf() 格式串组合,等价于 "%d\n%d\n%d\n%d\n%d\n",根据前面的分析,中间被 %d 夹着的 \n 对输入没有影响,而最后的 \n 会多等待一个非空白符,导致输入数据时多了一次,真相大白。

要使这个程序正常执行,可将 for 循环中输入语句改成 scanf("\n%d", &a[i]);,只要确保格式串最后一个字符不是空白符即可。

总结:利用 scanf() 函数读取多个数据时,若以空白符分隔,可在格式串中省略不写(如果是其他逗号什么的当然要写啦),就算要写也不要写在格式串最后啊啊啊!!!



读取失败、缓冲区残留问题

在上面最后一个例子中, 输入缓冲区中的 520 和 1314 分两次被 scanf() 函数读走,即 scanf() 函数不是直接读键盘,而是间接从输入缓冲区中读取数据,并且一次没读完可以留着下一次调用时再读,这让我想起之前遇到的另一个问题 —— scanf() 非法输入导致死循环,简单分享下。

密码锁程序

功能:判断用户输入的密码(整型)是否正确,并实现输错连续重输:

#include <stdio.h>

int main()
{
    int password;
    
    printf("请输入密码:");
    
    scanf("%d", &password);
    
    while (password != 123)
    {
        printf("密码错误,请重新输入:");
        scanf("%d", &password);
    } 
    
    printf("密码正确,成功解锁!");
    
    return 0;
}

运行结果:

请输入密码:23
密码错误,请重新输入:235
密码错误,请重新输入:12
密码错误,请重新输入:334
密码错误,请重新输入:123
密码正确,成功解锁!

死循环问题

输入数字倒没什么问题,但如果输一个字母,噩梦就开始了……满屏的命令行被「密码错误,请重新输入:」所淹没。很明显,while 死循环了。为什么会这样呢?上面说过,scanf() 函数遇到不可能属于此数据项类型的字符时停止,由于我们输入的是字母,显然不能匹配格式串中的 %d,于是 scanf() 函数读入失败,啥也没干就直接结束,变量 password 的值没变。更为致命的是,非法的字符一直停留在缓冲区中,下一次进入循环时 scanf() 再次读到的结果依然非法、啥也不干,于是 scanf() 函数形同虚设,password 的值一直保持不变,造成死循环打印。

解决方法

解决办法也不难,只要稍作判断即可。其实 scanf() 函数是有返回值的,表示成功读入的数据项个数。可以利用这一点判断数据读取是否成功,再通过 getchar() 函数把缓冲区读空(清除残留字符),就能实现非法输入的处理,提高程序的可靠性。修改后代码如下:

#include <stdio.h>

int main()
{
    int password;
    
    printf("请输入密码:");
    
    while (password != 123)
    {
        if (scanf("%d", &password) == 0)
        {
            printf("非法字符,请重新输入:");
            while (getchar() != '\n'); // 清空缓冲区,或者用 fflush(stdin);
        }
        else if (password != 123)   
            printf("密码错误,请重新输入:");
    } 
    
    printf("密码正确,成功解锁!");
    
    return 0;
}

运行结果:

请输入密码:43
密码错误,请重新输入:345
密码错误,请重新输入:adf
非法字符,请重新输入:asdg
非法字符,请重新输入:234
密码错误,请重新输入:123
密码正确,成功解锁!


小结

大多数人都懂得 scanf() 函数的常规用法,但是在遇到「非主流」的冷门奇葩情况时,就要求我们scanf() 函数的执行流程以及输入缓冲区有一定的了解,才能解释一些诡异的现象。事实上 scanf() 函数并不常用,甚至还有的程序员建议干脆完全避免使用它,但作为学习,我们多了解一点也无妨,最后附上《C Primer Plus》中对 scanf() 说明的一段话。

Actually, scanf() is not the most commonly used input function in C. It is featured here because of its versatility (it can read all the different data types), but C has several other input functions, such as getchar() and gets(), that are better suited for specific tasks, such as reading single characters or reading strings containing spaces. —— 《C Primer Plus, Fifth Edition》- The scanf() View of Input




参考文章