序
几年前,我拿到一份 TEX : Donald Knuth 写了这个程序,而这程序的前言让我大吃一惊:
我相信 TEX 的最后一只虫已经在 1985 年十一月二十七日被发现而且修正了。不过,如果程序中还有些错误,我将很乐意付出一笔 20.48 美金的发现奖金给第一个发现它的人。(这两倍于之前的金额,而且我计划每年增加一倍金额;如你所见,我真的很有自信。)
我不知道 Knuth 有没有付给人 20.48 美金或 40.96 美金;那并不重要。重要的是 Knuth 对自己程序品质的自信。你认识多少个程序写作者敢正经声明说他们的程序是完全没错误的?多少人敢发表这样一份声明,然后提供一份臭虫发现奖金的?
真正相信测试小组能找到所有问题的程序员们敢作出这样的声明,不过这就是问题所在了。你已经听过多少次程序员们在程序推出、包装好、发行给经销商前说, " 我希望测试人员已经找到所有的问题了 " ?他们只敢交叉手指,祷告说一切都会维持在最好状态。
今天的程序员们不确定他们的程序是否毫无错误,因为他们已经放弃彻底测试自己程序的责任了。管理阶层并没有说过像 " 不要担心测试你们程序的事-测试员会帮你测试 " 的话,而且更不可思议的,他们虽然期望程序员测试自己的程序,但是却预期测试员能测试得更通盘些;因为这整个就是测试员被找来作的事。
本书的目的是说明程序员如何重新负起写出零错误程序的责任。那不表示程序必须在第一次写出来就达到完美状态-而是让一样产品在第一次进行测试前就已经达到零错误的程度。有些程序员也许会怀疑的嘲笑这个想法,不过本书示范的技巧与提供的准则还是可供作程序员们迈向那个目标前进之用。
两个关键问题
写出零错误程序的最关键需要是能够理解什么东西造成了臭虫的出现。所有本书中提到的技巧与准则都是程序写作者们针对每个在自己程序中发现的问题,年复一年、一遍又一遍询问底下两个问题后的产物:
我怎样自动找到这种问题?
我怎样预防这种问题的出现?
" 多测试些 " 是对这些问题的简单答案,但是那并不是个 " 自动 " 的答案,也不真的是个预防性的解答。如 " 多测试些 " 的答案普遍得一点也不带劲-这类型的答案一点用处也没有。解决这些问题的良好答案来自于用来消除我们碰到的这些问题的特殊技巧。
本书专门提供被找来减少或完全消灭整类程序臭虫的技巧与准则。有些做法完全违背常见的程序写作方式,但在你拿 " 每个人都违背这些准则 " 或 " 没人遵守这种东西 " 当作驳斥它们的理由前,先自己停下来好好想想。如果是 " 没人遵守这种东西 " ,那人们为何会不遵守它呢?自己好好确认一下人们为何不照著作的理由,这理由是不是合宜的。在 FORTRAN 语言当道的那个年代有意义的做法在现在未必有意义的。
这并不是说你应该盲目遵循本书的指示,这些指示并不是常规。太多程序设计者将 " 不要使用 goto 叙述 " 这样的指示当成由上帝所说那不可违背的戒律。当问起我们为何如此强烈排斥 goto 时,人们说那样子会让程序代码盘根错节得难以维护。可是经验老到的程序写作者经常加上触怒编译器程序最佳化功能的 goto 叙述。其实,两种做法都是合宜的。有些时候,谨慎的使用 goto 还能大大增进程序的清晰性与效率,照着 " 不要使用 goto 叙述 " 的指示去作反而会让程序代码变得更差而非更好。
本书中提供的准则也没什么不同:它们大部分时候应被遵从,但也有为了得到更佳结果而违背的时候。
除了这些准则与技巧,本书大部分的篇章都在结尾有段 " 该想想的东西 " 。这些段落里的问题探索了那些章节前头未曾提到的东西。这些问题不是习题-它们并不是用来测验你对该章节的了解程度的。我试着在每个问题里头介绍一个新观念,而且我提供了整组的答案来尽可能的传递更多讯息。如果你经常跳过习题不作,那考虑看看 附录C 里头的习题解答吧,这样你才不会遗漏任何我在这些段落里所要介绍的技巧或准则。
建立在现有基础上
用 C 语言写了一阵子程序的程序员们都知道他们应该在宏定义的参数前后加上括号;他们知道字符串后头有看不见的 nul 字符;他们知道 C 语言中的数组元素注标是从 0 开始而不是从 1 开始;他们也知道必须用 break 叙述来避免 switch 的一个状况执行完后跑去执行别的状况的处理叙述。这些认知与其它对 C 语言的错误认知经常是程序臭虫的根源,不过在本书的讨论中,你不会找到这些问题的讨论,除非它们包含在我所要提到的一些东西的一部份讨论里头。我试着将内容集中在鲜为人知或少见于市面上,你很少能在程序写作的教科书中或是程序设计的训练课程里学到的零错误程序写作技巧上。
我不曾试着重新整理 Brian Kernighan 跟 P.J. Plauger 合箸的程序设计经典 Elements of Programming Style 书中已提到而广为人知的那些准则。虽然 Kernighan 跟 Plauger 在他们的范例中使用 FORTRAN 与 PL/I ,他们的准则-只有一小部份例外-还是可以用到任何程序设计语言上,包括 C 语言在内。本书建立在 Elements of Programming Style 确立的基础之上,而且依循着类似的风格。
最后,虽然本书是为有着真实时间底线的真正程序写作计划的专业程序员们所写的,对于高等 C 语言程序设计课程的学生们一样适用。有些修过编译器课程的学生会写出自己的编译器,不过大部分人会专注于写出零错误程序上。我希望本书能够给予学生在毕业后写出有着成品水准的稳固程序所需的技巧。
什么是麦金塔?
有时一本书如果不提到 PDP-11 , IBM 360 或其它的老古董,几乎就引不起别人的重视。所以我才在这里提到这些老古董,而本书之后就完全不会再提到它们啰。本书中你最常看到的计算机系统是 MS-DOS , Microsoft Windows ,以及苹果公司的麦金塔计算机,尤其是麦金塔-因为我最近才在这些系统上头写过程是。
你也会在本书中听到许多 Microsoft Excel 与 Microsoft Word 的发展历史。 Excel 是微软公司的图形化电子表格程序,原先是为麦金塔计算机而写的,后来又明显改写、整理并加强过,移植到 Windows 平台上。
本书从头到尾,我都会谈到我身为一名麦金塔版 Excel 程序写作者的经验,不过我必须承认我将大部分的时间花在把 Windows 版的程序代码移植成麦金塔上执行的版本跟实作相似于 Windows 版 Excel 已经有了的功能上,这套产品的杰出成就跟我在这套产品中出的力不太相关。
我对麦金塔版 Excel 的唯一策略贡献是说服了微软当局停止发展麦金塔专门版的 Excel ,而从改良许多了的 Windows 版 Excel 的原始码直接建立起麦金塔版的东西。麦金塔版的 Excel 2.2 是第一个从 Windows 版的 Excel 移植而来的版本,有 80% 的原始码是相同的。这对麦金塔版的 Excel 使用者来说是一大改善,因为从 2.2 版中,它们可以看到功能与品质上的重大突破。
Word 是微软的文书处理应用程序。实际上,有三个版本的 Word :文字模式的 MS-DOS 版 Word ,麦金塔版的 Word 与 Windows 版的 Word 。当我写这本书时,这三套产品依然是从不同的原始码产生出来的,不过每个版本对于大部分使用者来说都还是挺像的,它们可以以同样的操作方式来使用这三个不同的版本而不会碰到什么障碍。不过,所有版本的 Word 最后都会从共通的原始码产生出来,这项工程正在进行中。(译按:现在市面上的麦金塔版与 Windows 版的 Word 应该都是从同样的原始码产生出来的了。)
程序代码的写法
即使不是 MS-DOS , Microsoft Windows 或苹果麦金塔的专家也可以看懂本书的程序代码-这些程序都是直接用 C 写成的,应该可以在任何 ANSI C 的开发系统上编译与执行。
不过,如果你是个主机程序员或者在微电脑的程序设计上没有什么经验,要留心微电脑的操作系统还很少有支持内存保护措施的。你可以透过 NULL 指针进行内存读写,乱填堆栈框,或是在内存中到处乱丢垃圾-即使是属于其它应用程序的内存-而硬件在你作这些事情时并不会提出任何警告。如果你原先认为 " 硬件要会抓出这种问题 " ,那是你原来的程序发展环境刚好幸运的是个有着强力保护措施的系统而已。并不是所有的程序员都如此幸运的。
在好几个地方我用到了一些不太合乎标准要求的 ANSI C 程序酷函式。例如 ANSI C 版本的 memchr 函式将字符 c 宣告成 int 变量:
void *memchr(const void *s,int c,size_t n);
内部运作上, memchr 将这个字符当成无正负号的 unsigned char 字符,但是字符为了后向兼容于 ANSI 标准颁布以前那没有雏形宣告时代的原始码,被宣告成了有正负号的 int 有号整数。由于我在本书从头到尾都使用 ANSI C 语法,我将这些后向兼容的细节完全拋弃,为了让语法清晰与符合较强势的型态检查,而使用了更精确的变量型态。在第一章中,我会更详细解释为何这么做是必要的,不过现在只要记住并不是所有的 " 标准 " 函式都是精确照着标准写出来的。你将经常看到 memchr 的函式中引用的字符变量被宣告成了 char 而非 int :
void *memchr(const void *pv,unsigned char ch,size_t size);
译注:在现在的 Mac OS , Windows 95/98/NT 上都提供了内存保护的支持,不过有时候操作系统实作会为了效率与方便上的需求,在内存保护的支持上打出许多大洞,这也是为何写错了的程序还是可以很轻松的把这些支持了内存保护的微电脑操作系统当掉的原因。
那些冗长难懂的名称是作什么用的?
现在你大概已经概略翻阅过这本书,并注意到程序代码列表里那许多长得很奇怪的变量与函式名称,如常见的 pch , ppb 与 pvResizeBlock 。
虽然像 pch 这样的名称看来有趣而且难念,它们还是表达了些讯息-只要你了解这种由 Charles Simonyi 在 1970 年代早期发展出来的 " 匈牙利 " 命名规则( Huangarian Naming Convention ),这种命名规则的前提是让一个变量名称除了代表一个变量本身以外,更重要的是让检阅程序代码的人能够了解一个变量存在的意义。
我在本书中使用的简化版匈牙利命名规则的细节相当简单。对你程序中每种资料型态的变量,你拿这资料型态的缩写作为变量名称的一部份命名。这不算什么重大突破-写程序的人们老早就习惯了把他们的字符变量叫做 c 或 ch ,字节变量叫做 b ,整数变量叫做 i 等等的做法,匈牙利命名规则只是强制标明了程序中每一种资料型态的变量。例如:
char ch; /* 一个简单的字符 */
byte b; /* 一个字节,也可以代表无正负号的字符 */
flag f; /* 永远为TRUE或FALSE的旗标 */
symbol sym; /* 某种符号结构 */
这种做法并不指定资料型态的缩写该长什么样子-只要在程序中能够前后一致的表示同个资料型态就好了。
指针变量带来了一个有趣的问题,它们总是指向别的资料。对于这个问题,匈牙利命名规则要求所有指针变量的名称都以 p 开始,接着它们指向的资料型态名称的缩写。如果你要宣告指向上头那些资料的指针,你就会写成如底下这样:
char *pch; /* 一个字符指针 */
byte *pb; /* 字节指针 */
flag *pf; /* 指向自己型态的指针等等 */
symbol *psym;
指向其它指针的指针也跟指向一般资料型态的指针没什么太大的不同-只是在变量名称之前再多个 p 而已。对于一个指向字符指针的指针,它的名称就是在 pch 之前再多个 p :
char **ppch; /* 指向一个字符指针的指针 */
这种照着匈牙利命名规则接起来的资料型态用法不好念,不过这命名规则允许程序员在变量型态的缩写之后接上一两个有描述作用的单字-每个都以大写字母起头。这不只增进了可读性,也易于分辨两个变量型态相似的变量。以 strcpy 函式为例,使用两个字符指针作为参数,函式的雏形宣告应该如下:
char *strcpy(char *pchTo,char *pchFrom); /* 函式雏形宣告 */
有另一点要提的,匈牙利命名规则的用途在于增进变量使用的可读性,注重资料型态所代表的意义更甚于它们实际宣告的形式。虽然 strcpy 的两个参数都宣告成指针变量的型态,但更重要的,它们指向以零字符结尾的字符串。将 strcpy 的两个参数以 pch 开头命名是对的,但是以 str 命名的话,会更有意义:
char *strcpy(char *strTo,char *strFrom); /* 函式雏形宣告 */
两个以 str 起头的变量仍然是字符指针,但是当你看到这些名称时,你知道它们是比较特别的字符指针-它们指向字符串。函式与数组名也依循同样的命名规则-它们以传回的型态接着一个描述性的卷标命名。在正式的匈牙利命名规则中,函式名称永远以大写起头,但在本书中,我为了一致性,将命名方式加以统一;命名时的大写用法在函式名称、数组名及变量名称之间并没有什么不同。如果把标准的 malloc 跟 realloc 函式写成匈牙利命名规则的形式,它们的函式雏形宣告将如下:
void *pvNewBlock(size_t size); /* 函式雏形宣告 */
void *pvResizeBlock(void *pv,size_t sizeNew); /* 函式雏形宣告 */
使用匈牙利命名规则的一个好处是易于解读指针表达式。例如,你在本书中看到许多指向指针的指针,特别是 ppb 的:
*ppb = pbNew;
虽然这段程序代码可能在第一次看到时让人看不懂,一旦你了解你可以拿掉这种表达式中的 * 跟 p 后,你就能简单了解这种表达式是用来作什么的了。如果把上头这表达式中的 * 跟一个 p 拿掉,你得到
pb = pbNew;
由于资料型态吻合-都是 pb -你知道这个表达式是正确的。 & 跟 -> 也可以跟 p 一起拿掉。考虑下面的叙述:
pb = & b;
b = psym->bLength;
把第一个叙述中的 p 跟 & 一起拿掉,你会得到一个把字节变量的值指派给一个字节变量的叙述,而你知道这样的表达式是合法的。接下来第二个叙述,如果你把 p 跟 -> 拿掉,你也会得到把一个字节变量的值指派给一个字节变量的叙述: b = sym.bLength. 这种称作 " 型态运算 " 的做法简化了拆开复杂的指针表达式的难度。
虽然匈牙利命名规则还有很多部分没提到,这里提到的大略足够让你看懂本书中的程序代码了。
想知道更多?
本书中采用的简化版匈牙利命名规则与 Charles Simonyi 发展的完整版本完全不能相较。 pch[] 是个 pch 的数组呢,或者它只是个指向一个字符数组的指针呢?在我用的简化版匈牙利命名规则中,你得看过这变量的宣告后才能确定是哪一种,但在完整版的匈牙利命名规则中完全没有这种状况与其它模棱两可的情形。我使用简化过的版本,只因为这么做比较简单,而且本书中不会碰到那些模棱两可的情形。对于只使用 Charles Simonyi 那定义完整的命名规则的一小部份,我在此道歉。
匈牙利命名规则并不适用于所有人。有些人会认为这么做在结构化程序设计中是最好的;其它人则只是因为因为个人感觉而讨厌它,两派人马各有主张。如果你觉得匈牙利命名规则有意思,你想深入了解一下,你可以在 Charles Simonyi 的博士论文: "Meta-Programming: A Software Production Method" ( Standford University , 1997; Xerox Palo Alto Research Center , 1997 )中找到找到这种命名规则的完整讨论。
什么东西才算程序臭虫?
在我们开始第一章那种假想中会帮你抓虫的编译器的讨论前,我应该稍微解释一下本书所要消灭的程序错误的种类。我了解你晓得程序错误是什么-我不需要为读这本书的人定义什么叫做臭虫吧?不过在本书中,我对两种程序错误作了分类:你在写出一个功能时产生的臭虫,跟你认为你的程序已经完成了以后仍然存在的臭虫。
许多软件工作室使用原始码控制系统来简化程序发展。一名程序员大致以查阅图书馆中藏书的方式来找寻需要修改的档案,唯一的不同点在于,程序员找到的是档案的一份复制品,而不是档案本身。这让程序员可以写作新功能而不用碰到原始码正本。一旦程序员完成了这个新功能,而且确定程序中没虫了,这档案就被放回去更新,原始码控制系统据此更新原始码正本。
在这种做法之下,一名程序员不必在乎自己写作新功能时究竟产生了多少错误,只要所有的错误在新程序代码归档进原始码正本前都被抓出来了就好。
本书中我所要讲的 " 臭虫 " ,是指那些存在于原始码正本里头的,那些对推出的产品造成伤害,而且会影响到消费者的那种。我不期望程序员们每次坐在键盘前面时都能写出毫无缺点的程序,但是我相信避免让臭虫出现在原始码正本中是办得到的一件事。
接下来的篇章里所出现的准则与技巧说明了如何写出这样的零错误程序。