0%

scanf——从入门到放弃

欢迎!

从C入门的同学们,对scanf一定不陌生。C++入门的同学们最早学的应该是cincout——不过很快,就会听到有人说:scanfcin快N多倍!至于速度怎么样,可以看我的另一篇文章:c++的IO优化
然而,无数的人都在奔走相告:放弃scanf吧!不要再用scanf了!实际上也没有那么严重——只要你知道scanf是怎么运作的就好。先给解决方案吧:不想用scanf的同学可以先用fgets或者getline拿到一整行,存入字符串,再用sscanf处理字符串。

大多数初学者可能都没有在scanf上停留多久——包括我。那些快速入门教程,清华给非信息学院开设的C课程,以及那些《Primer》重量级以下的那些C/C++入门书,似乎都对这么基础的事情简单略过,只是讲明白它的“用法”,%c %d %a %lf的意义,再给几个能跑的例子让大家照葫芦画瓢,而没有停下来,详细地讲它的“原理”或者“内部执行步骤”。

scanf的工作原理

我们在理解scanf时,一定要记住:scanf是处理输入流的函数,而不是一个“输入”函数。scanf会一个个从输入流拿出字符,而那些不匹配的符号(比如读%s时遇到的空白字符)也会被放回输入流。

scanf的工作原理分为三步:

  1. 遇到%c时:读一个字符,继续。不跳过任何字符

  2. 遇到%d,%s一类的输入占位符(除了%c)时:

    1. 跳过(也就是从输入流拿出并丢弃)空白符,直到找到第一个非空白字符。

    2. 读入,直到遇到第一个不符合条件的字符(比如,%d中的非数字字符,和%s的空白字符

      注意,%s只从非空白字符开始读,也只读到非空白字符,也就是只能读“单词”而非“句子”

      %d也可以在一开始接受正号和负号

    3. 把那个不符合条件的字符放回输入流。

      这就是为什么上面的例子出现读空行等情况。

  3. 遇到空格时:读入零个或若干个空白字符(不只是空格),直到遇到一个非空白字符。

  4. 遇到其它字符时:读入完全匹配的字符,否则出错停止。

    比如,scanf("%d,%d,%d", &a, &b, &c);可以正确读入1, 2, 3(2和3在读入前可以跳过空白字符)而不能正确读入1 ,2 ,3(逗号必须完全匹配,不跳过空白字符)。

另外,scanf在扫过格式化串时,只要一个地方匹配出错就会停止。并且,那个不满足要求的字符也会被放回输入流。

几个有用的特性

进制和浮点数

%e,%f,%g,%a,%E,%F,%G,%A都代表读入浮点数。

%i代表十进制;%o代表八进制;%x,%X代表十六进制(都是有符号的)。%u代表无符号十进制。

这些在进制转换里很有用——sprintf或者sscanf就能解决进制转换,而不用手写转换代码了。

星号(学名:滞后赋值)

%*d%*s

相当于跳过本输入。会读入一个数字%*d或者字符串%*s,正常读取,然后丢掉。不计入返回值里的“成功读取的个数”,也不占用后面的那些地址参数。

举例:处理一个加法式子“123+456=579”,提取所有数字:

1
scanf("%d%*[^d]%d%*[^d]%d",%a,%b,%c);

即可。

数字(学名:最大字符宽度)

%10d%10s

最多只读这么多个字符。

方括号

%[d],或者%*[d]

匹配若干个方括号内的字符,直到遇到不符合的。%[d]s会读若干个d直到遇到非d字符;%*[d]则会读若干个d丢弃。例如:

1
2
3
char name[20];
scanf("%*[d]%s",&name);
printf("%s",name);

输入ddd111会被%*[d]部分丢弃ddd,最后输出111

注意:这个特性不能和%d等混搭,或者说,%后的方括号实际上取代了%s中的s的位置,代表读入(或者用星号丢弃)一个字符串。比如:你不能写%[0-3]d来表示你想把读到的东西转化为数字。这个d会被解释称单独的符号——前面介绍过(那个“%d,%d,%d”的例子),scanf会在读完0-3数字(也就是遇到第一个不符合0-3的字符时)把它与d匹配。

方括号内的格式:

  • [ab]代表a和b都符合。

  • [0-9]代表0和9之间的所有字符都匹配。注:这并不是固定用法,而是ASCII码匹配(包含0和9)。例如,[0-;]包含数字0~9,逗号和分号(ASCII码表里,9后面是逗号,然后是分号)。

    实际上[0-9]的0和9掉个个也没关系——[9-0]也可以表达同样的含义。

  • [^a]代表除了a以外的所有字符。

以上三点可以任意组合:[0-9,a-z]代表匹配数字,逗号,和小写字母。[^0-9,a-z]代表不匹配这些字符。

例如:

1
2
3
char name[20];
scanf("%*[0-9,a-z]%s",&name);
printf("%s",name);

输入abc,1,23,#d,输出#d

那么你对scanf又理解到什么程度呢?举几个简单的例子吧。

简单的例子

1
2
3
4
char s[20];scanf("%s",s);
printf("str:'%s'\n",s);
char c = getchar();
printf("char:'%c'\n",c);

如果输入一行123,回车,会怎么样呢:输出情况是:

1
2
3
str:'123'
char:'
'

getchar()换成getline()也一样,在同样的只输入一行123的情况下,getline()也是会读取那个留下的\n,读了个空行。

那么下面举个比较复杂的例子:比如说,统计第一个数字前的字符个数,比如“C++11”输出3。如果你这样写:

1
2
3
4
5
6
7
8
#include<cstdio>

int main(void)
{
int a,num=0;
while (scanf("%d", &a) != 1)++num;
printf("%d",num);
}

这里利用scanf的返回值:返回成功解析了的对象的个数。例如,读入3个数字的scanf("%d%d%d", &a, &b, &c)如果成功,会返回3。读到输入尽头(例如文件末尾,或者Windows下的键盘输入里,在空行中按Ctrl+Z再按回车)会返回EOF(一个定义好的常量,一般是-1)。返回值也可以用来调试这些scanf问题。

程序会死循环!(你可以在循环里打印点东西来证明,它不是等待输入,确实是死循环了)实际上需要这样:

1
2
3
4
5
6
7
8
9
#include<cstdio>

int main(void)
{
int a,num=0;
char c=5;
while (scanf("%d", &a) != 1 && scanf("%c",&c) == 1)++num;
printf("%d",num);
}

(当然,没有考虑输入“C++”之类没有数字的情况,这个还需要额外的代码)

关于回车

读文件和从键盘输入的一大区别在于回车:读文件可以一股脑读到底,而键盘键入时,键入一行,回车之后这一行被电脑读入,然后电脑等待下一行。不过,电脑是只能看到输入流的,他看到你的回车也只是一个\n字符而已。

也就是,数据从用户到程序内一共有三层:用户在控制台敲一行内容 > 用户按回车,内容被读入进入输入流缓冲区 > 被程序内的scanf等函数读入。

另外一点:C++并没有什么简洁而跨平台的函数可以做到,从键盘(标准输入)读入时,可以不等回车就读入字符,比方说,捕获用户按下的每一个按键。conio.h头文件的_getch()函数是从CRT(C runtime)维度获得实时按键的,不在C标准里,而它在某些平台(比如UWP)就不能工作。

边角情况

大略讲一下边角情况吧,也就是OJ以外的,更贴近生活的情况。

在OJ里,输入输出的格式都是固定的,而如果你要做一些生活中的小程序的话,你需要考虑到任何用户可能输入的情况,让代码更加鲁棒。

比如,你希望用scanf输入一些“空格和换行符”分隔的字符串:

1
2
char s[20];
scanf("%s",s);

首先,你要考虑到超限的情况。比如你可以改成:

1
2
char s[20];
scanf("%19s",s);

预留一个放\0的地方。其次,你要考虑——只用“空格和换行符”的话,说明\t(就是键盘上敲tab键)分割可不能算分隔符啊。

1
2
char s[20];
scanf("%19[^ \n]",s);

还要考虑,如果用户直接按回车,s内的东西就是未初始化的…所以在%前面加一个空格,代表跳过前置的任意个空白字符:

1
2
char s[20];
scanf(" %19[^ \n]",s);

就不再展开了。通过这些例子,你应该看明白了:scanf大概真的很不适合读用户输入,它最方便的还是读那些确定格式的(甚至,最好是确定数量的)输入。

引用paxdiablo在stackoverflow上的话结束本节:“The whole point of scanf is scan-formatted and there is little more unformatted than user input”(scanf意图是读那些有格式的输入,而没什么比用户输入更缺乏‘格式’了)。

结语

scanf——从入门到放弃。它看上去很简单,很小巧,很人畜无害,但其实很强大,而,“from great power comes great responsibility”,越强大的函数越不好驾驭。在复杂的场景下,比如字符串输入和数字输入交错的,输入行数不定的,等等,还是推荐大家先用fgets或者getline拿到一整行,存入字符串,再用sscanf处理——把处理过程从输入过程剥离开,牢牢攥在掌心。