memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew);
}
#endif
pbResize = (byte*)realloc(*ppb, sizeNew);
if(pbResize != NULL)
{
#ifdef DEBUG
{
/* 如果扩大,对尾部增加的内容进行初始化 */
if(sizeNew > sizeOld)
memset(pbResize+sizeOld, bGarbage, sizeNew-sizeOld);
}
#endif
*ppb = pbResize;
}
return( pbResize != NULL );
}
为了做这两件事在该函数中似乎增加了许多额外的代码。但仔细看过就会发现,其中的大部分内容都是虚的。如花括号、 #ifdef伪指令和注解。就算它确实增加了许多的额外代码,也不必杞人忧天。因为调试版本本来就不必短小精悍,不必有特别快的响应速度,只要能够满足程序员和测试者的日常使用要求就够了。因此,除非调试代码会变得太大、太慢而没法使用,一般在应用程序中可以加上你认为有必要的任何调试代码。以增强程序的查错能力。
重要的是要对子系统进行考查,确定建立数据和释放数据的各种情况并使相应的数据变成无用信息。
用 #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)
{
……
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在扩大内存块时不但确实会移动相应内存块的位置,而且原有的内存块必须被重新分配并被填写上新的数据。在汇编程序中,这两种情况都很少发生。
这使我们得出了编写无错代码的另一个准则:“不要让事情很少发生。”因此我们需要确定子系统中可能发生哪些事情,并且使它们一定发生和经常发生。如果发现子系统中有极罕见的行为,要干方百计地设法使其重现。
你有过跟踪错误跟到了错误处理程序中,并且感到“这段错误处理程序中的错误太多了,我敢肯定它从来都没有被执行过”这种经历吗?肯定有,每个程序员都有过这种经历。错误处理程序之所以往往容易出错,正是因为它很少被执行到。
同样,如果不是 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);
*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实际上并不紧张,尽管其调试版本是那样不屈不挠地对内存块进行移动,就好象吃了安非他明一样。
如果某事件很少发生并没有什么问题,只要在程序的交付版本和调试版本中不少发生就行。
保存一个日志,以唤起你的注意
从调试的端点看,内存管理程序的问题是当第一次创建内存块时知道其大小,但随后几乎马上就会失去这一信息,除非在某个地方保存了一个有关的记录。我们已经看到函数 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中改变了内存块的大小时,要修改相应的日志信息,使它反映出相应内存块的新大小和新位置。显然,我们可以把这三个动作封装在三个不同的调试界面中:
/* 为新分配的内存块建立一个内存记录 */
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);
}
#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);
}
就是这样。
现在,我们有了相应内存系统的完整记录。利用这些信息,我们可以很容易地编写出象 sizeofBlock和fValidPointer(见附录B)这样的函数,以及任何其它的有用函数。
不要等待错误发生
直到目前为止,我们所做的一切努力只能帮助用户注意到错误的发生。这固然不错,但它还不能自动地发现错误。以前面讲过的 deletenode函数为例,如果该函数调用函数FreeMemory释放某个结点时,在相应的树结构中留下了指向已释放内存空间的指针,那么在这些指针永远都不会被用到的情况下,我们能够发现这个问题吗?不,不能。又如,如果我们在函数fResizeMemory中忘了调用FreeMemory,又会怎样?
……
if( fNewMemory(&pbNew, sizeNew) )
{
memcpy(pbNew, *ppb, sizeOld)
/* FreeMemory(*ppb); */
*ppb = pbNew;
}
结果会在该函数中产生一个难解的错误。说它难解,是因为表面看起来,什么问题都没有。但我们每次执行这段程序,就会“丢失”一块内存空间。因为在把 pbNew赋给*ppb时,这个唯一指向该内存块的指针被冲掉了。那么该函数中的调试代码能够帮助我们查出这个错误吗?根本不能。
这些错误与前面讲的错误不同,因为它们不会引起任何不合法情况的发生。正如匪徒根本没打算出城,路障就没用了一样,在相应数据没被用到的情况下相应的调试代码也没用,因为它查不出这些错误。查不到错误并不意味这些错误不存在,它们确实存在只不过我们没有看到它们 ─── 它们“隐藏”得很深。
要找出这些错误,就得象程序员一样,对错误进行“挨门挨户”的搜查。不要等待错误自己暴露出来,要在程序中加上能够积极地寻找这种问题的调试代码。
对于上面的程序我们遇到两种情况。第一种情况,我们得到一个指向已被释放了的内存块的“悬挂指针”;第二种情况,我们分配了一个内存块,但却没有相应的指针指向它。这些错误通常都很难发现,但是如果我们在程序中一直保存有相应的调试信息,就可以比较容易地发现它们。
让我们来看看人们是怎样检查其银行财务报告书中的错误:我们自己有一个拨款清单,银行有一个拨款清单。通过对这两个清单进行比较,我们就可以发现其中的错误、这种方法同样可以用来发现悬挂指针和内存块丢失的错误。我们可以对已知指计表(保存在程序的调试信息中)进行比较,如果发现指针所引用是尚未分配的内存块或者相应的内存块没有被任何指针所指向,就肯定出了问题。
但程序员,尤其是有经验的程序员总是避免直接对存储在每个数据结构中的每个指针进行检查。因为要对程序中的所有数据结构以及存储在其中的所有指针进行跟踪,如果不是不可能的话,似乎也非常困难。实际的情况是,即使某些编写得很差的程序,也是为指针再单独分配相应的内存空间,以便于对其进行检查。
例如, 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; /* 结点的正文表示 */
……
}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();
/* 保证每个指针都没有问题 */
CheckMemoryRefs();
}
#endif
最后一个问题是:“应该在什么时候调用这个例程?”显然,我们应该尽可能多地调用这个例程,但其实这要取决于具体的需要。至少,在准备使用相应的子系统之前,应该调用这一例程对其进行一致性检查。如果能在程序等待用户按键、移动鼠标或者拨动硬件开关期间,对相应的子系统进行检查,效果会更好。总之,要利用一切机会去捕捉错误。
非确定性原理
我经常向程序员解释使用调试检查是怎么回事。在我解释的过程中,有时他或她会因为所加入的调试代码会对原有的代码产生妨碍,而对增加这种代码可能带来的不良后果的严重程度表示担忧。这又是一个与Heisenberg提出的“非确定性原理”有关的问题。如果读者对这一问题感兴趣,请继续读下去。
毫无疑问,所加入的调试代码会引起程序交付版本和调试版本之间的区别。但只要在加入调试代码时十分谨慎,并没有改变原有程序的内部行为,那么这种区别就不应该有什么问题。例如虽然fResizeMemory可能会很频繁地移动内存块,但它并没有改变该函数的基本行为。同样,虽然fNewMemory所分配的内存空间会比用户所请求的多(用于存放相应的日志信息),但这对用户程序也不应该有什么影响。(如果你指望请求分配 21个字节,fNewMemory或者malloc就应该恰好为你分配21个字节,那么无论有没有调试代码你都会遇到麻烦。因为要满足对齐要求,内存管理程序分配的内存总是要比用户请求的量多)
另一个问题是调试代码会增加应用程序的大小,因此需要占用更多的RAM。但是读者应该记得,建立调试版本的目的是捕捉错误,而不是最大限度地利用内存。对于调试版本来说,如果无法装人最大的电子表格,无法编辑最大可能的文档或者没法做需要大量内存的工作也没有什么关系,只要相应的交付版本能够做到这些就可以。使用调试版本会遇到的最坏情况,是相对交付版本而言,运行不久便耗尽了可用的内存空间,使程序异常频繁地执行相应的错误处理代码;最好的情况,是调试版本很快就捉住了错误,几乎没有或者花费很少的调试时间。这两种极端情况都有价值。
一点就透
Robert Cialdini博土在其“ Influence:How and Why people Agree to Things”一书中指出:如果你是个售货员,那么当顾客来到你负责的男装部准备购买毛衣和套装时,你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为在顾客买了一件$500元的套装之后,相比之下,一件$80元的毛衣就显得不那么贵了。但是如果你先给顾客看毛衣,那么$80元一件的价格可能会使其无法接受,最后也许你只能卖出一件$30元的毛衣。任何人只要花30秒的时间想一想,就会明白这个道理。可是,又有多少人花时间想过这一问题呢?
同样,一些程序员可能会认为, bGarbage选为何值并不重要,只要从过去用过的数中随便挑一个就行了。另外一些程序员也可能会认为,究竟是按先序、中序还是后序对符号表进行递归遍历并不重要。但正如我们在前面指出的那样,有些选择确实比另外的一些选择要好。
如果可以随意地选择实现细节的话,那么在做出相应的选择之前,要先停下来花 30秒钟考查一下所有的可能选择。对于每一种选择,我们都要问自己:“这种选择是会引起错误,还是会帮助发现错误?”如果对bGarbage的取值问过这一问题的话,你就会发现选择0会引起错误而选择OxA3之类的值则会帮助我们发现错误。
无需知道
在对子系统进行测试时,为了使用相应的测试程序,你可能遇到过需要了解这些测试程序各方面内容的情况。 fValidPointer的使用就是这样一个例子。如果你不知道有这样一个函数,就根本不会去使用它。然而,最好的测试代码应该是透明的代码,不管程序员能否感觉到它们的存在,它们都会起作用。
假定一个没有经验的程序员或者某个对项目不熟悉的人加入了项目组。在根本不知道 fNewMemory、fResizeMemory和FreeMemory的内部有相应测试代码的情况下,他不是照样可以随意地在程序中使用这些函数吗?
那么如果他没有意识到 fResizeMemory会引起内存块的移动,并因此在其程序中产生了类似于前述汇编程序中出现的错误,那么会发生什么现象呢?他需要因为执行了相应的一致性检查程序并产生了断言“illegal pointer”而对一致性检查程序的内容有所了解吗?
如果他创建并随即丢失了一个内存块,又会怎样呢?这时同样会执行相应的一致性检查程序并产生断言“ lost memory”。也许,他甚至连什么叫做“lost memory”都不知道。但事实是,他并不需要知道这个,相应的检查就可以起作用。更妙的是,通过跟踪这一错误不用向有经验的程序员请教也可以学到与内存丢失有关的内容。
这就是精心设计子系统测试代码的好处 ─── 当测试代码将错误限制在一个局部的范围之内后,就通过断言把错误抓住并送到“广播室”,把正常的工作打断。对于程序员来说,这真是再好不过的反馈。
我们交付的不是调试版本
在这一章中,我确实给内存管理程序加上了许多调试代码。对此,一些程序员可能会认为:“在程序中加入调试代码似乎很有用,但象这样把所有的检查都加上并且还包括了对日志信息的处理,就太过分了。”我得承认,我也有过这种感觉。
以前我也对给程序加上这么多降低效率的调试代码很反感,但不久我就认识到了自己的错误。在程序的交付版本中加上这种调试代码是会断送它的市场前途,但我们并没有在其交付版本中增加任何的测试代码,这些代码只是被用在了它的调试版本中。确实,调试代码会降低调试版本的运行速度。但使你的零售产品瘫在用户那儿,或者为了帮助查错使你的调试版本运行得稍慢,哪一种情况更糟糕呢?我们不应该担心调试版本的效率,因为毕竟顾客不会使用程序的调试版本。
重要的是要在感情上区分程序的调试版本和交付版本。调试版本事用来发现错误的,而交付版本则是用来取悦顾客的。因此在编码时,对这两个版本所作的权衡也会相当不同。
记住,只要相应的交付版本能够满足顾客的大小和速度要求,就可以对调试版本做你想做的任何事情。如果为内存管理程序加上日志程序可以帮助你发现各种难于捕捉的错误,那么就会皆大欢喜。顾客可以得到一个充满活力的程序而你不费很多的时间和精力就可以发现错误。
Microsoft的程序员总是在其程序中加上相应的调试代码。例如, Excel就含有一些内存子系统的测试程序(它们比我们这里介绍的还要详尽)。它有单元表格一致性检查程序;它有人为产生内存失败的机制,使程序员可以强制程序执行“内存空间耗尽”的错误处理程序;它还有许多的其它检查程序。这不是说Excel的交付版本从来没有错误,它确实有,但这些错误很少出现在通过了详尽的子系统检查的代码中。
同样,虽然我们在这一章中给内存管理程序增加了许多的代码,但增加的所有代码都是用来构造 fNewMemory、FreeMemory和fResizeMemory,我们没给这些函数的调用程序增加任何东西,也没给malloc、free和realloc的内部支持代码(它们可以非常重要)增加任何东西。甚至增加调试代码所引起的速度下降,也并非如想象的那样糟糕。如果Microsoft公司的统计结果具有代表性的话,程序调试版本(充满了断言和子系统测试)的速度大约应该是相应交付版本的一半。
不要把对交付版本的约束应用到相应的调试版本上
要用大小和速度来换取错误检查能力
|
确有其事
为了发现更多的错误,过去Microsoft总是把其开发的应用程序的调试版本送给β测试者进行β测试。但当基于产品的β调试版本对产品进行评论的“Pre-release”周刊出现,并且说其程序虽然非常好,但就是慢得和鼻涕虫一样之后,他们不再至少是暂时不再提供产品的β调试版本。这个事实告诫我们不要把调试版本送到测试场所,或者在这样做之前要把调试版本中影响性能的内部调试检查代码全部清除。
小结
在这一章中,我们介绍了六种增强内存子系统的方法。这些方法虽然是针对内存子系统提出来的,但其中的观点同样适用于其它的子系统。大家可以想象得出,在程序自己具有详尽的确认能力之后错误要想悄悄地溜入这种程序,简直比登天还难。同样,假如在我前面讲过的汇编程序中用上了这些调试检查,那么通常要花上几年才能发现的 realloc错误,在相应代码第一次编写的几个小时或者几天之内就可以被自动地发现。不管程序员的技术很高,还是没有经验,这些测试代码都能够抓住这个错误。
事实上,这些测试代码能够抓住所有的这类错误。而且是自动地抓住,不靠运气,也不靠技巧
这就是编写无错代码的方法。
要点:
l 考查所编写的子系统,问自己:“在什么样的情况下,程序员在使用这些子系统时会犯错误。”在子系统中加上相应的断言和确认检查代码,以捕捉难于发现的错误和常见的错误。
l 如果不能使错误不断重现,就无法排除它们。找出程序中可能引起随机行为的因素,并将它们从程序的调试版本中清除。把目前尚“无定义”的内存单元置成了某个常量值,就可能产生这种错误。在这种情况下,如果程序在该单元被正确地定义为某个值之前引用了它的内容,那么每次执行这部分错误的代码,都会得到同样的错误结果。
l 如果所编写的子系统释放内存(或者其它的资源),并因此产生了“无用信息”,那么要把它搅乱,使它真的象无用信息。否则,这些被释放了的数据就有可能仍被使用,而又不会被注意到。
l 类似地,如果在所编写的子系统中某些事情可能发生,那么要为该子系统加上相应的调试代码,使这些事情一定发生。这样可以增大查出通常得不到执行的代码中的错误的可能性。
l 尽力使所编写的测试代码甚至在程序员对其没有感觉的情况下亦能起作用。最好的测试代码是不用知道其存在也能起作用的测试代码。
l 如果可能的话,把测试代码放到所编写的子系统中,而不要把它放到所编写子系统的外层。不要等到进行了系统编码时,才考虑其确认方法。在子系统设计的每一步,都要考虑“如何对这一实现进行详尽的确认”这一问题。如果发现这一设计难于测试或者不可能对其进行测试,那么要认真地考虑另一种不同的设计,即使这意味着用大小或速度作代价去换取该系统的测试能力也要这么做。
l 在由于速度太慢或者占用的内存太多而抛弃一个确认测试程序之前,要三思而后行。切记,这些代码并不是存在于程序的交付版本中。如果发现自己正在想:“这个测试程序太慢、太大了”,那么要马上停下来问自己:“怎样才能保留这个测试程序,并使它既快又小?”
练习
1) 如果在进行代码测试时偶然碰到了 0xA3的某种组合构成的数据,那么这一数据可能是未经过初始化的数据,或者是已被释放了的数据。怎样才能修改相应的凋试代码,使我们可以比较容易地确定所发现的数据是哪一类?
2) 程序员编写的代码有时会对所分配内存块上界之外的内存单元进行填充。请给出增加相应的内存子系统检查,使其能够对这类错误报警的方法。
3) 虽然 CheckMemoryIntegrity程序被用来对悬挂指针错误进行检查,但在有些情况下,该程序检查不出这种错误。例如,假定一个函数调用了FreeMemory,但由于该函数的错误,某个指针被悬挂起来,即该指针指向的内存块已被FreeMemory释放掉。现在我们进一步假定在该指针被确认之前,某个函数调用fNewMemory对这块刚释放不久的内存块进行了再分配。这样一来,刚才被悬挂起来的指针又指向了新分配的内存块。但是,这个内存块已经不是原来那个内存块了。因此,这是个错误。但对于CheckMemoryIntegrity来说却一切都很正常,并没有什么不合法。假如这个错误在你的项目中比较常见,那么怎样增强该程序才能使其查出这个问题呢?
4) 利用 NoteMemoryRef程序,我们可以对程序中的所有指针进行确认。但是,我们如何对所分配内存块的大小进行确认呢?例如,假定指针指向的是一个含有18个字符的字符串,但所分配内存块的长度却小于18。或者在相反的情况下,程序认为所分配的内存块有15个字节,但相应的日志信息表明为其分配了18个字节。这两种情况都是错误的。怎样加强相应的一致性检查程序,使其能够查出这种问题?
5) NoteMemoryRef可以使我们把一个内存块标为“已被引用”,但利用它我们无法知道引用该内存块的指针数目是否超过了其应有的数目。例如,双向链表的每个结点只应该有两个引用。一个是前向指针,另一个是后向指针。但在大多数的情况下,每个内存块只应该有一个指针在引用着它。如果有多个指针同时引用一个内存块,那么一定是程序中什么地方出了错误。如何改进相应的一致性检查程序,使其对某些内存块允许多个指针对其进行同时的引用;但对另外一些内存块仍不允许多个指针对其进行同时的引用,并在这种情况发生时,引发相应的断言?
6) 本章自始自终所谈的都是为了帮助程序员检查错误,可以在相应的内存系统中加上调试代码。但是,我们可不可以增加对测试者有所帮助的代码呢?测试者知道程序经常会对错误情况进行不正确的处理,那么如何为测试者提供模拟“内存空间耗尽”这一条件的能力呢?
课题:
考查你项目中的主要子系统,看看为了检查出与使用这些子系统有关的常见错误,可以实现哪种类型的调试检查?
课题:
如果没有所用操作系统的调试版本,那么尽可能买一个。如果买不到,就利用外壳函数自己写一个。如果你助人为乐,那么请使所编出的代码(以某种方式)可以为其它的开发者所用。
第4章 对程序进行逐条跟踪
前面我们讲过,发现程序中错误的最好方法是执行程序。在程序执行过程中,通过我们的眼睛或者利用断言和子系统一致性检查这些自动的测试工具来发现错误。然而,虽然断言和子系统检查都很有用,但是如果程序员事先没有想到应该对某些问题进行检查也不能保证程序不会遇到这些问题。这就好比家庭安全检查系统一样。
如果只在门和窗户上安装了警报线,那么当窃贼从天窗或地下室的入口进入家中时,就不会引起警报。如果在录像机、立体声音响或者其它一些窃贼可能盗取的物品上安装了干扰传感器,而窃贼却偷取了你的 Barry Manilow组合音响,那么他很可能会不被发现地逃走。这就是许多安全检查系统的通病。因此,唯一保证家中物品不被偷走的办法是在窃贼有可能光顾的期间内呆在家里。防止错误进入程序的办法也是这样,在最有可能出现错误的时候,必须密切注视。
那么什么时候错误最有时能出现呢?是在编写或修改程序的时候吗?确实是这样。虽然现在程序员都知道这一点,但他们却并不总能认识到这一点的重要性,并不总能认识到编写无错代码的最好办法是在编译时对其进行详尽的测试。
在这一章中,我们不谈为什么在编写程序时对程序进行测试非常重要,只讲在编写程序时对程序进行有效测试的方法。
增加对程序的置信
最近,我一直为 Microsoft的内部Macintosh开发系统编写某个功能。但当我对所编代码进行测试时,发现了一个错误。经过跟踪,确定这个错误是出在另一个程序员新编的代码中。使我迷惑不解的是,这部分代码对其他程序员的所编代码非常重要,我想不出他这部分代码怎么还能工作。我来到他的办公室,以问究竟
“我想,在你最近完成的代码中我发现了一个错误”。我说道。“你能抽空看一下吗?”他把相应的代码装入编辑程序,我指给他看我认为的问题所在。当他看到那部分代码时不禁大吃一惊。
“你是对的,这部分代码确实有错。可是我的测试程序为什么没有查出这个错误呢?”
我也对此感到奇怪。“你到底用什么方法测试的这部分代码?”,我问道。
他向我解释了他的测试方法,听起来似乎它应该能够查出这个错误。我们都感到很费解。“让我们在该函数上设置一个断点对其进行逐条跟踪,看看实际的情况到底怎样”,我提议道。
我们给该函数设置了一个断点。但当找们按下运行键之后,相应的测试程序却运行结束了,它根本就没有碰上我们所设置的断点。没过多久,我们就发现了测试程序没有执行该函数的原因 ─── 在该函数所在调用链上几层,一个函数的优化功能使这个函数在某种情况下面跳过了不必要的工作。
读者还记得我在第 1章中所说的黑箱测试问题吗?测试者给程序提供大量的输入,然后通过检查其对应的输出来判断该程序是否有问题。如果测试者认为相应的输出结果没有问题,那么相应的程序就被认为没有问题。但这种方法的问题是除了提供输入和接受输出之外,测试者再没有别的办法可以发现程序中的问题。上述程序员漏掉错误的原因是他采用了黑箱方法对其代码进行测试,他给了一些输入,得到了正确的输出,就认为该代码是正确的。他没有利用程序员可用的其他工具对其代码进行测试。
同大多数的测试者不同,程序员可以在代码中设置断点,一步一步地跟踪代码的运行,观察输入变为输出的过程。尽管如此,但奇怪的是很少有程序员在进行代码测试时习惯于对其代码进行逐条的跟踪。许多程序员甚至不耐烦在代码中设置一个断点,以确定相应代码是否被执行到了。
还是让我们回到这一章开始所谈论的问题上:捕捉错误的最好办法是在编写或修改程序时进行相应的检查。那么,程序员测试其程序的最好办法是什么呢?是对其进行逐条的跟踪,对中间的结果进行认真的查看。对于能够始终如一地编写出没有错误程序的程序员,我并不认识许多。但我所认识的几个全都有对其程序进行逐条跟踪的习惯。这就好比你在家时夜贼光临了 ─── 除非此时你睡着了,否则就不会不知道麻烦来了。
作为一个项目负责人,我总是教导许多程序员在进行代码测试时,要对其代码进行遍查,而他们总是会吃惊地看着我。这倒不是他们不同意我的看法,而是因为进行代码遍查听起来太费时间了。他们好容易才能赶得上进度,又哪有时间对其代码进行逐条的跟踪呢?幸好这一直观的感受是错误的。是的,对代码进行逐条的跟踪确实需要时间,但它同编写代码相比,只是其一小部分。要知道,当实现一个新函数时,你必须为其设计出函数的外部界面,勾画出相应的算法并把源程序全部输入到计算机中。与此相比,在你第一次运行相应的的程序时,为其设置一个断点,按下“步进”键检查每行的代码又能多花多少时间呢?并不太多,尤其是在习惯成自然之后。这就好比学习驾驶一辆手扳变速器的轿车,一开始好象不可能,但练习了几天以后,当需要变速时你甚至可以无意识地将其完成。同样,一旦逐条地跟踪代码成为习惯之后,我们也会不加思索地设置断点并对整个过程进行跟踪。可以很自然地完成这一过程,并最后检查出错误。
代码中的分支
当然有些技术可以使我们更加有效地对代码进行逐条的跟踪。但是如果我们只对部分而不是全部的代码进行逐条跟踪,那么也不会取得特别好的效果。例如,所有的程序员都知道错误处理代码常常有错,其原因是这部分代码极少被测试到,而且除非你专门对这部分代码进行测试,否则这些错误就不会被发现。为了发现错误处理程序中的错误,我们可以建立使错误情况发生的测试用例,或者在对代码进行逐条跟踪时可以对错误的情况进行模拟。后一种方法通常费时较少。例如,考虑下面的代码中断:
pbBlock = (byte*)malloc(32);
if( pbBlock == NULL )
{
处理相应的错误情况 ;
……
}
……
通常在逐条跟踪这段代码时, malloc会分配一个32字节的内存块,并返回一个非NULL的指针值使其中的错误处理代码被绕过。但为了对该错误处理代码进行测试,可以再次逐条跟踪这段代码并在执行完下行语句之后,立即用跟踪程序命令将pbBlock置为NULL指针值:
pbBlock =( byte*)malloc(32);
虽然 malloc可能分配成功,但将pbBlock置为NULL指针就相当于malloc产生了分配失败,从而使我们可以步进到相应的错误处理部分。(注意:在改变了pbBlock的值之后,malloc刚分配的的内存块即被丢失,但不要忘了这只是在做测试!)除了要对错误情况进行逐条的跟踪之外,对程序中每一条可能的路径都应该进行逐条的跟踪。程序中具有多条代码路径的明显情况是if和switch语句,但还有一些其它的情况:&&,||和?:运算符,它们每个都有两条路径。
为了验证程序的正确性,至少要对程序中的每条指令逐条跟踪一遍。在做完了这件事之后,我们对程序中不含错误就有了更高的置信。至少我们知道对于某些输入,相应的程序肯定没错。如果测试用例选择得好,代码的逐条跟踪会使我们受益非浅。
大的变动怎么样?
过去程序员问过这样的问题:“如果我增加的功能与许多地方的代码都有关系怎么办?那对所有增加的新代码进行逐条的跟踪不是太费时间了吗?”假如你是这么想的,那么我不妨问你另一个问题:“如果你做了这么大的变动,在进行这些改动时可能不引进任何的问题吗?“
习惯于对代码进行逐条跟踪会产生一个有趣的负反馈回路。例如,对代码进行逐条跟踪的程序员很快就会学会编写较小的容易测试的函数,因为对于大函数进行逐条的跟踪非常痛苦。(测试一个10页长的的函数比测试10个一页长的函数要难得多)程序员还会花更多的时间去考虑如何使必需做的大变动局部化,以便能够更容易地进行相应的测试。这些不正是我们所期望的吗?没有一个项目的负责人喜欢程序员做大的变动,它们会使整个项目太不稳定。也没有一个项目负责人喜欢大的、不好管理的函数,因为它们常常不好维护。
如果发现必须做大的变动,那么要检查相应的改变并进行判断。同时要记住,在大多数情况下,对代码进行逐条跟踪所花的时间要比实现相应代码所花的时间少得多。
数据流 ─── 程序的命脉
在我编写的第 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,所以结果正确。
我几乎立即就发现了这个错误,因为在把它搁置在一边继续往下走查之前,我又多花了一点时间逐条跟踪了这部分代码。确实,这个错误很严重,最终一定会被发现。但要记住,我们的目标是尽可能早地查出错误。对代码进行逐条跟踪可以帮助我们达到这个目标。
对代码进行逐条跟踪的真正作用是它可以使我们观察到数据在函数中的流动。如果在对代码进行逐条跟踪时密切地注视数据流,就会帮助你查出下面这么多的错误:
l 上溢和下溢错误;
l 数据转换错误;
l 差 1错误;
l NULL指针错误;
l 使用废料内存单元错误( 0xA3类错误);
l 用 = 代替 == 的赋值错误;
l 运算优先级错误;
l 逻辑错误。
如果不注重数据流,我们能发现所有这些错误吗?注重数据流的价值在于它可以使你以另一种非常不同的观点看待你的代码。你也许没能够注意到下面程序中的赋值错误:
if( ch = ’/t’ )
ExpandTab();
但当你对其进行逐条跟踪,密切注视其数据流时,很容易就会发现 ch的内容被破坏了。
为什么编译程序没有对上述错误发出警告?
在我用来测试本书中程序的五个编译程序中尽管每个编译程序的警告级别都被设置到最大,但仍没有一个编译程序对于b<<24这个错误发生警告。这一代码虽然是合法的ANSI C,但我想象不出在什么情况下这一代码实际能够完成程序员的意图。既然如此,为什么不给出警告呢?
当你遇到这种错误,要告诉相应编译程序的制造商,以使该编译程序的新版本可以对这种错误送出警告。不要低估作为一个花了钱的顾客你手中的权利。
你遗漏了什么东西吗?
使用源级调试程序的一个问题是在执行一行代码时可能会漏掉某些重要的细节。例如,假定在下面的代码中错误地将 && 输入了 & :
/* 如果该符号存在并且它有对应的正文名字,
* 那么就释放这个名字
*/
if( psym != NULL & psym->strName != NULL )
{
FreeMemory( psym->strName );
psym->strName = NULL;
}
这段程序虽然合法但却是错误的。 if语句的使用目的是避免使用NULL指针psym去引用结构symbol的成员strName,但上面的代码做的却并不是这件事情。相反,不管psym的值是否为NULL这段程序总会引用strName域。
如果使用源级调试程序对代码进行逐条跟踪,并在到达该 if语句时,按了“步进”键,那么调试程序将把整个if语句当做一个操作来执行。如果发现了这个错误,你就会注意到即使在其表达式的左边是FALSE的情况下,表达式的右边仍会被执行。(或者,如果你很幸运,当程序间接引用了NULL指针时系统会出现错误。但并没有许多的台式计算机会这样做,至少在目前它们不这样做。)
记得我们以前说过: & ,|| 和 ? : 运算符都有两条路径,因此要查出错误就必须对每条路径进行逐条的跟踪。源级调试程序的问题是用一个单步就越过了 && 、||和 ?: 的两条路径。有两个实用的方法可以解决这一问题。
第一个方法,只要步进到使用 && 和 || 运算符的复合条件语句,就扫描相应的一些条件,验证这些条件拼写无误然后使用调试程序命令显示条件中每个比较的结果。这样做可以帮助我们查出在某些情况下虽然整个表达式的计算结果正确,但该表达式中确实有错误这一情况。例如,如果你认为在这种情况下 || 表达式的第一部分应该是TRUE,第二部分应该是FALSE,但其结果恰恰相反。此时虽然整个表达式的计算结果虽然正确,但表达式中却有错误。观察表达式的各个部分可以发现这类问题。
第二个,也是更彻底的方法是在汇编语言级步进到复合条件语句和?:运算符的内部。是的,这要花费更多的工夫,但对于关键的代码,为了观看到中间的计算结果而对其内部的代码实际地走上一遍是很重要的。这同对 C语句进行逐条的跟踪一样,一旦你习惯之后。对汇编语言指令进行逐条地跟踪也很快,只不过需要经过练习而已。
源级调试程序可能会隐瞒执行的细节
对关键部分的代码要进行汇编指令级的逐条跟踪
|
关掉优化?
如果所用的编译程序优化功能很强,那么对代码进行逐条的跟踪可能会是一个十分有趣的练习。因为编译程序在生成优化的代码时,可能会把相邻源语句对应的机器代码混在一块。对于这种编译程序,一条“单步”命令跳过三行代码并非不常见;同样,利用“单步”指令执行完一行将数据从一处送到另一处的源语句之后却发现相应的数据尚未传送过去的情况也很常见。
为了对代码进行逐条跟踪容易一些,在编译调试版本时可以考虑关掉不必要的编译程序优化。这些优化除了扰乱所生成的机器代码之外,毫无用处。我听到过某些程序员反对关掉编译程序的优化功能他们认为这会在程序的调试版本和交付版本之问产生不必要的差别从而带来风险。如果担心编译程序会产生代码生成错误的话,这种观点还有点道理。但同时我们还应该想到,我们建立调试版本的目的是要查出程序中的错误,既然如此,如果关掉编译的优化功能可以帮助我们做到这点,那么就值得考虑。
最好的办法是对优化过的代码进行逐条的跟踪,先看看这样做的困难有多大,然后为了有效地对代码进行逐条跟踪,只关闭那些你认为必须关闭的编译程序优化功能。
小结
我希望我知道一种能够说服程序员对其代码进行逐条跟踪的方法,或者至少能够使他们尝试一个月。但是我发现,程序员一般说来都克服不了“那太费时间”这一想法。作为项目负责人的一个好处是对于这种事情你可以霸道一些,直到程序员认识到这样做并不费很多时间,并且觉得很值得这样做,因为出错率显著的下降了。
如果你还没有对你的程序进行逐条的跟踪,你会开始这样做吗?只有你自己才知道这个问题的答案。但我猜想当你拿起这本书并开始阅读的时候,准是因为你正被减少你或你领导的程序员的代码中的错误所困扰。这自然就归结为如下的问题:你是宁愿花少量的时间,通过对代码进行逐条的跟踪来验证它;还是宁愿让错误溜进原版源代码中,希望测试者能够注意到这些错误以便你日后对其进行修改。选择在你。
要点:
l 代码中不会自己生出错误来,错误是程序员编写新代码或者修改现有代码的产物。如果你想发现代码中的错误,没有哪个办法比在对代码进行编译时对其进行逐条跟踪更好。
l 虽然直观上你可能认为对代码进行走查会花费大量的时间,但这是不对的。刚开始进行代码的走查确实要多花一点时间,但当这一切习惯成自然之后并不会多花多少时间,你可以很快地走查一遍。
l 一定要对每一条代码路径进行逐条的跟踪,至少要跟踪一遍,尤其是对代码中的错误处理部分。不要忘记 &&、|| 和?:这些运算符,它们每个都有两条代码路径需要进行测试。
l 在某些情况下也许需要在汇编语言级对代码进行逐条的跟踪。尽管不必经常这样做,但在必要的时候不要回避这种做法。
课题:
如果看看第一章中的练习,你就会发现它们所涉及的都是编译程序能够自动为你检查出来的常见错误。重新考查一遍这些练习,这次问问自己:如果使用调试程序对相应的代码进行逐条跟踪,你会漏掉那些错误吗?
课题:
看着六个月以来对你的程序报告出来的错误,确定假如你在编写程序时对其进行了逐条跟踪的话,你会抓住多少个错误。
第5章 糖果机界面
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返回值的变量。
这不是说明即使有经验的程序员也必须小心谨慎地使用函数吗?按照 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中是无用信息
这样一来,“ char还是int”的问题就解决了。任何程序员,不管多么幼稚都不太可能偶然忘记测试它的错误返回值,比较一下getchar和fgetchar的返回值,你看出getchar强调的是所返回的字符而fGetChar强调的是错误情况吗?如果你的目标是编写出无错的代码,那么你认为应该强调哪一方面?
确实,这样一来在编写代码时就失去了下面的灵活性:
putchar( getchar() );
但你知道 getchar的失败频度有多高吗?而几乎在所有的情况下,上面的代码都会产生错误。
一些程序员可能会想:“确实 fGetChar的界面很安全,但却浪费了代码。因为在调用它时,必须多传一个参数。另外如果程序员没有传递 &ch 而传递了ch怎么办?当程序员使用scanf函数时,忘记相应的 & ,长期以来一直是一个出错的根源。”
问得好。
编译程序生成代码的好坏其实取决于具体的编译程序,有的编译程序生成稍多的代码,有的稍少,因为我们不必在每次调用该函数之后对函数的返回值和 EOF进行比较。不管稍多也好稍少也罢,考虑到磁盘和存储器价格的暴跌,同时程序的复杂性及相应的错误率骤增,代码大小上的细微差别也许并不值得顾虑。
在于第二个问题 ─── 比如为 fGetChar传递了字符而不是字符指针,在采用了第1章建议的函数原型之后也用不着担心。如果给fGetChar传递了非字符指针的其它参数,编译程序会自动地产生一条错误信息向你指明所犯的错误。
事实上把相互排斥的输出组合到单一返回值中的做法是从汇编语言继承下来的。对于汇编语言来说,只有有限的机器寄存器可以用来处理和传递数据。因此,在汇编语言环境中使用一个寄存器返回两个相互排斥的值既有效率常常又是必需的。然而用 C编程是另一回事,尽管C可以使我们“更接近于机器”,但这并不是说我们应该把它当作高级的汇编语言来使用。
当设计函数的界面时,要选择使程序员第一次就能够写出正确代码的设计。不要使用引起混淆的双重意义的返回值 ─── 每个输出应该只代表一种数据类型,要在设计中显式地体现出这些要点,使用户很难忽视这些重要的细节。
要使用户不容易忽视错误情况
不要在正常地返回值中隐藏错误代码
|
只再多考虑一下
程序员总知道在什么时候把多个输出组合到单一的返回值中,所以实施上述的建议很容易 ─── 只要不那么做就行了。然而在其它的情况下,程序员设计的界面可能很好,但却象特洛伊木马一样会含有潜在的危险。观察一下改变内存块大小的以下代码:
pbBuf = (byte*)realloc( pbBuf, sizeNew );
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的丢失内存块问题。本书的全部论点都建筑在相互作用的基础上,它们会起到意想不到的效果。这就是一个例证。
然而,将函数的输出分开不总能使我们避免设计出隐藏陷阱的界面,我真希望对此给出一点更好的忠告,但我认为找出这些暗藏陷阱的唯一办法是停下来思考所做的设计。这样做的最佳途径是检查输入和输出的各种可能组合,寻找可能引起问题的副作用。我知道这样做有时非常乏味,但要记住:这比以后再花时间回过来考虑这一问题要划算得多。最坏的情况是略过这一步骤,那么天晓得会有多少个其他的程序员要对设计的不好的界面所引起的错误进行跟踪追击了。只要想一想为了查出由 getchar,malloc和realloc这类界面暗藏陷阱的函数所引起的错误,全世界的程序员要浪费掉多少时间,我们对所有按此模式编写出其他函数简直无话可说。这真是太可怕了!其实只要在设计时多多考虑一点,就可以完全避免这种现象。
单一功能的内存管理程序
虽然在第 3章我们花了许多时间去讨论realloc函数,但并没有涉及到它许多更令人奇怪的方面。如果你抽出C运行库手册,查出realloc的完整描述你就会发现一些类似于下面的叙述:
void* realloc( void* pv, size_t size );
realloc改变先前已分配的内存块的大小,该内存块的原有内容从该块的开始位置到新块和老块长度的最小长度之间得到保留。
l 如果该内存块的新长度小于老长度, realloc释放该块尾部不再想要的内存空间,返回的pv不变。
l 如果该内存块的新长度大于老长度,扩大后的内存块有可能被分配到新的地址处,该块的原有内容被拷贝到新的位置。返回的指针指向扩大后的内存块,并且该块扩大部分的内容未经初始化。
l 如果满足不了扩大内存块的请求, realloc返回NULL,当缩小内存块时,realloc总会成功。
l 如果 pv为NULL,那么realloc的作用相当于调用malloc(size),并返回指向新分配内存块的指针,或者在该请求无法满足时返回NULL。
l 如果 pv不是NULL,但新的块长为零,那么realloc的作用相当于调用free(pv)并且总是返回NULL。
l 如果 pv为NULL且当前的内存块长为零,结果无定义
哎呀! realloc真是一个实现得“面面俱到”的最好例子,它在一个函数中完成了所有的内存管理工作。既然如此还要malloc干什么?还要free干什么?realloc全包了。
有几个很好的理由说明我们不应该这样设计函数。首先,这样的函数怎么能指望程序员可以安全地使用呢?它包括了如此之多的细节,甚至有经验的程序员都不全知道。如果你对此有疑问,不妨调查一下,算算有多少程序员知道给 realloc传递一个NULL指针相当于调用了malloc;又有多少程序员知道给realloc传递一个为零的块长效果与调用free相同。确实,这些功能都相当隐秘,所以我们可以问他们要避免错误就必须知道的一些问题,如当调用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); //设缩小不会失败
只写:
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的字符串确定是个以零结尾的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 ─── 从字符串中抽取子串
* 把 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函数开始考核候选者。我递给候选者一个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是大写字母,就返回它对应的小写字母;否则,将其不改变地返回。”
如果发现无法消除错误的情况,那么可以考虑干脆不允许这些有问题的情况出现,即用断言对函数的输入进行验证。如果把这一建议应用于 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当作有名的常量来使用。但它们并不是有名的常量,只不过是莫明其妙数字的一种正文表示。例如,下面的调用完成什么工作?
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);
这样我们可以得到清晰的灵活设计,它使相应的调用代码容易理解,同时还增加了该函数的功能。
这一建议似乎与我们早先说过的“要严格地定义函数的参数”互相矛盾 ─── 我们把具体的 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时,这两种描述会产生什么样的差别?你认为他会编写新的代码,还是会从你做的注解中复制下典型用法给出的例子,然后再根据需要对其进行修改?
按照这种方式对函数进行注解的另一个积极作用,是它可以迫使不够谨慎的程序员停下来考虑别的程序员怎样才能使用他们编出的函数。如果程序员设计的函数界面很笨,在编写典型用法时,他就应该注意到界面的笨拙。即使他没有注意到界面的问题,只要典型用法给出的例子详尽正确,也没有什么关系。例如,倘若 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的界面都使用户容易出错,这些害处都应该予以消除而不仅仅是给予说明。
小结
设计能够抵御错误的界面并不困难,但这确实需要多加考虑并且愿意放弃根深蒂固的编码习惯。这一章给出的建议只需简单地改变函数的界面,就可以使程序员编写出正确的代码,而不必过多地考虑其它部分的代码。本章贯穿始终的关键慨念是“尽可能地使一切清晰明了”。如果程序员理解并记住了每个细节,也许就不会犯错误 ─── 他们之所以会犯错误,是因为他们忘记了或者从来就不知道这些重要的内容。因此要设计能够抵御错误的界面,使程序员很难无意地忽视相应的细节。
要点:
l 最容易使用和理解的函数界面,是其中每个输入和输出参数都只代表一种类型数据的界面。把错误值和其它的专用值混在函数的输入和输出参数中,只会搞乱函数的界面。
l 设计函数的界面迫使程序员考虑所有重要细节(如错误情况的处理),不要使程序员能够很容易地忽视或者忘记有关的细节。
l 老要想到程序员调用所编函数的方式,找出可能使程序员无意间引入错误的界面缺陷。尤其重要的是要争取编出永远成功的函数,使调用者不必进行相应的错误处理。
l 为了增加程序的可理解性从而减少错误,要保证所编函数的调用能够被必须阅读这些调用的程序员所理解。莫明其妙的数字和布尔参数都与这一目标背道而驰,因此应该予以消除。
l 分解多功能的函数。取更专门的函数名(如 ShrinkMemory而不是 realloc)不仅可以增进人们对程序的理解,而且使我们可以采用更加严格的断言自动地检查出调用错误。
l 为了向程序员展示出所编函数的适当调用方法,要在函数的界面中通过注解的方式详细说明。要强调危险的方面。
练习:
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中要求的显式&。但为什么使用这一特征会产生容易出错的界面,而不是能够抵御错误的界面?
6) 标准的 strCmp函数取两个字符串并对它们进行逐字符的比较。如果这两个字符串相等,strcmp返回0;如果第一个字符串小于第二个,它返回负数;如果第一个字符串大于第二个,它返回正数。因此当调用strcmp时,相应的代码通常有下面的形式:
if( strcmp(str1,str2) re1_op 0 )
……
这里 rel_op是 == 、 != 、> 、>= 、< 或 <= 之一,尽管这样也可以完成相应的比较,但对于不熟悉strcmp函数的人来说它毫无意义。为字符串比较设计至少两个其它的函数界面,所设计的界面应该更能抵御错误,更加可读。
课题:
检查一个标准的 C库函数对相应的界面重新设计使其更能抵御错误。为了使重新设计的函数更加明了易懂,给这些函数改变名字的利弊是什么?
课题:
在大量的代码中搜寻所使用的 memset、memmove、memcpy和strn系列函数(如strncpy等)。在所找到的调用中,有多少个要求对应的函数接受为零的计数值?所得到的这种便利足以说明允许函数接受零计数值是合理的吗?
第6章 风险事业
假如将一程序员置于悬崖边,给他绳子和滑翔机,他会怎样从悬崖上下来呢?是沿绳子爬下来呢?还是乘滑翔机呢?还是干脆直接跳下来呢?是沿绳子爬下来还是使用滑翔机我们说不太准,但可以肯定,他不会跳下来,因为那太危险了。可是当程序员有几种可能的实现方案时,他们却经常只考虑空间和速度,而完全忽视了风险性。如果程序员处于这样的悬崖边而又忽视了风险性,只考虑选择到达崖底最有效的途径的话.结果又将如何呢?
程序员忽视风险性,至少有两个原因:
一是因为他们盲目地认为,不管他们怎样实现编码,都不会有错误。没有任何程序员会说:“我准备编写快速排序程序,并打算在程序中有三个错误。”程序员并没有打算出错,而后来错误出现了,他们也并不特别吃惊。
我认为程序员忽视风险性的第二个原因也是主要原因:在于从来没有人教他们这样去问问题:“该设计有多大的风险性?该实现有多大的风险性?有没有更安全的方法来写这个表达式?能否测试一下该设计?”要想问出这些问题,首先必须从思想上放弃这样的观点:不管作出哪种选择,最后总能得到无错代码。即使该观点是正确的,可是什么时候能得到无错代码呢?是由于使用安全的编码,在几天或几周之后就可以得到无错代码呢?还是由于忽视了风险性,出现很多错误而需要经过数月的调试和修改之后才能得到无错代码呢?
因此本章将讨论在某些普通的编码实践中所存在的一些风险,以及如何做才能减少甚至消除这些风险。
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 )
……
我的问题是 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的值,这是有符号字符和无符号字符域的交集。所以,当代码写为:
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个字位
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效率可能会低一些,也应该坚持使用可移植类型。
数据上溢或下溢
有这样一些代码,表面看起来很正确。但是由于实现上存在着微妙的问题,执行却失败了,这是最严重的错误。“简单字符”就是这种性质的错误。下面的代码也具有这样的错误,这段代码用作初始化标准 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++;
}
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 );
*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,因此应解释为无符号数。
至此,代码正确了,但并不美观。上面的代码容易让人产生错觉。根据可移植类型。 -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 */
可以查阅任何算法书籍,从中找到处理二叉树的有效方法。可是当我阅读了字符窗口代码中有关向树中插入子窗口的代码时,我有点吃惊了,该代码如下所示;
/* 指向最顶层窗口列表,例如象菜单框和主文件窗口
*/
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两个小组担心代码庞大。
我提出 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;
}
}
上面的代码不仅改进了 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是链中第一个“下一个兄弟指针”
*/
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。
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;
}
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] );
}
通过避免使用“ ?:”可以得到更好的算法,而不仅仅是看上去好一些的方法。列表版本与以前的版本相比较,哪个更好理解呢?哪个产生的代码最好呢?哪个更容易第一次实现就正确呢?你应该从中领悟到一些道理。
消除代码的冗余性
在实现中,有时得支持特殊情况。为了避兔特殊情况遍及整个函数,我们把处理特殊情况的代码独立出来。这样,维护人员在以后的维护中就不会无意识地将其遗漏而导致出现错误。
前面已经给出了实现 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
pch ++;
return( ( pch < pchEnd ) ? pch : NULL );
}
再与下面的 memchr版本做一下比较:
void* memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = ( unsigned char * )pv;
unsigned char *pchEnd = pch + size;
while( pch < pchEnd )
{
if( *pch == ch )
return ( pch );
pch ++ ;
}
return( NULL );
}
哪个看上去更好一些呢?第一个版本要比较 pch和pchEnd两次,第二个版本只比较一次。哪个更清楚呢?更重要的是哪个第一次执行就可能正确呢?
由于第二个版本只在 while条件中进行块范围检查,所以它更容易理解并且准确地实现了函数的功能。第一个版本的唯一长处是当需要将程序打印出来时,可以节省一些纸。
不返回太危险
上面给出的 memchr两个版本正确吗?你是否看出这两个版本具有同样一个细小错误?提示一下:当pv指向存储区的最后72个字节,并且size也是72时,memchr将要查找存储区的什么范围呢?如果答案是“存储区的全部范围,反复不断地查找。”那么你的回答就是对的。由于在程序中使用了有风险的惯用语,memchr陷入了无限的循环之中。
有风险的惯用语就是这样一些短语或表达式,它们看上去似乎能够正确地工作,但实际上在某些特殊场合下,它们并不能正确执行。 C语言就是具有这样一些惯用语的语言,最好的办法是:无论什么时候,只要有可能就尽量避免使用这些惯用语。在memchr中有风险的惯用语是:
pchEnd = pch + size;
while( pch < pchEnd )
…
其中, pchEnd指向存储区中被查找的最后一个字符的下一个位置,因此它可用在while表达式中。程序员觉得这样使用很方便,如果所指的存储位置存在的话,则该程序会工作得很好,但是如果恰好查找到存储器的结尾处,那么所指的位置就不存在了(一个例外情况是:如果使用 ANSI C,总可以计算出某个数组之外的第一个元素的地址。ANSI C支持这个性能)。
作为改正错误的第一步尝试,将上面的代码改写为如下所示:
pchEnd = pch + size – 1;
while ( pch <= pchEnd )
…
但是,这还不能正确工作。还记得前面讲过的 BuildToLowerTable中UCHAR_MAX上溢错误吗?这里也有同样的错误。现在pchEnd可能指向一个合法的存储位置,但是,由于每一次pch增加到pchEnd + l时都要上溢,因此循环将不会终止。
当你可用指针也可用计数器时,使用计数器作为控制表达式是覆盖一个范围的安全方法:
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 ++;
}
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”的唯一原因是想加快速度。让我们仔细看一下,如果真的存在速度问题,那么进行这种改进就好象用指甲刀剪草坪一样,可以这么做,但没有什么效果。如果不存在速度问题,那为什么又要冒这样的风险呢?这就好象没有必要让草坪的所有草叶都一样长,没有必要让每行代码效率都最优一样,要认识到最重要的是总体效果。
在某些程序员看来,放弃任何可能获得效率的机会似乎近似于犯罪。但是,当读完本书以后,你会得到这样的思想即使效率可能会稍稍低一点,也要使用安全的设计和实现来系统地减少风险性。用户不应该注意是否在某个地方又增加了一些循环,而应集中注意力来看是否在试图节省某些循环时而偶然引入了错误。用投资方面的一句术语来说就是:赢利并不能证明冒险是正确的。
使用移位操作来实现乘、除、求模 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语言还有许多其它的有风险的惯用语。有个最好的方法来找到自己经常使用的有风险的惯用语,这就是检查以前出现的每一个错误,再问一下自己:“怎样来避免这些错误?”然后建立个人的风险惯用语表从而避免使用这些惯用语。
不要过高地估计代价
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;
由于程序员学过数理逻辑课程,熟悉象 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 )
……;
不能毫无必要地将不用类型地操作符混合使用,如果必须将
不同类型地操作符混合使用,就用括号把它们隔离开来
|
不查优先级表
要插入括号的时候,有些程序员总要先查优先级表,再来确定是否有必要插入括号,如果没有必要就不插。对于这样的程序员,要提醒他:“如果必须通过查表才能确定优先顺序的话,那就太复杂了,简单一些嘛。”这就意味着可以在不需要括号的地方插入括号。这样做不仅正确,而且显然可使任何人不经查表就可判断优先级了。
避免和错误联系在一起
在上一章,我们讨论了在设计函数时尽量避免返回错误值,以免程序员错误地处理或漏掉这些返回值(例如 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 );
}
这种方法的缺点是:当超量分配名字空间时,就会浪费存储区。但同时,由于不需要任何错误处理代码,又复得了代码空间。现在的问题是权衡数据空间和代码空间,并根据运行的具体情况决定哪个更重要。假如程序中有数千个窗口需要重命名,你可能就不会超量分配窗口名字空间了。
小结
至此,本章所讲的:程序设计是“风险事业”的含义已经很清楚了。本章的所有观点都集中在如何把有风险的编码转换成为编写在空间、速度、甚至在无错方面都堪与之匹敌的代码上。
但是,不要倡留在本章给出的各点上,在实践中要不断地总结出自己的新观点,并在编码时严格地遵守这些原则。你是否周密思考了每一个编码习惯?是否因为看到别的程序员采用了某个编码习惯,于是自己也采用?刚刚入门的程序员经常认为用移位实现除法是一种“技巧”,而有经验的程序员则认为这是十分显然的,没有什么可值得疑虑的,哪个正确呢?
要点:
l 在选择数据类型的时候要谨慎。虽然 ANSI标准要求所有的执行程序都要支持char,int,long等类型,但是它并没有具体定义这些类型。为了避免程序出错,应该只按照ANSI的标准选择数据类型。
l 由于代码可能会在不理想的硬件上运行,因此很可能算法是正确的而执行起来却有错。所以要经常详细检查计算结果和测试结果的数据类型范围是否上溢或下溢。
l 在实现某个设计的时候,一定要严格按照设计去实现。如果在编写代码时只是近似地实现所提出的要求,那就很容易出错。
l 每个函数应该只有一个严格定义的任务,不仅如此,完成每个任务也应只有一种途径。假如不管输入什么都能执行同样的代码,那就会大大降低那些不易被发现的错误所存在的概率。
l if语句是个警告信号,说明代码所做的工作可能比所需要的要多。努力消除代码中每一个不必要的 if语句,经常反问自己:“怎样改变设计从而删掉这个特殊情况?”有时可能要改变数据结构,有时又要改变一下考察问题的方式,就象透镜是凸的还是凹的问题一样。
l 有时 if语句隐藏在while和for循环的控制表达式中。“?:”操作符是if语句的另外一种形式。
l 曾惕有风险的语言惯用语,注意那些相近但更安全的惯用语。特别要警惕那些看上去象是好编码的惯用语,因为这样的实现对总体效率很少有显著的影响,但却增加了额外的风险性。
l 在写表达式时,尽量不要把不同类型的操作符混合起来,如果必须混合使用,用括号把它们分隔开来。
l 特殊情况中的特殊情况是错误处理。如果有可能,应该尽量避免调用可能失败的函数,假如必须调用返回错误的函利,将错误处理局部化以便所有的错误都汇集到一点,这将增加在错误处理代码中发现错误的机会。
l 在某些情况下,取消一般的错误处理代码是有可能的,但要保证所做的事情不会失败。这就意味着在初始化时要对错误进行一次性处理或是从根本上改变设计。
练习:
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代表语句集。第二个版本速度要快一些,但是,与第一个版本比较起来它有什么风险?
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项目,需要编写“保存文件”子例程,这时,为了保存用户文件,必须给用户文件分配一个临时数据缓冲区。问题是如果你无法分配缓冲区,就不能保存文件,从而违反了上述原则。怎样做才能保证保存用户文件呢?
课题:
将所有你能想到的有风险的语言特点列成一个表格,列出使用每一特征的优缺点。随后,针对表中的每一项,分析在什么情况下,你宁愿冒险而使用该特征。
第7章 编码中的假象
写小说,就希望每一页都能吸引读者,使读者激动、吃惊、悬念!决不能使读者感到厌烦。因此在每一页都要撒些胡椒粉,描述一些场景来吸引读者、如果小说写成;“罪犯走近乔并刺伤了他”,读者就会睡觉了。为了使读者感兴趣,就要使得当描述到乔听到身后“咚!咚!咚!”的脚步声时,读者也能感觉到乔是怎样的恐惧;当“咚!咚”的脚步声慢慢地越来越近的时候,读者也能感觉到乔的手在冒汗;当脚步声加速,罪犯朝乔逼近的时候,读者也能理解到乔是怎样的惊慌。最重要的是读者保持着悬念,乔能不能逃脱? ……
在小说中使用惊奇和悬念很重要也很必要。但是如果把它们放到代码中,那就糟糕了。当写代码时,“情节”应该直观,以便别的程序员能预先清楚地知道将要发生的一切。如果用代码表述罪犯走近乔并刺伤了他,那么写成“罪犯走近乔并刺伤了他”最恰当了。该代码简短、清楚、并讲述了所发生的一切。但是由于某些原因,程序员拒绝写简捷清楚的代码,却极力主张使用具有技巧的、比较精炼的、异乎寻常的编码方法,最好不要这样。
但是直观的代码并不意味着是简单的代码,直观的代码可以使你沿着一条明确无奇的路径从 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);
}
大多数程序员玩弄的一种游戏是“我如何使得代码更快? ”的游戏。这并不是坏游戏,但是正如我们从这本书所感到的那样:如果过份地热衷于这种游戏,那就是坏事。
例如如果在上面的例子上玩这个游戏的话,你就会问自己:“如何使循环加快?”只有三种可能的途径:删除范围检查、删除字符测试、或删除指针递增,好象删除哪一步骤都不行,但是如果愿意放弃传统的编码方式并进行大胆尝试的话是可以删除的。
看一下范围检查,之所以需要该检查仅仅是因为:当在存储器的头 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的这个版本还是有问题。对于初学者来讲,请考虑下面几点:
l 如果 pcPlant指向只读存储器,那么在*pchPlant处存放字符ch就不起作用,因此当在size+1范围内没有发现ch时,函数将返回无效指针。
l 如果 pchPlant指向被映射到I/O的存储器,那么将ch存储在*pchPlant处就难以预计会发生什么事情,从使得软盘停止(或开始)工作到工业机器人狂暴地挥舞焊枪都有可能。
l 如果 pch指向RAM最后的size个字节,pch和size都是合法的,但pchPlant将指向不存在的或是写保护的存储空间。将ch存储在*pchPlant处就可能会引起存储故障,或是不做任何动作。此时如果在size+1个字符内没有找到宇符ch,函数就会失败。
l 如果 pchPlant指向的是并行进程共享的数据,那么当一个进程在*pchPlant处存储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);
}
}
请看一下 for循环,看出什么问题了吗?当FreeWindowsTree释放pwndSibling链表中的每个子窗口时,先释放pwnd,然后for循环在控制赋值时又引用已释放的块:
pwnd = pwnd->pwndSilbing;
但是一旦 pwnd被释放,那么pwnd->pwndSibling的值是什么呢?当然是一堆垃圾。但是某些程序员并不接受这个事实,刚刚存储区还好好的,并且也没做什么影响它的事,它仍应该是有效的呀!也就是说,除了释放pwnd之外没做别的什么事情。
我从不明白为什么某些程序员会认为引用已经释放的存储区是允许的,这与你使用过去的钥匙进入曾经住过的公寓或开走曾经属于你的汽车又有什么区别呢?你之所以不能安全引用释放的存储空间是因为正如我们在第 3章中讲的,存储管理程序可能已将这块释放的空间连到空闲链上了,或已将它用于别的私有信息了。
数据的权限
在你所阅读的程序设计手册中可能没有讲到这个问题,但是,在代码中的每一条数据都隐含地有一个与之相联系的读写权限。该权限没有明文出处,也没在声明变量时显式地给出,而是在设计子系统和函数的界面时隐含地声明的。
例如,实际上在调用某个函数的程序员和写这个函教的程序员之间有个隐式的约定:
假设我是调用者,你是被调用者,如果我向你传递一个指向输入的指针,那么你就同意将输入当作常量并且承诺不对其进行写操作。同样,如果我向你传递一个指向输出的指针,你就同意把它当作只写对象来处理并承诺不对其进行读操作。最后,无论指针指向输入还是指向输出,你都同意严格限制对保存这些输出的存储空间的引用。
回过来说,我这个调用者同意把只读输出当作常量并已承诺不对它们进行写操作。此外,还同意严格限制对保存这些输出空间的引用。
换句话说:“你不要搞乱我的事情,我也不搞乱你的事情。”要牢记:任何时候,只要你违反了隐含的读写权限,那么就冒着中断代码的危险,因为编写这些代码的程序员坚信每个程序员都应遵守这些约定。调用象memchr这样的函数程序员不应担心在一些特殊的情况下,memchr会运转异常。
仅取所需
上一章,我们给出了 UnsToStr函数的一种实现方法,它如下所示:
/* UnsToStr—一将无符号值转换为字符串 */
void UnsToStr(unsigned u,char *str)
{
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()
{
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是要创建一个输出串。那么当你通过返回一个指向你已经建立的字符串的指针来节省一些循环时,为什么不将它拷贝到另一个缓冲区呢?
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的调用时,你必须做到下列两点:
l 确保你的调用者(以及你的调用者的调用者等等)中没有任何一个正在使用由 strFromUns返回的字符串。换句话说,你必须验证没有任何一个这样的函数在可能调用你的函数的调用链上,并假定strFrornUns的私有缓冲区是被保护的。
l 确保你不调用任何调用 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程序定义了三个标准函数:FILL,它以字节为单位填充存储块;CMOVE,它用“头到头”的算法拷贝存储;
在那些 FORTH程序中,CMOVE是用优化的汇编语言写的,但为了具有可移植性,FILL用FORTH语言本身编写。CMOVE的代码和我们设计的代码差不多,转换为C语言如下:
/* CMOVE ─── 用头到头的移动来转移存储 */
void CMOVE (byte *pbFrom,byte *pbTo,size_t size)
{
while(size-- > 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,其它三个自由度因之填入垃圾,导致机器人处于紊乱状态。为什么会这样呢?如果查看一下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)
{
/* 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)
{
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代码尽量写在源代码的一行上,从而达到最好的瑜伽状态的话,你很可能患有可怕的“一行清”疾病(也称为程序设计语言综合症)。那么你就要做个深呼吸,反复地提醒自己:“多行源代码可能产生效率高的机器代码。多行源代码可能产生效率高的机器代码……。”
切勿傲慢轻率
世上最令人厌烦的书,就是那些由专家撰写的、其内容充满了没有必要的技术术语的书。他们不说“该错误可能使你的系统暂停或失败”,而是说“这样的程序设计缺陷可能导致丧失对系统的控制或引起系统终止”。他们还用象“公理化程序验证”和“缺陷分类”这样的术语,好象程序员每天都要用到些术语似的。啧!啧!啧!啧!这些作者将他们要说明的信息隐藏在含糊难懂的术语中,这不仅没有帮助读者,反而使读者更糊涂了。不只是书作者这么做,有一些程序员也热衷于编写含糊难懂的代码,他们认为只有代码含糊不清才能给人留下深刻的印象。
例如,看看下面的函数是怎样工作的:
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进行初始化呢?你能不用思考就知道正确答案吗?如果你不能肯定,说明你训练有索,因为即使是专家级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公司,每个程序员编写新代码的数量,与他对所从事研制的产品内部情况的熟悉程度成正比,对产品比较熟悉的程序员,编写新代码的是多一些,而较少进行维护性的程序设计。当然,如果对项目了解很少那么就要花大量时间来阅读别人写的代码、修改别人的错误、对于已有特征作少量的局部性的增补。直观地看,这种安排很有意义。如果你不知道系统是怎样写的,那你就不能给系统增加重要的功能。
概括起来,这种安排的结果就是:一般来说,有经验的程序员编写出代码,新手维护代码。我并不是说不应该这样安排,这种安排是实用的而且就是这么作的。但是,只有在有经验的程序员认识到,他们有责任使得他们所编写的代码,能够被程序维护人员和程序设计新手维护,这时这种安排才能行得通。
不要错误的理解我的意思,我并不是说你应该写初级的C程序以使程序设计新手能够理解你的代码,这样就和总是编写专家级C代码一样愚蠢了。我要说的是,当你能用普通程度语言表达清楚时,就应该避免使用困难的或神秘的C。如果你的代码很容易理解,那么新手在维护时就不易引入错误,你也不必总是向他们解释代码是如何如何地工作了。
小结
我们已经考察了一些有争议的编码实践,其中大部分初看上去都很好。但是,正如我们已经看到的,看一遍,甚至看五遍,你可能都没有警觉到那些巧妙代码产生的微妙的副作用。因此建议:如果你发现自己编写的代码用了较多技巧,那么停止编写代码并寻找别的解决方法。你的“技巧”也许很好,但是如果你确实觉得它有些费解,那就是你的直觉在告诉你,情况不妙。听凭你的直觉,如果你认为你的代码确有技巧的话,那么,这实际上是在对自己讲,尽管这个算法应该直观而实际并非如此,但它却产生了正确的结果。那么这个算法的错误同样也会不明显。
因此,编写直观的代码才是真正的聪明人。
要点:
l 如果你要用到的数据不是你自己所有的,那怕是临时的,也不要对其执行写操作。尽管你可能认为读数据总是安全的,但是要记住,从映射到 I/O的存储区读数据,可能会对硬件造成危害。
l 每当释放了存储区人们还想引用它,但是要克制自己这么做。引用自由存储区极易引起错误。
l 为了提高效率,向全局缓冲区或静态缓冲传递数据也是很吸引人的,但是这是一条充满风险的捷径。假若你写了一个函数,用来创建只给调用函数使用的数据,那么就将数据返回给调用函数,或保证不意外地更改这个数据。
l 不要编写依赖支持函数的某个特殊实现的函数。我们已经看到, FILL例程不该象给出的那样调用CMOVE,这种写法只能作为坏程序设计的例子。
l 在进行程序设计的时候,要按照程序设计语言原来的本意清楚、准确地编写代码。避免使用有疑问的程序设计惯用语,即使语言标准恰好能保证它工作,也不要使用。请记住,标准也在改变。
l 如果能用 C语言有效地表示某个概念,那么类似地,相应的机器代码也应该是有效的。逻辑上讲似乎应该是这样,可是事实上并非如此。因此在你将多行C代码压缩为一行代码之前,一定要弄清楚经过这样的更改以后,能否保证得到更好的机器代码。
l 最后,不要象律师写合同那样来编写代码。如果一般水平的程序员不能阅读和理解你的代码,那就说明你的代码太复杂了,使用简单一点的语言。
练习:
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允许程序员向函数传递参数的个数比函数期望接收的参数个数少。某些程序员利用这个特征来优化调用,这些调用并不要求全部参数。例如:
…
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
怎样改写 memmove使它既保持上面代码的效率,又更容易理解?
8) 下面的汇编语言代码给出了调用函数的常用捷径。如果你使用了这段代码,为什么说是自找麻烦呢?
move r0,#PRINTER
call print+4
…
print: move r0,#DISPLAY ;(4字节指令 )
… ; ro == 设备标识符 ID
9) 下面的汇编语它给出了另一个技巧。这个代码与上个练习的代码具有同样的问题,除了上述问题外,为什么你还应该避免使用这个技巧?
instClear R0 = 0x36A2 ;“ clear ro”是16进制指令
…
call print+2 ;输出到打印机
…
print: move r0,#instClearR0 ;(4字节指令 )
comp r0,#0 ;0 == PRINTER , non-0 == DISPLAY
第8章 剩下来的就是态度问题
本书中讨论的方法可以用来检查错误和防止错误,但是这些技术并不能保证肯定可以写出无错代码,就象一个熟练的球队不可能是常胜军一样。重要的是养成好的习惯和正确的态度。
如果一个球队成天在嘴上讨论如何训练,这个球队可能有取胜的机会吗?如果这个球队的队员不断地因为工资低而牢骚满腹,或时刻耽心被换下场或裁减掉,那又会怎么样呢?虽然这些问题与球赛没有直接关系,但是却影响了球员水平的发挥。
同样读音可以使用本书的所有建议,但是,如果你持疑虑的态度或者使用错误的编码习惯,那么要写出无错的代码将是很困难的。因此,你要有必胜的信心和良好的习惯,同样,你同级的同事如果没有必胜信心和良好习惯也会遇到同样的问题。
因此在本章中将指出一些编写无错代码的主要障碍。只要能意识到这些障碍,改正就很容易了。
错误不出现,我还有一招
当向程序员询问有关他们修改错误的情况时,有多少次听到他们这样的回答:“唉呀!错误消失了。”多年以前,我就曾经向我的第一个经理说过这样的话,当时 ,我们正在研制Apple Ⅱ数据库产品,经理问我若已经设法找到错误项目能否就此收尾,我说:“唉呀!错误消失了。”经理停顿了片刻,然后邀请我到他的办公室坐坐。
“你说‘错误消失了’,是什么意思?”
“哎呀!你知道,我一步步地仔细查看了错误报告。错误没再出现。”
经理在椅子上向后仰了一下问:“你认为错误到哪儿去了?”
“我不知道。”我说,“我想它已被改正了吧。”
“你并不知道谁改的,是吧?”
“是的,我是不知道。”我坦诚地回答。
“好,那你不认为你应该查明到底真正发生了什么吗?”他说。“毕竟你是在和计算机打交道,错误不会自我改正。”
然后,经理进一步解释说,错误消失有三个原因:一是错误报告不对;二是错误已被别的程序员改正了;三是这个错误依然存在但没有表现出来。也就是说,作为一个专业程序员,其职责之一就是要确定错误的消失究竟属于以上三种情况中的哪一种,从而采取相应的行动,但是决不能因为错误不出现就简单地忽略了它,就万事大吉了。
在我第一次听到这个忠告的时候,也就是在 CP/M和Apple Ⅱ的时代,这个忠告很有价值。实际上,在这之前的几十年里,它就很有价值,而且至今它仍然很有价值。但是,直到我自己成为项目负责人,并且发现程序员普遍乐于接受测试员搞错了或有某个程序员已经为其排除了这个错误,这时我才认识到这个忠告多么有意义。
错误消失经常是因为程序员和测试员使用了不同的程序版本。如果在程序员使用的代码中错误没有出现,就采用测试员使用的程序版本,如果错误仍未出现,就可通知测试组。但是,如果错误确实出现了,就要追踪到它早些的源程序版本,并决定如何修改它,然后再查看一下为什么在当前的源程序版本中,错误会不见了。通常错误仍然存在,只是环境有了更改从而掩盖了错误。无论什么原因,为了采取适宜的步骤来改正错误,必须弄明白为什么错误消失了。
浪费精力?
当我要求程序员在老版本源程序上寻找所报告的错误时,他们经常要发牢骚,这样做似乎象是浪费时间。要是你也这么认为的话,你要明白这样做并不是要你恢复老版本源程序,而不过要你查看一下这些源程序,为你提供查错的良机,而且这也是找到错误最有效的方法。
即使你发现错误已被改正了,那些老版本源代码中将错误分离出来也是值得的。是将错误以“改正了”终结好呢?还是给错误标以“不会再产生了”并送还给测试组好呢?测试人员将怎样做呢?他们肯定不会认为这个错误已经更正了,他们只有两种选择,一种是花时间来试图提出另一组能再产生错误的用例;另一种是丢下这个错误,将其标以“不会再产生了”并希望错误已被改正。与在老版本源代码中找到了错误并以“改正了”而终结比较起来。后两种选择都不好。
及时纠正,事半功倍
我第一次参加 Excel小组的时候,我们把所有的错误改正工作都推迟到项目的最后。这并不是说有人用枪顶着我们的脊骨说:“直到所有的特征都实现了再去改正错误”,但总有保持进度和实现特征的压力,而在修改错误方面却一点压力也没有。我曾经说过:“除非错误使系统瘫痪了或使测试组停工,否则别急着更改它,完成进度要求的各特征之后,我们有的是时间来修改错误。”简而言之,改正错误没放在优先地位。
我相信现在的 Microsoft程序员听到上面所讲的肯定感到很逆耳,因为项目不再以这种方式研制了。这种方法存在的问题太多,最坏的是不能预言产品什么时候能够完成。你怎样估计修改1742个错误的时间?当然,不仅仅有1742个错误需要修改,因为在程序员修改旧错误时又会引起新错误。更密切相关的是,修改一个错误可能暴露其它的潜在错误,由于第一个错误的障碍,测试组未能发现这些潜在错误。
但这还不是唯一的问题。
由于没有改正错误就完成了所要求的特征,产品看上去比它实际进展情况要提前了许多。公司的重要人物测试使用内部发行版本,发现除了一些偶然的错误之外,产品工作得很好,他们很惊奇,只用了六个月的开发时间就几乎完成了一个最终产品。他们看不到存储空间溢出错,或某些从未试用过的特征错,他们只知道代码“各特征齐全”,基本上可以工作。
到最后用几个月的时间来修改错误也往往士气不振。程序员喜欢编程序而不愿意改错,但是在每个项目的最后,有好几个月的时间,他们除了改错无事可作。由于开发组以外的每个人都明显地知道代码已接近完成,因此改错经常具有很大的压力。
这不是自找吗?
然而,自打 Macintosh Excel 1.03开始到撤消 ─── 未宣布名字的窗口产品(由于失控的错误表造成的)为止,Microsoft一直运行带有错误的产品,这就迫使Microsoft认真研究怎样开发产品。得到的结论并不使人感到惊奇:
l 不要通过把改正错误移置产品开发周期的最后阶段来节省时间。修改一年前写的代码比修改几天前写的代码更难,实际上这是浪费时间。
l “一次性”地修改错误会带来许多问题,因为发现错误越早,重复这个错误的可能性就越小。
l 错误是一种负反馈,程序开发倒是快了,却使程序员疏于检查。如果规定只有把错误全部改正之后才能增加新特征的话,那么在整个产品开发期间都可以避免程序员的疏漏,他们将忙于修改错误。反之,如果允许程序员略过错误,那就使管理失控。
l 若把错误数保持在近乎于 0的数量上,就可以很容易地预言产品的完成时间。只需要估算一下完成 32个特征所需的时间,而不需要估算完成32个特征加上改正1742个错误所需的时间。更好的是,你总能处于可随时交出已开发特征的有利地位。
以上这些观点并不只适用于 Microsoft开发,而且适用于任何软件开发。如果你在发现错误时没有及时纠正,那么Microsoft的坏经验就是你的反面教材。你可以从自己的艰难经历中或从别人的沉痛教训中学到很多东西。到底该怎样做呢?
憨医救人
在安东尼·罗宾斯的小说《唤醒巨人》( Awaken the Giant Within)中讲了一位医生的故事。一天,有个医生走到一条汹涌的河边,她突然听到落水者的呼救声。她环顾了四周,发现没有人去救,于是,她就跳入水中,朝着落水者游去。她将落水者救上岸,做口对口的人工呼吸,这个人刚一恢复呼吸,又从河里传来了另外两个落水者的求救声。她又一次跳入水中,把这两个人救上岸,正当她安顿好这两个人时,医生又听到另外四个落水者的求救声,然后她又听到另外八个落水者的求救声 …… 问题是医生只忙于救人,抽不出时间到上游查明是谁把人们扔到水中。
象这个医生一样,程序员也经常忙于“治愈”错误而没有停下来判断一下是什么原因引起了这些错误。例如象上一章我们讲过的函数 strFromUns,由于它迫使程序员使用未受保护的数据而导致错误。但是错误总是出现在调用strFromUns的函数内,而不是在strFromUns本身。因此,你认为应该修改错误的真正根源strFromUns呢,还是修改出了错的调用strFromUns的这个函数呢?
在我把 Windows Excel的一个特征移植到Macintosh Excel时(当时,它们仍是两个独立源代码段)也出现了类似的问题。在移植了一个特征之后,我开始测试代码并发现有个函数得到了未预料到的NULL空指针。我检查了代码,但是错误是在这个函数所调用的函数(传出NULL)中呢,还是在这个函数本身(没有处理NULL)呢?因此我找到原来的程序员并问他说明了情况。他马上编辑了该函数并说:“哦,这个函数无处理NULL指针能力。”然后,就在我站在边上看着的时候他通过插入如下代码而改正了错误,当指针为NULL时“快跳”出来
if(pb == NULL)
return(FALSE);
我提醒他是否这个函数不应该得到空指针 NULL,错误在调用函数中而不在这个函数中。他回答道:“我清楚这些代码,这样做就可以改正错误了。”但是我觉得这种解决方法好象只改正了错误的症状而没有改正错误的原因,于是我返回我的办公室花了10分钟时间来追踪NULL空指针的来源。发现空指针NULL不仅是这个错误的真正根源,而且也是另外两个已知错误的原因。
还有几次当我追踪到错误的根源时,经常这样认为:“等一下,修改这个函数可能是不对的。如果是这个函数出错的话,函数在另外的地方也应该出问题呀,可是它没有出问题呀。”我肯定你能猜出为什么函数在另外的地方能够工作,它之所以工作是因为某个程序员已经局部性地修改了这个较为通常的错误。
你有无事生非的代码吗?
“只要没有破坏作用,怎么改也行。”这似乎是某些程序员的口号。不管代码是否很好地工作,某些程序员总要强行在代码上留下自己的痕迹。如果你曾与那些喜欢将整个文件重新格式化以适合他们口味的程序员工作过的话,你肯定会理解我所讲的内容。尽管大多数程序员对“清理”代码非常谨慎,但是,似乎所有程序员都不同程度地做过这件事情。
清理代码的问题在于程序员总不把改进的代码作为新代码处理。例如,有些程序员在浏览文件时,看到下面所示的代码,他们就把与 0比较的测试改为与‘/0’作比较的测试(其他程序员也可能想全部删掉测试)。
char* strcpy(char* pchTo, char* pchFrom)
{
char* pchStart = pchTo;
while( (*pchTo++ = *pchFrom++) != 0 )
NULL;
Return(pchStart);
}
把 0改为一个空字符的问题是很容易将‘/0’错误地键入为‘0’,但是有多少程序员愿意在做了这样简单的改变之后再测试一下strcpy呢?这提醒我们:当你做了如此简单的更改之后,你是否也象对待新编写的代码那样进行完全的测试呢?如果没有,那么这些没有必要的代码更改就会有引入错误的危险。
你可能认为只要修改后的代码仍能通过编译,那么这些更改就不算错。例如,改变局部变量的名字怎么会引出问题呢?可是它确实能引起问题。我曾经跟踪一个错误直到一个函数,这个函数具有一个局部变量名 hPrint,它与具有同样名字的全局变量冲突。由于这个函数在不久前工作还很正常,我查看了老版本的源程序,来看一下到底当前版本改变了什么并验证我的修改是否会重新引入以前有过的错误。我就发现了清理代码问题。老版本中有个局部变量hPrint1,但是没有hPrint2或hPrint3来解释名宇中‘1’的意义。删除‘1’的程序员肯定认为hPrint1是人为的冗余并将其清理为hPrint,从而引起了名字冲突,导致了错误。
为了避免犯上面的错误,要经常提醒自己:与我一起工作的程序员并非一些笨蛋。当你发现一些有明显错误或显然没有必要的代码时,上面的警句将提醒你小心从事。看上去明显有问题的代码,以后你就会发现,它这样写可能有很好但又不明显的原因。我曾经见过一段荒谬的代码,它唯一的目的是当编译程序代码生成有了错误才工作(这是极罕见的 ───译者注),如果你清理了这段代码那么就会引人错误。当然这样的代码应该有注释来解释一下它要实现的功能,但是,不是所有的程序员都想得那么周到。
因此,如果你发现了象下面这样的代码:
char chGetNext(void)
{
int ch; /* ch“必须”是 int类型 */
ch = getchar();
return(chRemapChar(ch));
}
不要急于删除显然“没有必要”的 ch,清理成这样的函数:
char chGetNext(void)
{
return( chRemapChar(getchar()) );
}
这样整理后,如果 chRemapChar是宏,它会多次求参数的值,从而可能引入了错误。因此,保持“没有必要的”局部变量,避免没有必要的错误。
把“冷门”特征打入冷宫
避免清理代码只是编写无错代码普遍原则的特例,这个普遍原则就是:如果没有必要就不要编写(或修改)代码。这个建议看上去似乎很奇怪,但是如果你经常提出疑问:“这个特征对产品的成败有什么重要作用?”从而删掉这些特征,那么你就不会陷入困境。
某些特征加到产品中并没有价值,但是它之所以存在仅仅是为了填满特征集;另外一些特征的存在是因为大公司买主要求这些特征;还有一些特征能够存在是因为竞争者的产品具有这些特征,评审人就决定将这些特征纳入特征表中。如果有很好的市场和产品规划小组,那就不应该加入这些没有必要的特征。但是,作为一名程序员,不仅会随大流采用这些特征,甚至还可能是某些没有必要特征的发源人。
你曾经听过程序员说这样的话吗?“如果 WordSmasher可以做 …… ,那将是个大‘冷门’。”这个所谓“冷门”特征是因为它能提高产品的质量呢,还是因为它的实现在技术上具有挑战性?如果这个特征能提高产品的质量,那么应将该特征推迟到程序的下个版本实现,到那时将对其进行合理的评价并制定相应的进度表。如果这个特征仅仅是一种技术上的挑战,那么否决它。我的建议并不是要抑制创造力,而是要遏制那些不必要的特征以及相关错误的发展。
有时,技术上具有挑战性的特征能提高产品的质量,有时就不能。请小心选择。
不存在免费午餐
“自由”特征是那些多余性错误的另一个来源。表面上,自由特征似乎是值得的,因为这只需要很少甚至不需要做任何努力就能跳过已有的设计。怎样才能比这更好呢?具有自由特征会带来很大的问题,尽管它们对产品的成败几乎从未起过任何关键的作用。正如我在上一节讲的,你应该把任何非关键特征看成是错误的来源。程序员向程序内增加自由特征是因为他们可以增加而不是因为他们必须增加。如果它不需要你付出任何代价,那为什么不增加一个特征呢?
啊!但是这是谬论。对于程序员来说,增加自由特征可能不费事,但是对于特征来讲,它不仅仅增多了代码,还必须有人为该特征写又档,还必须有人来测试它。不要忘记还必须有人来修改该特征可能出现的错误。
当我听到某个程序员说某特征是自由的,我就知道他没有花时间来考虑纳入该特征的真正代价。
灵活性滋生错误
避免错误的另一条策略是排除设计中没有必要的灵活性。这个原则贯穿本书的始终。例如,在第一章,我使用了选择编译警告以避免出现冗余的和有风险的 C语言惯用语。在第2章,我把ASSERT定义为一条语句来防止在表达式中错误地使用宏。在第3章,我使用了断言来捕获传递给FreeMemory的NULL指针,即使使用NULL指针调用free函数是合法的,我也这么做了。…… 我可以列出每一章的例子。
灵活设计的问题在于,设计越灵活,就越难发觉错误。还记得我在第 5章中针对realloc所强调的那几点吗?你几乎可以扔掉realloc的任何输入集,可它仍将继续执行,但是它可能并没按你所希望的去执行。更糟糕的是,由于函数很灵活,因此不能插入有意义的断言验证输入的有效性。但是,如果把realloc分成为扩展、收缩、分配、释放存储块四个专门函数,则确认函数变元就要容易得多了。
除了过度灵活的函数之外,还应该时刻警惕着过度灵活的特征。由于灵活的特征可能产生一些没有预料到的“合法”情况,你可能会认为这些情况不需要测试甚至认为这就是合法的,因此,灵活特征同样很棘手。
例如,当我为 Apple的Excel和新的Macintosh Ⅱ机器的Excel增加彩色支持程序时,我要从Windows Excel上移植一段代码,该代码允许用户指定显示在电子表格格子内的正文颜色。例如,向一个格子内增加颜色,你应该选择已有的格子形式,如下所示(将1234.5678打印为$1,234.57):
$#,##0.00
并且在前面加上颜色声明。为了显示蓝色,用户就需要将上面的形式改为:
[blue]$#,##0.00
如果用户写了 [red],那么数据以红色显示,如此等等。
Excel的产品说明非常清楚,颜色说明应放在数据形式的开始处,但是当我移植了这个特征打开始测试代码时,我发现下面的所有形式都工作
$#,##0.00[blue]
$#,##[blue]0.00
$[blue]#,##0.00
你可以将 [blue]放在任何地方。当我向原来的程序员询问这是个错误还是个特征时,他说颜色声明可以放在任意位置“仅仅是脱离了语法分析循环。”他不认为允许一点点额外的灵活性是个错误,当时我也那么认为,于是代码就那样保留下来了。然而,回顾一下,我们不应该允许这个额外的灵活性。
不久测试组发现了六个微妙的错误,最终所有这些错误都起因于格式的语法分析程序,因为它没有料想到会发现彩色说明处于格式中间的情况。但是我们没有通过删除没有必要的灵活性来改正这个错误(这需要增加一个简单的 if语句),而只是改正了这些特定的错误,即改正了错误的症状,从而保留了任何人已不再需要的灵活性。时至今日,Excel仍允许将彩色说明置于你所希望的任何位置。
因此在你实现特征时要记住:不要使它们具有没有必要的灵活性,但是要容易使用。这两者是有差别的。
移植的代码也是新代码
在把Windows Excel代码移植到Maxintosh Excel的过程中,我得到了这样一条教训,人们对这种移植过来的代码,总想少做些检查。毕竟这些代码是在原来的产品中测试过的。我在把移植代码交给测试组之前就应捕获Excel数字格式代码中的全部错误,但是我没有这么做。我只是把代码拷贝到Macintosh Excel,做了一些为把这些代码连接到项目中所必须的修改,然后临时测试了一下代码来验证它已被正确地连接起来了。我没有全面测试特征本身,因为我认为这已经测试过了。这是失策的,特别是在当时的情况下,Windows Excel本身也正处于开发阶段,这就更是失策。那正是Microsoft小组把修改错误推迟到产品周期的最后阶段那个时期。
实际上,不管你是怎样实现特征的,是从头开始设计实现,还是依据某个已有代码来设计实现的,你都有责任排除要加入到项目的那些代码中所存在的错误。如果Macintosh Excel只具有与Windows Excel相同的错识这可以吗?当然不可以,因为这并不能减轻这些错误的严重性。我一犯懒它就出现了。
“试一试”是个忌讳词
你也许说过多次类似这样的话:“我不知道该怎样来 …… ”,而别的程序员回答你:“你是否试过 …… ?”几乎可以在每个新成立的小组中听到类似这样的对话。某程序员邮出一条消息问:“我怎样才能把光标隐藏起来?”第一个人说:“试着把光标移到屏幕之外去”,另一个人建议:“把光标标志置为 0,光标象素就不可见了”,第三个人或许会说:“光标只是个位映象,因此想办法把它的宽度和高度都置为零”。试、试、试 ……
我承认这是个荒唐的例子,但肯定你听到过类似的对话。通常在被建议“试一试”的所有方案中,可能都不是可以采纳的合适方案。当别人告诉你试一试某件事情时,只是告诉你一个考虑过的猜测并非问题的答案。
试一试各种方案有什么错?假如试验的东西已被系统明确定义的话,那么没有任何错误。但事情常常不是这样,当程序员开始尝试某方案时,他往往会远离他们所了解的系统,进人到饥不择食地寻求解答的境界,这种解很可能有无意识的副作用,将来还要更改。程序员还有个坏习惯,就是有意识地从自由存储区读取,你此对此有何看法呢? free肯定没有定义自由存储区中的内容是什么,但有些程序员由于某种原因感到他们需要引用自由存储区,他们一试,偏巧成功了,于是他们只好依赖free来实施这种行为。
因此注意听取程序员向你提出的建议,如:“你可以试一试 …… ”等,你就会发现大多数建议利用了未定义的或病态定义的副作用。如果程序员提建议时知道怎样求解,他们就不会向你说“试一试”。例如,他们肯定会告诉你“使用 SetCursorState(INVISIBLE)系统调用。”
在找到正确的解法之前,不要一味地“试”,要花时间寻求正确的解
|
少试多读
几年来,在Microsoft的Macintosh程序员都能接收到Macintosh新闻小组在其内部网络上的一些只读编辑物。这些编辑物很有趣,但它并不十分有用,常常不能回答所提出的问题。总有一些程序员提出那些答案已清楚写在“苹果公司内用Macintosh手册”中的问题,但是,程序员得到的回答除了在手册中清楚地给出解的以外,往往是笼统的解决方法。幸运的是,总有几个Macintosh的内部专家能给出明确的答案,如:“参看Macintosh内部手册第4章,第32页,它上面说你应 …… ”。
我的观点是:如果你发现你自己正在测试问题的可能解时,停下来,拿出你的手册仔细阅读。这可没有玩代码那么有趣,也没有向别人询问怎么试那么容易,但你将学到许多有关操作系统的知识和如何在它上面编程的知识。
“神圣的”进度表
当要实现相当大的特征时,某些程序员不得不花上两个星期趴在键盘上编写代码,从不着急测试他的程序。另一些程序员则在实现了十来个小特征之后才停下来检查他的程序。如果这种方法能使程序员彻底全面地测试他们的代码,那么这种方法就没有什么错误。但是,这可能吗?
请考虑一下这种情况:一个程序员要用 5天实现5个特征。这个程序员有两种选择:一种是实现一个特征就测试一个特征,一个一个地进行;另一种是五个五个地进行。你认为实际上哪一种方法能产生强健的代码呢?几年来,我考察了这两种编码风格。绝大多数情况下,边编写代码边测试代码的程序员较少出错。我甚至可以告诉你为什么会是这样的。
假设程序员把 5天时间全部用来实现5个特征,但是随后他意识到在进度表中他没有剩下太多的时间来全面测试这些代码了。你认为程序员会用额外的一天或两天来全面测试这些代码吗?或者玩一玩代码,验证一下代码似乎是工作正常的,然后就转到下个特征呢?当然,答案要取决于程序员和工作环境。但是,带来的问题却是是放弃进度计划,还是减少测试,如果放弃进度计划,大多数公司都会表示不满,而减少测试,则会失去负反馈,程序员可能更赞成保持进度计划。
即使程序员单个而不是成批地编写和测试特征,也常常由于进度原因,程序员仍要减少测试。但是当程序员成批实现特征时效果更加明显。在一批特征中只要有一个困准特征就会占用所有特征的测试时间。
使用进度表的缺点是程序员会给速度比测试还高的优先级,本质上就是进度获得了比写正确代码还高的优先级。我的经验是,如果程序员按照进度的时间来编写某个特征的代码,那么即使减少测试,他也要按进度“完成”该特征。他会想到:“如果在代码中有某些未知的错误,测试组会通知我的。”
为了避免这一陷阱,尽量编写和测试小块代码,不要用进度作为借口跳过测试这一步。
尽量编写和测试小块代码。
即使测试代码会影响进度,也要坚持测试代码
|
名实难符
第 5章曾解释过,getchar的名字经常使得程序员认为,该函数返回一个字符,它实际上返回一个int。同样,程序员经常认为,测试组会测试他们的代码,这是他们的工作,除此之外,他们还干什么呢?其实这种看法是错误的。无论程序员们怎么认为,测试组的存在并非是为了测试程序员写的代码,他们是为了保护公司,最终使用户不受低劣产品的损害。
如果和房屋建筑过程比较一下,就很容易理解测试的作用。在房屋建筑中,建设者建房,检查员检查它。但是检查员并不“测试”这些房屋:电气工程师决不会亲自去安装房屋的电线,也决不会在不接通电源,不测试保险盒,不用万用表检查每个出线口之前就交付线路。这个电气工程师决不会认为:“我不必做这些测试,如果有问题,检查员会通知我的。”有这种想法的电气工程师会很快发现他们难以找到工作。
就象上述房屋检查员一样,程序测试员不负责测试程序的主要理由是,他们不具备必要的工具和技巧,虽然有例外,但这是一条原则。尽管和计算机界的说法不一样,但是测试员测试用户的代码不可能要比用户自己测试的更好。测试员能够加入断言来捕获有问题的数据流吗?测试员能够对存储管理程序进行像第 3章中那样的子系统测试吗?测试员能够使用调试程序来逐次逐条指令地通过代码,以检查每条代码路径是否都按照设汁的要求工作吗?而现状是,尽管程序员测试他们的代码要比测试员有效得多,但是他们却不做,这就是因为计算机界有这些说法。
但是不要误解我的意思,测试组在开发过程中起着重要的作用,但决不是程序员所想象的那种作用。他们在检验产品时,寻找使程序失败的缺陷,证实产品是否与以前推出的产品不兼容,提醒发展部门改进产品性能,利用产品在实际使用中的情况来证实产品的这些特征是非常有用的。所有上述的都跟测试无关,仅仅是在产品中注入质量。
因此,请记住第 2章中所说的,如果要持续不断地写出无错代码,就必须抓住要害并不受其控制,不要依靠测试组来发现错误,因为这不是他们的工作。
测试代码的责任不在测试员身上,而是程序员自己的责任
|
重复劳动
如果程序员负有测试代码的责任,那么就自然出现了这个问题:“程序员和测试员在做重复的努力吗?”可能是。但是再问一遍,当然不是。程序员测试代码,是从里向外测试,而测试员则是从外向里测试。
例如,程序员测试代码时,总是从测试每个函数开始,逐次逐条指令(或行)地通过各条代码路径,验证代码和数据流,逐步向外移动来证实函数能够在子系统中与其它函数一道正常操作,最后程序员利用单元测试来验证各个独立的子系统之间能够正确地相互配合。通过单元测试,还能检测内部数据结构的状态。
另一方面,测试员却把代码作为一个黑盒子,从程序的各个输入处进行测试以寻找错误,测试员也可能利用回归测试来证实所有报告的错误都已排除。然后,测试员逐步向里推进,利用代码覆盖工具,来检查在全局测试中执行了多少内部代码,随之获得的信息产生新的测试,来执行未接触到的代码。
这是使用两个不同“算法”测试程序的例子。之所以这样,是因为程序员强调的是代码而测试员强调的是特征,两者从不同的方位考虑问题,这就增加了发现未知错误的机会。
遭白眼的测试员
读者是否注意到,当测试组发现一个错误后,有多少程序员发出宽慰的叹息,他们会说:“唷!我很高兴程序在交付之前测试出了这个错误。”然而另有一些程序员,在测试员报告他们程序中的错误特别是指出一段代码中的多个错误时,他们却对此忿恨不满。我曾经见到过这种程序员怒发冲冠,也曾听到有些项目负责人说为什么测试员让我不得安宁,这是测试错误,因为我们已经删掉了这个数据。”有一次,我还阻止过一位项目负责人和一位测试负责人之间的拳打脚踢,原因是项目负责人已经处于推迟交付产品的巨大压力之下,而测试组还在继续报告错误,这使他很不安。
这听起来是否是很愚蠢?的确是很愚蠢。在我们没有注意到这个产品是在非难和压力下交付之前,容易觉得这是多么荒唐可笑。但是设身处地想一想,如果你被错误包围着,交付期已过数月,便很容易认为这些测试员的确是坏家伙。
每当我看见程序员向测试人员发火时,我总是把他们拉到一旁并问他们:你们为什么要测试人员为程序员所犯的错误负责呢,和测试员发火毫无道理,他们仅仅是报信者。
每当测试员向你报告你的代码中有某个错误时,你最先的反应是震惊和不相信,你本来就没想到测试员会在你的代码中发现错误;你的第二个反应应该是表示感谢,因为测试员帮助你避免交付错误。
不存在荒谬的错误
有时你会听到程序员抱怨某个错误太荒谬,或者抱怨某个测试员经常报告一些愚蠢的错误。如果你听到这样的抱怨时,制止并提醒他,测试员并不判断错误的严重性,也不说这些错误是否值得排除。测试员必须报告所有的错误,不管是愚蠢的还是不愚蠢的,尽管测试员知道,有些愚蠢的错误可能是某个严重问题的副作用。
但是真正的问题是,程序员在测试这个代码时为什么没有捕获这些错误呢?即使这些错误很轻微并且不值得排除,但找出错误的根源也是非常重要的,以避免将来出现类似的错误。
一个错误可能很轻微。但是它的存在本身就很严重。
建立自己的优先顺序
如果往前翻几页重温一下本书的主要观点,你就会惊奇地发现,其中有些观点似乎是相互矛盾的。然而当你仔细思考以后,你可能又不那么认为了。总之,程序员要经常和编写快速代码和紧凑代码这样相互矛盾的目标打交道。
因此问题是,当面临两个可能的实现时究竟选哪一个?可以肯定要在快速算法和小巧算法之间做出选择是比较困难的,但是,要在快速算法与可维护算法之间、或者在小巧但有风险的算法与较大但易于测试的算法之间作出选择时,你会做出怎样的选择呢?肯定有些程序员会不假思索地回答这些问题,但也有一些程序员不能确定到底选择哪一种。如果几星期之后再问他们同样的问题,他们将会给出不同的答案。
程序员之所以不能确定这种互易的理由是,因为他们除了知道象大小或速度这些非常普通的优先次序之外,不知道他们自己的优先顺序是什么。但是在程序设计中如果没有明确的优先顺序,就象是盲人骑瞎马一样,在每个转弯处都要停下来并问问自己:“现在我该怎么办?”这时你往往做出错误的选择。
然而有些程序员,他们清楚地知道自己的优先顺序,但是由于他们的优先顺序不正确或相抵触,在关键问题上他们没有认真思考因此不断地作出错误的选择。例如,许多有经验的程序员仍受 70年代末期提倡的优先顺序的影响,那时存储空间很少,微机运行很慢,为了写有用的程序,必须要用可维护性来换取空间和速度。但是现在,程序越来越复杂,而RAM的容量却越来越大,计算机运行速度也不断加快,以至于即使用很差的算法,大多数任务也都能按时完成。因此现在的优先顺序不同了,不再用可维护性来换取空间和速度,否则就会得到在速度上并不明显地快但又不可维护的程序。仍还有一些程序员把程序大小和速度奉为神灵,把它们看作是产品成败的关键。由于这些程序员的有限顺序已经过时,因此他们一直在做着错误的实现选择。
因此,只要你还没有考虑过你的优先顺序,那么你就要坐下来为你自己(如果你是项目负责人,就为你的小组)认真地建立优先级列表,从而使你能够在完成项目目标的过程中不断地作出最佳选择。注意我说的是“项目目标”。你的优先级列表不应该反映你想要做的,而应反映你应该做的。例如,某些程序员可能把“个人表达方式”列为最高优先级,这样对程序员或者对产品有利吗?这些程序员接不接受命名标准?同不同意使用 {}的定位风格,还是自搞一套呢?
应当指出,没有“正确”的方法来确定你的优先级序列,但是所选定的优先顺序将决定代码的风格和质量。让我们看一看约克和吉尔两个程序员的优先级列表 :
约克的优先级列表 吉尔的优先级列表
正确性 正确性
全局效率 可测试性
大小 全局效率
局部效率 可维护性 / 明晰性
个人方便性 一致性
可维护性 / 明晰性 大小
个人表达方式 局部效率
可测试性 个人表达方式
一致性 个人方便性
这些优先顺序将怎样影响约克和吉尔的代码呢?两人都首先集中在写出正确代码上,这仅仅是他们在优先级排列上唯一的相同之处。可以看出,约克非常重视大小和速度,对编写清晰代码关心一般,几乎不怎么考虑测试代码是否容易。
而吉尔则把更多的注意力放在编写正确的代码上,只是在大小和速度危及到产品是否成功时,才把它们作为考虑对象。吉尔认为,除非能够很容易地测试代码,否则就无法验证代码是否正确,因此吉尔把可测试性放在优先级排列顺序列表中很高的位置。
你认为这两个程序员哪个更可能:
l 使得所选择的编译程序都能自动捕获错误并报警?虽然为了使用安全环境可能需要额外做点工作。
l 使用断言和子系统作调试检查?
l 走查每一条代码路径从微观赏验证所有刚写的新代码?
l 用安全函数界面取代有风险的函数界面?虽然在每个调用点可能要额外声称 1~2条以上的指令。
l 使用可移植类型,以及当用到移位的情况下而用除法(例如使用 /4代替>>2)?
l 避免使用第 7章中的效率技巧?
这是个充满问题的表吗?我不这样认为。我问你:“你认为吉尔和约克谁会读这本书并按照书中的建议取做?” 吉尔和约克谁会去阅读《程序设计风格要素》或者其他指导性书籍,并按照书中的建议去做呢?
读者应该注意到,由于约克的优先顺序安排,他会把注意力集中在对产品不利的代码上,他回在如何使每一行代码尽量快和小上浪费时间,而对产品长期健全却很少考虑。吉尔却相反,根据她的优先顺序,她把注意力集中在产品上,而不是代码上,除非证明(或显然)确实需要考虑大小和速度,否则她不考虑大小和速度。
现在想一想,代码和产品哪个对你的公司更重要?因此你的优先顺序应该是怎样的?
说出道道
你是否看过别人写的代码,并奇怪他们为什么这样写呢?你是否就此代码问过他们,而后他们说:“哎呀,我不知道我为什么这样写,我猜我当时感觉到这样写正确吧。”
我经常评审代码,寻找帮助程序员改进技术的方法。我发现“哎呀,我不知道”这样的回答相当普遍,我还发现作出这种回答的程序员没有建立明确的优先顺序,他们的决定似乎具有随意性。相反地,具有明确优先顺序的程序员,精确地知道他们为什么选择这个实现,并且当问及他为什么这样实现时,他能够说出道道。
小结
本章还没有提到一个很重要的观点,这就是:你必须养成经常询问怎样编写代码的习惯。本书就是长期坚持询问一些简单问题所得的结果。
l 我怎样才能自动检测出错误?
l 我怎样才能防止错误?
l 这种想法和习惯是帮助我编写无错代码呢还是妨碍了我编写无错代码?
本章的所有观点都是询问最后一个问题所产生的结果。审视一下自己的观念很重要,这些观念就反映了个人考虑问题的优先次序。如果你认为测试组的存在是为了测试你的代码,那么在编写无错代码方面你就会继续有麻烦,因为你的观念在某种程度上告诉你,在测试方面马虎点是可以的。如果你没有在观念上想着要编写无错代码,那你怎么可能会试着编写无错代码呢?
如果你想编写无错代码,就应该清除妨碍你达到这一目标的观念,清除的方法就是反问一下自己,自己的观念对达到目标是有益的还是有害的。
要点:
l 错误既不会自己产生,也不会自己改正。如果你得到了一个错误报告,但这个错误不再出现了。不要假设测试员发生了幻觉,而要努力查找错误,甚至要恢复程序的老版本。
l 不能“以后”再修改错误。这是许多产品被取消的共同教训。如果在你发现错误的时候就及时地更正了错误,那你的项目就不会遭受毁灭性的命运。当你的项目总是保持近似于 0个错误时,怎么可能会有一系列的错误呢?
l 当你跟踪查到一个错误时,总要问一下自己,这个错误是否会是一个大错误的症状。当然,修改一个刚刚追踪到的症状很容易,但是要努力找到真正的起因。
l 不要编写没有必要的代码。让你的竞争者去清理代码,去实现“冷门”但无价值的特征,去实现自由特征。让他们花大量的时间去修改由于这些无用代码所引起的所有没有必要的错误。
l 记住灵活与容易使用并不是一回事。在你设计函数和特征时,重点是使之容易使用;如果它们仅仅是灵活的,象 realloc函数和Excel中的彩色格式特征那样,那么就没法使得代码更加有用;相反地,使得发现错误变得更困难了。
l 不要受“试一试”某个方案以达到预期结果的影响。相反,应把花在尝试方案上的时间用来寻找正确的解决方法。如果必要,与负责你操作系统的公司联系,这比提出一个在将来可能会出问题的古怪实现要好。
l 代码写得尽量小以便于全面测试。在测试中不要马虎。记住,如果你不测试你的代码,就没有人会测试你的代码了。无论怎样,你也不要期望测试组为你测试代码。
l 最后,确定你们小组的优先级顺序,并且遵循这个顺序。如果你是约克,而项目需要吉尔,那么至少在工作方面你必须改变习惯。
课题:
说服你们程序设计组建立或采纳一个优先级列表。如果你们公司具有不同层次的人才(例如初级程序设计员,程序设计员,高级程序设计员,程序设计分析员),你可能要考虑不同的层次使用不同的优先级列表,为什么?
附录A 编码检查表
本附录给出的问题列表,总结了本书的所有观点。使用本表的最好办法是花两周时间评审一下你的设计和编码实现。先花几分钟时间看一看列表,一旦熟悉了这些问题,就可以灵活自如地按它写代码了。此时,就可以把表放在一边了。
一般问题
── 你是否为程序建立了 DEBUG版本?
── 你是否将发现的错误及时改正了?
─一 你是否坚持彻底测试代码.即使耽误了进度也在所不惜?
── 你是否依靠测试组为你测试代码?
─一 你是否知道编码的优先顺序?
─一 你的编译程序是否有可选的各种警告?
关于将更改归并到主程序
─一 你是否将编译程序的警告(包括可选的)都处理了?
── 你的代码是否未用 Lint
─一 你的代码进行了单元测试吗?
─一 你是否逐步通过了每一条编码路径以观察数据流?
─一 你是否逐步通过了汇编语言层次上的所有关键代码?
── 是否清理过了任何代码?如果是,修改处经过彻底测试了吗?
─一 文档是否指出了使用你的代码有危险之处?
── 程序维护人员是否能够理解你的代码?
每当实现了一个函数或子系统之时
─一 是否用断言证实了函数参数的有效性?
─一 代码中是否有未定义的或者无意义的代码?
─一 代码能否创建未定义的数据?
─一 有没有难以理解的断言?对它们作解释了没有?
─一 你在代码中是否作过任何假设?
─一 是否使用断言警告可能出现的非常情况?
─一 是否作过防御性程序设计?代码是否隐藏了错误?
─一 是否用第二个算法来验证第一个算法?
─一 是否有可用于确认代码或数据的启动( startup)检查?
─一 代码是否包含了随机行为?能消除这些行为吗?
── 你的代码若产生了无用信息,你是否在 DEBUG代码中也把它们置为无用信息?
── 代码中是否有稀奇古怪的行为?
── 若代码是子系统的一部分,那么你是否建立了一个子系统测试?
── 在你的设计和代码中是否有任意情况?
── 即使程序员不感到需要,你也作完整性检查吗?
── 你是否因为排错程序太大或太慢,而将有价值的 DEBUG测试抛置一边?
── 是否使用了不可移植的数据类型?
─一 代码中是否有变量或表达式产生上溢或下溢?
── 是否准确地实现了你的设计?还是非常近似地实现了你的设计?
── 代码是否不止一次地解同一个问题?
── 是否企图消除代码中的每一个 if语句?
── 是否用过嵌套?:运算符?
── 是否已将专用代码孤立出来?
── 是否用到了有风险的语言惯用语?
─一 是否不必要地将不同类型的运算符混用?
── 是否调用了返回错误的函数?你能消除这种调用吗?
─一 是否引用了尚未分配的存储空间?
─一 是否引用已经释放了的存储空间?
── 是否不必要地多用了输出缓冲存储?
── 是否向静态或全局缓冲区传送了数据?
── 你的函数是否依赖于另一个函数的内部细节?
── 是否使用了怪异的或有疑问的 C惯用语?
── 在代码中是否有挤在一行的毛病?
── 代码有不必要的灵活性吗?你能消除它们吗?
─一 你的代码是经过多次“试着”求解的结果吗?
─一 函数是否小并容易测试?
每当设计了一个函数或子系统后
─一 此特征是否符合产品的市场策略?
─一 错误代码是否作为正常返回值的特殊情况而隐藏起来?
─一 是否评审了你的界面,它能保证难于出现误操作吗?
─一 是否具有多用途且面面俱到的函数?
─一 你是否有太灵活的(空空洞洞的)函数参数?
─一 当你的函数不再需要时,它是否返回一个错误条件?
─一 在调用点你的函数是出易读?
─一 你的函数是否有布尔量输入?
修改错误之时
── 错误无法消失,是否能找到错误的根源?
─一 是修改了错误的真正根源,还是仅仅修改了错误的症状?
附录B 内存登录例程
本附录中的代码实现了第 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);
#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;
* ……
* 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
*/
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;
}
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);
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;
}
/* ------------------------------------------------------------------
* 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;
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
附录C 练习答案
本附录给出本书中所有练习的答案。
第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;
但是,由于下面的语句含有括号,因此编译程序不发出警告信息:
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
……
}
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语句中使用了无定义的宏,此开关不会给出警告,因为这是有意安排的。
第二章
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宏的实现,有意识的只引用整个文件中相同文件名的字符串。唯一的困难是如何建立文件名的字符串,但是即使这不成问题,你也应该把实现细节隐藏在一个新的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 );
}
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是可以工作的,你可以扫描排序后的数据,以验证次序是正确的(扫描并不是排序,应把它看作不同的算法)。为了验证二分查找工作正常,就用线性扫描来看一下两种查找的结果是否相同。最后,为了验证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
#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的调试版本使它不真正地释放这些存储块,而是为已分配的块建立一个释放链,(这些存储块,对于系统来讲,它们是已分配的,对于用户程序来讲,它们已被释放了)。以这种方式修改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结构中,随后作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 )
{
/* 简单的检查块的集成性。若以下断言引发则意味着管理块信
* 息的调试代码错了,或者可能有一新的存储抹去了数据结
* 构。这两种情况都是错误。
*/
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 )
{
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; /* 在出故障之前有 # 次成功 */
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 )
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得到下一个字符
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作了较小的改动。它不是对不相等的字符串返回某个正值或负值,而是迫使程序员将所有的比较都改为和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 )
甚至更好的代码:
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的值是什么,它们都要被执行和测试。而在第二版中,和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效率更高一点的代码,但是,要记住,局部效率的提高很少对程序的总体产生影响。使用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)
{
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 */
……
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*sizeof(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 )
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”的调用者。如果你发现了这个问题就要将入口点的代码重写,加到例程中间,至少要使入口点呈现在维护人员眼前:
move r0, #PRINTER
call printDevice
……
printDisplay: move r0, #DISPLAY
printDevice: …… ;r0 == device ID
9)当微型计算机还只有非常少量的只读存储器时,这种无意义的类型很受欢迎,因为每一个字节都很宝贵,而这种方式通常能节省一个或两个字节。后来,这就是一个坏习惯了。现在就把它看成很糟糕的习惯了。如果你小组中仍有人写这样的代码,让他们改正这个习惯,或让他们离开你的小组。你没有必要让这样的代码给你找麻烦。
第8章
第 8章没有练习。
后记 走向何方
我们的讨论就要结束了,读者或许还会带着疑虑问我:“你本人相信有可能写出无错程序吗?”当然不能绝对地、百分之百地保证这一点。但是可以相信,只要你坚持照做就能写出非常接近于无错的程序。这就象粉刷房间一样,在粉刷房间时可以不弄脏地毯,但必须在地上铺上布,围上挡板,并小心地粉刷,还要下决心坚持到底。同样,读者必须努力剔除代码中的错误,要想做到这一点的唯一途径就是按照正确的方向坚持下去。
尽管你把写无错代码置于首位,但是只利用本书中给出的技术还不能完全达到这一目标。事实上,不存在一个能保证编码不出任何错误的准则表。因此,最重要的是,读者自己要坚持建立一个查错表列出你查出的错误,以避免重犯以前犯过的错误。这个表中的某些项也许会使你大吃一惊。
例如,我曾经将一个令人讨厌的微小错误引入了 Excel:在浏览一个文件时,不小心删了一行。当时我没有检测到这一错误,把这一改动了的文件连同其它文件一起合并到主程序。以后别人发现了这个错误并追踪到我这。我就想,怎样才能检测并防止这类错误呢,答案很显然:在把更改了的代码合并到主程序之前,利用源代码控制管理程序列出所做的改变。这一额外的步骤付诸实施并不占用多少时间,在其后的五年中,它帮助我发现了三个重大的错误以及一些不太恰当的小更改。这三个错误是否值五年的努力?对我来说是值得的,因为正如我前面所说,这样做费不了多大的事,我就可以知道不会把不希望的更改带入主程序。再说一遍,改正错误要放在首要地位。
读者或许会发现,评审代码就是为了解决问题,提供较好的文档就是为了帮助开发产品的内部工作人员。如果不采用单元测试,或许你早就这么做了。读者甚至会发现,特意加入第 3章练习6中提到的DEBUG代码对帮助测试员是十分有用的。有时候这种解决问题的方法不太切合实际,这时最好的办法就是避开导致错误的算法和实现。
事实上,无法做到完全消除错误,但是通过不懈的努力,可以加大犯同样错误的时间间隔。为帮助大家做到这一点,在附录 A中给出了程序员使用的检查清单,这个检查清单综合了本书的所有观点。
综上所述,成功地书写无错代码的关键可以总结为一个总的原则