这里我将陆续给大家载出我在以前学习和编写c代码时遇到的问题和解决方法、学习的心得,有些是经过查询一些曾经参加微软microsoft的开发小组的老程序员的书籍和资料后提供给大家!
首先,当发现错误时,要不断就以下两个问题追问自己的结果:
1、怎样才能自动地查出这个错误?
2、怎样才能避免这个错误?
关于错误:
错误可以分为两类:
1、开发某一功能时产生的错误。
2、程序员认为该功能已经开发完成之后仍然遗留在代码中的错误。
第一种错误好解决,可以把编译器可以设置的警告等级开关打开,以及语法检查来排除;逻辑错误也可以使用跟踪手段来排除。跟踪逻辑错误就相对麻烦一些,要消除这些麻烦就要养成一个好的编程习惯和方法。
第二种错误时非常隐蔽的,需要长期的实践和经验在其中,还要对c语言具有深刻的了解才能够提高上来,这里就是要告诉大家一些这样的事情,通过代码解说来阐明具体事实。
以下的文章里,实际上有许多是微软 microsoft 的老程序员开发 word 和 excel 的经验之谈,这也是我当初学习他们的经验时的体会和材料的总结和整理。
总之,这些对于在c道路上前进的人们是非常重要的,不敢独占,先拿出来以供大家享受
(第一个问题)
考虑自己所用的语言和编程环境?使空语句明显化!
充分利用语言的特性和编程环境,把所有环境下的调试报错等级开关都打开,注意使用语言的保留字,例如下面的两段程序对比:
/*复制一个不重叠的内存块*/
void *memcpy(void *pvto, void *pvfrom,size_t size)
{
byte *pbto = (byte *)pvto;
byte *pbfrom = (byte *)pvfrom;
while(size-- > 0);
*pbto++ = *pbfrom++;
return (pvto);
}
从以上缩进格式可以看出,while后的分号肯定是一个错误。但编译器认为这是一个合法的语句,允许循环体为空语句。报警开关都打开时,大多编译器都能够报出这一错误。但需要用空语句时,最好实用null(大写)明确出来:
char *strcpy(char *pchto, char *pchfrom)
{
char *pchstart = pchto;
while(*pchto++ = *pchfrom++)
null;/*此处null大写*/
return (pchstart);
}
这样,编译器编译程序接受显式的null语句,把隐式空语句自动地当做错误标出。
(第二个问题)
无意的赋值。
例如:
if(ch = ' ')
expandtab();
有些编译器允许在程序&&和||表达式以及if、for和while中直接使用赋值的地方禁止简单赋值,如果以上五种情况将==偶然地键入为=号,就会报错。
while(*pchto++ = *pchfrom++)
null;
编译程序就会产生警告信息,为了防止这种情况出现,可以这样做:
while((*pchto++ = *pchfrom++) != '')
null;
这样做的结果由两个好处:
1、现在的编译器不会为这种冗余的比较产生额外的代码和开销,可以将其优化掉。
2、可以少冒风险,尽管以上两种都合法,但这是更安全的用法。
(第三个问题)
参数错误:
例如:
fprintf(stderr, "unable to open file %s. ",filename);
......
fputc(stderr,' ');
这个程序看上去好像没有问题,实际上fputc的参数顺序错了。幸好ansi c提供了函数原型,在编译时自动查出这些错误。
ansi c标准要求每个库函数都必须有原型,stdio.h中可以查到:
int fputc(int c, file *stream);
如果在程序文件头里给出了原型,这类错误就可以检查出。
ansi c虽然要求标准库函数必须有原型,但并不要求用户编写的函数也必须有原型。可以有,也可以没有。有些程序员经常抱怨对函数的原型进行维护,如果没有原型,就不得不依靠传统的测试方法来查出程序中的调用错误,大家可以扪心自问:究竟哪个更重要?
利用原型可以生成质量更好的代码。ansi c标准使得编译程序可以根据原型信息进行相应的优化。
有这样的名言:
投资者与赌徒之间的区别在于投资者利用每一次机会,无论它是多么小,去争取利益;而赌徒则只靠运气。我们应该将这一概念同样应用于编程活动。
把所有的警告开关都打开,除非有极好的理由才不这样做!
(原则一)
如果有单元测试,就进行单元测试。
你认识那个程序员宁愿花费时间去跟踪排错,而不是编写新的代码?肯定有这样的程序员,但我至今还没有见到一个。
当你写程序时,要在心中时刻牢记着假想编译程序这一概念,这样就可以毫不费力或者直费很少力气利用每个机会抓住错误。
如果想要快速容易地发现错误,就要利用工具的相应特性对错误进行定位。错误定位的越早,就能够越早地投身于更有趣的工作。
努力减少程序员查错的技巧。可以选择编译程序的环境来实现。高级的编码方法虽然可以查出或减少错误,但它们也要求程序要有较多的技巧,因为程序员必须学习这些高级的编码方法。
(原则二)
自己设计并使用断言。
利用编译器自动查错固然好,但实际上只是很少一部分。如果排除掉了程序中的所有错误,大部分时间程序会正确工作。
看一下下列代码:
strcopy = memcpy(malloc(length),str,length);
该语句在多数情况下会工作的很好,除非malloc的调用产生失败。一旦产生,就会给memcpy返回一个null指针,而memcpy处理不了null指针,这样的错误产生,如果在交付用户之前将导致程序的瘫痪。但如果交付了用户,那用户就一定“走运”了。
解决方法:
对null指针进行检查,如果为null,就给出一条错误信息,并终止memcpy执行。ee
/*拷贝不重叠的内存块*/
void memcpy(void *pvto, void *pvfrom, size_t size)
{
void *pbto = (byte *)pvto;
void *pbfrom = (byte *)pvfrom;
if(pvto == null || pvfrom == null)
{
fprintf(stderr, "bad args in memcpy! ");
abort();
}
while(size-- > 0)
*pbto++ = *pbfrom++;
return(pvto);
}
只要调用时错用了null指针,这个函数就会查出来。但测试的代码增加了一倍,降低了执行速度,这样“越治病越糟”,还有没有更好的方法?
有,利用c的预处理程序!
这样就会保存两个版本。一个整洁快速,用于交付用户;另一个臃肿缓慢(包含了额外的检查),用于调试。这样就要同时维护同一个程序的两个版本,利用c的预处理程序有条件地包含相应的部分。
例如:只有定义了debug时,才对应null指针测试。
void memcpy(void *pvto, void *pvfrom, size_t size)
{
void *pbto = (byte *)pvto;
void *pbfrom = (byte *)pvfrom;
#ifdef debug
if(pvto == null || pvfrom == null)
{
fprintf(stderr, "bad args in memcpy! ");
abort();
}
#endif
while(size-- > 0)
*pbto++ = *pbfrom++;
return(pvto);
}
这样,调试编译时开放debug,进行测试程序和找错;交付用户时,关闭debug后进行编译,封装之后交给经销商。
这种方法的关键是保证调试代码不在最终产品中出现。
那么还有没有比以上两种更好的方法,有!下次再讲。
(准则二续)
利用断言进行补救。
实际上,memcpy中的调试代码编的非常蹩脚,喧宾夺主。他能产生好的效果,这无疑,但许多程序员不会让他这样存在的,聪明的程序员会让调试代码隐藏在断言assert中。
assert是个宏,定义在头文件assert.h中,每个编译器都自带。前面的程序完全可以使用assert来处理,看一下下面代码,把7行减为了1行代码。
void memcpy(void *pvto, void *pvfrom, size_t size)
{
void *pbto = (byte *)pvto;
void *pbfrom = (byte *)pvfrom;
assert(pvto != null && pvfrom != null);
while(size-- > 0)
*pbto++ = *pbfrom++;
return(pvto);
}
这里要强调的是:assert是个只有定义了debug才起作用的宏,如果其参数的计算结果为假,就中止调用程序的执行。
当然程序编制也可以编制自己的断言宏,但要注意不要和assert冲突,因为assert是全局的。举个例子:
先定义宏assert:
#ifdef debug
void _assert(char *, unsigned); /*自定义断言函数的函数原型*/
#define assert(f)
if(f)
null;
esle
_assert(_file_ , _line_ );
#else
#define assert(f) null
#endif
从上述我们可以看到,如果定义了debug,assert将扩展为一个if语句。
当assert失败时,他就是用预处理程序根据 _file_ 和 _line_ 所提供的文件名和行号参数调用 _assert。 _assert在标准错误输出设备stderr上打印一条错误信息,然后中止:
void _assert(char *strfile, unsigned uline)
{
fflush(stdout);
fprintf(stderr, " assertion failed: %s, line %u ", strfile, uline);
fflush(stderr);
abort();
}
程序中的相关函数,大家可以查阅头文件帮助来了解,这里就不在详述了。
下一讲:使用断言对函数参数确认。
(准则二 续二)
使用断言对函数参数确认。
掌握原则为:“无定义”就意味着“要避开”。
读一下ansi c的memcpy函数的定义,最后一行这样说:“如果在存储空间相互重叠的对象之间进行了拷贝,其结果无意义。”那么当使用相互重叠的内存块调用该函数时,实际上在做一个编译程序(包括同一编译程序的不同版本),结果可能也不同的荒唐的假定。
对于程序员来说,无定义的特性就相当于非法的特性,因此要利用断言对其进行检查。
通过增加一个验证两个内存块决不重叠的断言,可以把memcpy加强:
void memcpy(void *pvto, void *pvfrom, size_t size)
{
void *pbto = (byte *)pvto;
void *pbfrom = (byte *)pvfrom;
assert(pvto != null && pvfrom != null);
assert(pbto >= pbfrom+size || pbfrom >= pbto+size);
while(size-- > 0)
*pbto++ = *pbfrom++;
return(pvto);
}
从今以后,在编程时,要经常停下来看看程序中有没有使用了无定义的特性。如果使用了,就要把它从相应的设计中去掉,或者在程序中包含相应的断言,以便在使用了无定义的特性时,能够向程序员发出通报。
(须注意问题一)
前面所述的做法,为其他的程序员提供代码库(或操作系统——例如各个厂家的编译器)时显得特别重要。如果为他人提供过类似的库(或者自己使用自己以前编过的库时,或者使用了不同厂家提供的库时),就应该知道当程序员试图得到所需要的结果时,就会利用各种各样的无定义特性。更大的挑战在于改进后新库的发行,因为尽管新库与老库完全兼容,但总有半数的应用程序在试图使用新库时会产生瘫痪现象。问题在于新库在其“无定义的特性”方面,与老库并不100%兼容。
明白了这些,在编程时,就要考虑程序的移植性、兼容性、容错性、安全性、可发行性、商品性等等方面。而不是说在一个编程环境下能够实现功能就万事大吉了。程序员的道路不知是停留在编程的语法学习、技巧、实现功能上。要全面、全方位考虑所编制的程序的有可能造成的后果。
各个厂家的编译器都有所不同,一个厂家的编译器版本不同时,特性也不同。要想很好的编程,这些都是需要了解的,去尽量了解这些特性,才能真正学到编程。才能提高编程效率。有时会出现这样的情况。看人家的代码,感觉到非常傻,自以为很聪明,实际上是自己错误,因为人家考虑的更加广泛,考虑的更多,实际上那样的代码特别具有可移植性和容错性。只是自己的思想受到了局限,只从一个角度来看问题造成的。劝告大家:千万不要夜郎自大!
大呼“危险”的代码:
我们再谈谈memcpy中的重叠检查断言。对于上面的重叠检查断言:
assert(pbto >= pbfrom + size || pbfrom >= pbto + size);
假如在调用memcpy时这个断言测试的条件为真,那么在发现这个断言失败了之后,如果你以前从来没有见过重叠检查,不知道它是怎么回事,你能想到发生的是什么差错吗?但这并不是说上面的断言技巧性太强、清晰度不够,因为不管从哪个角度看这个断言都很直观。然而,只管并不等于明显。
很少比跟踪到了一个程序中用到的断言,但却不知道该断言的作用这件事更令人沮丧的了。你浪费了大量的时间,不是为了排除错误,而只是为了弄清楚这个错误到底是什么。这还不是事情的全部,更有甚者,有的程序员偶尔还会设计出有错的断言。所以如果搞不清楚相应的断言检查的是什么,就很难知道错误是出现在程序中,还是出现在断言中。解决这个问题的办法,是给不够清晰的断言加上注解即可。这是显而易见的事情,但令人惊奇的是很少有程序员这样做。为了使用户避免错误的危险,程序员经历了各种磨难,但却没有说明危险到底谁什么。程序员不理解的断言也会被忽视。在这种情况下,程序员会认为相应的断 言是错误的,并把它们从程序中去掉。因此,为了使程序员能够理解断言的意图,要给不够清楚的断言加上注解。
如果在断言中的注解中还注明了相应错误的其他可能解法,效果更好。例如在程序员使用相互重叠的内存块调用memcpy时,就是这样做的一个好机会。程序员可以利用注解指出此时应该使用memmove,它不但能够正好完成你想做的事情,而且没有不能重叠的限制:
/*内存块重叠吗?如果重叠,就使用memmove*/
assert(pbto >= pbfrom + size || pbfrom >= pbto + size);
在写断言注解时,不要长篇大论。一般的方法是使用经过认真考虑过的简短问句,它可以比用一整段的文字系统地解释出每个细节的指导性更强。但要注意,不要在注解中建议解决问题的办法,除非你能够确信它对其他程序员确有帮助。做注解的人当然不想让注解把别人引入歧途。
不要浪费别人的时间——详细说明不清楚的断言
《断言不是用来检查错误的》
当程序员使用断言时,有时会错误地利用断言去检查真正的错误,而不去检查非法的情况。看一下下面函数strdup中的两个断言:
/*strdup----为字符串分配一个副本(不是拷贝一个副本,是分配)*/
char *strdup( char *str)
{
char *strnew;
assert( str != null );
strnew = ( char *)malloc( strlen(str) + 1 );
assert( strnew != null);
strcpy( strnew, str);
return(strnew);
}
第一个断言的用法是正确的,它被用来检查该程序正常工作时,绝对不应该发生的非法情况.
第二个断言的用法相当不同,它测试的是错误情况,是在其最终产品中肯定会出现,并且必须对其进行处理的错误情况.
也就是说:断言是用来检查非法情况的,而不是测试和处理错误的。
《你又做假定了吗?》
有时在编程序时,有必要对程序的运行环境做出某些假定。但这并不是说在编程序时,总要对运行环境作出假定。例如:下面的函数memset就没有对其运行环境做出任何假定。因此它虽然未必效率很高,但却能够运行在任何的ansi c 编译程序之下。也就是说,编译程序时要考虑移植,有许多是和编译器有关的——是独立于ansi c 之外的特定编译器才能够运行正确的程序,这就是自己所做得假定,这种假定编程者心里一定要清楚才行:
/* memset----用“byte”的值填充内存块 */
void *memset( void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
while(size-- > 0)
*pb++ = b;
return( pv );
}
但在许多计算机上,先通过将要填充到内存块中的小值拼成较大的数据类型,然后用较大的大值填充内存,由于实际填充的次数减少了,可使编出的memset函数速度更快,在68000上,下面的memset函数的填充速度比上面的要快四倍。
/* longfill-----用“long”的值填充内存块。在填完了最后一个长字之后,
* 返回一个指向所填第一个长字的指针
*/
long *longfill(long *pl, long l, size_t size); /* 原型 */
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
if(size >= sizethreshold)
{
unsigned long l;
l = ( b<< 8) | b;
l = ( l << 16 ) | l;
pb = (byte *)longfill( (long *)pb, l, size/4 );
size=size%4;
}
while(size-- > 0)
*pb++ = b;
return( pv );
}
在上面的程序中,可能除了对sizethreshold所进行的测试之外,其他的内容都很直观。如果还不大明白为什么要进行这一测试,那么可以想一想无论是将4个字节拼成一个long,还是调用函数都化一定的时间。对sizethreshold进行测试是为了使memset只有在用long进行填充,使速度更快时才进行相应的填充。否则,就仍使用byte进行填充。
这个memeset新版本的唯一问题是他对编译程序和操作系统都作了一些假定。这段代码很明显的假定long占用4个内存字节,该字节的宽度是8位。这些假定对许多计算机都正确。不过这并不意味着因此就应该对这一问题置之不理,因为现在正确并不等于今后也正确。
有的程序员“改进”这一程序的方法,是把它写成如下可移植性更好的形式:
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
if(size >= sizethreshold)
{
unsigned long l;
size_t sizesize;
l = 0;
for( sizesize = sizeof(long); sizesize-- > 0; null)
l = (l << char_bit) | b;
pb = (byte *)longfill( (long *)pb, l, size/sizeof(long) );
size=size%sizeof(long);
}
while(size-- > 0)
*pb++ = b;
return( pv );
}
由于在程序中大量的使用了运算符sizeof,这个程序看起来移植性更好,但“看起来”不等于“就是”。如果要把它移植到新的环境,还是要对其进行考察才行。如果在macintosh plus或者其他基于68000的计算机上运行这个程序,假如pv开始指向的是奇数地址,该程序就会瘫痪。这是因为在68000上,byte * 和 long * 是不可以相互转换的类型,所以如果在奇数地址上存储long 就会引起硬件错误。
那么到底应该怎么做呢?
其实在这种情况下,就不应该企图将memset写成一个可移植的函数。要接受不可移植这一事实,不要对其改动。对于68000,要避免上述的奇数地址问题,可以先用byte进行填充,填到偶数地址之后,再换用long继续填充。虽然将long对齐在偶数上已经可以工作了,但在各种基于68020、68030、68040等新型的machintosh上,如果使其对齐在4字节的边界上,性能会更好。至于对程序中所作的其他假定,可以利用断言和条件编译进行相应的验证:
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
#ifdef mc680x0
if(size >= sizethreshold)
{
unsigned long l;
assert( sizeof(long) == 4 && char_bit == 8);
assert( sizethreshold >= 3);
/* 用字节进行填充,直到对齐在长字边界上 */
while((( unsigned long) pb & 3) != 0)
{
*pb++ = b;
size--;
}
/* 现在拼装长字,并用长字填充其他的内存单元 */
l = ( b<< 8) | b;
l = ( l << 16 ) | l;
pb = (byte *)longfill( (long *)pb, l, size/sizeof(long) );
size=size%sizeof(long);
}
#endif /* mc680x0 */
while(size-- > 0)
*pb++ = b;
return( pv );
}
正如所见,程序中与具体及其相关的部分已经被mc680x0预处理程序定义设施括起。这样不仅可以避免这部分不可移植的代码被不小心地用到其他不同的机器上,而且通过在程序中搜索m680x0这个字符串,可以找出所有与目标机器有关的代码。
为了验证long占用个内存字节、byte的宽度是8,还在程序中加了一个相当直观的断言。虽然暂时不太可能发生改变,但谁知道以后会不会发生改变呢?
为了在调用longfill之前使pv指向4字节的边界上,程序中使用了一个循环。由于不管size的值如何,这个循环最终都会执行到size等于3的倍数,所以在循环之前还加了一个检查sizethreshold是否至少是3的断言(sizethreshold应该取较大的值。但他至少应该是3,否则程序就不会工作)。
经过这些改动,很明显这个程序已不再可移植,原先所作的假定或者已经被消除,或者通过断言进行了验证。这些措施使得程序极少可能被不正确地使用。
消除所作的隐式假定,或者利用断言检查其正确性
《光承认编译程序还不够》
在微软的历史上,曾经有过这么一件事情。他们的一些小组渐渐发现他们不得不对其代码进行重新的考察和整理,因为相当多的代码充满了“+2”,而不是“+sizeof(int)”、与上了0xffff,而不是unit_max进行无符号数的比较、在数据结构中使用的是int,而不是真正想用的16位数据类型这一类问题。
也许有人认为这是因为这些程序员太懒惰,但他们不会同意这一看法。事实上,他们认为有很好的理由说明他们可以安全地使用“+2”这种形式,及相应的c编译程序是由microsoft自己编写的。这一点给程序员造成了安全的家假象,正如几年前一位程序员所说:“编译程序组从来没有做使我们所有程序垮掉的改变”。
但这位程序员错了。
为了在intel 80386和更新的处理器上生成更快更小的程序,编译程序组改变了int的大小(以及其他一些方面)。虽然编译程序组并不想使公司内部的代码垮掉,但是保持在市场上的竞争地位显然更重要。毕竟,这是那些自己做了错误假定的microsoft程序员的过错。
所以,大家应该考虑,自己的程序是否可以在以后发展了的64位机器上运行呢?是否可以在当前的其他系统上运行呢?是否具有移植性呢?是否具有代码可重用性呢?如果没有,那么你现在所编的程序的价值就非常的小,只是在学习而已。
《不可能的事情也能发生?》
函数的形参并不一定总是给出所有输入数据,有时它给出的只是一个指向函数输入数据的指针。例如:下面这个简单的压缩还原程序:
byte *pbexpand(byte *pbfrom, byte *pbto, size_t sizefrom)
{
byte b, *pbend;
size_t size;
pbend = pbfrom + sizefrom; /*正好指向缓冲区尾的下一个位置*/
while( pbfrom < pbend)
{
b = *pbfrom++;
if( b == brepeatcode)
{
/*在pbto开始的位置存储“size”个“b”*/
b = *pbfrom++;
size = (size_t) *pbfrom++;
while( size-- > 0)
*pbto++ = b;
}
else
*pcto++ = b;
/*end if*/
}/*end while*/
return(pbto);
}/*end pbexpand*/
本程序将一个数据缓冲区中的内容拷贝到另一个数据缓冲区中。但在拷贝过程中,它要找出所有的压缩字符序列。如果在输入数据中找到了特殊的字节brepeatcode,它就认为其后的下两个字节分别是要重复的还原字符以及字符的重复次数。尽管这一过程显得有点过于简单,但我们还是可以把它们用在某些类似于程序编辑的场合下。那里,正文中常常包括有许多表示缩进的连续水平制表符和空格符。
为了使pbexpand更健壮,可以在该程序的入口点加上一个断言,来对pbfrom、sizefrom和pbto的有效性进行检查。实际上,还有许多其他可以做的事情。例如:可以对缓冲区中的数据进行确认。
由于进行一次译码总需要三个字节,所以相应的压缩程序从不对两个连续的字符进行压缩。另外,虽然也可以对三个连续的字符进行压缩,但这样做并不能得到什么便宜。因此,压缩程序只对三个以上的连续字符进行压缩。
存在一个例外的情况。如果原始数据中有brepeatcode,就必须对其进行特殊的处理。否则当使用pbexpand时,就会把它误认为是一个压缩字符序列的开始。当压缩程序在原始数据中发现了brepeatcode时,就把它再重复一次,以便和真正的压缩字符序列区别。
总之,对于每个字符压缩序列,其重复次数至少是4,或者是1。在后一种情况下,相应的重复字符一定是brepeatcode本身。我们可以使用断言对这一点进行验证:
byte *pbexpand(byte *pbfrom, byte *pbto, size_t sizefrom)
{
byte b, *pbend;
size_t size;
assert(pbfrom != null && pbto != null && sizefrom != 0);
pbend = pbfrom + sizefrom; /*正好指向缓冲区尾的下一个位置*/
while( pbfrom < pbend)
{
b = *pbfrom++;
if( b == brepeatcode)
{
/*在pbto开始的位置存储“size”个“b”*/
b = *pbfrom++;
size = (size_t) *pbfrom++;
assert(size >= 4 || (size == 1 && b == brepeatcode));
while( size-- > 0)
*pbto++ = b;
}
else
*pcto++ = b;
/*end if*/
}/*end while*/
return(pbto);
}/*end pbexpand*/
如果这一断言失败,说明pbfrom指向的内容不对或者字符压缩程序中有错误。无论那哪种情况都是错误,而且是不用断言就很难发现的错误
《利用断言来检查不可能发生的情况》
《安静的处理》
程序员,尤其是有经验的程序员编的程序通常都是这样:当某些意料不到的事情发生时,程序只进行无声无息的安静处理,甚至有些程序员会有意识的使程序这样做。也许你自己用的是另一种方法。
当然,我们现在谈的是所谓的防错性程序设计。
前面,我们介绍了pbexpand程序。该函数使用的就是防错程序设计。但从其循环条件可以看出,下面的修改版本并没有使用防错性程序设计。
byte *pbexpand(byte *pbfrom, byte *pbto, size_t sizefrom)
{
byte b, *pbend;
size_t size;
pbend = pbfrom + sizefrom;/*正好指向缓冲区尾的下一个位置*/
while(pbfrom != pbend)
{
b = *pbfrom++;
if(b == brepeatcode)
{
/* 在pbto开始的位置存储“size”个“b”*/
b = *pbfrom++;
size = (size_t) *pbfrom++;
do
*pbto ++= b;
while(size -- != 0)
}
elae
*pbto ++ = b;
}/* while */
return(pbto);
}/* pbexpand */
虽然这一程序更精确地反应了相应的算法,但有经验的程序员很少会这样编码。否则好机会就来了,我们可以把他们塞进一辆既没有安全带又没有车门的双人cessna车种。上面的程序使人感到太危险了。
有经验的程序员会这样想:“我知道在外循环中pbfrom绝不应该大于pbend,但如果确实出现了这种情况怎样办呢?还是在这种不可能的情况出现时,让外循环退出为好。”
同样对于内循环,即使size总应该大于或等于1,但使用while循环代替do循环,可以保证进入内循环时一旦size为0,不至于使整个程序瘫痪。
使自己免受这些“不可能”的打扰似乎很合理,甚至很聪明。但如果出于某种原因pbfrom被加过了pbend,那么会发生什么事情呢?在上面这个充满危险的版本或者前面看到的防错性版本中,找出这一错误的可能性又有多大呢?当发生这一错误时,上面的危险版本也许会引起整个系统的瘫痪,因为pbexpand会企图对内存中的所有内容进行压缩还原。在这种情况下,用户肯定会发现这一错误。相反,对于前面的防错性版本来说,由于在pbexpand还没有来得及造成过多的损害(如果有的话)之前,它就会退出。所以虽然用户仍然可能发现这一错误,但这种可能性不大。
实际的情况就是这样,防错性程序设计虽然常常被誉为有较好的编码风格,但它却隐瞒了错误。要记住,我们正在谈论的错误决不应该再发生,而对这些错误所进行的安全处理又使编写无错代码变得更加困难。当程序中有了一个类似于pbfrom这样的跳跃性指针,并且其值在每次循环都增加不同的量时,编写无错误代码尤其困难。
这是否意味着我们应该放弃防错性程序设计呢?
答案是否定的。尽管防错性程序设计会隐瞒错误,但它确实有价值。一个程序所能导致的最坏结果是执行瘫痪,并使用户可能花几个小时建立的数据全部丢掉。在非理想的世界中,程序确实会瘫痪,因此为了防止用户数据丢失而采取的任何措施都是值得的。防错性程序设计要实现的就是这个目标。如果没有它,程序就会如同一个用纸牌搭起的房子,哪怕硬件和操作系统中发生了最轻微的变化,都会塌落。同时,我们还希望在进行防错性程序设计时,错误不要被隐瞒。
假定某个函数以无效的参数调用了pbexpand,比如sizefrom 比较小并且数据缓冲区最后一个字节的内容碰巧是brepeatcode。由于这种情况类似于一个压缩字符序列,所以pbexpand将从数据缓冲区外多读2个字节,从而使pbfrom超过pbend。结果呢?pbexpand的危险版本可能会瘫痪,但其防错性版本或许可以避免用户数据的丢失,尽管它也可能冲掉255个字节的未知数据。既然两者都想得到,既需要调试版本对错误进行报警,又需要交付版本对错误进行安全的恢复,那么可以一方面一如既往地利用防错性程序设计编码,另一方面在事情变糟的情况下利用断言进行报警。
byte *pbexpand(byte *pbfrom, byte *pbto, size_t sizefrom)
{
byte b, *pbend;
size_t size;
pbend = pbfrom + sizefrom;/*正好指向缓冲区尾的下一个位置*/
while(pbfrom < pbend)
{
b = *pbfrom++;
……
}/* while */
assert(pbfrom == pbend);
return(pbto);
}/* pbexpand */
上面的断言只是用来验证该函数的正常终止。在该函数的交付版本中,相应的防错措施可以保证当出了毛病时,用户可以不受损失;而在该函数的调试版本中,错误仍然可以被报告出来。
但是在实际的编程中,也不必过分拘泥于此。例如,如果每次循环pbfrom的内容总是增1,那么要使pbfrom超过pbend从而引起问题,恐怕需要一束宇宙射线的偶然轰击才行。在这种情况下,相应的断言没有什么用处,因此可以从程序中删除。在程序中究竟是否需要使用断言,要根据常识视具体情况而定。最后应该说明的是,循环只是程序员通常用来进行防错性程序设计的一个方面。实际上,无论把这种程序设计风格用在哪里,在编码之前都要问自己:“在进行防错性程序设计时,程序中隐瞒错误了吗?”如果答案是肯定的,就要在程序中加上相应的断言,以对这些错误进行报警
待续。。。