编译程序仅仅能查找出程序的语法错误,而对于“数组越界访问”,“对空指针解引用”等错误,编译程序是束手无策的。同时我们知道测试人员所使用的黑箱测试方法所能做的只是往程序里填数据,并看它弹出什么。这就决定了对程序错误的检测可能需要点运气。
假如编译程序能够检测出“数组越界访问”,“差一错误”,“空指针”等等错误,那么编写无错代码其实就要简答多了。
所以我们需要一个思维转变: 不要光依赖黑箱测试方法,还应该试着去模仿前面所讲的假想编译程序,来排除运气对程序测试的影响,自动地抓住错误的每个机会。好的编译程序应该能够这样: 可以把屡次出错的合法的C习惯用法看成程序中的错误。这句话什么意思呢? 一些C用法从语法上讲是合法的,但是往往却给程序带来意想不到的错误。所以好的编译程序应该提供支持:让我们把这些用法当成错误。
举个例子:
/* memcpy 复制一个内存块 */ void* memcpy(void *pvTo,void *pvFrom, size_t size) { byte *pbTo=(byte *) pvTo; byte *pbFrom=(byte *)pvFrom; while(size-->0); *pbTo++=*pbFrom++; return pvTo; }
编译程序会让这个程序愉快地通过编译,但是程序运行后我们可能需要花费大量时间才能调试出这个非常隐藏的错误:while条件判断语句之后多了一个分号,这导致循环体为空语句。这显然不是程序员的意图,但是编译器却无法检测出这个错误,因为空语句从语法角度上是合法的。
尽管在C语言中空语句本身是合法的,但是我们的确很少这样使用,出现空语句时往往是由于程序员不小心导致的,而这样的空语句也会导致隐藏很深的错误。所以当出现空语句时,如果编译器把它认为是个错误,并自动给我们一个警告,这样让我们非常容易查找出错误。
当然如果我们的确要使用空语句时,那就用。但是最好使用NULL使其明显可见。NULL只是个常量,所以编译程序不会为NULL语句生成任何代码。这样,编译程序只接受显示的NULL语句,而把隐式的空语句(即只有一个分号)标示为错误。这就使得我们既可以明确地使用空语句,同时又可以指示出那些隐式的往往导致错误的空语句。
还有一种常见的问题就是无意的赋值。例如
if(ch=‘\t‘) ExpandTab();
我们是想判断ch是否和‘\t’相等,却导致‘\t’赋值给ch,这导致程序的行为和我们期望的大相径庭。但是编译器却不会给出任何抱怨,因为这是合法的C语句。所以某些编译程序允许用户在&& 和 || 表达式以及if,for, while构造的表达式中禁止使用简单赋值。这样就可以帮助用户查出这种错误。这样做的基本依据就是用户极有可能在以上五种情况下把“==”打成“=”。
但是有时为了代码的简单性,我们可能编写出以下代码
while(*pchTo++=*pchFrom++) NULL;
所以此时为了避免警告信息,我们可以这样编写
while((*pchTp++=*pchFrom)!=‘\0‘) NULL;
这样做尽管看上去要麻烦,但是现代的商业级编译器不会为这种的冗余代码产生额外的代码,会把它优化掉。同时又可以减少风险,更加安全。
空语句,错误的赋值以及原型检查等只是许多C编译程序提供的选择项中的一小部分内容,实际上还有更多的选择项。这里的要点是:用户可以选择的编译程序警告设施可以就可能的错误向用户发出警告信息。尽管有时为了这些警告设施,我们可能需要一些额外的工作,但是我们应该把这些警告设施看成一种无风险高偿还的程序投资。
使用编译程序所有的可选警告设施。
另一种检查错误更详细,更彻底的方法是使用lint。lint这个工具最初是用来扫描C源文件并对源程序中不可移植的部分提出警告,现在的lint实用程序变得更加严谨,lint可以检测出虽然可移植并且完全合乎语法但很有可能是错误的特性。
使用lint来检查出编译程序漏掉的错误。
有时,似乎可以跳过一些设计用来避免程序出错的步骤,例如单元测试,但是走捷径之时,就是麻烦将至之日。
如果有单元测试,就进行单元测试。
总结: 当你写程序时,要在心中时刻牢记着假想编译程序这一概念,这样就可以花费很少力气利用每个机会抓住错误。要考虑编译程序产生的错误,lint产生的错误以及单元测试失败的原因。消除程序错误的最好方法是尽可能早,尽可能容易地发现错误,要寻求费力最小的自动差错方法。
最后用作者在本章里的一句引言结束这篇文章:
投资者与赌徒之间的区别在于投资者利用每一次机会,无论它是多么小,去争取利益;而赌徒只靠运气。