荔园在线

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

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


发信人: jek (Super Like Cat), 信区: Program
标  题: 完美程式設計指南--增強子系統
发信站: 荔园晨风BBS站 (Thu Apr  4 06:57:54 2002), 转信


一場橄欖球賽就算有五千名球迷到場觀戰,也只需要幾個計票員-當然,前提是他
們都站在球場門口前。你的程式就有這樣的門口,用來進入各個程式中的子系統。


想想你用的檔案系統,你開關檔案,讀寫檔案,建立檔案中的資料。這其中有五個
基本的檔案存取動作,而支援這些動作的程式通常又大又複雜。但從這些使用檔案
系統功能的進入點,你不用擔心檔案目錄怎麼擺的,儲存空間怎麼分布的,也不用
擔心怎樣讀寫檔案,不管是從磁碟機、磁帶機還是網路上。

記憶體管理也是如此,你配置記憶體,釋放記憶體,有時改變配置的記憶體塊的大
小,卻不用管這些動作的背後是怎麼處理的。

一般說來,子系統將其下可能相當複雜的實作細節隱藏,而其供給個關鍵進入點讓
程式員能跟子系統所提供的功能溝通。如果你是在這樣的子系統進入點上加上一些
除錯檢查,你就能得到實在的錯誤檢查而不用大費周章的到處更動程式碼。

假設你得替一個標準C語言執行期程式庫寫出malloc,free跟realloc副程式。(總
得有人來寫這些東西的,不是嗎?)你可以將這些程式加上除錯檢查巨集,你可以
徹底將它測試過,然後你可以寫本一流的程式寫作指導手冊。不過你我都知道,即
使如此,程式員們還是會在使用這些程式時碰到問題。你該怎麼幫助他們?

這裡有個建議:當你已經寫好一個子系統時,問問你自己,"程式員們會怎樣誤用
這個子系統?我該怎麼把這些問題自動抓出來?"你也許在開始寫程式以前就自問
過這些問題,拿掉了危險的設計方式,但你得對同樣的問題再自我省思一遍。對一
個記憶體管理器而言,你可以想到程式員可能會作底下這些事:

配置一塊記憶體,使用裡頭未初始化的內容。

釋放一塊記憶體後,繼續使用它裡頭存放的東西。

呼叫realloc加大一塊記憶體,卻在記憶體塊被搬移後繼續使用記憶體塊舊位置的
內容。

配置記憶體後,沒保留好記憶體位址的指標。

讀寫不在已配置記憶體塊所屬空間內的內容。

沒注意到錯誤狀況。

這些問題不是隨便亂說出來的-這些問題總是層出不窮的出現著。更糟糕的,它們
經常難以找出來,因為這些問題不會重複出現。

當一次機,下一次跑又不當了-至少在你程式的使用者生氣要你修好這個經常當機
的問題之前,你的程式沒在你的機器上再當掉過。這些錯誤不好找,可是那不表示
你改善不了這種情形。除錯檢查巨集很好用,可是得要讓這些檢查跑得到才行。看
看上面的情況,告訴我,在記憶體管理器中放入除錯檢查巨集會有用嗎?一點用也
沒有。

在本章中,我會談到你能用來找尋別的方法很難找出來的子系統錯誤的其他技巧。
我會用C語言的記憶體管理器作為例子,不過你還是可以把後頭提到的技巧用到別
的子系統上,無論那是個簡單的串列管理器或共用的文字存取引擎。

時有時無的錯誤
一般說來,你會把測試檢查直接寫在子系統中,不過我不會這樣作的理由有兩點。
首先,我不想用malloc,free跟realloc的實作程式碼填滿這整本書。再者,你有
時不可能拿得到你用的子系統的原始碼。我用來測試本書中範例的半打編譯器中,
只有兩個提供了標準程式庫的原始碼。

代替將測試檢查寫到你可能拿不到的原始碼中或者寫到跟我手上的原始碼完全不一
樣的東西裡頭的做法,是在記憶體管理器提供的副程式呼叫之上加上一層包裝。不
管你有沒有你使用的子系統的原始碼,你都可以這樣子作。我這樣子作了以後,我
在整本書中就沿用這些加蓋上去的包裝函式了。

這就開始提malloc的包裝函式。這包裝函式看起來就像這樣:

/* fNewMemory – 配置一塊記憶體。 */

flag fNewMemory(void **ppv, size_t size)
{
    byte **ppb = (byte **)ppv;
    *ppb = (byte *)malloc(size);
    return (*ppb != NULL);           /* 成功? */
}
這看來可能比malloc複雜,不過這大抵是由於void **這個參數指標造成的視覺錯
亂引起的。如果你看過程式員怎麼使用這個函式的,你就會發現這個函式並不會比
呼叫malloc來得差。本來是寫這樣:

if ((pbBlock = (byte *)malloc(32)) != NULL)
    成功 – pbBlock指向配置到的記憶體塊
else
    不成功 – pbBlock的值是NULL
你會寫成:

if (fNewMemory(&pbBlock32))
    成功 – pbBlock指向配置到的記憶體塊
else
    不成功 – pbBlock的值是NULL
這樣也達到了同樣效果。兩個函式的唯一不同是,fNewMemory分開了成功與否的判
斷跟指標結果的輸出,而malloc把兩個東西混在一起送出來。其實在兩個寫法裡頭
,如果記憶體配置成功了,pbBlock都會指向配置好的記憶體塊,否則pbBlock就會
是NULL.

在上一章中,我說你應該消除未定義行為的存在,或者使用除錯檢查巨集來檢查未
定義行為的運作。如果你將這些建議用在malloc上,你將看到兩個必須處理的未定
義狀況。首先,要求malloc配置一塊沒有大小的記憶體(依據ANSI標準)是沒有意
義的。再者,malloc如果傳回一塊記憶體,它不會初始化那塊記憶體的內容-那塊
記憶體也許全部都為0,或者保留著之前用過那個地方的程式留下的垃圾,反正你
不知道會是哪種情形。

處理配置沒大小的記憶體塊的要求很簡單。你用個除錯檢查巨集檢查就好了。不過
另一個問題該怎麼處理?你能檢查一塊記憶體的內容到底有沒有用嗎?一點意義也
沒有。所以你只有一個選擇:消除未定義的行為。這樣的做法就是讓我們在
fNewMemory中將配置好的記憶體全部填為0. 這麼作當然有用,可是在一個正確的
程式中,配置出來的記憶體塊一開始的內容不管是怎樣子,應該都不打緊的。以不
必要的填寫動作加重程式的負擔是應該避免的。

何況不必要的填寫動作也會藏住錯誤。

假設你給一個資料結構配置一塊記憶體,卻為了初始化一個欄位-或者一個維護這
程式的人擴充了結構的欄位,卻忘了替新欄位加上初始化的動作。這麼作當然是錯
的,可是如果fNewMemory替你把這些欄位都填成了0或別的可能有用的值,你就不
會注意到這樣錯誤的做法。

不過你還是不希望記憶體內容出現未定義的狀態,因為那樣子錯誤不好重現,你就
不好抓出錯誤來。如果錯誤只發生在這些未定義內容的值是個特定值時,在大多數
狀態下你就會漏掉這隻臭蟲,而讓程式在某個時候莫名其妙當掉。想像一下,如果
每個錯誤都只在某些不特定時候出現,你要怎麼讓程式達到零錯誤?程式員們(跟
測試員們)為了抓出這些臭蟲,一定會抓狂。找出錯誤的關鍵,就是在你發現會產
生隨機結果的非預期行為時,消除這樣的行為。

你該怎麼作,決定於你所用的子系統是怎樣子的,以及會產生隨機結果的非預期行
為是怎麼來的。要消除malloc製造的隨機行為,只要將配置出來的記憶體填入某個
值就好了-不過你只能在除錯版的程式中這麼作。這樣子解決了問題,而不用在你
推出的程式中放上一副影響執行速度的鐵鍊。不過必須記住,不要把錯誤隱藏起來
。這裡的構想是,記憶體還是照樣填入一個固定值,不過這個值看起來就像垃圾,
會讓錯誤無所遁形的垃圾。

我在麥金塔的程式中使用0xA3的值。在我挑選這個值以前,我問過自己幾個問題:
怎樣讓一個壞指標自己暴露出來?怎樣讓不正確的計數器或陣列索引現形?如果陣
列中的內容被當成程式執行,結果會怎樣?

在一些麥金塔機器上,你不能在奇數位址上讀寫16位元或32位元的數值,所以我想
我該挑著奇數值。我也知道,一個看起來大得足以造成系統變慢或錯亂的錯誤計數
器或陣列索引會更好被找到。最後,我挑出了一個又怪異又大而又能用位元組表示
的奇數值,0xA3,因為當記憶體塊被當成程式執行時,0xA3A3是個未定義的機械語
言指令,會立刻讓程式當掉-你在系統除錯器中會得到一個"未定義的A列陷落"。
(譯註:在Motorola 680X0微處理器系列上,所有A開頭的機械碼指令都會造成這
個陷落例外,讓系統能夠處理某些東西。有些680X0的作業系統就靠這種特性來提
供如MS-DOS上INT 21h等透過中斷呼叫來提供的的系統功能。)最後一點看來有點
吹毛求疵,不過有什麼理由不把握任何一個能自動捉住錯誤的機會,不管這機會有
多小呢?

在你的機器上,你挑的值可能不同。在以Intel 80x86為微處理器的系統上,指標
可以是奇數值,用不用奇數值當作除錯值就不重要了。不過挑選這個值的過程還是
相似:你該省思一下未初始化的記憶體會怎麼被用到,然後想辦法讓這種情形能被
找出來。微軟的應用程式用0xCC來填寫剛配置出來的記憶體塊,因為這數字夠大,
容易注意到,而且如果被執行到,會自動呼叫除錯器的中斷點處理。(這個值本身
就是Intel 80x86微處理器用來當作除錯中斷點指令用的。)

如果你將檢查大小的除錯檢查巨集跟填寫特定值到未初始化記憶體塊的程式碼加上
,fNewMemory會變成

#define bGarbage   0xA3

flag fNewMemory(void **ppv, size_t size)
{
    byte **ppb = (byte * )ppv;

    ASSERT(ppv != NULL  &&  size != 0);

    *ppb = (byte *)malloc(size);

    #ifdef DEBUG
    {
        if (*ppb != NULL)
            memset(*ppb, bGarbage, size);
    }
    #endif

    return (*ppb != NULL);
}
這一版的fNewMemory不只可以讓錯誤重現,而且讓這些錯誤更易於被找出來。如果
你發現自己的迴圈索引的值是0xA3A3,或者找到一個指標的值是0xA3A3A3A3,那很
清楚的就是你用到了未初始化的資料。不只一次,我找到了一個錯誤後,又發現另
一個錯誤,只因為它們一連串的使用了未初始化的記憶體區塊而讀到一堆0xA3.

所以,檢查你程式中使用的子系統,找出設計上會產生隨機錯誤的那些地方。一旦
你找到了,變更你的設計來消除這些隨機錯誤產生的原因,或者加入除錯碼來減低
隨機現象出現的機率。


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

消除隨機行為。

讓錯誤可重現。


------------------------------------------------------------------------
--------
輾碎垃圾
free的包裝函式長得像這樣:

void FreeMemory(void *pv)
{
    free(pv);
}
ANSI標準說free在你傳入一個非法指標時的行為是未定義的。這聽來合理,可是你
怎麼判斷pv是合法的?你怎麼檢查pv指向一塊已配置記憶體的起頭?答案是,你沒
辦法,至少你得要有更多資訊才作得到。

還有更糟糕的呢。

假設你的程式處理某種樹狀結構,而刪除樹狀結構中某個節點的deletenode副程式
呼叫FreeMemory來釋放某個節點。如果deletenode中有個錯誤會讓它釋放了節點的
記憶體,可是沒同時更新周圍那些已配置節點指向這個被釋放節點的連結指標?很
明顯,你將有個包含已釋放記憶體塊的樹狀結構。還有你意想不到的,在大部分系
統上,這個已釋放節點會繼續讓你覺得它的內容是合法的。

這應該不會太驚人才對,當你呼叫free時,你只告訴記憶體管理器說你不需要那塊
記憶體了而已,它為什麼要幫你把裡頭的垃圾處理掉?

那是個合理的最佳化做法,但對被釋放的那塊記憶體來說有個糟糕的副作用-裡頭
都是別人丟掉的東西-讓它看起來好像還是帶著合法的資料。這樣子並不會讓你有
一個進行處理時會讓你的系統當掉的已釋放節點的樹狀結構,這個樹狀結構的內容
看起來完全正常。所以你怎麼找出這種問題?除非你有買到中獎彩券的運氣,不然
實在不太可能找得到。

"沒問題",你說,"我在FreeMemory裡頭加上一些除錯碼,在記憶體塊被釋放前填
上0xA3的值,這樣子裡頭的內容看起來就保證像是垃圾了,而處理樹狀結構的副程
式將會在碰到這個已釋放節點時當掉。"好構想,不過這記憶體塊有多大?糟糕,
你不知道!

你可能會放下雙手,宣佈FreeMemory已經打敗你了。畢竟你不能檢查pv到底合不合
法,因為你沒辦法檢查。你也不能清掉已釋放記憶體塊的內容,因為你不知道它到
底多大。

不要放棄得太快。假設一下,你有個除錯函式sizeofBlock可以告訴你任何已配置
記憶體有多大。如果你有記憶體管理器的原始碼,你大概不用費多少力就可以寫出
這樣的函式。不過即使你沒有,也不用擔心-我會提供一個本章稍後要用到的
sizeofBlock實作。使用sizeofBlock,你可以在釋放一塊記憶體前先把它的內容清
掉。

void FreeMemory(void *pv)
{
    ASSERT(pv != NULL);

    #ifdef DEBUG
    {
        memset(pv, bGarbage, sizeofBlock(pv));
    }
    #endif

    free(pv);
}
這程式碼不只填寫記憶體塊的內容,還用呼叫sizeofBlock的副作用來核對pv合不
合法。如果這指標是壞的,sizeofBlock會發出警告-它能作到這點,因為它必須
認得每一個已配置的記憶體塊。

這可能很奇怪,我怎麼用了個除錯檢查巨集來檢查pv是不是NULL,何況NULL對
free來說是合法的參數值-ANSI標準說這種情形下free什麼事也不會作。理由很簡
單:我不相信除了方便以外,還有什麼理由要將NULL傳入函式中;這個除錯檢查巨
集只是簡單的核對這裡的指標內容而已。當然,你的想法可能不同,你也可以把這
個檢查拿掉。我所要指出的是,你不用盲目跟著ANSI標準所說的每一件事情。別人
認為free應該能接受NULL指標,並不表示你應該強迫自己也接受這種觀念。

realloc函式是另一個會釋放記憶體並製造垃圾的地方。底下是它的包裝函式:

flag fResizeMemory(void **ppv, size_t sizeNew)
{
    byte **ppb = (byte **)ppv;
    byte *pbNew;

    pbNew = (byte a)realloc(*ppb, sizeNew);
    if (pbNew != NULL)
        *ppb = pbNew;
    return (pbNew != NULL);
}
類似fNewMemory,fResizeMemory也傳回一個狀態旗標,顯示它是否已經成功改變
了記憶體塊的大小。假設pbBlock指向已配置記憶體,你就可以用底下的方法改變
記憶體塊的大小:

if (fResizeMemory(&pbBlock,sizeNew))
    成功 – pbBlock指向新的記憶體塊位址
else
    不成功 – pbBlock指向舊位址
你應該注意到了不像realloc,fResizeMemory不會在操作失敗時傳回NULL指標;它
把本來的指標傳回來,仍然指向本來那個沒改變大小的記憶體塊。

realloc函式(以及fResizeMemory)令人感到有趣的地方在於它同時處理free跟
malloc的功能,視你要的是擴增或縮減記憶體而定。在FreeMemory中,我在記憶體
塊釋放以前把它的內容處理掉。在fNewMemory中,我把malloc配置出來的新記憶體
塊的內容填成難看的垃圾。這兩件事你都得作到,好讓fResizeMemory穩固得起來
。這需要兩段不同的除錯碼:

flag fResizeMemory(void **ppv, size_t sizeNew)
{
    byte **ppb = (byte **)ppv;
    byte *pbNew;
    #ifdef DEBUG
        size_t sizeOld;
    #endif

    ASSERT(ppb != NULL  &&  sizeNew != 0);

    #ifdef DEBUG
    {
        sizeOld = sizeofBlock(*ppb);

        /* 縮減記憶體塊大小時,把尾端的內容處理掉。 */
        if (sizeNew < sizeOld)
            memset((*ppb)+sizeNew, bGarbage, sizeOld-
                sizeNew);
    }
    #endif

    pbNew = (byte a)realloc(*ppb, sizeNew);
    if (pbNew != NULL)
    {
        #ifdef DEBUG
        {
            /* 擴大記憶體塊時,初始化尾端的新區域。 */
            if (sizeNew > sizeOld)
               memset(pbNew+sizeOld, bGarbage, sizeNew-
            sizeOld);
        }
        #endif

        *ppb = pbNew;
    }
    return (pbNew != NULL);
}
這看起來多了一堆程式碼,不過當你仔細觀察時,你會發現到大部分都在#idef編
譯指示中。不過就算這些東西都是額外的程式碼好了,擔心這樣多出來的程式碼會
造成什麼問題都是浪費精力的事情。

除錯版本不需要既小又快;它們唯一的需要作到的就是穩定到讓程式員跟測試者能
夠跑得動它們。除非程式碼變得太肥或是慢到讓程式員與測試者拒絕用它,不然你
可以加入任何你想要的除錯碼來加強程式的穩定度。如果程式碼太大了或太慢了,
你可以用特定集合的除錯碼來產生混合版本的程式。

重要的是,檢查你的子系統,找出配置或是放記憶體的地方,確定它處理完的記憶
體中都是看來像垃圾的東西。


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

把丟掉的資料處理乾淨,才不會被誤用。


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

------------------------------------------------------------------------
--------
把區域變數用#ifdef圍起來真的很難看!
看一下sizeOld這個除錯用的區域變數。把它用#ifdef括起來也許不好看,可是對
於將所有除錯碼從發行版本的程式中移除有所幫助。當然,我曉得,如果你把
#ifdef從那邊拿掉,整個程式會更有可讀性,函式當然也還是會在發行版根除錯版
的程式中正確執行。唯一的缺點是,在發行版的程式中,你宣告了sizeOld卻從來
沒用到它。

這麼作看來好好的,卻有個大問題。如果維護程式的程式員沒注意到sizeOld只是
個除錯用的變數而誤用了它,這個沒經過初始化的變數會在發行版的程式裡頭造成
什麼問題?把這樣的變數用#ifdef圍起來,你可以避免程式元誤用這變數,而不會
在發行版的程式要編譯時讓編譯器發出錯誤訊息。

把除錯用的變數用#ifdef圍起來確實不好看,可是有助於去除一個潛在的錯誤。


------------------------------------------------------------------------
--------
搬家工人
假設你的程式呼叫fResizeMemory來擴大一個裝著可變長度資料的樹狀結構節點,
而不釋放這個節點的記憶體。如果fResizeMemory將節點在擴充記憶體塊大小時搬
移了,你現在就有兩個節點了:新位置上那個新的節點,跟舊位置上那個裝著沒處
理掉的垃圾的節點。

如果程式員寫的expandnode沒注意到fResizeMemory可能會在擴大節點大小時搬移
它的話,會發生什麼事?程式員不會讓整個樹狀結構的其他地方都維持在舊狀態,
周圍的節點仍然指向原來那個看起來仍然有效的節點?新的節點會不會就這樣被丟
著,沒有任何指向它的指標保留著?結果,你會有個看起來好好的,實際上卻壞了
的樹狀結構-跟一塊遺失的記憶體。這樣子不好吧?

現在你可能會想,讓fResizeMemory在搬移記憶體塊時毀掉舊內容不就好了?呼叫
一下memset就能作到這件事了。

flag fResizeMemory(void **ppv, size_t sizeNew)
{
    .
    .
    .
    pbNew = (byte a)realloc(*ppb, sizeNew);
    if (pbNew != NULL)
    {
        #ifdef DEBUG
        {
            /* 記憶體塊搬移時,把舊的那一塊的內容毀掉。 */
            if (pbNew != *ppb)
               memset(*ppb, bGarbage, sizeOld);
            /* 擴大記憶體塊時,初始化尾端的新區域。 */
            if (sizeNew > sizeOld)
               memset(pbNew+sizeOld, bGarbage, sizeNew-
                   sizeOld);
        }
        #endif

        *ppb = pbNew;
    }
    return (pbNew != NULL);
}
不幸的,你不能這麼作。即使你知道舊記憶體塊的位置跟大小,你也不能把它的內
容處理掉,因為你不知道記憶體管理器會對它掌握下的未使用記憶體作什麼處置。
有些記憶體管理器什麼也不作,其他的則將它用來存放未使用記憶體塊的串聯資料
或其他內部實作的資料。事實是,一旦你是放了記憶體,你就不再擁有它,也不能
再去動它了。如果你去動了你釋放的記憶體,你就得冒著系統當掉的風險。

"我克服了其他問題,我還需要擔心這個嗎?"我想式的,主要因為程式員們不知道
,或經常忘記,realloc可以搬移記憶體塊的位址。找出這個問題是重要的。

在一個特例中,我在替微軟內部的68000交叉組譯器加上新功能時,Word跟Excel的
程式員要我找出一個會隨機當掉系統的老臭蟲。唯一的困難在於這隻臭蟲很少出現
,每個人都不太常碰到它,可是每個人都碰過,所以他們希望先把這個問題找出來
。我不會告訴你整個細節經過,不過那花了好幾個星期,到處找來找去,我才弄出
一個可以重現那個錯誤的環境,然後我又花了三天才找出真正的出錯的地方。

為了找到這樣一個可以重現的錯誤,那真的花了很久的時間,不過我還是不知道為
什麼會出錯,我每次追蹤執行過那裡,所有東西看起來都很漂亮。我不知道那些看
起來很正常的資料原來是之前一個realloc留下來的垃圾。

不過真正的問題不在於我花了那麼久的時間去找這樣一個錯誤;而是花費了那麼大
的功夫才讓這個錯誤能被準確重現。不只因為realloc在擴大記憶體塊時把它搬家
了,舊的記憶體還被重複使用,填上新的資料。在組譯器中,這兩件事同時發生的
機率很小。

一條零錯誤程式寫作準則這時浮現了。你不要任何機率很小的錯誤。你得找出子系
統中可能發生的那些錯誤行為,確定它們真的會不會發生。常常,如果你發現子系
統中有個少見的怪異行為,最好加些檢查措施來盯住它。

如果realloc不是這麼少將記憶體塊搬來搬去的話,這個組譯器的錯誤其實可以在
幾個鐘頭內就被發現,而不用等好幾年。不過問題是,你該怎樣強迫realloc把記
憶體塊更常搬來搬去?答案是你辦不到,至少在你的作業系統允許你這麼作以前,
你是辦不到。不過你可以模擬realloc的行為。如果有名程式員呼叫
fResizeMemory來擴展一塊記憶體的大小,你可以在fResizeMemory中藉由配置一塊
新的記憶體,複製舊記憶體塊的內容到新的記憶體塊中,然後釋放本來的記憶體塊
,來達到搬移記憶體塊位址的目標。把fResizeMemory改成如下的寫法就能跟
realloc的動作一模一樣:

flag fResizeMemory(void **ppv, size_t sizeNew)
{
    byte **ppb = (byte **)ppv;
    byte *pbNew;
    #ifdef DEBUG
        size_t sizeOld;
    #endif

    ASSERT(ppb != NULL  &&  sizeNew != 0);

    #ifdef DEBUG
    {
        sizeOld = sizeofBlock(*ppb);
        /* 如果要縮小一塊記憶體,先將要釋放掉的記憶體部分清理掉。
         * 如果記憶體塊要擴大,就強迫它搬到新的位址去(而不是原地
         * 擴大)。
         * 如果新舊大小一樣,就什麼都不作。
        */

        if (sizeNew < sizeOld)
            memset((*ppb)+sizeNew, bGarbage, sizeOld-
                sizeNew);
        else if (sizeNew > sizeOld)
        {
            byte *pbForceNew;
            if (fNewMemory(&pbForceNew, sizeNew))
            {
                memcpy(pbForceNew, *ppb, sizeOld);
                FreeMemory(*ppb);
                *ppb = pbForceNew;
            }
        }
    }
    #endif
    pbNew = (byte a)realloc(*ppb, sizeNew);
    .
    .
    .
}
在上頭,我已經加上了只有在擴大記憶體塊時才會執行的程式碼。透過在釋放舊記
憶體塊前配置新記憶體塊的做法,可以確定記憶體塊的位址一定會被搬動,除非記
憶體配置失敗。如果記憶體配置失敗了,新的程式碼就像一大塊什麼都不作的程式
了。

不過回想一下我在裡頭加了什麼東西。這些新加上的程式不只強迫記憶體塊的搬移
,還-多了個副作用-會清理掉舊記憶體塊的內容。這副作用出現在FreeMemory將
記憶體塊釋放掉時。

現在你也許在想,"既然這程式模擬了realloc,為什麼它還要呼叫realloc?"畢竟
,你覺得應該可以在新程式碼裡頭加個return來讓整個函式跑更快一些:

if (fNewMemory(&pbForceNew, sizeNew))
{
    memcpy(pbForceNew, *ppb, sizeOld);
    FreeMemory(*ppb);
    *ppb = pbForceNew;
    return (TRUE);
}
你可以那樣作,沒錯,但是不要那樣作-那是個壞習慣。記住,除錯碼對整個程式
而言只是額外的東西,而不是讓程式變得不一樣的東西。除非你有令人信服的理由
不執行發行版程式中的程式碼,不然你應該要讓發行版程式的程式碼被執行到,即
使這樣作是多餘的。畢竟,沒有比執行一個程式更好的除錯方法,你應該要盡可能
讓發行版程式的程式碼在除錯時被執行到。

有時在我把這節的觀念解釋給程式員們聽時,會有人說總是把記憶體搬來搬去跟沒
搬一樣糟糕-他們說我只是把事情的做法寫成了另一個特例而已。這樣的看法值得
多提一下。

如果在除錯版跟發行版的程式中總是這樣作或從來不這樣作,當然是一樣糟糕的事
情。但在這例子中,發行版的fResizeMemory跟在除錯版時的表現是不一樣的。

很少出現的錯誤當然不構成問題,只要你能在除錯版時逼它常常出現,而讓它在發
行版的程式中不會出現的話。


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

如果出現了不太常發生的錯誤,就製造一個環境讓它經常發生吧。


------------------------------------------------------------------------
--------
保存記憶體配置的紀錄
記憶體管理器的問題-從除錯的觀點看來-在於當你剛配置一塊記憶體時,你雖然
知道它的大小,但你會幾乎立即失去這個資訊,除非你在某個地方保留一份紀錄。
你已經曉得sizeofBlock這函式多麼有用了,可是再想想,如果知道有多少塊已配
置的記憶體跟他們的位置,會多有用呢?如果你曉得這些資訊的用處,我就可以隨
便丟給你一個指標,讓你告訴我這指標有沒有指向一個正確配置的記憶體塊位置。
想想這會多有用處啊,尤其在檢查傳遞給函式的指標參數時。

假設你有個函式fValidPointer,取得一個指標跟大小參數後,它會在這個指標正
確指向一塊給定大小的記憶體時傳回TRUE。那藉由這個函式,你就可以寫出特定用
途而參數檢查更嚴格的常用函式了。舉例來說,如果你發現自己經常把資料填入配
置到的記憶體中,你就可以略過memset而使用你自己那個會檢查指標參數正確性的
FillMemory副程式了:

void FillMemory(void *pv, byte b, size_t size)
{
    ASSERT(fValidPointer(pv, size));

    memset(pv, b, size);
}
藉由呼叫fValidPointer,你可以確定pv指向一個合法的記憶體塊,而且至少配置
了size個位元組給這記憶體塊,這遠比memset的NULL指標測試要更有力多了。這是
個以速度跟程式大小交換安全性的例子。

或者,你也可以選擇在除錯版的程式中呼叫FillMemory而在發行版的程式裡直接呼
叫memset. 你可以在發行版的程式中加上一行這樣的巨集來達到這個目的:

#define FillMemory(pb,b,size)  memset((pb),(b),(size))
不過我不是要提這個。

我要說的是,如果你在除錯版的程式中提供額外資訊,你往往可以提供更強悍的錯
誤檢查功能。

到現在為止,你已經看過如何用sizeofBlock在FreeMemory跟fResizeMemory中清理
記憶體塊的內容,可是清理記憶體內容跟保留一份每個記憶體塊配置的資訊比較起
來,前者的效力就弱多了。

再來假設一下最糟的狀況:你沒辦法從子系統中取得記憶體配置的資訊。對一個記
憶體管理器而言,最糟的狀況代表你不能取得一塊記憶體的大小,你沒辦法分辨一
個指標是否有效,你也沒辦法分辨有一塊記憶體還是一堆記憶體塊。如果你需要這
些資訊,你得自己提供這些東西,也就是說自己紀錄配置了哪些記憶體。你怎麼紀
錄不打緊,要緊的是你必須在需要的時候取得它。

這裡有個維護這些紀錄的可行辦法:在使用fNewMemory配置一塊記憶體時,多配置
一塊紀錄項目;當你使用FreeMemory釋放一塊記憶體時,把紀錄資訊一起釋放掉;
當你使用fResizeMemory改變記憶體塊的大小時,就更新紀錄資訊來反映新記憶體
塊的位置跟大小。不用驚訝,這三個動作可以被分離成三個除錯介面:

/* 幫新記憶體塊建立一筆記錄。 */
flag fCreateBlockInfo(byte *pbNew, size_t sizeNew);

/* 釋放一塊記憶體的紀錄資訊。 */
void FreeBlockInfo(byte *pb);

/* 更新一塊現成記憶體的資訊。 */
void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew);
當然,這些副程式怎麼維護紀錄資訊是不太重要的事情,只要他們不會把系統拖慢
到不能用的地步就好了。你可以在附錄B中找到實作這些記憶體配置紀錄功能的程
式碼。

更動FreeMemory跟fResizeMemory來呼叫適當的記憶體配置紀錄函式是挺簡單的一
件事。改過的FreeMemory變成

void FreeMemory(void *pv)
{
    #ifdef DEBUG
    {
        memset(pv, bGarbage, sizeofBlock(pv));
        FreeBlockInfo(pv);
    }
    #endif

    free(pv);
}
在fResizeMemory中,如果realloc成功改變了記憶體塊的大小,你可以呼叫
UpdateBlockInfo. 如果realloc失敗了,就不用更新任何東西。fResizeMemory的
尾端變成

flag fResizeMemory(void **ppv, size_t sizeNew)
{
    .
    .
    .
    pbNew = (byte a)realloc(*ppb, sizeNew);
    if (pbNew != NULL)
    {
        #ifdef DEBUG
        {
            UpdateBlockInfo(*ppb, pbNew, sizeNew);
            /* 擴大記憶體塊時,初始化尾端的新區域。 */
            if (sizeNew > sizeOld)
               memset(pbNew+sizeOld, bGarbage, sizeNew-
                   sizeOld);
        }
        #endif
        *ppb = pbNew;
    }
    return (pbNew != NULL);
}
修改fNewMemory有點複雜些,所以我留到最後才講。當你呼叫fNewMemory配置記憶
體塊時,系統必須配置兩塊記憶體出來:一塊給你,另一塊給fNewMemory紀錄配置
給你的記憶體的資訊。一個fNewMemory呼叫要成功,兩個記憶體配置的呼叫都必須
要成功才行;不然你就會有塊沒有紀錄的記憶體了。這一點是重要的,因為沒有紀
錄的記憶體會在任何一個有檢查指標參數正確性的函式時中被除錯檢查巨集的檢查
欄下來。

在接下來的程式碼中,你將看到fNewMemory成功配置一塊記憶體後卻沒辦法配置紀
錄用的記憶體塊時,它會把第一塊記憶體釋放掉,然後偽裝成記憶體配置失敗的情
形。這樣讓記憶體系統與紀錄資訊保持同步狀態。

flag fNewMemory(void **ppv, size_t size)
{
    byte **ppb = (byte **)ppv;

    ASSERT(ppv != NULL  &&  size != 0);

    *ppb = (byte a)malloc(size);

    #ifdef DEBUG
    {
        if (*ppb != NULL)
        {
            memset(*ppb, bGarbage, size);


            /*
            如果沒辦法配置紀錄記憶體塊資訊所需的記憶體,
            就偽裝成記憶體配置失敗的情形。
            */
            if (!fCreateBlockInfo(*ppb, size))
            {
                free(*ppb);
                *ppb = NULL;
            }
        }
    }
    #endif

    return (*ppb != NULL);
}
現在你有了記憶體系統的完全掌控,你可以輕鬆寫出sizeofBlock跟
fValidPointer函式(你也可以參考 附錄B )或任何有覺得有用的東西了。


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

留下除錯紀錄可以強化錯誤檢查的功能。


------------------------------------------------------------------------
--------
不要等到錯誤被用到
到現在為止,我所建議的每個修改動作都幫你在錯誤出現時找出它們來。這很好,
不過不是自動的。想想稍早提到的deletenode副程式的例子,如果這程式呼叫
FreeMemory來釋放一個節點,然後留下一個錯誤指標在樹狀結構中,如果這些指標
從來沒被用到,你會發現這樣的錯誤嗎?當然不會。那如果我在fResizeMemory中
忘了呼叫FreeMemory呢?

if (fNewMemory(&pbForceNew,sizeNew))
{
    memcpy(pbForceNew,*ppb,sizeOld);
    /* FreeMemory(*ppb); */
    *ppb = pbForceNew;
}

------------------------------------------------------------------------
--------
不確定原理與其他鬼怪
有時在我對程式員解釋使用除錯檢查的觀念時,有人會關切加上這樣的測試碼會不
會有什麼麻煩發生。海森堡的不確定原理這時就開始作怪了。

無疑的,除錯碼會在你程式的發行版跟除錯版間產生差異,不過只要你小心不去動
到程式的行為方式,這樣的差異應該可以忽視不計。fResizeMemory的除錯版會更
常搬動記憶體塊的位置,可是它不會改變程式的基本行為。相似的,fNewMemory會
在除錯版中配置比你要的更多的記憶體(用來存放記憶體配置紀錄的資訊),可是
它不會影響到你程式的行為。如果你希望fNewMemory或malloc確實在你要配置21個
位元組的記憶體時就只給你那麼多記憶體,那不管你有沒有加上除錯碼,都會碰到
麻煩。為了遵守記憶體位址對齊的問題,記憶體管理器經常會配置比你要的更多的
記憶體。

另一個論點是,除錯碼本身會讓程式變胖,因而佔用更多記憶體。不過你得記住一
點,除錯版本出現的用意在於找出錯誤來,而不是讓記憶體發揮最大用處。也許你
沒辦法載入你手上最大的試算表資料或是編輯最大的文件,或者你的程式作不到任
何要吃很多記憶體的動作,那都無妨。最糟糕的情形是,你會比正常更快耗光可用
記憶體,比往常更常觸動你的錯誤處理程式。最好的情形則是除錯碼不用太費事就
快速抓出了錯誤的地方。


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

我製造了一隻躲著的臭蟲。它躲著,因為沒有任何東西會因為這樣子而出錯。不過
每次你執行程式時,你就會"失去"一塊記憶體,因為唯一參考到它的指標已經在你
把pbForceNew的值指派給*ppb時毀掉了。除錯碼能捉出這個錯誤嗎?完全不行。

如這樣的錯誤與其他稍早提到過的問題不同的地方在於,沒有任何非法行為浮現檯
面。就好比壞人如果都不出城,那在出城的路上設下路障有什麼用一樣。到目前為
止所提到過的除錯碼的寫法,在捕捉讓錯誤資料從來不會被用到的錯誤時完全沒用


要找出這些錯誤,你得在程式中到處查探。不是坐著等錯誤出現,而是自己去找。


在第一個例子中,你有一個指向已釋放記憶體的指標。在第二個例子中,你有一塊
失去參考指標的已配置記憶體。這些錯誤通常不好找,不過你手上有除錯資訊,你
可以用這些資訊來找出它們來。

想想你在銀行中怎麼尋找錯誤:你有一串你認為你握在手上的資金紀錄;銀行有一
串它認為你借了的資金紀錄;只要比對兩份紀錄,就能找出錯誤來。這樣子你會發
現錯誤指標跟遺失記憶體塊的問題其實沒差別,你只要比較存放在你自己的資料結
構中的已知指標的紀錄跟存放在除錯資訊中那份已知配置記憶體塊的紀錄,你就能
找出一個指標是否沒指向已配置的記憶體,或者一塊記憶體沒有任何指標參考到它


不過程式員們-特別是經驗老到的程式員們-對這個檢查每個資料結構中的指標的
想法會猶豫不決,因為追蹤這些東西似乎是困難的,即使可行的話。事實上,只有
寫得最差的程式才會把一堆指標分開來放。

舉例來說,稍早提到的68000組譯器會配置753個符號名稱所需的記憶體,可是它不
會追蹤753個整體變數的值,當然不能用那麼笨的方法。它用一個陣列,一個雜取
表,一個樹狀結構或一個簡單的串列來作這件事。也許會有753個符號名稱,可是
要檢查這些東西很簡單,而且不用多大的程式碼就作得到。

要把一串存在資料結構中的指標跟除錯資訊中存放的記憶體配置串列進行比較,我
定義了三個函式,與上一節提到的資訊蒐集副程式搭配在一起-你可以在附錄B找
到這些東西的實作:

/* 將所有記憶體塊標示成為沒被指標指到。 */
void ClearMemoryRefs(void);

/* 將一塊記憶體標示成被pv指到。 */
void NoteMemoryRef(void *pv);

/* 檢查參考旗標,找出遺失的記憶體塊。 */
void CheckMemoryRefs(void);
這些副程式的用法很簡單。首先,你呼叫ClearMemoryRefs將除錯資訊初始化。然
後檢查整體資料結構,並呼叫NoteMemoryRef,這麼會作檢查指標的正確性,同時
標示有指標參考到的記憶體塊。一旦完成了這個步驟,每個指標應該都被核對過了
,而每一塊記憶體也應該都有個被參考到的旗標。最後,呼叫CheckMemoryRefs來
檢查是否每個記憶體塊都被標示成參考到了;如果CheckMemoryRefs發現一塊未標
示的記憶體,它就會發出警告,告訴你找到遺失的記憶體塊了。

看看這些副程式怎麼用來核對68000組譯器中的指標吧。為了簡單起見,假設組譯
器的符號表以二元樹存放,每個節點長得像這樣:

/*
 * "symbol"是個符號名稱的節點定義。
 * 每個定義在使用者的組合語言原始碼中的符號都會配置一個節點。
 */

typedef struct SYMBOL
{
    struct SYMBOL *psymRight;
    struct SYMBOL *psymLeft;
    char *strName;              /* 文字表示式 */
    .
    .
    .
} symbol;                       /* 命名方式: sym,*psym */
上頭只列出三個指標欄位。前兩個欄位式指向左子樹跟右子樹的指標;第三個則是
個零字元結尾的符號字串。一旦你呼叫過ClearMemoryRefs,你在處理這個樹狀結
構時就得註記一下你用的每個指標。我把這些程式從一個除錯專用的函式中分離出
來:

void NoteSymbolRefs(symbol *psym)
{
    if (psym != NULL)
    {
        /* 繼續執行前,先檢查目前節點的正確性。 */
        NoteMemoryRef(psym);
        NoteMemoryRef(psym->strName);

        /* 檢查兩個子樹的正確性。 */
        NoteSymbolRefs(psym->psymRight);
        NoteSymbolRefs(psym->psymLeft);
    }
}
上頭的程式碼對整個符號表以前序處理的方式進行指標的檢查。正常說來,由於符
號表是存成中序樹狀結構,我應該用中序處理的方式才對,可是我沒有這麼作,因
為我想在跳過一個節點以前先檢查它。這樣作需要以前序搜尋的方式進行。如果你
採用中序或後序進行的處理方式,你必須在檢查psym以前先使用它提供給你的指標
,那可能會讓這函式在遞迴許多遍以後讓系統當掉。是的,錯誤出現了,可是一個
控制良好的除錯檢查巨集發出的警告比一個隨機性的當機更好找出原因來。

一旦你寫好了其他資料結構用的Note-Ref副程式,將它們包裝在單一個副程式中,
這樣你就可以從程式的任何地方呼叫它們了。對我的組譯器來說,這個副程式會長
得像這樣:

void CheckMemoryIntegrity(void)
{
    /* 將所有區塊標示成未參考到。 */
    ClearMemoryRefs();

    /* 註記所有已知的整體記憶體配置。 */
    NoteSymbolRefs(psymRoot);
    NoteMacroRefs();
    .
    .
    .
    NoteCacheRefs();
    NoteVariableRefs();

    /* 確定所有東西都正常。 */
    CheckMemoryRefs();
}
唯一剩下的問題是,你怎麼呼叫這個副程式?顯然,你得經常呼叫它,不過呼叫的
時機取決於你的程式怎麼寫的。至少,你應該在你準備使用子系統時呼叫它。更好
一點,你應該在你的程式等待使用者輸入時呼叫它,不管是案件輸入、滑鼠移動或
是撥動某個硬體開關。你也可以在同樣的時機檢查其他的問題有沒有出現。


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

建立徹底的子系統檢查,經常作檢查。


------------------------------------------------------------------------
--------
知道了就很明顯的事
Robert Cialdini博士在他的Influence: How and Why People Agree to Things
(Morrow,1984)一書中指出,如果你是個銷售員,有人走進你的男裝店想買件毛衣
跟套裝,你應該先給那個人看看套裝,然後再讓他看毛衣。你的銷售額就會更好,
因為在你賣給一個人500元的套裝後,一件80元的毛衣就顯得很便宜了。不過如果
你反過來作,你的顧客會覺得80元的毛衣太貴了,他要35元的就好了。很明顯,任
何人都可以在半分鐘內想到為什麼這麼作,可是多少人會這麼作呢?

同樣的,有些程式員也許會覺得挑選bGarbage的數值是簡單的事情-就挑任一個老
數字就好了。其他程式員也許會覺得在處理符號表的樹狀結構時,不管怎麼遞迴處
理都不打緊,管他是前序、中序還是後序處理?不過,就如我前面指出的,有些選
擇就是比別的選擇要來得好。

如果你發現自己在處理實作細節時作出了隨便的選擇,停下來思考半分鐘,看看會
發生什麼事;對每件可能發生的事情,問問你自己,"這會產生問題,還是會幫我
找出問題?"如果你對bGarbage值的問題作出同樣的詢問,你就會看到挑選0會製造
問題,而挑選0xA3會幫你找出問題來。


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

小心設計測試檢查。

沒有東西是可以隨便來的。


------------------------------------------------------------------------
--------
不用知道的事
你當然也會碰到過必須先知道一堆東西才會用的測試方式。fValidPointer就是個
例子;如果你不知道有這東西,你當然就不會用它。不過最好的測試方式是透明的
-無論程式員知不知道這些東西,它們都會運作著。

假設一名菜鳥程式員或不熟悉專案的人加入你的團隊中。這個人難到不能在不清楚
fNewMemory,fResizeMemory跟FreeMemory這些東西怎麼運作的前提下使用這些測
試函式嗎?

如果一名新進程式員不曉得fResizeMemory可能會搬動記憶體塊的位置而產生一個
如我的組譯器中出現的錯誤一樣的問題呢?這個人得知道那些子系統整合度檢查怎
麼作,才能讓系統丟出一個非法指標的警告嗎?

假設一名新程式員製造了一大堆遺失的記憶體塊。再一次的,一堆檢查動作了,並
且告訴這個人程式中有遺失記憶體塊的錯誤。這名新程式員甚至可能不曉得什麼是
遺失的記憶體塊;他或她當然也不用曉得這些檢查是怎麼進行的。最好,經由找尋
錯誤的根源,這名菜鳥會了解什麼是遺失的記憶體塊-而不用花一名老程式員的時
間來教這名菜鳥。

這就是設計完善的子系統測試的厲害之處-當它們逮到一隻臭蟲時,它們會揪住這
個錯誤,把問題報告出來,打斷你正常的時間規劃來處理這個問題。你去哪裡找比
它們回報狀況報得更好的測試員呢?


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

積極實作透明的整合度檢查。


------------------------------------------------------------------------
--------
不要把除錯版的程式發行出去
我曉得在本章中對記憶體管理器已經加上了一大堆程式碼。有些程式寫作者也許會
認為,"這些東西似乎值得,可是全都加上去的話,再加上那些紀錄資訊的程式碼
就真的太多了。"我得承認,我也有過這種感覺。

我對在程式中加上這麼多沒效率的東西曾經有過直覺的反感,可是我很快就了解我
錯了。在一個公開發行版的程式中加上這些東西,當然會讓它在市場上一敗塗地,
可是我們只在除錯版的程式中加上這些檢查。當然,除錯碼降低了執行效率,不過
哪件事情比較糟糕?讓你的零售產品當死在使用者面前,或者你的內部除錯版在幫
你找尋錯誤時會跑得比較慢?你應該不用太擔心除錯碼的效率問題。畢竟,你的顧
客不是拿那個版本來用的。

對使用的感覺上,區別除錯版跟發行版程式是重要的。你拿除錯版的程式來找尋錯
誤,拿發行版的程式來討好顧客。就因為這樣,兩個版本之間的程式碼效率跟代價
也是截然不同的。

記住,當你的產品必須滿足顧客對大小跟速度的需求時,你可以在自己的除錯版程
式中作任何你認為可以用來找出錯誤的事情。如果加上記憶體管理器對已配置記憶
體的紀錄資訊可以幫你找出各種骯髒的臭蟲,那一定會皆大歡喜的。你的使用者有
個跑得快又好的程式,而你自己則不用花很多時間跟精力來找尋問題。


------------------------------------------------------------------------
--------
歷史註記
微軟慣於經常性的發出除錯版的程式給公開測試人員,讓它們幫忙找出更多錯誤。
因為有本叫做"搶鮮版"(Prerelease)的雜誌上的一篇報導,微軟公司一度停止這
麼作-根據產品的除錯測試版-這篇報導判定,微軟的程式很好,可是跑得跟三指
樹獺一樣慢。如果是你,當然也會覺得這是個警訊。不要把除錯版丟給測試人員,
不然就告訴他們這個除錯版裡頭有些內部除錯檢查會拖慢執行速度。如果你的程式
會顯示開頭訊息,最好在上頭聲明這一點。


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

微軟的程式員經常在程式中加入除錯碼。Microsoft Excel,就是個例子,包含一
個記憶體子系統測試(比這裡提供的那些更完整),一個儲存表整合度測試,跟人
工假造記憶體配置的機制,讓測試人員可以強迫程式執行記憶體用完了的錯誤處理
程式,還有其他一大堆檢查。不用說,Excel沒問題了才會推出-它有記憶體子系
統檢查-可是在給末端使用者的發行版本裡頭幾乎不會有這些東西。

我知道我在本章中加上了一大堆記憶體管理器相關的程式碼,不過你可以想想:所
有新的程式碼都寫在包裝函式fNewMemory,FreeMemory跟fResizeMemory中;沒有
半個是加在呼叫這些函式的程式裡頭,也沒有半點需要加在malloc,free跟
realloc的實作中。

而速度也沒有你預期的下降那麼多。如果微軟的結果是典型的例子,那除錯版的程
式-加滿了除錯檢查巨集跟子系統檢查的-大約有發行版本的程式執行速度的一半



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

不要將發行版的限制加到除錯版上。

用大小跟速度作為代價來找尋錯誤。


------------------------------------------------------------------------
--------
現在就把錯誤找出來,不要等以後再找
本章中,我提供了半打的方式來加強記憶體子系統的檢查,不過這些做法也可以用
到其他方面。想像一下怎麼可能有錯誤跑得過徹底自我檢查的程式眼線呢?同理可
推,如果這些除錯檢查被用在我提到的那個68000組譯器中,那個難以理解的
realloc問題就不用花上好幾年來找,而會自動在程式第一次寫出來的幾個小時或
幾天內被找到。我不在乎一名維護程式的程式員是否技術高超或是菜鳥一個;這些
測試都能抓得住臭蟲。

事實上,這些測試應該已經抓住所有類似的錯誤了。自動的,而不用任何運氣或技
巧。

這就是你該怎樣寫出零錯誤程式的方法。


------------------------------------------------------------------------
--------
快速回顧
檢查你的子系統,問問你自己,程式員們可能會怎麼誤用它。加上除錯維護敘述跟
核對檢查來捕捉不好找跟常見的錯誤。

如果你不重複找尋,就修不好錯誤。找尋任何可能造成隨機行為的東西,把它們從
除錯版程式中拿掉。將未初始化的記憶體以某個垃圾值填滿只是一種去除隨機行為
的辦法,那樣子如果有參考到未初始化記憶體的情形發生,你就可以在每次執行到
出錯的程式時都能重複同樣的現象。

如果你的子系統釋放記憶體(或其他資源)然後製造垃圾,請清理被釋放的記憶體
中的內容,讓裡頭的東西看起來像垃圾;不然別處的程式可能會繼續使用這些已釋
放記憶體中的東西而不被察覺。

類似的,如果你的子系統有些可能會發生卻不一定發生的行為,加上除錯碼來確定
這些行為一定會發生。讓每件事都發生過可以增進你捕捉到較少執行到的程式中出
現的怪異現象。

確定你的測試工作在程式員沒注意到時都在進行著。最佳的測試方式就是那些完全
不用在乎它們的存在的測試。

如果可能,將測試碼建立在子系統裡頭,而不要寫在它們上頭。不要等到子系統已
經寫好了,才來想辦法查對它們的動作正不正確。對每個你考慮的設計方式,問問
你自己,"我該怎樣徹底查對這個實作?"如果你發現幾乎不可能或難於測試這個實
作方式,好好考慮一下是不是該換個設計方式,即使那表示拿程式大小跟執行速度
為代價來讓系統可被測試。

在拿掉一個讓程式跑得很慢或吃很多記憶體的查核測試前,多想一遍。記住,查核
程式碼不會出現在發行版的程式裡。如果你發現自己想著,"這些測試太慢了(或
太大了)",停下來,問問自己,"我該怎樣讓這些測試繼續留著,而讓程式跑快一
點(或變小一點)?"


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

------------------------------------------------------------------------
--------
該想想的事
在測試程式時,你碰到了一大堆0xA3之中,你知道你大概用了未初始化的資料或是
已經釋放了的記憶體塊。你該怎麼改變除錯碼,讓它更好決定你碰到了哪一種情形

程式員們偶爾寫出會蓋過已配置記憶體末端的程式。描述一下你該怎麼增強記憶體
子系統的檢查來找出這類的問題。
雖然CheckMemoryIntegrity副程式會抓出指向已釋放記憶體的非法指標的問題,它
也有找不到問題的時候。舉例來說,假設你有個函式呼叫了FreeMemory,可是函式
中的錯誤留下了一個指向已釋放記憶體的指標。更進一步,假設在這指標被核對正
確與否以前,有程式呼叫了fNewMemory,並重新配置了一塊之前釋放掉的記憶體。
然後那個錯誤的指標指向了剛被配置的這塊記憶體,雖然這個記憶體塊已經不是這
指標本來所指向的那一塊了。這當然是個錯誤,可是CheckMemoryIntegrity也找不
出這問題來,每件是看起來都好好的。如果這在你的程式中是個常見的問題,你該
怎樣增強系統來找出這些問題?
藉著NoteMemoryRef副程式,你可以查核每個程式中的指標,不過你要怎麼檢查記
憶體塊的大小?舉例來說,假設你有個合法的指標指向一個18個字元長的字串,可
是記憶體塊的大小小於那個字串的話,你該怎麼辦?或者反過來,你的程式認為它
配置的記憶體有15個位元組的空間,可是記憶體配置紀錄資訊顯示你配置了18個位
元組?這通常是很不好的情形,你該怎樣強化整合度檢查來找出這些問題?
附錄B中的NoteMemoryRef副程式讓你能將一塊記憶體標示為已被參考狀態,但是它
不會在碰到一個記憶體塊被參考五次而實際上只被參考一次時警告你。舉例來說,
一個雙向連結串列應該對某個節點都有兩次參考:一個是前向指標,一個是後向指
標。不過在大多數情形下,你的記憶體塊只會有一次參考,如果有超過的情形,某
個地方一定出錯了。你該怎樣改善整合度檢查來讓多重參考情形能被除錯檢查巨集
判斷,並找出不應該發生的情形?
綜觀本章,我提到你可以在記憶體系統中加上幫程式員找出問題的除錯碼。可是該
怎麼加上程式碼來幫助測試員找出問題?測試員知道程式員們常常會搞錯錯誤狀態
應該怎麼處理,所以你該怎樣讓測試員能夠偽造記憶體不足的情形?

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

------------------------------------------------------------------------
--------
學習計劃:
檢查你程式的主子系統。你能用怎樣的除錯碼來捕捉跟這些子系統相關的常見錯誤



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

------------------------------------------------------------------------
--------
學習計劃:
如果你沒有作業系統的除錯版本,盡可能取得一份;不然運用包裝功能的技巧,自
己寫一個。如果你覺得自己很好心,還可以把這份自己寫的包裝功能公開出來-以
某種形式-給其他程式開發者。


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

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


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

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