《C专家编程》第二章——这不是Bug,而是语言特性

时间:2023-02-11 19:59:44

  无论一门语言有多么流行或多么优秀,它总是存在一些问题,C语言也不例外。本章讨论的重点是C语言本身存在的问题,作者煞费苦心的用一个太空任务和软件的故事开头,也用另一个太空任务和软件的故事结尾,引人入胜。

  关于这两个故事,在这里不说,有兴趣的朋友还是建议买这本书去看看,这本书用相当轻松的文字而又不失深沉地向我们道来C语言的各种特性与特别的用法。

  书中提到一种分析编程语言缺陷的方法,让我们能够详细的去分析各种编程语言的缺陷,即把所有的缺陷归于3类:不该做的做了(多做之过)、该做的没做(少做之过)、该做的做了但不合适(误做之过),本章也是按照这样一种分析方法来分析C语言本身存在的一些问题,由于C是一门神奇的语言,被许多平台所选用,也被大家所学习,所以了解C语言是一件相当有必要的事情,本章就是从缺陷来了解C语言。

  多做之过,就是语言中存在某些不应该存在的特性,包括容易出错的switch语句、相邻字符串常量自动连接和缺省全局作用域。

  首先说说switch语句吧,这个语句在多条件的时候使用率还是相当高的,相比大量if语句,我还是比较倾向于它的。switch语句的一般形式如下:

   switch(表达式)
  {
    case 常量表达式1:语句1; break;
    ....
    case 常量表达式n:语句n; break;
    default:语句;break;
  }

  每个case结构由3个部分组成,关键字case;其后的常量表达式;以及后面的冒号,当表达式的值与case后面的常量表达式匹配时,case后面的语句就会执行,否则执行default后面的语句,default都可以出现在case列表出现的任何位置,如果没有default语句,那么switch语句就什么也不做,你不要指望它会提醒你它什么都没做。在C语言中,几乎从来不进行运行时错误检查——对进行解引用操作的指针进行有效性检查大概是唯一的例外,这是因为运行时检查与C语言的设计理念相违背,按照C语言的理念,程序员应该知道自己在干什么,而且保证自己的所作所为是正确的。switch的另一个问题是它内部的任何语句都可以加上标签,并在执行时跳转到那里,作者给出了一个例子,那就是当你的default语句写错的时候,比如把l字母写成了数字1,看起来很像对吧defau1t,不过功能可是大不相同,这意味着如果表达式不匹配任何常量表达式时它将什么也不干,因为没有default语句啊,然而即使这样,编译器也无法检查出错误来。当然switch语句里最大的问题还不是这个,而是它不会在每个case语句执行完毕后自动跳出,如果你不使用break语句来跳出,它将一直执行下去,在《C与指针》描述switch语句时有一句话我觉得非常合适,那就是case语句只是确认进入switch语句的入口,如果你不使用break语句,那么出口都是在swtich语句的右花括号那里,作者还举了一个利用switch语句这种特性的例子,用来计算程序输入中字符、单词和行的个数,有趣的是,这个例子正是需要switch语句一直执行下去而不是遇到一个case就退出,有兴趣的朋友可以参考《C与指针》第四章的内容。当然,如果在这种情况下要使用switch语句的特性,那么一句注释"FALL THRU"是必不可少的,它会告诉你,我就是要利用这个特性,我不需要break语句,不过,在绝大多数情况下是不会需要这种特性的。以上就是switch语句存在的三个主要问题。

  其次,相邻字符串常量的自动合并这个约定也会带来一些问题。在printf的使用中,这是一个优点,因为你不用担心要输出的字符串有多长,你可以放心的用双引号包括每一行的内容,反正它会自动合并,比方说

  printf("A second favorite children's book"

    "is Thoms the tank engine and the Naughty Enginedriver who"

    "tied down thomas's boiler safety value");

这个printf语句会自动连接三个行,这可以使每一行的代码看起来简洁而又完整,不过,你该担心的是下列情况:

  char *available_resouces[] = {

    "color monitor",

    "big disk"

    "Cray"  /*少了一个逗号*/

    "on-line drawing routhines",

    ...

在这种情况下我们都知道,由于数组大小的缺省,而少了逗号会使两个字符串常量自动连接,所以在编译器看来,这并不是一个错误,它也就不会提示你,而程序可能会莫名其妙的运行,打印"Crayon-line drawing routhines“或是修改其他变量,因为字符串数目比预期少了一个。

  缺省可见性这个问题主要体现在全局函数的定义上,我们知道在声明函数的时候,如果没有任何关键字限制,那么会被自动定义为全局函数,除非你加上static关键字,才能限制对这个函数的访问。事实上,几乎所有人都没有在函数名前添加存储类型说明符的习惯,所以绝大多数函数都是全局可见的,然而,根据实际经验,这个缺省的全局可见性多次被证明是个错误。软件对象在大多数情况下应该缺省的采用有限可见性,当程序员需要让它全局可见时,应该采用显式的手段,原因在于这种大范围的全局可见性会与C语言的另一个特性产生影响,也就是用户编写和库函数同名的函数并取而代之的行为。这也说明了在C语言中,对信息可见性的选择很有限,要么是extern,意味着整个库的所有对象都可见,要么是static,对其他文件都不可见。

  所谓”误作之过“,就是语言中有误导性质或是不适当的特性,这些特性跟C语言的简洁性有关,有些则与操作符的优先级有关。

  C语言存在的其中一个问题就是它太简洁了,仅增加、修改或删除一个字符就会使原先的程序变成另外一个仍然有效但全然不同的程序,这就意味着,如果你在一个小问题上出了一点问题,那么编译器是不会检查出来提示你的,因为你的程序仍然有效。当然,还造成一个问题,那就是很多符号同时具有好几种意思,你要直到它到底是什么意思,还要根据上下文来,这一点尤其体现在作用域上。比方说static关键字就曾经令我疑惑,它有时候表示静态变量,有时候又表示内部链接属性,那么它到底代表什么呢?正确的答案是这样的,在函数内部,表示静态变量,当表示函数时,代表内部链接属性。同样的extern关键字也是这样,在缺省可见性已经提到,extern的外部链接属性不应该作为缺省属性。还有&操作符,既表示取地址操作符,又表示按位与操作,同样*操作符也有多种含义,最明显的、用法最多的操作符可能还是要数()操作符了,它们无处不在。一个符号所表达的意思越多,编译器就越难检测到这个符号在你的使用中所存在的异常情况。

  另外在操作符的优先级上,我完全能够感同身受,初学C语言,甚至在学完C语言很久一段时间之内,我都没有真正的完全搞清楚过操作符的优先级,凭感觉用吧,一般来说结果都是错的,不过用多了,可能也就会了。还记得->这个操作符在结构指针中的使用吗,我们知道->这个操作符是对一个结构成员进行解引用,它所代表的意思p->f也就相当于(*p).f,不过千万别忘了添加括号哦,因为”."操作符的优先级大于"*",这个问题也是导致->操作符出现的原因之一,类似的还有很多,比如[]的优先级高于*,int *p[]这个表达式呢代表p是一个元素为int指针的数组,而不是说p是个指向int数组的指针哦。不过在多年前,Dennis Ritchie解释了这些不正常的情况是如何由于历史的偶然原因而产生的,最大的原因还是,如果现在把它们更改过来的话,现有的大量代码都可能出现问题。

  最后,少做之过的特性就是语言应该提供但未提供的特性,如标准参数处理以及把lint程序错误的从编译器中分离出来。

  标准参数处理这个问题不管是在UNIX还是在C语言中都没有得到好好的处理,因为参数与文件名,程序是分不清楚的。其中一个例子就是在在UNIX中创建一个文件,文件名以’-‘连字符开头,然后却发现无法用rm命令把连字符去掉,这就是它分不清文件名与参数的影响,书中还给出一个有趣的实例——关于在1990年以前给“用户名的第二个字母是f的用户”发邮件,那么他将收不到,进一步让我们理解分不清参数与文件名的影响。

  而lint程序,甚至现在好多使用C语言的人都没有听过,在早期的C语言中,语言设计者作出了明确的规定——把编译器中所有的语义检查措施全部分离出来,错误检查由一个单独的程序完成,这个程序被称为“lint”,在省掉lint之后,编译器可以做得更小,更快而且更简单,所以理所当然的,它被去掉了,不过,所付出的代价是,代码中悄悄混入了大量的Bug和不可靠的编码风格,许多程序员缺省情况下在每次编译中并不使用lint。在书中给出了一些实例,是一些程序员在写代码的过程中容易犯得错误而编译器又检查不出,如果使用lint程序,则可以全部检查出来,所以作者大力推荐使用lint程序作为检查。

  下面,来介绍一下这个lint程序吧。

  lint程序不但可以检查出可移植性问题,而且可以检查出那些虽然可移植并且完全合乎语法但却很可能是错误的特性,lint程序会产生一系列程序员有必要从头到尾仔细阅读的诊断信息。

  这是lint程序的系统版本:

  UNIX系统 在UNIX系统中,可自动获得lint,它是一个标准的UNIX工具。
  Linux系统 在Linux各种发行版中,使用lint的版本是GNU下的Splint(前身是LClint)
  Windows 在Windows系统中,从第三方获得的lint工具的名称是PC lint以及Splint
 
  在这里,由于我使用的是Linux,所以介绍一下Linux中lint的使用。
  首先安装splint工具:
sudo apt install splint

  然后假定你要检查的文件是main.c

splint main.c

  其中main.c中代码如下所示,使用了switch语句来测试:

#include <stdio.h>

int main(void)
{
int x;
scanf(
"%d",&x);
switch(x){
case 3:
printf(
"4\n");
case 4:
printf(
"4\n");
}
return 0;
}

  如果是直接gcc main.c,那么不会有任何提示,使用splint程序之后,它显示了这些文本:

Splint 3.1.2 --- 03 May 2009

main.c: (
in function main)
main.c:
6:5: Return value (type int) ignored: scanf("%d", &x)
Result returned by function call
is not used. If this is intended, can cast
result to (
void) to eliminate message. (Use -retvalint to inhibit warning)
main.c:
10:10: Fall through case (no preceding break)
Execution falls through
from the previous case (use /*@fallthrough@*/ to mark
fallthrough cases). (Use
-casebreak to inhibit warning)

Finished checking
--- 2 code warnings

  显而易见的是,它给出了两条提示,一条是说你的scanf语句的返回值并没有用,另一条就是switch语句没有break语句,并且提示你,如果确实不需要break语句,请用/*fallthrough*/把它注释出来。

  所以,多用lint程序来检查你的程序吧,说不定会给你一个惊喜。