对大多数程序员来说scanf可以能是最熟悉,也是陌生的工具。在学习C语言时,大家一定没少用它,但是对它也知道不多。比如说,它有哪些可能的返回值?又比如怎么样才能跳过回车,读一个字符?我们可以一起来研究一下,为什么scanf会设计成这样子,我们如何更好的使用它?如何扩展它?
处理好IO不容易--scanf的返回值设计
如果我们有这样一个函数int readInt()是不是比scanf更好用呢?一切正常时OK,但有些情况下不一定。
int readInt();
比如要1 2 3 4 5这样的数据,开始它很好用,但是如何决定已经结束了呢?按照C语言的惯例,我们用返回值来表示出错,接口变成int readInt(int *data)
int readInt(int *data);
当函数遇到文件结束时,返回EOF,值可能是-1。
当成功时,返回0,虽然一般是用0表示失败,不过因为出错已经返回-1,这里定义为0。
我们很容易注意到scanf可能有多个数据,当scanf返回,是否每个数据都已经赋值了?也许是,也许不是,这时我们访问data的数据就会得到上一次的结果。
如果只是数据不足或是没有适合的数据,可能我们返回已经赋值的数量也许是一个选择。对于以下代码,我们可能返回2。
ret = sscanf("1 2 3 a", "%d%d%d", &a, &b, &c);
如果是真的出错了,比如读到了文件结尾,这时我们只能返回出错,如EOF,可能就无法知道在出错前正确处理了几个数据。
不过,通常一组数据如果有一个无效,我们不关心其它几个正常的数据,所以标准库中还是把它处理成了这样。这个返回值的设计相当不完美,但体现了实用性原则。
组合爆炸—转型
如果只是简单读取整数、浮点数和字符串,我们完全可以设计一组接口,比如:
int readDecimal (int *d);
int readFlt(int *f);
int readStr(char *s);
int readChar(char *s);
这样我们的代码就很难看了,完成同样的功能可能会比scanf多很多代码。C语言选择另一种方式来定义接口,一个小型的说明性语言。
上面的接口中,除了返回值外,只有函数名和参数类型不同,如果用一个字符代表来参数类型,我们可能定义这样一个接口。
int readX (char x, void*d);
因为C语言中没有通用类型,我们使用void类型指针来定义数据。在实现时,可以根据x的值转成相应的类型。
if (x == 'd') return readDecimal((int*)d);
if (x == 'f') return readFlt((float*)d);
if (x == 's') return readStr((char*)d);
其实C语言中并不需要这个转型,因为void*可以赋值任何类型的指针,这里强制转型只是为了明确。
注意,整型可能有八进制、十进制、甚至十六进制的表示法,也就是说,对于int*类型,我们需要用不同的函数来读取不同的表示法。当然,我们可再引入一些其它字符来区分不同的表示,比如o, d, x分别表示八进制、十进制和十六进制。
另一个问题是,整型还有short/long的区别,我们不得不再引入一个字符来表示比如h和l分别表示short int和long int,这时接口已经变成了下面这样,相当接近scanf了。X可能有时可能是两个字符,有时可能是一个。
int readX (char* x, void*d);
想想如果用函数接口,我们需要多少个接口?事实上,我们还有更复杂的情况,如有符号和无符号整数,不同的浮点数表示法,这就是一个组合爆炸。
下表解析了标准库中已经定义的格式转换,外部表示法指示不同的外部数据形式,数据类型说明了保存数据需要的指针类型,空白表示没有这个组合。hh,ll,j,z,t都是C99才引入的。
外部表示法 |
||||||
数据类型 |
d i |
u o x |
f e g a |
c s [] [^] |
p |
n |
(none) |
int* |
unsigned int* |
float* |
char* |
void** |
int* |
hh |
signed char* |
unsigned char* |
signed char* |
|||
h |
short int* |
unsigned short int* |
short int* |
|||
l |
long int* |
unsigned long int* |
double* |
wchar_t* |
long int* |
|
ll |
long long int* |
unsigned long long int* |
long long int* |
|||
j |
||||||
z |
||||||
t |
||||||
L |
long double* |
灵活性不易得
有几个常见的情况我们不得不注意,一是数据前后的空白字符,二是分隔符问题。空白字符其实很好解决,只要在读取数据前跳过空白的字符就好了,但是当空白字符也是数据时,这个问题就难办了。这时就需要一个明确的说明,哪此空白字符是需要跳过,哪些需要读取。从可读性来说,我们可以用一个空格来表示,这里要跳过空白字符,用c来表示,这里要读取字符,无论有多少个。例如,"ldc hd"可以表示,先读取一个long int,再读取一个字符,跳过后面的空白,再读取一个short int。为了方便发现我们到底要传入几个数据指针,我们用%和*来标记,%要保存到数据,*不保存数据,上面的例子变成%ld*c %hd。
经过对空白字符的处理另一个问题也很好解决了, 我们只要把分隔符原样写出来,让它们表示这里要读取对应的符号就可以,如以逗号分隔,即可以写成"ld,"。不过,对于cdfosx这几个已经有含义的字符同,我们需要特殊处理,可以通过前面的%来区别,因此也需要为*c加上一个前缀%。
有了这个模式后,我们再加入一些新的特性就比较简单了,如我们可以要求一个数据只解析一定长度,即宽度限制,这个写在%以后即可。如%3d%3d可以解析315248为315和248。
不过在C语言的标准定义中,数值会自动跳过前导空白字符,但不会跳过数值后面的空白。
最特别是的,如果以%为分隔符,我们需要在格式中说明%%。
解决了空白字符后,对于字符串中的空白我们还不好解决,这时通过引入一个新的外部表示,表示可以包括空白的字符串。事实上,标准中定义了两种特别字符串,一是只能包含一些字符的字符串,另一种是除了一些字符不包含外,其它字符都可以的字符串,分别是[]和[^]。使用时在中括号中写上可以包含或需要禁止的那些字符。
超越C语言标准
特别说明,以下不是C语言标准实现,不能直接使用。需要使用xscanf这个库。
沿着说明式接口这条路,我们还可以走的更远一点,比如引入数组。数组是重复同一个说明符号,简单的方式我们引入#,如#%d,即可完成读取一串整数,直到行尾,这个最常用的情况。
int n, a[32];
xscanf("#32%d", &n, a);
上面的代码,会读取一行上最多32个整型,实际读取了多个数保存在n中。
下面是完成同样功能的代码
char line[1024]; int off = 0, read; fgets(line, sizeof(line), stdin); *n = 0; while (sscanf(line + off, "%d%n", a + *n, &read) > 0) { ++*n; off+=read; }