首页  编辑  

危險的做法

Tags: /超级猛料/Book.凤毛麟角(电子书籍片断)/完美程式設計指南/   Date Created:

危險的做法

如果你把程序员放在悬崖上,给他一根绳子跟滑翔翼,你想他会用什么方法到悬崖下头去?他会用绳子爬下去,还是用滑翔翼飞下去?我不知道他用用哪一种方法,不过我打赌他绝对不会直接跳下去-那样子作太危险了。不过有时候,当程序员们在好几种可能的程序写法之间决定要采用哪一种做法时,他们往往只考虑到程序大小与执行速度,而完全忽略了那样作的危险性。如果在悬崖上的程序员忽视了危险的存在,而采用了到悬崖下最快的方式,你想会发生什么事?你会听到跳下去的程序员发出一声惨叫。

至少有两种让程序员们忽视危险性的存在的理由。

有时候,程序员们忽视危险的存在,因为他们盲目认为自己写出来的程序一定不会有问题。当然没人会说 " 有什么好猜的,我要写的不过是个 quicksort 子程序,我计划让程序里头有三个错误。 " 程序员们不会计划要写出有问题的程序,不过他们对程序出错也不会太吃惊。

不过我相信程序员们忽略了危险的存在的最主要理由,是因为没人交他们这样思考, " 这种做法有多危险? "" 这种写法有多危险? "" 有更安全表达出这个点子的方法吗? "" 这种做法有办法测试吗? " 思考这样的问题时,你就会丢开那种让你相信不管采用哪一种写法都能让你写出零错误程序的观念。也许你本来的观念有充分的根据,不过问题是你要花多久才写得出零错误程序呢?当你采用安全的写作方式时,你是不是只要花几小时或几天就写得出零错误程序?如果你忽视危险的存在而制造了要花时间找寻跟修正的一大堆错误,你是不是得花好几星期到好几月的时间才写得出零错误的程序呢?

我在本章中到处都会谈到由一些常见的程序写作方式而来的危险与如何削减,或甚至去除那些危险性的方法。

一个长整数资料型态有多长?

ANSI 委员会检查许多平台上使用的各种 C 语言开发环境后,他们发现 C 语言并不是个普遍以为的具有真正可移植性程序设计语言。不光是每个系统上的标准链接库都不统一,连前置处理器与语言本身都有重大的歧异存在。 ANSI 委员会标准化了大部分有问题的地方 , 让程序员们有个写出真正可移植到不同平台的程序代码的依循标准 , 可是他们却大大漏掉了一个重要的地方,资料型态的本质定义。 ANSI 标准中并没有集中定义 char int long 这些资料型态,反而将这些资料型态的细节交给编译器写作者去处理。

所以,一个符合 ANSI 标准的编译器可以有 32 位宽的整数跟有号的字符型态,而另一个一样符合标准的编译器则可以有 16 位宽的整数跟无号的字符型态。即使有这些深刻的不同处,这两个编译器还是可能符合 ANSI 标准的严格规范。

看一下这段程序:

char ch;           /* 宣告一个字符变量ch */

.

.

.

ch = 0xFF;

if (ch == 0xFF)

   .

   .

   .

我要问的是,这个 if 叙述里头的比较式能够正确评估出结果吗?

当然这个式子会被评估为 TRUE -你确定吗?正确答案是,你不知道;这问题的结果完全取决于你用的编译器而定。如果你的字符内定是无号的,这比较式就会成立。不过在一个将字符型态当成有号数处理的编译器上,在 80x86 680x0 微处理器上执行的编译器常见这种情形,这个比较式永远都不会成立,而原因就跟 C 语言变量值域扩展规则有关。

在前面的程序中,字符变量 ch 是跟一个整数 0xFF 进行比较。依据 C 语言变量域扩展规则,编译器必须先将 ch 当成一个整数,才能跟兼容的资料型态进行比较。如此一来,当 ch 是个有号数时,正负号扩展的结果会让它的值从 0xFF 变成 0xFFFF (假设你使用 16 位的整数型态),因而让比较结果不成立,虽然看起来这个比较式应该永远成立。

我把上头的程序当成是用来指出我的论点的范例,你可以说这例子不过是个不实际的程序代码片段而已,可是你底下这个常见的程序片段中也会碰到相同的问题:

char *pch;         /* 宣告pch */

.

.

.

if (*pch == 0xFF)

   .

   .

   .

字符型态并不是这类不良定义行为的唯一受害者;位字段的情形也差不多一样糟糕。底下的位字段型态的数值范围多大?

int reg : 3;

再一次的,你不知道。即使 reg 被定义成个整数型态,暗示说它是个有号数, reg 还是有可能依照你用的编译器不同而被当成有号数或无号数处理。你必须使用 signed int unsigned int 来清楚的将 reg 定义成你要的型态。

还有,一个短整数有多短?一个整数有多宽?一个长整数又多长? ANSI 标准没说,而是将这些通通丢给编译器写作者去决定。

我不想让你以为 ANSI 委员会的成员们不晓得这个资料型态的不良定义行为的情形,事实并不是那样。事实上,它们检查了许多 C 语言开发环境的实作后,归纳说,资料型态的宽度在各种不同编译器间都不一样,定义出一个严格的资料型态宽度规范将会让太多现有的程序代码都得重新改写过,那将违反 ANSI 委员会的一个指导原则:现有的程序也很重要。它们的目标不是建立一种更好的程序设计语言,而是将现有的东西标准化,不管可不可以,他们不会去破坏大部分现有程序代码依循的东西。

定下严格的资料型态规范也会违反 ANSI 委员会的另一个保留 C 语言原始精神的指导原则:即使牺牲可移植性也要让程序跑快点。如果一名实作者觉得有号的字符型态在特定机器上会更有效率,你在这机器上用的编译器就会有这样的设计。这种注意执行速度的原则也代表着实作者有权决定整数是 16 位或 32 位宽的,或任何其它对特定硬件 " 自然 " 的宽度,那也表示说你无从晓得位字段型态会是有号的还是无号的。

我在这里要说的是,这些基本的资料型态在规格上有漏洞,你可能在下一次编译器升级或换另一牌编译器、换到新的目的平台、跟其它小组与公司共享程序代码时,或是在你换个工作后,发现自己用的编译器的规矩全变了,而掉进这些洞中。

这并不意味着你不能安全的使用基本的资料型态。你还是可以安全的使用那些东西,不过要降低风险,你不应该假设你用的资料型态有任何 ANSI 标准没清楚指定的性质。

举例来说,只要你在字符型态变量中只用到有号跟无号数保证交集的 0 127 范围内的值,你就可以安全的使用字符型态的变量。所以底下的程序代码在任何编译器上都有效,因为它不预设变量域的范围大小:

char *strcpy(char *pchTo, char *pchFrom)

{

       char *pchStart = pchTo;

       

       while ((*pchTo++ = *pchFrom++) != '\0')

               {}

               

       return (pchStart);

}

而底下的程序则没办法在所有编译器上都正常动作:

/* strcmp - 比较两个字符串。

* 如果strLeft < strRight,就传回负值。

* 如果strLeft == strRight,就传回0,

* 否则在strLeft > strRight时传回正值。

*/

int strcmp(const char *strLeft, const char *strRight)

{

   for ( ; *strLeft == *strRight; strLeft++, strRight++)

   {

       if (*strLeft == '\0')    /* 字符串结尾? */

           return (0);

   }

   

   return ((*strLeft < *strRight) ? -1 : 1);

}

上头的程序代码不具可移植性,因为最后一行的比较有问题。当你使用 < 或其它使用正负号信息的运算子时,你就会让编译器产生出不具移植性的程序。 strcmp 修理起来很简单,把 strLeft strRight 宣告成无号的字符指针,或在比较式中转换一下资料型态就好了:

(*(unsigned char *)strLeft < *(unsigned char *)strRight)

记住一条值得抄下来贴在墙上的好规矩:别在表达式中使用没指定有无号的字符型态资料。位字段型态的变量也有相似的规矩,因为它们有着相似的问题,别用没指定有无号的位字段型态的资料。我说 " 别用 " ,因为位字段宣告前头的 int 本身就会误导人。

如果你读过 ANSI 标准,并保守的解释内容,你就可以得到一组良好定义而具备在三个最常见的数值系统上的不同编译器间都能安全使用的资料型态-这三种数值系统分别以一的补码、二的补码跟正负号直接标示的方式来表示二进制制系统中的正负数:

char  0到127

    signed char  -127(而不是-128)到127

  unsigned char  0到255

                 大小未知,但不小于8个位

                 

          short  -32767(而非-32768)到32767

   signed short  -32767到32767

 unsigned short  0到65535

                 大小未知,但不小于16个位

                 

            int  -32767(而非-32768)到32767

     signed int  -32767到32767

   unsigned int  0到65535

                 大小未知,但不小于16个位

                 

           long  -2147483647(而非-2147483648)到2147483647

    signed long  -2147483647到2147483647

  unsigned long  0到4294967295

                 大小未知,但不小于32个位

                 

        int i:n  0到2n-1-1

 signed int i:n  -(2n-1-1)到2n-1-1

unsigned int i:n  0到2n-1-1

                 大小未知,但至少有n个位

使用具可移植性的资料型态

一些程序员会在乎具可移植性的资料型态比起使用 " 自然 " 的资料型态要没效率。例如,整数型态应该是对目的平台在大小上有着最佳效益的资料型态,那代表着它的 " 自然 " 大小可以大于 16 个位而能够装下大于 32767 的值。

假设你的编译器使用 32 位的整数资料型态,而你有个值得范围在 0 40000 之间。你会因为机器能够有效使用整数资料型态来处理 40000 这个值而使用整数资料型态,还是因为会为了可移植性而使用长整数的资料型态?

这里有个相当狡猾的答案:如果你的机器使用 32 位整数型态,它大概也能使用 32 位长整数型态,而两种资料型态的程序代码就算不相同,也会是相似的(至少到现在这都是成立的),所以你应该使用长整数的资料型态。即使你担心在未来的某些你得支持的机器上处理长整数会没效率,你还是应该使用具有可移植性的资料型态。

好吧,也许你不需要担心程序的可移植性,不过你可以把这问题当成帮料理台选择新磁砖花色看待。如果你的行为与大多数人相似,你会挑选你喜欢的磁砖花色,并会考虑至少让将来买你房子的人能够忍受的样子。如此一来,你会得到一个你要的磁砖图样,同时不用在卖房子时把那些磁砖全部打掉重铺。在许多时候,写出具有可移植性的程序跟写出不具可移植性的程序一样容易,所以你可以将这个问题与选择料理台磁砖花色的问题一同看待。帮你自己省下未来改写程序的功夫-尽可能写出具可移植性的程序。

使用良好定义的资料型态。

你的资料会溢位吗?

最可恶的程序错误中有些会让程序看来似乎是对的,可是却因为一些潜在实作上的问题而出错。没指定有号与否的字符资料型态的问题就有这种特性,而底下这个初始化标准的 tolower 宏使用的检索表内容的程序也有同样情形:

#include <limits.h>              /* 带入UCHAR_MAX的定义。 */

.

.

.

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 永远小于或等于 UCHAR_MAX 而进入无穷循环的当机状态。

当你检查程序时,这种问题会有多容易看出来?

如果有变量发生借位的情形,你也会碰到类似的问题。底下是 memchr 函式的一种写法,在一块内存中找寻一个字符第一次出现的地方。如果它在内存块中找到了那个字符,它会传回一个指向那字符的指针;不然它会传回一个 NULL 指针。如同前面的 BuildToLowerTable ,底下这个 memchar 的程序代码看起来是对的,却会出错:

void *memchr(void *pv, unsigned char ch, size_t size)

{

   unsigned char *pch = (unsigned char a)pv;

   

   while (-size >= 0)

   {

       if (*pch == ch)

           return (pch);

       pch++;

   }

   

   return (NULL);

}

循环何时才会终止?当 size 小于等于 0 时,循环就会终止。可是这条件会成立吗?不会,因为 size 是个无号数值-当它的值是 0 时, --size 只会让它发生借位而变成 size_t 资料型态定义范围中最大的值。

这次这个借位的错误比起 BuildToLowerTable 中那个更糟糕,因为 memchr 只要在内存中找得到 ch ,就会正常无误的动作。即使它没找到要找的字符,它大概也不会把你的系统当掉-它只会继续在内存的某处找寻,直到找到并传回一个指针为止。这将会是个不好找出的错误。

我希望编译器会对这种没指定有号与否的字符资料型态与另外两种刚提到的问题发出警告讯息,可是我发现只有很少数编译器会对这些问题发出警告,虽然技术上没什么理由会做不到这点。除非编译器开发商了解编译器并不只是能够产生好的程序代码就好了,不然你就得靠自己来发掘这类溢位跟借位的问题。

一个好消息是,如果你如我在第四章中建议的去追踪程序的执行,你就抓得出这三种错误。你会看到 *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);      /* Unreverse the digits. */

}

这程序在使用二的补码机制表示负数的机器上,当 i 等于最小的负数时会出问题(如果是在 16 位的机器上,最小的负数就是 -32768 )。常见的解释是因为表达式 i=-i 中的 -i 会溢位超出整数资料型态的范围,这十分正确。不过真正的错误在于程序员写这个程序的方式。他没把想要的东西写出来;而只是写了个近似于他要的东西的程序。

他想要的是 " 如果 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 是最小负数 0x8000 ,你对它使用 " 翻转所有位再加一 " 的取负值方式还是会得到 0x8000 ,这对有号数来说就好象 -32768 一样,可是对无号数来说,这值却是 32768. 这些完全看你如何解释各个位的意义。定义上,将所有位翻转后再加一一定会在任何二的补码系统上给你一个数的负值,可是这完全得靠你将所有位的意义弄对才行。

不过,写得对不等于写得好。前面这程序让人觉得好象写错了的感觉。程序中假设 -32768 是个有效的整数值,可是并不是这样子-至少在你只使用具有可移植性的资料型态时并非如此。如果你同意 -32768 是个不具可移植性的整数值,你就可以在 IntToStr 中加上一个除错检查来避免整个问题的出现:

void IntToStr(int i, char *str)

{

   /* i超出范围?使用LongToStr... */

   ASSERT(i >= -32767  &&  i <= 32767);

   .

   .

   .

藉由使用这样的除错检查,你不只是避免了跟特定数值系统有关的古怪问题,也能提醒其它程序员写出更有可移植性的程序。

将设计方式尽可能精确的写出来。

愈不精确的写法愈有问题。

" 各行其事 " 的函式

我彻底检查过 Character Windows 的程序代码-那是一套微软设计给 DOS 程序用的文字模式窗口链接库-因为两个使用这程序的主要开发小组- Word Works 的小组-觉得程序本身笨重又拖泥带水,而且不稳定。我开始检查那程序时,我碰到一个程序员们没把自己设计的东西精确的写出来的例子-这些人也违反了写出零错误程序的其它指导原则。

不过,这是有些背景因素造成的结果。

Character Windows 的基本设计很简单:使用者将视讯显示当成一组窗口,每个窗口都有自己的子窗口。设计中,一个根窗口代表着整个显示区域,而这窗口有子窗口:菜单列,下拉式菜单,应用文件窗口,对话盒,跟其它的子窗口,而且每个窗口都可以有自己的子窗口。一个对话盒可能有 OK Cancel 案件的子窗口,也可能有个清单方块窗口,而其中又有个卷动列子窗口。这样子说,你应该能了解这种设计的方式吧?

为了表示这种阶层式窗口结构, Character Windows 使用了一个二元树状结构,让一个分支都指向子窗口,称这分支为 " 孩子 " ,而另一个分支指向拥有相同上层窗口的其它窗口,称为 " 兄弟 "

typedef struct WINDOW

{

   struct WINDOW *pwndChild;    /* 如果没有子窗口,这里内容就

   struct WINDOW *pwndSibling;     是NULL */

   char *strWndTitle;           /* 如果没有兄弟窗口,这里内容

   .                               就是NULL */

   .

   .

} window;                    /* 变量命名方式: wnd,*pwnd */

你翻开任何一本算法书籍都可以找到有效率的二元树状结构处理子程序,所以在检查 Character Windows 把子窗口放到二元树中的程序时,我有点被吓到。 Character Windows 中把程序写成像这样子:

/* pwndRootChildren是个指向如菜单列跟煮文件窗口等

* 最上层窗口串行的指针。

*/

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熜wnd = pwndParent->pwndChild;

           

           while (pwnd->pwndSibling != NULL)

                   pwnd = pwnd->pwndSibling;

           pwnd->pwndSibling = pwndNewBorn;

       }

   }

}

为何使用阶层式窗口?

如果你怀疑使用阶层式窗口有何好处,想想看:这种阶层式排列简化了如移动、隐藏与删除窗口等的动作。如果你搬移一个对话窗口时,里头的 OK Cancel 按键留在原处,那会如何?或者如果你隐藏了一个窗口,而里头的子窗口没被隐藏呢?这些都不是你要的效果。藉由子窗口的支持,你可以 " 搬移一个窗口 " 而晓得它底下的子窗口也会跟着搬动。

尽管这窗口结构被设计成二元树,它并没有被当成二元树来用。由于根窗口(代表显示区域的那一个窗口)永远不会有兄弟窗口,也不会有标题,而且它也不能被移动、隐藏或删除,使得整个窗口结构中唯一有意义的就是 pwndChild -指向菜单列跟应用程序的子窗口。这会让人觉得把宣告一个窗口结构是件多余的事,而将 wndRoot 由一个指向最上层窗口的单纯指针 pwndRootChildren 取代掉。

wndRoot 替换成一个指针会省下几个字节的资料空间,可是对花费的程序空间成本却很巨大。如 AddChild 般的子程序不能再只是处理一个单纯的二元树,而得处理两种不同的数据结构:最上层的窗口串行跟窗口内的树状结构。更糟的,每个使用窗口指针作为参数的函式-有很多函式都是如此-都得检查特别代表显示 " 窗口 " NULL 指针参数。难怪 Word Works 小组会对这烂东西表示关切。

我不提 AddChild 里头的问题,只针对设计方式本身讨论,不过我还是要指出这种的写法至少违反三条零错误程序写作的指导原则。你已经看到这些指导原则中的两条了:不要接受如 NULL 指针般的特殊用途参数,还有把原始设计忠实呈现而不要只是写出接近的东西。第三条指导原则我还没提过:努力让每个函式一次只完成它所要作的一件事。

这第三条指导原则表示什么?想想, AddChild 的功能就是在一个现存窗口中加上一个子窗口,可是程序中却有三个分开的窗口插入例程。常识告诉你说,如果你有三份程序而不是一份,你就更有可能会碰到错误。如果你发现自己写了一个函式,函式中你把同样的 " 工作 " 作了好几遍,停下来问问自己,你是否可以把同样的工作在一份程序里头就做好。

改善 AddChild 的第一步简单得很:拋弃最佳化的方式,把原始设计忠实呈现出来。要做到这点,你把 pwndRootChildren 替换成一个指向代表显示区域的窗口结构的指针 pwndDisplay 。要加上最上层窗口时,传入 pwndDisplay ,而不要把 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 (与其它用到那个古怪的树状结构的函式),还修好了本来程序中最上层窗口被逆向插入的问题。够有趣的是,这问题在 Character Windows 中被逆向处理最上层窗口的权宜之计修好了-不过,这让整个程序变得更肥大。

把工作一气喝成。

不要用多余的 if && 跟反面叙述

新一版的 AddChild 比本来的好,可是还是把它要作的工作处理了两次。你脑袋中的警铃应该会被那个 if 叙述给弄得响声大作,那代表着这程序中同样的事情被处理了两次,虽然是以不同方式处理的。式的,有时你当然需要用 if 叙述来处理一些条件状况,可是许多时候 if 叙述只是草率的设计或写法的结果-迅速完成一个充满例外的写法比起停下来想出一个完美的做法当然要简单多了。

举例来说,要处理兄弟窗口链上的窗口,你会使用 window 结构跟里头指向 " 下个窗口 " 的指针,不过有两种处理窗口链的方式:你可以传入指向窗口结构的指针,在循环中逐窗处理,或是传给循环一个指向 " 下个窗口 " 的指针,然后处理各个指针。你可以用以窗口为主的算法,或以指针为主的算法,现在 AddChild 的写法就是以窗口为主。

不过当你使用指针为主的方式时,你永远都在用一个指向下个窗口的指针,不管这 " 下个 " 窗口是上层窗口的子窗口指针,或是指向它的兄弟窗口的指针。这让你消除了在窗口为主的算法中需要出现的 if 叙述,因为这么做就不存在判断窗口种类的特例了。如果你把本来的写法跟底下的程序比较,就更容易了解之间的差别了:

void AddChild(window *pwndParent, window *pwndNewBorn)

{

   window **ppwndNext;

   

   /* 新窗口中可以有子窗口,但是不能有兄弟窗口的存在... */

   ASSERT(pwndNewBorn->pwndSibling == NULL);

   

   /*  使用指针为主的算法处理兄弟窗口链。我们把ppwndNext指向

       pwndParent->pwndChild,因为后者是串行中第一个"下个视

       窗"的指针。

    */

   ppwndNext = &pwndParent->pwndChild;

   

   while (*ppwndNext != NULL)

       ppwndNext = &(*ppwndNext)->pwndSibling;

       

   *ppwndNext = pwndNewBorn;

}

上头的程序如果看来有点眼熟,也不用吃惊,它本来就应该很眼熟。毕竟,这只是因为不须任何特例程序代码来处理空串行而闻名的古典 " 空头 " 连结串行插入算法的一个小变形而已。

如果你在乎这一版的 AddChild 有没违反我先前 " 精确写出符合设计精神的程序 " 的建议,你不用担心这一点。这程序也许不如你正常所想的实作连结串行,可是它实际上忠实呈现了一种连结串行处理的写法。将一副眼镜当成透镜看待-你认为那是发散透镜还是个聚焦透镜?两种都有可能,完全取决于你怎么看它。

如果你担心程序效率的问题,想想看: AddChild 这个最后版本会比只前任何版本产生出小多了的程序代码。即使是循环中的程序,也跟之前版本产生的程序代码差不多-或甚至可能更好。不要以为那些多出来的 *s &s 会让这循环跑得更慢-事实并非如此,你可以自己编译两种版本来比较一下。

去掉多余的 if 叙述吧。

?: 运算子也算是 if 叙述

C 语言程序员们常会忘记 ?: 运算子也是个伪装了的 if-else 叙述;程序员们为何使用 ?: 而不明白写出 if-else 并没有很合理的理由。我在 Excel 的对话处理程序中碰到一个好例子。底下函式中包含的程序决定一个复选框的 " 下一个状态 "

/* uCycleCheckBox - 传回一个复选框的下个状态。

*  

* 给定现有的设定uCur,传回下个应该出现的复选框状态。

* 这函式处理了在0与1之间切换的两态复选框跟在2,3,4,2...

* 之间循环的三态复选框。

*/

       

unsigned uCycleCheckBox(unsigned uCur)

{

   return ((uCur<=1) ? (uCur?0:1) : (uCur==4)?2:(uCur+1));

}

我跟没多想想就用巢状 ?: 写出上头那种 uCycleCheckBox 的程序员们合作过,不过他们在将自己的名字放进使用底下那个使用清楚的 if 叙述的程序版本开发者名单之前,就改写 COBOL 程序去了。大部分编译器,即使是最佳化作得最好的那些,对这两个版本的写法都会产生出接近相同的程序代码。

unsigned 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

   {

       uRet = 2;        /* 处理2,3,4,2,...的循环。 */

       if (uCur != 4)

           uRet = uCur+1;

   }

   return (uRet);

}

好好看看这三个版本的 uCycleCheckBox. 即使你知道它们的功用正如预期的,你第一眼看到这三种写法时,你看得出来吗?如果我问你,我传入 3 会得到什么结果,你能简单看出答案是 4 吗?我看不出来。对维护两个简单状态循环的函式来说,这些写法就好象车子里用过了的机油般一样乌黑而难以理解。

使用 ?: 运算子的问题是,尽管它简单易用,似乎可以产生出最佳化的程序代码,却会让程序员以为一个程序没有更好的写法了。更糟糕的,程序员们会把 if 版本的程序缩减成 ?: 版本的写法,以取得实际上一点也不好的 " 更好 " 解决方案,就好象把一张一百块钱美金的钞票换成一万分美金的零钱,你就以为自己有更多钱了一样。如果这些程序员花时间想出一个不同的算法,而不是把相同算法用些微不一样的写法表示出来,他们可能会想出底下这种简单的写法:

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 语言程序设计书籍中常见的写法-虽然那些书中管它叫做 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 叙述就好了?

有时重复的测试动作并不是在 if 叙述中,而是在 for while 叙述的条件式中,像 memchr 的另一种可能写法:

void *memchr(void *pv, unsigned char ch, size_t size)

{

   unsigned char *pch = (unsigned char a)pv;

   unsigned char *pchEnd = pch + size;

   

   while (pch < pchEnd  &&  *pch != ch)

       pch++;

   return ((pch < pchEnd) ? pch : NULL);

}

跟底下这个比起来:

void *memchr(void *pv, unsigned char ch, size_t size)

{

   unsigned char *pch = (unsigned char a)pv;

   unsigned char *pchEnd = pch + size;

   

   while (pch < pchEnd)

   {

       if (*pch == ch)

           return (pch);

       pch++;

   }

   

   return (NULL);

}

你觉得哪一种比较好?是将 pch pchEnd 比较两次的第一个,还是只比较 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 溢位问题吗?你在这里犯了同样的错误。 pcEnd 现在也许指向合法的内存地址,可是由于 pch 每次都会跳到溢位了的 pchEnd+1 ,而让循环永不终止的执行下去。

当你有个指针跟计数器时,安全处理一个内存范围的做法是使用计数器作为控制条件:

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 -的写法会让程序变大而且比用 pchend 的写法慢,因为 size 必须在递减以前先保留数值(为了跟 0 进行比较)。事实是,大部分编译器对使用 size -写法的版本产生的程序代码确实跑得要快些,而且更小;你得到的程序代码取决于编译器如何配置缓存器的使用方式,而在 80x86 的编译器上,还决定于你使用的内存模式。不过这两种因素造成的程序大小与执行速度的差异可以说是可以忽略的。

这边又有一个稍早我提到过的语法习惯的问题。有些程序员会劝你把循环表示式中的 size-- 写成 --size

while (--size >= 0)

   .

   .

   .

这样改的理由是这样的写法应该不会产生出更差的程序代码,有时还会产生更好的程序代码。这建议的唯一问题在于,如果你盲目照著作,臭虫就会像秃鹰般侵袭你的程序。

为什么?

嗯,对于初学者来说,如果 size 是个无号数(像 memchr 中那样),这种写法永远不会动作,因为无号数在定义上,永远大于或等于 0 ,这循环会一直跑下去。糟吧?

对有号数来说,这种写法也不一定会正常动作。如果 size 是个整数,而在进入循环时的内容是最小的负数 INT_MIN ,会发生什么事? size 会先被递减而溢位,让循环跑许多遍,而非完全不动作。这也很糟糕吧?

size-- > 0 的写法无论你如何宣告 size 的资料型态都会正常动作-这是个不起眼却很重要的差别。

程序员们使用 --size > =0 写法的唯一理由是想获得一些执行效率,可是让我们看看这理由成不成立。如果你真的碰到执行速度上的问题,作这样微小的改善就好象拿指甲刀去修草坪一样-你当然可以这么做,不过你剪下去也看不出什么效果罢了。如果你没碰到执行速度上的问题,冒那种风险作什么哩?就好象每把修剪草皮用的刀子一不一样长并不重要,让每一行程序都最佳化得有效率也不是重要的。重要的是,程序的整体效率。

对有些程序员来说,放弃任何一点可以改进程序效率的机会都像罪恶般。不过,如你在本书中看到的,我的想法是有系统的使用安全的方式跟写法来降低风险,即使那样也许比较没效率些。使用者不会注意到你哪边慢了几个微处理器执行周期,却会注意到你在省下那些执行时间时带来的问题。就投资报酬率来讲,不得冒那种风险。

另一种 " 浪费效率 " 的危险语法是使用位运算来代替对二的乘方作乘、除法跟余数运算。例如你在第二章中见过的那个最快版的 memset 中有这几行:

pb = (byte *)longfill((long *)pb, l, size / 4);

size = size % 4;

我确定有些程序员看过那段程序后,会认为那段程序 " 多么没效率啊。 " 那些程序员就是那些会把除法跟余数运算写成位运算的人:

pb = (byte *)longfill((long *)pb, l, size >> 2);

size = size & 3;

使用位运算比除法或余数运算在许多机器上要快很多,可是无号数值对二的乘方的除法或余数运算也确实真的没效率-就像加 0 或乘 1 一样-即使最笨的商用编译器也常会帮你将这些表示式最佳化成你的机器上更有效率的程序代码。

不过,有号数表达式呢?这种明白写出的最佳化值得吗?是,也不是。

假设你有个像这样的有号数表达式:

midpoint = (upper + lower) / 2;

一个二的补码系统上的编译器不会把那个除法最佳化成位位移,因为位移一个负数会带来跟有号数除法不同的结果。不过如果你知道 upper+lower 永远是正的,你就可以把表达式用位位移改写得更快:

midpoint = (upper + lower) >> 1;

所以,是的,明确的最佳化一个有号数表达式有用。问题是,这样子作是最好的方式吗?答案,不。把这样的表达式的资料型态转换一下,就跟位位移的写法一样快而更安全了。试试这个:

midpoint = (unsigned)(upper + lower) / 2;

这点的构想是不要告诉编译器该怎么做,而让它拥有最佳化所需的信息。告诉编译器那个和是个无号数,你就确定它会以位位移的方式处理。比较两种方式,哪一种比较好懂?哪一种比较具有可移植性?

多年来,我找出了许多程序员用位位移来代替不保证为正的有号数除法所造成的错误,找出了程序员们把位移错方向的错误,找出了程序员们用错位移计数的错误。我甚至碰到过有程序员粗心犯下将 a=b+c/4 写成 a=b+c>>2 的运算子顺序错误,我可不记得看过有程序员把除以 4 / 4 打错过。

C 语言中还有许多危险的语法习惯。找出你使用的这些危险用法的最佳方式就是检查你碰到的每个问题,反省先前我告诉你的: " 我怎样避免这种错误? " 你很快就会找出一串自己应该避免的危险语法。

避免使用危险的语法习惯。

Don't Overestimate the Cost

微软是苹果计算机公司在 1984 年推出麦金塔计算机时,少数几家准备好麦金塔应用程序的公司。明显的,最先推出麦金塔上的产品对微软公司有好处,可是也有缺点。为了在麦金塔上市时推出产品,微软得在麦金塔还在开发时就开始发展产品。结果,微软的程序员们有时得抄些快捷方式来让程序在开发中的麦金塔计算机上能够动作。直到苹果方面对麦金塔的操作系统作了第一次大改版,问题出来了,简单说来就是:苹果要求微软拿掉那些过时的快捷方式来符合如 Inside Macintosh 书中所提到的最新操作系统规范。

Microsoft Excel 中拿掉其中一个早期的快捷方式写法代表着将一个关键的手写最佳化汇编语言子程序改写过,而改写后的程序要多花 12 个微处理器周期来执行。由于那个子程序很重要,对这函式该不该修改的讨论就开始了,想照着苹果订出的规范作的跟那些追求速度的人分成了两派。

最后,一名程序员在函式中放了暂时的计数器,并让 Excel 跑了三个小时的严格测试,看看这函式到底被呼叫了多少次。这函式被呼叫的次数相当高: 76,000 次。不过即使被呼叫了那么多次,多出来 12 个微处理器周期的改写函式跑了 76,000 遍也只在这三小时的测试中多花 0.1 秒而已,而这是假设你在苹果计算机推出的最慢一种麦金塔计算机上执行这个测试。有了这些发现,程序就被改写了。

这只是另一个说明担心区域效率的问题很少有用处的例子。如果你在乎执行效率,把焦点放在整体跟算法效率上,那你也许可以看到努力后明显改善的结果。

不协调,程序的麻烦

看看底下这程序,里头包含一种最容易出现在你程序中的错误:运算子顺序问题。

word = high<<8 + low;

这程序用来将两个 8 位的字节拼组成一个 16 位的位字组,不过 + 的运算顺序比位移位运算要高,结果就达不到这程序所要作的-它会把 high 位移 8+low 个位。这种错误是可以理解的,因为程序员们一班不会把位运算跟数值运算子混用。不过当你可以把两种运算分开写时,为什么要把它们混在一起写呢?

word = high<<8 ? low;      /* 位运算的结果 */

word = higha256 + low;     /* 数值运算的结果 */

这些例子会比本来那个难懂吗?它们会比第一个没效率吗?当然不。不过它们之间有个重大差异:两种做法都是对的。

当程序员们写出只有一种运算方式的表达式时,他们更容易写出零错误程序,因为他们直觉上晓得每一种运算方式的运算顺序;当然,有些例外,不过就规则上来说,那是正确的。你认识多少程序员会写这样:

midpoint = upper + lower / 2;

而期望加法会比除法慢执行?

程序员们似乎也不会忘记位运算子之间的计算顺序-我想是因为他们还计得在逻辑概念课程中是怎么计算 f(A,B,C)=?B+C 的。大部分程序员知道位运算子的顺序由高到低是 ~ & | ,而不用想太多就可以挤掉 ~ & 之间的移位运算。

程序员们易于了解不同型态运算子类型内各运算子的顺序,但混用运算子时,直到碰到麻烦,他们才会晓得自己不晓得不同类型运算子间的顺序。所以第一准则是不要不必要的混用运算子。第二准则是如果你必须混用不同类型的运算子,用括号分隔开来。

你已经看过第一准则如何让你免于出错,看看第一章中第一个习题中那个 white 循环,你就晓得第二准则如何让你免于出错了:

while (ch=getchar() != EOF)

   .

   .

   .

这循环混合了一个指派运算子跟一个比较运算子,而产生运算顺序错误。你可以把循环改写成不混合的形式来避开问题,不过结果看来很可怕:

do

{

   ch = getchar();

   if (ch == EOF)

       break;

   .

   .

   .

} while (TRUE);

在这例子中,最好忽略第一准则,而用第二准则中提到的括号来分隔运算子:

while ((ch=getchar()) != EOF)

   .

   .

   .

非必要不混用不同类型的运算子。

如果你必须混用运算子,就用括号分隔不同运算。

不要查了!

在放入括号时,有些程序员会检查运算顺序表看看有没必要放入这些括号,如果没必要,他们就不放括号。如果你是这类型的程序员,把这段讯息贴在你的屏幕上:如果得查看文件,你的程序就不够明显易懂;让它更容易看得懂吧。如果这代表说你得在技术上不需要括号的地方加上括号,那又如何?明显易懂比只是写得正确更重要,这样看你程序的人也不用再去查一下你写的是什么意思。这个准则比括号带来的好处更多,当你查看某个东西的细节时,值得花时间思索这个问题。

不要跟失误状况扯在一起

第五章 中,我提到过如果你的函式会传回错误状态,就容易让程序员们不正确处理或忽略这些错误状态。我建议你将函式设计成不会传回错误的形式。在本章中,我要转个弯说,不要呼叫会传回错误的函式。那样子,你就不会处理错或是忽略别人写的函式传回来的错误状况。有时你别无选择,而那些时候,你就得好好追踪一下你的错误处理程序有没正确执行了。

我要强调一点建议:如果你的程序中不断处理相同的错误状况,就把那个错误处理的程序分离出来。这是每个程序员都晓得的最简单做法,会将错误处理简化到子程序中去作。这样子不错,不过有时你能作得更好些。

假设 Character Windows 有六处改变窗口名称的地方。底下的程序会在取得足够内存来容纳新名称时改变窗口标题;不然它会留着本来的窗口标题,并试着把错误处理掉:

if (fResizeMemory(&pwnd->strWndTitle, strlen(strNewTitle)+1))

   strcpy(pwnd->strWndTitle, strNewTitle);

else

   /* 没办法配置窗口标题所需的内存... */

问题在于如何处理那些错误?告诉使用者,错误发生了?忽略程序的要求,静静的保持原状?拿截短了的新标题盖过原来的窗口标题?没有一种理想的做法,尤其这程序本身只是一个通用链接库的一部份。

这只是你不要程序出现错误状况的一种例子而已。你要窗口的标题永远可被更换,你当然作得到这一点。

前面程序中的问题在于你没保证留下足够的内存来存放新的窗口标题。这解决起来很简单,你只要多配置些内存就好了。例在典型的 Character Windows 程序中,只有少数窗口的名称会改变,而这些窗口的标题即使在最长状态下占用的内存都不多。你配置内存时不要只配置跟现在标题字符串一样长的内存,而是一次配置好足够装下最长标题字符串的内存量。于是改变窗口标题就成了简单的字符串复制工作了:

strcpy(pwnd->strWndTitle, strNewTitle);

更棒的,你可以将这实作细节隐藏在 RenameWindow 函式中,而用除错检查来核对已配置的窗口标题内存块是不是足够装下任何可能长度的窗口标题:

void RenameWindow(window *pwnd, char *strNewTitle)

{

   ASSERT(fValidWindow(pwnd));

   ASSERT(strNewTitle != NULL);

   

   ASSERT(fValidPointer(pwnd->strWndTitle, sizeMaxWndTitle));

   strcpy(pwnd->strWndTitle, strNewTitle);

}

这种做法的明显缺失是浪费内存。不过同时,你回收了错误处理程序使用掉的程序代码空间。对你碰到的各种状况,你的任务是衡量资料空间跟程序代码空间的使用情形,决定何者较重要。

避免呼叫会传回错误的函式。

应付风险

现在,你应该相当了解我所说危险的程序设计方式是怎样子了。本章中所有论点都集中在拿能够产生出差不多的程序大小与执行速度而较不易于引发错误的方式来取代危险的程序写作方式。

不过我要说的并不局限于本书所提到的那些而已。好好检查一下你自己写程序的方式,你有彻底思索过你的程序写作习惯吗?或者你只是从别的程序员那边学到这些写作方式呢?刚入门的程序员们会认为使用位位移来作除法是一种旁门左道,可是经验老到的程序员会认为那种技巧是相当明显,作起来不用多想的程序最佳化方式。不过他们应该用这种程序最佳化方式吗?谁的观念才是真正正确的呢?

快速回顾

        小心选择使用的资料型态。即使 ANSI 标准要求所有实作支持 char int long 跟其它资料型态,标准中并没有严格定义这些资料型态的内容。只用标准中保证支持的资料型态规格,避开那些不具可移植性的问题。

        记住一点,即使你的算法正确,你还是有可能在缺乏理想特性的硬件上碰到问题。对此,你应该永远检查你的计算结果,并测试你的资料型态有无溢位或借位的情形。

        让程序写法忠于原始设计。最容易制造潜藏问题的方式就是再写程序时抄不应该抄的快捷方式。

        每个函式都应该有个良好定义了的工作,此外,他还应该只有一种完成工作的方式。如果同个程序代码不管输入状态如何都会执行,你就降低了找不出错误的机率。

        if 叙述在你也许作了些不必要的工作时,是一种相当良好的警告讯息。反省 " 我该怎么改变设计来去除特例 " ,努力消除每一个程序中不必要的 if 叙述。有时你可能得更动数据结构的设计,有时你得改变处理资料的方式。记住,眼镜镜片是凸透镜还是凹透镜全看你怎么使用它。

        不要忘了 if 叙述有时会隐藏在 while for 循环叙述的控制条件式中。 ?: 运算子也是另一种 if 叙述。

        提防危险的惯用语法-永远注意如何使用差不多但更安全的写法。留心那些据说能让你得到更加执行效能的程序写法。很少有什么程序写法能让一小段程序在整体效率上有显著的提升作用,而且随之而来的额外风险往往不值得。

        当你写表达式时,试着不要混用不同类型的运算子。如果你必须混用运算子,就用括号来隔开不同的运算。

        错误处理算是特例中的特例。如果可能,避免呼叫会执行失败的函式。如果你必须呼叫一个会传回错误状态的函式,试着将错误处理函式区域化-这样子可以在错误处理程序中增加找出错误的机率。

        有时候,只要保证你要作的事情一定会完成,就可以把常见的错误处理动作消除掉。那也许意味着在程序初始化时将错误处理掉,或是改变你程序的设计方式。

该想想的事

1.        " 普通 " 单位元字段的可移植性值域范围多大?

2.         函式如何将布尔( boolean )值如 " 普通 " 单位元字段传回?

3.         有一次我想把 AddChild 中的那个 pwndRootChildren 改成 pwndDisplay. 如此一来我就可以不用指向一块已配置好的 window 结构的 pwndDisplay ,而只要宣告一个叫做 wndDisplay window 型态整体数据结构就好了。虽然那样子应该可行,你觉得我为什么不那样子作?

4.         一名程序员偶尔会问,他或她为了执行效率,是不是应该把一个这样的循环

5.        while (expression)

6.        {

7.            A;

8.            if (f)      /* f是个常数表示式。 */

9.                B;

10.            else

11.                C;

12.            D;

}

改写成这样

if (f)

   while (expression)

   {

       A;

       B;

       D;

   }

else

   while (expression)

   {

       A;

       C;

       D;

   }

其中 A D 表示两堆叙述。第二种写法也许比较快,不过跟原来的写法比较起来,有多危险呢?

13.         如果你看过 ANSI 标准,你会发现有好几个有着近乎相同参数的函式-例如,

int strcmp(const char *s1, const char *s2);

为何使用这样相似的参数名称是危险的?你该如何消除这样的风险?

14.         我说明过为何使用如下的循环条件式是危险的

while (pch++ <= pchEnd)

不过为何类似的倒数循环也是危险的呢?

while (pch- >= pchStart)

15.         为了效率或简洁,有些程序员抄如下的快捷方式。为何你应该避免那样作?

16.        a.使用printf(str); 而非printf(%s",str);

17.        

18.        b.使用f=1-f; 而非f=!f;

19.        

20.        c.使用多次指派叙述,如

21.        

22.        int ch;            /* ch必须 是个整数。 */

23.        .

24.        .

25.        .

26.        ch = *str++ = getchar();

27.        .

28.        .

.

而非使用两个分开的指派叙述。

29.         比较 tolower uCycleCheckBox 与第二章中使用的查表法,你觉得这些查表法的优缺点为何?

30.         假设你的编译器不会自动帮与二的乘方数值的运算使用位运算,在不考虑可移植性的问题跟风险的情形下,为何你还是应该避免使用位位移跟 & 来作明确的最佳化?

31.         程序设计的一条金科玉律是决不毁损使用者的资料。假设为了存入使用者的档案,你得先成功的配置一个暂存数据缓冲区。你会怎样确保内存不够时,使用者的资料一样能被储存起来?

学习计划:

将你想象得到的危险惯用语法全列出来- switch ,任意使用 goto ,多次评估同样的宏参数,等等方式-将这些用法的优缺点写下来。然后,对列出来的每个项目,决定一下在何种状态下你愿意承担随之而来的风险去使用那种写法。