CSDN博客

img tom255

完美程序设计指南

发表于2001/5/24 13:58:00  663人阅读

完美程式設計指南 視窗縮放
自我維護 加入書架 加入書籤 上一章 下一章 


加入書架
加入書籤
上一章
下一章

用編譯器來自動抓蟲是很棒的事,但是我打賭,如果你檢查過程式專案中抓到的那些臭蟲,你會發現編譯器只抓到了其中一小部份。我打賭,如果你將臭蟲隔離開來,你會發現程式現在大概會執行得很正常。

還記得下面這個第一章中的程式片段嗎?

strCopy = memcpy(malloc(length), str, length);

這程式在任何狀態下都會正常運作,除非malloc配置記憶體失敗。當記憶體配置失敗時,malloc會傳給memcpy一個NULL指標,而memcpy沒辦法處理這種狀況。如果你夠幸運,在你推出這個產品之前,你就會看到這個系統當掉;否則,你的顧客也會碰到程式當掉的災難。1

編譯器抓不到像這樣的錯誤,也沒有編譯器能夠幫你抓到演算法中的錯誤,檢驗你的假設,或在資料傳遞時進行一般性的查核工作。

找尋這種錯誤是困難的事情,需要一名有技巧的程式員或測試人員整個把它們挖出來。不過要自動找到這類錯誤是容易的事情,如果你知道怎麼做的話。

兩個版本的故事

讓我們更進一步,看看你該怎麼抓到像上頭那個memcpy敘述中的錯誤。最簡單的解決辦法就是讓memcpy檢查NULL指標是不是被當成了參數使用,如果是,就丟個錯誤訊息出來,並中止程式執行。底下就是我們自己改良過的新版memcpy:

/* 複製一段非重疊的記憶體。 */
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
    byte *pbTo   = (byte *)pvTo;
    byte *pbFrom = (byte *)pvFrom;
    
    if (pvTo == NULL  ||  pvFrom == NULL)
    {
        fprintf(stderr, "Bad args in memcpy/n");
        abort();
    }
    
    while (size- > 0)
    
        *pbTo++ = *pbFrom++;
    return (pvTo);
}

使用這個函式,沒人會漏掉將NULL指標傳給memcpy函式的錯誤。剩下來的問題只是這種測試方式把程式變大而且變慢了些。如果你覺得這是另一個有醫比沒醫更糟糕的狀況,我想你是對的;這種測試方式並不實用。這時C語言前置處理器就派上用場了。

如果你有兩個版本的程式,結果會怎樣?一個發行版本又快又好,另一個包含額外檢查碼的版本則又胖又慢。你可以在同一份原始碼中維護兩個版本的程式,只要使用C語言的前置處理器來條件性的加入或移除檢查程式碼就好了。2

舉例來說,你可以讓NULL指標的測試只有在DEBUG符號被定義時才會被編譯到:

void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
    byte *pbTo   = (byte a)pvTo;
    byte *pbFrom = (byte a)pvFrom;
    
    #ifdef DEBUG
        if (pvTo == NULL  ||  pvFrom == NULL)
        {
                fprintf(stderr, "Bad args in memcpy/n");
                abort();
        }
    #endif
    
    while (size- > 0)
        *pbTo++ = *pbFrom++;
        
    return (pvTo);
}

這裡的構想是同時維護你程式的除錯跟非除錯版(就是用來公開發行的版本)。在寫程式時,你編譯出除錯版的程式,在加入新功能時用它來自動除錯。之後,當你準備推出產品時,重新編譯一個公開發行版,把它打包好,就可以送去給經銷商了。

當然,你不會真的想等到推出產品的最後一刻才執行你的程式-那太糟糕了。在開發過程中,你就應該讓程式的除錯版本能跑得動了,主要的理由在於,如我們將在本章跟下一章中看到的,執行除錯版本的程式能大幅降低發展程式所需要的時間。如果每個函式都有著最低限度的錯誤檢查跟測試不應該發生的狀況,想像一下你的程式將會多麼穩固啊。

技巧,當然就是確保除錯碼是最後產品中完全不必要的額外程式碼。你也許明白了,不過稍後我還會在提一下。


同時維護你程式的發行跟除錯版本。


除錯檢查巨集ASSERT的說明3

坦白說,,我在memcpy裡頭放的除錯碼看來很差而且佔據了整個函式的空間。我知道不多程式員忍受得了這種東西,即使這麼做的理由很好。所以有些精明的程式員就把這些除錯碼全用個巨集隱藏起來,把它稱作assert除錯檢查巨集,全定義在ANSI的assert.h表頭檔裡頭。

assert只是我們之前看到那些#ifdef程式碼的重新包裝版而已,不過當你使用巨集時,它只需要用到一行原始碼的空間而已:

assert只是個除錯專用的巨集,會在參數為false時中止程式的執行。在上頭的程式中,如果兩個指標之中有一個是NULL,assert就會發生效用。

assert並不是一個匆匆拼湊而成的巨集;你必須小心定義它,避免在除錯版跟發行版的程式中出現重大差異。assert不應該干擾到記憶體內容,不應該初始化原先未初始化的記憶體,也不應該產生任何副作用,程式在除錯時產生的結果得跟發行版的一模一樣。所以assert才被寫成巨集而不是一個函式;如果它是個函式,呼叫它將會造成非預期的記憶體或程式變化。記住,程式員是把assert當成非破壞性的測試工具使用,因為他們能夠安全的使用它而不會改變系統狀態。

你也應該留意到,一旦程式員學會用除錯檢查巨集,他們常會重新定義assert巨集的內容。舉例來說,與其讓assert在錯誤發生時中止程式執行,程式員有時會把assert重新定義,讓它在錯誤發生時把控制權轉移給除錯器去處理。有些版本的assert甚至給你選擇是否要當成錯誤沒發生過,讓程式繼續執行下去。

如果你決定定義自己的除錯維護巨集,想個assert以外的名稱,讓那些使用標準除錯檢查巨集的程式不會受到影響。在本書中,我會使用非標準的除錯維護巨集,我給了它一個叫做ASSERT的巨集名稱,好在程式中辨識出它來。assert跟ASSERT的主要差別在於assert是個你能自由使用在程式中的運算式,而ASSERT是個敘述,限制了能使用的地方。使用assert,你可以寫這樣子:

if (assert(p != NULL), p->foo != bar)
    .
    .
    .4

改用ASSERT的話,你會碰到語法錯誤的警告。我是故意這樣安排的,除非你想在運算式裡頭使用除錯檢查巨集,不然你應該將ASSERT定義成一個敘述,這樣編譯器才能在你把它誤用在運算式中時發出錯誤。記住,這裡的每一點都對除錯有所幫助。為何要讓你根本用不著的彈性存在?

你可以如下定義ASSERT巨集:

#ifdef DEBUG
    void _Assert(char *, unsigned);   /* prototype */
    
    #define ASSERT(f)                 if (f)                            {}                        else                              _Assert(__FILE__, __LINE__)
#else

    #define ASSERT(f)
    
#endif

你看到如果DEBUG定義了,ASSERT將會擴展成一個if敘述。if敘述中的空區塊可能有點奇怪,不過你需要前頭的if跟後頭的else,以免出現非預期的if敘述被孤立的警告。你也許會覺得你需要在呼叫_Assert之後的)後頭加個分號,不過你並不並需要這麼作,因為你在使用ASSERT時就會自己加上那個分號了:

ASSERT(pvTo != NULL  &&  pvFrom != NULL);

當ASSERT敘述不成立時,它會以前置處理器透過__FILE__跟__LINE__巨集提供的檔案名稱跟行號數當成參數來呼叫_Assert. _Assert會把錯誤訊息印到stderr,然後中止程式執行:

void _Assert(char *strFile, unsigned uLine)
{
    fflush(NULL);
    fprintf(stderr, "/nAssertion failed: %s, line %u/n",
            strFile, uLine);
    fflush(stderr);
    abort();
}5

程式結束前,你得呼叫fflush來完全寫出緩衝區中等待輸出的東西。呼叫fflush(NULL)是非常重要的,因為這樣可以確保錯誤訊息在其他緩衝區的東西都被送去寫入後才出現。

現在,如果你使用NULL指標呼叫memcpy,ASSERT將抓到這個錯誤,並印出如下的訊息

Assertion failed: string.c, line 153

這顯示出了assert跟ASSERT的差別。標準巨集會顯示一個類似上頭的訊息,可是它也會顯示不成立的那個測試運算式。底下就是我常用的一個編譯器產生的assert不成立時的訊息:

Assertion failed: pvTo != NULL  &&  pvFrom != NULL
File string.c, line 153

將條件運算視野一起印出來的唯一問題是當你使用assert時,程式中也會包含一份這個條件式的文字給_Assert列印。那編譯器怎麼存放這字串?麥金塔、MS-DOS跟Windows的編譯器一般都會將字串放在整體資料區域內,在麥金塔上的整體資料區域大小限制一般是32K,在MS-DOS跟16-bit Windows上,這個大小限制是64K. 對於如Microsoft Word跟Microsoft Excel這類的大程式來說,這些除錯維護字串會迅速吃光整體資料區域的空間。


譯註:

在Mac OS 7.0的32-bit addressing跟Win 95/98/NT下,類似的限制幾乎是不存在了。不過不要忘了,即使沒有限制了,這些除錯訊息所用到的字串還是要吃記憶體的。6


當然有解決方法,不過最簡單的就是從錯誤訊息中省掉那個條件運算式的字串。畢竟,當你看過string.c的第153航後,你就知道問題在哪裡,你可以在那裡頭找到原因。

如果你想看看怎樣標準的assert是怎麼定義的,你可以看到系統上提供的assert.h檔案。ANSI標準的Rationale段落也談到assert,並提供一個可能實作。P. J. Plauger也在他的書The Standard C Library (Prentice Hall,1992)中提到實作標準assert的巧妙之處。

不管你最後怎麼定義自己的除錯維護巨集敘述,用它來核對傳給函式的參數正確性。如果你在每個函式的進入點都檢查資料的正確性,程式的錯誤不用多久就會被找到。最棒的,是這些錯誤都是在發生時就被自動抓到的。


用除錯檢查巨集來核對函式參數。


"未定義"就要"釐清"

如果你要停下來看ANSI C怎麼定義memcpy副程式的,你會看到最後一行寫著,"在互相覆蓋的記憶體區塊間進行資料搬移動作時的行為是未定義的。"其他書籍將這種未定義說得不太一樣,像Standard C (Microsoft Press,1989),P. J. Plauger跟Jim Brodie說,"陣列的元素可以任意順序存取跟存放。"

簡單說來,這些書在說的就是,如果你依賴memcpy在處理重疊的記憶體塊時的某種特定行為,你假定這種在不同電腦系統間或甚至同個編譯器的不同版本間可能有所變化的行為是不變的。7

我確定有程式員謹慎的使用著這種未定義的行為,不過我想大部分的程式員聰明的避免這麼作。那些不避開這種未定義行為的人最好學著避開它。大部分程式員將未定義行為視作非法行為,這時除錯檢查巨集就派上用場了。如果你在想呼叫memmove時使用memcpy,你不想多了解一點這樣子有什麼不同嗎?

你可以加強memcpy的功能,加上一個除錯檢查巨集來檢查兩個記憶體區間是不是完全沒重疊到:

/* memcpy  --  複製一塊非重疊的記憶體區域。  */
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
    byte *pbTo   = (byte a)pvTo;
    byte *pbFrom = (byte a)pvFrom;
    
    ASSERT(pvTo != NULL  &&  pvFrom != NULL);
    ASSERT(pbTo >= pbFrom+size  ||  pbFrom >= pbTo+size);
    
    while (size- > 0)
        *pbTo++ = *pbFrom++;
        
    return (pvTo);
}

那個只有一行的重疊檢查也許運作得不太明顯,不過你可以簡單的把兩塊記憶體想成兩台停在交通號誌前面的車子。當一台車子的後安全桿在另一台車的前安全桿之前時,你知道車子不能重疊。這裡的檢查就是實作這樣的想法:pbTo跟pbFrom就是兩塊記憶體的後端,而pbTo+Size跟pbFrom+size就是兩塊記憶體的前端,這就是整個實作所需的東西。


不要讓這種事情發生

在1988年尾,微軟的一隻大金牛-MS-DOS版Word的推出日期已經順延了三個月,而且嚴重影響到公司的底線。(這些喜怒無常的大金牛們常見這種問題。)那次順延令人挫折的一面是Word的開發團隊在那三個月中認為這個東西"隨時都可以推出了"。

Word小組依賴一個應用工具小組開發的一個關鍵元件,而這個工具小組一直告訴Word小組說那個程式快寫好了,而且工具小組內的人也真的相信自己所說的。他們不明白自己的程式碼中充滿了問題。8

Word程式碼跟那個工具小組的程式碼中一個顯著的不同點是,Word的程式碼過去充滿了(現在也還是如此)除錯檢查巨集跟除錯碼,而那個工具小組則完全不使用除錯檢查巨集,所以這些工具程式員沒辦法判定他們的程式到底有多少問題。臭蟲一直跑出來,那種如果用了除錯檢查巨集的話,早在好幾個月前就被抓光了的臭蟲。


順帶一提,如果你不了解為何重疊記憶體間搬移的問題是如何重要,想想當pbTo等於pbFrom+1而你至少搬移兩個位元組時的情況-memcpy不會正確動作。

所以在將來,請停下來檢視你的程式中未定義行為的運用。如果發現有哪裡用到了未定義行為,請將它從設計中移除,或加上一段除錯檢查巨集來警惕程式員們他們正在使用的是未定義的行為。

如果你提供程式庫(或作業系統)給其他程式員,處理未定義行為將是非常重要的。如果你曾經發展過這樣供人使用的程式庫,你就會了解其他程式員可能會使用任何他們找得到的未定義行為來產生他們要的結果出來。當你推出改進並推出新版程式庫時,這些未定義行為的使用結果就會真正浮現檯面。你會發現當你的程式庫百分之百相容於過去版本時,總是有一半使用它的程式當掉了。原因很簡單:新的程式庫不完全相容於舊程式庫的"未定義行為"。


拿掉程式中用到的未定義行為,不然就用除錯檢查巨集把未定義行為的錯誤使用抓出來。


哀嚎著"危險"的程式碼

當我們談到這裡,我想再提處理memcpy重疊狀況的除錯檢查巨集一下。再看一下底下這東西:9

ASSERT(pbTo >= pbFrom+size  ||  pbFrom >= pbTo+size);

假設你呼叫了memcpy而上頭的除錯檢查巨集發生了不成立的狀況。當你檢查時,如果你從來沒看過如何檢查記憶體區域的重疊,你會曉得哪裡出問題了嗎?我想我大概不曉得。這種寫法的詭異或不清楚是不用提了-畢竟,它只是個簡單的記憶體區間重疊檢查。不過簡單跟明白是兩回事。

記住我的話,沒有多少事情比底下這件更讓人有挫折感的-追蹤程式執行到別人寫的除錯檢查巨集裡頭,卻摸不清楚為何放個那樣的檢查在那邊。你不但沒修好問題,反而浪費了許多時間來想問題究竟在哪裡。這樣還沒結束哩,程式員們有時會寫出有問題的除錯檢查巨集,可是如果當你除錯時,如果你根本不知道他們的除錯檢查巨集在檢查什麼,那你根本就很難弄清楚該修理這程式還是那個除錯檢查巨集。

幸運的,這問題很好收拾-只要對用意不明的除錯檢查巨集加上注釋就好了。這聽來簡單,可是很少程式員作到這點。他們製造了一堆麻煩讓你不會碰到危險的東西,可是他們不會告訴你危險的究竟是什麼。就好像走在森林裡,你看到一個牌子釘在樹上,牌子上寫著大大的紅色危險字樣,那到底哪個東西才是危險的?是樹會倒下來嗎?還是附近有廢棄的礦坑?或是有沼澤怪獸大腳?除非你告訴人們危險的是什麼(或至少那東西很明顯),不然你留下的警告標誌一點也沒幫到他們,樹林中的人們將會忽略那個警告標誌。相似的,程式員們會忽略任何他們看不懂得除錯檢查巨集-他們假設那個檢查是錯的而把它拿掉,所以才需要加上注釋來加以詳細說明。


這不是用來抓錯誤的

當程式員們開始使用除錯檢查巨集時,他們有時會用它來捕捉真正的錯誤,而不是非法狀況。例如下面這個strdup函式中的除錯檢查巨集:

/* strdup – 配置一塊記憶體來複製一個字串。 */
char *strdup(char *str)

{
     char *strNew;
     
     ASSERT(str != NULL);
     
     strNew = (char a)malloc(strlen(str)+1);
     ASSERT(strNew != NULL);
     strcpy(strNew, str);

     return (strNew);
}10

在這程式中,第一個除錯檢查巨集用得對,因為它檢查只要程式正確執行就永遠不會發生的錯誤狀況。第二個就不一樣了-它檢查一個會出現在最後產品中而必須要處理掉的錯誤。這樣的用法是不對的,應該補上一段錯誤狀況處理程式才是。


如果一種錯誤有個大概解決方案,最好把它紀錄下來。當一名程式員呼叫memcpy來搬動重疊的記憶體區域時,那很可能就是他或她想作的事情,只是沒注意到記憶體區域有重疊到而已。你可以加個注釋來說明,如果要搬動重疊的記憶體區域,應該用memmove:

/* 記憶體區塊重疊時,請改用memmove. */
ASSERT(pbTo >= pbFrom+size  ||  pbFrom >= pbTo+size);

你不需要寫得很長,一種做法是使用簡短的問題來誘使程式員自己想辦法,這樣比起長篇大論的解釋每個解決的細節要能讓看的人得到更多資訊。不過要小心的-除非你確定有用,不然不要隨便建議一個做法給別的程式員當作問題的解決方案,你總不想讓你的注釋誤導別人吧?


不要浪費別人的時間,把你自己的除錯檢查巨集注釋說明清楚。


你又在假設東西如你想的那樣了嗎?11

有時在你寫程式時,你需要作些目的環境的假設,雖然不總是如此。舉例來說,底下的memset副程式就不用任何關於使用環境的假設而能在任何一種ANSI C編譯器上用得很好:

/* memset – 將記憶體填滿那個位元組型態參數的值。 */
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);  
    /a prototype */

void *memset(void *pv, byte b, size_t size)
{
    byte *pb = (byte a)pv;
    
    if (size >= sizeThreshold)
    {
       unsigned long l;
        l = (b << 8) ? b;/* 將一個長整數的四個位元組都填成b的值。 */
       l = (l << 16) ? l;
        
       pb = (byte a)longfill((long a)pb, l, size / 4);
       size = size % 4;
    }
    
    while (size- > 0)
        *pb++ = b;
        
    return (pv);
}

上面這個副程式相當簡單,除了那個對sizeThreshold的檢查可能有點不清楚。不清楚的東西為什麼要留著呢?想想,將一個長整數的四個位元組填成我們要的值也是需要時間的,再加上呼叫longfill函式的損耗時間,對sizeThreshold的檢查確保memset只有在能比原先的做法跑得更快時,才會使用新的做法。

新版本memset唯一的問題是,它使用了一堆對編譯器與作業系統的假設。這程式假設長整數一定是四個位元組長的,而一個位元組一定是八個位元寬的。這樣的假設在許多電腦上都成立,而且現在在微電腦上也近乎每一部上頭都成立。可是,那不代表你應該讓程式依賴於這樣的假設之上,因為唯一靠得住的假設,就是你的假設現在可能成立,可是幾年後卻可能不成立。


譯註:

從十六位元的MS-DOS,Windows 3.1到三十二位元的Mac OS與Win32環境跟目前的大部分Unix版本下,大部分的編譯器都將長整數當成32位元長的。但在微軟公司1998年五月訂定的Win64環境的資料型態標準中,長整數的長度延伸成了64位元長。在一些64位元微處理機的系統上,如DEC Alpha,也有編譯器將長整數的長度訂為64位元長的。所以依賴這種位元長度的假設可以說是相當危險的一件事情。12


有些程式員會將這副程式改寫成底下這樣,來改善可攜性:

void *memset(void *pv, byte b, size_t size)
{
    byte *pb = (byte a)pv;
    if (size >= sizeThreshold)
    {
        unsigned long l;
        size_t sizeLong;
        
        l = 0;
        for (sizeLong = sizeof(long); sizeLong- > 0; )
            l = (l << CHAR_BIT) ? b;
            
        pb = (byte a)longfill((long a)pb, l,  size /
            sizeof(long));
        size = size % sizeof(long);
    }
    
    while (size- > 0)
        *pb++ = b;
        
    return (pv);
}

這程式看來比較具有可攜性,因為它使用了大量的sizeof運算子,可是用看的一點意義也沒有;你還是得在它移植到另一個環境後,重新檢查一遍。如果你將這程式在Macintosh Plus(譯按:好幾年以前的一種標準型麥金塔電腦)或任何其他68000電腦上執行,這程式在pv一開始就指向奇數位址時就當掉。因為byte *跟long *兩種資料型態在68000上是不能完全互通的-你不能把一個長整數存放在奇數位址上,不然就會引發微處理器的匯流排位址失誤。


譯註:

32位元微處理器如Motorola 68000跟Sun Sparc要求長整數一定要存放在偶數位址上,不然就會引發Bus fault,匯流排位址失誤。好一點的作業系統會處理這個失誤,把程式關閉掉,差一點的作業系統乾脆直接當機了事。從Motorola 68020允許長整數存放在奇數位址以後,這種位址存取失誤就變成可以選擇性關閉觸發了;其實在Intel 80386以後的微處理器也都支援開啟這樣的位址存取失誤處理,不過在Windows底下是絕對不會開啟這旗標的。


所以你該怎麼作才好呢?

在這裡,你不想把memset寫成本來那個沒有可攜性問題的版本,而想接受那些不可移植到其他環境的版本,抗拒以後可能有的變化。對68000來說,你可以先只填入位元組,直到對齊了偶數位址,才開始填入長整數。雖然對齊偶數位址就算足夠了,在使用更新的68020,68030跟68040微處理器的麥金塔電腦上,對齊位址為四的倍數可以讓長整數的記憶體填寫達到更好的執行效率。如同其他假設情形,你可以用除錯檢查巨集跟條件式編譯敘述來核對這樣的做法有沒有用:

/* 將一個長整數的四個位元組都填成填寫記憶體用的位元組值,再呼
   叫longfill. 
 */

void *memset(void *pv, byte b, size_t size)
{
    byte *pb = (byte a)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-;
        }
        
        
        /* 將一個長整數的四個位元組都填成填寫記憶體用的位元組值,
           再呼叫longfill. */
        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);
}13

如你所見,我把機器平台相關的程式碼放在檢查MC680x0的前置處理器定義的條件式編譯敘述裡頭。使用這樣的前置處理器定義不只讓這些不可移植到其他環境的程式在其他環境下不會被意外用到,當你在程式中搜尋MC680x0時,你就能找到各個依賴於執行環境的特定條件的程式碼。

我也加了個簡單的除錯檢查巨集來檢查長整數是否佔用四個位元組,一個位元組是否佔用八個位元。這些假設所依賴的條件也許不會改變,但是你怎麼知道這些永遠不會改變呢?

最後,我加了個迴圈在呼叫longfill之前讓pb對齊記憶體位址,由於不管size多大,這個迴圈最多會執行三次,我還加上了一個除錯檢查巨集來檢查sizeThreshold最少為3. (sizeThreshold應該更大一點,不過最少一定要是3,不然程式就跑不動了。)

在這些改變之下,這個副程式明顯不具可攜性,而所有的假設都用除錯檢查巨集消除或檢查過了。這樣的設計讓這個函式不太可能會被誤用囉。


拿掉含糊的假設,不然就用除錯檢查巨集來檢查這些假設成不成立。



編譯器是自家人寫的又怎樣?

微軟公司內有的應用程式開發小組發現它們得檢閱並清理他們的程式碼,因為程式裡頭到處都把+sizeof(int)寫成+2,把無號數跟0xFFFF比較而不是跟UINT_MAX比較,把資料結構中 16位元寬的欄位寫成int。14

對你來說,本來的那些程式員們似乎很懶散,不過他們過去認為拿+2來取代+sizeof(int)確實有很好的理由。微軟公司開發自己的編譯器,這讓程式員們有著安全的錯覺。一名程式員好幾年前說過一句話,"編譯器小組永遠也不會改變編譯器的某些東西來讓我們大修我們的程式。"

編譯器小組改變了整數int(還有其他一些資料型態)的長度,以便產生在Intel的80386跟更新的微處理器上跑得更快而又更小的程式執行碼。編譯器小組不想讓其他小組的人傷腦筋,可是對他們來說,在市場上維持競爭力是一件更重要的事情。畢竟,一些微軟的程式員自己作出錯誤的假設並不是他們的錯。


不可能的事情會發生嗎?

一個函式的輸入資料不總是來自函式參數,有時你從輸入端只得到一個指標。看一下這個反壓縮副程式:

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)
        {
            /* Store "size" copies of "b" at pbTo. */
            b = *pbFrom++;
            size = (size_t)*pbFrom++;
            
            while (size- > 0)
                *pbTo++ = b;
        }
        else
           *pbTo++ = b;
    }
    
    return (pbTo);
}

這程式將資料從一個緩衝區複製到另一個緩衝區,但在複製過程中尋找壓縮字元的封包。如果他發現資料中有特殊位元組bRepeatCode,它就知道接下來兩個位元組是要重複的字元跟該重複的次數。雖然這做法很簡單,你可以在程式員使用文字編輯器中使用這樣的副程式。在那樣的程式中,通常每一行前面都有一堆縮排用的移位字元或空白字元。

要讓pbExpand更穩固,你可以在開頭檢查pbFrom,pbTo跟SizeFrom參數是否合格,不過你還可以做到更多檢查:你可以核對緩衝區中的資料。15

要編碼一串文字要用三個位元組,所以壓縮副程式從來不會在相同字元只有兩個時進行編碼;把三個同樣的字元編碼起來也得不到好處。它只在同樣的字元連續出現四次時,才會進行編碼。

有個例外。如果本來的資料包含bRepeatCode,它得特別處理這個情形,以便在bRepeatCode單獨出現時,pbExpand不會以為自己拿到了一個壓縮過的封包。當壓縮副程式發現bRepeatCode時,它把這東西當成被編碼一次的bRepeatCode處理。

簡單說來,對每個封包,長度最少是四,不然被編碼的位元組一定是bRepeatCode,而長度一定是一。你可以用個除錯檢查巨集來查對這一點:

.
.
.
{
    /* 將size個b存在pbTo的位置開始的地方。 */
    b = *pbFrom++;
    size = (size_t)*bFrom++;
    
    
    ASSERT(size >= 4  ??  (size == 1  &&  b == bRepeatCode));
    .
    .
    .

如果這個檢查敘述不成立,一定是pbFrom指向了垃圾,不然就是壓縮副程式出錯了。兩種情形都算是其他做法不好捉到的錯誤。


用除錯檢查巨集來找出不可能發生的狀況。


沉默的羔羊16

假設有人聘你去寫核子反應爐的軟體,你得要處理爐心過熱的情形。

一些程式員會以自動加水到爐心,插入控制棒,或任何他們認為要冷卻爐心時所該作的事情來解決這個問題。只要這個程式控制全局,就不會觸發警報。

別的程式員也許會選擇只要爐心過熱就觸發警報。電腦還是可以自動處理狀況,不過管理員總是可以知道有什麼事情發生了。

你會怎麼寫這個程式?

我想不會有太多人不同意這個做法;你會警告管理員。讓電腦把爐心溫度冷卻回正常狀態是不對的,爐心不會自動增溫-一定有些東西出錯了,最好有人趕快找出哪裡出了問題,才不會讓同樣的事情再度發生。

令人驚訝的,程式員們,尤其是經驗老到的程式員們,每天寫著修正一些非預期發生的問題的程式。他們甚至是故意這樣寫的,也許你也是這樣子作。

當然,我所要針對的不是這樣的程式員們,而是這樣子防禦性的程式設計方式。17

在上一節裡,我給你看過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)
        {
            /* 將size個b存在pbTo的位置開始的地方。 */
            b = *pbFrom++;
            size = (size_t)*pbFrom++;
            
            do
                *pbTo++ = b;
            while (-size != 0);
        }
        else
            *pbTo++ = b;
    }
    
    return (pbTo);
}

即使程式本身更精確的反映了演算法,只有少數經驗老到的程式員們會這樣子實作這個演算法。這樣子寫太危險了。

他們會認為,"我曉得pbFrom在外層迴圈永遠不應該大於pbEnd,可是如果這種情形發生了呢?嗯,我最好在這種情形發生時,讓這迴圈跳出來。"

他們會對內層迴圈也採用同樣的邏輯,即使size應該總是大於或等於1,用個while迴圈代替do迴圈可以在size為0時讓這程式不會當掉。

這樣子似乎合理,甚至精明,從不可能發生的情形中保護自己。不過如果pbFrom不知怎的超出了pbEnd呢?你比較想在前一頁那個比較危險的版本中找出為何如此呢,還是在我們稍早看到的那個防禦性的版本裡頭,當作什麼事情都沒發生?

比較危險的那個版本大概會當掉,因為pbExpand會拿它所碰得到的記憶體位址中的所有東西來解壓縮。你當然會注意到這個。那個防禦性的版本,另一方面,則在pbExpand可以破壞任何東西以前就跳離開了。你可能還是找得到造成這種現象的問題,不過你不會跟我賭你找得到這個問題在哪裡。18

防禦性程式設計方式看來似乎是一種比較好的寫作方式,但它會把錯誤隱藏起來。記住,錯誤根本不應該發生,如果你把它們藏在安全處,那你就更難寫出零錯誤程式了。特別是當你有個如pbFrom般到處亂指,每次都處理不同資料量的指標時,上面的話更是真實的。

這意味著你應該停止使用防禦性的程式寫作方式嗎?

當然不是。防禦性的寫作程式會把錯誤隱藏起來,也同時有可貴的用途。一個程式最糟糕就是當掉,遺失使用者可能花了好幾個小時建立的資料。在一個程式會當掉的非烏扥邦裡,任何能避免使用者遺失資料的手段都是值得的。防禦性程式設計方式正式朝著這樣的目標前進,如果沒有它,任何一點硬體或作業系統上的改變都可能讓你的程式如一疊骨牌般完全倒掉。不過同時,你也不想用防禦性的程式寫作方式把錯誤隱藏起來。

假設pbExpand被呼叫時的參數不合格,特別是在sizeFrom有點小而資料緩衝區的最後一個位元組剛好是bRepeatCode時。由於這最後一個位元組看來像是個壓縮封包的開頭,pbExpand會一次讀取超出緩衝區邊界的兩個位元組,而讓pbFrom超出pbEnd. 結果?危險版的pbExpand大概會當掉,而防禦版的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++;
        . 
        .
        .
    
    }
    ASSERT(pbFrom == pbEnd);
    
    return (pbTo);
}

這個除錯檢查巨集只是簡單的查對程式有沒正常結束。在發行版的程式中,這樣的防禦性程式保護使用者免於碰到錯誤。但在除錯版的程式裡,錯誤發生時還是會被報告出來。如果這樣子還不夠好,我不知道怎樣子才算好了。

你還不用太關心這個。如果pbFrom在迴圈中一次總是加一,得要有個迷失的宇宙射線剛好打中它,把它踢到pbEnd後頭去,這才會發生問題。在這種情形下,除錯檢查巨集幫不了你任何忙。好好檢查你的程式,善用你的常識吧。19

最後一點,迴圈只是程式員們慣常防禦性寫作程式的地方之一。無論你在哪邊用防禦性的方式來寫程式,問問你自己,"我正在用防禦性程式寫作的方式隱藏錯誤嗎?"如果是,加上除錯檢查巨集來幫你抓臭蟲吧。


防禦性的寫程式時,不要把臭蟲藏起來。


有兩種演算法好過只有一種演算法

檢查錯誤輸入跟不正確的假設只是你在程式中抓蟲所該作的事情中的一部份。如同其他函式可能丟垃圾給你的函式,你的函式也可能傳回垃圾給它的呼叫者-你當然不想讓這種事情發生。

memcpy跟memset都只傳回一個參數,它們不太可能會意外傳回垃圾給呼叫它們的程式。不過對於更複雜的副程式來說,你可能就沒辦法如此肯定了。

舉例來說,我最近寫了個68000反組譯器,作為一個麥金塔程式開發工具的一部份。對反組譯器來說,速度不是最重要的,重要的是它正確運作,所以我決定用一種簡單的查表演算法來寫這個程式,好讓我能簡單的測試它,我也用了除錯檢查巨集來自動捕捉任何我在測試程式時漏掉的錯誤。

如果你看過組合語言的參考手冊,運氣好的話,上頭會有每個指令的仔細說明。而且為了表示這本手冊很完整,它還會附一張每個指令的位元編碼表。舉例來說,如果你在一本68000參考說明書上頭找到ADD這指令,你會看到它的位元編碼格式如下:20


你可以把這指令的暫存器代碼跟模式代碼那兩欄忽略不看-我們在乎的只有那些明白為0或1的位元-在這例子中,就是這指令的前四個位元。你可以判斷一個指令是不是個ADD指令,只要將它不明確的位元濾掉,再比較剩下的前四個位元是不是1101,或十六進制的0xD就好了:

if ((inst & 0xF000) == 0xD000)
        這是個ADD指令...

DIVS指令(用來作有號數除法)的編碼格式中有七個明確的位元:


如果你將不明確的位元濾掉,你就可以用下頭的敘述分辨一個指令是不是DIVS:

if ((inst & 0xF1C0) == 0x81C0)
        這是個DIVS指令...21

你可以用這種遮罩測試的技巧來分出每個組合語言指令,一旦你分出了ADD或DIVS,你可以呼叫一個解碼函式來判讀剛剛我們忽略掉的位元裡頭的暫存器跟指令模式訊息。

這就是我在工具中發展的反組譯器的運作方式。

當然,我沒寫142個不同的if敘述來檢查每個可能的指令,我作了張有遮罩,位元格式跟指令解碼函式位址的指令表。檢查演算法會查遍整張表,如果有符合的指令,就呼叫對應的副程式來解讀指令的暫存器跟模式欄位。

底下是這張表的一部份,還有使用它的程式:

/*  idInst是張識別各指令的位元遮罩與位元格式的指令表 */
static identity idInst[] =
{
    { 0xFF00, 0x0600, pcDecodeADDI  }, /* mask, pat, function */
    { 0xF130, 0xD100, pcDecodeADDX  },
    { 0xF000, 0xD000, pcDecodeADD   },
    { 0xF000, 0x6000, pcDecodeBcc   }, /* short branches */
    { 0xF1C0, 0x4180, pcDecodeCHK   },
    { 0xF138, 0xB108, pcDecodeCMPM  },
    { 0xFF00, 0x0C00, pcDecodeCMPI  },
    { 0xF1C0, 0x81C0, pcDecodeDIVS  },
    { 0xF100, 0xB100, pcDecodeEOR   },
    .
    .
    .
    { 0xFF00, 0x4A00, pcDecodeTST   },
    { 0xFFF8, 0x4E58, pcDecodeUNLK  },
    { 0x0000, 0x0000, pcDecodeError }
};

/*  pcDisasm
 *  反組譯指令,並填入opc運算碼結構中,
 *  然後傳回更新過的程式計數器值。
 *  一般用法:pcNext = pcDisasm(pc,&opc);
 */

instruction *pcDisasm(instruction *pc, opcode *popcRet)
{
    identity     *pid;
    instruction  inst = *pc;
    
    for (pid = &idInst[0]; pid->mask != 0; pid++)
    {
        if ((inst & pid->mask) == pid->pat)
             break;
    }
    
    return (pid->pcDecode(inst, pc+1, popcRet));
}

如你所看到的,pcDisasm不是個大函式。它用了個讀取目前指令,查表,然後呼叫解碼副程式填好popcRet指向的opcode結構的簡單演算法。pcDisasm的最後工作就是把更新過的程式計數器值傳回,這是必要的,因為不是每個68000的指令長度都是一樣的。這個解碼副程式會在需要時讀取指令的額外部分,並傳回新的程式計數器值給pcDisasm,再傳給呼叫pcDisasm的程式。

現在回到原來的話題,我們正在討論你沒辦法確定副程式永遠不會傳回垃圾的問題。22

一個像pcDisasm般的函式,很難說它傳回來的資料是不是正確的。即使pcDisasm本身會適當的辨識指令,解碼副程式也可能會吐出垃圾,讓你找臭蟲找個老半天。一個抓住這樣錯誤的辦法是將除錯檢查巨集加到每個解碼副程式中。我沒說你應該這麼作,不過有個更為有用的方法,就是將除錯檢查巨集加在pcDisasm中,因為這裡是所有解碼函式的瓶頸所在。

問題是,怎麼作?你如何自動檢查每個解碼副程式是不是都正確填好了opcode結構?你得寫程式來檢驗這個結構的正確性,而你又會怎麼寫這東西?基本上,你得寫個副程式比較一個68000指令跟opcode結構內容。換句話說,你得寫第二個反組譯器。

我知道這聽起來很瘋狂,不過這真的會很瘋嗎?

看看Microsoft Excel在它的重新計算引擎裡的設計吧。速度對於一個試算表程式的成功是很關鍵的要求,Excel使用了一種複雜的演算法來確定它不會重複計算一個不需要重新計算的儲存格。唯一的問題是這演算法太複雜了,改寫它很難不產生新錯誤。Excel的程式員們不喜歡這件難事,所以它們在除錯版的程式中寫了另一個重新計算引擎。當靈巧的那一個引擎停止計算後,除錯版的引擎就開始計算,緩慢但完整的重新算過每個有運算式的儲存格。當兩者的結果有所差異時,就會讓除錯檢查巨集出現不成立的狀況。

Microsoft Word也有類似的問題,因為速度對於文書處理器的排版程式也是重要的東西,Word的程式員們把排版程式碼用手工最佳化過的組合語言來寫。這東西當然跑得很快,可是除錯上很傷腦筋。不像不常更動的Excel重新計算引擎,Word的排版程式碼經常在新功能加入Word時被動到。為了自動捕捉排版錯誤,Word的程式員們用C重寫了每個手工最佳化過的組合語言副程式。如果兩個版本的結果不一樣,除錯檢查巨集的條件式就不成立。

同樣的,用個除錯版的反組譯器來核對我在程式發展工具中寫的主反組譯器也是有意義的。

我不會拿我怎麼寫第二個反組譯器pcDisasmAlt的細節來煩你,不過它的邏輯並不是查表法。我用巢狀的switch敘述來去掉有效位元,直到分離出正確指令為止。後面就是我怎麼用pcDisasmAlt來核對主反組譯器結果的程式碼。23

instruction *pcDisasm(instruction *pc, opcode *popcRet)
{
    identity     *pid;
    instruction  inst = *pc;
    instruction  *pcRet;
    
    for (pid = &idInst[0]; pid->mask != 0; pid++)
    {
        if ((inst & pid->mask) == pid->pat)
            break;
    }
    
    pcRet = pid->pcDecode(inst, pc+1, popcRet);
    
    #ifdef DEBUG
    {
        opcode opc;
        /* 檢查兩個結果是不是吻合。 */
        ASSERT(pcRet == pcDisasmAlt(pc, &opc));
        ASSERT(compare_opc(popcRet, &opc) == SAME);
    }
    #endif
    
    return (pcRet);
}

正常說來,除錯檢查不應該影響到現有程式結果,不過在這裡我沒辦法完全作到這點-我得宣告一個存放pid->pcDecode傳回來結果的區域變數pcRet,才能在接下來作核對。這沒什麼問題,這並沒有違背"不應該拿除錯版本程式碼產生的結果來取代發行版本程式的結果"的基本原則。這原則當然很對,不過當你開始用除錯檢查巨集跟除錯程式碼時,你會發現有時候你真的得執行除錯版本的程式碼來替代發行版本程式,我們在第三章就會碰到一個例子。不過在那之前,讓我重申一遍:不要那樣作。我雖然更動了pcDisasm來加上除錯檢查,不過發行版本的程式碼還是都跑到了。

我不會要求你一定要替程式的每個函式都寫兩個版本出來,那樣子作就跟要求每個函式都跑得很有效率一樣可笑而且浪費時間。我相信大部分的程式都有正常運作必須的關鍵功能,這關鍵的部分最不可以出錯。在文書處理程式中,排版引擎就是個關鍵。在專案管理程式中,工作時程表就是關鍵。資料庫裡頭,關鍵在於資料搜尋與取出引擎。對每個程式,保障使用者永遠不會遺失資料的程式碼都是關鍵所在。

當你寫程式時,留意每個核對結果的機會,瓶頸所在的副程式就是仔細檢查的好地方。如果可能,用另一套演算法而不只是同樣演算法的另一個實作版本,來檢查輸出的結果是不是正確的。藉由不同演算法的使用,你不只找得出實作的錯誤,也能增加找出演算法本身錯誤的機會。


用第二種演算法來核對結果。



嘿,這裡怎啦?

本章稍早,我說過你必須小心定義ASSERT巨集。我特地提到,這巨集不能搬動記憶體,不能呼叫別的函式或是造成其他非預期的副作用。如果這些都是對的,為什麼我在pcDisasm中用了底下這樣的除錯檢查巨集?24

/a Check both outputs for validity. a/
ASSERT(pcRet == pcDisasmAlt(pc, &opc));
ASSERT(compare_opc(popcRet, &opc) == SAME)

ASSERT不可以呼叫函式的理由是這個巨集可能會非預期的干擾到周圍的程式碼。不過在上面的程式片段裡,ASSERT並不是在呼叫函式;我是寫ASSERT的人,我知道這樣子在pcDisasm中呼叫函式是安全的而不會產生任何問題,所以我並不擔心在除錯檢查巨集中呼叫函式。


在一開始就把錯誤找出來

到現在為止,我都忽略了指令的暫存器跟模式欄位,不過如果這些欄位有個特別的編碼方式改變了指令本身的意義呢?舉例來說,EOR指令長得像這樣:


而CMPM指令長得出奇的像EOR:


25

注意到,如果EOR的有效位址模式是001,這樣的EOR指令就長得像是個CMPM指令了。當然問題就是,如果把EOR指令放在IdInst指令表的前面,它會擋到CMPM指令的判斷。

還好,pcDisasm跟pcDisasmAlt做法不同,你會在第一次要反組譯CMPM指令時碰到除錯檢查巨集發出來的警報。這是因為pcDisasm會把opcode結構填成EOR指令,可是pcDisasmAlt會正確(至少我們希望如此)填入CMPM指令的東西。當兩個結構在除錯碼中比較時,你就會得到檢查失敗的結果。這是個在除錯時使用不同演算法核對結果後找到錯誤的正面例子。

壞事是,只有在你試著反組譯CMPM指令時,你才會抓到問題。我希望你的外部測試套件夠完整得能抓到這個問題,不過記住我在第一章中說過的:你得儘可能早的自動抓到臭蟲,而不要依賴別人找尋錯誤的技巧。

所以當你希望把這東西丟給測試小組去解決時,先不要那樣作。儘管許多程式員相信測試人員是幫他們找尋問題,實際上測試人員存在的目的是找尋程式員沒找到的錯誤,寫程式的人應該自己找尋自己製造的臭蟲。如果你不同意,不如不要把自己叫做程式員了,叫個別的名稱都好,反正你可以毫不在意的亂寫東西,一定會有人幫你檢查哪邊犯了錯,不是嗎?寫程式沒有例外的,如果你要寫出零錯誤的程式,你就必須抓緊機會,把握每個能找出錯誤的機會。如果你沒有這樣的觀念,從現在起照著這觀念作吧。

當你注意到程式中某個危險的東西跑了過去,問問你自己,"我該怎樣儘可能早的自動抓到那個錯誤?"

習於如此自我省問,你將發現各種讓你的程式更穩固的方法。

在main主函式初始化過程式後,你可以檢查一下指令表中有沒有錯誤。只要看看指令表中的各個指令有沒有跟前面的判斷互相衝突的,就能找到有沒有錯誤了。底下的程式碼就是檢查指令表中有沒有這樣的錯誤的,雖然短了點,也不用寫得太清楚了:26

void CheckIdInst(void)
{
    identity *pid, *pidEarlier;
    instruction inst;
    
    
    /* 檢查指令表中的每個指令... */
    for (pid = &idInst[0]; pid->mask != 0; pid++)
    {   
        /* ...verify that no earlier entries collide with
            it. */
        for (pidEarlier = &idInst[0]; pidEarlier < pid;                         
            pidEarlier++)
        {
            inst = pid->pat ? (pidEarlier->pat & ~pid-
                >mask);
            if ((inst & pidEarlier->mask) == pidEarlier-
                >pat)
                ASSERT(bitcount(pid->mask) <                                    
                       bitcount(pidEarlier->mask));
        }
    }
}

這個檢查會逐一比對稍早出現過的指令跟現在的指令。每個指令都有個忽略遮罩-通常是暫存器與模式位元被遮罩掉。不過如果這些被遮罩掉的位元裡有組成在指令表中前面的指令所需的位元呢?這時你就找到兩個衝突的指令了。哪個指令放到前面去才好?

這答案很簡單,如果有一串位元吻合指令表中的兩個指令的格式,明確位元愈多的指令應該在指令表中排得愈前面。如果你覺得這樣不夠直覺,再看一下EOR跟CMPM的指令格式吧。如果有一串位元吻合這兩個指令的格式,你會怎麼判定哪一個才是這一串位元所表示的指令?為何如此?由於遮罩位元對每個明確位元的對應位元都是1,你可以找出哪個指令有更多明確位元,只要計算一下遮罩中被設立的位元數就好了。

更困難的是如何分辨哪兩個指令衝突了。上頭程式中的構想是將一個指令格式中的忽略遮罩填成前面每一個命令對應的位元,如果產生的結果能滿足前面某一個指令的格式要求,就找出衝突的兩個命令在指令表中的位置了。


警告

一旦你開始用除錯檢查巨集,你大概會發現你抓到的臭蟲數急速增加。這會嚇到那些沒準備好的人們。

我有次重寫了一個錯誤百出的程式庫,微軟公司內好幾個小組共用這程式庫。原來的版本沒有任何除錯檢查巨集,我寫的新版本裡頭則有用到。我得到預料之外的驚訝反應,當我把新程式庫丟出來時,一名程式員憤怒的要我把原來的版本弄回來。我問他,為什麼。27

"我們安裝了你寫的新版本,結果得到一大堆錯誤",他說。

"你認為是這程式庫造成的問題?"我嚇到了。

"看來是這樣。我們碰到一大堆我們沒碰過的除錯檢查巨集發出的警告訊息。"

"你有看過這些警告訊息中的任何一個嗎?"

"我們看過了,那些都說是我們程式中的錯誤,可是那麼多錯誤不可能都是真的錯誤。我們沒有時間可以浪費在找這些鬼錯誤上,把我們的老程式庫弄回來!"

嗯,我不認為他看到的是鬼錯誤,所以我要他繼續用我的程式庫版本,直到他真的碰到了鬼錯誤為止。他很生氣,可是他承認,到目前為止,他所找到的錯誤都是在他的專案程式裡的,而不是程式庫中的。

那名程式員嚇慌了,因為我沒告訴任何人我在程式庫中加上了除錯檢查巨集,沒人預期會碰到一堆錯誤訊息。如果我告訴人們注意看他們碰到的錯誤訊息,我就不會讓程式員們抓狂了。不過程式員們並不是唯一抓狂的人,因為公司以未完成的功能數和抓到的臭蟲數來評估專案進度,只要這兩個數字一大幅攀升,參予專案的每個人就會開始緊張。如果你可以,好好預警你底下的人,告訴他們不要太緊張。28


在程式開頭呼叫CheckIdInst,你就可以在程式一執行時找到衝突的指令了-你還不必反組譯任何一個命令哩。你應該在自己程式的開頭設計類似的啟動檢查,因為他們能快速預警錯誤的存在,而不會讓錯誤不小心躲過你的視線。


不要等到錯誤發生;使用啟動檢查吧。


除錯檢查巨集永遠有效

在本章中,你已經看到如何用除錯檢查巨集來自動捕捉程式中的錯。當這種做法成為幫你迅速找到"最後"一隻臭蟲的寶貴工具時,你可以如其他工具一般的濫用它,怎麼用全看你自己。對某些程式員來說,檢查一個除法的分母不為零也許很重要;對別人來說,這則是很好笑的事情。所以唯有經過你自己的判斷,這個工具才派得上用場。

另一件事:在專案的生命週期裡,把除錯檢查巨集放進程式中-除非程式已經發行了,不然不要拿掉除錯檢查巨集。這些檢查在你開始為下一版本的發行作準備時會再度變成很寶貴的基礎。


快速回顧

  • 維護程式的發行版跟除錯版。發行版用來打包發行,除錯版用來盡可能快速抓蟲。29
  • 除錯檢查巨集是寫出除錯檢查的快速方式。用它們來捕捉不應該發生的非法狀況。不要把這些狀況跟錯誤狀況混在一起;錯誤狀況在最後的產品中一定得被處理掉。
  • 用除錯檢查巨集來核對函式參數,並警告程式原有東西是未定義的。你愈嚴格定義你的函式,核對它的參數就愈簡單。
  • 一旦你寫好了一個函式,把它重新檢查一遍,並問問你自己,"我假設了什麼條件一定成立的嗎?"如果你找到一個假設條件,用除錯檢查巨集檢查這條件是否永遠成立,或者重寫程式以去除這樣的假設。也問問自己,"程式中哪裡最可能出錯,我怎樣才能自動找到錯誤?"盡可能將捕捉錯誤的測試放在愈早執行到的地方愈好。
  • 教科書鼓勵程式員防禦性的寫程式,可是記住這種寫作方式會隱藏錯誤。當你寫出防禦性的程式時,用除錯檢查巨集來提醒你自己是否有"不可能"發生的狀況發生了。


該想想的事

  1. 假設你維護一套共用程式庫,你要把除錯檢查巨集加進去,可是你不想把程式庫原始碼公開出來。你該如何定義ASSERTMSG這個用來顯示一段有意義訊息的巨集,取代本來顯示的檔名跟行號數呢?例如memcpy副程式也許會顯示下面除錯維護訊息:30
    Assertion failure in memcpy: The blocks overlap
  2. 每當你使用ASSERT時,__FILE__巨集產生一個唯一的檔名字串。這表示說,如果你在同個檔案中使用了73次除錯檢查巨集,編譯器就會產生同個檔名字串的73個複製體。你該怎樣實作ASSERT巨集,讓檔名在每個檔案中只會被定義一次?
  3. 底下函式中的除錯檢查巨集哪裡錯了?
    /* getline – 讀取以/n中止的一行文字到緩衝區中。 */
    void getline(char *pch)
    {
        int ch;         /* ch must be an int. */
        
        do
        
            ASSERT((ch = getchar()) != EOF);
        while ((*pch++ = ch) != n');
    }
  4. 當程式員們在一個資料列舉型態中加入了新元素,它們有時會忘了在適當的switch敘述中處理新的元素條件。怎樣使用除錯檢查巨集才能找到這種錯誤?
  5. CheckIdInst檢查IdInst指令表中的項目是否排列正確,可是指令表中會出現的問題並不只有這一種。有那麼多個指令,打錯遮罩或指令格式的數值是很容易發生的事情。你該如何加強CheckIdInst來檢查這樣子打錯的東西?
  6. 稍早我們看到EOR指令的有效位址模式欄位的值是001時,真正表示的是個CMPM指令。還有其他指令也包含在我們使用的EOR指令格式中,像EOR的那個兩位元的模式欄位的值就不能是11(不然就變成CMPA.L指令了),而有效位址模式欄位如果是111,有效位址暫存器欄位就必須是000或001. 由於pcDecodeEor永遠不會碰到這些不是EOR的組合,你該怎樣加上除錯檢查巨集來捕捉表格中的這些錯誤?31
  7. 怎樣用第二種演算法來查對qsort函式的結果?怎樣查對一個二元搜尋副程式的結果?怎樣查對itoa函式的結果?


學習計劃:

聯絡你的作業系統廠商,鼓勵它們提供一套除錯版本的作業系統給程式員們。這樣子一來對於雙方都有好處,因為作業系統廠商希望人們替他們的作業系統寫應用程式,這樣子可以讓這個作業系統上執行的產品更容易出現於市面。


完美程式設計指南 視窗縮放
自我維護 加入書架 加入書籤 上一章 下一章 
0 0

相关博文

我的热门文章

img
取 消
img