全能編譯器
想象一下:如果编译器可以找出你程序中的所有问题,那你的程序还会有多少错误在里头?我并不是在说语法错误而已,而是所有问题,无论是多么隐匿的问题。
如果你犯了个笔误,而编译器能找到它,而且能给你一个如下的错误讯息:
->line 23: while (i <= j)
^^
Off-by-one error: This should be '<'
或编译器能在你的算法中找到错误:
->line 42: int itoa(int i, char *str)
^^^^
Algorithm error: itoa fails when i is -32768
或假设编译器能在你传入错误参数时提醒你:
->line 318: strCopy = memcpy(malloc(length), str, length);
^^^^^^
Invalid argument: memcpy fails when malloc returns NULL
好吧,这也许有点牵强,不过如果编译器作得到这点,你想要写出无错误的程序会事件多么容易的事呢?至少跟一般程序员所经历过的写作过程比起来,那不会变得很简单吗?
如果你从间谍卫星的摄影机偷看一间普通的软件工作室,你会发现程序员们龟缩在他们的键盘前面追踪已知的程序错误,而另一边,你会发现测试人员正在程序的最新内部测试版上找寻臭虫,用奇奇怪怪的招式输入垃圾,等着捕捉错误。你甚至可能发现测试人员正在检查有没有老问题又浮现台面了。如果你认为这种找寻程序错误的方法比起使用一套假想编译器来捕捉错误要花费更多努力,你说得没错;除了努力,还需要许多运气。
运气?
是的,运气。当一名测试人员发现一个问题,难道不是因为他或她注意到某些数字错了,或某个菜单现不如预期,或者程序当掉了吗?再看一下假想编译器告诉你的那些错误讯息。有测试人员在程序执行时能够看得到那个运算子的笔误吗?他们又如何能看到另外两个错误呢?
这听来吓人,但是测试人员在丢东西喂给程序时,会希望有些隐藏的错误自己跑出来。 " 对啦,可是我们的测试人员比起那一种测试者更出色。他们使用程序代码涵盖测试工具,自动化的测试套件,随机攻击程序,显示捕捉功能,还有许多其它的工具。 " 也许这样子说得没错,但是看看这些工具能作什么吧。涵盖分析告诉测试者,程序的哪个部分还没测试过;测试人员用这些讯息来检查你程序的新输入跟产生的结果。然后其它工具只是自动化了 " 敲敲看会不会有反应 " 的找寻问题策略。
不要误解我,我不是说这些测试人员作错了。我是在说,把程序当成个黑箱子来测试是件困难的事情,因为测试者所能做到的就是喂给程序一些输入,然后等着看有什么东西跑出来。就好象在判断一个人是不是疯了一样,你发问;你听取答案;然后你作出判断。最后,你永远不能真正确定结果,因为你不知道在另一个人脑袋里到底有些什么东西正在运作中。你永远会怀疑, " 我问够问题了吗?我问对问题了吗? "
不要依赖于黑箱测试。试着模仿一个全能编译器,消除运气成分,争取每个自动找到问题的机会。
注意你用的语言
你上一次看到一份顶尖的文书处理器的广告是哪时候?广告商的说辞大概会长得像这样: " 无论你在写信给小孩的老师或是构思下一部伟大的英文小说, WordSmasher 都能不费吹灰之力办好它,而且找到您作品中的笔误。本程序提供惊人的 233,000 字拼字辞典-比其它竞争软件至少多出 51,000 字。现在就去店里头买份 WordSmasher ,这个从原子笔发明以来最具革命性的写作工具。 "
身为使用者,我们已经被行销宣传骗得相信拼字辞典愈大愈好,但这是不对的。在任何纸制字典中,你都能找到 em , abel 跟 si 这些字,但是你真的希望你的拼字检查器允许这些字在更常见的 me , able 跟 is 之间出现吗?如果你在我写的东西里头看到 suing 这字,这种天文奇迹般的错误一定是我把 using 写错了。我不在乎是不是真的有 suing 这个字;至少,在我的文章里,那是个错字。
幸运的,高级拼字检查器能让你从字典中删除如 em 般讨厌的字,让你能够将那样的字当成错字处理。好的编译器也是一样-它们能够你标示一些可视为合法的 C 语言用法,因为这些用法通常被用错了。这样的编译器可以侦测到底下这个 while 循环中放错了的分号:
/* memcpy - copy a nonoverlapping memory block. */
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte *)pvTo;
byte *pbFrom = (byte *)pvFrom;
while (size- > 0);
*pbTo++ = *pbFrom++;
return (pvTo);
}
你能分辨出缩排之后的那个分号是错的,可是编译器看到的是一个没作任何事的 white 叙述,这是完全合法的。你有用到空叙述的时候,也有不会用到的时候。要捕捉到一个非你想要的空叙述,编译器通常提供了一个警告选项,如果你用了它,就会在碰到类似这样的错误时警告你。而当你真的想要用到空叙述时,你也可以依照编译器使用手册中建议的翻译避开它-用个会在程序代码最佳化时被移除的常数表示式(如 NULL; ),或使用空白区块 { } ,或使用其它编译器支持的解决方案。我在这里使用 { } 。
char *strcpy(char *pchTo, char *pchFrom)
{
char *pchStart = pchTo;
while (*pchTo++ = *pchFrom++)
{}
return (pchStart);
}
结果:
你得到了空叙述提供的弹性,而编译器将你不想要的空叙述自动当成错误。不允许一种空叙述的存在与为了统一使用 zeroes 的拼法而将 zeros 从拼字辞典中删除差不了多少。
另一个常见的问题是无意间错用的指派叙述。 C 的弹性让你能在表达式的任何地方使用指派叙述,不过如果你不够小心,这样额外的弹性将会造成你的困扰。让我们来看个常见的错误:
if (ch = t')
ExpandTab();
虽然这程序代码很清楚的是用来比较 ch 是不是制表符,它真正作的却是将 ch 的内容设定为这个字符。当然编译器不会产生任何错误,因为这段程序代码合乎 C 语言的语法。
有些编译器可以让你禁止简单指派叙述出现在 && 跟 || 表达式间与如 if , for 跟 while 等其它控制表达式中,帮你抓到这样的错误。这个功能背后的点子是,如果一名程序写作者不小心把 == 打成了 = ,就可以从上面那五种状况中捕捉到这种错误。
这样的选项不阻止你在表达式中对变量内容进行设定,但你必须明白指定一个值如 0 或 nul 字符来进行比较,才能避开这种警告。因此,回到前面那个 strcpy 的例子,我们不把循环写成底下会产生警告讯息的这样子
while (*pchTo++ = *pchFrom++)
{}
而是写成底下这样子:
while ((*pchTo++ = *pchFrom++) != 0')
{}
还好现在的商业版编译器不会对这种比较表达式产生额外的程序代码,因为这是多余的,可以被最佳化略掉的。你可以放心依赖有这类警告选项的编译器提供的最佳化功能。再一次的,这样的想法是禁止有安全替代方式的危险性程序写法被使用,即使两种写法是合法的。
另一类型的错误属于 " 参数错误 " 的类型。好几年前,当我还在学 C 语言时,我习惯这样子用 fputc :
fprintf(stderr, "Unable to open file %s.\n", filename);
.
.
.
fputc(stderr, '\n');
这样子看来还好 , 可是 fputc 的参数顺序不对。不知道怎么搞的,我以为档案资料流指针( stderr )永远是任何档案资料流处理函式的第一个参数。这是不对的,所以我经常丢垃圾给这些子程序。幸运的, ANSI C 提供了自动捕捉这类错误的编译方法:函式雏形宣告。
由于 ANSI 标准要求所有链接库函式都有雏形宣告,你可以在 stdio.h 这表头档中找到 fputc 的雏形宣告,它应该长得像这样子:
int fputc(int c, FILE *stream);
如果你将 stdio.h 包含进档案里,并呼叫了 fputc ,编译器就会比较你传入的每个参数是不是预期的那样子,如果型态不一致,它就会产生错误。在我的例子中,由于我是在一个 int 参数的位置传入一个 FILE * 参数,雏形宣告检查就会捕捉到我早期犯下的 fputc 错误。
ANSI C 会要求标准函式要满足雏形宣告的要求,可是它不要求你我所写的函式也同样有着雏形宣告:那纯粹是选择性的。如果你想在自己的程序代码中找到参数使用的错误,你就必须维持一份最新的函式雏形宣告。
现在我听到有程序员抱怨必须维护雏形宣告的更新,特别当他们将使用传统 C 语言写的项目移植到 ANSI C 的开发平台时。这样的抱怨有时是情有可原的,可是仔细想想:如果你不使用雏形宣告,你就得仰赖传统的测试方法,期望这样子可以在你的程序中捕捉到任何参数使用的错误。问问你自己,哪样子比较重要?是替你自己省下一些维护功夫重要,还是能够在你编译程序代码时捉到错误重要?如果你还不能决定,考虑一下使用雏形宣告可以产生更好的程序执行码,因为: ANSI C 标准允许编译器依据雏形宣告的信息进行程序最佳化。
强化雏形宣告
不幸的,雏形宣告不能在你把两个相同型态的参数弄反时警告你。例如,如果 memchr 函式有这样的雏形宣告:
void *memchr(const void *pv, int ch, int size);
你可能会把 ch 跟 size 两个参数弄反了,而编译器不会发出任何警告。不过如果在你的接口与雏形宣告上使用更精确的资料型态,你就能强化雏形宣告所提供的错误检查。例如,底下的雏形就能在你弄反了 ch 跟 size 两个参数时警告你:
void *memchr(const void *pv, int ch, int size);
这样的缺点是得使用更精细的资料型态,让你得经常把参数转型成必要的资料型态-即使它们的顺序是对的-来避开这种不重要的型态不符警告。
在传统 C 语言中,编译器在编译时对于档案外头的函式没有那么多信息,可是它还是需要产生呼叫这些函式的程序代码,而这些呼叫显然必须生效。编译器作者使用了一种正规化的呼叫方式来解决这个问题,这么做虽然让函式呼叫一定生效,但是编译器却经常需要产生额外的程序执行码来支撑这种做法的运作。不过如果你打开编译器的 " 所有函式都要有雏形宣告 " 的警告选项,编译器就可以它认为最有效率的呼叫方式来产生执行码,因为它知道程序中每个函式的参数用法。
空叙述警告,错误指派警告,以及雏形宣告检查都只是许多 C 语言编译器中都能找到的选项;通常它们提供更多警告选项。选择性编译器警告的重点在于让你了解哪些地方可能有错误,就像一个拼字检查器告诉你哪边可能有拼错字一样。
Peter Lynch ,可以说是 1980 年代最棒的共同基金经理,曾经说过投资者跟赌客的差别在于投资者把握所有机会,无论机会多小,来让优势倾向自己;而赌客,在他看来,只依靠运气。将这观念套到程序写作上,打开所有选择性的编译器警告讯息吧。不要问 " 我应该打开这个警告选项吗? " 而是问 " 为什么不打开这个警告选项? " 除非你有个很好的理由,不然把每个警告讯息都打开吧。
打开所有选择性的编译器警告。
lint -这东西还不错
一个几乎不费事而更完美的错误找寻法是使用 lint ,这本来是个用来检查 C 语言原始码档案,然后报告原始码中有哪里缺乏移植性的工具。不过现在,大部分的 lint 工具都更完美许多,不只是能够标示出有移植性问题的程序代码,还能找出虽然具可移植性而且看来完全合法,却似乎有问题的语句写法。无意留下的空叙述、错误的指派叙述跟呼叫参数错误,这些前面段落里提到的错误都在它的捕捉范围内。
不幸的,许多程序员还是将 lint 当成会吐出一大堆他们不在乎的警告讯息的可移植性检查工具而已;这个工具的恶名让人裹足不前。如果你就是这样子误解 lint 的程序员之一,也许你应该重新评估你的意见了。不管怎么说,你觉得哪个工具比较贴近我们稍早讲的假想编译器?是你用的编译器,还是 lint ?
事实上,一旦你将原始码丢给 lint 检查过并把它修改成一点警告讯息也不会出现的样子,要让它继续维持让 lint 一点也找不出错误是很简单的-只要在你把修改过的程序更新到原始码正本以前,先跑一遍 lint 就好了。一两个星期后,你不用想太多就写得出来 lint 找不出半点错误的程序代码了。当你达到这个目标后 , 你就学到 lint 提供的所有优点,而不会有半点头痛了。
用 lint 来抓你的编译器可能漏掉了的错误。
可是我改的都很简单
有次我跟本书一位技术检阅者一起吃午餐,它问我要不要加上一段单元测试的内容。我说我不想,因为单元测试虽然跟写出零错误程序有关,不过那实在应该归类到别类去:如何设计程序的测试。
他说, " 不,你误解我的意思了。我以为你正想指出程序员该在将他们所修改的程序代码更新到原始码正本之前,实际进行单元测试。我团队里的一名程序员才刚让一只臭虫溜进我们的原始码正本里,因为他没在改过东西后进行单元测试。 "
这真是令人惊讶,因为大部分微软的项目领导者都期望程序员在将改过的东西放进原始码正本以前进行单元测试。
" 你问过他为何不作测试吗? " 我说。
我的朋友从吃饭中抬起头看。 " 他说他没写任何新东西-他只是搬动了一些老程序。他说他没想过他需要测试一下。 "
这故事让我想起,有个程序员在改过东西后,连编译都没作,就把程序代码更新到原始码正本里头。这问题是我发现的,因为我没办法不碰到错误就让这程序项目编译过关。当我问那名程序员他怎么漏掉一个编译错误的,他说, " 这修改很简单,我不觉得我会犯错。 "
没有任何一个这样的错误应该出现在原始码正本里,因为这些都是可以被自动捉到的错误,几乎不用费任何力气就可以捉到了。为何程序员们会犯下这些错误?主要是因为他们对于自己正确写出程序的能力太具自信了。
有时你会觉得你可以略过一个预防错误的设计,但当你抄快捷方式时,你就是在找麻烦。我想有许多程序员写好程序后根本连编译一下都没有-我只是刚好碰到了一个-只因为略过单元测试的诱惑强了些,谁叫他们修改的部分都很简单呢?
如果你发现自己想要跳过一步可以简单找到错误的步骤,最好停下来,并且搬出每个你用得了的工具来好好检查一番。单元测试是用来抓臭虫的,可是如果你把它略掉了,当然就什么虫都抓不到了。
有单元测试就作,不要跳过去。
没蛋卷啰
你认识多少个程序写作者喜欢花时间找寻错误跟修正臭虫而不是花时间写新的程序?我确定有这样的程序写作者,不过我从来也没碰到过。如果跟我认识的程序员们说他们不用再找别的臭虫了,他们谁也不愿意把中国菜叫外带冷掉的一点也不好吃。
当你写程序时,将本章所说的假想编译器的观念记在心里,把握每个自动或者不需费劲就能抓到错误的机会。想想编译器发出的错误讯息, lint 发出的错误讯息,跟单元测试的失败。你需要什么技巧来找寻这些错误?几乎什么也不用。如果没有臭虫需要高深的技巧或重大努力就能找到,那还会有多少错误出现在你推出的产品里头?
如果你想快速而简单的找到程序中的错误,使用那些你的程序发展工具提供来告诉你哪边有错误的功能吧。你愈快知道哪边有错误,你就能愈快修好它们,并且进行下一件更有趣的工作。
快速回顾
最好的程序除错方式就是尽早而且尽可能简单的找到错误,采用花最小力气的自动错误捕捉方式。 ?
努力减少程序员捉到错误所需的技巧量,编译器的选择性警告或 lint 的警告讯息不需要任何程序设计技巧就能捉到程序错误。
该想想的事
1. 假设你正使用编译器选项禁止在 while 条件式中使用指派叙述。为何这样子会抓到底下的这个错误?
2. while (ch=getchar() != EOF)
3. .
4. .
.
5. 你已经件事过如何使用编译器来捕捉无意中产生的空叙述与指派叙述。对下面这些会让编译器选择性的产生警告的常见问题给些建议,你该如何避开这些警告?
a. if(flight==063) 你认为你正在测试 Flight 63 ,可是实际上因为起头那个 0 让编译器认为 063 是个八进制数字,而造成你实际上是在测试 Flight 51 。
b. if (pb != NULL & *pb != 0xFF) 你不小心把 && 打成了 & ,让 *pb != 0xFF 在 pb 为 NULL 时一样会被执行到。
c. quot = numer /* pdemon; 不管你的意思是什么,那个 /* 是被当成注释的开头解释的。
d. word=bHigh<<8 + bLow; 由于运算子顺序的规则,不管你的原意为何,这式子被解释成 word=bHigh<<(8+bLow);
6. 编译器怎样自动警告你 " 孤立的 else 叙述 " 的可能错误?你该怎样避开这种错误?
7. 再来看一下底下这段错误程序:
8. if (ch = '\t')
ExpandTab();
你可以另一种常见方式来抓到这种错误而不用禁止 if 叙述中的指派用法。你可以将两个操作数的顺序反过来看看:
if ('\t' = ch)
ExpandTab();
如果你把 == 打成了 = ,编译器这时就会抱怨,因为你不能把任何东西指派给一个常数。这种解决方案有多彻底呢?为何这个方法不如编译器的编译选项那样自动化?
9. C 语言的前置处理器也可能产生非预期的结果。例如, UINT_MAX 宏定义在 limits.h 中,可是你如果忘了引用这个表头档, #if 编译底下的指示将会什么错误讯息也没发出就出错了-前置处理器将把未定义的 UINT_MAX 替换成 0 ,而测试结果就不正确了:
10. .
11. .
12. .
13. #if UINT_MAX > 65535u
14. .
15. .
16. .
#endif
学习计划:
为了简化维护雏形宣告的工作,有些编译器会在编译程序时自动产生表头宣告。如果你的编译器不支持这种选项,写个会替你作这件事的工具吧。标准的函式写法会让这样的工具多好写呢?
学习计划:
如果你的编译器没支持本章节所提到的警告选项(包括前面习题里提到的),鼓励你的编译器制造商支持这些选项吧。也顺便催你的编译器制造商提供选择性开关特定警告讯息的方法,以便让你能够开关不同类型的选择性错误。为何这么做是必须的呢?