11.2 字符串输入
如果想把一个字符串读入程序,首先必须预留存储该字符串的空间,然后用输入函数获取该字符串。
11.2.1 分配空间
要做的第1件事是分配空间,以存储稍后读入的字符串。这意味着必须要为字符串分配足够的空间。不要指望计算机在读取字符串时顺便计算它的长度,然后再分配空间(计算机不会这样做,除非你编写一个处理这些任务的函数)。假设编写了如下代码:
char *name;
scanf( "%s", name );
虽然可能会通过编译(编译器很可能给出警告),但是在读入name时,name可能会擦写掉程序中的数据或代码,从而导致程序异常中止。
因为scanf()要把信息拷贝至参数指定的地址上,而此时该参数是个未初始化的指针,name可能会指向任何地方。大多数程序员都认为这种情况很搞笑,但仅限于评价别人的程序时。(哈哈哈)
最简单的方法是,在声明时显式指明数组的大小:
char name[81];
现在name是一个已分配块(81字节)的地址。还有一种方式是使用C库函数来分配内存。
为字符串分配内存后,便可读入字符串。C库提供了许多读取字符串的函数;scanf()、gets()和fgets()。最常用gets()函数。
11.2.2 不幸的gets()函数
在读取字符串时,scanf()和转换说明%s只能读取一个单词。如果要读取一整行输入,gets()函数就用于处理这种情况。gets()函数简单易用,它读取整行输入,直至遇到换行符,然后丢弃换行符,存储其余字符,并在这些字符的末尾添加一个空字符使其成为一个C字符串。它经常和puts()函数配对使用,该函数用于显示字符串,并在末尾添加换行符。
/* getsputs.c -- using gets() and puts() */
#include
#define STLEN 81
int main(void)
{
char words[STLEN];
puts("Enter a string, please.");
gets(words); // typical use
printf("Your string twice:\n");
printf("%s\n", words);
puts(words);
puts("Done.");
return 0;
}
/* 输出:
*/
有些编译器在输出中插入了一行警告信息。每次运行这个程序,都会显示这行信息。
这是怎么回事?问题出在gets()唯一的参数是words,它无法检查数组是否装得下输入行。数组名会被转换成该数组首元素的地址,因此,gets()函数只知道数组的开始处,并不知道数组中有多少个元素。
如果输入的字符串过长,会导致缓冲区溢出(buffer overflow),即多余的字符超出了指定的目标空间。如果这些多余的字符只是占用了尚未使用的内存,就不会立即出现问题;如果它们擦写掉程序中的其他数据,会导致程序异常中止;或者还有其他情况。为了让输入的字符串容易溢出,把程序中的STLEN设置为5。
程序输出中的"Segmentation fault"(分段错误)似乎不是个好提示,的确如此。在UNIX系统中,这条信息说明该程序试图访问未分配的内存。
C提供解决某些编程问题的方法可能会导致陷入另一个尴尬棘手的困境。提到gets()函数是因为该函数的不安全行为造成了安全隐患。过去,有些人通过系统编程,利用gets()插入和运行一些破坏系统安全的代码。
不久,C编程社区的许多人建议在编程时摒弃gets()。制定C99标准的委员会把这些建议放入了标准,承认了gets()的问题并建议不要再使用它。尽管如此,在标准中保留gets()也合情合理,因为现有程序中含有大量使用该函数的代码。而且,只要使用得当,它的确是一个很方便的函数。
好景不长,C11标准委员会采取了更强硬的态度,直接从标准中废除了gets()函数。既然标准已经发布,那么编译器就必须根据标准来调整支持什么,不支持什么。然而在实际应用中,编译器为了能兼容以前的代码,大部分都继续支持gets()函数。
11.2.3 gets()的替代品
通常用fgets()来代替gets(),fgets()函数稍微复杂些,在处理输入方面与gets()略有不同。
C11标准新增的gets_s()函数也可代替gets()。该函数与gets()函数更接近,而且可以替换现有代码中的gets()。但是,它是stdio.h输入/输出函数系列中的可选扩展,所以支持C11的编译器也不一定支持它。
1.fgets()函数(和fputs())fgets()函数通过第2个参数限制读入的字符数来解决溢出的问题。该函数专门设计用于处理文件输入,所以一般情况下可能不太好用。
fgets()和gets()的区别如下:
*fgets()函数的第2个参数指明了读入字符的最大数量。如果该参数的值是n,那么fgets()将读入n-1个字符,或者读到遇到的第一个换行符为止。
*如果fgets()读到一个换行符,会把它存储在字符串中。这点与gets()不同,gets()会丢弃换行符。
*fgets()函数的第3个参数指明要读入的文件。如果读入从键盘输入的数据,则从stdin(标准输入)作为你参数,该标识符定义在stdio.h中。
因为fgets()函数把换行符放在字符串的末尾(假设输入行不溢出),通常要与fputs()函数(和puts()类似)配对使用,除非该函数不再字符串末尾添加换行符。fputs()函数的第2个参数指明它要写入的文件。如果要显示在计算机显示器上,应使用stdout(标准输出)作为该参数。
/* fgets1.c -- using fgets() and fputs() */
#include
#define STLEN 14
int main(void)
{
char words[STLEN];
puts("Enter a string, please.");
fgets(words, STLEN, stdin);
printf("Your string twice (puts(), then fputs()):\n");
puts(words);
fputs(words, stdout);
puts("Enter another string, please.");
fgets(words, STLEN, stdin);
printf("Your string twice (puts(), then fputs()):\n");
puts(words);
fputs(words, stdout);
puts("Done.");
return 0;
}
/* 输出:
*/
第1行输入,apple pie,比fgets()读入的整行输入短,因此,apple pie\n\0被存储在数组中。
第2行输入,strawberry shortcake,超过了大小的限制,所以fgets()只读入了13个字符,并把strawberry sh\0存储在数组中。
fgets()函数返回值返回指向char的指针。如果一切进行顺利,该函数返回的地址与传入的第1个参数相同。但是,如果函数读到文件结尾,它将返回一个特殊的指针:空指针(null pointer)。该指针保证不会指向有效的数据,所有可用于标识这种特殊情况。在代码中,可以用数字0来代替,不过在C语言中用宏NULL来代替更常见(如果在读入数据时出现某些错误,该函数也返回NULL)。
读入并显示用户输入的内容,直到fgets()读到文件结尾或空行(即,首字符是换行符)
/* fgets2.c -- using fgets() and fputs() */
#include
#define STLEN 10
int main(void)
{
char words[STLEN];
puts("Enter strings (empty line to quit):");
while (fgets(words, STLEN, stdin) != NULL && words[0] != '\n')
fputs(words, stdout);
puts("Done.");
return 0;
}
/* 输出:
*/
有意思,虽然STLEN被设置为10,但是该程序似乎在处理过长的输入时完全没问题。程序中一次读入STLEN-1个字符。之前的读入在没有读取到'\n'的情况,字符串都是以一个非'\n'字符和'\0'字符结尾的,读取到'\n'字符时,字符串是以'\n'和'\0'结尾。fputs()打印该字符串,由于字符串中的\n,光标被移至下一行开始处。
系统使用缓冲的I/O。这意味着用户在按下Return键之前,输入都被存储在临时存储区(即,缓冲区)中。按下Return键就在输入中增加了一个换行符,并把整行输入发送给fgets()。对于输出,fputs()把字符发送给另一个缓冲区,当发送换行符时,缓冲区中的内容被发送至屏幕上。
fgets()存储换行符有好处也有坏处。坏处是你可能并不想把换行符存储在字符串中,这样的换行符会带来一些麻烦。好处是对于存储的字符串而言,检查末尾是否有换行符可以判断是否读取了一整行。如果不是一整行,要妥善处理一行中剩下的字符。
首先,如何处理换行符?一个是在已存储的字符串中查找换行符,并将其替换成空字符:
while( words[i] != '\n' ) //假设\n在words
i++;
words[i] = '\0';
其次,如果仍有字符串留在输入行怎么办?一个可能的方法是,如果目标数组装不下一整行输入,就丢弃那些多出的字符:
while( getchar() != '\n' ) //读取但不存储输入,包括\n
continue;
程序读取输入行,删除存储在字符串中的换行符,如果没有换行符,则丢弃数组装不下的字符。
/* fgets3.c -- using fgets() */
#include
#define STLEN 10
int main(void)
{
char words[STLEN];
int i;
puts("Enter strings (empty line to quit):");
while (fgets(words, STLEN, stdin) != NULL
&& words[0] != '\n')
{
i = 0;
while (words[i] != '\n' && words[i] != '\0')
i++;
if (words[i] == '\n')
words[i] = '\0';
else // must have words[i] == '\0'
while (getchar() != '\n')
continue;
puts(words);
}
puts("done");
return 0;
}
/* 输出:
*/
空字符和空指针
从概念上看,两者完全不同。空字符(或'\0')是用于表示C字符末尾的字符,其对应字符编码是0。由于其他字符的编码不可能是0,所以不可能是字符串的一部分。
空指针(或NULL)有一个值,该值不会与任何数据的有效地址对应。通常,函数使用它返回一个有效地址表示某些特殊情况发生,例如遇到文件结尾或未能按预期进行。
空字符是整数类型,而空指针是指针类型。两者容易混淆的原因是:它们都可以用数值0来表示。但是,从概念上看,两者是不同类型的0。另外,空字符是一个字符,占1字节;而空指针是一个地址,通常占4字节。
2.gets_s()函数
C11新增的gets_s()函数(可选)和fgets()类似,用一个参数限制读入的字符数。假设把fgets3.c中的fgets()换成gets_s(),其他内容不变,那么下面的代码将把一行输入中的前9个字符读入words数组中,假设末尾有换行符:
gets_s()与fgets()的区别如下:
*gets_s()只从标准输入读取数据,所以不需要第3个参数。
*如果gets_s()读到换行符,会丢弃它而不是存储它。
*如果gets_s()读到最大字符数都没有读到换行符,会执行以下几步。首先把目标数组中的首字符设置为空字符,读取并丢弃随后的输入直至读到换行符或文件结尾,然后返回空指针。接着,调用依赖实现的“处理函数”(或你选择的其他函数),可能会中止或退出程序。
第2个特性说明,只要输入行未超过最大字符数,gets_s()和gets()几乎一样,完全可以用gets_s()替换gets()。第3个特性说明,要使用这个函数还需要进一步学习。
比较一下gets()、fgets()和gets_s()的适用性。如果目标存储区装得下输入行,3个函数都没问题。但是fgets()会保留输入末尾的换行符作为字符串的一部分,要编写额外的代码将其替换成空字符。
如果输入行太长会怎样?使用gets()不安全,它会擦写现有数据,存在安全隐患。gets_s()函数很安全,但是,如果并不希望程序中止或退出,就要知道如何编写特殊的“处理函数”。另外,如果打算让程序继续运行,get_s()会丢弃该输入行的其余字符,无论你是否需要。由此可见,当输入太长,超过数组可容纳的字符数时,fgets()函数最容易使用,而且可以选择不同的处理方式。如果要让程序继续使用输入行中超过的字符,可以参考fgets2.c的处理方法。如果想丢弃输入行的超出字符,可以参考程序fgets3.c中的处理方法。
所以,当输入与预期不符时,gets_s()完全没有fgets()函数方便、灵活。也许这也是gets_s()只作为C库的可选扩展的原因之一。鉴于此,fgets()通常是处理类似情况的最佳选择。
3.s_gets()函数
程序读取整行输入并用空字符代替换行符,或者读取一部分输入,并丢弃其余部分。
char *s_gets( char *st, int n ){
char *ret_val;
int i = 0;
ret_val = fget( st, n, stdin );
if( ret_vale ){
while( st[i] != '\n' && st[i] != '\0' ){
i++;
}
if( st[i] == '\n' ){
st[i] = '\0';
} else{
while( getchar() != '\n' ){
continue;
}
}
}
return ret_val;
}
如果fgets()返回NULL,说明读到文件结尾或出现读取错误,s_gets()函数跳过了这个过程。
丢弃过长输入行中的余下内容是因为输入行中多出来的字符会被留在缓冲区中,成为下一次读取语句的输入。丢弃输入行余下的字符保证了读取语句与键盘输入同步。
设计的s_gets()函数并不完美,它最严重的问题是遇到不合适的输入时毫无反应。它丢弃多余的字符时,既不通知程序也不告知用户。
11.2.4 scanf()函数
scanf()和gets()或fgets()的区别在于它们如何确定字符串的末尾;scanf()更像是“读取单词”函数,而不是“获取字符串”函数;如果预留的存储区装得下输入行,gets()和fgets()会读取第1个换行符之前所有的字符。scanf()函数有两种方法确定输入结束。无论哪种方法,都从第1个非空白字符作为字符串的开始。如果使用%s转换说明,以下一个空白字符(空行、空格、制表符或换行符)作为字符串的结束(字符串不包括空白字符)。如果指定了字段宽度,如%10s,那么scanf()将读取10个字符或读到第1个空白字符停止(先满足的条件即是结束输入的条件)。
输入语句 原输入序列 name中的内容 剩余输入序列
scanf( "%s", name ) Fleebert Hub Fleebert Hub
scanf( "%5s", name ) Fleebert Hub Fleeb ert Hub
scanf( "%5s", name ) Ann Ular Ann Ular
scanf()函数中指定字段宽带的用法。
/* scan_str.c -- using scanf() */
#include
int main(void)
{
char name1[11], name2[11];
int count;
printf("Please enter 2 names.\n");
count = scanf("%5s %10s",name1, name2);
printf("I read the %d names %s and %s.\n",
count, name1, name2);
return 0;
}
/* 输出:
*/
第3个输出示例,Portensia的后4个字符nsia被写入name2中,因为第2次调用scanf()时,从上一次调用结束的地方继续读取数据。在该例中,读入的仍是Portensia中的字母。
根据输入数据的性质,用fgets()读取从键盘输入的数据更合适。scanf()的典型用法是读取并转换混合数据类型为某种标准形式。否则可能要自己拼凑一个函数处理一些输入检查。如果一次只输入一个单词,用scanf()也没问题。
scanf()和gets()类似,也存在一些潜在的缺点。如果输入行的内容过长,scanf()也会导致数据溢出。不过,在%s转换说明中使用字段宽度可防止溢出。