不顾一切找 Frank

《Head First C 中文版》第 94 页:

 1: #include <stdio.h>
 2: #include <string.h>
 3: 
 4: char tracks[][80] = {
 5:   "I left my heart in Harvard Med School",
 6:   "Newark, Newark - a wonderful town",
 7:   "Dacing with a Dork",
 8:   "From here to maternity",
 9:   "The girl from Iwo Jima",
10: };
11: 
12: void find_track(char search_for[])
13: {
14:   int i;
15:   for (i = 0; i < 5; i++) {
16:     if (strstr(tracks[i], search_for))
17:       printf("Track %i: '%s'\n", i, tracks[i]);
18:   }
19: }
20: 
21: int main()
22: {
23:   char search_for[80];
24:   printf("Search for: ");
25:   fgets(search_for, 80, stdin);
26:   find_track(search_for);
27:   return 0;
28: }

试驾

第 95 页:

下面打开终端,看看代码能否工作:

> gcc text_search.c -o text_search && ./text_search
Search for: town
Track 1: 'Newark, Newark - a wonderful town'
>

好消息,程序工作了!
到目前为止,这个程序是你写过最长的一个,但它做了更多的事情。程序创建了一个字符数组,并利用标准库中的字符串处理函数搜索数组中的歌名,最后找到了用户想要找的歌曲。

程序其实不工作

实际上,在我的机器上,这个程序的运行结果是:

> gcc text_search.c -o text_search && ./text_search
Search for: town
>

并且,不论你输入什么,这个程序都没有找到任何歌曲。为什么会这样呢?

C 语言库函数 fgets()

问题出在这个程序第 25 行的fgets()上面:

char *fgets(char *s, int size, FILE *stream);

实际上,fgets() 函数从输入流stream读取最多size - 1个字符到缓冲区s中。如果遇到EOF或者换行符则停止。如果读到换行符,则换行符也保存到s中。然后在缓冲区s中附加一个null字节('\0')。

这就是程序不工作的原因了,我们的search_for中包含了换行符,所以find_track()函数无法搜索到任何歌曲。

修正方案一

简单地使用以下语句代替第 25 行的语句:

gets(search_for);

这个程序就可以正常工作了。因为gets()函数从标准输入读取字符到缓冲区中,也是遇到EOF或者换行符就停止。如果读取换行符,就扔掉它,并不保存到search_for中。这正符合我们的要求。

且慢,gets()函数是个十分危险的家伙,非常容易造成缓冲区溢出。实际上,gcc 编译器在编译时会给出以下警告:

text_search.c: 在函数‘main’中:
text_search.c:25:3: 警告:不建议使用‘gets’(声明于 /usr/include/stdio.h:638) [-Wdeprecated-declarations]
   gets(search_for);
   ^

C 语言库函数 gets()

《Head First 中文版》第 67 页:

fgets() 函数其实是从一个更古老的函数演变而来的,它叫 gets() 。

尽管我们说 fgets() 比 scanf() 更安全,但它的祖先 gets() 才是最危险的家伙。为什么?因为 gets() 函数没有任何限制:

char dangerous[10];
gets(dangerous);  // 别!我是认真的,千万别用它。

虽然 gets() 函数已经行走江湖很多年了,但真的不应该用它。

修正方案二

将第 25 行替换为以下语句:

scanf("%79s", search_for);

这也可以正确工作。注意,一定要用%79s格式串,不要使用%s格式串,否则也非常容易造成缓冲区溢出。

修正方案三

但是,修正方案二也有个小缺点,如果我们输入的字符串中包含空格,scanf()函数只会读取空格之前的字符。所以就有以下修正方案三:

  1. 在第 25 行之后增加一句:

    remove_tail_newline(search_for);
    
  2. 在第 21 行之前增加一个函数remove_tail_newline,用于删除字符串尾部的换行符:

    void remove_tail_newline(char str[])
    {
      size_t len = strlen(str);
      if (len > 0 && str[len - 1] == '\n') str[len - 1] = '\0';
    }
    

这样,就完美解决这个问题。注意,remove_tail_newline函数不能写成以下样子:

str[strlen(str) - 1] = '\0';

这是因为:

  1. 字符串str的尾部可能不包含换行符,比如标准输入被重定向到一个不包括换行符的文件中。
  2. 字符串str的长度还有可能为零。

修正方案四

将第 25 行替换为以下语句:

scanf("%79[^\n]\n", search_for);

这和修正方案二很像,只不过是把格式符 %79s 替换为 %79[^\n]\n 。这能够读入空格,避免修正方案二的缺点。但 scanf() 函数的这个格式符在 K&R 的经典著作中没有提到,应该是后来增加的。我想现代的 C 编译器应该都会支持。(老古董的 C 编译器就难说了)

附注:根据 @It 的评论,这个修正方案有个问题:输入完回车后不会马上响应,需要再输入点什么后才返回结果。所以这个方案不太靠谱,用户体验不好。看来这种格式符更适用于从文件中读取输入等不与用户发生交互的场合。

再次附注:把格式符从%79[^\n]\n改为%79[^\n]可以解决上述问题。

其他影响

《Head First C 中文版》第 91 页、第 93 页也涉及到上述text_search.cmain()函数,也要进行相应修改。

参考资料

  1. 《Head First 中文版》
  2. The GNU C Library: Line Input
  3. POSIX specification: fscanf, scanf, sscanf
  4. Linux manual page: gets(3)
  5. Linux manual page: scanf(3)
  6. cppreference: gets, gets_s
  7. Brian W. Kernighan, Dennis M. Ritchie: The C Programming Language, Second Edition