微软C编程精粹(建国专用版)


1 第1111章 假想的编译程序 读者可以考虑一下倘若编译程序能够正确地指出代码中的所有问题,那相应程序的错 误情况会怎样?这不单指语法错误,还包括程序中的任何问题,不管它有多么隐蔽。例如, 假定程序中有“差1”错误,编译程序可以采用某种方法将其查出,并给出如下的错误信息 -> 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 好了,要求编译程序能够做到这一程度似乎有点过分。但如编译程序真能做到这些, 可以想象编写无错程序会变得多么容易。那简直是小事一桩,和当前程序员的一般作法真 没法比。 假如在间谍卫星上用摄像机对准某个典型的软件车间.就会看到程序员们正弓着身子 趴在键盘上跟踪错误;旁边,测试者正在对刚作出的内部版本发起攻击,轮番轰炸式地输 入人量的数据以求找出新的错误。你还会发现,测试员正在检查老版本的错误是否溜进了 新版本。可以推想,这种查错方法比用上面的假想编译程序进行查错要花费大得多的工作 量、确实如此,而且它还要有点运气。 运气? 是的,运气。测试者之所以能够发现错误,不正是因为他注意到了诸如某个数不对、 某个功能没按所期望的方式工作或者程序瘫痪这些现象吗?再看看上面的假想编译程序给 出的上述错误:程序虽然有了“差1”错误,但如果它仍能工作,那么测试者能看得出来吗? 就算看得出来,那么另外两个错误呢? 这听起来好象很可怕但测试人员就是这样做的大量给程序输入数据,希望潜在的错误 能够亮相。“噢,不!我们测试人员的工作可不这么简单,我们还要使用代码覆盖工具、自 动的测试集、随机的“猴”程序、抽点打印或其他什么的”。也许是这样,但还是让我们来 看看这些工具究竟做了些什么吧!覆盖分析工具能够指明程序中哪些部分未被测试到,测 试人员可以使用这一信息派生出新的测试用例。至于其它的工具无非都是“输入数据、观 察结果”这一策略的自动化。 请不要产生误解,我并不是说测试人员的所作所为都是错误的。我只是说利用黑箱方 法所能做的只是往程序里填数据,并看它弹出什么。这就好比确定一个人是不是疯子一样。 问一些问题,得到回答后进行判断。但这样还是不能确定此人是不是疯子。因为我们没法 知道其头脑中在想些什么。你总会这样地问自己:“我问的问题够吗?我问的问题对 2 吗… … ”。 因此,不要光依赖黑箱测试方法。还应该试着去模仿前面所讲的假想编译程序,来排 除运气对程序测试的影响,自动地抓住错误的每个机会。 考虑一下所用的语言 你最后一次看推销字处理程序的广告是什么时候?如果那个广告是麦迪逊大街那伙人 写的,它很可能是这么说:“无论是给孩子们的老师写便条还是为下期的《Great American Novel》撰稿,WordSmasher都能行,毫不费劲!WordSmasher配备了令人吃惊的 233000 字 的拼写字典,足足比同类产品多 51000 个字。它可以方便地找出样稿中的打字错误。赶快 到经销商那里去买一份拷贝。WordSmasher是从圆珠笔问世以来最革命性的书写工具!”。 用户经过不断地市场宣传熏陶,差不多都相信拼写字典越大越好,但事实并非如此。 象em、abel 和si这些词,在任何一本简装字典中都可以查到、但在 me、able 和is如此 常见的情况下您还想让拼写检查程序认为 em、abel 和si也是拼写正确的词吗?如果是, 那么当你看到我写的 suing 时,其本意很可能是与之风马牛不相及的 using。问题不在于 suing 是不是一个真正的词而在于它在此处确实是个错误。 幸运的是,某些质量比较高的拼写检查程序允许用户删去象em这类容易引起麻烦的词 。 这样一来,拼写检查程序就可以把原来合法的单词看成是拼写错误。好的编译程序也应该 能够这样 ─── 可以把屡次出错的合法的 C习惯用法看成程序中的错误。例如,这类编 译程序能够检查出以下 while 循环错放了一个分号: /* 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 表达式后由的分号肯定是个错误,但编译程 序却认为这是一个完全合法的 while 语句,其循环体为空语句。由于有时需要空语句,有 时不需要空语句,所以为了查出不需要的空语句,编译程序常常在遇到空语句时给出一条 可选的警告信息,自动警告你可能出了上面的错误。当确定需要用空语句时,你就用。但 最好用 NULL 使其明显可见。例如: char* strcpy(char* pchTo, char* pchFrom) { char* pchStart = pchTo; 3 while(*pchTo++ = *pchFrom++) NULL; Return(pchStart); } 由于NULL 是个合法的C表达式,所以这个程序没有间题。使用 NULL 的更大好处在于 编译程序不会为 NULL 语句生成任何的代码,因为 NULL 只是个常量。这样,编译程序接受 显式的 NULL 语句,但把隐式空语句自动地当作错误标出。在程序中只允许使用一种形式的 空语句,如同为了保持文字的一致性,文中只想使用zero 的一种复数形式 zeroes,因此要 从拼写字典中删除另一种复数形式zeros。 另一个常见的问题是无意的赋值。C是一个非常灵活的语言,它允许在任何可以使用表 达式的地方使用赋值语句。因此如果用户不够谨慎,这种多余的灵活性就会使你犯错误。 例如,以下程序就出现了这种常见的错误: if(ch = ‘\t’) ExpandTab(); 虽然很清楚该程序是要将 ch与水平制表符作比较,但实际上却成了对 ch的赋值。对 于这种程序,编译程序当然不会产生错误,因为代码是合法的 C。 某些编译程序允许用户在 && 和 | | 表达式以及 if、for 和while 构造的控制表达式 中禁止使用简单赋值,这样就可以帮助用户查出这种错误。这种做法的基本依据是用户极 有可能在以上五种情况下将等号==偶然地健入为赋值号=。 这种选择项并不妨碍用户作赋值,但是为了避免产生警告信息,用户必须再拿别的值, 如零或空字符与赋值结果做显式的比较。因此,对于前面的 strcpy 例子,若循环写成: while(*pchTo++ = *pchFrom++) NULL; 编译程序会产生警告信息一所以要写成; while( (*pchTo++ = *pchFrom++)!=’\0’) NULL; 这样做有两个好处。第一,现代的商用级编译程序不会为这种冗余的比较产生额外的 代码,可以将其优化掉。因此,提供这种警告选择项的编译程序是可以信赖的。第二,它 可以少冒风险,尽管两种都合法,但这是更安全的用法。 另一类错误可以被归入“参数错误”之列。例如,多年以前,当我正在学 C语言 时, 曾经这样调用过 fputc: fprintf(stderr, “Unable to open file %s. \n”, filename); …… fputc(stderr, ‘\n’); 这一程序看起来好象没有问题,但 fputc 的参数次序错了。不知道为什么,我一直认 为流指针(stderr)总是这类流函数的第一个参数。事实并非如此,所以我常常给这些函 数传递过去许多没用的信息。幸好ANSI C提供了函数原型,能在编译时自动地查出这些错 4 误。 由于ANSI C标准要求每个库函数都必须有原型所以在stdio.h 头文件中能够找到 fputc 的 原型。fputc 的原型是: int fputc(int c, FILE* stream); 如果在程序中 include 了stdio.h,那么在调用fputc 时,编译程序会根据其原型对所 传递的每个参数进行比较。如果二者类型不同,就会产生编译错误。在上面的错误例于中, 因为在int的位置上传递了FILE* 类型的参数,所以利用原型可以自动地发现前一个fputc 的错误。 ANSI C虽然要求标准的库函数必须有原型,但并不要求用户编写的函数也必须有原型。 严格地说,它们可以有原型,也可以没有原型。如果用户想要检查出自己程序中的调用错 误,必须自己建立原型,并随时使其与相应的函数保持一致。 最近我听到程序员在抱怨他们必须对函数的原型进行维护。尤其是刚从传统 C项目转 到ANSI C项目时,这种抱怨更多。这种抱怨是有一定理由的,但如果不用原型,就不得不 依赖传统的测试方法来查出程序中的调用错误。你可以扪心自问,究竟哪个更重要,是减 少一些维护工作量,还是在编译时能够查出错误?如果你还不满意,请再考虑一下利用原 型可以生成质量更好的代码这一事实。这是因为:ANSI C标准使得编译程序可以根据原型 信息进行相应的优化。 在传统 C中,对于不在当前正被编译的文件中的函数,编译程序基本上得不到关于它 的信息。尽管如此,编译程序仍然必须生成对这些函数的调用,而且所生成的调用必须奏 效。 编译程序实现者解决这个问题的办法是使用标准的调用约定。这一方 法虽然奏效,但常常意味着编译程序必须生成额外的代码,以满足调用约定的要求。但如 果使用了“要求所有函数 都必须有原型”这一编译程序提供的警告选择项, 由于编译程序了解程序中每个函数的参数情况,所以可以为不同的函数选择它认为最有效 率的调用约定。 空语句、错误的赋值以及原型检查只是许多 C编译程序提供的选择项中的一小部分内 容,实际上常常还有更多的其它选择项。这里的要点是:用户可以选择的编译程序警告设 施可以就可能的错误向用户发出警告信息,其工作的方式非常类似于拼写检查程序对可能 的拼写错误的处理 Peter Lynch,据说是 80年代最好的合股投资公司管理者,他曾经说过:投资者与赌 徒之间的区别在于投资者利用每一次机会,无论它是多么小,去争取利益;而赌徒则只靠 运气。用户应该将这一概念同样应用于编程活动,选择编译程序的所有可选警告设施,并 把这些措施看成是一种无风险高偿还的程序投资。再不要问:“应该使用这一警告设施吗? 而应该 问:“为什么不使用这一警告设施呢?”。要把所有的警告开关都打开,除非有极好 的理由才不这样做。 使用编译程序所有的可选警告设施 5 增强 原型的能力 增强 原型的能力 增强 原型的能力 增强 原型的能力 不幸的 是,如果函 数有两个参数 的类型相同 ,那么即使 在调用该函数 时互换了这 两个 不幸的 是,如果函 数有两个参数 的类型相同 ,那么即使 在调用该函数 时互换了这 两个 不幸的 是,如果函 数有两个参数 的类型相同 ,那么即使 在调用该函数 时互换了这 两个 不幸的 是,如果函 数有两个参数 的类型相同 ,那么即使 在调用该函数 时互换了这 两个 参数的位置,原型也 查不出这一调用错误。例如,如果函 数 参数的位置,原型也 查不出这一调用错误。例如,如果函 数 参数的位置,原型也 查不出这一调用错误。例如,如果函 数 参数的位置,原型也 查不出这一调用错误。例如,如果函 数 memchr 的原型是: 的原型是: 的原型是: 的原型是: void* memchr(const void* pv, int ch, int size); 那么在 调用该函数 时,即使互换 其字 符 那么在 调用该函数 时,即使互换 其字 符 那么在 调用该函数 时,即使互换 其字 符 那么在 调用该函数 时,即使互换 其字 符 ch和大 小 和大 小 和大 小 和大 小 size 参数, 编译程序也 不会发出警告 信 参数, 编译程序也 不会发出警告 信 参数, 编译程序也 不会发出警告 信 参数, 编译程序也 不会发出警告 信 息。但 是如果在相 应界面和原型 中使用了更 加精确的类 型,就可以增 强原型提供 的错误检 息。但 是如果在相 应界面和原型 中使用了更 加精确的类 型,就可以增 强原型提供 的错误检 息。但 是如果在相 应界面和原型 中使用了更 加精确的类 型,就可以增 强原型提供 的错误检 息。但 是如果在相 应界面和原型 中使用了更 加精确的类 型,就可以增 强原型提供 的错误检 查能力。例如,如果 有了下面的原型: 查能力。例如,如果 有了下面的原型: 查能力。例如,如果 有了下面的原型: 查能力。例如,如果 有了下面的原型: void* memchr(const void* pv, unsigned char ch, size_t size); 那么在调用该函数时 弄反了其字 符 那么在调用该函数时 弄反了其字 符 那么在调用该函数时 弄反了其字 符 那么在调用该函数时 弄反了其字 符 ch和大 小 和大 小 和大 小 和大 小 size 参数,编译程序就会 给出警告错误。 参数,编译程序就会 给出警告错误。 参数,编译程序就会 给出警告错误。 参数,编译程序就会 给出警告错误。 在原型 中使用更精 确类型的缺陷 是常常必须 进行参数的 显式类型转换 ,以消除类 型不 在原型 中使用更精 确类型的缺陷 是常常必须 进行参数的 显式类型转换 ,以消除类 型不 在原型 中使用更精 确类型的缺陷 是常常必须 进行参数的 显式类型转换 ,以消除类 型不 在原型 中使用更精 确类型的缺陷 是常常必须 进行参数的 显式类型转换 ,以消除类 型不 匹配的错误,即使参 数的次序正确。 匹配的错误,即使参 数的次序正确。 匹配的错误,即使参 数的次序正确。 匹配的错误,即使参 数的次序正确。 lint并不那么差 另一种检查错误更详细、更彻底的方法是使用lint,这种方法几乎不费什么事。最初, lint 这个工具用来扫描 C源文件并对源程序中不可移植的代码提出警告。但是现在大多数 lint 实用程序已经变得更加严密,它不但可以检查出可移植性问题,而且可以检查出那些 虽然可移植并且完全合乎语法但却很可能是错误的特性,上一节那些可疑的错误就属于这 一类。 不幸的是,许多程序员至今仍然把 lint 看作是一个可移植性的检查程序,认为它只能 给出一大堆无关的警告信息。总之,lint 得到了不值得麻烦的名声。如果你也是这样想的 程序员,那么你也许应该重新考虑你的见解。想一想究竟是哪一种工具更加接近于前文所 述的假想编译程序,是你正使用的编译程序,还是 lint? 实际上,一旦源程序变成了没有 lint 错误的形式,继续使其保持这种状态是很容易做 到的。只要对所改变的部分运行lint,没有错误之后再把其并入到原版源代码中即可。利 用这种方法,并不要进行太多的考虑,只要经过一、二周就可以写出没有lint 错误的代码 。 在达到这个程度时,就可以得到lint 带来的各种好处了。 但我做的修改很平常 一次在同本书的一个技术评审者共进午餐时,他问我本书是否打算包括一节单元测试 方面的内容。我回答说:“不”。因为尽管单元测试也与无错代码的编写有关,但它实际上 属于另一个不同的类别,即如何为程序编写测试程序。 他 说 :“不,你误解了。我的意思是你是否打算指出在将新做的修改并入原版源代码之 使用lint lint lint lint 来查出编译程序漏掉的错误 6 前,程序员应该实际地进行相应的单元测试。我的小组中的一位程序员就是因为在进行了 程序的修改之后没有进行相应的单元测试,使一个错误进入到我们的原版源代码中。” 这使我感到很惊奇。因为在Microsoft,大多数项目负责人都要求程序员在合并修改了 的源代码之前,要进行相应的单元测试。 “你没问他为什么不做单元测试吗?”,我问道。 我的朋友从餐桌上抬起头来对我说:“他说他并没有编写任何新的代码,只是对现有代 码进行了某些移动。他说他认为没必要再进行单元测试”。 这种事情在我的小组中也曾经发生过。 它使我想起曾经有一个程序员在进行了修改之后,甚至没有再编译一次就把相应的代 码并入了原版源代码中。当然,我发现了这一问题,因为我在对原版源代码进行编译时产 生了错误。当我问这个程序员怎么会漏掉这个编译错误,他说:“我做的修改很平常,我认 为不会出错”,但他错了。 这些错误本来都应该不会进入原版源代码中,因为二者都可以几乎毫不费力地被查出 来。为什么程序员会犯这种错误呢?是他们过高地估计了自己编写正确代码的能力。 有时,似乎可以跳过一些设计用来避免程序出错的步骤,但走捷径之时,就是麻烦将 至之日。我怀疑会有许多的程序员甚至没有对相应的代码进行编译,就“完成”了某一功 能。我知道这只是偶然情况,但绕过单元测试的趋势正在变强,尤其是作简单的改动。 如果你发现自己正打算绕过某个步骤。而它恰恰可以很容易地用来查错,那么一定要 阻止自己绕过。相反,要利用所能得到的每个工具进行查错。此外,单元测试虽然意味着 查错,但如果你根本就不进行单元测试也是枉然。 小结 你认识哪个程序员宁愿花费时间去跟踪排错,而不是编写新的代码?我肯定有这种程 序员,但至今我还没有见过一个。对于我认识的程序员,如果你答应他们再不用跟踪下一 个错误,他们会宁愿一辈子放弃享用中国菜。 当你写程序时,要在心中时刻牢记着假想编译程序这一概念,这样就可以毫不费力或 者只费很少的力气利用每个机会抓住错误。要考虑编译程序产生的错误、lint 产生的错误 以及单元测试失败的原因。虽然使用这些工具要涉及到很多的特殊技术,但如果不花这么 多的功夫,那产品中会有多少个错误? 如果想要快速容易地发现错误,就要利用工具的相应特性对错误进行定位。错误定位 得越早,就能够越早地投身于更有兴趣的工作。 要点: ���� 消除程序错误的最好方法是尽可能早、尽可能容易地发现错误,要寻求费力最小 如果有单元测试,就进行单元测试 7 的自动查错方法。 ���� 努力减少程序员查错所需的技巧。可以选择的编译程序或lint 警告设施并不要求 程序员要有什么查错的技巧。在另一个极端,高级的编码方法虽然可以查出或减 少错误,但它们也要求程序员要有较多的技巧,因为程序员必须学习这些高级的 编码方法。 练习: 1) 假如使用了禁止在 while 的条件部分进行赋值的编译程序选择项,为什么可以查 出下面代码中的运算优先级错误? While(ch = getchar() != EOF) …… 2) 看看你怎样使用编译程序查出无意使用的空语句和赋值语句。值得推荐的办法是 进行相应的选择,使编译程序能够对下列常见问题产生警告信息。怎样才能消除 这些警告信息呢? a) if(flight == 063)。这里程序员的本意是对 63号航班进行测试,但因为前 面多了一个 0使063 成了八进制数。结果变成对51号航班进行测试。 b) If(pb != NULL & pb != 0xff)。这里不小心把&&键入为&,结果即使pb等于 NULL 还会执行*pb != 0xff。 c) quot = numer/*pdenom。这里无意间多了个*号结果使/*被解释为注释的开 始。 d) word = bHigh<<8 + bLow。由于出现了运算优先级错误,该语句被解释成了: word = bHigh << (8+bLow) 3) 编译程序怎样才能对“没有与之配对的 else”这一错误给出警告?用户怎样消除 这一警告? 4) 再看一次下面的代码: if(ch == ‘\t’) ExpandTab(); 除禁止在 if语句中使用简单赋值的方法之外,能够查出这个错误的另一种众所周 知的方法是把赋值号两边的操作数颠倒过来: if(‘\t’ == ch) ExpandTab(); 这样如果应该键入==时健入了=,编译程序就会报错,因为不允许对常量进行 赋值。这个办法彻底吗?为什么它不象编译程序开关自动化程度那么高?为什么 8 新程序员会用赋值号代替等号? 5) C的预处理程序也可能引起某些意想不到的结果。例如,宏 UINT_MAX 定义在 limit.h 中,但假如在程序中忘了include 这个头文件,下面的伪指令就会无声无 息地失败,因为预处理程序会把预定义的 UINT_MAX 替换成 0: …… #if UINT_MAX > 65535u …… #endif 怎样使预处理程序报告出这一错误? 课题: 为了减轻维护原型的工作量,某些编译程序会在编译时自动地为所编译的程序生成原 型。如果你用的编译程序没有提供这一选择项,自己写一个使用程序来完成这一工作。为 什么标准的编码约定可以使这个使用程序的编写变得相对容易? 课题: 如果你用的编译程序还不支持本章(包括练习)中提及的警告设施,那么促进相应的 制造商支持这些设施,另外要敦促他们除了允许用户设定或者取消对某些类错误的检查之 外,还要提供有选择地设定或取消一些特定的警告设施,为什么要这样做呢? 9 第2222章 自己设计并使用断言 利用编译程序自动查错固然好,但我敢说只要你观察一下项目中那些比较明显的错误, 就会发现编译程序所查出的只是其中的一小部分。我还敢说,如果排除掉了程序中的所有 错误那么在大部分时间内程序都会正确工作。 还记得第 1章中的下面代码吗? strCopy = memcpy(malloc(length), str, length); 该语句在多数情况下都会工作得很好,除非 malloc 的调用产生失败。当 malloc 失败 时,就会给memcpy 返回一个 NULL 指针。由于memcpy 处理不了 NULL 指针,所以出现了错 误。如果你很走运,在交付之前这个错误导致程序的瘫痪,从而暴露出来。但是如果你不 走运,没有及时地发现这个错误,那某位顾客就一定会“走运”了。 编译程序查不出这种或其他类似的错误。同样,编译程序也查不出算法的错误,无法 验证程序员所作的假定。或者更一般地,编译程序也查不出所传递的参数是否有效。 寻找这种错误非常艰苦,只有技术非常高的程序员或者测试者才能将它们根除并且不 会引起其他的问题。 然而假如你知道应该怎样去做的话,自动寻找这种错误就变得很容易了。 两个版本的故事 让我们直接进入 memcpy,看看怎样才能查出上面的错误。最初的解决办法是使memcpy 对NULL 指针进行检查,如果指针为 NULL,就给出一条错误信息,并中止 memcpy 的执 行。 下面是这种解法对应的程序。 /* memcpy ─── 拷贝不重叠的内存块 */ void memcpy(void* pvTo, void* pvFrom, size_t size) { void* pbTo = (byte*)pvTo; void* pbFrom = (byte*)pvFrom; if(pvTo == NULL | | pvFrom == NULL) { fprintf(stderr, “Bad args in memcpy\n”); abort(); } while(size-->0) *pbTo++ == *pbFrom++; return(pvTo); } 只要调用时错用了 NULL 指针,这个函数就会查出来。所存在的唯一问题是其中的测试 10 代码使整个函数的大小增加了一倍,并且降低了该函数的执行速度。如果说这是“越治病 越糟”,确实有理,因为它一点不实用。要解决这个问题需要利用 C的预处理程序。 如果保存两个版本怎么样?一个整洁快速用于程序的交付;另一个臃肿缓慢件(因为 包括了额外的检查),用于调试。这样就得同时维护同一程序的两个版本,并利用C的预处 理程序有条件地包含或不包含相应的检查部分。 void memcpy(void* pvTo, void* pvFrom, size_t size) { void* pbTo = (byte*)pvTo; void* pbFrom = (byte*)pvFrom; #ifdef DEBUG if(pvTo == NULL | | pvFrom == NULL) { fprintf(stderr, “Bad args in memcpy\n”); abort(); } #endif while(size-->0) *pbTo++ == *pbFrom++; return(pvTo); } 这种想法是同时维护调试和非调试(即交付)两个版本。在程序的编写过程中,编译 其调试版本,利用它提供的测试部分在增加程序功能时自动地查错。在程序编完之后,编 译其交付版本,封装之后交给经销商。 当然,你不会傻到直到交付的最后一刻才想到要运行打算交付的程序,但在整个的开发工 程中,都应该使用程序的调试版本。正如在这一章和下一章所建,这样要求的主要原因是 它可以显著地减少程序的开发时间。读者可以设想一下:如果程序中的每个函数都进行一 些最低限度的错误检查,并对一些绝不应该出现的条件进行测试的活,相应的应用程序会 有多么健壮。 这种方法的关键是要保证调试代码不在最终产品中出现。 利用断言进行补救 说老实话 memcpy 中的调试码编得非常蹩脚,且颇有点喧宾夺主的意味。因此尽管它能 产生很好的结果,多数程序员也不会容忍它的存在,这就是聪明的程序员决定将所有的调 试代码隐藏在断言 assert 中的缘故。assert 是个宏,它定义在头文件assert.h 中 。assert 既要维护程序的交付版本,又要维护程序的调试版本 11 虽然不过是对前面所见#ifdef 部分代码的替换,但利用这个宏,原来的代码从7行变成了 1行。 void memcpy(void* pvTo, void* pvFrom, size_t size) { void* pbTo = (byte*)pvTo; void* pbFrom = (byte*)pvFrom; assert(pvTo != NULL && pvFrom != NULL); while(size-->0) *pbTo++ == *pbFrom++; return(pvTo); } aasert 是个只有定义了 DEBUG 才起作用的宏,如果其参数的计算结果为假,就中止调 用程序的执行。因此在上面的程序中任何一个指针为 NULL 都会引发 assert。 assert 并不是一个仓促拼凑起来的宏,为了不在程序的交付版本和调试版本之间引起 重要的差别,需要对其进行仔细的定义。宏assert 不应该弄乱内存,不应该对未初始化的 数据进行初始化,即它不应该产主其他的副作用。正是因为要求程序的调试版本和交付版 本行为完全相同,所以才不把 assert 作为函数,而把它作为宏。如果把 assert 作为函数 的话,其调用就会引起不期望的内存或代码的兑换。要记住,使用assert 的程序员是把它 看成一个在任何系统状态下都可以安全使用的无害检测手段。 读者还要意识到,一旦程序员学会了使用断言,就常常会对宏assert 进行重定义。例 如,程序员可以把assert 定义成当发生错误时不是中止调用程序的执行,而是在发生错误 的位置转入调试程序。assert 的某些版本甚至还可以允许用户选择让程序继续运行,就仿 佛从来没有发生过错误一样。 如果用户要定义自己的断言宏,为不影响标准 assert 的使用,最好使用其它的名字。 本书将使用一个与标准不同的断言宏,因为它是非标准的,所以我给它起名叫做 ASSERT, 以使它在程序中显得比较突出。宏assert 和ASSERT 之间的主要区别是 assert 是个在程序 中可以随便使用的表达式,而ASSERT 则是一个比较受限制的语句。例如使用assert,你可 以写成: if(assert(p != NULL), p->foo!=bar) …… 但如果用 ASSERT 试试就会产生语法错误。这种区别是作者有意造成的。除非打算在表 达式环境中使用断言,否则就应该将ASSERT 定义为语句。只有这样,编译程序才能够在它 被错误地用到表达式时产生语法错误。记住,在同错误进行斗争时每一点帮助都有助于错 误的发现。我们为什么要那些自己从来用不着的灵活性呢? 下面是一种用户自己定义宏ASSERT 的方法: #ifdef DEBUG void _Assert(char* , unsigned); /* 原型 */ 12 #define ASSERT(f) \ if(f) \ NULL; \ else \ _Assert(__FILE__ ,__LINE__) #else #define ASSERT(f) NULL #endif 从中我们可以看到,如果定义了DEBUG,ASSERT 将被扩展为一个 if语句。if语句中的 NULL 语句让人感到很奇怪,这是因为要避免 if不配对,所以它必须要有 else 语句。也许读者 认为在_Assert 调用的闭括号之后需要一个分号,但并不需要。因为用户在使用ASSERT 时, 已经给出了一个分号. 当ASSERT 失败时,它就使用预处理程序根据宏__FILE__和__LINE__所提供的文件名和 行号参数调用_Assert。_Assert 在标准错误输出设备 stderr 上打印一条错误消息,然后中 止: void _Assert(char* strFile, unsigned uLine) { fflush(stdout); fprintf(stderr, “\nAssertion failed: %s, line %u\n”,strFile, uLine); fflush(stderr); abort(); } 在执行 abort 之前,需要调用fflush 将所有的缓冲输出写到标准输出设备stdout 上。 同样,如果stdout和stderr 都指向同一个设备,fflush stdout仍然要放在fflush stderr 之前,以确保只有在所有的输出都送到stdout 之后,fprintf 才显示相应的错误信息。 现在如果用NULL 指针调用 memcpy,ASSERT 就会抓住这个错误,并显示出如下的错误 信息: Assertion failed: string.c , line 153 这给出了 assert 与ASSERT 之间的另一点不同。标准宏assert 除了给出以上信息之外,还 显示出已经失败了的测试条件。例如对这个问题,我通常所用编译程序的assert 会显示出 如下信息: Assertion failed: pvTo != NULL && pbFrom != NULL File string.c , line 153 在错误消息中包括测试表达式的唯一麻烦是每当使用 assert 时,它都必须为_Assert 产生一条与该条件对应的正文形式打印消息。但问题是,编译程序要在哪儿存储这个字符 串呢?Macintosh、DOS和Windows 上的编译程序通常在全局数据区存储字符串,但在 Macintosh 上,通常把最大的全局数据区限制为32K,在DOS和Windows 上限制为 64K。因 13 此对于象 Microsoft Word 和Excel 这样的大程序,断言字符串立刻会占掉这块内存。 关于这个问题存在一些解决的办法,但最容易的办法是在错误信息中省去测试表达式 字符串。毕竟只要查看了string.c 的第153 行,就会知道出了什么问题以及相应的测试条 件是什么。 如果读者想了解标准宏assert 的定义方法,可以查看所用的编译系统的 assert.h 文 件。ANSI C标准在其基本原理部分也谈到了 assert 并且给出了一种可能的实现。P.J. Plauger 在其“The Standard C library”一书中也给出了一种略微不同的标准assert 的 实现。 不管断言宏最终是用什么样方法定义的,都要使用它来对传递给相应函数的参数进行 确认。如果在函数的每个调用点都对其参数进行检查,错误很快就会被发现。断言宏的最 好作用是使用户在错误发生时,就可以自动地把它们检查出来。 “无定义”意味着“要避开” 如果读者停下来读读 ANSI C中memcpy 函数的定义,就会看到其最后一行说:“如果在 存储空间相互重叠的对象之间进行了拷贝,其结果无定义”。在其它的书中,对此的描述有 点不同。例如在 P.J. Plauger 和Jim Brodie 的“Standard C”中相应的描述是:“可以 按任何次序访问和存储这两个数组的元素”。 总之,这些书都说如果依赖于以按特定方式工作的 memcpy,那么当使用相互重叠的内 存块凋用该函数时,你实际上是做了一个编译程序不同(包括同一编译程序的不同版本)、 结果可能也不同的荒唐的假定。 确实有些程序员在故意地使用无定义的特性,但我想大多数的程序员都会很有头脑地 避开任何的无定义特性。我们不应该效仿前一部分程序员。对于程序员来说,无定义的特 性就相当于非法的特征,因此要利用断言对其进行检查。倘若本想调用memmove,却调用了 memcpy,难道你不想知道自己搞错了吗? 通过增加一个可以验证两个内存块绝不重叠的断言,可以把 memcpy 加强如下: /* memcpy ─── 拷贝不重叠的内存块 */ void memcpy(void* pvTo, void* pvFrom, size_t size) { void* pbTo = (byte*)pvTo; void* pbFrom = (byte*)pvFrom; ASSERT(pvTo != NULL && pvFrom != NULL); ASSERT(pbTo>=pbFrom+size || pbFrom>=pbTo+size); while(size-->0) *pbTo++ == *pbFrom++; 要使用断言对函数参数进行确认 14 return(pvTo); } 读者可能会认为上面的加强不大明显,怎么只用了一行语句就完成了重叠检查呢?其 实只要把两个内存块比作两辆在停车处排成一行等候的轿车,就可以很容易明白其中的道 理。我们知道,如果一辆车的后保险杠在另一辆车的前保险杠之前,两辆车就不会重叠。 上面的检查实现的就是这个思想,那里 pbTo 和pbFrom 是两个内存块的“后保险杠”。 PbTo+size 和pbFrom+size 分别是位于其相应“前保险杠”之前的某个点。就是这么简单。 顺便说一句如果读者还没有认识到重叠填充的严重性,只要考虑 pbTo 等于pbFrom+1 并且要求至少要移动两个字节这一情况就清楚了。因为在这种情况下,memcpy 的结果是错 误的。 所以从今以后,要经常停下来看看程序中有没有使用无定义的特性。如果程序中使用 了无定义的特性就要把它从相应的设计中去掉,或者在程序中包括相应的断言,以便在使 用了无定义的特性时,能够向程序员发出通报。 这种做法在为其他的程序员提供代码库(或操作系统)时显得特别重要。如果读者以 前曾经为他人提供过类似的库,就知道当程序员试图得到所需的结果时,他们会利用各种 各样的无定义特性。更大的挑战在于改进后新库的发行,因为尽管新库与老库完全兼容, 但总有半数的应用程序在试图使用新库时会产生瘫痪现象,问题在于新库在其“无定义的 特性”方面,与老库并不 100%兼容。 不要让 这种事 情发生在你 的身上 不要让 这种事 情发生在你 的身上 不要让 这种事 情发生在你 的身上 不要让 这种事 情发生在你 的身上 在在在在1988 年晚些时候, 年晚些时候, 年晚些时候, 年晚些时候, Microsoft 公司的摇钱 树 公司的摇钱 树 公司的摇钱 树 公司的摇钱 树 DOS版版版版Word 被推迟了三个月, 明显地 被推迟了三个月, 明显地 被推迟了三个月, 明显地 被推迟了三个月, 明显地 影影影影 响了公 司的销售。 这件事情的重 要原因,是 整整六个月 来开发小组成 员一直认为 他们随时 响了公 司的销售。 这件事情的重 要原因,是 整整六个月 来开发小组成 员一直认为 他们随时 响了公 司的销售。 这件事情的重 要原因,是 整整六个月 来开发小组成 员一直认为 他们随时 响了公 司的销售。 这件事情的重 要原因,是 整整六个月 来开发小组成 员一直认为 他们随时 都可以交 出 都可以交 出 都可以交 出 都可以交 出 Word。。。。 问题出 在 问题出 在 问题出 在 问题出 在 Word 小组要用到的一个关 键部分是由公司中另一个小组负责开发的。这个 小组要用到的一个关 键部分是由公司中另一个小组负责开发的。这个 小组要用到的一个关 键部分是由公司中另一个小组负责开发的。这个 小组要用到的一个关 键部分是由公司中另一个小组负责开发的。这个 小小小小 组一直告 诉 组一直告 诉 组一直告 诉 组一直告 诉 Word 小组他们的代码马上就 可以完成,而且该小组的成员对此确信不疑。但 小组他们的代码马上就 可以完成,而且该小组的成员对此确信不疑。但 小组他们的代码马上就 可以完成,而且该小组的成员对此确信不疑。但 小组他们的代码马上就 可以完成,而且该小组的成员对此确信不疑。但 他他他他 们没有意识到的是在 他们的代码中充斥了错误。 们没有意识到的是在 他们的代码中充斥了错误。 们没有意识到的是在 他们的代码中充斥了错误。 们没有意识到的是在 他们的代码中充斥了错误。 这个小 组的代码 与 这个小 组的代码 与 这个小 组的代码 与 这个小 组的代码 与 Word 代码之 间一个明显 的区别 是 代码之 间一个明显 的区别 是 代码之 间一个明显 的区别 是 代码之 间一个明显 的区别 是 Word 代码从 过去到现在 一直都使 代码从 过去到现在 一直都使 代码从 过去到现在 一直都使 代码从 过去到现在 一直都使 用断言 和调试代码 ,而他们的代 码却几乎没 有使用断言 。因此,其程 序员没有什 么好的办 用断言 和调试代码 ,而他们的代 码却几乎没 有使用断言 。因此,其程 序员没有什 么好的办 用断言 和调试代码 ,而他们的代 码却几乎没 有使用断言 。因此,其程 序员没有什 么好的办 用断言 和调试代码 ,而他们的代 码却几乎没 有使用断言 。因此,其程 序员没有什 么好的办 法可以 确定其代码 中的实际错误 情况,错误 只能慢慢地 暴露出来。如 果他们在代 码中使用 法可以 确定其代码 中的实际错误 情况,错误 只能慢慢地 暴露出来。如 果他们在代 码中使用 法可以 确定其代码 中的实际错误 情况,错误 只能慢慢地 暴露出来。如 果他们在代 码中使用 法可以 确定其代码 中的实际错误 情况,错误 只能慢慢地 暴露出来。如 果他们在代 码中使用 了断言,这些错误本 该在几个月之前就被检查出来。 了断言,这些错误本 该在几个月之前就被检查出来。 了断言,这些错误本 该在几个月之前就被检查出来。 了断言,这些错误本 该在几个月之前就被检查出来。 大呼“危险”的代码 要从程序中删去无定义的特性 或者在程序中使用断言来检查出无定义特性的非法使用 15 尽管我们已经到了新的题目,但我还是想再谈谈memcpy 中的重叠检查断言。对于上面 的重叠检查断言: ASSERT(pbTo>=pbFrom+size || pbFrom>=pbTo+size); 假如在调用 memcpy 时这个断言测试的条件为真,那么在发现这个断言失败了之后,如 果你以前从来没见过重叠检查,不知道它是怎么回事,你能想到发生的是什么查错吗?我 想我大概想不出来。但这并不是说上面的断言技巧性太强、清晰度不够,因为不管从那个 角度看这个断言都很直观。然而直观并不等于明显。 请相信我的话,很少比跟踪到了一个程序中用到的断言,但却不知道该断言的作用这 件事更令人沮丧的了。你浪费了大量的时间,不是为了排除错误,而只是为了弄清楚这个 错误到底是什么。这还不是事情的全部,更有甚者程序员偶尔还会设计出有错的断言。所 以如果搞不清楚相应断言检查的是什么,就很难知道错误是出现在程序中,还是出现在断 言中。幸运的是,这个问题很好解决,只要给不够清晰的断言加上注解即可。我知道这是 显而易见的事情,但令人惊奇的是很少又程序员这样做。为了使用户避免错误的危险,程 序员们经历了各种磨难,但却没有说明危险到底是什么。这就好比一个人在穿过森林时, 看到树上钉着一块上书“危险”红字的大牌子。但危险到底是什么?树要倒?废矿井?大 脚兽?除非告诉人们危险是什么或者危险非常明显,否则这个牌子就起不到帮助人们提高 警觉的作用,人们会忽视牌子上的警告。同样,程序员不理解的断言也会被忽视。在这种 情况下,程序员会认为相应的断言是错误的,并把它们从程序中去掉。因此,为了使程序 员能够理解断言的意图,要给不够清楚的断言加上注解。 如果在断言中的注解还注明了相应错误的其他可能解法,效果更好。例如在程序员使 用相互重叠的内存块调用memcpy 时,就是这样做的一个好机会。程序员可以利用注解指出 此时应该使用 memmove,它不但能够正好完成你想做的事情,而且没有不能重叠的限制: /* 内存块重叠吗?如果重叠,就使用memmove */ ASSERT(pbTo>=pbFrom+size || pbFrom>=pbTo+size); 在写断言的注解时,不必长篇大论。一般的方法时使用经过认真考虑过的间断文句, 它可能比用一整段的文字系统地解释出每个细节地指导性更强。但要注意,不要在注解中 建议解决问题的办法,除非你能确信它对其他程序员确有帮助。做注解的人当然不想让注 解把别人引入歧途。 不是用 来检查错误的 不是用 来检查错误的 不是用 来检查错误的 不是用 来检查错误的 当程序 员刚开始使 用断言时,有 时会错误地 利用断言去 检查真正地错 误,而不去 检查 当程序 员刚开始使 用断言时,有 时会错误地 利用断言去 检查真正地错 误,而不去 检查 当程序 员刚开始使 用断言时,有 时会错误地 利用断言去 检查真正地错 误,而不去 检查 当程序 员刚开始使 用断言时,有 时会错误地 利用断言去 检查真正地错 误,而不去 检查 非法的况。看看在下 面的函 数 非法的况。看看在下 面的函 数 非法的况。看看在下 面的函 数 非法的况。看看在下 面的函 数 strdup 中的两个断言: 中的两个断言: 中的两个断言: 中的两个断言: char* strdup(char* str) { 不要浪费别人的时间 ─── 详细说明不清楚的断言 16 char* strNew; ASSERT(str != NULL); strNew = (char*)malloc(strlen(str)+1); ASSERT(strNew != NULL); strcpy(strNew, str); return(strNew); } 第一个断言的用法是正确的,因为它被用来检查在该程序正常工作时绝不应该发生的第一个断言的用法是正确的,因为它被用来检查在该程序正常工作时绝不应该发生的第一个断言的用法是正确的,因为它被用来检查在该程序正常工作时绝不应该发生的第一个断言的用法是正确的,因为它被用来检查在该程序正常工作时绝不应该发生的 非法情况。第二个断言的用法相当不同,它所测试的是错误情况,是在其最终产品中肯定非法情况。第二个断言的用法相当不同,它所测试的是错误情况,是在其最终产品中肯定非法情况。第二个断言的用法相当不同,它所测试的是错误情况,是在其最终产品中肯定非法情况。第二个断言的用法相当不同,它所测试的是错误情况,是在其最终产品中肯定 会出现并且必须对其进行处理的错误情况。会出现并且必须对其进行处理的错误情况。会出现并且必须对其进行处理的错误情况。会出现并且必须对其进行处理的错误情况。 你又做假定了吗? 有时在编程序时,有必要对程序的运行环境做出某些假定。但这并不是说在编程序时, 总要对运行环境做出假定。例如,下面的函数 memset 就没对其运行环境做出任何的假定。 因此它虽然未必效率很高,但却能够运行在任何的 ANSI C编译程序之下: /* memset ─── 用“byte”的值填充内存 */ void* memset(void* pv, byte b, size_t size) { byte* pb = (byte*)pv; while(size-- > 0) *pb++ = b; return(pv); } 但是在许多计算机上通过先将要填充到内存块中的小值拼成较大的数据类型,然后用 拼出的大值填充内存,由于实际填充的次数减少了,可使编出的memset 函数速度更快,例 如在68000 上,下面的 mernset 函数的填充速度比上面的要快四倍。 /* longfill ─── 用“long”的值填充内存块。在填完了最后一个长字之后, * 返回一个指向所填第一个长字的指针。 */ long* longfill(long* pl, long l, size_t size); /* 原型 */ memset(void* pv, byte b, size_t size) { byte* pb = (byte*)pv; if(size >= sizeThreshold) { unsigned long l; 17 l = (b<<8) | b; /* 用4个字节拼成一个长字 */ l = (l<<16) | l; pb = (byte*)longfill((long*)pb, l, size/4); size = size%4; } while(size-- > 0) *pb++ = b; return(pv); } 在上面的程序中可能除了对sizeThreshold所进行的测试之外,其它的内容都很直观。 如果读者还不太明白为什么要进这一测试,那么可以想一想无论是将4个字节拼成一个long 还是调用 longfill 函数都要花一定的时间。对 sizeThreshold 进行测试是为了使 memset 只有在用 long 进行填充速度更快时才进行相应的填充。否则就仍用 byte 进行填充。 这个memset 新版本的唯一问题是它对编译程序和操作系统都做了一些假定。例如,这 段代码很明显地假定 long 占用四个内存字节,该字节的宽度是八位。这些假定对许多计算 机都正确,目前几乎所有的微机都毫无例外。不过这并不意味着因此我们就应该对这一问 题置之不理,因为现在正确并不等于今后几年也正确。 某些程序员“改进”这一程序的方法,是把它写成下面这种可移植性更好的形式: memset(void* pv, byte b, size_t size) { byte* pb = (byte*)pv; if(size >= sizeThreshold) { unsigned long l; size_t sizeSize; l = 0; for(sizeSize=sizeof(long); sizeSize-->0; NULL) l = (l< 0) *pb++ = b; return(pv); } 由于在程序中大量使用了运算符 sizeof ,这个程序看起来移植性更好,但“看起来” 不等于“就是”。如果要把它移到新的环境中,还是要对其进行考察才行。例如,如果在 18 Macintosh plush 或者其它基于 68000 的计算机上运行这个程序,假如pv开始指向的是奇 数地址,该程序就会瘫痪。这是因为在68000 上,byte*和long*是不可以相互转换的类型, 所以如果在奇数地址上存储long 就会引起硬件错误。 那么到底应该怎么做呢? 其实在这种情况下,根本就不应该企图将 memset 写成一个可移植的函数。要接受其不 可移植这一事实,不要对其进行改动。对于68000,要避免上述的奇数地址问题,可以先用 byte 进行填充,填到偶数地址之后,再换用long 继续填充。虽然将 long 对齐在偶数地址 上已经可以工作了,但在各种基于 68020,68030 和68040 的新型 Macintosh 上,如果使其 对齐在 4字节边界上,性能会更好。至于对程序中所做的其他假定,可以利用断言和条件 编译进行相应的验证: memset(void* pv, byte b, size_t size) { byte* pb = (byte*)pv; #ifdef MC680x0 if(size >= sizeThreshold) { unsigned long l; ASSERT(sizeof(long)==4 && CHAR_BIT==8); ASSERT(sizeThreshold>=3); /* 用字节进行填充,直到对齐在长字边界上 */ while( ((unsigned long)pb & 3) != 0 ) { *pb++ = b; size--; } /* 现在拼装长字并用长字填充其他内存单元 */ l = (b<<8) | b; /* 用4个字节拼成一个长字 */ l = (l<<16) | l; pb = (byte*)longfill((long*)pb, l, size/4); size = size%4; } #endif /* MC680x0 */ while(size-- > 0) *pb++ = b; return(pv); } 正如读者所见,该程序中与具体机器相关的部分已被 MC680x0 预处理程序的定义设施 19 括起。这样不仅可以避免这部分不可移植的代码被不小心地用到其它不同的目标机上,而 且通过在程序中搜寻 MC680x0 这个字符串,可以找出所有与目标机有关的代码。 为了验证 long 占用4个内存字节、byte 的宽度是 8,还在程序中加了一个相当直观的 断言。虽然暂时不太可能发生改变的,但谁知道以后会不会发生变化呢? 最后,为了在调用 longfill 之前使 pb指向4字节边界上,程序中使用了一个循环。 由于不管 size 的值如何,这个循环最终都会执行到size 等于3的倍数,所以在循环之前 还加了个检查 sizeThreshold是否至少是 3的断言(sizeThreshold应该取较大的值。但它 至少应该是 3,否则程序就不会工作)。 经过了这些改动,很明显这个程序已不再可移植。原先所做的假定或者已经被消除, 或通过断言进行了验证。这些措施使得该程序极少可能被不正确地使用。 光承认编 译程序 还不够 光承认编 译程序 还不够 光承认编 译程序 还不够 光承认编 译程序 还不够 最近, 最近, 最近, 最近, Microsoft 的一些小组渐渐发现他 们不得不对其代码进行重新的考察和整理 , 的一些小组渐渐发现他 们不得不对其代码进行重新的考察和整理 , 的一些小组渐渐发现他 们不得不对其代码进行重新的考察和整理 , 的一些小组渐渐发现他 们不得不对其代码进行重新的考察和整理 , 因因因因 为相当多的代码中充 满了 为相当多的代码中充 满了 为相当多的代码中充 满了 为相当多的代码中充 满了 ““““+2””””而不是 而不是 而不是 而不是 ““““+sizeof(int)””””、与、与、与、与0xFFFF 而不 是 而不 是 而不 是 而不 是 UINT_MAX 进进进进行行行行 无符号数的比较、 在数据结构中使用的 是 无符号数的比较、 在数据结构中使用的 是 无符号数的比较、 在数据结构中使用的 是 无符号数的比较、 在数据结构中使用的 是 int而不是真正想用 的 而不是真正想用 的 而不是真正想用 的 而不是真正想用 的 16位数据类型这一类问 位数据类型这一类问 位数据类型这一类问 位数据类型这一类问 题。题。题。题。 你也许 会认为这是 因为这些程序 员太懒散, 但他们却不 会同意这一看 法。事实上 ,他 你也许 会认为这是 因为这些程序 员太懒散, 但他们却不 会同意这一看 法。事实上 ,他 你也许 会认为这是 因为这些程序 员太懒散, 但他们却不 会同意这一看 法。事实上 ,他 你也许 会认为这是 因为这些程序 员太懒散, 但他们却不 会同意这一看 法。事实上 ,他 们认为有很好的理由 说明他们可以安全地使用 们认为有很好的理由 说明他们可以安全地使用 们认为有很好的理由 说明他们可以安全地使用 们认为有很好的理由 说明他们可以安全地使用 ““““+2””””这种形式,即相应 的 这种形式,即相应 的 这种形式,即相应 的 这种形式,即相应 的 C编译程序是 由 编译程序是 由 编译程序是 由 编译程序是 由 Microsoft 自己编写的。这一点 给程序员造成了安全的假象,正如几年前 一位程序员所说: 自己编写的。这一点 给程序员造成了安全的假象,正如几年前 一位程序员所说: 自己编写的。这一点 给程序员造成了安全的假象,正如几年前 一位程序员所说: 自己编写的。这一点 给程序员造成了安全的假象,正如几年前 一位程序员所说: ““““编译程序组从来没有 做使我们所有程序垮掉的改变 编译程序组从来没有 做使我们所有程序垮掉的改变 编译程序组从来没有 做使我们所有程序垮掉的改变 编译程序组从来没有 做使我们所有程序垮掉的改变 ””””。。。。 但这位程序员错了。 但这位程序员错了。 但这位程序员错了。 但这位程序员错了。 为了 在 为了 在 为了 在 为了 在 Intel 80386 和更 新的处 理器上 生成更 快更小 的程序, 编译程 序组改 变 了 和更 新的处 理器上 生成更 快更小 的程序, 编译程 序组改 变 了 和更 新的处 理器上 生成更 快更小 的程序, 编译程 序组改 变 了 和更 新的处 理器上 生成更 快更小 的程序, 编译程 序组改 变 了 int 的大小(以及其他一些方面) 。虽然编译程序组并不想使公司内部的代码垮掉,但是保持 的大小(以及其他一些方面) 。虽然编译程序组并不想使公司内部的代码垮掉,但是保持 的大小(以及其他一些方面) 。虽然编译程序组并不想使公司内部的代码垮掉,但是保持 的大小(以及其他一些方面) 。虽然编译程序组并不想使公司内部的代码垮掉,但是保持 在在在在 市场上 的竞争地位 显然更重要。 毕竟,这是 那些自己做 了错误假定 的 市场上 的竞争地位 显然更重要。 毕竟,这是 那些自己做 了错误假定 的 市场上 的竞争地位 显然更重要。 毕竟,这是 那些自己做 了错误假定 的 市场上 的竞争地位 显然更重要。 毕竟,这是 那些自己做 了错误假定 的 Microsoft 程序员 的 程序员 的 程序员 的 程序员 的 过错。 过错。 过错。 过错。 不可能的事用也能发生? 函数的形参并不一定总是给出函数的所有输入数据,有时它给出的只是一个指向函数 输入数据的指针。例如,请看下面这个简单的压缩还原程序: byte* pbExpand(byte* pbFrom, byte* pbTo, size_t sizeFrom) { byte b, *pbEnd; size_t size; pbEnd = pbFrom + sizeFrom; /* 正好指向缓冲区尾的下一个位置 */ while(pbFrom < pbEnd) 消除所做的隐式假定,或者利用断言检查其正确性 20 { b = *pbFrom++; if(b == bRepeatCode) { /* 在pbTo 开始的位置存储“size”个“b”*/ b = *pbFrom++; size = (size_t)*pbFrom++; while(size-- > 0) *pbTo++ = b; } else *pbTo++ = b; } return(pbTo); } 这个程序将一个数据缓冲区中的内容拷贝到另一个数据缓冲区中。但在拷贝过程中, 它要找出所有的压缩字符序列。如果在输入数据中找到了特殊的字节bRepeatCode,它就认 为其后的下两个字节分别是要重复的还原字符以及该字符的重复次数。尽管这一程序显得 有些过于简单,但我们还是可以把它们用在某些类似于程序编辑的场合下。那里,正文中 常常包括有许多表示缩进的连续水平制表符和空格符。 为了使 pbExpand 更健壮,可以在该程序的入口点加上一个断言,来对 pbFrom、sizeFrom 和pbTo 的有效性进行检查。但除此之外,还有许多其它可以做的事情。例如,还可以对缓 冲区中的数据进行确认。 由于进行一次译码总需要三个字节,所以相应的压缩程序从不对两个连续的字符进行 压缩。另外,虽然也可以对三个连续的字符进行压缩,但这样做并不能得到什么便宜。因 此,压缩程序只对三个以上的连续字符进行压缩。 存在一个例外的情况。如果原始数据中含有bRepeatCode,就必须对其进行特殊的处理 。 否则当使用加 pbExpand 时,就会把它误认为是一个压缩字符序列的开始。当压缩程序在原 始数据中发现了 bRepeatCode时,就把它再重复一次,以便和真正的压缩字符序列区别。 总之,对于每个字符压缩序列,其重复次数至少是 4,或者是1。在后一种情况下,相 应的重复字符一定是 bRepeatCode本身。我们可以使用断言对这一点进行验证: byte* pbExpand(byte* pbFrom, byte* pbTo, size_t sizeFrom) { byte b, *pbEnd; size_t size; ASSERT(pbFrom != NULL && pbTo != NULL && sizeFrom != 0); pbEnd = pbFrom + sizeFrom; /* 正好指向缓冲区尾的下一个位置 */ 21 while(pbFrom < pbEnd) { b = *pbFrom++; if(b == bRepeatCode) { /* 在pbTo 开始的位置存储“size”个“b”*/ b = *pbFrom++; size = (size_t)*pbFrom++; ASSERT( size>=4 || (size==1 && b==bRepeatCode) ); while(size-- > 0) *pbTo++ = b; } else *pbTo++ = b; } return(pbTo); } 如果这一断言失败说明 pbFrom 指向的内容不对或者字符压缩程序中有错误。无论哪种 情况都是错误,而且是不用断言就很难发现的错误。 安静地处理 假如你受雇为核反应堆编写软件,就必须对堆芯过热这一情况进行处理。 某些程序员解决这个问题的方法可以是自动地向堆芯灌水、插入冷却棒或者是能使反 应堆冷却下来的一些其他什么方法。而且,只要程序已经控制了势态就不必向有关人员发 出警报。 另一些程序员可能会选择另一种方法,即只要堆芯过热就向反应堆工作人员发出警报。 虽然相应的处理仍由计算机自动进行,不同的是操作员总是知道这件事。 如果由你来实现这一程序,你会选择哪一种方法? 我想关于这一点,大家基本上不会有太多的异议,即总是应该向操作人员发出警报, 这与计算机能够恢复反应堆的正常操作是两回事。堆芯不会无缘无故地出现过热现象,一 定是发生了某种不同寻常的事情,才会引起这一故障。因此在计算机进行相应处理的同时, 最好使操作人员搞清楚发生了什么事情以避免事故的再次发生。 令人惊奇的是,程序员,尤其是有经验的程序员编的程序通常都是这样:当某些意料 不到的事情发生时,程序只进行无声无息的安静处理,甚至有些程序员会有意识地使程序 利用断言来检查不可能发生的情况 22 这样做。也许你自己用的是另一种方法。 当然,我现在谈的是所谓的防错性程序设计。 在上一节中,我们介绍 pbExpand 程序。该函数使用的就是防错程序设计。但从其循环 条件可以看出,下面的修改版本并没有使用防错性程序设计。 byte* pbExpand(byte* pbFrom, byte* pbTo, size_t sizeFrom) { byte b, *pbEnd; size_t size; pbEnd = pbFrom + sizeFrom; /* 正好指向缓冲区尾的下一个位置 */ while(pbFrom != pbEnd) { b = *pbFrom++; if(b == bRepeatCode) { /* 在pbTo 开始的位置存储“size”个“b”*/ b = *pbFrom++; size = (size_t)*pbFrom++; do *pbTo++ = b; while(size-- != 0) } else *pbTo++ = b; } return(pbTo); } 虽然这一程序更精确地反应了相应的算法,但有经验的程序员很少会这样编码。否则 好机会就来了,我们可以把他们塞进一辆既没有安全带又没有车门的双人Cessna 车中。上 面的程序使人感到太危险了。 有经验的程序员会这样想:“我知道在外循环中 pbFrom 绝不应该大于 pbEnd,但如果确 实出现了这种情况怎样办呢?还是在这种不可能的情况出现时,让外循环退出为好。” 同样,对于内循环,即使size 总应该大于或等于 1,但使用 while 循环代替 do循 环 , 可以保证进入内循环时一旦size 为0,不至于使整个程序瘫痪。 使自己免受这些“不可能”的打扰似乎很合理,甚至很聪明。但如果出于某种原因pbFrom 被加过了 pbEnd,那么会发生什么事情呢?在上面这个充满危险的版本或者前面看到的防错 性版本中,找出这一错误的可能性又有多大呢?当发生这一错误时,上面的危险版本也许 会引起整个系统的瘫痪,因为pbExpand 会企图对内存中的所有内容进行压缩还原。在这种 23 情况下,用户肯定会发现这一错误。相反,对于前面的防错性版本来说,由于在pbExpand 还没来得及造成过多的损害(如果有的话)之前,它就会退出。所以虽然用户仍然可能发 现这一错误,但我看这种可能性不大。 实际的情况就是这样,防错性程序设计虽然常常被誉为有较好的编码风格,但它却隐 瞒了错误。要记住,我们正在谈论的错误决不应该再发生,而对这些错误所进行的安全处 理又使编写无错代码变得更加困难。当程序中有了一个类似于 pbFrom 这样的跳跃性指针。 并且其值在每次循环都增加不同的量时,编写无错代码尤其困难。 这是否意味着我们应该放弃防错性程序设计呢? 答案是否定的。尽管防错性程序设计会隐瞒错误,但它确实有价值。一个程序所能导 致的最坏结果是执行崩溃,并使用户可能花几个消失建立的数据全部丢掉。在非理想的世 界中,程序确实会瘫痪,因此为了防止用户数据丢失而参去的任何措施都是值得的。防错 性性程序设计要实现的就是这个目标。如果没有它,程序就会如同一个用纸牌搭起的房子, 哪怕硬件或操作系统中发生了最轻微的变化,都会塌落。同时,我们还希望在进行防错性 程序设计时,错误不要被隐瞒。 假定某个函数以无效的参数调用了pbExpand,比如sizeFrom 比较小并且数据缓冲区最 后一个字节的内容碰巧是 bRepeatCode。由于这种情况类似于一个压缩字符序列,所以加 pbExpand 将从数据缓冲区外多读 2个字节,从而使pbFrom 超过pbEnd。结果呢?pbExpand 的危险版可能会瘫痪,但其防错性版本或许可以避免用户数据的丢失,尽管它也可能冲掉 255 个字节的未知数据。既然两者都想得到,既需要调试版本对错误进行报警,又需要交付 版本对错误进行安全的恢复,那么可以一方面一如既往地利用防错性程序设计进行 编码, 另一方面在事情变糟地情况下利用断言进行报警: byte* pbExpand(byte* pbFrom, byte* pbTo, size_t sizeFrom) { byte b, *pbEnd; size_t size; pbEnd = pbFrom + sizeFrom; /* 正好指向缓冲区尾的下一个位置 */ while(pbFrom != pbEnd) { b = *pbFrom++; …… } ASSERT(pbFrom == pbEnd); return(pbTo); } 上面的断言只是用来验证该函数的正常终止。在该函数的交付版本中,相应的防错措 施可以保证当出了毛病时,用户可以不受损失;而在该函数的调试版本中,错误仍然可以 被报告出来。 24 但是在实际的编程中也不必过分拘泥于此。例如,如果每次循环pbFrom 的内容总是增 1,那么要使 pbFrom 超过pbEnd 从而引起问题,恐怕需要一束宇宙射线的偶然轰击才行。 在这种情况下,相应的断言没什么用处,因此可以从程序中删去。在程序中究竟是否需要 使用断言,要根据常识视具体的问题而定。最后应该说明的是,循环只是程序员通常用来 进行防错性程序设计的一个方面。实际上,无论把这种程序设计风格用在哪里,在编码之 前都要同自己:“在进行防错性程序设计时,程序中隐瞒错误了吗?”如果答案是肯定的, 就要在程序中加上相应的断言,以对这些错误进行报警。 两个算法比一个算法好 为了捕捉程序中的错误,只对坏的输入和有漏洞的假定进行检查是不够的。正如调用 函数可能给被调用函数传递无用信息一样,被调用函数也可能给调用函数返回无用信息。 二者都是我们所不期望的。 由于无论 memcpy 还是memset 只有一个返回参数,所以使它们返回无用信息的可能性 极小。但对于更复杂的程序,也许就不太容易做出这一结论了。 比如,最近我为Macintosh 程序员编写了一个开发工具的部分程序:68000 反汇编程序 。 在该程序的编写中,我对它能够运行得多快并不在意,关键是要工作正确。因此,我选择 了简单的表格驱动算法来实现这一程序,因为它比较容易测试。在程序中,我还使用了断 言,以便在测试期间捕捉到可能遗漏的错误。 如果读者以前曾经看过汇编语言参考书,那么很幸运,因为这种书通常都会精心描述出每 条指令的详细情况,如每条指令对应的二进制形式。例如,如果在 68000 汇编语言参考手 册中查阅 ADD指令,就可以知道它有如下的二进制形式: ADD: 我们可以忽略指令中的Register 和Mode 域,而只对其中明显标为 0或1的二进制位 感兴趣。在 ADD的情况下,我们只对其高 4位感兴趣。如果去掉该指令中没有明显标为 0 或1的其它进制位,然后检查其高4位是否为 1101 或16进制数 0xd,就可以知道该指令是 否是ADD指令: if( (inst & 0xf000) == 0xd000 ) 它是条ADD指令 …… 用来进行带符号相除的 DIVS 指令模式中有 7个被明显标为 0或1的二进制位: 在进行防错性程序设计时,不要隐瞒错误 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 1 1 0 1 Register Op-Mode Effective Address Mode | Register 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 1 0 0 0 Register 1 1 1 Effective Address Mode | Register 25 DIVS: 同样,如果去掉该指令中没有被明显标为 0或1的Register 和Mode 域,就可以知道 该指令是否是 DIVS 指令: if( (inst & 0xf1c0) == 0x81c0 ) 它是条DIVS 指令 …… 可以用这种先屏蔽后测试的办法来检查每条汇编指令,一旦确认为ADD或DIVS 指令就 可调用译码函数恢复刚才忽略的Register 和Mode 区域的内容。 这就是我设计的反汇编程序的工作方式。 自然,该程序并没有使用142 个条件不同的 if语句来实现对所有可能的142 条指令进 行检查而是使用一个含有屏蔽码、指令特征和译码函数的表格对每条指令进行检查。查表 程序循环检查指令,如果匹配上某条指令,就调用相应的译码程序译出该指令的Register 和Mode 域。 下面给出这个表格的部分内容以及使用该表的部分代码: /* idInst 是个屏蔽码和指令特征组成的表格, * 其内容表示了不同类型指令的二进制位模式。 */ static identity idInst[] = { { 0xFF00, 0x0600, pcDecodeADDI },/* 屏蔽码、特征及函数 */ { 0xF130, 0xD100, pcDecodeADDX }, { 0xF000, 0xD000, pcDecodeADD }, { 0xF000, 0x6000, pcDecodeBcc },/* 短转移 */ { 0xF1C0, 0x4180, pcDecodeCHK }, { 0xF138, 0xB108, pcDecodeCMPM }, { 0xFF00, 0x0C00, pcDecodeCMPI }, { 0xF1C0, 0x81C0, pcDecodeDIVS }, { 0xF100, 0xB100, pcDecodeEOR }, /*……*/ { 0xFF00, 0x4A00, pcDecodeTST }, { 0xFFF8, 0x4E58, pcDecodeUNLK }, { 0x0000, 0x0000, pcDecodeError } }; /* pcDisasm * 反汇编一条指令,并将其填入操作码结构opc 中。 26 * pcDisasm 返回一个修改过的程序计数器 * * 典型用法:pcNext = pcDisasm(pc, &opc); */ instruction* pcDisasm(instruction* pc, opcode* popcRet) { identity* pid; instruction inst = *pc; for(pid=&idInst[0]; pid->mask!=0; pid++) { if( (inst & pid->mask) == pid->pat ) break; } return( pid->pcDecode(inst, pc+1, popcRet) ); } 我们看到,函数pcDisasm 并不很大。它使用的算法非常简单:先读入当前的指令,在 表中查出其对应的内容;然后调用相应的译码程序在popcRet 指向的结构 opcode 中填入相 应的内容;最后返回一个修改后的程序计数器。由于并不是所有的 68000 指令长度都相同, 所以必须对程序计数器进行相应的修改。如果有必要的话,在上面译码程序的参数中还可 以包括指令的其它域,但仍然要把新的程序计数器值返回给 pcDisasm。 现在,我们再回到原先的问题。 通过类似于 pcDisasm 这样的函数,程序员很难知道其返回的数据是否有效。或许pcDisasm 自己能够正确地识别指令,但其用到的译码程序却可能产生无用信息,而且这一问题很难 发现。捕捉这种错误的一个方法是在每条指令对应的译码程序中都加上断言。这样做虽然 也可以达到目的,但更好的方法是在 pcDisasm 中加上相应的断言,因为它是调用所有译码 程序的关键之处。 问题是怎样才能做到这一点,怎样才能在以 pcDisasm 中检查出相应译码程序对结构 opcode 的填写是否正确呢?回答是必须编写相应的程序对该结构中填写的内容进行确认。 怎么确认呢?这基本上是说我们必须写一个子程序,用68000 的指令同结构 opcode 的填写 内容进行比较。换句话说,必须再写一个反汇编程序。 这听起来好象有点令人发疯,真的需要这样吗? 还是让我们看看 Microsoft Excel 重新计算工具的做法吧。由于速度是电子表格软件 成功的关键,所以为了保证绝不对其它无关单元中的公式重新计算,Excel 使用了一个相当 复杂的算法。这样做的唯一问题是因为该算法过于复杂,所以对其进行修改难免会引进新 的错误。Excel 的程序员当然不希望这种事情发生,所以他们又编写了一个只用在Excel 调 试版本的重新计算工具。当原来的重新计算工具完成了重新计算工作之后,再用这个重新 计算工具对含有公式的所有单元进行一遍虽然缓慢但很彻底的重新计算。如果两次计算的 27 结果不同,就会触发某个断言。 Microsoft Word 也遇到过类似的问题。由于字处理程序在进行页面布局时速度也很关 键,所以Word 的程序员用汇编语言编写了这部分程序,以便能够对其进行人工优化。这样 一来,虽然速度上去了,但在防止程序有错方面却变得很糟。而且同不常发生变化的Excel 重计算工具不同,Word 的页面布局程序需要随着 Word 新功能的增加而定期改变。因此为了 能够自动地查出页面布局程序中的错误,Word 程序员为每个可进行人工优化的汇编语言程 序都相应地写了一个 C程序,如果两个版本产生的结果不一致,就触发某个断言。 同样,我们可以把上述方法用到我们的反汇编程序上来,即使用另一个只用作凋试的 反汇编程序来对第一个反汇编程序进行确认。 我不想让第2个反汇编程序 pcDisasmAlt的实现细节打扰读者。简单地说,它是逻辑 驱动的,而不是表格驱动的。它利用嵌套的switch 语句不断地对指令中的有效位进行分离 , 直到分离出所需要的指令。下面的程序表明了利用 pcDisasmAlt来确认第一个反汇编程序 的方法: instruction* pcDisasm(instruction* pc, opcode* popcRet) { identity* pid; instruction inst = *pc; instruction* pcRet; for(pid=&idInst[0]; pid->mask!=0; pid++) { if( (inst & pid->mask) == pid->pat ) break; } pcRet = pid->pcDecode(inst, pc+1, popcRet); #ifdef DEBUG { opcode opc; /* 检查两个输出值的有效性 */ ASSERT( pcRet == pcDisasmAlt(pc, &opc) ); ASSERT( memcmp(popcRet, &opc, sizeof(opcode))==0 ); } #endif return(pcRet); } 在正常的情况下,在现有代码中增加调试检查代码不应该对原有代码产生其它的影响, 但在本程序中无法做到这一点。因为我们必须建立一个局部对象 pcRet,以便对 pid- >pcDecode 返回的指针值进行确认。幸好这并没有违反“除了原有代码之外,还应该执行所 28 加入的调试代码,而且加入了调试代码之后,仍然要执行原有的代码”这条基本的准则, 因此这样做还可以接受。这条基本准则虽然说得再清楚不过了,但一旦开始使用断言和调 试代码,有时仍会企图用所加入的调试代码取代原有代码的执行。在第 3章中我们将看到 一个这样的例子,但现在还是让我们说:“要抑制这一冲动”。虽然为了进行相应调试检查 我们不得不对 pcDisasm 进行了相应的修改,但所加入的调试代码并没有代替原有代码的执 行。 上面的做法并不意味着程序中的每个函数都得有两个版本,因为那无疑与浪费时间使 每个函数部尽可能效率很高的做法一样荒谬。正确的做法是只对程序中的关键部分这样做。 我确信大多数的程序都有必须做好的关键部分,例如电子表格软件中的重新计算程序、字 处理程序中的页面布局程序、项目管理程序中的任务调度程序以及数据库中的搜索/抽取 程序。另外,每个程序中用来保证用户数据不被丢失的部分也是其关键部分。 当编写代码时,要抓住一切机会对程序的结果进行验证(调用所有其它函数的瓶颈函 数,是特别适于进行这种检查的好地方)。要尽可能地使用不同的算法,而目要使其不仅仅 是同一算法的又一实现。通过使用不同的算法不仅可以发现算法实现中的错误,而且还增 加了发现算法本身错误的可能性。 嘿, 这是怎么 回事? 嘿, 这是怎么 回事? 嘿, 这是怎么 回事? 嘿, 这是怎么 回事? 在本章的早些时候曾 经说过,在定义 宏 在本章的早些时候曾 经说过,在定义 宏 在本章的早些时候曾 经说过,在定义 宏 在本章的早些时候曾 经说过,在定义 宏 ASSERT 时必须谨慎从事。其中特别提到它不 时必须谨慎从事。其中特别提到它不 时必须谨慎从事。其中特别提到它不 时必须谨慎从事。其中特别提到它不 能能能能 移动内 存的内容, 不能调用其它 的函数或者 引起了其它 不期望的副作 用。既然如 此,为什 移动内 存的内容, 不能调用其它 的函数或者 引起了其它 不期望的副作 用。既然如 此,为什 移动内 存的内容, 不能调用其它 的函数或者 引起了其它 不期望的副作 用。既然如 此,为什 移动内 存的内容, 不能调用其它 的函数或者 引起了其它 不期望的副作 用。既然如 此,为什 么下面的函 数 么下面的函 数 么下面的函 数 么下面的函 数 pcDisasm 还使用了不符合上述要 求的断言呢? 还使用了不符合上述要 求的断言呢? 还使用了不符合上述要 求的断言呢? 还使用了不符合上述要 求的断言呢? /* 检查两个输出值的有 效性 检查两个输出值的有 效性 检查两个输出值的有 效性 检查两个输出值的有 效性 */ ASSERT( pcRet == pcDisasmAlt(pc, &opc) ); ASSERT( memcmp(popcRet, &opc, sizeof(opcode))==0 ); 之所以 要禁 止 之所以 要禁 止 之所以 要禁 止 之所以 要禁 止 ASSERT 调用其 它的函数, 是因为那样可 能会 对 调用其 它的函数, 是因为那样可 能会 对 调用其 它的函数, 是因为那样可 能会 对 调用其 它的函数, 是因为那样可 能会 对 ASSERT 周围的 代码产生 周围的 代码产生 周围的 代码产生 周围的 代码产生 某种不可预料的影响 。 但在上面的代码中, 调用其它函数的不 是 某种不可预料的影响 。 但在上面的代码中, 调用其它函数的不 是 某种不可预料的影响 。 但在上面的代码中, 调用其它函数的不 是 某种不可预料的影响 。 但在上面的代码中, 调用其它函数的不 是 ASSERT,而是作者,即,而是作者,即,而是作者,即,而是作者,即ASSERT 的使用者。因为我知道 在 的使用者。因为我知道 在 的使用者。因为我知道 在 的使用者。因为我知道 在 pcDisasm 中调用其它的函数很 安全,不会引起问题,所以不用 中调用其它的函数很 安全,不会引起问题,所以不用 中调用其它的函数很 安全,不会引起问题,所以不用 中调用其它的函数很 安全,不会引起问题,所以不用 顾顾顾顾 虑在该断言中使用函 数调用。 虑在该断言中使用函 数调用。 虑在该断言中使用函 数调用。 虑在该断言中使用函 数调用。 一开始就要阻止错误的发生 到目前为止,我们一直忽略了指令中的 Register 和Mode 域。那么如果这些位使对应 指令与表中其它指令所对应的内容碰巧匹配上了,会发生什么事情呢?例如指令 EOR的二 进制形式如下: 要利用不同的算法对程序的结果进行确认 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 1 0 1 1 Register 1 Mode Effective Address Mode | Register 29 EOR: 而指令 CMPM 的二进制形式与其非常相似: CMPM: 注意,如果指令EOR的“Effective Address Mode”域为001,那么看起来它就象条CMPM 指令。因此如果EOR指令在 idInst 表中的位置比 CMPM 指令靠前,那么所有经过的CMPM 指 令都会被错误地认为是 EOR指令。 值得庆幸的是,由于pcDisasm 和pcDisasmAlt使用的算法不同,所以在第一次对CMPM 指令进行反汇编时,就会引起断言失败。原因是 pcDisasm 在opcode 结构中填写的是 EOR 指令而 pcDisasmAlt填写的则是我们所期待的正确指令CMPM。因此在调试代码对两个结构 进行内容比较时就会产生断言失败。这就是在调试函数中使用不同算法的威力。 但令人不快的是,只有在试图对 CMPM 指令进行反汇编的时候这一错误才会被发现。当 然,如果测试人员使用的测试集内容足够详尽是可以发现这一错误了。然而,读者还记得 我在第 1章中曾经说过的话吗?我们追求的是尽可能早地自动查出程序中的错误。而且在 查错时不应该依赖于其他的人。 因此虽然我们也可以把这一任务推给测试组,但不要那么做。尽管相当多的程序员认 为测试者就是要为自己测试程序,但要知道,测试者的工作并不只是对你的程序进行测试, 查出自己程序中的错误毕竟是你自己的工作。如果你不同意这一观点,那么请举出一个只 因为有人进行错误检查就可以草率从事的其它工作来。既然没有,那为什么程序设计应该 例外呢?如果你想要始终如一地编写出没有错误的代码,就必须采取措施负起责。所以, 还是让我们从现在就开始做起吧。 在进行程序设计时,只要注意到程序中存在某些危险的因素,就可以问自己:“怎样才 能自动地及早查出这个错误呢?”通过习惯性地就这个问题不断向自己发问,你会发现使 程序更加健壮的各种方法。 例如要查出上面的错误,可以在该程序的初始化完成之后,立即在main 函数中对该表 进行扫描,通过查看其每项的内容来验证先前没有哪个入口不正确地截取了其它的指令。 检查这种错误的表格检查程序虽然不长,但并不清晰: void CheckIdInst(void) { identity *pid, *pidEarlier; instruction inst; for(pid=&idInst[0]; pid->mask!=0; pid++) { 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 1 0 1 1 Register 1 Size 0 0 1 Register 30 for(pidEarlier=&idInst[0]; pidEarlierpat | (pidEarlier->pat & pid->mask); if( (inst & pidEarlier->mask) == pidEarlier->pat ) ASSERT( bitcount(pid->mask) < bitcount(pidEarlier->mask) ); } } } 该程序通过将当前指令与表中存放在该指令前面的每条指令进行比较来检查这种错 误。我们知道,每条指令都有其“不用考虑”的位,即那些在形成其指令特征时被屏蔽掉 的位。但是如果这些“不用考虑”的位碰巧使对应的指令与表中前面的指令匹配上了,会 发生什么情况呢?在这种情况下,就会产生表中两个入口之间的冲突。那么对于可能产生 冲突的入口,它们在表中的位置究竟应该谁先谁后呢? 答案很简单。如果表中两个入口都与同一条指令匹配那么必须把含 1多的入口放在表 中的前面。如果这还不够直观,请考虑一下指令 EOR和CMPM 的二进制形式。假如这两条指 令所对应的入口对于同一条指令匹配,那么应该选择哪个入口作为“正确的”匹配,为什 么?因为凡是指令二进制形式中被明确标为 0或1的位,其对应的屏蔽位都是1,所以通过 对相应屏蔽码中 1的个数进行比较就可以知道哪个入口更正确。 如果两条指令相互冲突,区别起来就更复杂。具体处理方法是先取出一个人口中的指 令特征,然后强制使其那些“不用考虑”的位与表中前面每条指令的特征精确匹配。这一 操作所产生的指令特征,就是上面程序中赋给变量inst 的值。根据设计我们知道,所形成 的指令 inst 必定与当前的指令匹配,因为它只改变了当前指令中那些与指令特征没关系的 位。但除此之外如果它还同表中前面的其它指令匹配就会产生两个入口之间的冲突,因此 必须进行相应屏蔽码的比较。 通过在程序的初始化过程中调用CheckIdInst,一开始执行反汇编时就可以查出以上的 冲突错误,而不必等到进行反汇编时才能发现。程序员应该在程序中寻找类似的初始检查, 这样可以尽快地发现错误。否则,错误就会隐藏一段时间。 一个告诫 一个告诫 一个告诫 一个告诫 一旦开 始使用断言 ,也许就会发 现程序中的 错误会显著 增加。人们对 此如果没有 心理 一旦开 始使用断言 ,也许就会发 现程序中的 错误会显著 增加。人们对 此如果没有 心理 一旦开 始使用断言 ,也许就会发 现程序中的 错误会显著 增加。人们对 此如果没有 心理 一旦开 始使用断言 ,也许就会发 现程序中的 错误会显著 增加。人们对 此如果没有 心理 准备就会感到惊惶失 措。 准备就会感到惊惶失 措。 准备就会感到惊惶失 措。 准备就会感到惊惶失 措。 我曾经 重写了一个 由几 个 我曾经 重写了一个 由几 个 我曾经 重写了一个 由几 个 我曾经 重写了一个 由几 个 Microsoft 小组共 享的代码, 它有许多的错 误,因为在 编写 小组共 享的代码, 它有许多的错 误,因为在 编写 小组共 享的代码, 它有许多的错 误,因为在 编写 小组共 享的代码, 它有许多的错 误,因为在 编写 原来的 代码库时没 有使用断言, 但我在新版 库中加上了 断言。使我吃 惊的是,当 我把新版 原来的 代码库时没 有使用断言, 但我在新版 库中加上了 断言。使我吃 惊的是,当 我把新版 原来的 代码库时没 有使用断言, 但我在新版 库中加上了 断言。使我吃 惊的是,当 我把新版 原来的 代码库时没 有使用断言, 但我在新版 库中加上了 断言。使我吃 惊的是,当 我把新版 代码交给这些小组使 用之后,一个程序员竟很生气地要求我把 原来的代码库还给他。 代码交给这些小组使 用之后,一个程序员竟很生气地要求我把 原来的代码库还给他。 代码交给这些小组使 用之后,一个程序员竟很生气地要求我把 原来的代码库还给他。 代码交给这些小组使 用之后,一个程序员竟很生气地要求我把 原来的代码库还给他。 不要等待错误发生,要使用初始检查程序 31 我问他为什么? 我问他为什么? 我问他为什么? 我问他为什么? 他说:他说:他说:他说:““““安装这个库后出现了 许多错误。 安装这个库后出现了 许多错误。 安装这个库后出现了 许多错误。 安装这个库后出现了 许多错误。 ”””” ““““你是说新库引起了错 误? 你是说新库引起了错 误? 你是说新库引起了错 误? 你是说新库引起了错 误? ””””,我感到震惊。 ,我感到震惊。 ,我感到震惊。 ,我感到震惊。 ““““好象是这样,我们遇 到了许多过去没有的断言。 好象是这样,我们遇 到了许多过去没有的断言。 好象是这样,我们遇 到了许多过去没有的断言。 好象是这样,我们遇 到了许多过去没有的断言。 ”””” ““““你们检查过这些断言 吗? 你们检查过这些断言 吗? 你们检查过这些断言 吗? 你们检查过这些断言 吗? ””””,我问道。 ,我问道。 ,我问道。 ,我问道。 ““““检查过 ,它们在我 们的程序中是 错误的。这 么多的断言 不可能全没错 误,而且我 们 检查过 ,它们在我 们的程序中是 错误的。这 么多的断言 不可能全没错 误,而且我 们 检查过 ,它们在我 们的程序中是 错误的。这 么多的断言 不可能全没错 误,而且我 们 检查过 ,它们在我 们的程序中是 错误的。这 么多的断言 不可能全没错 误,而且我 们 也没有时间去跟踪这 些根本不属于我们的问题。我想要回原来 的库。 也没有时间去跟踪这 些根本不属于我们的问题。我想要回原来 的库。 也没有时间去跟踪这 些根本不属于我们的问题。我想要回原来 的库。 也没有时间去跟踪这 些根本不属于我们的问题。我想要回原来 的库。 ”””” 我当然 不认为他们 没有问题。所 以我请求他 继续使用新 库,直到发现 某个断言有 错为 我当然 不认为他们 没有问题。所 以我请求他 继续使用新 库,直到发现 某个断言有 错为 我当然 不认为他们 没有问题。所 以我请求他 继续使用新 库,直到发现 某个断言有 错为 我当然 不认为他们 没有问题。所 以我请求他 继续使用新 库,直到发现 某个断言有 错为 止。他 虽然心里不 高兴,但还是 答应了我的 请求。结果 ,他发现所有 的错误都出 在他们自 止。他 虽然心里不 高兴,但还是 答应了我的 请求。结果 ,他发现所有 的错误都出 在他们自 止。他 虽然心里不 高兴,但还是 答应了我的 请求。结果 ,他发现所有 的错误都出 在他们自 止。他 虽然心里不 高兴,但还是 答应了我的 请求。结果 ,他发现所有 的错误都出 在他们自 己的项目,而不是我 的新库中。 己的项目,而不是我 的新库中。 己的项目,而不是我 的新库中。 己的项目,而不是我 的新库中。 正因为 我事前没有 告诉他们新库 中已加上断 言,所以这 个程序员才会 感到惊慌, 因为 正因为 我事前没有 告诉他们新库 中已加上断 言,所以这 个程序员才会 感到惊慌, 因为 正因为 我事前没有 告诉他们新库 中已加上断 言,所以这 个程序员才会 感到惊慌, 因为 正因为 我事前没有 告诉他们新库 中已加上断 言,所以这 个程序员才会 感到惊慌, 因为 没有人 想出错。但 如果我告诉大 家出现了断 言失败是件 好事,也许这 个程序员就 不会那么 没有人 想出错。但 如果我告诉大 家出现了断 言失败是件 好事,也许这 个程序员就 不会那么 没有人 想出错。但 如果我告诉大 家出现了断 言失败是件 好事,也许这 个程序员就 不会那么 没有人 想出错。但 如果我告诉大 家出现了断 言失败是件 好事,也许这 个程序员就 不会那么 惊慌了 。然而,对 错误感到惊慌 的并不止程 序员。因为 公司是通过项 目尚未完成 功能的数 惊慌了 。然而,对 错误感到惊慌 的并不止程 序员。因为 公司是通过项 目尚未完成 功能的数 惊慌了 。然而,对 错误感到惊慌 的并不止程 序员。因为 公司是通过项 目尚未完成 功能的数 惊慌了 。然而,对 错误感到惊慌 的并不止程 序员。因为 公司是通过项 目尚未完成 功能的数 目和项 目中出现的 明显错误数目 来进行项目 评估的,所 以每当这些数 字显著增加 时,项目 目和项 目中出现的 明显错误数目 来进行项目 评估的,所 以每当这些数 字显著增加 时,项目 目和项 目中出现的 明显错误数目 来进行项目 评估的,所 以每当这些数 字显著增加 时,项目 目和项 目中出现的 明显错误数目 来进行项目 评估的,所 以每当这些数 字显著增加 时,项目 组中的 每个人都会 变得精神紧张 。因此,要 让别人知道 你增加断言的 打算。否则 ,就得为 组中的 每个人都会 变得精神紧张 。因此,要 让别人知道 你增加断言的 打算。否则 ,就得为 组中的 每个人都会 变得精神紧张 。因此,要 让别人知道 你增加断言的 打算。否则 ,就得为 组中的 每个人都会 变得精神紧张 。因此,要 让别人知道 你增加断言的 打算。否则 ,就得为 他们准备一 些 他们准备一 些 他们准备一 些 他们准备一 些 EXCEDRIN。。。。 小结 在本章中,我们介绍了利用断言对程序中的错误进行自动检查的方法。虽然这一方法 对及早查出程序中的“最后错误”非常有价值,但同其它的工具一样,断言也可能被过度 使用,用户要自己灵活掌握断言的使用分寸。对于某些程序员来说,每次相除都利用断言 检查分母是否为 0可能很重要,但对其它的程序员来说,这可能很可笑。用户要自已做出 恰当的判断。 在项目的整个生存期中,程序中都应该保留断言。在程序的交付之前不要把它们删去。 在今后打算为程序增加新功能时,这些断言仍然有用。 要点 ���� 要同时维护交付和调试两个版本。封装交付的版本,应尽可能地使用调试版本进 行自动查错。 ���� 断言是进行调试检查的简单方法。要使用断言捕捉不应该发生的非法情况。不要 混淆非法情况与错误情况之间的区别,后者是在最终产品中必须处理的。 ���� 使用断言对函数的参数进行确认,并且在程序员使用了无定义的特性时向程序员 报警。函数定义得越严格,确认其参数就越容易。 ���� 在编写函数时,要进行反复的考查,并且自问:“我打算做哪些假定?”一旦确定 了相应的假定,就要使用断言对所做的假定进行检验,或者重新编写代码去掉相 应的假定。另外,还要问:“这个程序中最可能出错的是什么,怎样才能自动地查 32 出相应的错误?”努力编写出能够尽早查出错误的测试程序。 ���� 一般教科书都鼓励程序员进行防错性程序设计,但要记住这种编码风格会隐瞒错 误。当进行防错性编码时如果“不可能发生”的情况确实发生了,要使用断言进 行报警。 练习 1) 假定你必须维护一个共享库并想在其中使用断言,但又不想发行这个库的源代码, 那么怎样定义 ASSERTMSG 这个断言宏,才能在发生非法的情况下,显示一条有意 义的信息来代替相区的文件名和行名?例如,memcpy 可能显示如下的断言: Assertion failed in memcpy: the blocks over lap 2) 每当使用 ASSERT,宏__FILE__就产生一个唯一的文件名字符串。这就是说,如果 在同一个文件中使用了73个断言,编译程序就会产生 73个完全相同的文件名字 符串。怎样实现 ASSERT 宏,才能使文件名字符串在每个文件中只被定义一次? 3) 下面函数中的断言有什么问题? /* getline ─── 将一个以\n结尾的行读入缓冲区中 */ void getline(char* pch) { int ch; /* ch“必须”是int */ do ASSERT( (ch = getchar()) != EOF ); While( (*pch++=ch) != ‘\n’) } 4) 当程序员为枚举类型增加新元素时,有时会忘记在相应的switch 语句中增加新的 case 条件。怎样才能使用断言帮助查出这个问题? 5) CheckIdInst能够验证 idInst 表中的内容次序正确,但表中还可能发生其它的问 题。由于表中有许多数,很容易输入不正确的屏蔽码或指令特征。如何增强 CheckIdInst的功能,使其能够自动地查出这种输入错误。 6) 如前所述,当指令 EOR的“Effective Address Mode”域是001 时,它就真的变 成了一条 CMPM 指令。EOR指令中还有其它的限制,例如其MODE 域中的两位绝不能 是11(这会使它变成一条 CMPA L指令),而且如果其“Effective Address Mode” 域是111,那么其“Effective Address Register”域必须是 000 或00l。由于对 这些EOR的非法组合不应该调用 pcDecodeEOR,那么怎样为其加上可以查出表中这 些错误的断言呢? 7) 怎样利用不同的算法对qsort 函数进行验证?怎样对二分查找程序进行验证?又 怎样验证 itoa 函数呢? 33 课题: 同编写所用操作系统的公司联系,促使有关人员为程序员提供一个调试版本。(顺便说 一句,这是个尽赚不赔的买卖。因为开发操作系统的公司总是希望人们为其操作系统编写 应用程序,这样可以使其操作系统产品更容易走向市场。 34 第3333章 为子系统设防 在上一章中我们说过瓶颈子程序是加入断言的绝佳之处,因为它可以使我们用很少的 代码就能够进行很彻底的错误检查。这就好象一个足球场,虽然可以有 50000 个球迷来看 球,但如果检票人员站在球场的入口,那么只需要几个检票人员就够了。程序中也有这样 的入口,这就是子系统的调用点。 例如,对于文件系统,用户可以打开文件、关闭文件、读写文件和创建文件。这是五 个基本的文件操作,这些操作通常需要大量复杂代码的支持。有了这些基本的操作,用户 就可以通过对它们的调用来完成相应的文件操作,而不必操心文件目录、自由存储空间映 射或者特定硬件设备(如磁盘驱动器、磁带驱动器或联网设备)的读写等实现细节。 又如,对于内存管理程序,用户可以分配内存、释放内存,有时还可以改变分配了的 内存的大小。这些操作同样需要许多代码的支持。 通常,子系统都要对其实现细节进行隐藏,所隐藏的实现细节可能相当复杂。在进行 实现细节隐藏的同时,子系统为用户提供了一些关键的入口点。程序员通过调用这些关键 的入口点来实现同子系统的通讯。因此如果在程序中使用这样的子系统并且在其调用点加 上了调试检查,那么不用花很大力气就可以进行许多的错误检查。 例如,假如要求你为标准的 C运行时间库编写 malloc、free 和realloc 子程序(有时 必须做这件事情),那么你可能会在代码中加上断言。你可能进行了彻底的测试,并已编写 了极好的程序员指南。尽管如此,我们知道在使用这些程序时,用户还是会遇到问题。那 么为了对用户有所帮助,我们可以作些什么呢? 这里给出的建议是:当子系统编写完成之后,要问自己:“程序员什么情况下会错误地 使用这个子系统,在这个子系统中怎样才能自动地检查出这些问题?”在正常情况下,当 开始编码排除设计中的危险因素时就应该问过了这个问题。但不管怎样,还应该再问一次。 对于内存管理程序。程序员可能犯的错误是: ���� 分配一个内存块并使用其中未经初始化的内容; ���� 释放一个内存块但继续引用其中的内容; ���� 调用realloc 对一个内存块进行扩展,因此原来的内容发生了存储位置的变化, 但程序引用的仍是原来存储位置的内容; ���� 分配一个内存块后即“失去”了它,因为没有保存指向所分配内存块的指针; ���� 读写操作越过了所分配内存块的边界; ���� 没有对错误情况进行检查。 这些问题并不是臆想出来的,它们每时每刻都存在。更糟的是,这些问题都具有不可 再现的特点,所以很难发现。出现一次,就再也看不到了。直到某一天,用户因为被上面 某个常见问题搞得一筹莫展而怒气冲冲地打电话来“请”你排除相应的错误时,才会被再 次发现。 确实,这些错误都很难发现。但是,这并不是说我们没有什么可以改进的事情了。断 35 言确实很有用,但要使断言发挥作用就必须使其能够被执行到。对于我们上面列出的问题, 内存管理程序中的断言能够查出它们吗?显然不能。 在这一章中,将介绍一些用来肃清子系统中错误的其它技术。使用这些技术,可以免 除许多麻烦。本章虽然以 C的内存管理程序为例进行阐述,但所得到的结论同样适用于其 它的子系统,无论是简单的链表管理程序,还是个多用户共享的正文检查工具都适用。 若隐若现,时有时无 通常,解决上述问题的方法是直接在子系统中加上相应的测试代码。但是出于两个理 由,本书并没有这么做。第一个理由是我不想让例子中到处都是 malloc、free 和realloc 的实现代码。第二个理由是用户有时得不到所用子系统的源代码。我之所以会这么说,是 因为在用来测试本书例子的六个编译程序中,有两个提供了标准的源代码。 由于用户可能得不到子系统的源代码,或者即使能够得到,这些源代码的实现也未必 都相同,所以本书不是直接在子程序的源代码中加上相应的测试代码,而是利用所谓的“外 壳”函数把内存管理程序包装起来,并在这层包装的内部加上相应的测试代码。这就是在 得不到子系统源代码的情况下所能采用的方法。在编写外壳函数时,将采用本书前面介绍 过的命名约定。 下面我们先讨论 malloc 的外壳函数。它的形式如下: /* fNewMemory ─── 分配一个内存块 */ flag fNewMemory(void** pv, size_t size) { byte** ppb = (byte**)ppv; *ppb = (byte*)malloc(size); return(*ppb != NULL); /* 成功 */ } 该函数看起来比 malloc 要复杂,这主要是其指针参数void**带来的麻烦。但如果你看 到程序员调用这一函数的方法,就会发现它比malloc 的调用形式更清晰。有了fNewMemory, 下面的调用形式: if( (pbBlock) = (byte*)malloc(32) != NULL ) 成功 ─── pbBlock 指向所分配的内存块 else 不成功 ─── pbBlock 等于NULL 就可以被代替为: if( fNewMemory(&pbBlock, 32) ) 成功 ─── pbBlock 指向所分配的内存块 else 不成功 ─── pbBlock 等于NULL 36 后一种调用形式与前一种功能相同。FNewMemory 和malloc 之间的唯一不同,是前者把 调用“成功”标志与内存块分开返回,而后者则把这两个不同的输出结果合在一个参数中 返回。无论上面哪种调用形式,如果分配成功,pbBlock 都指向所分配的内存块;如果分配 失败,则 pbBlock 为NULL。 在上一章中我们讲过,对于无定义的特性,要么应该将其从程序中消去,要么应该利 用断言验证其不会被用到。如果把这一准则应用于 malloc,就会发现这个函数的行为在两 种情况下无定义,必须进行相应的处理。第一种情况,根据ANSI 标准,请求malloc 分配 长度为零的内存块时,其结果无定义。第二种情况,如果malloc 分配成功,那么它返回的 内存块的内容无定义,它们可以是零,还可以是内容随机的无用信息,不得而知。 对于长度为零的内存块,处理方法非常简单,可以使用断言对这种情况进行检查。但 是对于另一种情况,使用断言能够检查出所分配内存块的内容是否有效吗?不能,这样做 毫无意义。因此,我们别无选择,只能将其消去。消去这个无定义行为的明显方法,是使 fNewMemory 在分配成功时返回一个内容全部为零的内存块。这样虽然可以解决问题,但对 于一个正确的程序来说,所分配内存块的初始内容并不应该影响程序的执行结果,所以这 种不必要的填零增加了交付程序的负担,因此应该避免。 不必要的填充还可能隐瞒错误。 假如在为某一数据结构分配内存时,忘了对其某个域进行初始化(或者当维护程序扩 展该数据结构时,忘了为新增加的域编写相应的初始化代码)就会出现错误。但是如果 fNewMemory 把这些域填充为零或者其它可能有用的值,就可能隐瞒了这一错误。 不管怎样,我们还是不希望所分配内存块的内容无定义,因为这样会使错误难以再现。 那么如果只有当所分配内存块中的无用信息碰巧是某个特定值时才出错,会产生什么样的 结果呢?这就会在大部分的时间内发现不了错误,而程序却会由于不明显的原因不断地失 败、我们可以想象一下,如果每个错误都是在某个特定的时刻才发生,要排除程序中的所 有错误会多难。要是这样,程序(和测试人员)非发疯不可。暴露错误的关键是消除错误 发生的随机性。 确实,如何做到这一点要取决于具体的子系统及其所涉及到的随机特性。但对于malloc 来说,通过对其所分配的内存块进行填充,就可以消除其随机性。当然,这种填充只应该 用在程序的调试版本中。这样既可以解决问题,又不影响程序的发行代码。然而必须记住, 我们不希望隐瞒错误,所以用来填充内存块的值应该离奇得看起来象是无用的信息,但又 应该能够使错误暴露。 例如对于 Macintosh 程序,可以使用值 0xA3。选定这个值是向自己发问以下问题的结 果:什么样的值可以使非法的指针暴露出来?什么样的值可以使非法的计数器或非法的索 引值暴露出来?如果新分配的内存块被当做指令执行会怎样? 在一些 Macintosh 机上,用户使用奇数的指针不能引用 16或32位的值。由此可知, 新选择的填充值应该是奇数。另外,如果非法的计数器或索引值较大。就会引起明显的延 迟,或者会使系统的行为显得不正常,从而增大发现这类错误的可能性。因此,所选择的 填充值应该是用一个字节能够表示的、看起来很奇怪的较大奇数。我选择0xA3 不仅因为它 37 能够满足上述的要求,而且因为它还是一条非法的机器语言指令。因此如果该内存块被莫 名其妙地执行到,程序会立即瘫痪。此时如果是在系统调试程序的控制下,就会产生 “undefined A-Line trap”错误。最后一点似乎有点象大海捞针,发现错误的可能性极小 。 但我们为什么不应该利每个机会,不管它奏效的可能性有多么小,去自动地进行查错呢? 机器不同,所选定和填充值也可能不同。例如在基于 Intel 80x86 的机器上,指针可 以是奇数,所以填充值是否奇数并不重要。但填充值的选择过程是类似的,即先来确定在 什么样的情况下未经初始化的数据才会被用到,然后再千方百计使相应的情况出现。对于 Microsoft 应用,填充值可以选为 0xCC。因为它不仅较大,容易发现,而且如果被执行, 能使程序安全地进入调试程序。 在fNewMemory 中加上内存块大小的检查和内存块的填充代码之后,其形式如下: #define bGarbage 0xA3 flag fNewMemory(void** ppv, size_t size) { byte** ppb = (byte**)ppv; ASSERT(ppv!=NULL && size!=0); *ppb = (byte*)malloc(size); #ifdef DEBUG { if( *ppb != NULL ) memset(*ppb, bGarbage, size); } #endif return(*ppb != NULL); } fNewMemory 的这个版本不仅有助于错误的再现,而且常常可以使错误被很容易地发现。 如果在调试时发现循环的索引值是0xA3A3,或者某个指针的值是0xA3A3A3A3,那么显然它 们都是未经初始化的数据。不止一次,我在跟踪一个错误时,由于偶然遇到了0xA3 某种不 期望的组合,结果又发现了另一个错误。 因此要查看应用中的子系统,以确定其引起随机错误的设计之处。一旦发现了这些地 方,就要通过改变设计的方法把它们排除。或行在它们的周围加上相应的调试代码,最大 限度地减少错误行为的随机性。 冲掉无用的信息 free 的外壳函数形式如下: 要消除随机特性 ─── 使错误可再现 38 void FreeMemory(void* pv) { free(pv); } 根据ANSI 标准,如果给 free 传递了无效的指针,其结果无定义。这似乎很合理,可 是怎样才能知道 pv是否有效呢?又怎样才能得出 pv指向的是一个已分配内存块的开始地 址呢?结论是没法做到,至少在得不到更多信息的情况下做不到。 事情还可能变得更糟。 假定程序维护一颗某种类型的树,其 deletenode 程序调用 FreeMemory 进行结点的释 放。那么如果deletenode 中有错,使其释放相应结点时没有对邻接分配结点中的链指针进 行相应的修改,会产生什么样的结果?很明显,这会使树结构中含有一个已被释放了的自 由结点。但这又怎么样呢?在大多数的系统中,这一自由结点仍将被看作有效的树结点。 这一结果应该不会使人感到特别地惊讶。因为当调用Free 时,就是要通知内存管理程 序该块内存空间已经不再需要,所以为什么还要浪费时间搞乱它的内容呢? 从优化的角度看,这样做很合理。可是它却产生了一个不好的副作用,它使已经被释 放了的无用内存信息仍然包含着好象有效的数据。树中有了这种结点,并不会使树的遍历 产生错误,而导致相应系统的失败。相反,在程序看来,这颗树似乎没什么问题,是颗有 效的树。怎样才能够发现这种问题?除非你的运气同 lotto 数卡牌戏的获胜者一样好,否 则很可能就发现不了。 “没问题”,你可能会说,“只要在 freememory 中加上一些调试代码,使其在调用Free 之前把相应内存块都填上bGarbage 就行了。那样的话,相应内存块的内容看起来就象无用 信息一样,所以树处理程序遇到自由结点时就会跳出来”。这倒是个好主意,但你知道要释 放的内存块的大小吗?唬,不知道。 你可能要举手投降了,承认完全被FreeMemory 击败了。不是吗?既没办法利用断言检 查pv的有效性,又没办法破坏被释放内存块的内容,因为根本就不知这个内存块究竟有多 大。 但是不要放弃努力,让我们暂时假定有一个调试函数sizeofBlock,它可以给出任何内 存分配块和大小。如果有内存管理程序的源代码,编写一个这样的函数可能并不费事。即 使没有内存管理程序的源代码,也不必着急,在本章稍后的内容中,我们将介绍一种 sizeofBlock的实现方法。 还是让我们假定已经有了 sizeofBlock 函数。利用这个函数,在释放之前可以破坏掉 相应内存块的内容: void FreeMemory(void* pv) { ASSERT(pv != NULL); #ifdef DEBUG { 39 memset(pv, bGarbage, sizeofBlock(pv) ); } #endif free(pv); } 该函数中的调试代码不仅对所释放内存块的内容进行了废料填充,而且在调用 sizeofBlock时,还顺便对 pv进行了确认。如果该指针不合法,就会被 sizeofBlock 查出 (该函数当然可以做到这一点,因为它肯定了解每个内存分配块的细节)。 既然NULL 是free 的合法参数(根据ANSI 标准,此时free 什么也不做),为什么还要 使用断言来检查 pv是否为 NULL,这不是很奇怪吗?这样做的原因应该是在意料之中:我不 赞成只为了实现方便,就允许将无意义的NULL 指针传递给函数。这一断言就是用来对这种 用法进行确认。当然,你也许有不同的观点,所以可能想把该断言去掉。但我要说的是, 用户不必盲目地遵守 ANSI 标准。其他人认为free 应该接受 NULL 指针,并不意味你也得接 受这一想法。 relloc是释放内存并产生无用信息的另一个函数。下面给出它的外壳函数,它与malloc 的外壳函数 fNewMemory 很类似: flag fResizeMemory(void** ppv, size_t size) { byte** ppb = (byte**)ppv; byte* pbResize; pbResize = (byte*)realloc(*ppb, sizeNew); if( *pbResize != NULL ) *ppb = pbResize; return(*pbResize != NULL); } 同fNewMemory 一样,fResizeMemory也返回一个状态标志,该标志表明对相应内存块 大小的改变是否成功。如果 pbBlock 指向的是一个已经分配了的内存块,那么可以这样改 变其大小。 if( fResizeMemory(&pbBlock, sizeNew) ) 成功 ─── pbBlock 指向新的内存块 else 不成功 ─── pbBlock 指向老的内存块 读者应该注意到了,同 relloc 不一样,fResizeMemory在操作失败的情况下并不返问 空指针。此时,新返回的指针仍然指向原有的内存分配块,并且块内的内容不变。 有趣的是,realloc 函数(fResizeMemory也是如此)既要调用free,又要调用malloc。 执行时究竟调用哪个函数,取决于是要缩小还是扩大相应内存块的大小。在FreeMemory 中 , 相应内存块的内容在被释放之前即被冲掉;而在fNewMemory 中,在调用malloc 之后新分 40 配的内存块即被填上看起来很怪的“废料”。为了使fResizeMemory比较健壮,这两件事情 都必须做。因此,该函数中要有两个不同的调试代码块: flag fResizeMemory(void** ppv, size_t sizeNew) { byte** ppb = (byte**)ppv; byte* pbResize; #ifdef DEBUG /* 在此引进调试局部变量 */ size_t sizeOld; #endif ASSERT(ppb!=NULL && sizeNew!=0); #ifdef DEBUG { sizeOld = sizeofBlock(*ppb); /* 如果缩小,冲掉块尾释放的内容 */ if(sizeNew sizeOld) memset(pbResize+sizeOld, bGarbage, sizeNew-sizeOld); } #endif *ppb = pbResize; } return( pbResize != NULL ); } 为了做这两件事在该函数中似乎增加了许多额外的代码。但仔细看过就会发现,其中 的大部分内容都是虚的。如花括号、#ifdef 伪指令和注解。就算它确实增加了许多的额外 代码,也不必杞人忧天。因为调试版本本来就不必短小精悍,不必有特别快的响应速度, 只要能够满足程序员和测试者的日常使用要求就够了。因此,除非调试代码会变得太大、 太慢而没法使用,一般在应用程序中可以加上你认为有必要的任何调试代码。以增强程序 41 的查错能力。 重要的是要对子系统进行考查,确定建立数据和释放数据的各种情况并使相应的数据 变成无用信息。 用用用用#ifdef 来说明 局部变量很难 看 ! 来说明 局部变量很难 看 ! 来说明 局部变量很难 看 ! 来说明 局部变量很难 看 ! 看看看看看看看看sizeOld,一个 只用于调试 的局部变量。 虽然 将 ,一个 只用于调试 的局部变量。 虽然 将 ,一个 只用于调试 的局部变量。 虽然 将 ,一个 只用于调试 的局部变量。 虽然 将 sizeOld 的说明 括在 的说明 括在 的说明 括在 的说明 括在 #ifdef 序列 序列 序列 序列 中,使 程序变得很 难看,但这却 非常重要。 因为在该程 序的交付版本 中,所有的 调试代码 中,使 程序变得很 难看,但这却 非常重要。 因为在该程 序的交付版本 中,所有的 调试代码 中,使 程序变得很 难看,但这却 非常重要。 因为在该程 序的交付版本 中,所有的 调试代码 中,使 程序变得很 难看,但这却 非常重要。 因为在该程 序的交付版本 中,所有的 调试代码 都应该被去掉。我当 然知道如果去掉这个 都应该被去掉。我当 然知道如果去掉这个 都应该被去掉。我当 然知道如果去掉这个 都应该被去掉。我当 然知道如果去掉这个 #ifdef 伪指令,相应的程序 会变得更加可读,而 伪指令,相应的程序 会变得更加可读,而 伪指令,相应的程序 会变得更加可读,而 伪指令,相应的程序 会变得更加可读,而 且 程序 的调 试版 本和 交付 版本 会同 样地 正确 。但 这 样做 的唯 一问 题是 在其 交付 版本 中, 且 程序 的调 试版 本和 交付 版本 会同 样地 正确 。但 这 样做 的唯 一问 题是 在其 交付 版本 中, 且 程序 的调 试版 本和 交付 版本 会同 样地 正确 。但 这 样做 的唯 一问 题是 在其 交付 版本 中, 且 程序 的调 试版 本和 交付 版本 会同 样地 正确 。但 这 样做 的唯 一问 题是 在其 交付 版本 中, sizeOld 虽被说明,但却没被 使用。 虽被说明,但却没被 使用。 虽被说明,但却没被 使用。 虽被说明,但却没被 使用。 在程 序的 交付版 本中 声明但 不使 用 在程 序的 交付版 本中 声明但 不使 用 在程 序的 交付版 本中 声明但 不使 用 在程 序的 交付版 本中 声明但 不使 用 sizeOld 变量 ,似 乎没有 问题 。但事 实并非 如此 , 变量 ,似 乎没有 问题 。但事 实并非 如此 , 变量 ,似 乎没有 问题 。但事 实并非 如此 , 变量 ,似 乎没有 问题 。但事 实并非 如此 , 这样做会引起严重的 问题。 如果维护程序员没有注意 到 这样做会引起严重的 问题。 如果维护程序员没有注意 到 这样做会引起严重的 问题。 如果维护程序员没有注意 到 这样做会引起严重的 问题。 如果维护程序员没有注意 到 sizeOld 只是一个调试专用的变 量 只是一个调试专用的变 量 只是一个调试专用的变 量 只是一个调试专用的变 量 ,,,, 而把它用在了交付版 本中, 那么由于它未经初始化, 可能就会引起严重的问题。将 而把它用在了交付版 本中, 那么由于它未经初始化, 可能就会引起严重的问题。将 而把它用在了交付版 本中, 那么由于它未经初始化, 可能就会引起严重的问题。将 而把它用在了交付版 本中, 那么由于它未经初始化, 可能就会引起严重的问题。将 sizeOld 的声明用 的声明用 的声明用 的声明用 #ifdef 伪指令括起来, 就明确地表明 了 伪指令括起来, 就明确地表明 了 伪指令括起来, 就明确地表明 了 伪指令括起来, 就明确地表明 了 sizeOld 只是一个调试专用的 变量。 因此 只是一个调试专用的 变量。 因此 只是一个调试专用的 变量。 因此 只是一个调试专用的 变量。 因此 ,,,, 如果程序员在程序的 非调试代码 (即使是 如果程序员在程序的 非调试代码 (即使是 如果程序员在程序的 非调试代码 (即使是 如果程序员在程序的 非调试代码 (即使是 #ifdef) 中使用 了 ) 中使用 了 ) 中使用 了 ) 中使用 了 sizeOld, 那么当构造该程序 , 那么当构造该程序 , 那么当构造该程序 , 那么当构造该程序 的的的的 交付版本时就会遇到 编译程序错误。这等于加了双保险。 交付版本时就会遇到 编译程序错误。这等于加了双保险。 交付版本时就会遇到 编译程序错误。这等于加了双保险。 交付版本时就会遇到 编译程序错误。这等于加了双保险。 使用 使用 使用 使用 #ifdef 指令来除去调试用变 量虽然使程序变得很难看,但这种用法可 以帮助我们 指令来除去调试用变 量虽然使程序变得很难看,但这种用法可 以帮助我们 指令来除去调试用变 量虽然使程序变得很难看,但这种用法可 以帮助我们 指令来除去调试用变 量虽然使程序变得很难看,但这种用法可 以帮助我们 消除一个产生潜在错 误的根源。 消除一个产生潜在错 误的根源。 消除一个产生潜在错 误的根源。 消除一个产生潜在错 误的根源。 产生移动和震荡的程序 假定程序不是释放掉树结构的某个结点,而是调用 fResizeMemory将该结点扩大,以 适应变长数据结构的要求。那么当 fResizeMemory对该结点进行扩展时,如果移动了该结 点的存储位置,就会出现两个结点:一个是在新位置的真实结点,另一个是原位置留下的 不可用的无用信息结点。 这样一来,如果编写expandnode 的程序员没有考虑到当 fResizeMemory在扩展结点时 会引起相应结点的移动这种情况,会出现什么问题呢?相应树结构的状态会不会仍然不变, 即该结点的邻接结点仍然指向虽然已被释放但看起来似乎仍然有效的原有内存块?扩展之 后的新结点会不会漂浮在内存空间中,没有任何的指针指向它?事实确实会这样,它可能 产生看起来好象有效但实际上是错误的树结构,并在内存中留下一块无法访问到的内存块。 这样很不好。 我们可以想到通过修改 fResizeMemory,使其在扩展内存块引起存储位置移动的情况 下,冲掉原有的块内容。要达到这一目的,只需简单地调用 memset 即可: flag fResizeMemory(void** ppv, size_t sizeNew) { 冲掉无用的信息,以免被错误地使用 42 …… pbResize = (byte*)realloc(*ppb, sizeNew); if(pbResize != NULL) { #ifdef DEBUG { /* 如果发生移动,冲掉原有的块内容 */ if(pbResize != *ppb) memset(*ppb, bGarbage, sizeOld); /* 如果扩大,对尾部增加的内容进行初始化 */ if(sizeNew > sizeOld) memset(pbResize+sizeOld, bGarbage, sizeNew-sizeOld); } #endif *ppb = pbResize; } return( pbResize != NULL ); } 很遗憾,这样做不行。即使知道原有内存块的大小和位置,也不能破坏原有内存块的 内容,因为我们不知道内存管理程序会对被其释放了的内存空间进行如何的处理。对于被 释放了的内存空间,有些内存管理程序并不对其做些什么。但另外一些内存管理程序,却 用它来存储自由空间链或者其它的内部实现数据。这一事实意味着一旦释放了内存空间, 它就不再属于你了,所以你也不应该再去动它。如果你动了这部分内存空间,就有破坏整 个系统的危险。 举一个非常极端的例子,有一次当我正在为 Microsoft 的内部 68000 交叉汇编程序增 加新功能时,Macintosh Word 和Excel 的程序员请求我去帮助他们查明一个长期以来总是 使系统偶然失败的错误。检查这个错误的难点在于虽然它并不经常发生,但却总是发生, 因此引起了人们的重视。我不想谈过多的细节,但折腾了几周之后我才找到了使这个错误 重现的条件,而找出该错误的实际原因却只用了三天的时间。 找出使这个错误重现的条件花了我很长时间,但我还是不清楚是什么原因引起了这个 错误。每当我查看相应的数据结构时,它们看起来似乎都完全没有问题。我没想到这些所 谓完全没有问题的数据结构,实际上竟是早先调用 realloc 遗留下的无用信息! 然而,真正的问题还不在于发现这个错误的准确原因花了我多长的时间,而在于为了 找出使这个错误重现的条件花了那么多的时间。realloc 在扩大内存块时不但确实会移动相 应内存块的位置,而且原有的内存块必须被重新分配并被填写上新的数据。在汇编程序中, 这两种情况都很少发生。 这使我们得出了编写无错代码的另一个准则:“不要让事情很少发生。”因此我们需要 43 确定子系统中可能发生哪些事情,并且使它们一定发生和经常发生。如果发现子系统中有 极罕见的行为,要干方百计地设法使其重现。 你有过跟踪错误跟到了错误处理程序中,并且感到“这段错误处理程序中的错误太多 了,我敢肯定它从来都没有被执行过”这种经历吗?肯定有,每个程序员都有过这种经历。 错误处理程序之所以往往容易出错,正是因为它很少被执行到。 同样,如果不是 realloc 扩大内存块时使原有存储位置发生移动这种现象很罕见,这 一汇编程序中的错误在几个小时内就可以被发现,而用不着要耗上几年。可是,怎样才能 使realloc 经常地移动内存块呢?回答是做不到,至少在相应操作系统没有提供支持的情 况下做不到。尽管如此,但我们却能够模拟 realloc 的所作所为。如果程序员调用 fResizeMemory扩大了某个内存块,那么可以通过先建一个新的内存块,然后再把原有内存 块的内容拷贝到这个新块中,最后释放掉原有内存块的方法,准确地模拟出 realloc 的全 部动作。 Flag fResizeMemory(void** ppv, size_t sizeNew) { byte** ppb = (byte**)ppv; byte* pbResize; #ifdef DEBUG size_t sizeOld; #endif ASSERT(ppb!=NULL && sizeNew!=0); #ifdef DEBUG { sizeOld = sizeofBlock(*ppb); /* 如果缩小,先把将被释放的内存空间填写上废料 * 如果扩大,通过模拟 realloc 的操作来迫使新的内存块产生移动 *(不让它在原有的位置扩展)如果新块和老块的长度相同,不 * 做任何事情 */ if(sizeNew < sizeOld) memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew); else if(sizeNew > sizeOld) { byte* pbNew; if( fNewMemory(&pbNew, sizeNew) ) { memcpy(pbNew, *ppb, sizeOld); FreeMemory(*ppb); 44 *ppb = pbNew; } } } #endif pbResize = (byte*)realloc(*ppb, sizeNew); …… } 在上面的程序中,所增加的新代码只有在相应的内存块被扩大时,才会被执行。通过 在释放原有内存块之前分配一个新的内存块,可以保证只要分配成功,相应内存块的存储 位置就会被移动。如果分配失败,那么所增加的新代码相当于一个很大的空操作指令。 但是,请注意上面程序中所增加的新代码不仅使相应内存块不断地移动而,还顺便冲 掉了原有内存块的内容。当它调用FreeMemory 释放原有的内存块时,该内存块的内容即被 冲掉。 现在你可能会想:既然上面的程序是用来模拟realloc的,那它为什么还要调用realloc 呢?而且在所增加的代码中加入一条return 语句,例如: if( fNewMemory(&pbNew, sizeNew) ) { memcpy(pbNew, *ppb, sizeOld); FreeMemory(*ppb); *ppb = pbNew; return(TRUE); } 不是可以提高其运行速度吗? 我们为什么不这样做呢?我们可做到这点,但切记不要这么做,因为它是个不良的习 惯。要记住调试代码是多余的代码,而不是不同的代码。除非有非常值得考虑的理由,永 远应该执行原有的非调试代码,即使它在加入了调试代码之后已经变得多余。毕竟查出代 码错误的最好方法是执行代码,所以要尽可能地执行原有的非调试代码。 有时在我向程序员解释这些概念时,他们会反驳说:“总是移动内存块正如水远不移动 内存块一样有害,你已经走到了另一个极端。”他们确实非常机敏,因此有必要解释一下。 假如在程序的调试版本和交付版本中都总是做某件事情,那么它确实如同永远不做一 样有害。但在这个例子中,fResizeMemory实际上并不紧张,尽管其调试版本是那样不屈不 挠地对内存块进行移动,就好象吃了安非他明一样。 如果某事件很少发生并没有什么问题,只要在程序的交付版本和调试版本中不少发生 就行。 如果某件事甚少发生的话,设法使其经常发生 45 保存一个日志,以唤起你的注意 从调试的端点看,内存管理程序的问题是当第一次创建内存块时知道其大小,但随后 几乎马上就会失去这一信息,除非在某个地方保存了一个有关的记录。我们已经看到函数 sizeofBlock的价值很大,但如果能够知道已分配内存块的数目及其在内存中的具体存储位 置,用处会更大。假如能够知道这些信息,那么不管指针的值是什么,我们都能够确定它 是否有效。如果能这样,该有多大的用处,尤其是对于函数参数的确认。 假定我们有函数 fValidPointer,该函数有指针 pv和大小 size 两个参数;当 pv实际 指向的内存分配块正好有size 个字节时,该函数返回TRUE。利用这一函数我们可以为常用 的子程序编写出更加严格的专用版本。例如,如果发现内存分配块的部分内容常常被填掉, 那么我们可以绕过对指针检查得不太严格的 memset 函数,而调用自己编写的 FillMemory 程序。该程序能够对其指针参数进行更加严格的确认: void FillMemory(void* pv, byte b, size_t size) { ASSERT(fValidPointer(pv, size)); Memset(pv, b, size); } 通过应用 fValidPointer,该函数可以保证pv指向的是一个有效的内存块。而且,从pv 到该内存块的尾部至少会有size 个字节。 如果愿意的话我们可以在程序的调试版本中调用 FillMemory,而在其交付版本中直接 调用memset。要做到这一点,只需在其交付版本中包括如下的全局宏定义: #define FillMemory(pb, b, size) memset((pb), (b), (size)) 这些内容已经有点离题了。 这里一直强调的是如果在程序的调试版本中保存额外的信息,就经常可以提供更强的 错误检查。 到目前为止,我们介绍了在FillMemory 和fResizeMemory中使用 sizeofBlock填充内 存块的方法。但这种方法同通过保存一个含有所有分配内存块信息的记录所能做到的相比, 只是个相对“弱”的错误发现方法。 同前面一样,我们仍然假定会遇到最坏的情况:从相应的子系统本身,我们得不到关 于分配内存块的任何信息。这意味着通过内存管理程序,我们得不到内存块的大小,不知 道指针是否有效,甚至不知道某个内存块是否存在或者已经分配了多少个内存块。因此如 果程序中需要这些信息,就必须自己提供出来。这就是说,在程序中得保存一个某种类型 的分配日志。至于如何保存这个日志并不重要,重要的是在需要这些信息时就能够得到。 维护日志的一种可能方法是:当在fNewMemory 中分配一个内存块时,为日志信息也分 配一个内存块;当在 fFreeMemory中释放一个内存块时,还要释放相应的日志信息;当在 fResizeMemory中改变了内存块的大小时,要修改相应的日志信息,使它反映出相应内存块 46 的新大小和新位置。显然,我们可以把这三个动作封装在三个不同的调试界面中: /* 为新分配的内存块建立一个内存记录 */ flag fCreateBlockInfo(byte* pbNew, size_t sizeNew); /* 释放一个内存块对应的日志信息 */ void FreeBlockInfo(byte* pb); /* 修改现有内存块对应的日志信息 */ void UpdateBlockInfo(byte* pbOld, byte* pbNew, size_t sizeNew); 当然,只要它们不使相应系统的运行速度降低到无法使用的程度,这三个程序维护日 志信息的方法就不很重要。读者在附录B中可以找到上述函数的实现代码。 对FreeMemory 和fResizeMemory进行修改,使其调用适当的子程序非常简单。修改后 的FreeMemory 变成了如下形式: void FreeMemory(void* pv) { #ifdef DEBUG { memset(pv, bGarbage, sizeofBlock(pv)); FreeBlockInfo(pv); } #endif free(pv); } 在fResizeMemory 中,如果 realloc 成功地改变了相应内存块的大小,那么就调用 UpdateBlockInfo(如果realloc 失败,自然就没有什么要修改的内容)。fResizeMemory 的后一部分如下: flag fResizeMemory(void** ppv, size_t sizeNew) { …… pbResize = (byte*)realloc(*ppb, sizeNew); if(pbResize != NULL) { #ifdef DEBUG { UpdateBlockInfo(*ppb, pbResize, sizeNew); /* 如果扩大,对尾部增加的内容进行初始化 */ if(sizeNew > sizeOld) memset(pbResize+sizeOld, bGarbage, sizeNew-sizeOld); } 47 #endif *ppb = pbResize; } return(pbResize != NULL); } fNewMemory 的修改相对要复杂一些,所以把它放到了最后来讨论。当调用fNewMemory 分配一个内存块时系统必须分配两个内存块:一个用来满足调用者的请求,另一个用来存 放相应的日志信息。只有在两个内存块的分配都成功时,fNewMemory 的调用才会成功。如 果不这样规定就会使某些内存块没有日志信息。要求内存块必须有对应的日志信息非常重 要,因为如果没有日志信息,那么在调用对指针参数进行确认的函数时,就会产生断言失 败。 在下面的代码中我们将会看到,如果 fNewMemory 成功地进行了用户请求空间的分配, 但相应日志内容所需内存的分配失败,该函数会把第一个内存块释放掉,并返回一个内存 块分配失败标志。这样做可以使所分配的内存内容与相应的日志信息同步。 fNewMemory 的代码如下: flag fNewMemory(void** ppv, size_t size) { byte** ppb = (byte**)ppv; ASSERT(ppv!=NULL && size!=0); *ppb = (byte*)malloc(size); #ifdef DEBUG { if(*ppb != NULL) { memset(*ppb, bGarbage, size); /* 如果无法创建日志块信息, * 那么模拟一个总的内存分配错误。 */ if( !fCreateBlockInfo(*ppb, size) ) { free(*ppb); *ppb = NULL; } } } #endif return(*ppb != NULL); 48 } 就是这样。 现在,我们有了相应内存系统的完整记录。利用这些信息,我们可以很容易地编写出 象sizeofBlock和fValidPointer(见附录 B)这样的函数,以及任何其它的有用函数。 不要等待错误发生 直到目前为止,我们所做的一切努力只能帮助用户注意到错误的发生。这固然不错, 但它还不能自动地发现错误。以前面讲过的 deletenode 函数为例,如果该函数调用函数 FreeMemory 释放某个结点时,在相应的树结构中留下了指向已释放内存空间的指针,那么 在这些指针永远都不会被用到的情况下,我们能够发现这个问题吗?不,不能。又如,如 果我们在函数 fResizeMemory中忘了调用 FreeMemory,又会怎样? …… if( fNewMemory(&pbNew, sizeNew) ) { memcpy(pbNew, *ppb, sizeOld) /* FreeMemory(*ppb); */ *ppb = pbNew; } 结果会在该函数中产生一个难解的错误。说它难解,是因为表面看起来,什么问题都 没有。但我们每次执行这段程序,就会“丢失”一块内存空间。因为在把 pbNew 赋给*ppb 时,这个唯一指向该内存块的指针被冲掉了。那么该函数中的调试代码能够帮助我们查出 这个错误吗?根本不能。 这些错误与前面讲的错误不同,因为它们不会引起任何不合法情况的发生。正如匪徒 根本没打算出城,路障就没用了一样,在相应数据没被用到的情况下相应的调试代码也没 用,因为它查不出这些错误。查不到错误并不意味这些错误不存在,它们确实存在只不过 我们没有看到它们 ─── 它们“隐藏”得很深。 要找出这些错误,就得象程序员一样,对错误进行“挨门挨户”的搜查。不要等待错 误自己暴露出来,要在程序中加上能够积极地寻找这种问题的调试代码。 对于上面的程序我们遇到两种情况。第一种情况,我们得到一个指向已被释放了的内 存块的“悬挂指针”;第二种情况,我们分配了一个内存块,但却没有相应的指针指向它。 这些错误通常都很难发现,但是如果我们在程序中一直保存有相应的调试信息,就可以比 较容易地发现它们。 让我们来看看人们是怎样检查其银行财务报告书中的错误:我们自己有一个拨款清单, 银行有一个拨款清单。通过对这两个清单进行比较,我们就可以发现其中的错误、这种方 保存调试信息,以便进行更强的错误检查 49 法同样可以用来发现悬挂指针和内存块丢失的错误。我们可以对已知指计表(保存在程序 的调试信息中)进行比较,如果发现指针所引用是尚未分配的内存块或者相应的内存块没 有被任何指针所指向,就肯定出了问题。 但程序员,尤其是有经验的程序员总是避免直接对存储在每个数据结构中的每个指针 进行检查。因为要对程序中的所有数据结构以及存储在其中的所有指针进行跟踪,如果不 是不可能的话,似乎也非常困难。实际的情况是,即使某些编写得很差的程序,也是为指 针再单独分配相应的内存空间,以便于对其进行检查。 例如,68000 汇编程序可以为 753 个符号名分配内存空间,但它并没有使用753 个全局 变量对这些符号名进行跟踪,那样会显得相当的愚蠢。相反,它使用的是数组、散列表、 树或者简单的链表。因此,尽管可能会有 753 个符号名,但利用循环可以非常简单地遍查 这些数据结构,而且这也费不了多少代码。 为了对相应的指针表和对应的调试信息进行比较,我定义了三个函数。这三个函数可 以同上节给出的信息收集子程序(读者在附录 B中可以找到它们的实现代码)配合使用: /* 将所有的内存块标记为“尚未引用”*/ void ClearMemoryRefs(void); /* 将pv所指向的内存块标记为“已被引用”*/ void NoteMemoryRef(void* pv); /* 扫描引用标志,寻找被丢失的内存块 */ void CheckMemoryRefs(void); 这三个子程序的使用方法非常简单。首先,调用ClearMemoryRefs把相应的调试信息 设置成初始状态。其次,扫描程序中的全局数据结构,调用NoteMemoryRef对相应的指针 进行确认并将其指向的内存块标记为“已被引用”。在对程序中所有的指针这样做了之后, 每个指针都应该是有效的指针,所分配的每个内分块都应该标有引用标记。最后,调用 CheckMemroyRefs验证某个内存块没有引用标记,它将引发相应的断言,警自用户相应的内 存块是个被丢失了的内存块。 下面我们看看在本章前面介绍的汇编程序中,如何使用这些子程序对该汇编程序中使 用的指针进行确认。为了简单起见,我们假定该汇编程序所使用的符号表是棵二叉树,其 每个结点的形式如下: /*“symbol”是一个符号名的结点定义。 * 对于用户汇编源程序中定义的每个符号, * 都分配一个这样的结点 typedef struct SYMBOL { struct SYMBOL* psymRight; struct SYMBOL* psymLeft; char* strName; /* 结点的正文表示 */ …… 50 }symbol; /* 命名方法:sym,*psym */ 其中只给出了三个含有指针的域。头两个域是该结点的左子树指针和右子树指针,第 三个域是以零字符结尾的字符串。在我们调用 ClearMemoryRefs时,该函数完成对相应树 的遍历,并将树中每个指针的有关信息记载下来。完成这些操作的代码破封装在一个调试 专用的函数 NoteSymbolRefs中,该函数的形式如下: void NoteSymbolRefs(symbol* psym) { if(psym!=NULL) { /* 在进入到下层结点之前先确认当前的结点 */ NoteMemoryRef(psym); NoteMemoryRef(psym->strName); /* 现在确认当前结点的子树 */ NoteSymbolRefs(psym->psymRight); NoteSymbolRefs(psym->psymLeft); } } 该函数对符号表进行先序遍历,记下树中每个指针的情况。通常,符号表都被存储为 中序树,因此相应地应该对其进行中序遍历。但我这里使用的是先序遍历,其原因是我想 在引用 psym 所指内容之前,对其有效性进行确认,这就要求进行先序遍历。如果进行中序 遍历或者后序遍历,就会在企图对 psym 进行确认之前引用到其指向的内容,从而可能在进 行了多次的递归之后,使程序失败。当然,这样也可以发现错误。但跟踪一个随机的错误 和跟踪一个断言的失败,你宁愿选择哪一个呢? 在为其它的数据结构编写了“Note-Ref”这一类的例程之后,为了便于在程序的其它 地方进行调用,应该把它们合并为一个单独的例程。对于这个汇编程序,相应的例程可以 有如下的形式 #ifdef DEBUG void CheckMemoryIntegrity(void) { /* 将所有的内存块标记为“尚未引用”*/ ClearMemoryRefs(); /* 记载所有的已知分配情况 */ NoteSymbolRefs(psymRoot); NoteMacroRefs(); …… NoteCacheRefs(); NoteVariableRefs(); 51 /* 保证每个指针都没有问题 */ CheckMemoryRefs(); } #endif 最后一个问题是:“应该在什么时候调用这个例程?”显然,我们应该尽可能多地调用 这个例程,但其实这要取决于具体的需要。至少,在准备使用相应的子系统之前,应该调 用这一例程对其进行一致性检查。如果能在程序等待用户按键、移动鼠标或者拨动硬件开 关期间,对相应的子系统进行检查,效果会更好。总之,要利用一切机会去捕捉错误。 非确 定性原理 非确 定性原理 非确 定性原理 非确 定性原理 我经常 向程序员解 释使用调试检 查是怎么回 事。在我解 释的过程中, 有时他或她 会因 我经常 向程序员解 释使用调试检 查是怎么回 事。在我解 释的过程中, 有时他或她 会因 我经常 向程序员解 释使用调试检 查是怎么回 事。在我解 释的过程中, 有时他或她 会因 我经常 向程序员解 释使用调试检 查是怎么回 事。在我解 释的过程中, 有时他或她 会因 为所加 入的调试代 码会对原有的 代码产生妨 碍,而对增 加这种代码可 能带来的不 良后果的 为所加 入的调试代 码会对原有的 代码产生妨 碍,而对增 加这种代码可 能带来的不 良后果的 为所加 入的调试代 码会对原有的 代码产生妨 碍,而对增 加这种代码可 能带来的不 良后果的 为所加 入的调试代 码会对原有的 代码产生妨 碍,而对增 加这种代码可 能带来的不 良后果的 严重程度表示担忧。这又是一个 与 严重程度表示担忧。这又是一个 与 严重程度表示担忧。这又是一个 与 严重程度表示担忧。这又是一个 与 Heisenberg 提出的 提出的 提出的 提出的 ““““非确定性原理 非确定性原理 非确定性原理 非确定性原理 ””””有关的问题。如 有关的问题。如 有关的问题。如 有关的问题。如 果果果果 读者对这一问题感兴 趣,请继续读下去。 读者对这一问题感兴 趣,请继续读下去。 读者对这一问题感兴 趣,请继续读下去。 读者对这一问题感兴 趣,请继续读下去。 毫无疑 问,所加入 的调试代码会 引起程序交 付版本和调 试版本之间的 区别。但只 要在 毫无疑 问,所加入 的调试代码会 引起程序交 付版本和调 试版本之间的 区别。但只 要在 毫无疑 问,所加入 的调试代码会 引起程序交 付版本和调 试版本之间的 区别。但只 要在 毫无疑 问,所加入 的调试代码会 引起程序交 付版本和调 试版本之间的 区别。但只 要在 加入调 试代码时十 分谨慎,并没 有改变原有 程序的内部 行为,那么这 种区别就不 应该有什 加入调 试代码时十 分谨慎,并没 有改变原有 程序的内部 行为,那么这 种区别就不 应该有什 加入调 试代码时十 分谨慎,并没 有改变原有 程序的内部 行为,那么这 种区别就不 应该有什 加入调 试代码时十 分谨慎,并没 有改变原有 程序的内部 行为,那么这 种区别就不 应该有什 么问题 。例如虽 然 么问题 。例如虽 然 么问题 。例如虽 然 么问题 。例如虽 然 fResizeMemory可能会 很频繁地移 动内存块, 但它并没有改 变该函数的 可能会 很频繁地移 动内存块, 但它并没有改 变该函数的 可能会 很频繁地移 动内存块, 但它并没有改 变该函数的 可能会 很频繁地移 动内存块, 但它并没有改 变该函数的 基本行为。同样,虽 然 基本行为。同样,虽 然 基本行为。同样,虽 然 基本行为。同样,虽 然 fNewMemory 所分配的内存空间会比 用户所请求的多(用于存放相 所分配的内存空间会比 用户所请求的多(用于存放相 所分配的内存空间会比 用户所请求的多(用于存放相 所分配的内存空间会比 用户所请求的多(用于存放相 应应应应 的日 志信息) ,但这对用户程序 也不应该 有什么影 响。 (如果你指望 请求分配 的日 志信息) ,但这对用户程序 也不应该 有什么影 响。 (如果你指望 请求分配 的日 志信息) ,但这对用户程序 也不应该 有什么影 响。 (如果你指望 请求分配 的日 志信息) ,但这对用户程序 也不应该 有什么影 响。 (如果你指望 请求分配 21个字节 , 个字节 , 个字节 , 个字节 , fNewMemory 或者或者或者或者malloc 就应该恰好为你分 配 就应该恰好为你分 配 就应该恰好为你分 配 就应该恰好为你分 配 21个字节,那么无论有 没有调试代码你都会 个字节,那么无论有 没有调试代码你都会 个字节,那么无论有 没有调试代码你都会 个字节,那么无论有 没有调试代码你都会 遇到 遇到 遇到 遇到 麻烦。因为要满足对 齐要求,内存管理程序分配的内存总是要比 用户请求的量多) 麻烦。因为要满足对 齐要求,内存管理程序分配的内存总是要比 用户请求的量多) 麻烦。因为要满足对 齐要求,内存管理程序分配的内存总是要比 用户请求的量多) 麻烦。因为要满足对 齐要求,内存管理程序分配的内存总是要比 用户请求的量多) 另一个问题是调试代 码会增加应用程序的大小, 因此需要占用更多 的 另一个问题是调试代 码会增加应用程序的大小, 因此需要占用更多 的 另一个问题是调试代 码会增加应用程序的大小, 因此需要占用更多 的 另一个问题是调试代 码会增加应用程序的大小, 因此需要占用更多 的 RAM。 但是读者 。 但是读者 。 但是读者 。 但是读者 应应应应 该记得 ,建立调试 版本的目的是 捕捉错误, 而不是最大 限度地利用内 存。对于调 试版本来 该记得 ,建立调试 版本的目的是 捕捉错误, 而不是最大 限度地利用内 存。对于调 试版本来 该记得 ,建立调试 版本的目的是 捕捉错误, 而不是最大 限度地利用内 存。对于调 试版本来 该记得 ,建立调试 版本的目的是 捕捉错误, 而不是最大 限度地利用内 存。对于调 试版本来 说,如 果无法装人 最大的电子表 格,无法编 辑最大可能 的文档或者没 法做需要大 量内存的 说,如 果无法装人 最大的电子表 格,无法编 辑最大可能 的文档或者没 法做需要大 量内存的 说,如 果无法装人 最大的电子表 格,无法编 辑最大可能 的文档或者没 法做需要大 量内存的 说,如 果无法装人 最大的电子表 格,无法编 辑最大可能 的文档或者没 法做需要大 量内存的 工作也 没有什么关 系,只要相应 的交付版本 能够做到这 些就可以。使 用调试版本 会遇到的 工作也 没有什么关 系,只要相应 的交付版本 能够做到这 些就可以。使 用调试版本 会遇到的 工作也 没有什么关 系,只要相应 的交付版本 能够做到这 些就可以。使 用调试版本 会遇到的 工作也 没有什么关 系,只要相应 的交付版本 能够做到这 些就可以。使 用调试版本 会遇到的 最坏情 况,是相对 交付版本而言 ,运行不久 便耗尽了可 用的内存空间 ,使程序异 常频繁地 最坏情 况,是相对 交付版本而言 ,运行不久 便耗尽了可 用的内存空间 ,使程序异 常频繁地 最坏情 况,是相对 交付版本而言 ,运行不久 便耗尽了可 用的内存空间 ,使程序异 常频繁地 最坏情 况,是相对 交付版本而言 ,运行不久 便耗尽了可 用的内存空间 ,使程序异 常频繁地 执行相 应的错误处 理代码;最好 的情况,是 调试版本很 快就捉住了错 误,几乎没 有或者花 执行相 应的错误处 理代码;最好 的情况,是 调试版本很 快就捉住了错 误,几乎没 有或者花 执行相 应的错误处 理代码;最好 的情况,是 调试版本很 快就捉住了错 误,几乎没 有或者花 执行相 应的错误处 理代码;最好 的情况,是 调试版本很 快就捉住了错 误,几乎没 有或者花 费很少的调试时间。 这两种极端情况都有价值。 费很少的调试时间。 这两种极端情况都有价值。 费很少的调试时间。 这两种极端情况都有价值。 费很少的调试时间。 这两种极端情况都有价值。 一点就透 Robert Cialdini 博土在其“Influence:How and Why people Agree to Things”一 书中指出:如果你是个售货员,那么当顾客来到你负责的男装部准备购买毛衣和套装时, 你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为 在顾客买了一件$500 元的套装之后,相比之下,一件$80 元的毛衣就显得不那么贵了。但 建立详尽的子系统检查并且经常地进行这些检查 52 是如果你先给顾客看毛衣,那么$80 元一件的价格可能会使其无法接受,最后也许你只能卖 出一件$30 元的毛衣。任何人只要花 30秒的时间想一想,就会明白这个道理。可是,又有 多少人花时间想过这一问题呢? 同样,一些程序员可能会认为,bGarbage 选为何值并不重要,只要从过去用过的数中 随便挑一个就行了。另外一些程序员也可能会认为,究竟是按先序、中序还是后序对符号 表进行递归遍历并不重要。但正如我们在前面指出的那样,有些选择确实比另外的一些选 择要好。 如果可以随意地选择实现细节的话,那么在做出相应的选择之前,要先停下来花30秒 钟考查一下所有的可能选择。对于每一种选择,我们都要问自己:“这种选择是会引起错误 , 还是会帮助发现错误?”如果对 bGarbage 的取值问过这一问题的话,你就会发现选择0会 引起错误而选择 OxA3 之类的值则会帮助我们发现错误。 无需知道 在对子系统进行测试时,为了使用相应的测试程序,你可能遇到过需要了解这些测试 程序各方面内容的情况。fValidPointer的使用就是这样一个例子。如果你不知道有这样一 个函数,就根本不会去使用它。然而,最好的测试代码应该是透明的代码,不管程序员能 否感觉到它们的存在,它们都会起作用。 假定一个没有经验的程序员或者某个对项目不熟悉的人加入了项目组。在根本不知道 fNewMemory、fResizeMemory和FreeMemory的内部有相应测试代码的情况下,他不是照样 可以随意地在程序中使用这些函数吗? 那么如果他没有意识到fResizeMemory会引起内存块的移动,并因此在其程序中产生 了类似于前述汇编程序中出现的错误,那么会发生什么现象呢?他需要因为执行了相应的 一致性检查程序并产生了断言“illegal pointer”而对一致性检查程序的内容有所了解 吗? 如果他创建并随即丢失了一个内存块,又会怎样呢?这时同样会执行相应的一致性检 查程序并产生断言“lost memory”。也许,他甚至连什么叫做“lost memory”都不知 道。 但事实是,他并不需要知道这个,相应的检查就可以起作用。更妙的是,通过跟踪这一错 误不用向有经验的程序员请教也可以学到与内存丢失有关的内容。 这就是精心设计子系统测试代码的好处 ─── 当测试代码将错误限制在一个局部的 范围之内后,就通过断言把错误抓住并送到“广播室”,把正常的工作打断。对于程序员来 说,这真是再好不过的反馈。 仔细设计程序的测试代码,任何选择都应该经过考虑 努力做到透明的一致性检查 53 我们交付的不是调试版本 在这一章中,我确实给内存管理程序加上了许多调试代码。对此,一些程序员可能会 认 为 :“在程序中加入调试代码似乎很有用,但象这样把所有的检查都加上并且还包括了对 日志信息的处理,就太过分了。”我得承认,我也有过这种感觉。 以前我也对给程序加上这么多降低效率的调试代码很反感,但不久我就认识到了自己 的错误。在程序的交付版本中加上这种调试代码是会断送它的市场前途,但我们并没有在 其交付版本中增加任何的测试代码,这些代码只是被用在了它的调试版本中。确实,调试 代码会降低调试版本的运行速度。但使你的零售产品瘫在用户那儿,或者为了帮助查错使 你的调试版本运行得稍慢,哪一种情况更糟糕呢?我们不应该担心调试版本的效率,因为 毕竟顾客不会使用程序的调试版本。 重要的是要在感情上区分程序的调试版本和交付版本。调试版本事用来发现错误的, 而交付版本则是用来取悦顾客的。因此在编码时,对这两个版本所作的权衡也会相当不同。 记住,只要相应的交付版本能够满足顾客的大小和速度要求,就可以对调试版本做你 想做的任何事情。如果为内存管理程序加上日志程序可以帮助你发现各种难于捕捉的错误, 那么就会皆大欢喜。顾客可以得到一个充满活力的程序而你不费很多的时间和精力就可以 发现错误。 Microsoft 的程序员总是在其程序中加上相应的调试代码。例如,Excel 就含有一些内 存子系统的测试程序(它们比我们这里介绍的还要详尽)。它有单元表格一致性检查程序; 它有人为产生内存失败的机制,使程序员可以强制程序执行“内存空间耗尽”的错误处理 程序;它还有许多的其它检查程序。这不是说 Excel 的交付版本从来没有错误,它确实有, 但这些错误很少出现在通过了详尽的子系统检查的代码中。 同样,虽然我们在这一章中给内存管理程序增加了许多的代码,但增加的所有代码都 是用来构造 fNewMemory、FreeMemory 和fResizeMemory,我们没给这些函数的调用程序增 加任何东西,也没给 malloc、free 和realloc 的内部支持代码(它们可以非常重要)增加 任何东西。甚至增加调试代码所引起的速度下降,也并非如想象的那样糟糕。如果Microsoft 公司的统计结果具有代表性的话,程序调试版本(充满了断言和子系统测试)的速度大约 应该是相应交付版本的一半。 确有 其事 确有 其事 确有 其事 确有 其事 为了发 现更多的错 误,过 去 为了发 现更多的错 误,过 去 为了发 现更多的错 误,过 去 为了发 现更多的错 误,过 去 Microsoft 总是把 其开发的应 用程序的调 试版本送给 总是把 其开发的应 用程序的调 试版本送给 总是把 其开发的应 用程序的调 试版本送给 总是把 其开发的应 用程序的调 试版本送给 ββββ测测测测 试者进行 试者进行 试者进行 试者进行 ββββ测试。 但当基于产品的 测试。 但当基于产品的 测试。 但当基于产品的 测试。 但当基于产品的 ββββ调试版本对产品进行评 论的 调试版本对产品进行评 论的 调试版本对产品进行评 论的 调试版本对产品进行评 论的 ““““Pre-release””””周刊出 周刊出 周刊出 周刊出 现,现,现,现, 并且说 其程序虽然 非常好,但就 是慢得和鼻 涕虫一样之 后,他们不再 至少是暂时 不再提供 并且说 其程序虽然 非常好,但就 是慢得和鼻 涕虫一样之 后,他们不再 至少是暂时 不再提供 并且说 其程序虽然 非常好,但就 是慢得和鼻 涕虫一样之 后,他们不再 至少是暂时 不再提供 并且说 其程序虽然 非常好,但就 是慢得和鼻 涕虫一样之 后,他们不再 至少是暂时 不再提供 产品的 产品的 产品的 产品的 ββββ调试版 本。这个事实 告诫我们不 要把调试版 本送到测试 场所,或者在 这样做之前 调试版 本。这个事实 告诫我们不 要把调试版 本送到测试 场所,或者在 这样做之前 调试版 本。这个事实 告诫我们不 要把调试版 本送到测试 场所,或者在 这样做之前 调试版 本。这个事实 告诫我们不 要把调试版 本送到测试 场所,或者在 这样做之前 不要把对交付版本的约束应用到相应的调试版本上 要用大小和速度来换取错误检查能力 54 要把调试版本中影响 性能的内部调试检查代码全部清除。 要把调试版本中影响 性能的内部调试检查代码全部清除。 要把调试版本中影响 性能的内部调试检查代码全部清除。 要把调试版本中影响 性能的内部调试检查代码全部清除。 小结 在这一章中,我们介绍了六种增强内存子系统的方法。这些方法虽然是针对内存子系 统提出来的,但其中的观点同样适用于其它的子系统。大家可以想象得出,在程序自己具 有详尽的确认能力之后错误要想悄悄地溜入这种程序,简直比登天还难。同样,假如在我 前面讲过的汇编程序中用上了这些调试检查,那么通常要花上几年才能发现的 realloc 错 误,在相应代码第一次编写的几个小时或者几天之内就可以被自动地发现。不管程序员的 技术很高,还是没有经验,这些测试代码都能够抓住这个错误。 事实上,这些测试代码能够抓住所有的这类错误。而且是自动地抓住,不靠运气,也 不靠技巧 这就是编写无错代码的方法。 要点: ���� 考查所编写的子系统,问自己:“在什么样的情况下,程序员在使用这些子系统时会犯 错 误 。”在子系统中加上相应的断言和确认检查代码,以捕捉难于发现的错误和常见的 错误。 ���� 如果不能使错误不断重现,就无法排除它们。找出程序中可能引起随机行为的因素, 并将它们从程序的调试版本中清除。把目前尚“无定义”的内存单元置成了某个常量 值,就可能产生这种错误。在这种情况下,如果程序在该单元被正确地定义为某个值 之前引用了它的内容,那么每次执行这部分错误的代码,都会得到同样的错误结果。 ���� 如果所编写的子系统释放内存(或者其它的资源),并因此产生了“无用信息”,那么 要把它搅乱,使它真的象无用信息。否则,这些被释放了的数据就有可能仍被使用, 而又不会被注意到。 ���� 类似地,如果在所编写的子系统中某些事情可能发生,那么要为该子系统加上相应的 调试代码,使这些事情一定发生。这样可以增大查出通常得不到执行的代码中的错误 的可能性。 ���� 尽力使所编写的测试代码甚至在程序员对其没有感觉的情况下亦能起作用。最好的测 试代码是不用知道其存在也能起作用的测试代码。 ���� 如果可能的话,把测试代码放到所编写的子系统中,而不要把它放到所编写子系统的 外层。不要等到进行了系统编码时,才考虑其确认方法。在子系统设计的每一步,都 要考虑“如何对这一实现进行详尽的确认”这一问题。如果发现这一设计难于测试或 者不可能对其进行测试,那么要认真地考虑另一种不同的设计,即使这意味着用大小 或速度作代价去换取该系统的测试能力也要这么做。 ���� 在由于速度太慢或者占用的内存太多而抛弃一个确认测试程序之前,要三思而后行。 55 切记,这些代码并不是存在于程序的交付版本中。如果发现自己正在想:“这个测试程 序太慢、太大了”,那么要马上停下来问自己:“怎样才能保留这个测试程序,并使它 既快又小?” 练习 1) 如果在进行代码测试时偶然碰到了0xA3 的某种组合构成的数据,那么这一数据可 能是未经过初始化的数据,或者是已被释放了的数据。怎样才能修改相应的凋试 代码,使我们可以比较容易地确定所发现的数据是哪一类? 2) 程序员编写的代码有时会对所分配内存块上界之外的内存单元进行填充。请给出 增加相应的内存子系统检查,使其能够对这类错误报警的方法。 3) 虽然CheckMemoryIntegrity 程序被用来对悬挂指针错误进行检查,但在有些情况 下,该程序检查不出这种错误。例如,假定一个函数调用了 FreeMemory,但由于 该函数的错误,某个指针被悬挂起来,即该指针指向的内存块已被 FreeMemory 释 放掉。现在我们进一步假定在该指针被确认之前,某个函数调用 fNewMemory 对这 块刚释放不久的内存块进行了再分配。这样一来,刚才被悬挂起来的指针又指向 了新分配的内存块。但是,这个内存块已经不是原来那个内存块了。因此,这是 个错误。但对于CheckMemoryIntegrity 来说却一切都很正常,并没有什么不合法 。 假如这个错误在你的项目中比较常见,那么怎样增强该程序才能使其查出这个问 题呢? 4) 利用NoteMemoryRef程序,我们可以对程序中的所有指针进行确认。但是,我们 如何对所分配内存块的大小进行确认呢?例如,假定指针指向的是一个含有18个 字符的字符串,但所分配内存块的长度却小于 18。或者在相反的情况下,程序认 为所分配的内存块有 15个字节,但相应的日志信息表明为其分配了 18个字 节。 这两种情况都是错误的。怎样加强相应的一致性检查程序,使其能够查出这种问 题? 5) NoteMemoryRef可以使我们把一个内存块标为“已被引用”,但利用它我们无法知 道引用该内存块的指针数目是否超过了其应有的数目。例如,双向链表的每个结 点只应该有两个引用。一个是前向指针,另一个是后向指针。但在大多数的情况 下,每个内存块只应该有一个指针在引用着它。如果有多个指针同时引用一个内 存块,那么一定是程序中什么地方出了错误。如何改进相应的一致性检查程序, 使其对某些内存块允许多个指针对其进行同时的引用;但对另外一些内存块仍不 允许多个指针对其进行同时的引用,并在这种情况发生时,引发相应的断言? 6) 本章自始自终所谈的都是为了帮助程序员检查错误,可以在相应的内存系统中加 上调试代码。但是,我们可不可以增加对测试者有所帮助的代码呢?测试者知道 程序经常会对错误情况进行不正确的处理,那么如何为测试者提供模拟“内存空 间耗尽”这一条件的能力呢? 56 课题: 考查你项目中的主要子系统,看看为了检查出与使用这些子系统有关的常见错误,可 以实现哪种类型的调试检查? 课题: 如果没有所用操作系统的调试版本,那么尽可能买一个。如果买不到,就利用外壳函 数自己写一个。如果你助人为乐,那么请使所编出的代码(以某种方式)可以为其它的开 发者所用。 57 第4444章 对程序进行逐条跟踪 前面我们讲过,发现程序中错误的最好方法是执行程序。在程序执行过程中,通过我 们的眼睛或者利用断言和子系统一致性检查这些自动的测试工具来发现错误。然而,虽然 断言和子系统检查都很有用,但是如果程序员事先没有想到应该对某些问题进行检查也不 能保证程序不会遇到这些问题。这就好比家庭安全检查系统一样。 如果只在门和窗户上安装了警报线,那么当窃贼从天窗或地下室的入口进入家中时, 就不会引起警报。如果在录像机、立体声音响或者其它一些窃贼可能盗取的物品上安装了 干扰传感器,而窃贼却偷取了你的 Barry Manilow 组合音响,那么他很可能会不被发现地 逃走。这就是许多安全检查系统的通病。因此,唯一保证家中物品不被偷走的办法是在窃 贼有可能光顾的期间内呆在家里。防止错误进入程序的办法也是这样,在最有可能出现错 误的时候,必须密切注视。 那么什么时候错误最有时能出现呢?是在编写或修改程序的时候吗?确实是这样。虽 然现在程序员都知道这一点,但他们却并不总能认识到这一点的重要性,并不总能认识到 编写无错代码的最好办法是在编译时对其进行详尽的测试。 在这一章中,我们不谈为什么在编写程序时对程序进行测试非常重要,只讲在编写程 序时对程序进行有效测试的方法。 增加对程序的置信 最近,我一直为 Microsoft 的内部 Macintosh 开发系统编写某个功能。但当我对所编 代码进行测试时,发现了一个错误。经过跟踪,确定这个错误是出在另一个程序员新编的 代码中。使我迷惑不解的是,这部分代码对其他程序员的所编代码非常重要,我想不出他 这部分代码怎么还能工作。我来到他的办公室,以问究竟 “我想,在你最近完成的代码中我发现了一个错误”。我说道。“你能抽空看一下吗?” 他把相应的代码装入编辑程序,我指给他看我认为的问题所在。当他看到那部分代码时不 禁大吃一惊。 “你是对的,这部分代码确实有错。可是我的测试程序为什么没有查出这个错误呢?” 我也对此感到奇怪。“你到底用什么方法测试的这部分代码?”,我问道。 他向我解释了他的测试方法,听起来似乎它应该能够查出这个错误。我们都感到很费 解 。“让我们在该函数上设置一个断点对其进行逐条跟踪,看看实际的情况到底怎样”,我 提议道。 我们给该函数设置了一个断点。但当找们按下运行键之后,相应的测试程序却运行结 束了,它根本就没有碰上我们所设置的断点。没过多久,我们就发现了测试程序没有执行 该函数的原因 ─── 在该函数所在调用链上几层,一个函数的优化功能使这个函数在某 种情况下面跳过了不必要的工作。 读者还记得我在第 1章中所说的黑箱测试问题吗?测试者给程序提供大量的输入,然 58 后通过检查其对应的输出来判断该程序是否有问题。如果测试者认为相应的输出结果没有 问题,那么相应的程序就被认为没有问题。但这种方法的问题是除了提供输入和接受输出 之外,测试者再没有别的办法可以发现程序中的问题。上述程序员漏掉错误的原因是他采 用了黑箱方法对其代码进行测试,他给了一些输入,得到了正确的输出,就认为该代码是 正确的。他没有利用程序员可用的其他工具对其代码进行测试。 同大多数的测试者不同,程序员可以在代码中设置断点,一步一步地跟踪代码的运行, 观察输入变为输出的过程。尽管如此,但奇怪的是很少有程序员在进行代码测试时习惯于 对其代码进行逐条的跟踪。许多程序员甚至不耐烦在代码中设置一个断点,以确定相应代 码是否被执行到了。 还是让我们回到这一章开始所谈论的问题上:捕捉错误的最好办法是在编写或修改程 序时进行相应的检查。那么,程序员测试其程序的最好办法是什么呢?是对其进行逐条的 跟踪,对中间的结果进行认真的查看。对于能够始终如一地编写出没有错误程序的程序员, 我并不认识许多。但我所认识的几个全都有对其程序进行逐条跟踪的习惯。这就好比你在 家时夜贼光临了 ─── 除非此时你睡着了,否则就不会不知道麻烦来了。 作为一个项目负责人,我总是教导许多程序员在进行代码测试时,要对其代码进行遍 查,而他们总是会吃惊地看着我。这倒不是他们不同意我的看法,而是因为进行代码遍查 听起来太费时间了。他们好容易才能赶得上进度,又哪有时间对其代码进行逐条的跟踪呢? 幸好这一直观的感受是错误的。是的,对代码进行逐条的跟踪确实需要时间,但它同编写 代码相比,只是其一小部分。要知道,当实现一个新函数时,你必须为其设计出函数的外 部界面,勾画出相应的算法并把源程序全部输入到计算机中。与此相比,在你第一次运行 相应的的程序时,为其设置一个断点,按下“步进”键检查每行的代码又能多花多少时间 呢?并不太多,尤其是在习惯成自然之后。这就好比学习驾驶一辆手扳变速器的轿车,一 开始好象不可能,但练习了几天以后,当需要变速时你甚至可以无意识地将其完成。同样, 一旦逐条地跟踪代码成为习惯之后,我们也会不加思索地设置断点并对整个过程进行跟踪。 可以很自然地完成这一过程,并最后检查出错误。 代码中的分支 当然有些技术可以使我们更加有效地对代码进行逐条的跟踪。但是如果我们只对部分 而不是全部的代码进行逐条跟踪,那么也不会取得特别好的效果。例如,所有的程序员都 知道错误处理代码常常有错,其原因是这部分代码极少被测试到,而且除非你专门对这部 分代码进行测试,否则这些错误就不会被发现。为了发现错误处理程序中的错误,我们可 以建立使错误情况发生的测试用例,或者在对代码进行逐条跟踪时可以对错误的情况进行 模拟。后一种方法通常费时较少。例如,考虑下面的代码中断: pbBlock = (byte*)malloc(32); 不要等到出了错误再对程序进行逐条的跟踪 59 if( pbBlock == NULL ) { 处理相应的错误情况; …… } …… 通常在逐条跟踪这段代码时,malloc 会分配一个 32字节的内存块,并返回一个非NULL 的指针值使其中的错误处理代码被绕过。但为了对该错误处理代码进行测试,可以再次逐 条跟踪这段代码并在执行完下行语句之后,立即用跟踪程序命令将pbBlock 置为NULL 指针 值: pbBlock =(byte*)malloc(32); 虽然malloc 可能分配成功,但将pbBlock 置为NULL 指针就相当于 malloc 产生了分配 失败,从而使我们可以步进到相应的错误处理部分。(注意:在改变了 pbBlock 的值之 后, malloc 刚分配的的内存块即被丢失,但不要忘了这只是在做测试!)除了要对错误情况进行 逐条的跟踪之外,对程序中每一条可能的路径都应该进行逐条的跟踪。程序中具有多条代 码路径的明显情况是 if和switch 语句,但还有一些其它的情况:&&,||和?:运算符, 它们每个都有两条路径。 为了验证程序的正确性,至少要对程序中的每条指令逐条跟踪一遍。在做完了这件事 之后,我们对程序中不含错误就有了更高的置信。至少我们知道对于某些输入,相应的程 序肯定没错。如果测试用例选择得好,代码的逐条跟踪会使我们受益非浅。 大的 变动怎么 样? 大的 变动怎么 样? 大的 变动怎么 样? 大的 变动怎么 样? 过去程序员问过这样 的问题: 过去程序员问过这样 的问题: 过去程序员问过这样 的问题: 过去程序员问过这样 的问题: ““““如果我增加的功能与 许多地方的代码都有关系怎么办 如果我增加的功能与 许多地方的代码都有关系怎么办 如果我增加的功能与 许多地方的代码都有关系怎么办 如果我增加的功能与 许多地方的代码都有关系怎么办 ???? 那对所 有增加的新 代码进行逐条 的跟踪不是 太费时间了 吗? 那对所 有增加的新 代码进行逐条 的跟踪不是 太费时间了 吗? 那对所 有增加的新 代码进行逐条 的跟踪不是 太费时间了 吗? 那对所 有增加的新 代码进行逐条 的跟踪不是 太费时间了 吗? ””””假如你 是这么想的, 那么我 假如你 是这么想的, 那么我 假如你 是这么想的, 那么我 假如你 是这么想的, 那么我 不妨问你另一个问题 : 不妨问你另一个问题 : 不妨问你另一个问题 : 不妨问你另一个问题 : ““““如果你做了这么大的 变动,在进行这些改动时可能不引进任何的 如果你做了这么大的 变动,在进行这些改动时可能不引进任何的 如果你做了这么大的 变动,在进行这些改动时可能不引进任何的 如果你做了这么大的 变动,在进行这些改动时可能不引进任何的 问问问问 题吗? 题吗? 题吗? 题吗? ““““ 习惯于 对代码进行 逐条跟踪会产 生一个有趣 的负反馈回 路。例如,对 代码进行逐 条跟 习惯于 对代码进行 逐条跟踪会产 生一个有趣 的负反馈回 路。例如,对 代码进行逐 条跟 习惯于 对代码进行 逐条跟踪会产 生一个有趣 的负反馈回 路。例如,对 代码进行逐 条跟 习惯于 对代码进行 逐条跟踪会产 生一个有趣 的负反馈回 路。例如,对 代码进行逐 条跟 踪的程 序员很快就 会学会编写较 小的容易测 试的函数, 因为对于大函 数进行逐条 的跟踪非 踪的程 序员很快就 会学会编写较 小的容易测 试的函数, 因为对于大函 数进行逐条 的跟踪非 踪的程 序员很快就 会学会编写较 小的容易测 试的函数, 因为对于大函 数进行逐条 的跟踪非 踪的程 序员很快就 会学会编写较 小的容易测 试的函数, 因为对于大函 数进行逐条 的跟踪非 常痛苦。 (测试一 个 常痛苦。 (测试一 个 常痛苦。 (测试一 个 常痛苦。 (测试一 个 10页长的的函数比测 试 页长的的函数比测 试 页长的的函数比测 试 页长的的函数比测 试 10个一页长的函数要难 得多)程序员还会花 个一页长的函数要难 得多)程序员还会花 个一页长的函数要难 得多)程序员还会花 个一页长的函数要难 得多)程序员还会花 更更更更 多的时 间去考虑如 何使必需做的 大变动局部 化,以便能 够更容易地进 行相应的测 试。这些 多的时 间去考虑如 何使必需做的 大变动局部 化,以便能 够更容易地进 行相应的测 试。这些 多的时 间去考虑如 何使必需做的 大变动局部 化,以便能 够更容易地进 行相应的测 试。这些 多的时 间去考虑如 何使必需做的 大变动局部 化,以便能 够更容易地进 行相应的测 试。这些 不正是 我们所期望 的吗?没有一 个项目的负 责人喜欢程 序员做大的变 动,它们会 使整个项 不正是 我们所期望 的吗?没有一 个项目的负 责人喜欢程 序员做大的变 动,它们会 使整个项 不正是 我们所期望 的吗?没有一 个项目的负 责人喜欢程 序员做大的变 动,它们会 使整个项 不正是 我们所期望 的吗?没有一 个项目的负 责人喜欢程 序员做大的变 动,它们会 使整个项 目太不稳定。也没有一个项目负责人喜 欢大的、不好管理的函数,因为它们常常不好维护 目太不稳定。也没有一个项目负责人喜 欢大的、不好管理的函数,因为它们常常不好维护 目太不稳定。也没有一个项目负责人喜 欢大的、不好管理的函数,因为它们常常不好维护 目太不稳定。也没有一个项目负责人喜 欢大的、不好管理的函数,因为它们常常不好维护 。。。。 如果发 现必须做大 的变动,那么 要检查相应 的改变并进 行判断。同时 要记住,在 大多 如果发 现必须做大 的变动,那么 要检查相应 的改变并进 行判断。同时 要记住,在 大多 如果发 现必须做大 的变动,那么 要检查相应 的改变并进 行判断。同时 要记住,在 大多 如果发 现必须做大 的变动,那么 要检查相应 的改变并进 行判断。同时 要记住,在 大多 数情况下,对代码进 行逐条跟踪所花的时间要比实现相应代码 所花的时间少得多。 数情况下,对代码进 行逐条跟踪所花的时间要比实现相应代码 所花的时间少得多。 数情况下,对代码进 行逐条跟踪所花的时间要比实现相应代码 所花的时间少得多。 数情况下,对代码进 行逐条跟踪所花的时间要比实现相应代码 所花的时间少得多。 对每一条代码路径进行逐条的跟踪 60 数据流 ─── 程序的命脉 在我编写的第 2章中介绍的快速 memset 函数之前,该函数的形式如下(不含断言): void* memset( void *pv, byte b, size _tsize ) { byte pb=(byte*)pv; if( size >= sizeThreshold ) { unsigned long l; /* 用4个字节拼成一个长字 */ l = (b<<24) | (b<<16) | (b<<8) | b; pb = (byte*)longfill( (long*)pb, 1, size/4 ); size = size % 4; } while( size-- > 0 ) *pb++ = b; return(pv); } 这段代码看起来好象正确,其实有个小错误。在我编完了上述代码之后,我把它用到 了一个现成的应用程序中,结果没有问题,该函数工作得很好。但为了确信该函数确实起 作用了,我在该函数上设置了一个断点并重新运行该应用程序。在进入代码跟踪程序得到 了控制之后我检查了该函数的参数:其指针参数值看起来没问题,大小参数亦如此,字节 参数值为零。这时我感到使用字节值 0来测试这个函数真是太不应该,因为它使我很难观 察到许多类型的错误,所以我立即把字节参数的值改成了比较奇怪的0x4E。 我首先测试了size 小于sizeThreshold的情况,那条路径没有问题。随后我测试了size 大于或等于sizeThreshold的情况,本来我想也不会有什么问题。但当我执行了下条语句 之后: l = (b<<24) | (b<<16) | (b<<8) | b; 我发现 l被置成了 0x00004E4E,而不是我所期望的值0x4E4E4E4E。在对该函数进行汇 编语言的快速转储之后,我发现了这一错误,并且知道了为什么在有这个错误的情况下该 应用程序仍能工作。 我用来编译该函数的编译程序将整数处理为 16位。在整数为16位的情况下,b<<24 会 产生什么样的结果呢?结果会是0。同样b<<16 所产生的结果也会是 0。虽然这个程序在逻 辑上并没有什么错误,但其具体的实现却是错的。之所以该函数在相应应用程序中能够工 作,是因为该应用程序使用 memset 来把内存块填写为 0,而0<<24 则仍是 0,所以结果正 确。 61 我几乎立即就发现了这个错误,因为在把它搁置在一边继续往下走查之前,我又多花 了一点时间逐条跟踪了这部分代码。确实,这个错误很严重,最终一定会被发现。但要记 住,我们的目标是尽可能早地查出错误。对代码进行逐条跟踪可以帮助我们达到这个目标。 对代码进行逐条跟踪的真正作用是它可以使我们观察到数据在函数中的流动。如果在 对代码进行逐条跟踪时密切地注视数据流,就会帮助你查出下面这么多的错误: ���� 上溢和下溢错误; ���� 数据转换错误; ���� 差1错误; ���� NULL 指针错误; ���� 使用废料内存单元错误(0xA3 类错误); ���� 用 = 代替 == 的赋值错误; ���� 运算优先级错误; ���� 逻辑错误。 如果不注重数据流,我们能发现所有这些错误吗?注重数据流的价值在于它可以使你 以另一种非常不同的观点看待你的代码。你也许没能够注意到下面程序中的赋值错误: if( ch = ’’’’\t’’’’) ExpandTab(); 但当你对其进行逐条跟踪,密切注视其数据流时,很容易就会发现ch的内容被破坏了 。 为什么 编译程序 没有对上述错误 发出 警告? 为什么 编译程序 没有对上述错误 发出 警告? 为什么 编译程序 没有对上述错误 发出 警告? 为什么 编译程序 没有对上述错误 发出 警告? 在我用 来测试本书 中程序的五个 编译程序中 尽管每个编 译程序的警告 级别都被设 置到 在我用 来测试本书 中程序的五个 编译程序中 尽管每个编 译程序的警告 级别都被设 置到 在我用 来测试本书 中程序的五个 编译程序中 尽管每个编 译程序的警告 级别都被设 置到 在我用 来测试本书 中程序的五个 编译程序中 尽管每个编 译程序的警告 级别都被设 置到 最大, 但仍没有一个编译程序对 于 最大, 但仍没有一个编译程序对 于 最大, 但仍没有一个编译程序对 于 最大, 但仍没有一个编译程序对 于 b<<24这个错误发生警告。 这一代码虽然是合法 的 这个错误发生警告。 这一代码虽然是合法 的 这个错误发生警告。 这一代码虽然是合法 的 这个错误发生警告。 这一代码虽然是合法 的 ANSI C,,,, 但我想 象不出在什 么情况下这一 代码实际能 够完成程序 员的意图。既 然如此,为 什么不给 但我想 象不出在什 么情况下这一 代码实际能 够完成程序 员的意图。既 然如此,为 什么不给 但我想 象不出在什 么情况下这一 代码实际能 够完成程序 员的意图。既 然如此,为 什么不给 但我想 象不出在什 么情况下这一 代码实际能 够完成程序 员的意图。既 然如此,为 什么不给 出警告呢? 出警告呢? 出警告呢? 出警告呢? 当你遇 到这种错误 ,要告诉相应 编译程序的 制造商,以 使该编译程序 的新版本可 以对 当你遇 到这种错误 ,要告诉相应 编译程序的 制造商,以 使该编译程序 的新版本可 以对 当你遇 到这种错误 ,要告诉相应 编译程序的 制造商,以 使该编译程序 的新版本可 以对 当你遇 到这种错误 ,要告诉相应 编译程序的 制造商,以 使该编译程序 的新版本可 以对 这种错误送出警告。 不要低估作为一个花了钱的顾客你手中的 权利。 这种错误送出警告。 不要低估作为一个花了钱的顾客你手中的 权利。 这种错误送出警告。 不要低估作为一个花了钱的顾客你手中的 权利。 这种错误送出警告。 不要低估作为一个花了钱的顾客你手中的 权利。 你遗漏了什么东西吗? 使用源级调试程序的一个问题是在执行一行代码时可能会漏掉某些重要的细节。例如, 假定在下面的代码中错误地将 && 输入了 &: /* 如果该符号存在并且它有对应的正文名字, * 那么就释放这个名字 */ if( psym != NULL & psym->strName != NULL ) 当对代码进行逐条跟踪时,要密切注视数据流 62 { FreeMemory( psym->strName ); psym->strName = NULL; } 这段程序虽然合法但却是错误的。if语句的使用目的是避免使用NULL 指针psym 去引 用结构 symbol 的成员 strName,但上面的代码做的却并不是这件事情。相反,不管 psym 的值是否为 NULL 这段程序总会引用 strName 域。 如果使用源级调试程序对代码进行逐条跟踪,并在到达该if语句时,按了“步进”键 , 那么调试程序将把整个 if语句当做一个操作来执行。如果发现了这个错误,你就会注意到 即使在其表达式的左边是FALSE 的情况下,表达式的右边仍会被执行。(或者,如果你很幸 运,当程序间接引用了NULL 指针时系统会出现错误。但并没有许多的台式计算机会这样做 , 至少在目前它们不这样做。) 记得我们以前说过:&,|| 和 ?: 运算符都有两条路径,因此要查出错误就必须对 每条路径进行逐条的跟踪。源级调试程序的问题是用一个单步就越过了 &&、||和 ?: 的 两条路径。有两个实用的方法可以解决这一问题。 第一个方法,只要步进到使用 && 和 || 运算符的复合条件语句,就扫描相应的一些 条件,验证这些条件拼写无误然后使用调试程序命令显示条件中每个比较的结果。这样做 可以帮助我们查出在某些情况下虽然整个表达式的计算结果正确,但该表达式中确实有错 误这一情况。例如,如果你认为在这种情况下 || 表达式的第一部分应该是 TRUE,第二部 分应该是 FALSE,但其结果恰恰相反。此时虽然整个表达式的计算结果虽然正确,但表达式 中却有错误。观察表达式的各个部分可以发现这类问题。 第二个,也是更彻底的方法是在汇编语言级步进到复合条件语句和 ?:运算符的内部 。 是的,这要花费更多的工夫,但对于关键的代码,为了观看到中间的计算结果而对其内部 的代码实际地走上一遍是很重要的。这同对C语句进行逐条的跟踪一样,一旦你习惯之后。 对汇编语言指令进行逐条地跟踪也很快,只不过需要经过练习而已。 关掉优化? 关掉优化? 关掉优化? 关掉优化? 如果所 用的编译程 序优化功能很 强,那么对 代码进行逐 条的跟踪可能 会是一个十 分有 如果所 用的编译程 序优化功能很 强,那么对 代码进行逐 条的跟踪可能 会是一个十 分有 如果所 用的编译程 序优化功能很 强,那么对 代码进行逐 条的跟踪可能 会是一个十 分有 如果所 用的编译程 序优化功能很 强,那么对 代码进行逐 条的跟踪可能 会是一个十 分有 趣的练 习。因为编 译程序在生成 优化的代码 时,可能会 把相邻源语句 对应的机器 代码混在 趣的练 习。因为编 译程序在生成 优化的代码 时,可能会 把相邻源语句 对应的机器 代码混在 趣的练 习。因为编 译程序在生成 优化的代码 时,可能会 把相邻源语句 对应的机器 代码混在 趣的练 习。因为编 译程序在生成 优化的代码 时,可能会 把相邻源语句 对应的机器 代码混在 一块。 对于这种编 译程序,一条 一块。 对于这种编 译程序,一条 一块。 对于这种编 译程序,一条 一块。 对于这种编 译程序,一条 ““““单步 单步 单步 单步 ””””命令跳 过三行代码 并非不常见; 同样,利用 命令跳 过三行代码 并非不常见; 同样,利用 命令跳 过三行代码 并非不常见; 同样,利用 命令跳 过三行代码 并非不常见; 同样,利用 ““““单单单单 步步步步””””指令执 行完一行将 数据从一处送 到另一处的 源语句之后 却发现相应的 数据尚未传 送过 指令执 行完一行将 数据从一处送 到另一处的 源语句之后 却发现相应的 数据尚未传 送过 指令执 行完一行将 数据从一处送 到另一处的 源语句之后 却发现相应的 数据尚未传 送过 指令执 行完一行将 数据从一处送 到另一处的 源语句之后 却发现相应的 数据尚未传 送过 去的情况也很常见。 去的情况也很常见。 去的情况也很常见。 去的情况也很常见。 为了对 代码进行逐 条跟踪容易一 些,在编译 调试版本时 可以考虑关掉 不必要的编 译程 为了对 代码进行逐 条跟踪容易一 些,在编译 调试版本时 可以考虑关掉 不必要的编 译程 为了对 代码进行逐 条跟踪容易一 些,在编译 调试版本时 可以考虑关掉 不必要的编 译程 为了对 代码进行逐 条跟踪容易一 些,在编译 调试版本时 可以考虑关掉 不必要的编 译程 源级调试程序可能会隐瞒执行的细节 对关键部分的代码要进行汇编指令级的逐条跟踪 63 序优化 。这些优化 除了扰乱所生 成的机器代 码之外,毫 无用处。我听 到过某些程 序员反对 序优化 。这些优化 除了扰乱所生 成的机器代 码之外,毫 无用处。我听 到过某些程 序员反对 序优化 。这些优化 除了扰乱所生 成的机器代 码之外,毫 无用处。我听 到过某些程 序员反对 序优化 。这些优化 除了扰乱所生 成的机器代 码之外,毫 无用处。我听 到过某些程 序员反对 关掉编 译程序的优 化功能他们认 为这会在程 序的调试版 本和交付版本 之问产生不 必要的差 关掉编 译程序的优 化功能他们认 为这会在程 序的调试版 本和交付版本 之问产生不 必要的差 关掉编 译程序的优 化功能他们认 为这会在程 序的调试版 本和交付版本 之问产生不 必要的差 关掉编 译程序的优 化功能他们认 为这会在程 序的调试版 本和交付版本 之问产生不 必要的差 别从而 带来风险。 如果担心编译 程序会产生 代码生成错 误的话,这种 观点还有点 道理。但 别从而 带来风险。 如果担心编译 程序会产生 代码生成错 误的话,这种 观点还有点 道理。但 别从而 带来风险。 如果担心编译 程序会产生 代码生成错 误的话,这种 观点还有点 道理。但 别从而 带来风险。 如果担心编译 程序会产生 代码生成错 误的话,这种 观点还有点 道理。但 同时我 们还应该想 到,我们建立 调试版本的 目的是要查 出程序中的错 误,既然如 此,如果 同时我 们还应该想 到,我们建立 调试版本的 目的是要查 出程序中的错 误,既然如 此,如果 同时我 们还应该想 到,我们建立 调试版本的 目的是要查 出程序中的错 误,既然如 此,如果 同时我 们还应该想 到,我们建立 调试版本的 目的是要查 出程序中的错 误,既然如 此,如果 关掉编译的优化功能 可以帮助我们做到这点,那么就值得考虑 。 关掉编译的优化功能 可以帮助我们做到这点,那么就值得考虑 。 关掉编译的优化功能 可以帮助我们做到这点,那么就值得考虑 。 关掉编译的优化功能 可以帮助我们做到这点,那么就值得考虑 。 最好的 办法是对优 化过的代码进 行逐条的跟 踪,先看看 这样做的困难 有多大,然 后为 最好的 办法是对优 化过的代码进 行逐条的跟 踪,先看看 这样做的困难 有多大,然 后为 最好的 办法是对优 化过的代码进 行逐条的跟 踪,先看看 这样做的困难 有多大,然 后为 最好的 办法是对优 化过的代码进 行逐条的跟 踪,先看看 这样做的困难 有多大,然 后为 了有效地对代码进行 逐条跟踪,只关闭那些你认为必须关闭的 编译程序优化功能。 了有效地对代码进行 逐条跟踪,只关闭那些你认为必须关闭的 编译程序优化功能。 了有效地对代码进行 逐条跟踪,只关闭那些你认为必须关闭的 编译程序优化功能。 了有效地对代码进行 逐条跟踪,只关闭那些你认为必须关闭的 编译程序优化功能。 小结 我希望我知道一种能够说服程序员对其代码进行逐条跟踪的方法,或者至少能够使他 们尝试一个月。但是我发现,程序员一般说来都克服不了“那太费时间”这一想法。作为 项目负责人的一个好处是对于这种事情你可以霸道一些,直到程序员认识到这样做并不费 很多时间,并且觉得很值得这样做,因为出错率显著的下降了。 如果你还没有对你的程序进行逐条的跟踪,你会开始这样做吗?只有你自己才知道这 个问题的答案。但我猜想当你拿起这本书并开始阅读的时候,准是因为你正被减少你或你 领导的程序员的代码中的错误所困扰。这自然就归结为如下的问题:你是宁愿花少量的时 间,通过对代码进行逐条的跟踪来验证它;还是宁愿让错误溜进原版源代码中,希望测试 者能够注意到这些错误以便你日后对其进行修改。选择在你。 要点: ���� 代码中不会自己生出错误来,错误是程序员编写新代码或者修改现有代码的产物。 如果你想发现代码中的错误,没有哪个办法比在对代码进行编译时对其进行逐条 跟踪更好。 ���� 虽然直观上你可能认为对代码进行走查会花费大量的时间,但这是不对的。刚开 始进行代码的走查确实要多花一点时间,但当这一切习惯成自然之后并不会多花 多少时间,你可以很快地走查一遍。 ���� 一定要对每一条代码路径进行逐条的跟踪,至少要跟踪一遍,尤其是对代码中的 错误处理部分。不要忘记 &&、|| 和?:这些运算符,它们每个都有两条代码路 径需要进行测试。 ���� 在某些情况下也许需要在汇编语言级对代码进行逐条的跟踪。尽管不必经常这样 做,但在必要的时候不要回避这种做法。 课题: 如果看看第一章中的练习,你就会发现它们所涉及的都是编译程序能够自动为你检查 出来的常见错误。重新考查一遍这些练习,这次问问自己:如果使用调试程序对相应的代 64 码进行逐条跟踪,你会漏掉那些错误吗? 课题: 看着六个月以来对你的程序报告出来的错误,确定假如你在编写程序时对其进行了逐 条跟踪的话,你会抓住多少个错误。 65 第5555章 糖果机界面 Microsoft 雇员从公司得到的一个好处是可以随便享用免费的软饮料,如香味塞尔查矿 泉水、牛奶加巧克力和软包装果汁等,管够。但讨厌的是,如果你想吃糖果,就得自己掏 腰包。所以有时馋了,我就溜到自动售货机那儿。一次,我塞进几个25美分的硬币,然后 按下选择键 4和5。但当售货机吐出茉莉香味的泡泡糖,而不是我想买的老奶奶牌花生黄油 饼干时我愣住了。自然,售货机没错,是我错了,45号是代表泡泡糖。看一眼售货机上花 生黄油饼干的小标记,进一步证实了我的错误。标记上写着花生黄油饼干,21号,45美分 。 这件事一直使我耿耿于怀,因为假如自动售货机的设计者多花 30秒钟考虑一下他们的 设计,就不会使我以及无数其他人遇到这种事情:买了不想买的东西。如果他们想过:“嗯 , 人们在向键盘塞钱时常常会想着45美分 ─── 我敢打赌,人们在向键盘塞钱时常常会把 价钱错当选择号输入给售货机。因此,我们应该选用字母键,不应该使用数字键,以避免 这种情况。 这样设计自动售货机并不会增加他的造价,也不会明显改变它的原有设计,但每当在 键盘上敲入 45美分时就会发现机器拒绝接受这种输入,提醒你敲入相应的字母代码。这种 设计会引导人们去做正确的事情。 当我们设计函数的界面时,所面临的是相同的问题。不幸的是,程序员不常考虑其他 程序员会怎样使用他所设计的函数。就像上面的糖果机界面一样,设计上的细微差别有可 能非常容易引起错误,也可能非常容易避免错误。光使设计出的函数没有错误并不够,还 必须使它们使用起来很安全。 很自然,getchar()会得到一个int 标准的 C库函数以及按照该模式编写的数以千计的其它函数,都有着上述糖果机式界 面,容易使用户犯错误。就说 getchar 函数吧,我们有充足的理由说这个函数的界面是有 风险的,其中最严重的问题是该函数的设计名鼓励程序员编写有错的代码。关于这一点, 还是让我们看看 Brian Kernighan 和Dennis Ritchie 在其“C 程序设计语言”一书中是怎 么说的吧: 考虑以下的代码: char c; c = getchar(); if( c == EOF ) …… 在不进行符号扩展的机器上,c总是正数因为它是 char 类型而 EOF却是负数,结果上 面的测试条件总会失败。为了避免这一点,必须用int 而不用 char 来保存 getchar 返回值 的变量。 66 这不是说明即使有经验的程序员也必须小心谨慎地使用函数吗?按照 getchar 这样的 函数名,将 c定义成字符类型是很自然的事情,这就是程序员会遇到这个错误的原因。但 getchar 非得如此有害不可吗?该函数要做的工作并不复杂,不过是从某个设备上读入一个 字符并返回可能的错误情况。 以下代码给出了另一个常见的问题: /* strdup ─── 为一个字符串建立副本 */ char* strdup( char* str ) { char* strNew; strNew = (char*)malloc( strlen(str)+1 ); strcpy( strNew, str ); return( strNew ); } 这个函数在一般的情况下都会工作得很好,除非内存空间耗尽引起了 malloc 的失败 。 这时,它返回一个不指向任何内存单元的 NULL 指针。但当目的指针strNew 为NULL 时,鬼 才知道 strcpy 会做些什么。strcpy 不论是失败,还是悄悄地冲掉内存中的信息都不是程序 员所期望的。 程序员之所以在使用 getchar 和malloc 时会遇到麻烦,是因为他们能够写出即使有缺 陷但表面上仍能工作的代码。直到几个星期甚至几个月后,才会碰到一连串不易发生的事 件而导致这些代码的失败,就像泰坦尼克号邮船沉没的灾难一样。getchar 和malloc 都不 能引导程序员写出正确的代码,都极易使程序员忽视错误情况。 getchar 和malloc 的问题在于他们返回的值不精确。有时他们返回所要的有效数据, 但另一些时候他们却返回不可思议的错误值。 假如getchar 不返回奇怪的 EOF值,把 c声明为字符类型就是正确的,程序员也就不 会遇到 Kernighan 和Ritchie 所说的错误。同样,假如 malloc 不返回好象是内存指针的 NULL,程序员就不会忘记对相应的错误进行处理。问题在于不怕这些函数返回错误,而怕 他们把错误隐藏在程序员极易忽视的正常返回值中。 如果我们重新设计 getchar,使他们分别返回两个不同输出怎么样?它可以根据是否成 功读入一个新的字符而返回TRUE 或FALSE,并把读入的字符返回到一个通过引用传递给他 的变量中: flag fGetChar(char* pch); 通过这一界面我们可以很自然地写出 chat ch; if( fGetChar( &ch )) ch中是下一个字符 else 碰到了 EOF,ch中是无用信息 67 这样一来,“char 还是int”的问题就解决了。任何程序员,不管多么幼稚都不太可能 偶然忘记测试它的错误返回值,比较一下 getchar 和fgetchar 的返回值,你看出 getchar 强调的是所返回的字符而fGetChar强调的是错误情况吗?如果你的目标是编写出无错的代 码,那么你认为应该强调哪一方面? 确实,这样一来在编写代码时就失去了下面的灵活性: putchar( getchar() ); 但你知道 getchar 的失败频度有多高吗?而几乎在所有的情况下,上面的代码都会产 生错误。 一些程序员可能会想:“确实fGetChar 的界面很安全,但却浪费了代码。因为在调用 它时,必须多传一个参数。另外如果程序员没有传递 &ch 而传递了 ch怎么办?当程序员 使用scanf 函数时,忘记相应的 &,长期以来一直是一个出错的根源。” 问得好。 编译程序生成代码的好坏其实取决于具体的编译程序,有的编译程序生成稍多的代码, 有的稍少,因为我们不必在每次调用该函数之后对函数的返回值和 EOF进行比较。不管稍 多也好稍少也罢,考虑到磁盘和存储器价格的暴跌,同时程序的复杂性及相应的错误率骤 增,代码大小上的细微差别也许并不值得顾虑。 在于第二个问题 ─── 比如为 fGetChar 传递了字符而不是字符指针,在采用了第 1 章建议的函数原型之后也用不着担心。如果给fGetChar 传递了非字符指针的其它参数,编 译程序会自动地产生一条错误信息向你指明所犯的错误。 事实上把相互排斥的输出组合到单一返回值中的做法是从汇编语言继承下来的。对于 汇编语言来说,只有有限的机器寄存器可以用来处理和传递数据。因此,在汇编语言环境 中使用一个寄存器返回两个相互排斥的值既有效率常常又是必需的。然而用 C编程是另一 回事,尽管C可以使我们“更接近于机器”,但这并不是说我们应该把它当作高级的汇编语 言来使用。 当设计函数的界面时,要选择使程序员第一次就能够写出正确代码的设计。不要使用 引起混淆的双重意义的返回值 ─── 每个输出应该只代表一种数据类型,要在设计中显 式地体现出这些要点,使用户很难忽视这些重要的细节。 只再多考虑一下 程序员总知道在什么时候把多个输出组合到单一的返回值中,所以实施上述的建议很 容易 ─── 只要不那么做就行了。然而在其它的情况下,程序员设计的界面可能很好, 但却象特洛伊木马一样会含有潜在的危险。观察一下改变内存块大小的以下代码: pbBuf = (byte*)realloc( pbBuf, sizeNew ); 要使用户不容易忽视错误情况 不要在正常地返回值中隐藏错误代码 68 if( pbBuf != NULL ) 使用初始化这个更大的缓冲区 你看出这段程序的错误了吗?如果没看出,也没什么关系 ─── 这个错误虽然很严 重,但却很微妙,如果不给出一点暗示很少人会发现它。所以我们给出一个提示:如果pbBuf 是指向将要改变其大小的内存块的唯一指针,那么当 realloc 的调用失败时会怎样?回答 是当realloc 返回时会把 NULL 填入pbBuf,冲掉这个指向原有内存块的唯一指针。简而言 之,上面的代码会产生内存块丢失的现象。 我们有多少次在要改变一个内存块的大小时,想到要把指向新内存块的指针存储到另 一个不同的变量中?我想就象在大街上捡到 25美分硬币一样,把新指针存储到不同的变量 中肯定也很少见。通常人们在改变一个内存块的大小时,会希望仍用原来的变量指向新的 内存块,这就是程序员常常掉进陷阱,写出上面代码的原因。 请注意,那些经常把错误值和有效数据混杂在一起返回的程序员,会习惯性地设计出 象realloc 这样的界面。理想情况下,realloc 应该返回一个错误代码,同时不管内存块扩 大与否都要再返回一个指向相应内存块的指针。这是两个独立的输出。让我们再看看 fResizeMemory,它是我们在第3章中介绍过的 realloc 的外壳函数。去掉了其中的调试代 码之后,它的形式如下: flag fResizeMemory( void** ppv, size _t sizeNew ) { byte** ppb = (byte**)ppv; byte* pbResize; pbResize = (byte*)realloc(*ppb, sizeNew); if( pbResize != NULL ) *ppb = pbResize; return( pbResize != NULL ); } 上面代码中的 if语句保证了原有指针绝不会被破坏。如果利用fResizeMemory重写本 节开始例子中的 realloc 代码,就会得到: if( fResizeMemory(&pbBuf, sizeNew) ) 使用初始化这个更大的缓冲区 如果fResizeMemory失败,pbBuf 并不会被置为 NULL。它仍会指向原来的内存块,正 如我们所期待的那样。所以我们可以问:“使用fResizeMemory,程序员有可能丢失内存块 吗?”我们还可以问:“程序员有可能会忘记处理fResizeMemory的错误情况吗?” 需要说明的另一个有趣问题是:自觉遵循本章给出的第一个建议(“不要在返回值中隐 藏错误”)的程序员。永远不会设计出象 realloc 这样的界面。他们一开始就会做出更象 fResizeMemory这样的设计,因而不会有realloc 的丢失内存块问题。本书的全部论点都建 筑在相互作用的基础上,它们会起到意想不到的效果。这就是一个例证。 然而,将函数的输出分开不总能使我们避免设计出隐藏陷阱的界面,我真希望对此给 69 出一点更好的忠告,但我认为找出这些暗藏陷阱的唯一办法是停下来思考所做的设计。这 样做的最佳途径是检查输入和输出的各种可能组合,寻找可能引起问题的副作用。我知道 这样做有时非常乏味,但要记住:这比以后再花时间回过来考虑这一问题要划算得多。最 坏的情况是略过这一步骤,那么天晓得会有多少个其他的程序员要对设计的不好的界面所 引起的错误进行跟踪追击了。只要想一想为了查出由 getchar,malloc 和realloc 这类界 面暗藏陷阱的函数所引起的错误,全世界的程序员要浪费掉多少时间,我们对所有按此模 式编写出其他函数简直无话可说。这真是太可怕了!其实只要在设计时多多考虑一点,就 可以完全避免这种现象。 单一功能的内存管理程序 虽然在第 3章我们花了许多时间去讨论 realloc 函数,但并没有涉及到它许多更令人 奇怪的方面。如果你抽出 C运行库手册,查出 realloc 的完整描述你就会发现一些类似于 下面的叙述: void* realloc( void* pv, size_t size ); realloc 改变先前已分配的内存块的大小,该内存块的原有内容从该块的开始位置到新 块和老块长度的最小长度之间得到保留。 ���� 如果该内存块的新长度小于老长度,realloc 释放该块尾部不再想要的内存空间, 返回的 pv不变。 ���� 如果该内存块的新长度大于老长度,扩大后的内存块有可能被分配到新的地址处, 该块的原有内容被拷贝到新的位置。返回的指针指向扩大后的内存块,并且该块 扩大部分的内容未经初始化。 ���� 如果满足不了扩大内存块的请求,realloc 返回NULL,当缩小内存块时,realloc 总会成功。 ���� 如果pv为NULL,那么 realloc 的作用相当于调用 malloc(size) ,并返回指向新 分配内存块的指针,或者在该请求无法满足时返回 NULL。 ���� 如果pv不是NULL,但新的块长为零,那么 realloc 的作用相当于调用 free(pv) 并且总是返回 NULL。 ���� 如果pv为NULL 且当前的内存块长为零,结果无定义 哎呀!realloc 真是一个实现得“面面俱到”的最好例子,它在一个函数中完成了所有 的内存管理工作。既然如此还要malloc 干什么?还要 free 干什么?realloc 全包了。 有几个很好的理由说明我们不应该这样设计函数。首先,这样的函数怎么能指望程序 员可以安全地使用呢?它包括了如此之多的细节,甚至有经验的程序员都不全知道。如果 你对此有疑问,不妨调查一下,算算有多少程序员知道给realloc 传递一个 NULL 指针相当 于调用了 malloc;又有多少程序员知道给 realloc 传递一个为零的块长效果与调用 free 要不遗余力地寻找并消除函数界面中的缺陷 70 相同。确实,这些功能都相当隐秘,所以我们可以问他们要避免错误就必须知道的一些问 题,如当调用 realloc 扩大一个内存块时会发生什么事情,或者他们是否知道此时相应的 内存块可能会被移动? realloc 的另一个问题是:我们知道传递给realloc 的可能是无用信息,但是因为其定 义如此通用使它很难防范无效的参数。如果错误地给它传递了NULL 指针,合法;如果错误 地给它传递了为零的块长也合法。更糟的是本想改变内存块的大小,却malloc 了一个新块 或free 掉了当前的内存块。如果实际上任何参数都合法,那么我们怎样用断言检查realloc 参数的有效性呢?不管你提供了什么样的参数,realloc 全能处理,甚至在极端的情况下也 是如此。一个极端是它free 内存块,另一个极端是它 malloc 内存块。这是截然相反的两 种功能。 公平地说,程序员通常不会坐下来思考:“我打算在一个函数中设计一个完整的子系 统 。”象realloc 这样的函数几乎总是产生于两个原因:一个是其多种功能是逐步演变而来 的;另一个是具体的实现为其增加了多余的功能(如 free 和malloc),为了包括这些所谓 的“幸运”功能,实现该函数的程序员扩展了相应的形式描述。 不管出于什么样的理由编写了多功能的函数,都要把它分解为不同的功能。对于 realloc来说,就是要分解出扩大内存块、缩小内存块、分配内存块和释放内存块。把realloc 分解为四个不同的函数,我们就能使错误检查的效果更好。例如,如果要缩小内存块,我 们知道相应的指针必须指向一个有效的内存块,而且新的块长必须小于(也可以等于)当 前的块长。除此之外任何东西都是错误的。利用单独的ShrinkMemory函数我们可以通过断 言来验证这些参数。 在某些情况下我们实际也许希望一个函数做多个事情。例如当调用 realloc 时,通常 我们知道新的块长是大于还是小于当前的块长?这要取决于具体的程序,但我通常不知道 (尽管我常常能够推算出这一信息)。对我来说,最好是有一个函数既能扩大内存块,又能 缩小内存块。这样可以避免在每次需要改变内存块大小时,必须写出if语句。这样虽说放 弃了对某些多余参数的检查,但可以得到不再需要写多个if语句(可能会搞乱程序)的补 偿。既然我们总是知道什么时候要分配内存,什么时候要释放内存,所以应该把这些功能 从realloc 中割裂出来,使它们构成单独的函数。第 3章介绍的 fNewMemory,FreeMemory 和fResizeMemroy就是这样三个定义良好的函数。 但是假如我正在编一个通常确实知道是要扩大还是缩小内存块的程序,那我一定会把 realloc 的扩大内存块和缩小内存块功能分解出来,再建立两个新的函数: flag fGrowMemory(void** ppv, size_t sizeLarger); void ShrinkMemory(void* pv, size_t sizeSmaller); 这样不仅可以使我能够对输入的指针和块长参数进行彻底的确认,而且调用 ShrinkMemory的风险也小,因为它保证相应的内存块总是被缩小而且绝对不会被移动。所 以不用写: ASSERT( sizeNew <= sizeofBlock(pb) );//确认pb和sizeNew (void)realloc(pb, sizeNew); //设缩小不会失败 71 只写: ShrinkMemory( pb, sizeNew ); 就可以完成相应的确认.使用ShrinkMemory代替realloc 的最简单理由是这样做会使相应 的代码显得格外清晰。使用了ShrinkMemory,就不再需要用注解说明它可能失败,不再需 要用void 的类型转换去掉返回值中无用的部分,也不再需要用验证 pb和sizeNew 的有效 性,因为ShrinkMemory会为我们做这一切。但是如果使用reallo,我甚至认为还应该使用 断言检查他返回的指针是否与pb完全相同。 模棱两可的输入 前面我们谈过为了避免使程序员产生混淆,应该把函数的各种输出明确地分别列出。 如果把这一建议也应用于函数的输入,自然就可以避免写出象 realloc 这样包罗万象的函 数。realloc 输入一个内存块指针参数,但有时却可以取不可思议的 NULL 值,结果使它成 了malloc 的仿造物。realloc 还有一个块长参数,但却可以取不可思议的零值,结果使它 成了free 的仿造物。这些不可思议的参数值看起来好象没有什么害处,其实损害了程序的 可理解性。我们可以看一下,下面的代码究竟是改变内存块的大小,还是分配或者释放内 存块呢? pbNew = realloc( pb, size ); 我们对此一无所知,它们都有可能,这完全取决于 pb和size 的取值。但是假如我们 知道pb的指向的是一个有效的内存块,size 是个合法的块长,立刻就知道它是改变内存块 的大小。正象明确的输出使人容易搞清函数的结果一样,明确的输入亦使人容易理解函数 要做的事情,它对必须阅读和理解别人程序的维护人员极有价值。 有时模棱两可的输入并不象在 realloc 情况下那么容易发现。让我们来看看下面的专 用字符串拷贝例程。它从strFrom 开始取 size 个字符,并把它们存储到从strTo 开始的字 符串中: char* CopySubStr( char* strTo, char* strFrom, size_t size ) { char* strStart = strTo; while(size-- > 0) strTo++ = strFrom++; *strTo=‘\0’; return(strStart); } CopySubStr 类似于标准的函数 strcpy,所不同的是它保证起始于strTo 的字符串确定 不要编写多种功能集于一身的函数 为了对参数进行更强的确认,要编写功能单一的函数 72 是个以零结尾的 C字符串。该函数的典型用法是从大字符串中抽取子串。例如从一个组合 串中抽出星期几: static char* strDayNames = “SunMonTueWedThuFriSat”; …… ASSERT(day>=0 && day<=6); CopySubStr(strDay, strDayNames+day*3, 3); 现在我们明白了 CopySubStr 的工作方式,但你看得出该函数的输入有问题吗?只要你 试着为该函数写断言去确认它的参数,就很容易发现这一问题。参数 strTo 和strFrom 的 断言可以是: ASSERT( strTo != NULL && strFrom != NULL ); 但我们怎样确认 size 参数呢?size 为零合法吗?size 大于strFrom 的长度怎么办? 如果查看该函数的实现,我们就会看到这两种情况都可以得到处理。如果在进入该函数时 size 等于零,while 循环就不会执行;如果 size 大于strFrom,while 循环将把 strFrom 整个连同其终止符一道拷贝到strTo 中。为了说明这点,必需在函数的注解中加以说明: /* CopySubStr ─── 从字符串中抽取子串 * 把strFrom 的前size 个字符转储到从 strTo * 开始的字符串中。如果 strFtom 中的字符数小 * 于“size”,那么strFrom 中的所有字符都被拷 * 贝到strTo。如果 size 等于零,strTo 被设 * 置成空字符串. */ char* CopySubStr(char* strTo, char* strFrom, size_t size) { …… 听起来好象很熟悉,不是吗?确实如此,类似的函数就象灯泡上的灰尘一样司空见惯。 但这是处理其 size 输入参数的最好方式吗?回答是“不”,至少从编写无错代码的观点来 看是“不”。 例如,假定程序员在调用CopySubStr 时错把“3”输成了“33”: CopySubStr( strDay, strDayNames+day*3, 33 ); 这确实是个错误,但根据 CopySubStr 的定义用 33调用它却完全合法。是的,在交出 相应的代码之前或许也可能抓住这个错误,但却没法自动地发现它,必须由人查出它。不 要忘了从靠近错误的断言开始查错,要比从错误的输出开始查错速度更快。 从“无错”的观点,如果函数的参数越界或者无意义,那么即使能被智能地处理,仍 然应该被视为非法的输入。因为悄悄地接受奇怪的输入值,会隐藏而不是暴露错误。在某 种意义上,防错性程序设计应该允许“无拘无束”的输入。为了提高程序的健壮性,要在 代码中包括相应的防错代码,而不是禁止有问题的输入: /* CopySubStr ─── 从字符串中抽取子串 73 * 把strFrom 的前“size”个字符转储到从 strTo * 开始的字符串中,在 strFrom 中,至少必须要 * 有“size”个字符。 */ char* CopySubStr(char strTo, charstrFrom, size_t size) { char* strStart = strTo; ASSERT( strTo != NULL && strFrom != NULL ); ASSERT( size <= strlen(strFrom) ); while( size-- > 0 ) strTo++ = strFrom++; *strTo=‘\0’; reurn( strStart ); } 有时允许函数接受无意义的参数 ─── 如大小为 0的参数,是值得的,因为这样可 以免除在调用时进行不必要测试。例如,因为 memset 允许其 size 参数为零,所以下面程 序中的 if语句是不必要的: if( strlen != 0 )/* 用空格填充 str */ memset( str, chSpace, strlen(str) ); 在允许大小为 0的参数时要特别小心。程序员处理大小(或计数)为 O参数通常是因 为他们能够处理而不是应该处理。如果所编函数有大小参数,那么并不一定非得对大小为0 进行处理,而要问自己:“程序员用大小为 0的参数调用这个函数的额度是多少?”如果根 本或者几乎不会这么调用,那就不要对大小为0进行处理,而要加上相应的断言。要记住, 消除限制就是消除捕获相应错误的机会,所以一个良好的准则是,一开始就要为函数的输 入选择严格的定义,并最大限度地利用断言。这样,如果过后发现某个限制过于苛刻,可 以把它去掉而不至于影响到程序的其它部分。 第3章在FreeMemory中包含的 NULL 指针检查,用到的就是这一原理。因为我从来不 会用NULL 指针调用 FreeMemory,所以对我来说加强对这一错误的检查就十分重要。对此可 能会有不同的看法。这里并没有对错之分,但要保证所做的是自觉的选择,而不仅仅是一 种随便的习惯。 现在不要让我失败 Microsoft 公司招募雇员的政策,是在面试时就一些技术问题向候选者提问。对于程序 员来说,就是给出一些编程问题。我过去常常从要求编写标准的 tolower 函数开始考核候 不要模棱两可,要明确地定义函数的参数 74 选者。我递给候选者一个 ASCII 表,问候选者“怎样写一个函数把一个大写字母转换成对 应的小写字母?”我有意对如何处理字母以外的其它符号和小写字母说得很含糊,主要是 想看看他们会怎样处理这些情况。这些符号在返回时会保持不变吗?会用断言对这些符号 进行检查吗?它们会不会被忽视?半数以上的程序员写出的函数会是下面这样: char tolower(char ch) { return( ch + ‘a’-‘A’); } 这种写法在ch是大写字母的情况下没问题,但如果 ch是其他的符号就会出毛病。当 我向候选者指出这一情况时,有时他们会说:“我假定 ch必须是大写字母。如果它不是大 写字母我可以将其不变地返回。”这种解法很合理,但其它的解法就未必。更常见的是那些 未中选的候选者会说:“我没有考虑到这个问题。我可以解决这个问题,当 ch不是大写字 母时,令它返回一个错误代码。”有时他们会使 tolower 返回NULL,有时会返回空字符。但 出于某种原因,无疑-1会占上风: char tolower(char ch) { if( ch >= ‘A’&& ch <= ‘Z’) return( ch + ‘a’-‘A’); else return(-1); } 这些解法都违背了我们前面给出的建议,因为他们把出错值同真正的数据混在了一起。 但真正的问题并不在于候选者没能注意到他们也许从未听说过的建议,而是他们在大可不 必的情况下返回了错误代码。 这提出了另一个问题:如果函数返回错误代码,那么该函数的每个调用者都必须对该 错误进行处理。如果 tolower 可能返回-1,那么就不能简单地这么写: ch = tolower(ch); 而必须这么写: int chNew; /* 为了容纳-1,它必须是 int 类型 */ if( (chNew=tolower(ch)) != -1 ) ch = chNew; 这一点与上一节有关。如果你意识到在每次调用时都必须这样使用 tolower 就会明白 让它返回一个错误代码也许并不是定义这个函数的最佳方式。 如果发现自己在设计函数时要返回一个错误代码,那么要先停下来问自己:是否还有 其它的设计方法可以不用返回该错误情况,因此,不要将 tolower 定义成返回大写字母对 应的小写字母,而要使其“如果ch是大写字母,就返回它对应的小写字母;否则,将其不 改变地返回。” 75 如果发现无法消除错误的情况,那么可以考虑干脆不允许这些有问题的情况出现,即 用断言对函数的输入进行验证。如果把这一建议应用于 tolower。就会得到: char tolower(char ch) { ASSERT( ch >= ‘A’&& ch <= ‘Z’); return( ch + ‘a’-‘A’); } 这两种方法都可以使函数的调用者不必进行运行时的错误核查,这意味着产生的代码 更小并且错误更少。 看出言外之意 站在调用者的立场上,我并没有过分强调检查所设计的函数界面有多么重要。考虑到 函数只定义一次,但在程序中的许多地方都要调用它,就会明白不检查函数的调用方式是 很愚蠢的。我们见过的getchar,realloc 和蹩脚的 tolower 例子都说明了这一点,它们都 导致了相应调用代码的复杂化。然而,并非只有把输出都合在一起和返回不必要的错误代 码才会导致复杂的代码。有时引起代码复杂化的原因完全由于粗心而忽视了相应的函数调 用“读’的效果。 例如假定在改进所编应用程序的磁盘处理部分时,碰到了一个写成下面这样的文件搜 索调用: if( fseek(fpDocument, offset, l) == 0 ) 你可以说得出它将进行某种搜索,也可以看到相应的错误情况得到了处理,但这个调 用的可读程度究竟如何呢?它进行的是哪种类型的搜索(从文件开始位置、从文件的当前 位置、还是从文件的结束位置开始搜索)?如果该调用返回0值,这究竟表明的是成功还 是失败? 反过来,如果程序员使用预定义的名字来进行相应的调用; #include /* 引入SEEK_CUR 的定义 */ #define ERR_NONE 0 …… if( fseek(fpDocument, offset, SEEK_CUR) == ERR_NONE ) …… 这样不是使相应的调用更清晰吗了?确实如此。但这并不是使人感到惊奇的新鲜事, 程序员在几十年前就已经知道应该在程序中避免使用莫名其妙的数字。有名的常量不仅可 以使代码更可读,而且使代码更可移植(考虑到在其他的系统上,SEEK_CUR 可能不是 1)。 我要指出的是,虽然许多程序员把 NULL、TRUE 和FALSE 当作有名的常量来使用。但它 编写函数使其在给定有效的输入情况下不会失败 76 们并不是有名的常量,只不过是莫明其妙数字的一种正文表示。例如,下面的调用完成什 么工作? UnsignedToStr(u, str, TRUE); UnsignedToStr(u, str, FALSE); 你可能会猜出这些调用是用来将一个无符号的值转换成其正文表示。但上面的布尔参 数对这一转换起什么作用呢?如果我把这些调用写成下面这样,是不是会更清楚一些: #define BASE10 1 #define BASE16 0 ……… UnsignedToStr(u, str, BASE10); UnsignedToStr(u, str, BASE16); 当程序员坐下来编写这种函数时,其布尔参数值似乎非常清楚。程序员先做函数描述, 然后做函数的实现: /* UnsignedToStr * 这一函数将一个无符号的值转换成其对应 * 的正文表示,如果 fDecimal 为TRUE,u被转 * 换成十进制表示;否则,它被转换成 * 十六进制表示。 */ void UnsignedToStr(unsigned u, char *strResult, flag fDecimal) { ……… 还有什么比这更清楚的吗? 但事实上,布尔参数常常表明设计者对其设计并没有深思熟虑。相应的函数可以做两 种不同的事情,用布尔参数来选择想要做的事情;也可以很灵活地不只限于两种不同的功 能,但程序员使用布尔值来指明唯一感兴趣的两种情况。这两种可能常常都正确。 例如,如果我们把 UnsignedToStr看作一个只做两种不同事情的函数,就应该把它拆 成下面两个函数: void UnsignedToDecStr(unsigned u, char* str); void UnsignedToHexStr(unsigned u, char* str); 但这种情况下,一种更好的解决办法是把它的布尔参数改成通用的参数,从而使 UnsignedToStr更加灵活。这样可以使程序员不是传递 TRUE 或FALSE,而是相应的转换基 数: void UnsignedToStr(unsigned u, char* str, unsigned base); 这样我们可以得到清晰的灵活设计,它使相应的调用代码容易理解,同时还增加了该 函数的功能。 这一建议似乎与我们早先说过的“要严格地定义函数的参数”互相矛盾 ─── 我们 77 把具体的 TRUE 或FALSE 输入变成了一般的输入,函数的大部分可能取值都没有用到。但要 记住,虽然参数变得一般了,但我们总是可以在函数中包括断言来检查 base 的取值永远只 能是10或者16。这样如果以后决定还需要进行二进制或者八进制的转换,可以放松这一断 言以便程序员传递等于 2和8的基数值。 比起我所见过那些参数取值是TRUE、FALSE、2和-l的函数,这种做法要好得多。因为 布尔参数的值域不容易扩充,所以要么你得继续忍受这些无意义的参数值,要么就得修改 现有的每个调用语句。 向人们提示险情 作为防范错误的最后一个措施,我们可以在函数中写上相应的注解来强调它可能产生 的险情,并给出函数的正确使用方式,这样可以帮助其他的程序员在使用该函数时不致出 错。例如,getchar 的注解不应该这样: /* getchar ─── 该函数与 getc(stdin)相同 */ int getchar(void) …… 它对程序员真的起不到什么帮助作用,我们应该把它写成: /* getchar ─── 等价于 getc(stdin) * getchar 从stdin 返回了一个字符,当发生了 * 错误时,它返回“int”EOF。该函数的一种 * 典型用法是: * int ch; // 为了容纳 EOF,ch必须是 int 类型 * if( (ch=getchar()) != EOF ) * 成功 ─── ch是下一个字符 * else * 失败 ─── ferror(stdin)将给出错误的类型 */ int getchar(void) …… 如果把这两种描述都交给初学 C库函数的程序员,你认为对于使用getchar 时会出现 的险情哪种描述会给程序员留下比较深的印象?当程序员第一次使用 getchar 时,这两种 描述会产生什么样的差别?你认为他会编写新的代码,还是会从你做的注解中复制下典型 用法给出的例子,然后再根据需要对其进行修改? 按照这种方式对函数进行注解的另一个积极作用,是它可以迫使不够谨慎的程序员停 下来考虑别的程序员怎样才能使用他们编出的函数。如果程序员设计的函数界面很笨,在 使程序在调用点明了易懂;要避免布尔参数 78 编写典型用法时,他就应该注意到界面的笨拙。即使他没有注意到界面的问题,只要典型 用法给出的例子详尽正确,也没有什么关系。例如,倘若 realloc 被注解成如下形式,就 不会引起那么多的使用问题了: /* realloc( pv, size ) *…… * 该函数的一种典型用法是. * void* pvNew;// 用来保护 pv,以防 realloc 失败 * pvNew = realloc( pv, sizeNew ); * if( pvNew != NULL ) *{ * 成功 ─── 修改pv * pv = pvNew; *} * else * 失败 ─── 不要用值为 NULL 的pvNew 冲掉pv */ void realloc( void* pv, size_t size ) 通过复制这样的示例,即使不够谨慎的程序员也很可能会避免本章开始所讲的内存丢 失问题。例子虽然并不能对所有程序员都起作用,但就象药品包装上的警告信息一样,它 会对某些人产生影响。而且从任何一点看,这样做都有所帮助。 然而不要用例子来代替编写良好的界面。 getchar 和realloc 的界面都使用户容易出 错,这些害处都应该予以消除而不仅仅是给予说明。 小结 设计能够抵御错误的界面并不困难,但这确实需要多加考虑并且愿意放弃根深蒂固的 编码习惯。这一章给出的建议只需简单地改变函数的界面,就可以使程序员编写出正确的 代码,而不必过多地考虑其它部分的代码。本章贯穿始终的关键慨念是“尽可能地使一切 清晰明了”。如果程序员理解并记住了每个细节,也许就不会犯错误 ─── 他们之所以会 犯错误,是因为他们忘记了或者从来就不知道这些重要的内容。因此要设计能够抵御错误 的界面,使程序员很难无意地忽视相应的细节。 要点: ���� 最容易使用和理解的函数界面,是其中每个输入和输出参数都只代表一种类型数 据的界面。把错误值和其它的专用值混在函数的输入和输出参数中,只会搞乱函 编写注解突出可能的异常情况 79 数的界面。 ���� 设计函数的界面迫使程序员考虑所有重要细节(如错误情况的处理),不要使程序 员能够很容易地忽视或者忘记有关的细节。 ���� 老要想到程序员调用所编函数的方式,找出可能使程序员无意间引入错误的界面 缺陷。尤其重要的是要争取编出永远成功的函数,使调用者不必进行相应的错误 处理。 ���� 为了增加程序的可理解性从而减少错误,要保证所编函数的调用能够被必须阅读 这些调用的程序员所理解。莫明其妙的数字和布尔参数都与这一目标背道而驰, 因此应该予以消除。 ���� 分解多功能的函数。取更专门的函数名(如ShrinkMemory而不是 realloc)不仅 可以增进人们对程序的理解,而且使我们可以采用更加严格的断言自动地检查出 调用错误。 ���� 为了向程序员展示出所编函数的适当调用方法,要在函数的界面中通过注解的方 式详细说明。要强调危险的方面。 练习: 1) 本章开始的函数strdup为一个字符串分配一个副本,但如果它失败了则返回NULL。 更能抵御错误的 strdup 界面是什么? 2) 我说过布尔输人参数的存在,常常表示可能还有更好的函数界面。但对于布尔输 出参数怎样呢?例如,如果fGetChar 失败,它返回 FALSE 并要求程序员调用 ferror(stdin)来确定出错的原因,那么更好的getchar 界面会是什么? 3) 为什么 ANSI 的strncpy 函数必然会使轻率的程序员犯错误? 4) 如果读者熟悉C++的inline 指明符,说说它对于编写能够抵御错误的函数界面的 价值。 5) C++采用了类似于 pascal 中VAR参数的 & 引用参数。因此,不是这样写: flag fGetChar(char* pch); /* 原型 */ …… if( fGetChar(&ch) ) ch含有新的字符 …… 可以写: flag fGetChar(char &ch);/* 原型 */ …… if( fGetChar(ch) )/* 自动的传递 &ch */ ch含有新的字符 …… 从表面上看,这一加强似乎不错,因为程序员不可能“忘记”正规C中要求 的显式&。但为什么使用这一特征会产生容易出错的界面,而不是能够抵御错误 80 的界面? 6) 标准的 strCmp 函数取两个字符串并对它们进行逐字符的比较。如果这两个字符串 相等,strcmp 返回0;如果第一个字符串小于第二个,它返回负数;如果第一个 字符串大于第二个,它返回正数。因此当调用 strcmp 时,相应的代码通常有下面 的形式: if( strcmp(str1,str2) re1_op 0 ) …… 这里rel_op 是 == 、!= 、> 、>= 、< 或 <= 之一,尽管这样也可以完成 相应的比较,但对于不熟悉 strcmp 函数的人来说它毫无意义。为字符串比较设计 至少两个其它的函数界面,所设计的界面应该更能抵御错误,更加可读。 课题: 检查一个标准的 C库函数对相应的界面重新设计使其更能抵御错误。为了使重新设计 的函数更加明了易懂,给这些函数改变名字的利弊是什么? 课题: 在大量的代码中搜寻所使用的memset、memmove、memcpy 和strn 系列函数(如strncpy 等)。在所找到的调用中,有多少个要求对应的函数接受为零的计数值?所得到的这种便利 足以说明允许函数接受零计数值是合理的吗? 81 第6666章 风险事业 假如将一程序员置于悬崖边,给他绳子和滑翔机,他会怎样从悬崖上下来呢?是沿绳 子爬下来呢?还是乘滑翔机呢?还是干脆直接跳下来呢?是沿绳子爬下来还是使用滑翔机 我们说不太准,但可以肯定,他不会跳下来,因为那太危险了。可是当程序员有几种可能 的实现方案时,他们却经常只考虑空间和速度,而完全忽视了风险性。如果程序员处于这 样的悬崖边而又忽视了风险性,只考虑选择到达崖底最有效的途径的话.结果又将如何呢? 程序员忽视风险性,至少有两个原因: 一是因为他们盲目地认为,不管他们怎样实现编码,都不会有错误。没有任何程序员 会 说 :“我准备编写快速排序程序,并打算在程序中有三个错误。”程序员并没有打算出错, 而后来错误出现了,他们也并不特别吃惊。 我认为程序员忽视风险性的第二个原因也是主要原因:在于从来没有人教他们这样去 问 问 题 :“该设计有多大的风险性?该实现有多大的风险性?有没有更安全的方法来写这个 表达式?能否测试一下该设计?”要想问出这些问题,首先必须从思想上放弃这样的观点: 不管作出哪种选择,最后总能得到无错代码。即使该观点是正确的,可是什么时候能得到 无错代码呢?是由于使用安全的编码,在几天或几周之后就可以得到无错代码呢?还是由 于忽视了风险性,出现很多错误而需要经过数月的调试和修改之后才能得到无错代码呢? 因此本章将讨论在某些普通的编码实践中所存在的一些风险,以及如何做才能减少甚 至消除这些风险。 long的位域有多长 美国国家标准协会(ANSI)委员会查看了运行在众多平台上的各种 C语言。他们发现: 尽管人们认为 C语言是可移植语言,但实际上并非如此。不仅不同系统的标准库不同,而 且预处理程序和语言本身在许多重要方面也不相同。ANSI 委员会对大多数问题进行了标准 化,使程序员可以写出可移植的代码,但是,ANSI 标准忽视了一个非常重要的方面,它没 有定义象 char、int 和long 这样一些内部数据类型。ANSI 标准将这些重要的实现细节留给 编译程序的研制者来决定,标准本身并没有具体定义这些类型。 例如,某一个 ANSI 标准的编泽程序可能具有32位的int 和char。它们在缺省状态下 是有符号的;而另一个 ANSI 标准的编译程序可能有 16位的int 和char,缺省状态下是无 符号的。尽管如此不同,然而,这两个编译程序可能都严格附合 ANSI 标准。 请看下面的代码: char ch; …… ch = 0xff; if(ch == 0xff ) …… 82 我的问题是 if语句中的表达式求值为真还是为假呢? 正确的回答是:不知道。因为这完全依赖于编译程序。如果在缺省时字符是无符号的, 则表达式肯定为真。但对于字符为有符号的编译程序而言,如80x86 和680x0 的编译程序, 则每次测试都会失败,这是由C语言的扩充规则决定的。 在上面的代码中,字符ch与整型数 0xff 进行比较。根据 C语言的扩充规则,编译程 序必须首先将 ch转换为整型 int,两者类型一致后再进行比较。关键在于:如果ch是有符 号的,则在转换中要进行符号位扩充,其值将从 0xff 扩充为 0xffff(假设 int 是16位 )。 这就是测试失败的原因。 上面是为证明作者观点而设计的例子。读者可能会说,那不是一段有实际意义的代码。 但是,在下面的常用代码中也存在着同样的问题。 char * pch; …… if (*pch == 0xff ) …… 在该定义中,char 类型不唯一,位域不正确。例如,以下位域的值域是多少? int reg:3; 仍然是不知道。即使将reg 定义为整型 int,这就隐含了它是有符号的,但根据所使用 的不同编译程序,reg 既可以是有符号的,也可以是无符号的。如果要使reg 明确地成为有 符号的整型或无符号的整型,必须使用singned int 或unsigned int。 short,int,long 究竟有多大,ANSI 标准没有给出。而将其留给编译程序的研制者来 决定。 ANSI 委员会成员并非对错误定义数据类型的问题视而不见。实际上,他们考查了大量 的C语言实现并得出结论:由于各编译程序之间的类型定义是如此之不同,以至于定义严 格的标准将会使大量现存代码无效。而这恰恰违背了他们的一个指导原则:“现存代码是非 常重要的”。他们的目的并不是要建立更好的语言,而是给现存的语言制定标准,只要有可 能,他们就要保护现存的代码。 对类型进行约束也将违背委员会的另外一个指导原则:“保持C语言的活力,即使不能 保证它具有可移植性,也要使其速度快。”因此,如果实现者感到有符号字符对于给定的机 器来说更有效、那么就使用有符号字符。同样,根据硬件实现者可以将int 选择为 16位 、32 位或别的位数、这就是说,在缺省状态下,用户并不知道是具有有符号的位域还是无符号 的位域。 内部类型在其规格说明中存在着一个不足之处,在今后升级或改变编译程序时、或移 到新的目标环境时、或与其他单位共享代码时、甚至在改变工作并发现所用编译程序的规 则全部改变时,这个不足就会体现出来。 这并不意味着用户不能安全使用内部类型、只要用户不对ANSI 标准没有明确说明的类 型再作假设。用户就可以安全使用内部类型。 例如,你可以用易变的char 数据类型,只要它能提供0到127 的值,这是有符号字符 83 和无符号字符域的交集。所以,当代码写为: char * strcpy( char * pchTo, char * pchFrom ) { char *pchStart = pchTo; while(( *pchTo ++ = *pchFrom++ )!=’\0’) NULL; Return( pchStart ); } 时,它在任何编译程序上都可以工作,因为没有对域作假定。而以下代码就不可以: /* strcmp -- 比较两个字符串 * * 如果strLeft<strRight,返回一个负值 * 如果strLeft==strRight,返回 0 * 如果strLeft>strRight,返回一个正值 */ int strcmp( const char *strLeft, const char *strRight ) { for( NULL; *strLeft == *strRight; strLeft ++ ,strRight ++ ) { if( strLeft == ‘\0’)/* 是否与最后的结束字符相匹配?*/ return(0); } return ((*strLeft<*strRight)?-1:1 ); } 这段代码,由于最后一行的比较操作而失去了可移植性。只要用户使用了“<”操作 符或其它要用有符号信息的操作符,就迫使编译程序产生不可移植的代码。修改strcmp 很 容易,只须声明 strLeft 和strRight 为无符号字符指针,或直接将其填在比较式中: (*( unsigned char *) strLeft < *(unsigned char *)strRight ) 记住一个原则不要在表达式中使用“简单的”字符。由于位域也有同样的问题,因此 也有一个类似的原则:任何时候都不要使用“简单的”位域。 如果仔细阅读分析 ANSI 标准,就可以导出可移植类型集的定义。这些可移植类型可在 多个编译程序上以多种数制工作。 char 0 to 127 signed char -l27 to127(not -l28) unsigned char 0 to 255 大小未定,但不小于 8个字位 84 short -32767 to 32767(not -32768) signed short -32767 to 32767 unsigned short 0 to 65535 大小未定,但不小于 16个字位 long -2147483647 to 2147483647 (not –2147483648) signed long -2147483647 to 2147483647 unsigned long 0 to 4294967295 大小未定,但不小于 32个字位 int i:n 0 to 2^(n-1)-1 signed int i:n -(2^(n-1)-1) to 2^(n-1)-1 unsigned int i:n 0 to 2^(n)-1 大小未定,至少有n个字位 可移植类型最值得注意之处是:它们只考虑了三种最通用的数制:壹的补码、贰的补 码和有符号的数值。 现在我们不必为写可移植代码担心了。处理该问题就象人们为自己厨房操作台挑选贴 面瓷砖一样,大多数人都愿意挑选自己喜欢的,将来的房屋买主也能容忍的贴面瓷砖,这 样到时候就不必为了卖房屋来拆除、更换贴面瓷砖了。读者也应以同样的方式来考虑可移 植代码,在大多数情况下,写可移植性代码与写非可移植性代码一样容易。为了避免将来 的重复劳动,最好写可移植代码。 尽量用 可移植的 数据类 型 尽量用 可移植的 数据类 型 尽量用 可移植的 数据类 型 尽量用 可移植的 数据类 型 有些程序员可能认为 使用可移植的类型比使用 有些程序员可能认为 使用可移植的类型比使用 有些程序员可能认为 使用可移植的类型比使用 有些程序员可能认为 使用可移植的类型比使用 ““““自然的 自然的 自然的 自然的 ””””类型效率低。例如, 假 定 类型效率低。例如, 假 定 类型效率低。例如, 假 定 类型效率低。例如, 假 定 int 类型对 目标硬件其 物理字长是最 有效的。这 就意味着这 种 类型对 目标硬件其 物理字长是最 有效的。这 就意味着这 种 类型对 目标硬件其 物理字长是最 有效的。这 就意味着这 种 类型对 目标硬件其 物理字长是最 有效的。这 就意味着这 种 ““““自然的 自然的 自然的 自然的 ””””位数可 能大 于 位数可 能大 于 位数可 能大 于 位数可 能大 于 16位,位,位,位, 所保持的值可能大 于 所保持的值可能大 于 所保持的值可能大 于 所保持的值可能大 于 32767。。。。 现在假定用户的编译 程序使用的 是 现在假定用户的编译 程序使用的 是 现在假定用户的编译 程序使用的 是 现在假定用户的编译 程序使用的 是 32位的位的位的位的int, 且题目要 求 , 且题目要 求 , 且题目要 求 , 且题目要 求 0至至至至40,000 的值域。那的值域。那的值域。那的值域。那么,么,么,么, 是考虑 到机器可以 在 是考虑 到机器可以 在 是考虑 到机器可以 在 是考虑 到机器可以 在 int 内有效 地处 理 内有效 地处 理 内有效 地处 理 内有效 地处 理 40,,,,000 个值而 使 用 个值而 使 用 个值而 使 用 个值而 使 用 int 呢,还 是坚持使用可 移植 呢,还 是坚持使用可 移植 呢,还 是坚持使用可 移植 呢,还 是坚持使用可 移植 类型,而 用 类型,而 用 类型,而 用 类型,而 用 long 代替代替代替代替int 呢? 呢? 呢? 呢? 答案是 如果机器使 用的 是 答案是 如果机器使 用的 是 答案是 如果机器使 用的 是 答案是 如果机器使 用的 是 32位位位位int.那么 也可以使 用 .那么 也可以使 用 .那么 也可以使 用 .那么 也可以使 用 32位位位位long,这两 者产生的代码 ,这两 者产生的代码 ,这两 者产生的代码 ,这两 者产生的代码 即使不相同也很相似(事实证明是如此) , 因此要使 用 即使不相同也很相似(事实证明是如此) , 因此要使 用 即使不相同也很相似(事实证明是如此) , 因此要使 用 即使不相同也很相似(事实证明是如此) , 因此要使 用 long。 用户即便担心在将来必须支 。 用户即便担心在将来必须支 。 用户即便担心在将来必须支 。 用户即便担心在将来必须支 持持持持 的机器上使 用 的机器上使 用 的机器上使 用 的机器上使 用 long 效率可能会低一些, 也应该坚持使用可移植类型。 效率可能会低一些, 也应该坚持使用可移植类型。 效率可能会低一些, 也应该坚持使用可移植类型。 效率可能会低一些, 也应该坚持使用可移植类型。 使用有严格定义的数据类型 85 数据上溢或下溢 有这样一些代码,表面看起来很正确。但是由于实现上存在着微妙的问题,执行却失 败了,这是最严重的错误。“简单字符”就是这种性质的错误。下面的代码也具有这样的错 误,这段代码用作初始化标准tolower 宏的查寻表。 char chToLower[ UCHAR_MAX+1 ]; void BuildToLowerTable( void )/* ASCII 版本*/ { unsigned char ch; /* 首先将每个字符置为它自己 */ for (ch=0; ch <= UCHAR_MAX;ch++) chToLower[ch] = ch; /* 现将小写字母放进大写字母的槽子里 */ for( ch = ‘A’; ch <= ‘Z’; ch++ ) chToLower[ch] = ch +’a’–‘A’; } …… #define tolower(ch)(chToLower[(unsigned char)(ch)]) 尽管代码看上去很可靠,实际上 BuildToLowerTable 很可能使系统挂起来。看一下第 一个循环,什么时候 ch大于 UCHAR_MAX 呢?如果你认为“从来也不会”,那就对了。如果 你不这样认为,请看下面的解释。 假设ch等于UCHAR_MAX,那么循环语句理应执行最后一次了。但是就在最后测试之前, ch增加为 UCHAR_MAX+1,这将引起 ch上溢为 0。因此,ch将总是小于等于 UCHAR_MAX,机 器将进行无限的循环。 通过查看代码,这个问题还不明显吗? 变量也可能下溢,那将会造成同样的困境。下面是实现 memchr 函数的一段代码。它的 功能是通过查寻存储块,来找到第一次出现的某个字符。如果在存储块中找到了该字符, 则返问指向该字符的指针,否则,返回空指针。象上面的BuildToLowerTable一样,memchr 的代码看上去似乎是正确的,实际上却是错误的。 void * memchr( void *pv, unsigned char ch, size_t size ) { unsigned char *pch = (unsigned char *) pv; while( -- size >=0 ) { if( *pch == ch ) return (pch ); pch++; 86 } return( NULL ); } 循环什么时候终止?只有当 size 小于0时,循环才会终止。可是 size 会小于 0吗? 不会,因为size 是无符号值,当它为 0时,表达式--size 将使其下溢而成为类型size_t 定义的最大无符号位。 这种下溢错误比 BuldToLowerTable中的错误更严重。假如,memchr 在存储块中找到了 字符,它将正确地工作,即使没有找到字符,它也不致使系统悬挂起来.而坚持查下去, 直到在某处找到了这个字符并返回指向该字符的指针为止。然而,在某些应用中也可能产 生非常严重的错误。 我们希望编译程序能对“简单字符”错误和上面两种错误发出警告。但是几乎没有任 何编译程序对这些问题给出警告。因此,在编译程序的销售商说有更好的编译代码生成器 之前,程序员将依靠自已来发现上溢和下溢错误。 但是,如果用户按照本书第 4章的建议逐条跟踪代码,那么这三种错误就都能发现。 用户将会发现,*pch 在与0xff 比较之前已经转换为 0xffff,ch上溢为 0,size 下溢为 0xffff。由于这些错误太微妙,可能用户花几小时时间仔细阅读代码,也不会发现上溢错, 但是如果查看在调试状态下该程序的数据流,就能很容易地发现这些错误。 量体裁衣 在下面的代码中还可以看到另一个常见的上溢例子,它将整数转换为相应的 ASCII 表 示: void IntToStr( int i, char *str ) { char *strDigits; if( i < 0 ) { *str++ = ’-’; i = -i; /* 把i变成正值 */ } /* 反序导出每一位数值 */ strDigits = str; do *str++ = i%10 + ’0’; while( (i/=10) > 0 ); 经常反问:“这个变量表达式会上溢或下溢吗?” 87 *str=’/0’; ReverseStr( strDigits );/* 将数字的次序转为正序 */ } 若该代码在二进制补码机器上运行,当i等于最小的负数(例如,16位机器的-32768) 时就会出现问题。原因在于表达式i= -i中的-i上;即上溢超出了int 类型的范围。然而, 真正的错误在于程序员实现代码的方式上:程序员没有完全按照他自己的设计来实现代码, 而只是近似实现了他的设计。 在设计中要求:“如果i是负的,加入一个负号,然后将 i的无符号数值部分转换成 ASCII。”而上面的代码并没有这么做。它实际执行了:“如果i是负的,加入一个负号,然 后将i的正值也就是带符号的数值部分转换为ASCII。”就是这个有符号的数字引起了所有 的麻烦。如果完全根据算法并使用无符号数,代码会执行得很好。可以将上述代码分为两 个函数,这样做十分有用。 void IntToStr( int i, char *str ) { if( i < 0 ) { *str++ = ‘-‘; i = -i; } UnsToStr(( unsigned )i, str ); } void UnsToStr( unsigned u, char *str ) { char * strStart = str; do *str++ = (u%10) + ’0’; while(( u/=10 )>0 ); *str=’\0’; ReverseStr( strStart ); } 在上面的代码中,i也要取负,这与前面的例子相同,为什么它就可以正常工作呢?这 是因为:如果 i是最小负数-32768,二进制补码形式表示为0x8000,然后通过将所有位倒 装(即 0变 1)再加 1来取负,从而得到-i为 0x8000,若为有符号数,则表示-32768, 若为无符号数,则表示32768。按定义,由二进制补码表示的任意数,通过将其每一位倒装 再加l,可以得到该数的负值。因此 0x8000 表示的是最小负数-32768 的负值,即 32768, 因此应解释为无符号数。 88 至此,代码正确了,但并不美观。上面的代码容易让人产生错觉。根据可移植类型。- 32768 并不是有效的可移植整型值,因此通过在IntToStr 中适当的位置插入断言,就可以 排除所有的混乱。 void IntToStr( int i, char *str ) { /* i是否超出范围?使用 LongToStr …*/ ASSERT( i>=-32768 && i<- 32767 ); 通过使用上面的断言,既可以避免与某种数制相关的问题,又可以促使其他的程序员 编写更容易移植的代码。不管怎样,都要记住: 每个函数只完成它自己的任务 我曾经考察了字符窗口代码,这是为 Microsoft 基于字符的DOS应用而设计的类窗口 库,我之所以这样做,是因为使用该库的PC-Word 和PC-Works 两个小组都感到该库代码庞 大,执行缓慢,而且不稳定。我刚开始考察该代码时就发现了程序员并没有按照他们原来 的设计实现代码,而这恰恰违反了编写无错代码的另一条指导原则。 首先介绍一下背景。 字符窗口的基本设计非常简单:用户将视频显示看作一些窗口的集合,每个窗口可以 有它自己的子窗口。设计一个表示整个显示的根窗口,它可以具有菜单框、下拉式菜单、 应用文档窗口、对话等子窗口。每一个子窗口又可能具有其自己的子窗口。例如,对话窗 口可能具有为 OK键和Cancel 键而设立的子窗口,还可能包含一个列表框窗口,其中又可 能具有用作滚动条的子窗口。 为了表示窗口层次结构,字符窗口使用了二叉树结构。一个分支指向称为”children” 的子窗口;另一个分支指向有相同父母的称为”siblings”的窗口: typedef struct WINDOW { struct WINDOW * pwndChild; /* 如果没有孩子,则为 NULL */ strcut WINDOW * pwindSibling; /* 如果没有兄弟姐妹,则为 NULL */ char *strWndTitle; /*…*/ } window; /* 命名:wnd, *pwnd */ 可以查阅任何算法书籍,从中找到处理二叉树的有效方法。可是当我阅读了字符窗口 代码中有关向树中插入子窗口的代码时,我有点吃惊了,该代码如下所示; /* 指向最顶层窗口列表,例如象菜单框和主文件窗口 */ 尽可能精确地实现设计,近似地实现设计就可能出错 89 static window * pwndRootChildren = NULL; void AddChild( window * pwndParent, window * pwndNewBorn ) { /* 新窗口可能只有子窗口 …*/ ASSERT( pwndNewBorn->pwndSibling == NULL ); if( pwndParent == NULL ) { /* 将窗口加入到顶层根列表 */ pwndNewBorn->pwndSibling = pwndRootChildren; pwndRootChildren = pwndNewBorn; } else { /* 如果是父母的第一个孩子,那么开始一个链, * 否则加到现存兄弟链的末尾处 */ if( pwndParent -> pwndChild == NULL ) pwndParent -> pwndChild = pwndNewBorn; else { window *pwnd = pwndParent -> pwndChild; while( pwnd -> pwndSibling != NULL) pwnd = pwnd -> pwndSibling; pwnd -> pwndSibling = pwndNewBorn; } } } 尽管实际上是将窗口结构设计为二叉树结构,但并不是按这种结构实现的。由于根窗 口(表示整个显示的窗口)没有同级窗口也没有标题,而且也不会有移动、隐藏、删除的 操作,在window 结构中只有指向菜单框和应用子窗口的pwndChild 字段才是有意义的。因 此有人认为声明完整 window 结构是浪费,而用指向顶层窗口的简单格针pwndRootChildren 来代替 wndRoot 结构。 用一个指针代替 wndRoot,可能会在数据空间内节省一些字节,可是在代码宝间内的代 价巨大。象AddChild 这样的例用就不得不处理两种不同的数据结构:根层窗口树的链表和 窗口树本身,而不是处理简单的二叉树。当各个例程以窗口指针作为参数时,情况更糟, 它不得不检查表示显示窗口的专用NULL 指针,而这种情况很多。难怪PC-Word 和PC-Works 两个小组担心代码庞大。 90 我提出 AddChild 问题并不想讨论设计问题,而是想指出这种实现方法至少违反了编写 无错代码指导原则中的三条原则。前两条原则前面叙述过了:“不要接受具有特殊意义的参 数”,例如 NULL 指 针 ;“按照设计来实现而不能近似地实现。”第三个新的原则是:努力使 每个函数一次就完成住务。 如何理解这条新原则呢?假如AddChild 有一个任务,要在现有窗口中增加子窗口,而 上面的代码具有三个单独的插入过程。常识告诉我们如果有三段代码而不是一段代码来完 成一个任务,则很可能有错。比如做脑外科手术,如果一次能做好,那就不能做三次,写 代码也是这个道理。如果写个函数,发现是多次做一个任务,就要停下来问问自己,是否 能用一段代码来完成这个任务。 有时也需要这样的函数,它执行的功能要做两次,例如第2章的memset 快速版本(请 回顾一下,它具有两个独立的填充循环,一个快速的,一个慢速的)。如果能够肯定自己理 由充分的话,也可以打破这个原则。 改进AddChild 的第一步非常容易,删掉“优化”,按照原来的设计实现。用一个指向 窗口结构的指针 pwndDisplay 来代替pwndRootChildren,窗口结构表示屏幕显示,将 pwndDisplay传递给 AddChild,而不是将NULL 传递给 AddChild,就不需要有处理根窗口的 专用代码: /* 在程序初始化过程中分配根层窗口 * pwndDisplay将被设置为指向根层窗口 */ window* pwndDisplay = NULL; void AddChild( window *pwndParent, window *pwndNewBorn ) { /* 新窗口可能只有子窗口 */ ASSERT( pwndNewBorn -> pwndSibling == NULL ); /* 如果是父母的第一个孩子,那么开始一个链, * 否则加到现存兄弟链的末尾处 */ if( pwndParent -> pwndChild == NULL) pwndParent -> pwndChild = pwndNewBorn; else { window * pwnd = pwndParent -> pwndChild; while( pwnd -> pwndSibling != NULL ) pwnd = pwnd -> pwndSibling; pwnd -> pwndSibling = pwndNewBorn; } } 91 上面的代码不仅改进了 AddChild(和其它每个与树结构相适应的函数),而且将原来版 本中根窗口要反向插入的错误也更正了。 为什么 窗口是层次结 构 的? 为什么 窗口是层次结 构 的? 为什么 窗口是层次结 构 的? 为什么 窗口是层次结 构 的? 为什么 需要有层次 结构的窗口, 一个最主要 的原因就是 为了简化象移 动、隐蔽、 删除 为什么 需要有层次 结构的窗口, 一个最主要 的原因就是 为了简化象移 动、隐蔽、 删除 为什么 需要有层次 结构的窗口, 一个最主要 的原因就是 为了简化象移 动、隐蔽、 删除 为什么 需要有层次 结构的窗口, 一个最主要 的原因就是 为了简化象移 动、隐蔽、 删除 窗口这样一些操作。 如果移动对话窗口, 窗口这样一些操作。 如果移动对话窗口, 窗口这样一些操作。 如果移动对话窗口, 窗口这样一些操作。 如果移动对话窗口, OK和和和和Cancel 框还在原来的位置吗 ?或者说,如 框还在原来的位置吗 ?或者说,如 框还在原来的位置吗 ?或者说,如 框还在原来的位置吗 ?或者说,如 果将某 个窗口隐蔽 起来,它的子 窗口还可见 吗?显然, 这并不是所期 望的。通过 支持子窗 果将某 个窗口隐蔽 起来,它的子 窗口还可见 吗?显然, 这并不是所期 望的。通过 支持子窗 果将某 个窗口隐蔽 起来,它的子 窗口还可见 吗?显然, 这并不是所期 望的。通过 支持子窗 果将某 个窗口隐蔽 起来,它的子 窗口还可见 吗?显然, 这并不是所期 望的。通过 支持子窗 口,就可以说:口,就可以说:口,就可以说:口,就可以说:““““移动这个窗口 移动这个窗口 移动这个窗口 移动这个窗口 ””””并且所有与之相关的 窗口都将紧随着移动。 并且所有与之相关的 窗口都将紧随着移动。 并且所有与之相关的 窗口都将紧随着移动。 并且所有与之相关的 窗口都将紧随着移动。 避免无关紧要的if、&&和“但是”分支 AddChild 最后这个版本比前面的版本要好一些,但它仍然是由两段代码来完成同一任 务的。if语句的出现标志着可能有同一任务两次重复执行的情况发生,尽管两次执行的方 式不同。但if语句的出现应在人们的头脑中敲起警钟。确实,有一些场会需要合法使用if 语句来执行一些有条件的操作,但大多数情况下,这是草率设计粗心实现的结果。因为将 充满例外情况的设计组织在一起比停止并导出不含例外情况的模型要容易得多。 例如,在窗口结构中,可以有两种方法遍历兄弟链:一种方法是进入指向窗口结构的 循环。从一个窗口步进到另一窗口,即是以窗口为中心的算法;另一种方法是进入指向指 针的循环,从一个指针步进到另一个指针,即是以指针为中心的算法。上述 AddChild 实现 的是以窗口为中心的算法。 由于当前版本的 AddChlld 要扫描兄弟链列表来附加新的窗口,因此对第一个指针要有 特殊的处理。附加窗口实际上是在前一个窗口的“下一窗口指针”域内建立一个指向新窗 口的指针。要注意,从一个窗口步进到另一窗口,前一个窗口的指针可能是兄弟指针,也 可能是父子指针。特殊处理能够确保修改正确的指针。 但是如果使用以指针为中心的馍型,则总是指向“下一窗口指针”。而“下一窗口指针” 是父子指针还是兄弟指针无关紧要,因此没有什么特别的处理。为了便于理解,请看以下 代码。 void AddChild(window* pwndParent, window* pwndNewBorn ) { window **ppwindNext; /* 新窗口可能没有兄弟窗口 …*/ ASSERT( pwndNewBorn -> pwndSibling == NULL ); /* 使用以指针为中心的算法 * 设置ppwndNext 指向pwndParent -> pwndChild * 因为pwndParent -> pwndChild 是链中第一个“下一个兄弟指针” 一个“任务”应一次完成 92 */ ppwndNext = &pwndParent->pwndChild; while( *ppwndNext != NULL ) ppwndNext = &(*ppwndNext )->pwndSibling; *ppwndNext = pwndNewBorn; } 上面的代码是经典哑头(dummy header)链表插入算法的变形,这个算法因为不需要 任何特殊代码来处理空列表而著名。 不必担心上面的 AddChild 代码会违反以前提出的原则,即准确地实现设计而不能近似 地实现设计。该代码没有按人们通常想的那样实现设计,但它确实真正地实现了设计。就 好象我们观察眼镜片一样,这个镜片是凸的还是凹的呢?两个答案可能都是对的,这就要 看是怎样去观察它了。对于AddChild 来说,使用以指针为中心的算法可以不必为特殊情况 编写代码。 不用担心最后版本 AddChild 的效率。它产生的代码将比以前任一版本产生的代码少得 多。甚至循环部分的代码也可能比以前版本产生的代码好。不要因为加上了*和&,而认为 循环要比以前复杂,其实不然(编译一下这两个版本,看一下结果)。 “?:”运算符也是一种 if语句 C程序设计员必须正视:“?:”运算符不过是 if-else 语句的另外一种表示形式。因为 我们经常看到一些程序员判断时只用“?:”,从不明确使用if-else 语句。在Excel 的对话 框处理代码中就有这样的例子,它包含下面的函数,这个函数的功能是确定检查框的下个 状态: /* uCycleCheckBox ─── 返回对话框地下个状态 * * 给出了当前设置,uCur 返回对话框所应具有的下个状态 * 这既处理只有 0和1 又处理在 2,3,4,2 …… 三个状态 * 中循环的三状态检查框 */ unsigned uCycleCheckBox(unsigned uCur) { return( (uCur<=1)?(1-uCur):(uCur==4)?2:(uCur+1) ); } 我曾经和那些不想两次嵌套使用“?:”编写uCycleCheckBox的程序员一起工作过,可 是当要在下面显式使用 if语句的代码上写上他们的名字之前,尽管由大多数编译程序但非 最好的编译程序产生的这两个函数的代码几乎相同,他们还是转向了COBOL。 避免无关紧要地if if if if 语句 93 usigned uCycleCheckBox(unsigned uCur) { unsigned uRet; if(uCur <= 1) { if(uCur != 0) /* 处理0,1,0 …… 循环 */ uRet = 0; else uRet = 1; } else { if(uCur == 4) /* 处理2,3,4,2 …… 循环 */ uRet = 2; else uRet = uCur + 1; } return(uRet) } 尽管有些编译程序确实为嵌套“?:”版本产生了比较好的代码,但是实际上并好不了 多少。如果用户的目标机上已经有了效率很高的编译程序,不妨试一试下面的代码。做个 比较。 unsigned uCycleCheckBox( unsigned uCur ) { unsigned uRet; if( uCur <= 1 ) { uRet = 0; /* 处理0,1,0 …… 循环 */ if( uCur == 0 ) uRet = 1; } else { cuRet = 2; /* 处理2,3,4,2 …… 循环 */ if( uCur != 4 ) uRet = uCur + 1; } 94 return( uRet ); } 仔细看看 uCycleCheckBox的三个版本,尽管知道它们可能要做什么,但并不一目了然 。 如果输入 3将返回几?你能很容易得出答案是 4吗?我可不能。这些具有两个简单循环的 函数,实现方法十分清楚,但却难以掌握。 使用“?:”运算符所存在的问题是:由于它很简单,容易使用,看起来好象是产生高 效代码的理想方法,因此程序员就不再寻找更好的解决方法了。更严重的是,程序员会将if 版本转换为“?:”版本来获得“较好”的解决方法,而实际上“?:”版本并不好。这就好 象想通过将100 美元的钞票换成 10,000 美分来获得更多的钱一样,钱并没有增多,却变 重了,使用起来不方便了。如果程序员将时间花在导求替代算法上,而不是花在以某个稍 微不同的方式实现同一个算法上,那么可能就会提出下面这种更直接的实现方法: unsigned uCycleCheckBox( unsigned uCur ) { ASSERT( uCur >= 0 && uCur <= 4 ); If( uCur == 1 )/* 重新开始第一个循环?*/ return( 0 ); if( uCur == 4 )/* 重新开始第二个循环?*/ return( 2 ); return( uCur + 1 );/* 这时没有任何特殊处理 */ } 或许有人会提出下面这种列表解决方法: unsigned uCycleCheckBox( unsigned uCur ) { static const unsigned uNextState[] ={1,0,3,4,2 }; ASSERT( uCur >= 0 && uCur <= 4 ); return ( uNextState[uCur] ); } 通过避免使用“?:”可以得到更好的算法,而不仅仅是看上去好一些的方法。列表版 本与以前的版本相比较,哪个更好理解呢?哪个产生的代码最好呢?哪个更容易第一次实 现就正确呢?你应该从中领悟到一些道理。 消除代码的冗余性 在实现中,有时得支持特殊情况。为了避兔特殊情况遍及整个函数,我们把处理特殊 情况的代码独立出来。这样,维护人员在以后的维护中就不会无意识地将其遗漏而导致出 避免使用嵌套的“?:?:?:?:“运算符 95 现错误。 前面已经给出了实现 IntToStr 的两个版本,下面给出的是经常出现在C程序设计教程 中的IntToStr 代码(在教程中称为 itoa): void IntToStr( int i, char *str ) { int iOriginal = i; char* pch; if( iOriginal < 0 ) i = -i; /* 把i变成正值 */ /* 反导出字符串 */ pch = str; do *pch++ = i % 10 + ’0’; while(( i/=10 ) > 0 ); if( iOriginal < 0 )/* 不要忘掉负号 */ *pch++ = ’-’; *pch = ‘\0’; ReverseStr(str); /* 将子符串次序从逆序转为正序 */ } 请注意,代码中有两个 if语句,并且测试的是同一种特殊措况。既然可以很容易将两 个代码体写在一个 if语句内。我们就要问“为什么不那么做呢?” 有时,重复测试没有发生在if语句内,而发生在for 或while 循环语句的条件内。例 如下面给出另一种实现 memchr 函数的代码: void* memchr( void *pv, unsigned char ch, size_t size ) { unsigned char *pch = (unsigned char *)pv; unsigned char *pchEnd = pch + size; while( pch 0 ) { if( *pch == ch ) return( pch ); pch ++; } return( NULL ); } 上面的代码不仅正确,而且所产生的代码可能比前面各个版本产生的代码都要好,因 为它不必初始化 pchEnd。由于 size 在减1之前必须先复制,以便与 0进行比较,所以人 们通常认为 size--版本比 pchEnd 版本要大一些并且要慢一些。可是,实际上对于许多编译 程序来说,size--版本恰巧更快些、小些。这取决于编译程序是如何使用寄存器的。对于 80x86 编译程序言,还要取决于所使用的是哪种存储模型。不管怎样,在大小和速度方面, size--版本与 pchEnd 版本的差别很小,并不值得引起注意。 下面给出另一个惯用语,实际上在前面已经提过了。有些程序员可能会极力主张重写 循环表达式,用--size 代替size--: while(--size >= 0 ) …… 这种变化的合理一面是:上面这种表达式不产生比以前的表达式更坏的代码。在某些 情况下,可能会产生稍好一点的代码。但它的唯一问题是:如果盲目奉行的话,错误将会 象苍蝇见到牲畜一样向代码突袭而来。 为什么呢? 如果 size 是无符号值(象memchr 中的一样),根据定义,将总是大于或等于0,循环 将永运执行下去,因此表达式不能正常工作。 如果size 是有符号数,表达式也不能正常工作。如 size 是int 类型并且以最小的负 值INT_MIN 进入循环,它先被减 1,那么就会产生下溢,使得循环执行大量的次数。 相反,无论怎样声明 size 都能使“size-- > 0”正确工作。这是个小小的、但又很重 要的差别。 程序员使用“-- size > 0”的唯一原因是想加快速度。让我们仔细看一下,如果真的 存在速度问题,那么进行这种改进就好象用指甲刀剪草坪一样,可以这么做,但没有什么 98 效果。如果不存在速度问题,那为什么又要冒这样的风险呢?这就好象没有必要让草坪的 所有草叶都一样长,没有必要让每行代码效率都最优一样,要认识到最重要的是总体效果。 在某些程序员看来,放弃任何可能获得效率的机会似乎近似于犯罪。但是,当读完本 书以后,你会得到这样的思想即使效率可能会稍稍低一点,也要使用安全的设计和实现来 系统地减少风险性。用户不应该注意是否在某个地方又增加了一些循环,而应集中注意力 来看是否在试图节省某些循环时而偶然引入了错误。用投资方面的一句术语来说就是:赢 利并不能证明冒险是正确的。 使用移位操作来实现乘、除、求模2的幂是另外一种有风险的惯用语。它属于“浪费 效率”类。例如,第 2章给出的 memset 快速版本有如下几行代码: pb = ( byte *)longfill(( long *)pb, 1, size/4 ); size = size % 4 可以肯定,有一些程序员在读到上面的代码时会想:“效率多低”。他们可能会将除操 作和求模操作写成如下的形式: pb = ( byte *)longfill(( long *)pb, 1, size >> 2 ); size = size & 3 移位操作比除法或求模要快,这在大多数机器上是对的。但是,象用 2的幂去除或去 求模无符号值(如 size)的这样的操作,已经优化好了,即使商用计算机也是如此,没有 必要再手工优化这些无符号表达式。 那么,有符号表达式又将怎样呢?显式的优化是否值得呢?既值得也不值得。 假定有如下的有符号表达式: midpoint = ( upper + lower )/ 2; 当有符号数的值为负值时,将其移位与进行有符号除法所得的结果不同,因此二进制 补码的编译程序不将除法优化为移位。如果我们知道上面表达式中的 upper + lower 总是 正值,就可以采用移位将表达式改写成如下所示,这个代码要好一些: midpoint = ( unsigned )( upper + lower ) >> 1 因此,优化有符号表达式是值得的。可是,移位是否是最好的方法呢?不是。下面代 码所示的强制转换方法同样也很好,并且比移位法要安全得多。请在编译程序上试一下: midpoint = ( unsigned )( upper + lower )/2; 上面的代码不是告诉编译程序要做什么,而是将需要进行优化的信息传递给编译程序。 通过告诉编译程序所求得的结果是无符号的,来调知它可以进行移位。现在来比较一下两 种优化,那个更容易理解?那个更具有可移植性?那个更可能在第一次执行就正确呢? 多年来,我发现了许多由于程序员使用移位来进行有符号值的除法,而有符号值又不 能确保为正值而引起的错误;发现了许多方向移错了的移位错误;发现了许多移位位数错 了的移位错误;甚至发现了由于不小心将表达式“a=b+c/4”转换为“a=b+c>>2”而引入的 优先级错。但我却不曾发现过以键入’/’和’4’来实现除以 4时会发生错误。 C语言还有许多其它的有风险的惯用语。有个最好的方法来找到自己经常使用的有风险 的惯用语,这就是检查以前出现的每一个错误,再问一下自己:“怎样来避免这些错误?” 99 然后建立个人的风险惯用语表从而避免使用这些惯用语。 不要过高 地估计代价 不要过高 地估计代价 不要过高 地估计代价 不要过高 地估计代价 1984年,当年,当年,当年,当Apple研制 出 研制 出 研制 出 研制 出 Macintosh的时候,的时候,的时候,的时候,Microsoft公司是少数几个采 用 公司是少数几个采 用 公司是少数几个采 用 公司是少数几个采 用 Macintosh 产品 的公 司之一 。很 显然, 采用其 它公 司的产 品 对 产品 的公 司之一 。很 显然, 采用其 它公 司的产 品 对 产品 的公 司之一 。很 显然, 采用其 它公 司的产 品 对 产品 的公 司之一 。很 显然, 采用其 它公 司的产 品 对 Microsoft 公司 来说 ,既有 益也有 害。 公司 来说 ,既有 益也有 害。 公司 来说 ,既有 益也有 害。 公司 来说 ,既有 益也有 害。 采用 了 采用 了 采用 了 采用 了 Macintosh 就意味着随 着 就意味着随 着 就意味着随 着 就意味着随 着 Macintosh 本身的发展, 本身的发展, 本身的发展, 本身的发展, Microsoft 必须也要不断地发展 必须也要不断地发展 必须也要不断地发展 必须也要不断地发展 相相相相 应的产品。因此, 应的产品。因此, 应的产品。因此, 应的产品。因此, Microsoft 公司的程序员就必须经 常使用工作环境( 公司的程序员就必须经 常使用工作环境( 公司的程序员就必须经 常使用工作环境( 公司的程序员就必须经 常使用工作环境( work-arounds)以 )以 )以 )以 使使使使 Macintosh正常工作。 但 当 正常工作。 但 当 正常工作。 但 当 正常工作。 但 当 Apple第一次 对 第一次 对 第一次 对 第一次 对 Macintosh操作系统进行版本升级 时出现了问 操作系统进行版本升级 时出现了问 操作系统进行版本升级 时出现了问 操作系统进行版本升级 时出现了问 题。题。题。题。 在早期 的 在早期 的 在早期 的 在早期 的 测试中 发现新版的操 作系统 使 测试中 发现新版的操 作系统 使 测试中 发现新版的操 作系统 使 测试中 发现新版的操 作系统 使 Microsoft 的产品 不能正常工 作。为了简化 问题, 的产品 不能正常工 作。为了简化 问题, 的产品 不能正常工 作。为了简化 问题, 的产品 不能正常工 作。为了简化 问题, Apple 请求请求请求请求Microsoft 删除过时的工作环境 ( 删除过时的工作环境 ( 删除过时的工作环境 ( 删除过时的工作环境 ( work-arounds)以保持与最新操作 系统一致。 )以保持与最新操作 系统一致。 )以保持与最新操作 系统一致。 )以保持与最新操作 系统一致。 但是, 删 除 但是, 删 除 但是, 删 除 但是, 删 除 Excel 中的工 作环境( 中的工 作环境( 中的工 作环境( 中的工 作环境( work-arounds)就意 味着要重写 手工优化的汇 编语 )就意 味着要重写 手工优化的汇 编语 )就意 味着要重写 手工优化的汇 编语 )就意 味着要重写 手工优化的汇 编语 言过程。在代码中要增 加 言过程。在代码中要增 加 言过程。在代码中要增 加 言过程。在代码中要增 加 12个循环。由于这个过程很关键,围绕着是否要重写这个问题 个循环。由于这个过程很关键,围绕着是否要重写这个问题 个循环。由于这个过程很关键,围绕着是否要重写这个问题 个循环。由于这个过程很关键,围绕着是否要重写这个问题 展展展展 开了讨论。一部分人 认为要 与 开了讨论。一部分人 认为要 与 开了讨论。一部分人 认为要 与 开了讨论。一部分人 认为要 与 Apple 保持一致,另一部分 人则要保持速度。 保持一致,另一部分 人则要保持速度。 保持一致,另一部分 人则要保持速度。 保持一致,另一部分 人则要保持速度。 最后, 程序员将一 个计数器加到 函数中, 让 最后, 程序员将一 个计数器加到 函数中, 让 最后, 程序员将一 个计数器加到 函数中, 让 最后, 程序员将一 个计数器加到 函数中, 让 Excel 运行了 三个小时, 并观察该函数 被 运行了 三个小时, 并观察该函数 被 运行了 三个小时, 并观察该函数 被 运行了 三个小时, 并观察该函数 被 调用的频度。该函数 被调用了大 概 调用的频度。该函数 被调用了大 概 调用的频度。该函数 被调用了大 概 调用的频度。该函数 被调用了大 概 76,,,,000 次,只会 使 次,只会 使 次,只会 使 次,只会 使 3小时增加 到 小时增加 到 小时增加 到 小时增加 到 3小时小时小时小时0.1 秒。 秒。 秒。 秒。 这个例 子恰好又说 明了:关心局 部效率是不 值得的。如 果你很注重效 率的话,请 集中 这个例 子恰好又说 明了:关心局 部效率是不 值得的。如 果你很注重效 率的话,请 集中 这个例 子恰好又说 明了:关心局 部效率是不 值得的。如 果你很注重效 率的话,请 集中 这个例 子恰好又说 明了:关心局 部效率是不 值得的。如 果你很注重效 率的话,请 集中 于全局效率和算法的 效率上,这样你才会看到努力的效果。 于全局效率和算法的 效率上,这样你才会看到努力的效果。 于全局效率和算法的 效率上,这样你才会看到努力的效果。 于全局效率和算法的 效率上,这样你才会看到努力的效果。 不一致性是编写正确代码的障碍 请看下面的代码,它包含了最简单一类的错误——优先级错: word = high << 8 + low ; 该代码原意是用两个 8位字节组合成一个 16位的字,但是由于 + 操作符比移位操作 符的优先级要高,因此,该代码实际实现的是把high 移动了 8 + low 位。程序员一般不将 移位操作符和算术操作符混合使用。如果只用移位类操作符或只用算术类操作符就可以完 成,那么为什么还要将移位操作符和算术操作符混合起来呢? word = high << 8 | low; /* 移位解法 */ word = high * 256 + low;/* 算术解法 */ 这些式子难以理解吗?它们的效率低吗?当然不是。这两种解法差别很大,但这两种 解法都是正确的。 若程序员在写表达式时只用一类操作符,那么出现错误代码的概率就要小一些,因为 凭直觉,同一类操作符的优先顺序容易掌握。当然也有例外,但是作为一条原则,这是对 的。有多少程序员,脑子里想着先加后除,却写成下面的表达式呢? midpoint = upper + lower / 2; 避免使用有风险的语言惯用语 100 由于程序员学过数理逻辑课程,熟悉象f(A,B,C)= AB + C这样的函数,因此记住 移位操作符的优先级顺序不会有什么困难。大多数程序员都知道顺序(从高到低)是“~”、 “&”、“/”。很容易想到可在“~”和“&”之间插入移位操作符,因为它没有“~”约束 得那么紧(想一想~A<<2),但是它却比“&”的优先级要高(想一想幂运算和乘法运算就 可推知了)。 程序员可能清楚知道各类操作符的优先级,但是在他们混合使用各类操作符时,很容 易出现问题。因此,第一条原则是:如果有可能,就不要把不同类型的操作符混合使用。 第二条原则是:如果必须将不同类型操作符混合使用,就用括号把它们隔离开来。 你已经看到了第一条原则如何使代码免于出错。请看下面的while 循环,这在前面给 出过了,从这个例子又可以看到第二条原则是如何避免代码出错的: while ( ch = getchar( )!= EOF ) ……; 上面的代码将赋值操作符与比较操作符混合使用,从而引入了一个优先级错。可以通 过重写没有操作符混合使用的循环来改正上面的错误,但是结果看上去很糟: do { ch = getchar (); if( ch == EOF ) break; ……; }while ( TRUE ); 在这种情况下,最好打破第一条原则,使用第二条原则,用括号将操作符分隔开来: while ((ch = getchar()) != EOF ) ……; 不查优先级表 不查优先级表 不查优先级表 不查优先级表 要插入括号的时候,有些程序员总要先查优先级表,再来确定是否有必要插入括号, 如果没有必要就不插。对于这样的程序员,要提醒他:“如果必须通过查表才能确定优先顺 序的话,那就太复杂了,简单一些嘛。”这就意味着可以在不需要括号的地方插入括号。这 样做不仅正确,而且显然可使任何人不经查表就可判断优先级了。 避免和错误联系在一起 在上一章,我们讨论了在设计函数时尽量避免返回错误值,以免程序员错误地处理或 不能毫无必要地将不用类型地操作符混合使用,如果必须将 不同类型地操作符混合使用,就用括号把它们隔离开来 101 漏掉这些返回值(例如tolower,当ch不是大写字符时,它返回-1)。本章,我们又要谈论 这个话 题,“不要调用返回错误的函数”,这样,就不会错误地处理或漏掉由其它人设计的 函数所返回的错误条件。有时必须要调用这种函数,在这种情况下,必须在调试系统中走 查这段错误处理代码,从而确保该函数正确地工作。 但要强调一点,如果自始至终程序反复处理同样的错误条件,就将错误处理部分独立 出来。最简单的方法,也是每个程序员都知道的方法,就是将错误处理放在一个子过程中, 这样做效果很好。但在某些情况下,还可以做得比这更好。 例如,字符窗口具有可为一个窗口在六、七处换名的代码,如下述: if( fResizeMemory( &pwnd->strWndTitle, strlen(strNewTitle)+1 )) strcpy( pwnd->strWndTitle, strNewTitle ); else /* 不能为窗口名分配空间 ……*/ 在存储区具有足够的空间来存放新名字的情况下,上面的代码更改了窗口的名字,如 果空间不够,它将保持窗口的当前名并设法对错误进行处理。问题是,怎样处理这个错误 呢?向用户报警?不言语,悄悄地留下原来的名字?将新名字截取下来复制到当前名字 上?这几种解决方法都不理想,特别是当代码作为通用子过程的一部分时,就更不理想。 上面所述的只是不想让代码失败的多种情况中的一种情况。要为一个窗口重新命名, 总是办得到的。 上面代码所存在的问题是,不能保证有足够的存储空间来存放新的窗口名字。但是, 如果愿意超量分配名字空间,这个问题就很容易解决。例如,在一个典型的字符窗口应用 中,只有少数窗口需要重新命名,这些名字所占的存储空间都不大,即使名字都是最大长 度,我们就为存放最长的名字分配足够的空间,而不是仅仅分配当前名字长度所需的空间。 于是,重命名窗口就变成如下的简单代码: strcpy( pwnd -> strWndTitle, strNewTitle ); 还可改得更好,将实现细节隐藏在RenameWindow函数中,使用断言来验证所分配的名字空 间有足够大,它可以存放可能的任何名字: void RenameWindow( window *pwnd, char *strNewTitle ) { ASSERT( fValidWindow(pwnd) ); ASSERT( strNewTitle != NULL ); ASSERT( fValidPointer( pwnd->strWndTitle, strlen(strNewTitle) –1 )); strcpy( pwnd->strWndTitle, strNewTitle ); } 这种方法的缺点是:当超量分配名字空间时,就会浪费存储区。但同时,由于不需要 任何错误处理代码,又复得了代码空间。现在的问题是权衡数据空间和代码空间,并根据 运行的具体情况决定哪个更重要。假如程序中有数千个窗口需要重命名,你可能就不会超 量分配窗口名字空间了。 102 小结 至此,本章所讲的:程序设计是“风险事业”的含义已经很清楚了。本章的所有观点 都集中在如何把有风险的编码转换成为编写在空间、速度、甚至在无错方面都堪与之匹敌 的代码上。 但是,不要倡留在本章给出的各点上,在实践中要不断地总结出自己的新观点,并在 编码时严格地遵守这些原则。你是否周密思考了每一个编码习惯?是否因为看到别的程序 员采用了某个编码习惯,于是自己也采用?刚刚入门的程序员经常认为用移位实现除法是 一种“技巧”,而有经验的程序员则认为这是十分显然的,没有什么可值得疑虑的,哪个正 确呢? 要点: ���� 在选择数据类型的时候要谨慎。虽然ANSI标准要求所有的执行程序都要支持char, int,long 等类型,但是它并没有具体定义这些类型。为了避免程序出错,应该只 按照ANSI 的标准选择数据类型。 ���� 由于代码可能会在不理想的硬件上运行,因此很可能算法是正确的而执行起来却 有错。所以要经常详细检查计算结果和测试结果的数据类型范围是否上溢或下溢。 ���� 在实现某个设计的时候,一定要严格按照设计去实现。如果在编写代码时只是近 似地实现所提出的要求,那就很容易出错。 ���� 每个函数应该只有一个严格定义的任务,不仅如此,完成每个任务也应只有一种 途径。假如不管输入什么都能执行同样的代码,那就会大大降低那些不易被发现 的错误所存在的概率。 ���� if语句是个警告信号,说明代码所做的工作可能比所需要的要多。努力消除代码 中每一个不必要的 if语句,经常反问自己:“怎样改变设计从而删掉这个特殊情 况?”有时可能要改变数据结构,有时又要改变一下考察问题的方式,就象透镜 是凸的还是凹的问题一样。 ���� 有时if语句隐藏在 while 和for 循环的控制表达式中。“?:”操作符是 if语句的 另外一种形式。 ���� 曾惕有风险的语言惯用语,注意那些相近但更安全的惯用语。特别要警惕那些看 上去象是好编码的惯用语,因为这样的实现对总体效率很少有显著的影响,但却 增加了额外的风险性。 ���� 在写表达式时,尽量不要把不同类型的操作符混合起来,如果必须混合使用,用 括号把它们分隔开来。 ���� 特殊情况中的特殊情况是错误处理。如果有可能,应该尽量避免调用可能失败的 函数,假如必须调用返回错误的函利,将错误处理局部化以便所有的错误都汇集 避免调用返回错误的函数 103 到一点,这将增加在错误处理代码中发现错误的机会。 ���� 在某些情况下,取消一般的错误处理代码是有可能的,但要保证所做的事情不会 失败。这就意味着在初始化时要对错误进行一次性处理或是从根本上改变设计。 练习: 1) “简单的”一位位域的可移植范围是什么? 2) 如果布尔量的值用“简单的”一位位域来表示的话,返回布尔值的函数应该是怎 样的? 3) 从AddChild 的第二个版本到最后版本,都使用全局变量pwndDisplay 来指向已分 配的表示整个显示的窗口结构。如果不这样,也可以声明一个全局的窗口结构: wndDisplay。尽管这样也可以,但为什么不这样做呢? 4) 假如有个程序员提出;为了提高效率是否应该将下面的循环 while( expression ) { A; if( f ) B; else C; D; } 改写成为: if( f ) while( expression ) { A; B; D; } else while( expression ) { A; C; D; } 上面的 A和D代表语句集。第二个版本速度要快一些,但是,与第一个版本 104 比较起来它有什么风险? 5) 如果你阅读了 ANSI 标准的话,将会发现这样一些函数,它具有若干个名字几乎相 同的参数。例如: int strcmp( const char *s1, const char *s2 ); 为什么说这样的函数是有风险的?应如何消除风险? 6) 象下面这样的条件循环是有风险的: while( pch ++ <= pchEnd ) 但使用类似的递减循环为什么还有风险呢? while( pch -- >= pchStart ) 7) 一些程序员为了提高效率或使问题变得简洁,采用了下面的简化方法。为什么应 该避免这样做呢? a) 用printf( str )代替printf(“%s”,str); b) 用f=1-f 代替f=!f; c) 用 int ch; /* ch必须是整数 */ …… ch = *str ++ = getchar( ); …… 代替两个独立的赋值语句。 8) uCycleCheckBox,tolower 和第2章中的反汇编程序都使用了表驱动算法,使用表 的优点及风险性是什么? 9) 假设你的编译程序不能为无符号 2的幂的算术运算自动提供移位操作符,那么除 风险性问题和不可移植性问题之外,为什么在明确的优化中仍然应该避免使用移 位和“与”操作? 10) 程序设计中的一个重要原则是:决不能丢失用户的数据。假设你正在做 WordSnasher项目,需要编写“保存文件”子例程,这时,为了保存用户文件,必 须给用户文件分配一个临时数据缓冲区。问题是如果你无法分配缓冲区,就不能 保存文件,从而违反了上述原则。怎样做才能保证保存用户文件呢? 课题: 将所有你能想到的有风险的语言特点列成一个表格,列出使用每一特征的优缺点。随 后,针对表中的每一项,分析在什么情况下,你宁愿冒险而使用该特征。 105 第7777章 编码中的假象 写小说,就希望每一页都能吸引读者,使读者激动、吃惊、悬念!决不能使读者感到 厌烦。因此在每一页都要撒些胡椒粉,描述一些场景来吸引读者、如果小说写成;“罪犯走 近乔并刺伤了他”,读者就会睡觉了。为了使读者感兴趣,就要使得当描述到乔听到身后“咚 ! 咚!咚!”的脚步声时,读者也能感觉到乔是怎样的恐惧;当“咚!咚”的脚步声慢慢地越 来越近的时候,读者也能感觉到乔的手在冒汗;当脚步声加速,罪犯朝乔逼近的时候,读 者也能理解到乔是怎样的惊慌。最重要的是读者保持着悬念,乔能不能逃脱?…… 在小说中使用惊奇和悬念很重要也很必要。但是如果把它们放到代码中,那就糟糕了。 当写代码时,“情节”应该直观,以便别的程序员能预先清楚地知道将要发生的一切。如果 用代码表述罪犯走近乔并刺伤了他,那么写成“罪犯走近乔并刺伤了他”最恰当了。该代 码简短、清楚、并讲述了所发生的一切。但是由于某些原因,程序员拒绝写简捷清楚的代 码,却极力主张使用具有技巧的、比较精炼的、异乎寻常的编码方法,最好不要这样。 但是直观的代码并不意味着是简单的代码,直观的代码可以使你沿着一条明确无奇的 路径从 A点到达 B点。必要的时候直观的代码可能也很复杂。 因此,本章将考察导致产生不直观代码的编程风格。例子都很巧妙、有技巧,但是并 非显而易见,当然,这些程序都会引起一些微妙的错误。 要注意到底引用了什么 下面的代码是上一章所给的memchr 的无错版本: void *memchr(void *pv, unsigned char ch, size_t size) { unsigned char *pch = (unsigned char *)pv; while(size-- > 0) { if(*pcd == ch) return(pch); pch++; } return(NULL); } 大多数程序员玩弄的一种游戏是“我如何使得代码更快?”的游戏。这并不是坏游戏, 但是正如我们从这本书所感到的那样:如果过份地热衷于这种游戏,那就是坏事。 例如如果在上面的例子上玩这个游戏的话,你就会问自己:“如何使循环加快?”只有 106 三种可能的途径:删除范围检查、删除字符测试、或删除指针递增,好象删除哪一步骤都 不行,但是如果愿意放弃传统的编码方式并进行大胆尝试的话是可以删除的。 看一下范围检查,之所以需要该检查仅仅是因为:当在存储器的头size 个字节内没有 找到要找的字符 ch时,就要返回 NULL。要删除该检查,只要简单地保证总可以找到 ch字 符就可以了。这可以通过下面的方法来实现:在被查找的存储区域后面的第一个字节上存 放字符 ch。这样,若待查存储区域内无字符此时,就可以找到后存入的这个 ch字符: void* memchr(void *pv, unsigned char ch, size_t size) { unsigned char *pch = (unsigned char *)pv; unsigned char *pchPlant; unsigned char chSave; /* pchPlant 指向要被查寻的存储区域后面的第一个字节 * 将ch存储在 pchPlant 所指的字节内来保证 memchr 肯定能挂到 Ch */ pchPlant = pch+size; chSave = *pchPlant; *pchPlant = ch; while(*pch != ch) pch++; *pchPlant = chSave; return((pch == pchPlant)?NULL : pch); } 巧妙吗?正确吗?通过用ch覆盖pchPlant指向的字符,可以保证memchr总能找到ch, 这样就可以删除范围检查,使循环的速度加倍。但是,这样坚挺、可靠吗? 这个memchr 的新版看上去似乎坚挺,特别是它还仔细地把 pchPlant 原来所指的要被 覆盖的字符保存起来,但是memchr 的这个版本还是有问题。对于初学者来讲,请考虑下面 几点: ���� 如果pcPlant 指向只读存储器,那么在*pchPlant 处存放字符 ch就不起作用,因 此当在 size+1 范围内没有发现 ch时,函数将返回无效指针。 ���� 如果pchPlant 指向被映射到 I/O的存储器,那么将ch存储在*pchPlant 处就难以 预计会发生什么事情,从使得软盘停止(或开始)工作到工业机器人狂暴地挥舞 焊枪都有可能。 ���� 如果pch 指向RAM最后的 size 个字节,pch 和size 都是合法的,但 pchPlant 将 指向不存在的或是写保护的存储空间。将 ch存储在*pchPlant 处就可能会引起存 储故障,或是不做任何动作。此时如果在size+1 个字符内没有找到宇符 ch,函数 就会失败。 ���� 如果pchPlant 指向的是并行进程共享的数据,那么当一个进程在*pchPlant 处存 107 储ch时,就可能错改另一个进程要引用的存储空间。 最后一点尤其会引起麻烦,因为有许多方式都可以引起系统瘫痪。如果你调用memchr 来查寻已分配了的存储空间,却不料破坏了存储管理程序的某个数据结构,这将如何是好 呢?如果并行进程是代码连接或中断处理之类的例程,那么最好不要调用存储管理程序, 否则系统可能会瘫痪。如果调用memchr 扫描全局数组并且步入了由另一个任务引用的交界 变量,那又该如何呢?如果程序的两个实例要并行地查找共享数据时,那又会怎样呢?有 很多情况都会使程序死掉。 当然,你还不能体验到memchr 引起的微妙错误,因为只要不修改关键的存储区,它就 会工作得很好。但像memchr 这样的函数一旦引起了错误,要孤立这些错误就象在大海里捞 针一样的困难。这是因为:执行memchr 的进程工作得很好,而另一个进程却因为存储区损 坏而崩溃,此时,就没有理由怀疑是memchr 引起的。这样错误就很难发现。 现在你就知道了,为什么要买价值$50,000 的电路仿真器了。因为它们记录从开始到 崩溃前的每一个周期、每一条指令、和计算机引用的每一段数据。可能要花几天时间才能 艰难地读完仿真器的输出,但是如果坚持而且不盲目地处理这些输出结果的话,应该能找 到错误之所在。 早已有警句:不要引用不属于你的存储区。我们又何必如上例那样忍受痛苦绞尽脑汁 呢?注意,“引用”意味着不仅要读而且要写。读未知的存储区可能不会和别的进程产生不 可思议的相互作用,但是,如果引用了已保护的存储区、不存在的存储区、或者映射到I/O 存储区的话,程序将会迅速死掉。 拿车钥匙的贼还是赋 很奇怪有些程序员,他们从不引用不属于地们自己的存储空间。但他们却觉得编写象 下面FreeWindowsTree例程这样的代码是很正确的: void FreeWindowsTree(windows *pwndRoot) { if(pwndRoot != NULL) { window *pwnd; /* 释放pwndRoot 的子窗口 ……*/ for(pwnd = pwndRoot->pwndChild;pwnd != NULL;pwnd = pwnd->pwndSibling) FreeWindowTree(pwnd); if(pwndRoot->strWndTitle != NULL) FreeMemory(pwndRoot->strWndTitle); FreeMemory(pwndRoot); 只引用属于你自己的存储空间 108 } } 请看一下for循环,看出什么问题了吗?当FreeWindowsTree释放pwndSibling链表中 的每个子窗口时,先释放pwnd,然后 for 循环在控制赋值时又引用已释放的块: pwnd = pwnd->pwndSilbing; 但是一旦 pwnd 被释放,那么pwnd->pwndSibling的值是什么呢?当然是一堆垃圾。但 是某些程序员并不接受这个事实,刚刚存储区还好好的,并且也没做什么影响它的事,它 仍应该是有效的呀!也就是说,除了释放 pwnd 之外没做别的什么事情。 我从不明白为什么某些程序员会认为引用已经释放的存储区是允许的,这与你使用过 去的钥匙进入曾经住过的公寓或开走曾经属于你的汽车又有什么区别呢?你之所以不能安 全引用释放的存储空间是因为正如我们在第 3章中讲的,存储管理程序可能已将这块释放 的空间连到空闲链上了,或已将它用于别的私有信息了。 数据的 权限 数据的 权限 数据的 权限 数据的 权限 在你所阅 读的程序设计手册中 可能没有讲到这个 问题,但是,在代 码中的每一条数据 在你所阅 读的程序设计手册中 可能没有讲到这个 问题,但是,在代 码中的每一条数据 在你所阅 读的程序设计手册中 可能没有讲到这个 问题,但是,在代 码中的每一条数据 在你所阅 读的程序设计手册中 可能没有讲到这个 问题,但是,在代 码中的每一条数据 都隐含地 有一个与之相联系的 读写权限。该权限 没有明文出处,也 没在声明变量时显 式地 都隐含地 有一个与之相联系的 读写权限。该权限 没有明文出处,也 没在声明变量时显 式地 都隐含地 有一个与之相联系的 读写权限。该权限 没有明文出处,也 没在声明变量时显 式地 都隐含地 有一个与之相联系的 读写权限。该权限 没有明文出处,也 没在声明变量时显 式地 给出,而是在设计子 系统和函数的界面时隐含地声明的。 给出,而是在设计子 系统和函数的界面时隐含地声明的。 给出,而是在设计子 系统和函数的界面时隐含地声明的。 给出,而是在设计子 系统和函数的界面时隐含地声明的。 例如,实际上在调用 某个函数的程序员和写这个函教的程序员 之间有个隐式的约定: 例如,实际上在调用 某个函数的程序员和写这个函教的程序员 之间有个隐式的约定: 例如,实际上在调用 某个函数的程序员和写这个函教的程序员 之间有个隐式的约定: 例如,实际上在调用 某个函数的程序员和写这个函教的程序员 之间有个隐式的约定: 假设我是 调用者,你是被调用 者,如果我向你传 递一个指向输入的 指针,那么你就同 假设我是 调用者,你是被调用 者,如果我向你传 递一个指向输入的 指针,那么你就同 假设我是 调用者,你是被调用 者,如果我向你传 递一个指向输入的 指针,那么你就同 假设我是 调用者,你是被调用 者,如果我向你传 递一个指向输入的 指针,那么你就同 意将输入 当作常量并且承诺不 对其进行写操作。 同样,如果我向你 传递一个指向输出 的指 意将输入 当作常量并且承诺不 对其进行写操作。 同样,如果我向你 传递一个指向输出 的指 意将输入 当作常量并且承诺不 对其进行写操作。 同样,如果我向你 传递一个指向输出 的指 意将输入 当作常量并且承诺不 对其进行写操作。 同样,如果我向你 传递一个指向输出 的指 针,你就 同意把它当作只写对 象来处理并承诺不 对其进行读操作。 最后,无论指针指 向输 针,你就 同意把它当作只写对 象来处理并承诺不 对其进行读操作。 最后,无论指针指 向输 针,你就 同意把它当作只写对 象来处理并承诺不 对其进行读操作。 最后,无论指针指 向输 针,你就 同意把它当作只写对 象来处理并承诺不 对其进行读操作。 最后,无论指针指 向输 入还是指向输出,你 都同意严格限制对保存这些输出的存储空 间的引用。 入还是指向输出,你 都同意严格限制对保存这些输出的存储空 间的引用。 入还是指向输出,你 都同意严格限制对保存这些输出的存储空 间的引用。 入还是指向输出,你 都同意严格限制对保存这些输出的存储空 间的引用。 回过来说 ,我这个调用者同意 把只读输出当作常 量并已承诺不对它 们进行写操作。此 回过来说 ,我这个调用者同意 把只读输出当作常 量并已承诺不对它 们进行写操作。此 回过来说 ,我这个调用者同意 把只读输出当作常 量并已承诺不对它 们进行写操作。此 回过来说 ,我这个调用者同意 把只读输出当作常 量并已承诺不对它 们进行写操作。此 外,还同意严格限制 对保存这些输出空间的引用。 外,还同意严格限制 对保存这些输出空间的引用。 外,还同意严格限制 对保存这些输出空间的引用。 外,还同意严格限制 对保存这些输出空间的引用。 换句话说:换句话说:换句话说:换句话说:““““你不要搞乱 我的事情,我也不 搞乱你的事情。 你不要搞乱 我的事情,我也不 搞乱你的事情。 你不要搞乱 我的事情,我也不 搞乱你的事情。 你不要搞乱 我的事情,我也不 搞乱你的事情。 ””””要牢记:任 何时候,只要 要牢记:任 何时候,只要 要牢记:任 何时候,只要 要牢记:任 何时候,只要 你违反了 隐含的读写权限,那 么就冒着中断代码 的危险,因为编写 这些代码的程序员 坚信 你违反了 隐含的读写权限,那 么就冒着中断代码 的危险,因为编写 这些代码的程序员 坚信 你违反了 隐含的读写权限,那 么就冒着中断代码 的危险,因为编写 这些代码的程序员 坚信 你违反了 隐含的读写权限,那 么就冒着中断代码 的危险,因为编写 这些代码的程序员 坚信 每个程序员都应遵守 这些约定。 调用 象 每个程序员都应遵守 这些约定。 调用 象 每个程序员都应遵守 这些约定。 调用 象 每个程序员都应遵守 这些约定。 调用 象 memchr 这样的函数程序员不 应担心在一些特殊的 这样的函数程序员不 应担心在一些特殊的 这样的函数程序员不 应担心在一些特殊的 这样的函数程序员不 应担心在一些特殊的 情情情情 况下, 况下, 况下, 况下, memchr 会运转异常。 会运转异常。 会运转异常。 会运转异常。 仅取所需 上一章,我们给出了 UnsToStr 函数的一种实现方法,它如下所示: /* UnsToStr—一将无符号值转换为字符串 */ void UnsToStr(unsigned u,char *str) { 只有系统才能拥有空闲的存储区,程序员不能拥有 109 char *strStart = str; do *str++ = (u % 10) + ‘0’; while((u/=10)>0); *str = ‘\0’; ReverseStr(strStart); } 上面的代码是 UnsToStr 的直接实现,但是,有些程序员觉得这样做法不舒服,因为代 码以反向顺序导出数字,却要建立正向顺序的字符串。因此需要调用ReverseStr 来重排数 字的顺序。这样似乎很浪费。如果你打算以反向顺序导出数字,为什么不建立反向顺序的 字符串从而可以取消对 ReverseStr 的调用呢?为什么不可以这样呢: void UnsToStr(unsigned u,char *str) { char *pch; /* u超出范围吗?使用 UlongToStr…*/ ASSERT(u<= 65536); /* 将每一位数字自后向前存储 * 字符串足够大以便能存储u的最大可能值 */ pch = &str[5]; *pch = ‘\0’; do *--pch = u%10 + ‘0’; while((u/=10)>0); strcpy(str,pch); } 某些程序员对上面的代码感到很满意,因为它更有效并且更容易理解。它之所以更有 效是因为 strcpy 比ReverseStr更快,特别是对于那些可把“调用”生成为内联指令的编 译程序来说就更是这样。代码之所以更容易理解是因为C程序员对 strcpy 要更熟悉一些。 当程序员见到 ReverseStr 时,就好象听到他们的朋友住进医院的消息一样,都会迟疑一下 。 这又说明什么呢?如果 UnsToStr 真是那么完美,我说这些干嘛!当然,它并不完美, 事实上 UnsToStr 有个严重的缺陷。 告诉我,str所指的存储空间多大?你并不知道。对于C语言接口程序来说,这并不罕 见。在调用者和实现者之间有个无言的原则,这就是 str将指向足够大的存储区来存放 u 的正文表示。UnsToStr 假定str 指向转换 u的最大可能值所需的足够存储空间,但u并不 常是最大值。因而,调用者写出如下代码: DisplayScore() 110 { char strScore[3]; UnsToStr(UserScore,strScore); } 由于UserScore 不会产生小于三个字符(两位数字加一位空字符)的字符串,因此, 程序员将 strScore 定义为三个字符的数组是完全合理的,然而,UnsToStr 假设strScore 是6个字符的数组,并且破坏了存储区内strScore 后面的三个字节。在上面的例子中,如 果所用的机器具有向下增长的栈,那么 UnsToStr 将损坏结构的后向指针,或损坏返回给 DisplayScore调用者的地址,或对两者都有损坏。这时,机器很可能瘫痪,所以应该注意 这个问题。但是,如果strScore,不只是局部变量的话,可能不会注意到UnsToStr 破坏了 存储器中跟在 strScore 后面的变量。 我相信会有程序员争辩:将strScore 定义成恰好保存最大字符串是有风险的。这的确 有风险,但仅当程序员写出象UnsToStr 最后版本一样的代码之时。事实上,没有必要象上 面那样施展伎俩:因为可以通过在局部缓冲区中建立字符串,安全有效地实现 UnsToStr, 然后将最终产物复制到 str: void UnsToStr(unsigned u,char *str) { char strDigits[6]; char *pch; /* u超出范围了吗?使用 UlongToStr…*/ ASSERT(u <= 65536); pch = &strDigits[6]; *pch = ‘\0’; do *--pch = u % 10 + ‘0’; while((u/=10)>0); strcpy(str,pch); } 需要记住的是:除非 str 已在别处定义,象str 那样的指针不会指向被用作工作空间 缓冲的存储区。为了提高效率,象str 这样的指针是通过引用传递输出而不是通过值传递 输出的。 私有数据自己管 当然,还会有程序员认为在 UnsToStr 中调用 strcpy 效率太低。毕竟UnToStr 是要创 指向输出的指针不是指向工作空间缓冲区的指针 111 建一个输出串。那么当你通过返回一个指向你已经建立的字符串的指针来节省一些循环时, 为什么不将它拷贝到另一个缓冲区呢? char *strFromUns(unsigned u) { static char strDigits = “?????”;/* 5个字符+’\0’*/ char *pch; /* u 超出范围了吗?使用 UlongToStr …*/ ASSERT(u<=65535); /* 将每位数字自后向前存储在strDigits 中 */ pch = &strDigits[5]; ASSERT(*pch == ‘\0’); do *--pch = u%10 + ‘0’; while((u/=10)>0); return(pch); } 上面的代码与上一节所给的代码几乎相同,所不同的只是将StrDigits 声明为静态的, 这样即使在 strFromUns返回以后分配给 strDigits 的存储区仍然保存。 设想一下:如果要实现将两个无符号值转换成字符串的函数,你就会写成: strHighScore = strFromUns(HighScore); … strThisScore = strFromUns(Score); 这会有什么错误吗?你能看出调用 strFromUns来转换 Score 就损坏了 strHighScore 所指的字符串吗? 你可能争辩说错误在上面的这个代码中,而不是在strFromUns中。但是要记住我们在 第5章中讲的:函数能正确地工作是不够的,它们还必须能防上程序员产生明显的错误。 由于你和我都知道某些程序员将要犯类似上述的错误,我总可以证实strFromUns有个界面 错。 即使程序员已经意识到 strFromUns的字符串很脆弱,也会情不自禁地引入错误。假设 他们调用了 strFromUns 然后调用另一个函数,而他们并不知道这个函数也调用 strFromUns,因此破坏了他们的字符串。或者,假设有多条代码的执行线,其中一条执行 线凋用 strFromUns,那么就有可能冲掉另外一个执行线仍在使用的字符串。 即使上述问题与 strFromUns本身的问题比起来来是次要的,但是,这些问题肯定要出 现,随着项目的发展,还可能是多次出现。因此,当你决定在你的某个函数中插入对 strFromUns 的调用时,你必须做到下列两点: ���� 确保你的调用者(以及你的调用者的调用者等等)中没有任何一个正在使用由 strFromUns返回的字符串。换句话说,你必须验证没有任何一个这样的函数在可 112 能调用你的函数的调用链上,并假定strFrornUns的私有缓冲区是被保护的。 ���� 确保你不调用任何调用 strFromUns 的函数以防损坏作仍需要的字符串。当然这就 意味着你不能调用那些直接、间接地调用了 strFromUns 的函数。 如果你插入一个对strFromUns的调用而又不进行上面的两项检查那么你就冒着引入错 误的风险。但是,设想一下当程序员改正错误和增加新特征时遵守上面的两种情况该有多 么困难。每一次改变对你的函数调用的调用链,或修改你的代码所调用的函数,这些维护 人员都必须重新检验上面的两种情况。你认为他们会做到吗?很难。那些程序员甚至都没 有认识到他们应该检验上述条件。毕竟,他们只做改出错误、重组代码和增加新特征;那 么他们对函数 strFromUns 该做什么呢?他们可能从未用过,甚至从未见过这个函数。 正是由于这样的设计使得在维护程序时很容易引入错误,因此象strFromUns 这样的函 数可能一而再地引起错误。当然,当程序员要孤立 strFromUns 的错误时,错误并不在 strFromUns内而是在不正确地使用 strFromUns 的代码内。因此只咒骂 strFromUns并不能 解决真正的问题。程序员要改正这种特殊的错误,随着时间的流逝在程序中不再使用 strFromUns。 全局 量问题 全局 量问题 全局 量问题 全局 量问题 上述上述上述上述strFromUns例子说明了当借助指 向静态存储区的指针返回数据时, 你将面临的 例子说明了当借助指 向静态存储区的指针返回数据时, 你将面临的 例子说明了当借助指 向静态存储区的指针返回数据时, 你将面临的 例子说明了当借助指 向静态存储区的指针返回数据时, 你将面临的 危危危危 险。例子 没有说明每当你向非 局部的缓冲区传递 数据时,也存在着 同样的危险。你可 以改 险。例子 没有说明每当你向非 局部的缓冲区传递 数据时,也存在着 同样的危险。你可 以改 险。例子 没有说明每当你向非 局部的缓冲区传递 数据时,也存在着 同样的危险。你可 以改 险。例子 没有说明每当你向非 局部的缓冲区传递 数据时,也存在着 同样的危险。你可 以改 写写写写strFromUns使它能在 全局缓冲区,甚至在 永久缓冲区内建立 数值串, (永久缓冲区一般 使它能在 全局缓冲区,甚至在 永久缓冲区内建立 数值串, (永久缓冲区一般 使它能在 全局缓冲区,甚至在 永久缓冲区内建立 数值串, (永久缓冲区一般 使它能在 全局缓冲区,甚至在 永久缓冲区内建立 数值串, (永久缓冲区一般 在程序开始处利 用 在程序开始处利 用 在程序开始处利 用 在程序开始处利 用 malloc 建立) ,但是情况没有任何变化,因为程序员仍能连续两次调 用 建立) ,但是情况没有任何变化,因为程序员仍能连续两次调 用 建立) ,但是情况没有任何变化,因为程序员仍能连续两次调 用 建立) ,但是情况没有任何变化,因为程序员仍能连续两次调 用 strFromUns,并且第二次调用会破 坏第一次调用返回的字符串。 ,并且第二次调用会破 坏第一次调用返回的字符串。 ,并且第二次调用会破 坏第一次调用返回的字符串。 ,并且第二次调用会破 坏第一次调用返回的字符串。 因此,经 验方法是:除非你有 绝对的必要,否则 ,不要向全局缓冲 区传递数据。不要 因此,经 验方法是:除非你有 绝对的必要,否则 ,不要向全局缓冲 区传递数据。不要 因此,经 验方法是:除非你有 绝对的必要,否则 ,不要向全局缓冲 区传递数据。不要 因此,经 验方法是:除非你有 绝对的必要,否则 ,不要向全局缓冲 区传递数据。不要 忘记,静态局部缓冲 区和全局缓冲区一样。 忘记,静态局部缓冲 区和全局缓冲区一样。 忘记,静态局部缓冲 区和全局缓冲区一样。 忘记,静态局部缓冲 区和全局缓冲区一样。 在设计函 数过程中,当需要向 缓冲区传递数据时 ,安全的方法是让 调用者分配一个局 在设计函 数过程中,当需要向 缓冲区传递数据时 ,安全的方法是让 调用者分配一个局 在设计函 数过程中,当需要向 缓冲区传递数据时 ,安全的方法是让 调用者分配一个局 在设计函 数过程中,当需要向 缓冲区传递数据时 ,安全的方法是让 调用者分配一个局 部(非静态)的缓冲 区。 部(非静态)的缓冲 区。 部(非静态)的缓冲 区。 部(非静态)的缓冲 区。 如果能够迫使函数调 用者提供一个指向输出缓冲区的指针, 那么就可以避免全部问题 如果能够迫使函数调 用者提供一个指向输出缓冲区的指针, 那么就可以避免全部问题 如果能够迫使函数调 用者提供一个指向输出缓冲区的指针, 那么就可以避免全部问题 如果能够迫使函数调 用者提供一个指向输出缓冲区的指针, 那么就可以避免全部问题 。。。。 函数的寄生虫 向公共缓冲区传递数据是危险的,但是假若比较小心并且运气很好的话可能会摆脱危 险。但是,编写依赖于别的程序内部处理的寄生函数不仅危险,而且也是不负责任的:如 果宿主函数有了更改,寄生函数也就毁坏了。 我所知道的寄生函数的最好例子,来自一个广泛推广、移植的 FORTH 程序设计语言的 标准程序。在 70年代末 80年代初,FIG(FORTH Interest Group)试图通过提供公共的 FORTH-77 标准程序来刺激人们对 FORTH 语言的兴趣。那些FORTH 程序定义了三个标准函数 : 不要利用静态(或全局)量存储区传递数据 113 FILL,它以字节为单位填充存储块;CMOVE,它用“头到头”的算法拷贝存储; 0 ) *pbTo++ = *pbFrom++; } 而FILL 的实现却令人惊异: /* FILL 填充某一存储域 */ void FILL (byte *pb,size_t size,byte b) { if(size>0) { *pb = b; CMOVE(pb,pb+1,size-1); } } FILL 调用CMOVE 来实现它的功能,在弄清它是怎样工作之前,有点费解。这个实现方 法要么是“巧妙的”,要么就是“粗劣的”,就看你怎么看了。如果你认为 FILL 是巧妙的, 那么考虑一下:FORTH 可能需要将 CMOVE 实现为一个头到头的转移。但是,如果为了提高效 率。而改写 CMOVE,用long(长字)而不是 byte(字节)来移动存储,那又将如何呢?在 我看来,上面的 FILL 程序是粗劣的,而不是巧妙的。 但是假设你不打算改变 CMOVE。你甚至可在CMOVE 内写上注释:FILL 依赖于它的内部 处理,以警告其他的程序员,但这样只解决了一半问题。 假定你做过用于控制四自由度的工厂机器人的控制代码,每个自由度都有256 个位置。 只要用映射到I/O存储器的四个字节,就可以设计出这个机器人,其中每个字节控制一个 自由度。 为了保存一个自由度的位置,要将 0到255 之间的某个值写到存储器的相应位置内。 为了检索一个自由度的当前位置(特别是当某个自由度的位置要移到一个新位置时尤其有 用)要从相应的存储位置上读出相应值。 如果想将四个自由度复位到初始点(0,0,0,0)。从理论上讲,可以写成如下代码: FILL(pbRobotDevice,4,0);/* 将机器人复位到初始点状态 */ 按前述方式的FILL 定义,该代码是不能正常工作的。FILL 将给第一个自由度写上 0, 114 其它三个自由度因之填入垃圾,导致机器人处于紊乱状态。为什么会这样呢?如果查看一 下FILL 的设计,就可以明白,它是将以前存储的字节拷贝到当前字节来实现填充的。但是 , 当FILL 读出第一个字节时,希望它是 0。可是由于读到的应是第一个自由度的当前位置, 因此这个位置可能不是 0。因为在存储0到试图将该位置值读回之间这短短的若干分之一秒 内,第一自由度可能还没有移动到位置 0处。这个位置值可能是任意值,因此将第二自由 度发送到某个不确定点。类似地,第三和第四自由度也将被发送到未知的地方。 为了使 FILL 正确地工作就必须保证 FILL 可以从存储器中读到刚写进存储的那个值。 可是,对于映射到 I/O、ROM、被保护的存储、或空存储库的存储区来说,无法保证上面的 要求。 我的观点是 FILL 之所以有错误,是因为它剽窃了别的函数的私有细节并滥用了这些知 识。在除了 RAM之外的其它形式的存储器上,FILL 都不能正确地工作,这还是次要问题, 更主要的是它又一次证明了在任何时候只要不编写直观代码,就是自找麻烦! 断言 使程序员 更加诚实 断言 使程序员 更加诚实 断言 使程序员 更加诚实 断言 使程序员 更加诚实 假如假如假如假如CMOVE 用了断言 来验证其参数的合法 性(即源存储空间 在被拷贝到目标存 储空间 用了断言 来验证其参数的合法 性(即源存储空间 在被拷贝到目标存 储空间 用了断言 来验证其参数的合法 性(即源存储空间 在被拷贝到目标存 储空间 用了断言 来验证其参数的合法 性(即源存储空间 在被拷贝到目标存 储空间 之前不被破坏) ,那么编写 之前不被破坏) ,那么编写 之前不被破坏) ,那么编写 之前不被破坏) ,那么编写 FILL 的程序员在第一次测 试该代码时就碰到了断言。这样程序 的程序员在第一次测 试该代码时就碰到了断言。这样程序 的程序员在第一次测 试该代码时就碰到了断言。这样程序 的程序员在第一次测 试该代码时就碰到了断言。这样程序 员就有两个选择:要 么用合理的算法重 写 员就有两个选择:要 么用合理的算法重 写 员就有两个选择:要 么用合理的算法重 写 员就有两个选择:要 么用合理的算法重 写 FILL,要么 从 ,要么 从 ,要么 从 ,要么 从 CMOVE 中删除相应的断言。 幸运的 中删除相应的断言。 幸运的 中删除相应的断言。 幸运的 中删除相应的断言。 幸运的 是几乎没有程序员为 了使得糟糕的 是几乎没有程序员为 了使得糟糕的 是几乎没有程序员为 了使得糟糕的 是几乎没有程序员为 了使得糟糕的 FILL 程序能够工作而删除 程序能够工作而删除 程序能够工作而删除 程序能够工作而删除 CMOVE 的断言。 的断言。 的断言。 的断言。 断言还能阻 止 断言还能阻 止 断言还能阻 止 断言还能阻 止 FreeWindowTree中的自由存储空间错误 进入项目的原版源代码。 通过 中的自由存储空间错误 进入项目的原版源代码。 通过 中的自由存储空间错误 进入项目的原版源代码。 通过 中的自由存储空间错误 进入项目的原版源代码。 通过 使使使使 用断言和 第 用断言和 第 用断言和 第 用断言和 第 3章中的调试代码,当用有子窗口的窗口第一次测 试 章中的调试代码,当用有子窗口的窗口第一次测 试 章中的调试代码,当用有子窗口的窗口第一次测 试 章中的调试代码,当用有子窗口的窗口第一次测 试 FreeWindowTree时, 就 时, 就 时, 就 时, 就 会会会会 引发相应 的断言。除非碰上了 想通过扣除断言来 引发相应 的断言。除非碰上了 想通过扣除断言来 引发相应 的断言。除非碰上了 想通过扣除断言来 引发相应 的断言。除非碰上了 想通过扣除断言来 ““““排除 排除 排除 排除 ””””断言失败的 个别程序员,大多 数 断言失败的 个别程序员,大多 数 断言失败的 个别程序员,大多 数 断言失败的 个别程序员,大多 数 程序员为了消除断言 失败都会修 改 程序员为了消除断言 失败都会修 改 程序员为了消除断言 失败都会修 改 程序员为了消除断言 失败都会修 改 FreeWindowTree 本身。 本身。 本身。 本身。 物非所用 用一把螺丝刀来播开油漆罐的盖子,然后又用这把螺丝刀来搅拌油漆,这是家庭维护 中最熟悉的举动之一,我有一大堆各种颜色的螺丝刀可以来证明这一点。但是,当人们知 道这样会糟踏螺丝刀,不应该这样做时,为什么还要用螺丝刀来搅拌油漆呢?原因就在于, 之所以这样做是因为当时这样很方便,而且能够解决问题。当然,有一些程序设计手段也 很方便并保证能工作,但是就象那把螺丝刀一样,它们没有发挥它们本来的作用。 例如下面的代码,它将比较的结果作为计算表达式的一部分 unsigned atou(char *str); /* atoi 的无符号版本 */ /* atoi 将ASCII 字符串转换为整数值 */ int atoi(char *str) { 不要写寄生函数 115 /* str 的格式为“[空白][+/-]数字”*/ while(isspace(*str)) str++; if(*str == ‘-’) return (-(int)atou(str+1)); return ((int)atou(str + (*str ==‘ +’))); } 上面的代码把(*str ==‘+’)的测试结果加到字符串指针上,从而跳过可选的前导 ‘+’号。因为按ANSI 标准,任何关系操作的结果或者是0或者是 1,因此可以这样写代码 。 但是某些程序员可能没有意识到,ANSI 标准不是一本告诉你可以做什么和不可以做什么的 规则书。你可以写出形式合格的代码,但却违反了它的意图。因此,不要因为允许写出如 上那样的代码,就意味着应该写出这种代码。 但是,真正的问题与代码毫无关系,而与程序员的看法紧密相关。如果程序员觉得在 计算表达式中使用逻辑求值非常好的话,他还会愿意采用什么别的安全性未知的捷径吗? 标准也会改 变 标准也会改 变 标准也会改 变 标准也会改 变 当发 行 当发 行 当发 行 当发 行 FORTH-83 标准时, 一 些 标准时, 一 些 标准时, 一 些 标准时, 一 些 FORTH 程序员发 现他们的代码与其 不一致了。原因是: 程序员发 现他们的代码与其 不一致了。原因是: 程序员发 现他们的代码与其 不一致了。原因是: 程序员发 现他们的代码与其 不一致了。原因是: 在在在在FORTH-77 标准中, 布尔值的结果定义 为 标准中, 布尔值的结果定义 为 标准中, 布尔值的结果定义 为 标准中, 布尔值的结果定义 为 0和和和和1, 由于各种原因,在 , 由于各种原因,在 , 由于各种原因,在 , 由于各种原因,在 FORTH-83 标准中, 标准中, 标准中, 标准中, 将将将将 布尔值的结果改 为 布尔值的结果改 为 布尔值的结果改 为 布尔值的结果改 为 0和和和和-1。当然,这种改变只 破坏了那些依赖于 。当然,这种改变只 破坏了那些依赖于 。当然,这种改变只 破坏了那些依赖于 。当然,这种改变只 破坏了那些依赖于 ““““真真真真””””为为为为1的代码。 的代码。 的代码。 的代码。 并不只 是 并不只 是 并不只 是 并不只 是 FORTH 程序员遇到了这种情况 。 程序员遇到了这种情况 。 程序员遇到了这种情况 。 程序员遇到了这种情况 。 在在在在70年代末 到 年代末 到 年代末 到 年代末 到 80年代初, 年代初, 年代初, 年代初, UCSD Pascal 非常普及, 如果你在微机上使 用 非常普及, 如果你在微机上使 用 非常普及, 如果你在微机上使 用 非常普及, 如果你在微机上使 用 Pascal 的话 的话 的话 的话 ,,,, 多半 是 多半 是 多半 是 多半 是 UCSD Pascal。但是 后来,许 多 。但是 后来,许 多 。但是 后来,许 多 。但是 后来,许 多 UCSD Pascal 程序 员得到了 编译程序 的新版本 ,并 程序 员得到了 编译程序 的新版本 ,并 程序 员得到了 编译程序 的新版本 ,并 程序 员得到了 编译程序 的新版本 ,并 且发现在 新编译程序上,他们 的代码不能工作。 原因又是:编译程 序的编写者,由于 各种 且发现在 新编译程序上,他们 的代码不能工作。 原因又是:编译程 序的编写者,由于 各种 且发现在 新编译程序上,他们 的代码不能工作。 原因又是:编译程 序的编写者,由于 各种 且发现在 新编译程序上,他们 的代码不能工作。 原因又是:编译程 序的编写者,由于 各种 原因改变了 原因改变了 原因改变了 原因改变了 ““““真真真真””””的值从而破坏了依赖 于原先值的所有程序。 的值从而破坏了依赖 于原先值的所有程序。 的值从而破坏了依赖 于原先值的所有程序。 的值从而破坏了依赖 于原先值的所有程序。 谁又能说得准将来 的 谁又能说得准将来 的 谁又能说得准将来 的 谁又能说得准将来 的 C标准不会发生更改呢 ?即 使 标准不会发生更改呢 ?即 使 标准不会发生更改呢 ?即 使 标准不会发生更改呢 ?即 使 C不变改, 不变改, 不变改, 不变改, C++或者别的派生语言 或者别的派生语言 或者别的派生语言 或者别的派生语言 是是是是 否会发生变化? 否会发生变化? 否会发生变化? 否会发生变化? 程序设计语言综合症 那些不知道 C语言代码是如何转换为机器代码的程序员,经常试图通过使用简炼的 C 语句来提高机器代码的质量。他们认为,如果使用最少量的 C语句,那么就应该得到最少 量的机器代码。在C代码数量和相应的机器代码数量之间存在着一定的关系,但当把这种 关系应用到单行代码时,这种关系便不适用了。 你还记得第 6章的uCycleCheckBox函数吗? unsigned uCycleCheckBox(unsigned uCur) 不要滥用程序设计语言 116 { return ((uCur<=1)?(uCur?0:1) :(uCur == 4)?2 :(uCur +1)); } uCycleCheckBox可以说是简炼 C代码,但是正如我指出的那样,它产生了很糟的机器 代码。再看上一节中给出的返回语句: return((int)atou(str + (*str == ‘+’))); 如果你使用的是优化得很好的编译程序,并且你的目标机不用任何分支指令即可生成 0/1 的结果,那么把比较的结果加到指针上,这条语句将产生相当好的代码。如果不具备上 面描述的条件,编译程序很可能要作比较在内部将其扩展为 ?:操作,生成的代码就好象 你写了如下所示的 C代码一样: return ((int)atou(str+((*str == ‘+’)?1:0))); 由于“? :”操作只不过是化了妆的 if-else 语句,因此所得到的代码可能比下面明显 、 直观的代码更坏: if(*str == ‘+’)/* 跳过可选的‘+’号 */ str++; return ((int)atou(str)); 当然还有其它的方法来优化上面的代码。我曾经见到这样一些情况:程序员将一个两 行的if语句用“||”操作符“改进”成一行: if( (*sdtr != ‘+’) || str++ )/* 跳过可选择的’+’号 */ return ((int)atou(str)); 这样的代码之所以可以工作是因为C语言具有短路求值规则,但是将代码放在一行上 并不能保证比使用if语句产生更好的机器代码;如果编译程序产生的0或1有副作用的话 , 使用“||”甚至会得到更坏的代码。 需要记住这个简单规则:把“||”用于逻辑表达式,把“?:”用于条件表达式,把if 用于条件语句。这并不是说把它们混合起来就好,而往往出于使代码高效和可维护。 我的观点是:如果你总是使用稀奇古怪的表达式,以便把 C代码尽量写在源代码的一 行上,从而达到最好的瑜伽状态的话,你很可能患有可怕的“一行清”疾病(也称为程序 设计语言综合症)。那么你就要做个深呼吸,反复地提醒自己:“多行源代码可能产生效率 高的机器代码。多行源代码可能产生效率高的机器代码……。” 切勿傲慢轻率 世上最令人厌烦的书,就是那些由专家撰写的、其内容充满了没有必要的技术术语的 书。他们不说“该错误可能使你的系统暂停或失败”,而是说“这样的程序设计缺陷可能导 致丧失对系统的控制或引起系统终止”。他们还用象“公理化程序验证”和“缺陷分类”这 紧凑的CCCC代码并不能保证得到高效的机器代码 117 样的术语,好象程序员每天都要用到些术语似的。啧!啧!啧!啧!这些作者将他们要说 明的信息隐藏在含糊难懂的术语中,这不仅没有帮助读者,反而使读者更糊涂了。不只是 书作者这么做,有一些程序员也热衷于编写含糊难懂的代码,他们认为只有代码含糊不清 才能给人留下深刻的印象。 例如,看看下面的函数是怎样工作的: void *memmove(void *pvTo,void *pv From,size_t size) { byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *)pvFrom; ((pbTo < pbFrom)?(tailmove:headmove)(pbTo,pbFrom,size) return (pvTo); } 如果我将它改写如下,该函数是否更好理够呢? void *memmove(void *pvTo,void *pvFrom,size_t size) { byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *) pvFrom; if(pvTo < pbFrom) tailmove(pbTo,pbFrom,size); else headmove(pbTo,pbFrom,size); return (pbTo); } 第一个例子看起来不象合法的 C语言程序,但实际上是。比较一下是很有好处的,第 一个例子编译以后产生的代码比第二个例子所产生的代码要少得多。尽管如此,有多少程 序员能理解第一个函数是怎样工作呢?如果他们必须维护该代码那又将如何呢?如果你写 了正确的代码,但是没有人能够理解,那又有什么意义呢?如果不打算让别人看懂,你甚 至可以用手工优化的汇编语言来编写这个函数。 下面的代码是使许多程序员费解的另一个例子: while(expression) { int i = 33; char str[20]; … 其他代码 … } 请迅速回答,是每一次循环都要初始化i,还是仅仅第一次进入循环时对i进行初始化 呢?你能不用思考就知道正确答案吗?如果你不能肯定,说明你训练有索,因为即使是专 118 家级C程序员通常也要在脑子里浏览一下C语言的规则才能回答这个问题。 如果稍微修改一下,成为如下所示的代码。 While(expression) { int i; char str[20]; i = 33; … 其它代码 … } 你对每次通过循环都要将i置为33还有什么疑问吗?在你的小组中还有程序员对此表 示怀疑吗?当然没有。 和小说作家不一样,他们只有一类读者,而程序员却有两类读者:使用代码的用户和 必须对代码进行更新的程序维护人员。程序员经常忘记这一点。我知道,忘掉用户的程序 员并不多,但是根据我这些年读的程序来推测,程序员似乎忘记了他们的第二类读者:程 序维护人员。 应该编写可维护的代码这一观点并不新奇,程序员知道应该编写这样的代码。可是, 他们总是没有认识到,他们虽然整天编写可维护的代码,但是如果他们使用只有 C语言专 业人员才能理解的语言,那么这些代码实际上是不可维护的。根据定义,可维护的代码应 该是维护人员可以很容易地理解并且在修改时不会引入错误的代码。不管怎样,程序维护 人员一般都是该项目的新手而不是专家。 因此,当你考虑你的读者时,一定还要考虑到程序维护人员。下一次当你又想写下面 的代码时: strncpy(strDay,&“SunMonTueWedThuFriSat”[day*3],3); 你可以制止自己,并且以一种不让读者吃惊又很好理解的方式编写代码: static char strDayNames[]=”SunMonTueWedThuFriSat”; … strncpy(strDay,&strdayNames[day*3],3); 谁在维护 程序 谁在维护 程序 谁在维护 程序 谁在维护 程序 在在在在Microsoft 公司,每 个程序员编写新代 码的数量,与他对所 从事研制的产品内 部情 公司,每 个程序员编写新代 码的数量,与他对所 从事研制的产品内 部情 公司,每 个程序员编写新代 码的数量,与他对所 从事研制的产品内 部情 公司,每 个程序员编写新代 码的数量,与他对所 从事研制的产品内 部情 况的熟悉 程度成正比,对产品 比较熟悉的程序员 ,编写新代码的是 多一些,而较少进 行维 况的熟悉 程度成正比,对产品 比较熟悉的程序员 ,编写新代码的是 多一些,而较少进 行维 况的熟悉 程度成正比,对产品 比较熟悉的程序员 ,编写新代码的是 多一些,而较少进 行维 况的熟悉 程度成正比,对产品 比较熟悉的程序员 ,编写新代码的是 多一些,而较少进 行维 护性 的程序 设计。 当然, 如果对 项目了 解很少那 么就要 花大量 时间来 阅读别 人写的 代码、 护性 的程序 设计。 当然, 如果对 项目了 解很少那 么就要 花大量 时间来 阅读别 人写的 代码、 护性 的程序 设计。 当然, 如果对 项目了 解很少那 么就要 花大量 时间来 阅读别 人写的 代码、 护性 的程序 设计。 当然, 如果对 项目了 解很少那 么就要 花大量 时间来 阅读别 人写的 代码、 修改 别人的 错误、 对于已 有特征 作少量 的局部性 的增补 。直观 地看, 这种安 排很有 意义。 修改 别人的 错误、 对于已 有特征 作少量 的局部性 的增补 。直观 地看, 这种安 排很有 意义。 修改 别人的 错误、 对于已 有特征 作少量 的局部性 的增补 。直观 地看, 这种安 排很有 意义。 修改 别人的 错误、 对于已 有特征 作少量 的局部性 的增补 。直观 地看, 这种安 排很有 意义。 如果你不知道系统是 怎样写的,那你就不能给系统增加重要的 功能。 如果你不知道系统是 怎样写的,那你就不能给系统增加重要的 功能。 如果你不知道系统是 怎样写的,那你就不能给系统增加重要的 功能。 如果你不知道系统是 怎样写的,那你就不能给系统增加重要的 功能。 为一般水平的程序员编写代码 119 概括起来,这种安排的结果就是:一般来说,有经验的程序员编写出代码,新手维护 代码。我并不是说不应该这样安排,这种安排是实用的而且就是这么作的。但是,只有在 有经验的程序员认识到,他们有责任使得他们所编写的代码,能够被程序维护人员和程序 设计新手维护,这时这种安排才能行得通。 不要错误 的理解我的意思,我 并不是说你应该写 初级 的 不要错误 的理解我的意思,我 并不是说你应该写 初级 的 不要错误 的理解我的意思,我 并不是说你应该写 初级 的 不要错误 的理解我的意思,我 并不是说你应该写 初级 的 C程序以使 程序设计新手能够 程序以使 程序设计新手能够 程序以使 程序设计新手能够 程序以使 程序设计新手能够 理解你的 代码,这样就和总是 编写专家 级 理解你的 代码,这样就和总是 编写专家 级 理解你的 代码,这样就和总是 编写专家 级 理解你的 代码,这样就和总是 编写专家 级 C代码一样愚 蠢了。我要说的是 ,当你能用普通 代码一样愚 蠢了。我要说的是 ,当你能用普通 代码一样愚 蠢了。我要说的是 ,当你能用普通 代码一样愚 蠢了。我要说的是 ,当你能用普通 程度语言表达清楚时 , 就应该避免使用困难的或神秘 的 程度语言表达清楚时 , 就应该避免使用困难的或神秘 的 程度语言表达清楚时 , 就应该避免使用困难的或神秘 的 程度语言表达清楚时 , 就应该避免使用困难的或神秘 的 C。 如果你的代码很容易理解, 那 。 如果你的代码很容易理解, 那 。 如果你的代码很容易理解, 那 。 如果你的代码很容易理解, 那 么么么么 新手在维护时就不易 引入错误,你也不必总是向他们解释代码 是如何如何地工作了。 新手在维护时就不易 引入错误,你也不必总是向他们解释代码 是如何如何地工作了。 新手在维护时就不易 引入错误,你也不必总是向他们解释代码 是如何如何地工作了。 新手在维护时就不易 引入错误,你也不必总是向他们解释代码 是如何如何地工作了。 小结 我们已经考察了一些有争议的编码实践,其中大部分初看上去都很好。但是,正如我 们已经看到的,看一遍,甚至看五遍,你可能都没有警觉到那些巧妙代码产生的微妙的副 作用。因此建议:如果你发现自己编写的代码用了较多技巧,那么停止编写代码并寻找别 的解决方法。你的“技巧”也许很好,但是如果你确实觉得它有些费解,那就是你的直觉 在告诉你,情况不妙。听凭你的直觉,如果你认为你的代码确有技巧的话,那么,这实际 上是在对自己讲,尽管这个算法应该直观而实际并非如此,但它却产生了正确的结果。那 么这个算法的错误同样也会不明显。 因此,编写直观的代码才是真正的聪明人。 要点: ���� 如果你要用到的数据不是你自己所有的,那怕是临时的,也不要对其执行写操作。 尽管你可能认为读数据总是安全的,但是要记住,从映射到I/O的存储区读数据, 可能会对硬件造成危害。 ���� 每当释放了存储区人们还想引用它,但是要克制自己这么做。引用自由存储区极 易引起错误。 ���� 为了提高效率,向全局缓冲区或静态缓冲传递数据也是很吸引人的,但是这是一 条充满风险的捷径。假若你写了一个函数,用来创建只给调用函数使用的数据, 那么就将数据返回给调用函数,或保证不意外地更改这个数据。 ���� 不要编写依赖支持函数的某个特殊实现的函数。我们已经看到,FILL 例程不该象 给出的那样调用 CMOVE,这种写法只能作为坏程序设计的例子。 ���� 在进行程序设计的时候,要按照程序设计语言原来的本意清楚、准确地编写代码。 避免使用有疑问的程序设计惯用语,即使语言标准恰好能保证它工作,也不要使 用。请记住,标准也在改变。 ���� 如果能用 C语言有效地表示某个概念,那么类似地,相应的机器代码也应该是有 效的。逻辑上讲似乎应该是这样,可是事实上并非如此。因此在你将多行C代码 压缩为一行代码之前,一定要弄清楚经过这样的更改以后,能否保证得到更好的 120 机器代码。 ���� 最后,不要象律师写合同那样来编写代码。如果一般水平的程序员不能阅读和理 解你的代码,那就说明你的代码太复杂了,使用简单一点的语言。 练习: 1) C程序设计员经常修改传递给函数的参数。为什么这种做法没有违反输入数据的写 权限呢? 2) 前面已经介绍了有关下面strFromUns 函数的主要缺陷(复习一下,它将非保护缓 冲区里的数据返回),除此之外,strDigits 的声明方式还有什么错误吗? char *strFromUns(unsigned u) { static char strDigits = “?????”;/* 串长为 5个char + ‘ 0’*/ char *pch; /* u超出范围吗?使用 UlongToStr */ ASSERT( u <= 65535); /* 将每一位数字自后向前存储在strDigits 中 */ pch = &strDigits[5]; ASSERT(*pch == ‘\0’); do *--pch = u%10 + ‘0’; while((u/= 10)>0); return (pch); } 3) 在我阅读一本杂志上的代码时,我注意到了有这样一个函数,它用memset 函数将 三个局部变量置为 0,如下所示: void DoSomething(…) { int i; int j; int k; memset(&k,0,3*sizeof(int)); …… 这样的代码在某些编译程序上可以运行,但是为什么要避免使用这种技巧呢? 4) 尽管计算机在只读存储器中存有部分操作系统的程序,但假如为了避免不必要的 内部操作,你绕过了系统界面而直接调用 ROM过程,为什么这又是有风险的呢? 5) 传统上,C允许程序员向函数传递参数的个数比函数期望接收的参数个数少。某些 121 程序员利用这个特征来优化调用,这些调用并不要求全部参数。例如: … DoOperation(opNegAcc); /* 不需要传递 val */ … void DoOperation(operation op,int val) { switch(op) { case opNegAcc: accumulator = - accumulator; break; case opAddVal: accumulator + =val; break; … 尽管这样优化仍能工作但为什么要避免这么做呢? 6) 下面的断言是正确的,但是,为什么要改写它呢? ASSERT((f&1)==f); 7) 请研究使用以下代码的 memmove 的另一版本: ((pbTo>2)? ���� 避免使用第 7章中的效率技巧? 这是个充满问题的表吗?我不这样认为。我问你:“你认为吉尔和约克谁会读这本 书并按照书中的建议取做?” 吉尔和约克谁会去阅读《程序设计风格要素》或者其他 指导性书籍,并按照书中的建议去做呢? 读者应该注意到,由于约克的优先顺序安排,他会把注意力集中在对产品不利的代码 上,他回在如何使每一行代码尽量快和小上浪费时间,而对产品长期健全却很少考虑。 吉尔却相反,根据她的优先顺序,她把注意力集中在产品上,而不是代码上,除非证 明(或显然)确实需要考虑大小和速度,否则她不考虑大小和速度。 现在想一想,代码和产品哪个对你的公司更重要?因此你的优先顺序应该是怎样的? 建立自己优先级列表并坚持之 136 说出 道道 说出 道道 说出 道道 说出 道道 你是 否看 过别人 写的 代码, 并奇怪 他们 为什么 这样 写呢? 你是 否就此 代码问 过他 们, 你是 否看 过别人 写的 代码, 并奇怪 他们 为什么 这样 写呢? 你是 否就此 代码问 过他 们, 你是 否看 过别人 写的 代码, 并奇怪 他们 为什么 这样 写呢? 你是 否就此 代码问 过他 们, 你是 否看 过别人 写的 代码, 并奇怪 他们 为什么 这样 写呢? 你是 否就此 代码问 过他 们, 而后他们说:而后他们说:而后他们说:而后他们说:““““哎呀,我不知道我为什 么这样写,我猜我当时感觉到这样写正确 吧。 哎呀,我不知道我为什 么这样写,我猜我当时感觉到这样写正确 吧。 哎呀,我不知道我为什 么这样写,我猜我当时感觉到这样写正确 吧。 哎呀,我不知道我为什 么这样写,我猜我当时感觉到这样写正确 吧。 ”””” 我经常 评审代码, 寻找帮助程序 员改进技术 的方法。我 发现 我经常 评审代码, 寻找帮助程序 员改进技术 的方法。我 发现 我经常 评审代码, 寻找帮助程序 员改进技术 的方法。我 发现 我经常 评审代码, 寻找帮助程序 员改进技术 的方法。我 发现 ““““哎呀, 我不知道 哎呀, 我不知道 哎呀, 我不知道 哎呀, 我不知道 ””””这样 这样 这样 这样 的回答 相当普遍, 我还发现作出 这种回答的 程序员没有 建立明确的优 先顺序,他 们的决定 的回答 相当普遍, 我还发现作出 这种回答的 程序员没有 建立明确的优 先顺序,他 们的决定 的回答 相当普遍, 我还发现作出 这种回答的 程序员没有 建立明确的优 先顺序,他 们的决定 的回答 相当普遍, 我还发现作出 这种回答的 程序员没有 建立明确的优 先顺序,他 们的决定 似乎具 有随意性。 相反地,具有 明确优先顺 序的程序员 ,精确地知道 他们为什么 选择这个 似乎具 有随意性。 相反地,具有 明确优先顺 序的程序员 ,精确地知道 他们为什么 选择这个 似乎具 有随意性。 相反地,具有 明确优先顺 序的程序员 ,精确地知道 他们为什么 选择这个 似乎具 有随意性。 相反地,具有 明确优先顺 序的程序员 ,精确地知道 他们为什么 选择这个 实现,并且当问及他 为什么这样实现时,他能够说出道道。 实现,并且当问及他 为什么这样实现时,他能够说出道道。 实现,并且当问及他 为什么这样实现时,他能够说出道道。 实现,并且当问及他 为什么这样实现时,他能够说出道道。 小结 本章还没有提到一个很重要的观点,这就是:你必须养成经常询问怎样编写代码的习 惯。本书就是长期坚持询问一些简单问题所得的结果。 ���� 我怎样才能自动检测出错误? ���� 我怎样才能防止错误? ���� 这种想法和习惯是帮助我编写无错代码呢还是妨碍了我编写无错代码? 本章的所有观点都是询问最后一个问题所产生的结果。审视一下自己的观念很重要, 这些观念就反映了个人考虑问题的优先次序。如果你认为测试组的存在是为了测试你的代 码,那么在编写无错代码方面你就会继续有麻烦,因为你的观念在某种程度上告诉你,在 测试方面马虎点是可以的。如果你没有在观念上想着要编写无错代码,那你怎么可能会试 着编写无错代码呢? 如果你想编写无错代码,就应该清除妨碍你达到这一目标的观念,清除的方法就是反 问一下自己,自己的观念对达到目标是有益的还是有害的。 要点: ���� 错误既不会自己产生,也不会自己改正。如果你得到了一个错误报告,但这个错 误不再出现了。不要假设测试员发生了幻觉,而要努力查找错误,甚至要恢复程 序的老版本。 ���� 不能“以后”再修改错误。这是许多产品被取消的共同教训。如果在你发现错误 的时候就及时地更正了错误,那你的项目就不会遭受毁灭性的命运。当你的项目 总是保持近似于 0个错误时,怎么可能会有一系列的错误呢? ���� 当你跟踪查到一个错误时,总要问一下自己,这个错误是否会是一个大错误的症 状。当然,修改一个刚刚追踪到的症状很容易,但是要努力找到真正的起因。 ���� 不要编写没有必要的代码。让你的竞争者去清理代码,去实现“冷门”但无价值 的特征,去实现自由特征。让他们花大量的时间去修改由于这些无用代码所引起 的所有没有必要的错误。 137 ���� 记住灵活与容易使用并不是一回事。在你设计函数和特征时,重点是使之容易使 用;如果它们仅仅是灵活的,象 realloc 函数和 Excel 中的彩色格式特征那样, 那么就没法使得代码更加有用;相反地,使得发现错误变得更困难了。 ���� 不要受“试一试”某个方案以达到预期结果的影响。相反,应把花在尝试方案上 的时间用来寻找正确的解决方法。如果必要,与负责你操作系统的公司联系,这 比提出一个在将来可能会出问题的古怪实现要好。 ���� 代码写得尽量小以便于全面测试。在测试中不要马虎。记住,如果你不测试你的 代码,就没有人会测试你的代码了。无论怎样,你也不要期望测试组为你测试代 码。 ���� 最后,确定你们小组的优先级顺序,并且遵循这个顺序。如果你是约克,而项目 需要吉尔,那么至少在工作方面你必须改变习惯。 课题: 说服你们程序设计组建立或采纳一个优先级列表。如果你们公司具有不同层次的人才 (例如初级程序设计员,程序设计员,高级程序设计员,程序设计分析员),你可能要考虑 不同的层次使用不同的优先级列表,为什么? 138 附录AAAA 编码检查表 本附录给出的问题列表,总结了本书的所有观点。使用本表的最好办法是花两周时间 评审一下你的设计和编码实现。先花几分钟时间看一看列表,一旦熟悉了这些问题,就可 以灵活自如地按它写代码了。此时,就可以把表放在一边了。 一般问题 ── 你是否为程序建立了 DEBUG 版本? ── 你是否将发现的错误及时改正了? ─一 你是否坚持彻底测试代码.即使耽误了进度也在所不惜? ── 你是否依靠测试组为你测试代码? ─一 你是否知道编码的优先顺序? ─一 你的编译程序是否有可选的各种警告? 关于将更改归并到主程序 ─一 你是否将编译程序的警告(包括可选的)都处理了? ── 你的代码是否未用 Lint ─一 你的代码进行了单元测试吗? ─一 你是否逐步通过了每一条编码路径以观察数据流? ─一 你是否逐步通过了汇编语言层次上的所有关键代码? ── 是否清理过了任何代码?如果是,修改处经过彻底测试了吗? ─一 文档是否指出了使用你的代码有危险之处? ── 程序维护人员是否能够理解你的代码? 每当实现了一个函数或子系统之时 ─一 是否用断言证实了函数参数的有效性? ─一 代码中是否有未定义的或者无意义的代码? ─一 代码能否创建未定义的数据? ─一 有没有难以理解的断言?对它们作解释了没有? ─一 你在代码中是否作过任何假设? ─一 是否使用断言警告可能出现的非常情况? ─一 是否作过防御性程序设计?代码是否隐藏了错误? ─一 是否用第二个算法来验证第一个算法? ─一 是否有可用于确认代码或数据的启动(startup)检查? 139 ─一 代码是否包含了随机行为?能消除这些行为吗? ── 你的代码若产生了无用信息,你是否在DEBUG 代码中也把它们置为无用信息? ── 代码中是否有稀奇古怪的行为? ── 若代码是子系统的一部分,那么你是否建立了一个子系统测试? ── 在你的设计和代码中是否有任意情况? ── 即使程序员不感到需要,你也作完整性检查吗? ── 你是否因为排错程序太大或太慢,而将有价值的 DEBUG 测试抛置一边? ── 是否使用了不可移植的数据类型? ─一 代码中是否有变量或表达式产生上溢或下溢? ── 是否准确地实现了你的设计?还是非常近似地实现了你的设计? ── 代码是否不止一次地解同一个问题? ── 是否企图消除代码中的每一个if语句? ── 是否用过嵌套?:运算符? ── 是否已将专用代码孤立出来? ── 是否用到了有风险的语言惯用语? ─一 是否不必要地将不同类型的运算符混用? ── 是否调用了返回错误的函数?你能消除这种调用吗? ─一 是否引用了尚未分配的存储空间? ─一 是否引用已经释放了的存储空间? ── 是否不必要地多用了输出缓冲存储? ── 是否向静态或全局缓冲区传送了数据? ── 你的函数是否依赖于另一个函数的内部细节? ── 是否使用了怪异的或有疑问的C惯用语? ── 在代码中是否有挤在一行的毛病? ── 代码有不必要的灵活性吗?你能消除它们吗? ─一 你的代码是经过多次“试着”求解的结果吗? ─一 函数是否小并容易测试? 每当设计了一个函数或子系统后 ─一 此特征是否符合产品的市场策略? ─一 错误代码是否作为正常返回值的特殊情况而隐藏起来? ─一 是否评审了你的界面,它能保证难于出现误操作吗? ─一 是否具有多用途且面面俱到的函数? ─一 你是否有太灵活的(空空洞洞的)函数参数? ─一 当你的函数不再需要时,它是否返回一个错误条件? ─一 在调用点你的函数是出易读? 140 ─一 你的函数是否有布尔量输入? 修改错误之时 ── 错误无法消失,是否能找到错误的根源? ─一 是修改了错误的真正根源,还是仅仅修改了错误的症状? 141 附录BBBB 内存登录例程 本附录中的代码实现了第 3章中讨论的内存登录例程的一个简单链表版本。这个代码 有意作了简化使之便于理解,但这并不意味着它不可以用在那些大量地使用内存管理程序 的应用之中。但在你花时间重写代码使其使用AVL树、B树或其它可以提供快速查找的数据 结构之前,试一下这个代码验证它对于实际应用是否太慢了。你也许会发现这个代码很合 用,特别是在没有分配许多全局共享的存储模块之时,更是如此。 该文件中给出的实现是很直观的:每当分配一个内存块时,该例程就额外地分配一小 块内存以存放 blockinfo(块信息)结构,块信息中有登录信息(定义见下文)。当一个新的 blockinfo 结构创建时,就填充登录信息并置于链表结构的头部。该链表没有特意的顺序。 再次说明,该实现是精选的,因为它既简单又容易理解。 block.h: # ifdef DEBUG /*----------------------------------------------------------------------- - * blockinfo 是个数据结构.它记录一个已分配内存块的存储登录信息。 * 每个已分配的内存块在内存登录中都有一个相应的 blockinfo 结构 */ typedef struct BLOCKINFO { struct BLOCKINFO * pbiNext; byte* pb; /* 存储块的开始位置 */ size_t size; /* 存储块的长度 */ flag fReferenced; /* 曾经引用过吗?*/ }blockinfo; /* 命名:bi、*pbi */ flag fCreateBlockInfo(byte* pbNew, size_t sizeNew); void FreeBlockInfo(byte* pbToFree); void UpdateBlockInfobyte(byte* pbOld, byte* pbNew, size_t sizeNew); size_t sizeofBlock(byte* pb); void ClearMemoryRefs(void); void NoteMemoryRef(void* pv); void CheckMemoryRefs(void); flag fValidPointer(void* pv, size_t size); 142 #endif block.c: #ifdef DEBUG /*--------------------------------------------------------------------- * 该文件中的函数必须要对指针进行比较,而 ANSI 标准不能确保该操作是 * 可移植的。 * * 下面的宏将该文件所需的指针比较独立出来。该实现采用了总能进行直接 * 比较的“直截了当”的指针,下面的定义对某些通用80x86 内存模型不适用。 */ #define fPtrLess(pLeft,pRight) ((pLeft) < (pRight)) #define fPtrGrtr(pLeft,pRight) ((pLeft) < (pRight)) #define fPtrEqual(pLeft, pRight) ((pLeft) = = (pRight)) #define fPtrLEssEq(pLeft, pRight) ((pLEft) < = (pRight)) #define fPtrGrtrEq(pLeft, pRight) ((pLeft) > = (pRright)) /*------------------------------------------------------------------ */ /***** 私有数据/函数 *****/ /*------------------------------------------------------------------ */ /*------------------------------------------------------------------ * pbiHead 指向内存管理程序调试的单向链接列表。 */ static blockinfo* pbiHead = NULL; /*-------------------------------------------------------------------- * pbiGetBlockInfo(pb) * * pbiGetBlockInfo查询内存登录找到 pb所指的存储块,并返回指向内 * 存登录中相应 blockinfo 结构的指针。注意:pb必须指向一个已分配的 * 存储块,否则将得到一个断言失败;该函数或者引发断言或者成功,它从 * 不返回错误。 * * blockinfo * pbi; 143 *…… * pbi = pbiGetBlockInfo(pb); *// pbi->pb 指向pb所指存储块的开始位置 *// pbi->size 是pb所指存储块的大小 */ static blockinfo* pbiGetBlockInfo(byte* pb) { blockinfo* pbi; for( pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext ) { byte* pbStart = pbi->pb; /* 为了可读性 */ byte* pbEnd = pbi->pb + pbi->size – 1; if( fPtrGrtrEq( pb, pbStart )&& fPtrLessEq( pb, pbEnd )) break; } /* 没能找到指针?它是(a)垃圾?(b) 指向一个已经释放了的存储块? * 或(c)指向一个在由 fResizeMemory重置大小时而移动了的存储块? */ ASSERT( pbi != NULL ); return( pbi ); } /*------------------------------------------------------------------ */ /***** 公共函数 *****/ /*------------------------------------------------------------------ */ /*------------------------------------------------------------------ */ * fCreateBlockInfo(pbNew, sizeNew) * * 该函数为由 pbNew : sizeNew 定义的存储块建立一个登录项。如果成功地 * 建立了登录信息则该函数返回TRUE , 否则返回 FALSE 。 * * if( fCreateBlockInfo( pbNew, sizeNew )) * 成功 ─── 该内存登录具有 pbNew : sizeNew 项 * else * 失败 ─── 由于没有该项则应释放 pbNew 144 */ flag fCreateBlockInfo( byte* pbNew, size_t sizeNew ) { blockinfo* pbi; ASSERT( pbNew != NULL && sizeNew != 0 ); pbi = ( blockinfo* )malloc( sizeof( blockinfo )); if( pbi != NULL ) { pbi->pb = pbNew; pbi->size = sizeNew; pbi->pbiNext = pbiHead; pbiHead = pbi ; } return(flag)( pbi != NULL ); } /*------------------------------------------------------------------ * FreeBlockInfo( pbToFree ) * * 该函数清除由 pbToFree 所指存储块的登录项。pbToFree 必须指向一 * 个已分配存储块的开始位置,否则将得到一个断言失败。 */ void FreeBlockInfo( byte* pbToFree ) { blocinfo *pbi, *pbiPrev; for( pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext ) { if( fPtrEqual( pbi->pb, pbToFree )) { if( pbiPrev == NULL ) pbiHead = pbi->pbiHead; else pbiPrev->pbiNext = pbi->pbiNext; break; } 145 pbiPrev = pbi; } /* 如果是 pbi 是NULL 则pbToFree 无效 */ ASSERT( pbi != NULL ); /* 在释放之前破坏*pbi 的内容 */ memset(pbi, bGarbage, sizeof(blockinfo)); free(pbi); } /*------------------------------------------------------------------ * UpdateBlockInfo ( pbOld , pbNew , sizeNew ) * * UpdateBlockInfo查出pbOld 所指存储块的登录信息,然后该函数修 * 改登录信息已反映该存储块现在所处的新位置(pbNew)和新的字节长 * 度(sizeNew)。pbOld 必须指向一个已分配存储块的开始位置,否则 * 将得到一个断言失败。 */ void UpdateBlockInfo( byte* pbOld, byte* pbNew, size_t sizeNew ) { blockinfo* pbi; ASSERT( pbNew != NULL && sizeNew != 0 ); pbi = pbiGetBlockInfo( pbOld ); ASSERT( pbOld == pbi->pb );/* 必须指向一个存储块的开始位置 */ pbi->pb = pbNew; pbi->size = sizeNew; } /*------------------------------------------------------------------ * sizeofBlock (pb ) * sizeofBlock返回pb所指存储块的大小。pb必须指向一个已分配存储块 * 的开始位置,则将得到一个断言失败。 */ size_t sizeofBlock( byte* pb ) { blockinfo* pbi; pbi = pbiGetBlockInfo(pb); 146 ASSERT(pb == pbi->pb );/* 必须指向存储块的开始位置 */ return( pbi->size ); } /*------------------------------------------------------------------ */ /* 下面例程用来寻找丢失的存储块和悬挂的指针。有关这些例程的 */ /* 说明参见第三章 */ /*------------------------------------------------------------------ */ /*------------------------------------------------------------------ * ClearMemoryRefs( void ) * * ClearMemoryRefs将内存登录中所有存储块标志为未引用。 */ void ClearMemoryRefs( void ) { blockinfo* pbi; for( pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext ) pbi->fReferenced = FALSE; } /*------------------------------------------------------------------ * NoteMemoryRef( pv ) * * NoteMemoryRefs将pv所指的存储块标志为已引用。注意:pv不必指向一 * 个存储块的开始位置;它可以指向一个已分配存储块的任何位置。 */ void NoteMemoryRef ( void * pv ) ( blockinfo* pbi; pbi = pbiGetBlockInfo( (byte*)pv ); pbi->fReferenced = TRUE; } /*------------------------------------------------------------------ 147 * CheckMemoryRefs( void ) * CheckMemoryRefs 扫描内存登录以寻找未通过调用NoteMemoryRef进行标志 * 的存储块。如果该函数发现了一个未被标志的存储块,它就引发断言。 */ void CheckMemoryRefs( void ) { blockinfo * pbi ; for( pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext ) { /* 简单检查存储块的完整性。如果引发该断言,就说明管理 blockinfo * 的调试代码有某些错误,或者说明紊乱的内存已经破坏了数据结构。 * 无论哪种情况,都存在错误。 */ ASSERT ( pbi->pb != NULL && pbi->size != 0 ); /* 检查丢失/遗漏的存储空间。如果引发了该断言,就说明 app 或者丢 * 失了该存储块的轨道或者没有用NoteMemoryRef解释所有的全局指针。 */ ASSERT( pbi->fReferenced ); } } /*------------------------------------------------------------------ * fValidPointer( pv, size ) * * fValidPointer验证pv指向一个已分配的存储块并且从pv所指处到 * 块的结尾至少有“size”个已分配字节。如果有任一个条件没有满足, * fValidPointer将引发断言;该函数将从不返回FALSE,fValidPointer * 之所以返回一个(总为 TRUE)标记是为了允许在断言宏内调用该函 * 数。当这不是最有效的方法时,不采用#ifdef DEBUG 或者不引入其它象 * 断言的宏,而单纯地使用断言来处理调试/交付版本控制。 * * ASSERT( fValidPointer( pb, size )); */ flag fValidPointer( void* pv, size_t size ) { blockinfo* pbi; byte* pb = ( byte* )pv; 148 pbi = pbiGetBlockInfo( pb );/* 使pv有效 */ ASSERT( pv != NULL && size != 0 ); /* pv是有效的,但 size 呢?(如果 pb + size 上溢出了该存储块,则 * size 是无效的) */ ASSERT( fPtrLessEq( pb + size, pbi->pb + pbi->size )); return( TRUE ); } #endif 149 附录CCCC 练习答案 本附录给出本书中所有练习的答案。 第1章 1)编译程序会查获优先顺序错。因为它把表达式解释为: while( ch = ( getchar() != EOF )) 换句话说,编译程序把它看作是将表达式的值赋给 ch,因而认为你把“==”错误的键 为“=”,并向你发出可能有复制错误的警告。 2a)查获偶然“八进制错误”的最简单方法是扔掉可选择的编译开关,这个开关导致 编译程序在偶然遇到八进制常量时出错。取而代之的是使用十进制或十六进制。 2b) 为了查获程序员将“&&”误键入为“&”(或“||”误键为“|”)的情况,编译程 序采用了与查获将“==”误键为“=”的同样测试。当程序员在 if语句中或复合条件中使 用了“&”(或“|”),并且没有明确地将结果与 0进行比较时,编译程序将产生一个错误。 所以见到下面这条语句会产生一个警告。 if ( u & 1 )/* u是奇数吗?*/ 而下面这条语句则不会产生警告信息。 if( (u & 1) != 0 )/* u是奇数吗?*/ 2c) 警告一个无意而误成为注释的最简单的方法是,当编译发现注释的第一个字符是 字母或(时,发出一个警告。这样的测试将查获下面两个可疑情况: quot = numer/*pdenom; quot = number/*( pointer expression ); 为了避免发出警告,你可以通过将“/”与“*”之间用空格或括号分开,使你的意图 更明确。 quot = numer /*pdenom; quot = number /(*pdenom); /*注意:本注释将产生一个警告 */ /* 本注释不产生警告 */ /*----------------- 警告勿忧 -----------------*/ 2d) 编译查出可能存在的优先级顺序错的方法是,寻找在同一个不含括号的表达式中 的“有麻烦的运算符对”。例如,当程序员偶然将“< <”和“+”运算符一起使用时,编译 程序会发现优先级顺序错,对下面的代码发出警告: word = bHigh << 8 + bLow; 但是,由于下面的语句含有括号,因此编译程序不发出警告信息: 150 word = ( bHigh << 8 ) + bLow; word = bHigh << ( 8 + bLow ); 如果不专设注释则可写警告式注释:“如果两个运算符具有不同的优先级顺序并没被括 号括起,那么就要发出一个警告。”这样的注释太贫,但你在思想上要明白这点。开发一个 好的启发式注释,需要在计算机上运行大量的代码直到最后产生有用的结果。你肯定不希 望对下面这些常见的惯用语也产生警告信息: word = bHigh * 256 + bLow ; if ( ch == ‘’ || ch == ‘\t’ || ch == ‘\n’) 3)当编译程序发现两个连续的if语句其后跟有一个 else 语句时,编译程序就会发出 可能有悬挂 else 的警告信息: if(expression 1) if(expression 2) …… else …… if(expression 1) if(expression 2) …… else …… 为了避免编译程序发出警告信息,可以用括号将内层 if语句括起: if(expression1) { if(expression2) …… } else …… if(expression1) { if(expression2) …… else …… } 151 4)将常量和表达式置于比较操作的左边是很有意义的,它提供了自动检查错误的有一 个方法。但时,这种方法必须有一个操作数是常量或表达式作为前提,如果两个操作数都 是变量,这个方法就不起作用了。请注意,程序员在写代码的时候,一定要学会并记住使 用这一技术。 通过使用编译开关,编译程序将警告每一种可能的赋值错。特别是对于没有经验的程 序员,编译开关更显得特别有作用。 如果有编译开关,就一定要使用;如果没有,就把常量和表达式放在比较式的左边。 5)为了防止误定义的预处理的宏产生不可预料的结果,编译(实际是预处理)程序应 该具有一个开关允许程序员可以把无定义的宏用于错误情况。由于ANSI 编译程序及支持老 的#ifdef 预处理指令,又支持新预处理的defined 一元算子,那么就几乎没有必要将无定 义的宏“定义”为0。以下代码将会产生错误: /* 建立目标等式 */ # if INTEL8080 …… #elif INTEL80x86 …… #elif MC6809 …… #elif MC680x0 …… #endif 因此,应写为如下代码: /* 建立目标等式 */ #if defined ( INTEL8080 ) …… #elif defined ( INTEL80x86 ) …… #elif defined ( MC6809 ) …… #elif defined ( MC680x0 ) …… #endif 如果在# ifdef 语句中使用了无定义的宏,此开关不会给出警告,因为这是有意安排的 。 152 第二章 1)ASSERTMSG 宏的一种可能的实现是使它产生两个作用:一个是确认表达式,另一个 是当断言否定时显示一个字符串。例如,若要打印 memcpy 的消息,应如以下形式调用 ASSERTMSG: ASSERTMSG( pbTo >= pbFrom + size || pbFrom >= pbTo + size, “memcpy: the blocks overlap”); 下面是 ASSERTMSG 宏的实现。你应将ASSERTMSG 的定义放在头文件中,再将_AssertMsg 例程放在一个方便的源文件内。 #ifdef DEBUG void_AssertMsg( char* strMessage );/* 原型 */ #define ASSERTMSG( f, str )\ if( f )\ NULL \ else \ _AssertMsg( str ) #else #define ASSERTMSG( f, str ) NULL #endif 在另外一个文件中有: #ifdef DEBUG void_AssertMsg( char* strMessage ) { fflush( stdout ); fprintf( stderr, “\n\n Assertion failure in %s \n”, strMessage ); fflush( stdeer ); abort(); } #endif 2)如果你的编译程序支持一个这样的开关,它通知编译程序将所有相同的字符串分配 在同一个位置上,那么最简单的办法就是不要这个开关。如果允许这个选择,你的程序即 或声明了 73个文件名的副本,编译程序只分配一个字符串。这种方法的缺点是,它不仅“覆 盖”了断言字符串,还将源文件中所有等长的字符串都“覆盖”了,只是不希望有的多余 行为。 另一种办法是改变ASSERT宏的实现,有意识的只引用整个文件中相同文件名的字符串 。 唯一的困难是如何建立文件名的字符串,但是即使这不成问题,你也应该把实现细节隐藏 153 在一个新的 ASSERTFILE 宏中,这个宏只在源程序文件的开始处使用一次: #include …… #include ASSERTFILE( __FILE__ )/* 加 */ …… void* memcpy( void* pvTo, void* pvFrom, size_t size ) { byte* pbTo = (byte*)pvTo; byte* pbFrom = (byte*)pvFrom; ASSERT( pvTo != NULL && pvFrom != NULL );/* 没有变更 */ …… 下面是实现 ASSERTFILE 宏的代码和相应的 ASSERT 版本。 #ifdef DEBUG #define ASSERTFILE(str) static char strAssertFile[] = str; #define ASSERT(f) \ if( f )\ NULL \ else \ _Assert( strAssertFile, _LINE_ ) #else #define ASSERTFILE(str) #define ASSERT(f) NULL #endif 使用该版本的 ASSERT,可以获得大量的存储空间。例如,本书的测试应用程序很小, 但是使用上面新给的代码,这些程序可以节省 3K的数据空间。 3)使用该断言的问题是测试包含了应保留在函数非调试版本中代码。非调试代码将进 入一个无限循环,除非在执行do循环中,ch碰巧等于执行符。所以函数应写成如下形式: void getline( char* pch ) { int ch; /* ch必须是 int 类型 */ do { ch = getchar(); ASSERT( ch != EOF ); } 154 while( (*pch++ = ch )!= ‘\n’); } 4)查出不许修改的开关语句中所存在的错误,有一个很简单的方法,这就是将断言加 到default(缺省)分支来证实default 分支是唯一处理那些应该处理的分支。在某些情况 下,不能引用default 分支,因为所有可能的情况都被明确地处理了。如果发生上述情况, 请使用以下代码: …… default: ASSERT(FALSE); /* 此处从不可达 */ break; } 5)表中屏蔽码与相对应的模式之间有一个关系,模式应该总是屏蔽码的子集,或者说 , 一旦被屏蔽,不能有任何指令与该模式相匹配。下面的 CheckIdInst程序用来证实模式是 屏蔽码的子集: void CheckIdInst( void ) { identity *pid, *pidEarlier; instruction inst; for( pid = &idInst[0]; pid->mask != 0; pid++ ) { /* 模式肯定是屏蔽码的子集 */ ASSERT( (pid->pat & pid->mask) == pid->pat ); …… 6)使用断言来证实 inst 没有任何有疑问的设置: instruction* pcDecodeEOR( instruction inst, instruction* pc, opcode* popc ) { /* 我们是否错误地得到了 CMPM 或CMPA.L 指令? */ ASSERT( eamode(inst) != 1 && mode(inst) != 3 ); /* 如果为非寄存器方式,则只允许绝对字和长字方式 */ ASSERT( eamode(inst) != 7 || ( eareg(inst) == 0 || eareg( inst ) == 1 )); …… 7)选择备份算法的关键是要选择一个不同的算法。例如,为了证实qsort 是可以工作 的,你可以扫描排序后的数据,以验证次序是正确的(扫描并不是排序,应把它看作不同 155 的算法)。为了验证二分查找工作正常,就用线性扫描来看一下两种查找的结果是否相同。 最后,为了验证itoa 函数正确,将该函数返回的字符串重新转换为整数,然后与原来传递 给itoa 的整数进行比较,它们应该相等。 当然,除非你在为航天飞机、放射工厂、或其他一些一旦出错,可能威胁生命的情况 编码,否则,你可能不想为你写的每一段代码都用备份算法。但是,对于应用中所有较重 要的部分都应该使用备份算法。 第3章 1)通过用不同的调试值来破坏两类存储空间,能容易的区分某个程序是使用了未初始 化数据还是继续使用已释放的数据。例如,利用bNewGarbage,fNewMemoery可以破坏新的 未初始化的存储空间,使用bFreeGarbage,FreeMemory 可以破坏已释放的存储空间: #define bNewGarbage 0xA3 #define bFreeGarbage 0xA5 fResizeMemory建立这两类无用数据,你可以使用上面的两个值,或者,你也可以建立 两个别的值。 2)查获“溢出”错的一个方法是,定期地对跟在每一个已分配块后面的字节进行检查 , 证实这些字节并没有被修改。尽管这种测试听起来很直观,但是它却要求你记住所有的字 节,而且它还忽略了你可能会再没有分配给你的存储块里进行读操作这样一个潜在的问题。 幸运的是,还有一个简单的方法来实现这个测试,只不过要你为每一个分配的块再分配一 个额外的字节。 例如,当你调用fNewMemory 时需分配 36字节,你实际上要分配37字节,并且在那个 额外的存储单元内存储一个已知的“调试字”。类似地,当fResizeMemory调用realloc 时 , 你可以分配和设置一个额外的字节。为了查获溢出错,应该在sizeofBlock,fValidPointer, FreeBlockInfo,NoteMemoryRef和CheckMemoryRefs中加入断言来证实还没有接触到调试 位。 下面是实现该代码的一种方法。首先,你要定义 bDebugByte和sizeofDebugByte: /* bDebugByte 是一个奇异的值,它存储在该程序的DEBUG 版本的每一个被 * 分配存储块的尾部,sizeofDebugByte是加到传给 malloc 和realloc 的 * size 上,使分配的空间大小正确。 */ #define bDebugByte 0xE1 #ifdef DEBUG #define sizeofDebugByte 1 #else 156 #define sizeofDebugByte 0 #endif 下一步,你应该在 fNewMemory 和fResizeMemory 中用sizeofDebugByte 来调整对 malloc 和realloc 的调用,如果分配成功,就用bDebugByte来填充那些额外字节: flag fNewMemory( void** ppv, size_t size ) { byte** ppb = ( byte** )ppv; ASSERT( ppv != NULL && size != 0 ); *ppb = (byte*)malloc( size + sizeofDebugByte );/* 变更了 */ #ifdef DEBUG { *(*ppb + size ) = bDebugByte; /* 加 */ memset( *ppb, bGarbage, size ); …… flag fResizeMemory( void** ppv, size_t sizeNew ) { byte** ppb = ( byte** )ppv; byte* pbResize; …… pbResize = (byte*)realloc(*ppb, sizeNew + sizeofDebugByte); /* 变更了 */ if( pbResize != NULL ) { #ifdef DEBUG { *( pbResize + sizeNew ) = bDebugByte; /* 加 */ UpdateBlockInfo( *ppb, pbResize, sizeNew ); …… 最后,将以下断言插入到sizeofBlock、fValidPointer、FreeBlockInfo、NoteMemoryRef 和CheckMemoryRefs例程中,这些例程在附录B中给出。 /* 保证在块的上界之外什么也没有写入 */ ASSERT( *( pbi->pb + pbi->size ) == bDebugByte ); 做了这些改动之后,存储子系统就可以查获那些写到所分配的存储块上界之外的溢出 错误了。 3)查获不该悬挂的指针错有许多方法。一个可能的解就是,更改 FreeMemory 的调试 版本使它不真正地释放这些存储块,而是为已分配的块建立一个释放链,(这些存储块,对 于系统来讲,它们是已分配的,对于用户程序来讲,它们已被释放了)。以这种方式修改 157 FreeMemory 将是“释放的”存储块在调用 CheckMemoryRefs来确认子系统之前不被重新分 配。CheckMemoryRefs通过获取 FreeMemory 的“释放”链和真正释放所有这些存储块,使 存储系统有效。 虽然该方法可以查获不该悬挂的指针,但是,除非你的程序遇到了这类错误,一般不 要使用这种方法。因为这种方法违反了“调试代码时附加了额外信息的代码,而不是不同 的代码”原则。 4)为了使指针所引用的对象大小有效,必须考虑两种情况:一种情况是指针指向整个 块;另一种情况是指针指向块内的部分分配空间。对于第一种情况,可以采取最严格的测 试来证实指针引用了块的开头,块的大小与 sizeofBlock函数的返回值相匹配。对于第二 种情况,测试应弱一些:即指针只要指在块内,大小没有超出块的结尾就可以了。 因此,如不使用 NoteMemoryRef程序来表示部分分配块和完整块,可以使用两个函数 来表示两类块,这可以通过下面的方式来实现:给已有的 NoteMemoryRef函数增加一个参 数size ,用扩充以后的 NoteMemoryRef 函数标识部分分配块;建立一个新函数 NoteMemoryBlock来表示完整块,如下所示: /* NoteMemoryRef ( pv , size ) * * NoteMemoryRef 将pv所指的存储块标志为被引用的。注意:pv不必指向一个 * 存储块的开始;它可以指向一个已分配存储块内的任意位置,但是在该存储块 * 内至少要剩有“size”个字节。注意:如果有可能,就使用NoteMemoryBlock ── * 它更可靠。 */ void NoteMemoryRef( void* pv, size_t size ); /* NoteMemoryBlock( pv, size ) * * NoteMemoryBlock将pv所指的存储块标志为被引用。注意:pv必须 * 指向一个存储块的开始,该存储块长度恰好为“size”个字节。 */ void NoteMemoryBlock( void* pv, size_t size ); 这些函数可以查获在练习中给出的错误。 5)为了改进附录B中的完整性检查,应该首先将BLOCKINFO 结构中的引用标志改为引 用计数,然后更改 ClearMemoryRef和NoteMemoryRef,使其对计数器进行处理,这是很明 显的。可是,怎样来修改CheckMemoryRefs,使得当某些有多个引用的情况时,它只为这些 块作断言检查而不为别的存储块作断言检查呢? 解决这个问题的一个方法是:改进 NoteMemoryRef例程,是它除了具有指向存储块的 指针外,还保持一个标尺存储块的标签 ID。NoteMemoryRef可以将标签保存在 BLOCKINFO 158 结构中,随后作 CheckMemoryRefs并用标签来检验引用计数器。下面是进行了这些变化以 后的代码。前面的注释请参见附录B中的原版函数: /* 块标签是为引用保存各种类型分配块的表 */ typedef enum { tagNone, /* ClearMemoryRefs将所有块置为 tagNone */ tagSymName, tagSymStruct, tagListNode, /* 这些块必须有两种引用 */ …… }blocktag; void ClearMemoryRefs( void ) { blockinfo* pbi; for( pbi = pbiHead; pbi != NUL; pbi = pbi->pbiNext ) { pbi->nReferenced = 0; pbi->tag = tagNone; } } void NoteMemoryRef( void* pv, blocktag tag ) { blockinfo* pbi; pbi = pbiGetBlockInfo( (byte*)pv ); pbi->nReferenced++; ASSERT( pbi->tag == tagNone || pbi->tag == tag ); pbi->tag = tag; } void CheckMemoryRefs( void ) { blockinfo* pbi; for( pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext ) { /* 简单的检查块的集成性。若以下断言引发则意味着管理块信 159 * 息的调试代码错了,或者可能有一新的存储抹去了数据结 * 构。这两种情况都是错误。 */ ASSERT( pbi->pb != NULL && pbi->size != 0 ); /* 检查失去或漏掉的内存,若全无引用则意味着 app 要么丢失了该块的 * 踪迹,要么没有使所有全局指针都计入NoteMemoryRef。某些 * 类型的块可以有多个对它们的引用。 */ switch( pbi->tag ) { default: ASSERT( pbi->nReferenced == 1 ); break; case tagListNode: ASSERT( pbi->nReferenced == 2 ); break; …… } } } 6)DOS、Windows 和Macintosh 的开发者通常使用下面的方法来测试内存空间耗尽条件 。 他们使用一个工具来任意占用存储空间直到应用申请的存储空间出错为止。尽管这种方法 可以起作用,但是并不精确,它会引起程序某个地方要求的分配失败。如果要测试一个孤 立的特征,这种技术并不十分有用。一个更好的方法是,在存储管理程序中建立存储器溢 出的模拟程序。 但请注意,存储错仅仅是资源错误的一种类型,还有磁盘错、出纸错、电话线路忙碌 出错等各种错误。因此,需要一个故意制造资源短缺的通用工具。 一个解决办法是:建立failureinfo结构,在该结构中包含有通知如何去做错误处理 机制的信息。程序员和测试员在外部测试中填入failureinfo结构,然后,再演示他们的 特 征 。(Microsoft 应用经常使用 debug-only(只调试)对话,它允许测试员用这样的系统 , 象Excel 一类的应用中有宏语言,有一种debug-only宏能允许测试员将这一过程自动化)。 为了声明存储管理器的故障结构,应使用如下的代码: failureinfo fiMemory; 为了在 fNewMemory 或fResizeMemory中模拟内存耗尽错,应将四行调试代码加到每个 函数中: flag fNewMemory( void** ppv, size_t size ) 160 { byte** ppb = ( byte** )ppv; #ifdef DEBUG if( fFakeFailure( &fiMemory )) return( FALSE ); #enfif …… flag fResizeMemory( void** ppv, size_t sizeNew ) { byte** ppb = ( byte** )ppv; byte* pbResize; #ifdef DEBUG if( fFakeFailure( &fiMemory )) return( FALSE ); #endif …… 这样在代码中设置了故障机制,为了使其起作用,要调用 SetFailures函数来初始化 failureinfo结构: SetFailures( fiMemory, 5, 7 ); 用5和7调用SetFailures是告诉故障系统,在得到 7个连续的故障之前要成功地调 用系统 5次。对 SetFarilures的两个常见的调用是: SetFailures( &fiMemory, UINT_MAX, 0 );/* 不要伪造任何故障 */ SetFailures( &fiMemory, 0, UINT_MAX );/* 总是伪造故障 */ 用SetFailures,可以写出一次又一次调用同一段代码的单元测试,它是每次要用不同 的值调用SetFailures 来模拟所有可能的错误模式。通常将第二个“失败”值保持为 UINT_MAX,第一个“成功”值计数从 0到某个很大的数,逐渐试探它。这个数大到能测试 出所有的内存耗尽条件。 最后,当要多次调用内存(或磁盘等等)系统时,你肯定希望不出故障;特别是在某 个调试代码内分配资源时,常常如此。下面两个可嵌套函数暂时允许故障机制失灵: DisableFailures( &fiMemory ); … 进行分配 … EnableFailures( &fiMemory ); 下面的代码是建立四个函数的故障机制: typedef struct { unsigned nSucceed; /* 在出故障之前有 # 次成功 */ 161 unsigned nFail; /*# 次失败 */ unsigned nTries; /* 已被调用 # 次 */ int lock; /* 如lock>0,该机制不工作 */ }failureinfo; void SetFailures( failureinfo* pfi, unsigned nSucceed, unsigned nFail ) { /* 如果nFail 是0,则要求 nSucceed 为UINT_MAX */ ASSERT( nFail != 0 || nSucceed == UINT_MAX ); pfi->nSucceed = nSucceed; pfi->nFail = nFail; pfi->nTries = 0; pfi->lock = 0; } void EnableFailures( failureinfo* info ) { ASSERT( pfi->lock > 0 ); pfi->lock--; } void DisableFailures( failureinfo* pfi ) { ASSERT( pfi->lock >= 0 && pfi->lock < INT_MAX ); pfi->lock++; } flag fFakeFailure( failureinfo* pfi ) { ASSERT( pfi = NULL ); if( pfi->lock > 0 ) return( FALSE ); if( pfi->nTries != UINT_MAX )/* 勿使nTries 溢出 */ pfi->nTries++; if( pfi->nTries <= pfi->nSucceed ) return( FALSE ); if( pfi->nTries – pfi->nSucceed <= pfi->nFail ) 162 return( TRUE ); return( FALSE ); } 第4章 第四章没有练习。 第5章 1)与malloc 一样,由于strdup 的错误返回值是有假象的 NULL 指针,易于失察,因 此,strdup 具有一个危险的界面。作为一个不易出错的界面应将错误条件与指向输出的指 针分开,使错误条件更清晰。如下代码就是这样的界面: char* strDup; /* 指向复制串的指针 */ if( fStrDup( &strDup, strToCopy )) 成功 ─── strDup 指向新串 else 失败 ─── strDup 为NULL 2)getchar 的界面比 fGetChar 界面要好,它将返回一个错误代码而不是一个 TRUE 和 FALSE 的是否“成功”的值。例如: /* errGetChar 可能返回错误 */ typedef enum { errNone = 0 , errEOF , errBadRead , …… }error; void ReadSomeStuff( void ) { char ch; error err; if( ( err = errGetChar(&ch) ) == errNone ) 成功 ─── ch得到下一个字符 163 else 失败 ─── err 具有错误类型 …… 这个界面之所以比 fGetChar 的界面好,是因为它允许 errGetChar返回多种错误条件 (和多种对应的成功条件)。如果你不关心返回错误的具体情况,可以取消局部变量 err, 回到fGetChar 的界面形式: if( errGetChar(&ch) == errNone ) 成功 ─── ch得到下一个字符 else 失败 ─── 不关心是什么错误类型 3)strncpy 函数有一个麻烦的问题,该函数的性能不稳定:有时 strncpy 用一个空字 符终止一个指定的字符串,有时就不是这样。strncpy 与别的通用字符串函数列在一起,程 序员可能会错误地断定strncpy 函数本身是一个通用函数,其实它并不是。由于它具有异 常的性能,事实上 strncpy 不应在 ANSI 标准中,但是,由于它在 ANSI C的预处理实现中 广泛使用,所以也可以说它在ANSI 标准中。 4)C++的inline(内联)函数指明符非常有价值,它允许用户定义象宏一样有效的函 数,然而还没有宏“函数”对参数求值时所带来的那些麻烦的副作用。 5)C++ 新的 & 引用参数有一个严重的问题,它隐藏了一个事实,即通过引用来传递 变量,而不是通过值,这可能会引起混乱。例如,假设你重新定义了fResizeMemory函数, 使用了引用参数。程序员可以写: if( fResizeMemory( pb, sizeNew )) resize 是成功的 但是要注意,不熟悉这个函数的程序员不会认为在调用期间pb可能会改变。你认为这 是否会影响程序的维护呢? 与此相联系的是,C程序员经常对他们函数中的形式参数进行操作,因为他们知道这些 参数是通过值传递的,而不是通过引用。但是,考虑一下维护人员要修改函数中的错误, 就不能这样写。如果这些程序员没有注意到声明中的&,他可能就修改了参数,而且没有意 识到这个变更并非局部于这个函数。 6)strcmp 的界面所存在的问题是,该函数的返回值在调用点导致产生了难理解的代码 。 为了改进 strcmp,设计界面时应使返回值对于那些即使不熟悉该函数的程序员也很容易理 解。 有一种界面,它对现在的strcmp 作了较小的改动。它不是对不相等的字符串返回某个 164 正值或负值,而是迫使程序员将所有的比较都改为和 0比较。修改strcmp 是它返回三个定 义良好的命名常量: if( strcmp( strLeft, strRight ) == STR_LESS ) if( strcmp( strLeft, strRight ) == STR_GREATER ) if( strcmp( strLeft, strRight ) == STR_EQUAL ) 另一种可能的界面是,每一类比较都用单独的函数: if( fStrLess( strLeft, strRight )) if( fStrGreater( strLeft, strRight )) if( fStrEqual( strLeft, strRight )) 第二种界面的优点是,可以通过在已有的 strcmp 函数上使用宏来实现。把 <= 和 >= 这样的比较定义为宏可以大大提高可读性。结果是,提高了可读性,在空间和速度方面也 没有损失。 #define fStrLess(strLeft, strRight) ( strcmp(strLeft, strRgiht) < 0 ) #define fStrGreater(strLeft, strRight) ( strcmp(strLeft, strRight) > 0 ) #define fStrEqual(strLeft, strRight)( strcmp(strLeft, strRgiht) == 0 ) 第6章 1)“简单的”1位位域的可移植范围为 0,这没什么用。位域确实有非 0状态,却不知 道这是什么值:该值可以是-1或1,这取决于所使用的编译程序在缺省状态下是带符号的 位域呢还是不带符号的好的位域。如果将所有的比较都限制为与 0进行比较,那么就可以 安全地使用位域的两种状态。如果假设 psw.carry 是个简单的1位位域,则可以安全地写 如下的代码: if( psw.carry == 0 ) if( !psw.carry ) if( psw.carry != 0 ) if( psw.carry ) 但是,下面的语句是有风险的,因为它们依赖于所使用的编译程序: if( psw.carry == 1 ) if( psw.carry == -1 ) if( psw.carry != 1 ) if( psw.carry != -1 ) 2)返回布尔值的函数就像“简单的”1位位域一样,没办法安全的预言“TRUE”返回 的值将是什么。可以依赖于:FALSE 是0。但是程序员经常把非0值作为“TRUE”的返回值 , 当然,这并不等于常量TRUE。如果你假设fNewMemory 返回一个布尔值,那么就可以安全地 写成下面的代码: if( fNewMemory( …) == FALSE ) if( fNewMemory( …) == FASLE ) 甚至更好的代码: 165 if( fNewMemory( …)) if( fNewMemory( …)) 但是,下面的代码是有风险的,因为它假设 fNewMemory 将不会返回除了 TRUE 之外的 任何非零值: if( fNewMemory( …) == TRUE )/* 有风险 */ 记住一个很好的规则:不要将布尔值与TRUE 进行比较。 3)如果将 wndDisplay 声明为一个全局窗口结构,你给它一个别的窗口结构没有的特 殊属性:全局性。这看上去似乎是一个次要的细节,但是它可能会引入一个没有预料到的 错误。例如,假设你想写一个释放窗口和所有子窗口的例程,下面的函数就实现了这一功 能: void FreeWindowTree( window* pwndRoot ) { if( pwndRoot != NULL ) { window *pwnd, *pwndNext; ASSERT( fValidWindow( pwndRoot )); for( pwnd = pwndRoot->pwndChild; pwnd != NULL; pwnd = pwndNext ) { pwndNext = pwnd->pwndSibling; FreeWindowTree( pwnd ); } if( pwndRoot->strWndTitle != NULL ) FreeMemory( pwndRoot->strWndTitle ); FreeMemory( pwndRoot ); } } 但是要注意,如果要释放每一个窗口,就可以安全地传递pwndDisplay,因为它指向已 分配的窗口结构。但是,不能传递&wndDisplay,因为该代码将释放wndDisplay,这是不可 能的,因为 wndDisplay 是一个全局的窗口结构。为了使得有&wndDisplay 的代码能够正确 工作,必须在最后调用 FreeMemory 之前插入: if( pwndRoot != &wndDisplay ) 如果这么做了,代码就要依靠全局数据结构了。哟嗬! 要想在代码中没有错误,有一个最好的方法,这就是在实现中避免任何古怪的设计。 4)第二版代码比第一版代码所冒的风险更大一些,这有几个原因。由于在第一版代码 中A、D和expression都是公共代码,不管 f的值是什么,它们都要被执行和测试。而在 166 第二版中,和A、D有关的每一个表达式都将分别测试,除非它们是相同的,否则要冒漏掉 某一个分支的风险。(如果为了与B或C联用方便而专门对两个A和两个 D分别进行不同的 优化,那么两个 A和两个 D将不同)。 在第二版中,还有一个问题,当程序员修改错误或改进代码时,很难保证两个 A和两 个D同步。尤其当两个 A和两个 D本来就不相同时就更是如此了。因此,除非计算f的代 价太昂贵以至于用户都能观察出来,否则的话都使用第一版。在此请记住另外一条很有用 的规则:通过最大限度地增加公共代码的数量来使代码差异减到最少。 5)使用相似的名字是危险的,例如象 S1和S2,当你想键入 S2时很容易误键为 S1。 更糟的是,在编译这样的代码时,可能不会发现这个错误。使用相似的名字,使得很难发 现名字颠倒错误: int strcmp(const char* s1, const char* s2) { for( NULL; s1==s2; s1++, s2++ ) { if( *s1 == ‘\0’)/* 与末端匹配吗? */ return(0; } return( (*(unsigned char*)s2 < *(unsigned char*)s1) ?–1 : 1 ); } 以上代码是错误的,最后一行的测试方向反了,由于名字本身没有含义,所以这个错 误很难发现。但是,如果使用描述性的、有区别的名字,如 sLeft 和sRight,上述两类错 误的出现次数会自动下降,代码更好读。 6)ANSI 标准保证可以对所声明的数据类型的第一个字节寻址,但是,它不能保证能引 用任何数据类型前面的字节;该标准也不能保证对 malloc 分配的存储块前面的字节寻址。 例如,某些 80x86 存储模型的指针式使用 base:offset(基地址:偏移量)来实现的, 且只操纵无符号的偏移量。如果pchStart 是指向所分配的存储块开始处的指针,则其偏移 量为0。如果你假设 pch 开始就超出 pchStart+size 的值,那么它决不会小于 pchStart, 因为它的偏移量决不会小于pchStart 的偏移量 0。 7a)如果 str 包含若干%符号,则使用 printf(str)代替printf(“%s”, str)就会出 现错误,printf 将把str 包含的%符号错误地解释为格式说明。使用printf(“%s”, str) 的麻烦是,由于它可以非常“明显地”被优化为 printf(str),以至于粗心的程序员会在清 理代码时引入错误。 7b)使用 f=1-f 代替f=!f 是有风险的,因为它假设f或者是 0或者是 1。然而使用!f 清楚地表明是个倒装标志,对所有 f值都起作用。采用 1-f 的唯一理由是它能够产生比!f 167 效率更高一点的代码,但是,要记住,局部效率的提高很少对程序的总体产生影响。使用1-f 只能增加产生错误的风险。 7c)在一个语句中使用多重赋值的风险性在于,可能会引起不希望的数据类型转换。 在所给的例子中,程序员非常小心地将ch声明为 int,以便它能正确地处理 getchar 可以 返回的 EOF值。但是 getchar 返回的值却首先存在一个字符串中,要将值转换为 char,正 是这个 char 被赋给了 ch,而不是 getchar 返回的 int 赋给了 ch。如果在系统上 EOF的值 为负,而编译程序的缺省值为无符号字符,那么错误就会很快地显现出来。但是,若编译 程序的缺省值是有符号字符,EOF可能被截取为字符,当重新转换为int 时,可能恰好又一 次等于 EOF。这并不意味着该代码工作正确。如果你看不出EOF的问题,你就丧失了区分EOF 和EOF进位后所等价的字符的能力。 8)在典型情况下,表格使得代码减少、速度加快,可用以简化代码,增加正确的概率 。 但是,当考虑到表中的数据时,又得出了相反的结论。首先,代码可能少了,但是表格占 用了存储空间,总的来说,表格解法可能比非表格实现占用的存储空间要多。使用表格的 另一个问题是具有风险性,你必须确保表格中的数据市完全正确的,有时很容易做到,比 如tolower 何uCycleCheckBox表格就是如此。但是,对于一些大表格象第2章反汇编程序 中的表格,要保证表中的数据完全正确就很难了,因为很容易引入错误。所以得到了一条 原则:除非你可以确保数据有效,否则不要使用表格。 9)如果你使用的编译程序没有做一些象把乘法、除法转换为移位(在适当的时候)这 样一些基本优化的话,那么必然有更糟的代码生成问题使你耽心,切勿着意通过移位来代 替除法这样的微小改善。不要在提高效率的小技巧方面下功夫以克服差编译程序的局限性。 相反,要保持代码的清晰性并找到一个好编译程序。 10)为了确保总能保存用户的文件,在用户更改文件之前为其分配缓冲区。如果每个 文件需要一个缓冲区的话,那么每次打开一个文件时都要分配一个缓冲区。如果分配失败 了,就不打开文件,或将文件打开作为只读文件。但是,如果用一个缓冲区来处理所有打 开的文件,那么可以在程序初始化时分配这个缓冲区。并且当在大多数时间内缓冲区悬挂 着不做任何事,不要担心“浪费”存储空间。“浪费”存储空间,并确保可以保存用户的数 据,这比让用户工作 5小时,以后又由于不能分配缓冲区,数据不能保存要好的多。 第7章 1)下面的代码对函数的两个输入参数pchTo 和pchFrom 都做了修改: char* strcpy(char* pchTo, char* pchFrom) { 168 char* pchStart = pchTo; while(*pchTo++ = *pchFrom++) NULL; Return(pchStart); } 修改pchTo 和pchFrom 并没有违反与这两个参数有关的写权限,因为它们是通过值传 递的,这就是说 strcpy 接受了复制的输入,因此允许 strcpy 修改它们。但是要注意,并 不是所有的计算机语言(例如FORTRAN)都是通过值来传递参数的,因此,虽然这个练习用 C语言实现十分安全,但是,若用其它语言来实现,可能很危险。 2)strDigits 的问题是它被声明为静态指针,而不是静态缓冲区,如果所用编译程序 的选择项指示编译程序把所有的字符串直接量作为常量处理,那么这个声明上的微小差别 就会带来问题。支持“常量字符串直接量”选项的编译程序接受所有的字符串直接量,并 把它们和程序中别的常量储存在一起。由于常量不会变更,因此这些编译程序一般都扫描 所有的常量字符串,并删除复制的常量字符串。换句话说,如果strFromUns 和strFromInt 都将静态指针声明为类似于“?????”的字符串,那么编译程序可能会分配一份(而 不是两份)该字符串的拷贝。一些编译程序甚至更彻底,只要一个字符串和另一个字符串 的尾部相匹配(例如“her”就和“mother”的尾部相匹配),就把它们组合起来存放。这 样改变一个字符串就会改变其它的字符串。 解决这个问题的办法是将所有的字符串直接量作为常量处理,并限制程序代码只从它 们中读出信息。如果要改变一个字符串,那么就声明一个字符缓冲区,而不是声明一个字 符串指针: char* strFromUns(unsigned u) { static char strDigits[] = “?????”;/* 5个字符 + ‘\0’*/ …… 但这也是冒风险的,因为这取决于程序员键入“?”标志的正确个数,并且假设尾部 的空字符不会遭到破坏。使用“?”标志来占有空间并非是一种好思想,难道这个字符串 真是5个“?”标志吗?如果你不能保证这一点,那么就明白了为什么应该使用不同的字 符。 声明缓冲区的大小并做一次存储来替换断言是一个安全的实现: char* strFromUns(unsigned u) { static char strDigits[6]; /* 5个字符 + ‘\0’*/ …… pch = &strDigits[]5; *pch = ‘\0’;/* 替换ASSERT */ 169 …… 3)使用 memset 来初始化相邻的区域,既非常冒险,又非常低效(相对于直接使用赋 值而言): i = 0; /* 置i、j和k为零 */ j = 0; k = 0; 或者更简洁一些: i = j = k = 0; /* 置i、j和k为零 */ 这些代码片断既可移植又高效,因此非常明显,甚至不再需要解释,而memset 版本则 是另一回事。 我不能肯定最初的程序员想通过使用memset 得到什么,但可以肯定他没有得到什么好 处。对于除了最优秀的编译程序以外的所有编译程序来说,调用memset 的内存操作,比显 示声明 i、j和k的操作要昂贵的多,但是假设程序员使用的是一个优秀的编译程序,只要 在编译时间知道要填充值的长度,这个编译程序就可以插入微小的填充,这时这个“调用” 将蜕变成三个 sizeof(int)的存储。这并不能使情况得到多大改进:代码依然假设编译程序 会把i、j、k相邻地分配到栈中,其中 k存放在栈的最下面,代码还假设 i、j、k互相紧 连,没有任何其它多余的“垫”字节来调整变量长度以便于有效地存取。 又有谁说过变量非得放在主存储内呢?好的编译程序照例要作跨生命周期分析,将紧 要信息放入寄存器并保持常驻其整个声明周期。例如,i和j可能始终分配在寄存器中,根 本方不到主存储器内;另一方面,k必须分配在主存储器内,因为它的地址传给了 memset (你无法使用寄存器 的地址),在这种情况下,i和j依然未初始化,而k后面的2*sizeo f(int) 个字节将永远被错误地置为0。 4)当你调用或者跳到机器 ROM的某个固定地址上时,将会面临两个危险。第一个危险 是在你的机器上 ROM可能不会有更改,但未来新型号硬件肯定会有某种改变,即使 ROM的 例程没有变更,硬件销售商有时通过驻留在 RAM上的软件来修补 ROM中的错误,其中修补 程序是通过系统界面调用的。如果你绕过这些界面,那么也就绕过了这些修补程序。 5)如果不需要 val,就不传递val。所带来的问题是调用程序要对 DoOperation的内 部执行情况做个假设,就象 FILL 和CMOVE 之间的关系一样。假定有程序员要改进 DoOperation将其写为如下所示的代码,这样它就一直引用 val: void DoOperation(operation op, int val) { if( op < opPrimaryOps ) DoPrimaryOps(op, val); else if( op < opFloatOps ) 170 DoFloatOps(op, val); else …… } 当DoOperation 引用不存在地 val 时会发生什么呢?这取决于你的操作系统。如果 “val”是在栈结构的写保护部分时,代码可能会异常终止。 通过强行传递那些不再使用的变量所占据的位置,就可以使程序员难以对你的函数玩 什么花样。例如,在文档中你可以写明:“每当你用 opNegAcc 来调用 DoOperation时,就 将0传递给 val。”一个有关存储位置的断言就可以使程序员不再折腾: case opNegAcc : ASSERT( val == 0 );/* 向val 传递0 */ accumulator = -accumulator; break; 6)该断言用来验证f是TRUE 还是FALSE。该断言不仅不清晰,而且更重要的是它没有 必要在调试代码中如此复杂,这毕竟是以商品版本为基础得来的。该断言最好写成: ASSERT( f==TRUE || f==FALSE ); 7)不要将所有的工作都放在一行代码上,声明一个函数指针,将一行代码一分为二, 如下所示: void* memmove(void* pvTo, void* pvFrom, size_t size) { void(*pfnMove)(byte*, byte*, size_t); byte* pbTo = (byte*)pvTo; byte* pbFrom = (byte*)pvFrom; pfnMove = (pbTo < pbFrom) ? tailmove : headmove; (*pfnMove)(pbTo, pbFrom, size); return(pvTo); } 8)因为调用print 例程的代码依赖于 print 代码的内存实现。如果一个程序员改变了 print 代码,并且没有意识到别的代码调用它是从入口电跳过4个字节实现的,则这个程序 员修改代码后,可能就破坏了“print + 4”的调用者。如果你发现了这个问题就要将入口 点的代码重写,加到例程中间,至少要使入口点呈现在维护人员眼前: mover0, #PRINTER callprintDevice 171 …… printDisplay: mover0, #DISPLAY printDevice:……;r0 == device ID 9)当微型计算机还只有非常少量的只读存储器时,这种无意义的类型很受欢迎,因为 每一个字节都很宝贵,而这种方式通常能节省一个或两个字节。后来,这就是一个坏习惯 了。现在就把它看成很糟糕的习惯了。如果你小组中仍有人写这样的代码,让他们改正这 个习惯,或让他们离开你的小组。你没有必要让这样的代码给你找麻烦。 第8章 第8章没有练习。 172 后记 走向何方 我们的讨论就要结束了,读者或许还会带着疑虑问我:“你本人相信有可能写出无错程 序吗?”当然不能绝对地、百分之百地保证这一点。但是可以相信,只要你坚持照做就能 写出非常接近于无错的程序。这就象粉刷房间一样,在粉刷房间时可以不弄脏地毯,但必 须在地上铺上布,围上挡板,并小心地粉刷,还要下决心坚持到底。同样,读者必须努力 剔除代码中的错误,要想做到这一点的唯一途径就是按照正确的方向坚持下去。 尽管你把写无错代码置于首位,但是只利用本书中给出的技术还不能完全达到这一目 标。事实上,不存在一个能保证编码不出任何错误的准则表。因此,最重要的是,读者自 己要坚持建立一个查错表列出你查出的错误,以避免重犯以前犯过的错误。这个表中的某 些项也许会使你大吃一惊。 例如,我曾经将一个令人讨厌的微小错误引入了 Excel:在浏览一个文件时,不小心删 了一行。当时我没有检测到这一错误,把这一改动了的文件连同其它文件一起合并到主程 序。以后别人发现了这个错误并追踪到我这。我就想,怎样才能检测并防止这类错误呢, 答案很显然:在把更改了的代码合并到主程序之前,利用源代码控制管理程序列出所做的 改变。这一额外的步骤付诸实施并不占用多少时间,在其后的五年中,它帮助我发现了三 个重大的错误以及一些不太恰当的小更改。这三个错误是否值五年的努力?对我来说是值 得的,因为正如我前面所说,这样做费不了多大的事,我就可以知道不会把不希望的更改 带入主程序。再说一遍,改正错误要放在首要地位。 读者或许会发现,评审代码就是为了解决问题,提供较好的文档就是为了帮助开发产 品的内部工作人员。如果不采用单元测试,或许你早就这么做了。读者甚至会发现,特意 加入第 3章练习 6中提到的 DEBUG 代码对帮助测试员是十分有用的。有时候这种解决问题 的方法不太切合实际,这时最好的办法就是避开导致错误的算法和实现。 事实上,无法做到完全消除错误,但是通过不懈的努力,可以加大犯同样错误的时间 间隔。为帮助大家做到这一点,在附录 A中给出了程序员使用的检查清单,这个检查清单 综合了本书的所有观点。 综上所述,成功地书写无错代码的关键可以总结为一个总的原则 决不允许同样错误出现两次
还剩171页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 20 金币 [ 分享pdf获得金币 ] 0 人已下载

下载pdf

pdf贡献者

巧乐兹

贡献于2010-10-21

下载需要 20 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf