荔园在线

荔园之美,在春之萌芽,在夏之绽放,在秋之收获,在冬之沉淀

[回到开始] [上一篇][下一篇]


发信人: jek (Super Like Cat), 信区: Program
标  题: 完美程式設計指南--危險的做法
发信站: 荔园晨风BBS站 (Thu Apr  4 07:00:23 2002), 转信

如果你把程式員放在懸崖上,給他一根繩子跟滑翔翼,你想他會用什麼方法到懸崖
下頭去?他會用繩子爬下去,還是用滑翔翼飛下去?我不知道他用用哪一種方法,
不過我打賭他絕對不會直接跳下去-那樣子作太危險了。不過有時候,當程式員們
在好幾種可能的程式寫法之間決定要採用哪一種做法時,他們往往只考慮到程式大
小與執行速度,而完全忽略了那樣作的危險性。如果在懸崖上的程式員忽視了危險
的存在,而採用了到懸崖下最快的方式,你想會發生什麼事?你會聽到跳下去的程
式員發出一聲慘叫。

至少有兩種讓程式員們忽視危險性的存在的理由。

有時候,程式員們忽視危險的存在,因為他們盲目認為自己寫出來的程式一定不會
有問題。當然沒人會說"有什麼好猜的,我要寫的不過是個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敘述。

提防危險的慣用語法-永遠注意如何使用差不多但更安全的寫法。留心那些據說能
讓你得到更加執行效能的程式寫法。很少有什麼程式寫法能讓一小段程式在整體效
率上有顯著的提昇作用,而且隨之而來的額外風險往往不值得。

當你寫運算式時,試著不要混用不同類型的運算子。如果你必須混用運算子,就用
括號來隔開不同的運算。

錯誤處理算是特例中的特例。如果可能,避免呼叫會執行失敗的函式。如果你必須
呼叫一個會傳回錯誤狀態的函式,試著將錯誤處理函式區域化-這樣子可以在錯誤
處理程式中增加找出錯誤的機率。

有時候,只要保證你要作的事情一定會完成,就可以把常見的錯誤處理動作消除掉
。那也許意味著在程式初始化時將錯誤處理掉,或是改變你程式的設計方式。


------------------------------------------------------------------------
--------

------------------------------------------------------------------------
--------
該想想的事
"普通"單位元欄位的可攜性值域範圍多大?
函式如何將布林(boolean)值如"普通"單位元欄位傳回?
有一次我想把AddChild中的那個pwndRootChildren改成pwndDisplay. 如此一來我
就可以不用指向一塊已配置好的window結構的pwndDisplay,而只要宣告一個叫做
wndDisplay的window型態整體資料結構就好了。雖然那樣子應該可行,你覺得我為
什麼不那樣子作?
一名程式員偶爾會問,他或她為了執行效率,是不是應該把一個這樣的迴圈

while (expression)
{
    A;
    if (f)      /* f是個常數表示式。 */
        B;
    else
        C;
    D;
}
改寫成這樣



if (f)
    while (expression)
    {
        A;
        B;
        D;
    }
else
    while (expression)
    {
        A;
        C;
        D;
    }
其中A跟D表示兩堆敘述。第二種寫法也許比較快,不過跟原來的寫法比較起來,有
多危險呢?

如果你看過ANSI標準,你會發現有好幾個有著近乎相同參數的函式-例如,

int strcmp(const char *s1, const char *s2);
為何使用這樣相似的參數名稱是危險的?你該如何消除這樣的風險?

我說明過為何使用如下的迴圈條件式是危險的

while (pch++ <= pchEnd)
不過為何類似的倒數迴圈也是危險的呢?



while (pch- >= pchStart)
為了效率或簡潔,有些程式員抄如下的捷徑。為何你應該避免那樣作?

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

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

c.使用多次指派敘述,如

int ch;            /* ch必須 是個整數。 */
.
.
.
ch = *str++ = getchar();
.
.
.
而非使用兩個分開的指派敘述。

比較tolower,uCycleCheckBox與第二章中使用的查表法,你覺得這些查表法的優
缺點為何?
假設你的編譯器不會自動幫與二的乘方數值的運算使用位元運算,在不考慮可攜性
的問題跟風險的情形下,為何你還是應該避免使用位元位移跟&來作明確的最佳化

程式設計的一條金科玉律是決不毀損使用者的資料。假設為了存入使用者的檔案,
你得先成功的配置一個暫存資料緩衝區。你會怎樣確保記憶體不夠時,使用者的資
料一樣能被儲存起來?

------------------------------------------------------------------------
--------

------------------------------------------------------------------------
--------
學習計劃:
將你想像得到的危險慣用語法全列出來-switch,任意使用goto,多次評估同樣的
巨集參數,等等方式-將這些用法的優缺點寫下來。然後,對列出來的每個項目,
決定一下在何種狀態下你願意承擔隨之而來的風險去使用那種寫法。

--
 === I love Puss forever ===

※ 来源:·荔园晨风BBS站 bbs.szu.edu.cn·[FROM: 192.168.1.241]


[回到开始] [上一篇][下一篇]

荔园在线首页 友情链接:深圳大学 深大招生 荔园晨风BBS S-Term软件 网络书店