首页  编辑  

不可靠的做法

Tags: /超级猛料/Book.凤毛麟角(电子书籍片断)/完美程式設計指南/   Date Created:
不可靠的做法
当你在写一本离奇小说时,你希望每一页的内容都能牵动读者的注意力。你想唤起读者心中的惊奇、恐惧与悬疑感。如果你写, " 某人走了过来,把 Joe 刺伤了 " ,你大概会让读者们睡着。要让读者保持阅读兴趣,你得让她感受到 Joe 听到背后的脚步声一步步靠近时的恐惧感。你得让她经历 Joe 在那些脚步声愈来愈接近时的那种心跳加速的感受。你得让读者经历 Joe 在听到脚步声愈来愈近时那般感受,在她们心中升起一股惊慌感。最重要的,你得让读者想着: Joe 逃得掉吗?
在离奇小说中使用惊奇跟悬疑的效果是很重要的,可是把这两种效果用在程序中就太恐怖了。当你写程序时, " 企图 " 应该明显而无趣得让程序员们知道接下来会发生什么事。如果你的程序必须有个某人走了过来刺伤 Joe 的情节,那这个某人走过来,然后刺伤 Joe 的剧情就必须是你真正要求的东西,你不能旁生枝节。这很短,很清楚,而且告诉你每件你所必须知道的事情。
不过有时候,程序员们抗拒将程序写得如此清晰无趣,想要用怪异的小技俩来达到不正常的目的,完成某些超乎限制的工作。
在本章中,我们会看到几种既不简单也不无趣的程序写作风格。这些范例卖弄小聪明的技俩,用途一点也不明显。当然,这些方式全带来潜藏的错误。
对速度的需求
这是我们在上一章中看到的零错误版 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);
}
大部分程序员们爱玩的一个游戏就是 " 我怎样让程序跑得更快? " 这游戏玩起来不赖,可是如我们在书中看到的,如果你做得太离谱,它就会带来不可预期的后果。
如果你拿上头的 memchr 程序代码来玩这种游戏,你会问自己, " 我怎样加速那个循环的执行? " 只有三个可能:拿掉长度的检查,拿掉字符测试,或者拿掉指针递增的部分。这三个似乎都不可能拿掉,可是你还是可以-如果你想试着用传统大胆的做法的话。
看看那个长度的检查,只有当你在内存前面 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指向memchr正在搜寻中的内存的第一个字符。
    * 把ch放在那个位置,保证即使在在要搜寻的内存范围内
    * 没有找到ch,memchr也可以找到一个ch。
    */
   pchPlant = pch + size;
   chSave = *pchPlant;       /* 保留原来的字符。*/
   *pchPlant = ch;
   
   while (*pch != ch)
       pch++;
   *pchPlant = chSave;       /* 把本来的字符放回来。*/
   return ((pch == pchPlant) ? NULL : pch);
}
这样子写得够精巧吧?把 pchPlant 指向的字符换掉,你可以保证 memchr 会找到 ch ,而且让你拿将长度检查拿掉,加倍循环执行的速度。
不过这样写够稳定吗?这样写够扎实吗?
memchr 的新写法看来够稳定,特别是它小心将改变的字符保留着,可是这一版的 memchr 比蝙蝠侠的小玩意儿有着更多问题。初学者们,想想这些问题:
?
�         如果 pchPlant 指向只读存储器,将 ch 写入 *pchPlant 的动作就无效了,而且这函式会在前面 size+1 个字节中找不到 ch 时传回不合格的指针。 ?
�         如果 pchPlant 指向内存对应外围的地址范围内,将 ch 写入 *pchPlant 可能会产生可怕的结果,从让软盘机磁头马达停下来或激活,到让工厂的机器人疯狂乱动都有可能发生。 ?
�         如果 pch 指向随机存取内存的最后 size 个字节处, pch 跟 size 的值都是合格的,可是 pchPlant 会指向不存在或有写入保护的内存地址。在 *pchPlant 处写入 ch 会造成内存存取失误,或什么事情也不会发生,而让函式在 ch 没出现在头 size+1 个字符内时执行失败。 ?
�         pchPlant 如果指向并行的多任务执行程序共享的内存,当一个程序把 ch 写到 *pchPlant 处时,可能会把另一个多任务执行程序要执行时可能用到的资料毁掉。
最后一种可能性尤其麻烦,因为在并行的多任务执行程序间有许多方式可以把系统当死。如果你在搜寻一块先前配置好的内存时把内存管理器的数据结构毁了,会发生什么事?如果并行处理的多任务执行程序之中一个-以一条执行绪或是一个硬件中断处理程序为例-接下来获得执行权了,它最好不要呼叫内存管理器,因为系统可能会当掉。如果你用 memchr 来检查一个整体变量数组,而它把其它 task 使用的相邻变量给毁了,会发生什么事?又如果程序的两份执行程序试着同时搜寻共享的资料,会发生什么事?这些情形的任何几种都可以把你的程序当死。
当然,你可能不会明白最佳化过的 memchr 会带来潜伏的问题,因为除非它更动了内存的关键部分,不然它会动作得好好的。不过当像最佳化过的 memchr 函式带来问题时,要找出问题的根源却有如在沙漠风暴中寻找出路一样困难:毕竟,执行 memchr 的多任务执行程序会动作得好好的;出问题的是别的多任务执行程序-那个用到被更动的内存的-会当掉,而且完全没证据能够指出 memchr 才是元凶。
如果你本来不晓得价值 50,000 美金的内电路仿真器是用来作什么的,现在你知道了-我们可以用内电路仿真器保留每个时脉周期的纪录,以及计算机所用到的每个指令跟资料,直到程序当掉为止。你可能要好多天才能看完仿真器执行上头那个有问题的程序的输出结果,如果你能坚持到底,而且不在仿真的结果中瞎找,你应该能找出问题何在。
不过为何要那么辛苦呢?找出那种问题的另一个做法简单多了:不要参考到不属于自己的内存就好了。记住: " 参考 " 代表读跟写。读取未知的内存地址可能会跟其它多任务执行程序互动产生可怕的结果,而参考到只读存储器、不存在内存的地址或是内存对映外围的存取地址都可能让你的程序当掉。
不要参考不属于自己的内存。
拿到了钥匙的小偷还是小偷
有够奇怪,我认识一些程序员们,他们虽然不会去动到不属于自己的内存,可是他们会觉得把 FreeWindowTree 子程序写成底下这样很好:
void FreeWindowTree(window *pwndRoot)
{
   if (pwndRoot != NULL)
   {
       window *pwnd;
       /* 释放pwndRoot的子窗口... */
       pwnd = pwndRoot->pwndChild;
       while (pwnd != NULL)
       {
           FreeWindowTree(pwnd);
           pwnd = pwnd->pwndSibling;
       }
       
       if (pwndRoot->strWndTitle != NULL)
           FreeMemory(pwndRoot->strWndTitle);
       FreeMemory(pwndRoot);
   }      
}
看看那个 while 循环,看出问题在哪里了吗?当 FreeWindowTree 透过串行释放每个子窗口时,它先释放 pwnd ,然后又在下面这行中参考了已释放的内存块
pwnd = pwnd->pwndSibling
但是在 pwnd 释放后, pwnd->pwndSibling 的值是什么?当然是垃圾,不过有些程序员们不这么认为。稍早的内存内容既然不是垃圾,他们也没去动到内存,他们想,那内存中的内容应该还是有效的。他们并非什么都没作,他们把内存释放掉了。
我从来不了解为什么有些程序员相信被释放了的内存中的内容还是可以使用。这跟你用自己另外打造的钥匙进入自己住过的出租公寓,或者开走一部你卖掉了的车子有什么不同?你不能安全的参考释放了的内存,就如我在第三章中提到过了的,内存管理器会使用可用内存链来存放自己用的一些信息。
资料的存取权限
你看过的任何程序设计手册中可能都没提到,不过任何程序中的资料都预先安排了读写的权限。这种存取权限并没有公开宣告;这种权限定义并没有标示在你宣告的每个变量前面,而是由你的子系统设计跟函式接口所决定的。
举例来说,在呼叫函式的程序员跟写出函式的有效宣告的程序员间有个共识,
如果呼叫者「我」,传给「你」,被呼叫者,一个输入的指针,你同意将这输入指针当成个常数,并保证不会对它进行写入动作。更进一步,如果我传给你一个输出的指针,你同意将这个输出指针当成一个只能写、不能读的对象,承诺不会从它指向的地方读取资料。最后,无论输出或输入指针,你都同意将限制输出或输入指针所指向的内存的参考。
返回时,我,呼叫者,同意将只读的输出结果当成常数,保证不会写入资料进去。我更进一不同意限制对输出指针所指向的必须内存进行参考的动作。
换句话说, " 你不要把我的东西搞混,我也不会把你的东西搞混。 " 记住这点:当你违反一个预先定好的读写权限时,你就冒着让相信每个程序员都会遵守这些规矩的人所写的程序出错的风险。一名呼叫如 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 来把结果反转回来。这样似乎是浪费时间的做法,如果你要先把数字反着放,放完再反转回来,那为何不直接把数字从尾端放回来,这样就不用再多一次反转的步骤了,不是吗?底下就是省略反转步骤的写法:
void UnsToStr(unsigned u, char *str)
{
   char *pch;
   
   /* u超出范围?请改用UlongToStr吧... */
   ASSERT(u <= 65535);
   
   /* 将数字反过来放在str中。
    * 先从字符串装得下u的最大位数的位置放起。
    */
   pch = &str[5];
   *pch = 0';
   do
       *--pch = (u % 10) + '0';
   while ((u /= 10) > 0);
   
   strcpy(str, pch);
}
有些程序员觉得这样写比较好,因为这样跑起来比较有效率而易懂。 UnsToStr 变得更有效率,因为 strcpy (你还是需要这个函式)比 ReverseStr 快,特别在那些可以把这样的呼叫编译成内含指令而非函式呼叫的编译器上。这样的程序代码易于了解,因为 C 语言的程序写作者们熟悉 strcpy. 当程序员们看到 ReverseStr 时,他们就像看到一名住院的朋友还可以走路一样踌躇失措。
问题何在?如果 UnsToStr 如此完美,我还要跟你提这些作什么?它当然不完美,事实上,新的 UnsToStr 有个严重的缺陷。
告诉我, str 参数指向多大的内存?你不知道。你不知道一个指针指向多大的内存,这在 C 的接口中是很常见的事情。在呼叫者与实作之间的共识是 str 会指向能够装下 u 转换后的文字的内存。可是最佳化过的 UnsToStr 假设 str 会指向能够装下 u 所能代表的最大位数的空间,实际上可能不是如此。如果呼叫者写成底下这样,那会如何?
DisplayScore()
{
   char strScore[3];     /* UserScore从0到25。*/
   
   UnsToStr(UserScore, strScore);
   .
   .
   .
由于 UserScore 从来不会产生超过三个字符长度的字符串(两位数字跟一个零字符),对程序员来说,把 strScore 设计成长度为三个元素的字符数组十分合理。可是 UnsToStr 会假设 strScore 是个六元素的字符数组ㄝ而将 strScore 之后的三个字节的内存内容毁掉。在 DisplayScore 的例子中,如果你的机器上的堆栈是向下增长的类型, UnsToStr 通常会把堆栈框中存放的 DisplayScore 呼叫者的返回地址或且呼叫者的堆栈框指针毁掉。你的机器大概会当掉,而你会注意到这个问题。如果 strScore 如果不是个区域变量 , 你大概不会发现 UnsToStr 已经把 strScore 后头的内存内容搅乱了。
我确定有程序员会说把 strScore 宣告得大得只够装下他们所需要的最长可能字符串是危险的做法。可是只有在程序员把程序写得如同刚刚看到的 UnsToStr 版本一般时,那样的做法才会是危险的。事实上不用这样啰唆,你可以写个 UnsToStr 跑得既安全又有效率,只要在区域缓冲区内把字符串建立好,再把结果复制到 str 去就好了:
void UnsToStr(unsigned u, char *str)
{
   char strDigits[6];                /* 转换缓冲区 */
   char *pch;
   
   /* u超出范围?请改用UlongToStr吧... */
   ASSERT(u <= 65535);
   
   /* 将数字反过来放在str中。            
    * 先从字符串装得下u的最大位数的位置放起。
    */                                            
   pch = &strDigits[5];
   *pch = '\0';
   do
           *-pch = (u % 10) + '0';
   while ((u /= 10) > 0);
   
   strcpy(str, pch);
}
你得记得,除非另外定义,不然如 str 般的指针不会指向你能用来当作工作缓冲区的内存。如 str 这般为了效率需求而存在的指针是以参考地址的方式传递的,而不是以地址值的方式传递的。
不要使用输出内存作为工作缓冲区。
把自己的东西放好
当然,有程序员会想,即使在 UnsToStr 中呼叫 strcpy 都太浪费了。为何不干脆传回一个指向已经建立好了的字符串的指针,省下把字符串复制到别处的功夫呢?
char *strFromUns(unsigned u)
{
   static char *strDigits = "?????";   /* 5个字符 + '\0'*/
   char *pch;
   
   /* u超出范围?请改用UlongToStr吧... */
   ASSERT(u <= 65535);
   
   /* Store the digits in strDigits from back to fromt. */
   pch = &strDigits[5];
   ASSERT(*pch == '\0');
   do
       *--pch = (u % 10) + '0';
   while ((u /= 10) > 0);
   
   return (pch);
}
这个程序几乎跟我们在上一节中到的那个版本相同,除了 strDigits 被宣告成静态的,使它的内容在 strFromUns 返回时也一样继续保存着。
不过想象一下:如果你要实作一个用来转换两个无号数成字符串的函式,你用下面的方式来写
strHighScore = strFromUns(HighScore);
.
.
.
strThisScore = strFromUns(Score);
这里头有什么问题呢?你用 strFromUns 来转换 Score ,你把 strHighScore 指向的字符串毁了。
你可以说,这个错误在呼叫 strFromUns 的程序代码中而非 strFromUns 本身,不过记得我们在第五章提到的东西吗?函式能够正常运作还不够正确;它们必须避免程序员犯错才行。我会说,这个 strFromUns 至少有个接口错误,因为你我都知道有些程序员会使用刚刚那种有问题的做法。
即使一名程序员了解 strFromUns 产生的字符串内容的缺点,他们还是可能在不了解自己在作什么之前制造出问题来。假设一名程序员呼叫了 strFromUns ,然后呼叫另一个函式,在这后来的函式中也不知不觉的呼叫了 strFromUns ,那她本来的那个字符串就会被毁了。或者假设有好几条执行绪同时在跑,有条执行绪呼叫了 strFromUns ,而把另一条执行绪还在使用的字符串给清除掉。
而这些问题跟 strFromUns 制造的一个问题比较起来,不过是小巫见大巫而已。什么问题? strFromUns 会在你的程序项目发展大了以后爆炸开来。如果你决定把 strFromUns 的呼叫放到一个函式中:
?
�         你必须确定呼叫这个函式的程序代码(以及呼叫这些程序代码的程序)不会用到 strFromUns 传回来的字符串。换句话说,你必须确定没有函式会在你函式上头的呼叫链中用到 strFromUns 里头的缓冲区。 ?
�         你也必须确定你不会呼叫任何呼叫 strFromUns 的函式,免得毁了一个你正在使用的字符串。当然,这就是说,你不能呼叫一个会呼叫 strFromUns 的函式。
整体缓冲区的问题
strFromUns 的例子说明了你再将资料透过指向静态内存的指针传回来时所要面对的危险。这个例子中没提到的是,同样的危险也存在你将资料透过静态缓冲区传递时。你可以把 strFromUns 改写成将一个数值字符串放在一个整体缓冲区中,或是一个你的程序一开头就用 malloc 配置好了的永久缓冲区中,可是那样子并不能去除 strFromUns 的危险性,因为程序员们还是可能在一串呼叫链中使用这个函是两次,而让第一次呼叫得到的字符串毁掉。
基本原则就是,不要在整体缓冲区中传递资料,除非你一定得这么做。如果你强迫呼叫函式的程序代码得提供一个指向输出缓冲区的指针,你就可以避免掉整个问题。
如果你在一个函式中加上一个 strFromUns 的呼叫而不检查这两点限制,你就冒着制造错误的风险,这够糟糕了吧。想象一下,如果程序员们一定得照着那两点限制作的话,修正错误跟加入新功能会有多困难呢?每当他们改变了对你函式的呼叫链,进行维护的程序员就得重新核对一下这两个限制。你觉得他们会去检查这些东西吗?很难吧。这些程序员甚至不知道他们应该检查这两个限制。毕竟,他们只是在修正错误,重写程序代码跟加上新功能而已;他们怎么会晓得一个他们可能从来没看过或用过的 strFromUns 函式有什么必要的使用限制?
类似 strFromUns 这样的函式会一再造成问题,因为他们的设计方式让程序员们在维护程序时容易产生问题。当然,再程序辕门找出 strFromUns 类型的错误时,这些错误并不是由 strFromUns 内部产生的,而是因为 strFromUns 的使用方式不正确。程序员们并不会重写 strFromUns 来修正真正的问题所在,而会修正各个不正确使用 strFromUns 的错误,让 strFromUns 在程序中维持原样,继续制造新的问题 ... 。
不要透过静态(或整体)变量的内存传递资料。
有用的寄生虫
在公开的缓冲区中传递资料有危险,不过如果你小心而且幸运的话,还是可以避开这样的问题。不过写作依赖其它函式的内部运作机制的寄生函式不只是危险而已,还是不负责任的。如果你改变了宿主函式,你就把寄生函式一起毁了。
我看过最好的寄生函式范例是在一个被广泛推广并移植到不同平台上使用的 FORTH 程序语言标准实作内,里头到处都是寄生函式。在 1970 年代晚期跟 1980 年代早期, FORTH Interest Groups 试着透过 FORTH-77 标准的免费公开实作来提升 FORTH 语言的使用量。这些 FORTH 的实作定义了三个标准函式:将内存填写成一个给定的字节值的 FILL 函式;一个从内存块前端开始复制内存内容的 CMOVE 函式;还有一个从内存尾端开始复制内存内容的 <CMOVE 函式。 CMOVE 跟 <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 写成用长整数来搬移内存,而不是用字节来搬,那会发生什么事情?答案是,你当然写出了一个零错误又跑得飞快的 CMOVE 函式,可是把每个呼叫 FILL 的函式都给毁了。对我来说,那一点也不灵巧,而是糟糕透了。
让我们假设你知道 CMOVE 绝对不会被改写,你还在 CMOVE 前头放了一段警告叙述,告诉其它程序员说 FILL 会依赖 CMOVE 的内部运作机制,不要去动到 CMOVE 。这样的做法只解决了与 CMOVE 相关的一半问题。
假设你在开发一个简单的四轴工厂机器人的控制程序,每个轴的位置都有 256 种变化。这样子的机器人有个简单的设计方式,就是用四个字节的内存对映输出入方式,让不同的内存地址控制不同的机械轴。要改变一个机械轴的位置,你直接把 0 到 255 间的值写到对应的内存地址就好了。要取得一个机械轴的位置(在让机器轴移到新位置时,这样的信息是有用的),你从对应的内存位置读取一个字节就好了。
如果你要把四个机械轴都归位回 (0,0,0,0) 的位置,理论上你可以这样写
FILL(pbRobotArm, 4, 0); /* 把机器轴归位。*/
当然,这样的程序不会有效,因为 FILL 的定义方式- FILL 会先写个 0 给第一个机械轴,然后把垃圾丢给剩下三个轴,让机器人抓狂。为何如此?如果你检查一下 FILL 的写法,你会发现它用复制之前写入的字节到现在位置的方式来填写内存。可是当 FILL 读到第一个字节时-它当然期望这字节的值还是 0 -它会读到第一个机械轴的位置值,由于在写入 0 到读取这地址的时间内机械轴还没移到 0 点位置,它读到的值大概也不会是 0. 结果 FILL 把这个可能还玫归零的位置值写入第二个机械轴的控制地址,第三个跟第四个机械轴也同样收到了类似的垃圾结果。
如果 FILL 的写法要让上头的动作有效,你必须保证它会从它写入的内存中读到它所写入的相同值,可是对内存对映输出入的周边地址来说,这是不能保证成立的事情。
除错检查让程序员诚实
如果 CMOVE 用了个除错检查来查核它的参数是否有效(就是说,检查资料来源不会在复制到目的地以前被破坏),写出 FILL 函式的程序员就会在它第一次测试程序时碰到除错检查发出的错误讯息。
这给了这名程序员两种选择:以合理的算法重写 FILL ,或是将除错检查从 CMOVE 中拿掉。幸运的,只有少数程序员为了让这样简单的 FILL 写法能动,而会将 CMOVE 中的除错检查拿走。
而我的想法是, FILL 的做法是错误的,因为它使用了别的函式中的隐藏细节,并滥用的这些它所应该不晓得的东西。 FILL 不会在随机内存以外的内存地址上正常运作只是小事。最主要的,它示范了违背 " 简单又无聊 " 的程序代码写作原则时,你会碰到哪些麻烦。
不要写出寄生函式。
搅拌油漆的老做法
粉刷房子时的一个老做法是拿一把螺丝起子打开油漆盖,然后用螺丝起子搅拌油漆。我也知道这做法;我有一堆染了不同颜色的螺丝起子。为何人们即使知道不应该,还是会用螺丝起子来搅拌油漆?我会告诉你为什么:因为螺丝起子拿起来方便,也能有效搅拌油漆。有些程序设计技巧就像这样,既方便,又保证有用,不过就如同螺丝起子般,并不是被用在这些技巧本来的用途上。
让我们看看底下的程序片段,把比较的结果当成了计算表示式的一部份:
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 程序员发现他们的程序不会动了。布尔( boolean )结果值本来是在 FORTH-77 中定义成 0 跟 1 的,为了一些不同的理由,在 FORTH-83 中变成了 0 跟 -1. 结果,这个改变让一些依赖 true 为 1 的程序跑不起来。
FORTH 程序员们并不孤单。
USCD Pascal 在 1970 年代晚期跟 1980 年代早期也是相当热门的程序开发工具。如果你在一台微电脑上使用 Pascal ,你有相当大的机率是用个 USCD 版本的实作在写东西。有天, USCD Pascal 的程序员们收到了一份更新版的编译器,结果许多人发现他们的程序不会动了。编译器的作者们,为了不知道什么理由,把 true 的值改变了。
谁能保证,在未来的某些标准中, C 语言不会有所改变?即使不是 C 语言改变了,而是 C++ 或你改用的某个衍生语言有了类似的改变呢?
APL 并发症
那些不清楚 C 语言程序怎么转译成机械码的程序员们常会试着用精简的 C 语法来改善机械码的品质。它们认为,如果你写出最短的 C 语言叙述,你就应该会得到最短的机械码。在你的 C 程序大小跟对应的机械码大小间有个关联存在,不过这个关联在你针对一行行的程序分别讨论时,就不成立了。
你还记得 第六章中的uCycleCheckBox函式 吗?
unsigned uCycleCheckBox(unsigned uCur)
{
   return ((uCur<=1) ? (uCur?0:1) : (uCur==4)?2:(uCur+1));
}
uCycleCheckBox 也许简短,可是就如我已经指出了的,它会产生多得可怕的机械码。又如我们上一节中看到的 return 叙述,你以为它的机械码会有多长?
return ((int)atou(str + (*str == '+')));
将比较的结果加到一个指针上可能会产生不错机械码,如果你使用一个好的最佳化编译器,而且你的目的平台可以产生 0/1 的测试结果而不用分岔执行路线的话。如果你的环境不满足这些条件,那你的编译器将在暗中把这个比较式展开成 ?: 运算,并产生有如你写了个底下的叙述般的机械码:
return ((int)atou(str + ((*str == '+') ? 1 : 0)));
由于 ?: 运算只是把 if-else 叙述隐藏起来,你会得到比你把上头的东西写得成底下那个既明显又无聊而简单的版本要更糟糕的机械码:
if (*str == '+')          /* 如果有个正号 '+',跳过它。 */
   str++;
return ((int)atou(str));
当然还有别的方法可以最佳化这个程序。我看过一些程序员用了一个 if 叙述跟一个 || 运算子来取代两行的 if 叙述:
(*str != '+')  ||  str++;     /* 如果有个正号 '+',跳过它。*/
return ((int)atou(str));
这样的程序有效,因为 C 有着走评估快捷方式的规则,不过在一个 if 叙述中把程序塞进一行内部保证你会得到更好的机械码;如果你的编译器透过机械码组合的副作用来产生 0 或 1 的结果,那使用 || 可能会得到更糟的机械码结果。
一条简单的准则是用 || 来处理逻辑表达式,用 ?: 来处理条件表示式,用 if 来处理条件叙述。遵循这个撙哲可能十分无趣,可是你的程序大概会更有效率而更好维护。
如果你得到了致命的 " 一行搞定 " 的疾病(又称作 "APL 症状 " ) , 让你经常以奇怪的表示式来让 C 语言的程序代码全塞在一行里头,好好作一次瑜珈运动,深呼吸,然后再重复下面这句话, " 有效率的程序代码是可以写成好几行的。有效率的程序代码是可以写成好几行的 ...... 。 "
简短的 C 语言程序不保证产生有效率的机械码。
写程序不要轻浮
有些计算机专家没办法用日常英语写出技术文件来。他们不会说 " 这错误会当死你的系统 " ,而会说, " 这样的软件失误会造成系统失控或让系统停止执行 " 。这些专家使用像 " 原则程序检验 " 跟 " 失误分类法 " 这类他们认为是程序员日常用语的词汇。这些专家除了不能帮助读者解决问题,反而让读者掩埋在晦涩难懂的词语中。
技术写作者并不是唯一倾向让事情更混乱的人;有些程序员真的努力写着让人二丈金刚摸不着头脑的程序代码,认为这样只不过是让别人看不懂程序,却能让别人佩服他们的能力。例如,后头这函式怎么动作的?
void *memmove(void *pvTo, void *pvFrom, size_t size)
{
   byte *pbTo   = (byte a)pvTo;
   byte *pbFrom = (byte a)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 (pbTo > pbFrom)
       tailmove(pbTo, pbFrom, size);
   else
       headmove(pbTo, pbFrom, size);
       
   return (pvTo);
}
第一个例子看来不像是用合法的 C 语法写成的,可是它确实符合 C 的语法规定,而且有好些机会让编译器产生出比第二个例子更小的执行码。不过有多少程序员能理解第一个程序是怎么运作的?如果他们得维护那个程序,会发生什么事情?就算你把程序写对了,把程序写得很小,你也没帮助别人搞懂你在写什么,说不定你用汇编语言来手动最佳化那个程序还比较好些。
这里有另一个可以搞混许多程序员的例子:
while (expression)
{
   int i = 33;                 /* 宣告区域变量... */
   char str[20];
   
   .                  
   .                           /* 程序... */
   .
}
机智问答时间! i 在循环中每次都会被初始化,或是只有在循环进入时才会被初始化?你不用想就能答出正确答案吗?如果你不确定,你的公司真是间好公司-即使用 C 语言写程序的专家也要花一小段时间来想想 C 语言中初始化变量的规则。
如果我把程序改变一下呢?
while (expression)
{
   int i;                      /*      宣告区域变量... */
   char str[20];
   
   i = 33;                     /* 程序... */
   .                  
   .                                
   .
}
谁来维护程序?
在微软公司,一个人写作新程序代码的量直接正比于对开发中产品内部运作的了解程度;了解愈多的人就写愈多新程序代码,而比较少管程序的维护。当然,如果你对项目了解很少,你就得花许多时间来看别人写的程序,修正别人的错误,对现有功能加上少许区域性的改良。这样的安排合理,毕竟你如果不晓得一个项目里的东西是怎么写成的,你怎么能好好在项目中加上一个重要功能?
这样安排的不利面是,有如一般通则,经验老到的程序员写新程序而油菜鸟来维护程序。只有在经验老到的程序员了解他们得负起让维护程序的人能够维护程序的责任时,这样实际的安排才有用。
不要误解我的意思,我不是说你该把 C 语言的程序写得很幼稚,才能让菜鸟都看得懂你在写什么;那太蠢了。我要说的事,当你可以把程序写得很普通易懂的时候,你就应该避免将程序写得很艰深难懂。如果你把程序写得易于了解,新手也能够维护你的程序,而不会制造新的问题出来,你也不用一直跟人解释程序是怎么动作的了。
你对 i 在循环中每次都被设定成 33 有任何疑问吗?你团队中的其它程序员会质疑这样的写法吗?当然不会。
程序员们常常忘记他们的程序有两种读者:使用程序的顾客跟维护程序的程序员。会忘记顾客的程序员不多,可是从我经年阅读的程序中判断,我认为程序员常会忘掉第二种读者,那些帮他们维护程序的人。
你应该写出可维护的程序代码的观念并不新奇。程序员们知道他们应该写出这样的程序,可是他们并不完全明白,如果他们用的语言只有 C 的专家才看得懂,那他们的程序就真的不好维护。毕竟,可维护的程序代码就定义来讲,是能够让维护程序的程序员能够简单了解而且修改而不会制造任何问题的。无论他们应该作什么,这些维护程序员们通常都是项目中的新成员,而不是那些已经待了好一阵子的专家。
写程序时,不要忘了那些维护程序员们。
写出一般程序员看得懂得程序。
不要玩弄小把戏
我们已经看过数种有问题的程序写法,其中有许多第一眼看来很正常。不过如我们所知,在第二眼,或甚至第五眼,你可能都看不出来这些精巧的程序中有什么隐藏的问题存在。如果你发现自己写着连你自己都觉得在玩弄把戏的程序,停下来找寻另一种解决方式吧。如果你的程序让你觉得自己在玩弄把戏,你的直觉正在警告你有些东西不对劲。接受你的直觉,如果你发现自己认为一部份程序代码是很酷的把戏,你就是在对自己说有个算法虽然产生出正确的结果,却没有它本来所应该的写得清楚。也就是说,对你而言,程序中的错误也不会清楚显露出来。
真正精巧的是那些写得很无趣的程序。你会有更少问题,而维护程序员会喜欢你这么做。
快速回顾
�         如果你在处理不属于自己的资料,就不要对它写入,即使只是暂时性的写入。虽然你会认为读取资料应该总是安全的,记住从内存映对外围读取可能会危害到你的硬件装置。 ?
�         不要参考释放掉的内存。有太多方式会让对已释放内存的使用带来问题。
�         效率的诱惑会让人想把资料透过整体缓冲区或静态缓冲区传递,不过这种做法充满了危险。如果你写的函式对制造出只对呼叫者有用的数据,就将那些资料传给呼叫者,或者保证你不会意外改变那些资料。
�         不要依赖其它函式的特定实作来写作函式。我们看过的那个 FILL 子程序呼叫 CMOVE 的做法完全没保障。这样不合理的事情只适合出现在差劲的程序写法。
�         当你写程序时,用你的程序设计语言写出清楚精确的程序代码,因为别人可能会用到你的程序。避免用让人看不懂的程序写作语法,即使语言标准保证那样的写法可以正常动作。记住,标准是会改变的。
�         逻辑上, C 语言中有效率的表示式应该会产生出相似的有效率机械码。不过这条逻辑并不总是成立的。在你将明白易懂的多行 C 语言程序改写塞到一行中前,确定你会得到更好带来麻烦的机械码。即使你确定要这样做,记住区域性的效率增长很少带来显著的整体改善,而且通常不值得因此把程序搞得让人看不懂。
�         最后,不要用律师写作契约的方式写程序。如果一名普通的程序员不能看懂你的程序,那就是说你写得太复杂了;用简单的语言写法重写吧。
该想想的事
1.        C 语言程序写作者经常改变传给函式的参数。为何这样的做法不违背输入资料的写入权限?
2.         我已经提过底下那个 strFromUns 函式的主要缺陷-回想一下,它会将资料透过不受保障的缓冲区传回。除了这主要的问题之外, strDigits 的宣告方式有什么特别危险的地方吗?
3.        char *strFromUns(unsigned u)
4.        {
5.            static char *strDigits = "?????";  /* 5个字符 +
6.            char *pch;                          '\0' */
7.            
8.            /* u超出范围?请改用UlongToStr吧... */
9.            ASSERT(u <= 65535);
10.            
11.            /* 将strDigits中的数字从尾到头写入。 */
12.            pch = &strDigits[5];
13.            ASSERT(*pch == '\0');
14.            do
15.                a--pch = (u % 10) + '0';
16.            while ((u /= 10) > 0);
17.            
18.            return (pch);
}
19.         我在一份期刊上看过某个程序,我注意到一个使用 memset 函式来将三个区域变量清除为 0 的函式:
20.        void DoSomething(...)
21.        {
22.            int i;
23.            int j;
24.            int k;
25.            
26.            memset(&k, 0, 3*sizeof(int));  /* 将i,j跟k设定为0。 */
27.             .
28.             .
    .
这样的程序在某些编译器下会动作,可是你为何应该避免用这种做法?
29.         你的计算机可能会把操作系统的一部份存放在只读存储器中,为何直接跳过不必要的负担,略过系统接口而直接呼叫只读存储器中的子程序会带来危险?
30.        C 传统上允许程序员传入比函式期望收到的更少的参数。有些程序员用这个特性来最佳化不需要全部参数的呼叫。例如,
31.        .
32.             .
33.             .
34.            DoOperation(opNegAcc);    /* 不需要传入val。*/
35.             .
36.             .
37.             .
38.        
39.        void DoOperation(operation op, int val)
40.        {
41.            switch (op)
42.            {
43.            case opNegAcc:
44.                accumulator = -accumulator;
45.                break;
46.            
47.            case opAddVal:
48.                accumulator += val;
49.                break;
50.             .
51.             .
    .
虽然这样的最佳化有用,为何你应该避免这样做?
52.         底下的除错检查是正确的。为何它应该被重新写过?
ASSERT((f & 1) == f);
53.         看看另一个用下面程序代码的 memmove 版本:
((pbTo > pbFrom) ? tailmove : headmove)(pbTo, pbFrom, size);
你该怎么重写 memmove ,让它保留上头的效率而更易懂?
54.         底下的汇编程序说明常见的一种函式呼叫简略法。为何你用这种做法是在找麻烦?
55.        move  r0,#PRINTER
56.                call  Print+4
57.                .
58.                .
59.                .
60.        Print:  move  r0,#DISPLAY  ; (四个字节的指令)
61.                .                                                                    
62.                .                  ; r0 == 装置代号
       .
63.         底下的汇编程序码说明另一种偶尔出现的小技俩。这段程序代码跟上一个习题的程序代码一样,依赖 Print 程序的内部实作方式。你为何应该避免这样写?
64.        instClearR0 = 0x36A2          ; clear r0指令的十六进制码
65.                .
66.                .
67.                .
68.                call  Print+2         ; 输出到PRINTER
69.                .
70.                .
71.                .
72.        Print:  move  r0,#instClearR0 ; (四个字节的指令)
73.                comp  r0,#0           ; 0==PRINTER,非0 ==DISPLAY
74.                .
75.                .