荔园在线

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

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


发信人: jek (Super Like Cat), 信区: Program
标  题: 完美程式設計指南--動販賣機介面
发信站: 荔园晨风BBS站 (Thu Apr  4 06:59:52 2002), 转信

微軟公司提供給員工的一大福利就是免費的汽水、加味礦泉水、牛奶(也有巧克力
調味乳!)跟小紙盒裝的果汁,多少都沒關係,隨你取用。可是很該死的,如果你
要甜點,就得自己付錢了。偶爾我想吃點零食,就得走到販賣機前面去。投入硬幣
後,我按下4跟5,然後討厭的看著機器吐出墨西哥辣椒口味的口香糖,而不是我要
的老祖母牌花生奶油餅乾。在看了一下餅乾的代號後,我知道我又弄錯了:我要的
餅乾是該按21號,投45先令的硬幣。

那台自動販賣機總是讓我生氣,如果製造它的工程師肯多花半分鐘來想想他們設計
上的缺失,就不會讓我跟其他人花錢卻按到自己不想要的東西了。如果有個設計者
想,"嗯,人們應該會在投錢的時候想著‘45先令’-我賭一定有人會跟著也在把
價錢當成選擇東西時的代號而弄錯想要的東西。要避免這種事情發生,我們應該用
字母鍵代替數字鍵來選擇點心的種類。"

改良過的機器製造起來不會花更多錢,也不會改變什麼設計方式,可是如果機器是
如我上頭說的改良過的,當我想按下45時,我就會知道那不是我想按的,而會記得
要去按字母鍵來選擇我要的點心。這樣的介面設計可以讓人們不會弄錯。

當你設計函式的介面時,你一定經歷過類似的問題。不幸的,程式員們不常被訓練
成思考其他人會怎樣使用他們寫的函式,而且就像那不自動販賣機一樣,設計上的
一點些微差異都可能製造出錯誤或者避免錯誤的發生。你寫的函式沒有錯誤還不夠
;你寫出來的東西使用起來還要是安全的才行。

getchar當然會傳回整數值
許多標準C語言程式庫函式跟數以千計用到這些程式庫函式的其他函式,都跟我提
到的販賣機一樣有著令人噴飯的設計方式。就拿getchar函式來說,它的介面設計
就有好幾個危險之處,而最嚴重的問題在於這樣的設計容易讓程式員們寫出有問題
的程式碼來。看看Brian Kernighan跟Dennis Richie在The C Programming
Language一書中提到的:

考慮這段程式碼

char c;

c = getchar();
if (c == EOF)
  ...
在一部沒有正負號擴展功能的機器上,c一定是正的,因為它是個字元變數,而
EOF是個負數。所以,這個測試的結果一定會不成立。要避免這種情形,我們得小
心地使用整數變數來存放getchar傳回來的結果,而不是用字元變數。

像getchar這樣的名稱當然會讓人想把c定義成一個字元,而這就是為何程式寫作者
會在這裡出錯的原因。不過實際上,為何getchar會如此危險?它又沒有什麼大功
能:只不過是試著從一個周邊裝置取得一個字元,並傳回一個可能的錯誤狀態而已


底下的程式碼片段說明了另一個許多函式介面中常見的錯誤:

/*
strdup – 配置一塊記憶體,並將
輸入的字串複製到新配置的記憶體中。
*/

char *strdup(char *str)
{
    char *strNew;

    strNew = (char a)malloc(strlen(str)+1);
    strcpy(strNew, str);

    return (strNew);
}
這段程式碼會運作得很正常,直到你用光了記憶體,碰到了malloc傳回來配置記憶
體失敗,傳回來一個NULL指標的錯誤情形。誰曉得在strNew是個NULL指標時,
strcpy會作出什麼事來?也許是把程式當掉,或把記憶體填上垃圾,反正都不是寫
程式的人想要的結果。

程式員們在使用malloc跟getchar時會碰到問題,因為他們寫的程式即使有缺陷,
在大部分情形下也會正常運作著。只有在幾星期或幾個月後,就像鐵達尼號的意外
一樣,一連串不可能發生的事情湊在一起釀成了災難,程式才會非預期的當死。並
不是說malloc跟getchar讓程式員們寫錯了程式碼;只是程式員們忽略了這兩個函
式可能產生的錯誤狀態。

getchar跟malloc的問題在於它們傳回來的值不完全是真正的結果。有時它們傳回
來你期望的有效資料,其他時候它們則傳回來神奇的錯誤值。

如果getchar不會傳回那個好笑的EOF值,那把c宣告成字元變數就沒什麼錯誤,也
不會出現Kernighan跟Ritchie說的那個問題了。同理,如果malloc不會傳回NULL,
程式員就不用處理錯誤狀態了。這些函式的問題並不在於它們會有錯誤狀態,而在
於它們把這些錯誤狀態的反應經由傳回一般結果的管道送出,讓程式員容易因此忽
略了錯誤情況的處理。

如果你重新設計getchar來讓他同時傳回兩種輸出結果,那會怎樣呢?它會傳回
TRUE或FALSE讓你判斷是否正確讀到了資料,而如果讀到了資料,會把讀到的字元
放在一個經由指標參考到的字元變數內:

flag fGetChar(char *pch);       /* 函式的雛形宣告 */
使用上面這樣的介面宣告,程式很自然就會被寫成這樣:

char ch;

if (fGetChar(&ch))
    ch存放著下一個字元;
else
    碰到了檔案結尾,ch放著是垃圾內容;
字元型態跟整數型態差異的問題消失了,而且對任何資歷的程式員來說,都不會忘
了檢查是不是有錯誤發生。比較getchar跟fGetChar傳回的值,你看到了getchar比
較強調傳回來的字元,而fGetchar比較強調傳回來的錯誤狀態了嗎?如果你想寫出
零錯誤程式,你覺得該強調哪一種傳回值的方式?

對,你失去了把程式寫成底下這樣的彈性

putchar(getchar());
可是你能多麼確定getchar一定會正確讀到字元?在幾乎任何情形下,上頭的程式
碼都可能造成錯誤。

有些程式員可能會想,"當然,fGetChar有個比較安全的介面,可是你為了在呼叫
它時傳入額外的參數而浪費了些程式碼。如果程式員把ch當成了&ch送進去給它呢
?畢竟,忘了寫&是程式員在寫用scanf函式時的老毛病了。"

好問題。

編譯器產生的執行碼好不好完全看編譯器的最佳化程度而定,不過大部分的編譯器
都會在呼叫上頭的函式時產生多一點執行碼。不過這麼微小的程式大小增長並不值
得憂慮,尤其當你考量磁碟空間與記憶體成本正快速下滑,而程式複雜性與隨之而
來的錯誤發生率正逐漸攀升時,這樣的差距在未來只會拉得更大。

第二個重點-拿個字元變數當成fGetChar的字元指標參數用的情形-如果你在使用
第一章中建議的函式雛形檢查的話,就不用擔心了。如果你把字元指標以外的東西
傳給fGetChar,編譯器就會自動產生錯誤訊息,並告訴你哪邊出錯了。

把不同輸出結果混在單一個返回值中送出的做法反映了組合語言時代中一部微處理
器只有有限暫存器能用來處理資料跟傳遞資料的現實限制。在那樣的環境中,一個
暫存器能同時傳回兩個輸出結果不只是有效率而已,還經常是必要的。可是用C語
言寫程式就不同了-即使C語言讓你"更接近低階環境",那並不表示你應該把程式
當成高階的組合語言來寫。

當你設計函式介面時,選擇能讓程式員把程式一次就寫對的方式。不要用讓人傷腦
筋的雙重用途返回值的設計-每個輸出結果都應該只用來表達一種東西。設計介面
時就明確限定各個輸出結果的用法可以讓使用者不會忽略掉重要的細節。


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

讓錯誤狀態難以忽略。

不要將錯誤代碼跟別的輸出結果一起放在返回值中。


------------------------------------------------------------------------
--------
考慮多一點
程式員們當然知道他們哪時會從一個返回值中獲得多個輸出結果,所以照著上面的
建議作挺容易的-不要這麼把一堆東西塞在一個返回值裡頭就好了。不然這樣的使
用介面就像特洛伊木馬一樣隱藏著危險。看看底下這個改變記憶體塊大小的程式碼


pbBuf = (byte *)realloc(pbBuf,sizeNew);
if (pbBuf != NULL)
    初始化或使用更大的緩衝區
你看出哪裡有問題了嗎?如果你不曉得,不用擔心-問題嚴重,但被隱藏起來了,
如果沒有提示,只有很少人找得出這個問題。所以,來個提示吧:如果pbBuf是唯
一指向這塊改變大小的記憶體的指標,那在realloc失敗時會發生什麼事?答案是
NULL將會在realloc返回時蓋過pbBuf本來的值,毀掉指向本來記憶體塊的唯一指標
。這段程式碼製造了遺失的記憶體塊。


------------------------------------------------------------------------
--------
這就有個問題了:
你多常把要更動大小的記憶體塊的新舊指標放在不同變數中?我會把這個問題想像
成開一輛車進飯店中,而開另一輛車出來。當然,你會有想將新指標放到不同變數
中的時候,但一般說來,如果你改變一塊記憶體的大小,你會把變更過的結果放回
本來的指標變數中。這就是為何程式員們常掉進realloc的陷阱中了,realloc就像
微軟裡頭那個自動販賣機的介面一樣容易讓人出錯。


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

理想上,realloc永遠會將錯誤碼跟指向記憶體塊的指標一起傳回來,無論記憶體
塊的大小有沒被改變。那應該是兩個不同的輸出結果。讓我們再看一下
fResizeMemory,那個我在第三章中提到的realloc包裝函式。底下是拿掉了除錯碼
的fResizeMemory的樣子:

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);
}
注意一下上頭那個if敘述-他確保本來的指標永遠不會被銷毀。如果你從頭用
fResizeMemory替代realloc來重寫本節開頭那段程式,你會寫成

if (fResizeMemory(&pbBuf,sizeNew))
    初始化或使用更大的緩衝區
在這裡,如果改變記憶體塊大小的動作失敗了,pbBuf會保留原樣,繼續指向本來
的記憶體塊;pbBuf不會被改成NULL。這就是你想要的行為,不過又有個問題:"一
名程式員如果使用fResizeMemory,會不會遺失記憶體塊?"還有另一個問題:"程
式員會不會忘了處理fResizeMemory的錯誤狀況?"

另一個要提出來的有趣問題是,如果程式員們習於照著本章稍早的建議-"不要把
錯誤碼放在返回值中"-就不會設計出像realloc這樣的東西來了。他們最先構想出
來的程式會像fResizeMemory的那樣-而不會有realloc遺失記憶體的問題。本書中
的建議各有相關,以你所意想不到的方式交互作用著,剛提到的不過是其中一個例
子而已。

不過將你的輸出結果分開來並不能永遠保證你的設計介面不會出現陷阱。我希望我
能提供更好的意見,不過唯一能捉出那些隱藏現影的辦法就是停下來,好好思考你
的設計方式。最好的做法就是檢查每個可能有的合併式輸出入參數,並考慮這麼做
會有哪些可能造成問題的副作用。我知道這有時令人討厭,不過記住,多花掉的思
考時間對日後所需的除錯時間而言,可以說是相對廉價的。最糟糕的做法就是跳過
這個步驟,讓不知其數的程式員們追蹤找尋並修正一堆由這個很糟糕的介面所帶來
的問題。

想像全世界的程式員們已經浪費了多少時間在追蹤尋找由getchar,malloc跟
realloc的陷阱所造成的問題-比較起來,利用這三者之中一個寫出來的包裝函式
所需花的時間就顯得微不足道了。前人們浪費在找尋不必要的錯誤上的時間難道不
足以讓你清醒過來嗎?


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

永遠找尋並消除介面中的缺失。


------------------------------------------------------------------------
--------
單功能記憶體管理器
雖然我在 第三章 中花了許多時間討論realloc函式的問題,我並沒有提到它更奇
怪的許多地方。如果你找出你的C語言程式庫參考手冊,找到realloc的完整敘述,
你會發現像下面的文字:

void *realloc(void *pv,size_t size);
realloc改變先前配置出來的一塊記憶體的大小,記憶體塊的內容保持不變。

?
如果新記憶體塊比本來的小,realloc會釋放記憶體塊尾端不要了的記憶體,而pv
值維持不變。

如果新記憶體塊比本來的大,擴大了的記憶體塊可能會被放在新位址,並將本來的
內容複製過去。一個指向擴大後的記憶體塊的指標值會傳回來,而記憶體塊新增部
分的內容未經初始化。

如果你試圖擴大記憶體塊而realloc失敗了,會傳回一個NULL指標。realloc在縮小
記憶體塊時一定會成功。

如果pv值是NULL,realloc會呼叫malloc(size),並傳回新配置記憶體位址的指標
,或者如果這個要求也失敗了,就傳回NULL指標。

如果新的大小是0,而pv不是NULL,realloc就會如同呼叫了free(pv)般把記憶體釋
放掉,而會傳回NULL。

如果pv值是NULL而新的記憶體塊大小是0,結果是未定義的。

哦!realloc是個標準通殺型實作的範例-整個記憶體管理器的功能全塞在一個函
式之中。那你還要malloc作什麼?你還要free作什麼?realloc不是把它們的功能
全通吃了嗎?

你不應該把一個函式設計成這樣子,有幾個好理由。首先,你不能期望程式員安全
的使用這個函式。這樣的函式有許多細節連經驗老到的程式員都不完全知曉。如果
你懷疑這點,盡可以問看看有多少程式員知道把一個NULL指標傳給realloc可以有
malloc的效果,看看有多少人知道把0放入size參數中呼叫realloc會有如同呼叫
free的效果。是的,這些都是相當神秘的行為,所以他們應該都知道怎樣避免這些
東西製造問題出來。再問問看,當他們使用realloc來擴大一塊記憶體時,他們曉
不曉得realloc可能會把記憶體塊的位址搬動?

這裡還有realloc的另一個問題:你可以把垃圾丟給realloc,可是由於它的定義是
如此一般化,它根本很難檢查參數到底對不對。如果你錯傳了一個NULL進去,那對
realloc而言是合法的參數。如果你把0當成了size傳進去,那對realloc也是合格
的用法。如果你在要變更一塊記憶體的大小時卻不小心配置了一塊新的記憶體,或
是把本來的記憶體釋放掉了,那真的太糟糕了。如果實作上每個用法都是對的,你
要怎麼檢查realloc的參數有沒有效?不管你丟什麼參數給它,realloc都能處理,
即使是很極端的參數值。它一方面可以free掉記憶體塊,另一方面又能配置記憶體
塊出來。這些是完全相反的行為,不是嗎?

老實說,程式員們不常坐下來想想,"我就是想把整個子系統都寫在一個函式中。
"realloc跟其他類似的函式出現的理由很簡單,如果它們不是被慢慢寫成多功能的
函式,就是程式員們把一些本來實作外的功能加了進去,改寫正式的函式功能敘述
來容納這些"僥倖"行為。

不管什麼理由,如果你寫了個多功能函式,把它打散開來。把realloc拆開來,就
成為擴大記憶體塊,縮小記憶體塊,配置記憶體塊跟釋放記憶體塊。藉由將
realloc拆成四個分開的函式,你可以做出更好的錯誤檢查。如果你想縮小記憶體
塊,你知道怎樣的指標才是合格的參數,而且新的大小一定要小於(或等於)本來
的大小,其他的情形都是錯誤狀態。有了分開的ShrinkMemory函式後,你就可以用
除錯檢查巨集來核對呼叫參數了。

在某些狀況中,你可能真的會想有個多功能的函式。舉例來說,當你呼叫realloc
時,你能經常確切掌握新記憶體塊的大小嗎?這全看程式而定,不過我經常不曉得
一塊記憶體可能會變多大(雖然我還是找得出資訊來掌握這一點)。我發現有個同
時能夠縮小跟擴大記憶體塊的函式會比較好些,這樣我就不用在每次我需要改變記
憶體塊大小時寫個if敘述了。是的,我放棄了一些額外的參數檢查,不過好處是我
不用在多寫那些if了。我永遠知道我要配置記憶體或釋放記憶體,所以我把這些功
能從realloc拿掉了,把它們放在不同的函式中。第三章中的fNewMemory,
FreeMemory跟fResizeMemory就是三個定義完善的函式。

如果我在開發一個正常狀態下我都曉得我該擴大或縮小一塊記憶體的程式,我還是
會把擴大跟縮小記憶體塊的功能從realloc中分離出來,並創造兩個額外的函式:

flag fGrowMemory(void **ppv, size_t sizeLarger);

void ShrinkMemory(void *pv, size_t sizeSmaller);
這個分離工作讓我不只能夠徹底檢查指標跟大小參數,還能以較低的風險呼叫
ShrinkMemory函式,因為這樣可以保證記憶體塊一定會被縮小而不會被搬移到新的
位址去。我不用再寫成底下這樣

ASSERT(sizeNew <= sizeofBlock(pb));  /* 核對pb跟sizeNew */
(void)realloc(pb,sizeNew);          /* 縮小記憶體不會失敗 */
我可以寫成這樣

ShrinkMemory(pb,sizeNew);
這樣子就好了。使用ShrinkMemory而不用realloc的簡單理由是,這樣子程式更簡
潔了。用ShrinkMemory,你不需要解釋它不會失敗的注釋,也不需要把無用的返回
值再轉換型態成void,也不用在檢查pb跟sizeNew的值合不合格-ShrinkMemory把
這些工作全完成了。


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

不要寫出多功能的函式。

把不同功能分開來寫可以加強參數核對的檢查。


------------------------------------------------------------------------
--------
定義不明確的輸入
稍早我提過,你的輸出結果應該分開來放,避免讓使用函式的程式員造成困擾。如
果你把同樣的建議用在設計函式的輸入參數上,你自然就不會寫出像realloc這樣
多功能的函式。realloc用了個指向記憶體塊的參數,可是這參數有時可以是NULL
,讓realloc模仿malloc的行為。realloc也有個大小參數,可是這參數可以為0,
這時realloc會模仿free的行為。這樣奇怪的參數也許不夠有殺傷力,可是毀了合
理性。腦筋急轉彎一下,底下的程式會改變記憶體塊大小,配置記憶體塊,還是釋
放記憶體塊?

pbNew = realloc(pb, size);
你分不出來;可能是上述動作的任何一個-完全取決於pb跟size的內容而定。可是
如果你曉得pb一定指向合格的記憶體塊而size一定得是個合格的記憶體塊大小,那
你就可以立刻知道這一小段程式會改變記憶體塊的大小。就像明確的輸出結果會讓
錯誤檢查更容易些,明確的輸入參數也是如此。這樣的明確性對於得閱讀跟理解不
是自己寫的程式碼的維護程式員來說,真是無價之寶啊。

有時多用途的輸入參數並不像realloc的那樣容易找出來。舉例來說,看看底下這
個將前面size個字元從strForm複製成一個存放在strTo中的字串的特製字串複製副
程式:

char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
    char *strStart = strTo;

    while (size- > 0)
        *strTo++ = *strFrom++;
    *strTo = 0';

    return (strStart);
}
CopySubStr類似標準的strncpy函式,可是它保證strTo中的是個真正以零字元結尾
的C語言字串。一般你會用CopySubStr來把一個大字串的一部份取出來-像是從一
個裝有一星期中每個日子名稱的字串裡取出某一天的名稱出來:

static char strDayNames[] = unMonTueWedThuFriSat";
.
.
.
ASSERT(day >= 0  &&  day <= 6);
CopySubStr(strDay, strDayNames + daya3, 3);
現在你曉得CopySubStr怎麼用了,可是你看到那個有問題的輸入方式了嗎?如果你
寫除錯維護檢查來查核這些參數,你就容易找出問題何在。對strTo跟strFrom的檢
查會寫成

ASSERT(strTo != NULL  &&  strFrom != NULL);
可是你怎麼檢查size參數?0是合格的嗎?如果size比strFrom的長度大呢?如果你
檢查程式碼,你會看到它同時處理兩種狀態。while迴圈在size一開始就是0時會脫
離,所以0是合格的參數;如果size比strFrom的長度大,while迴圈會將整個字串
都複製,包括結尾的零字串。你只需要幫這函式寫個注釋說明一下,如後頭的例子


/*  CopySubStr – 從字串中取出子字串。
 *  將strFrom字串前面size個字元存成放在strTo的另一個字串。
 *  如果strFrom的長度小於size,就會將整個字串複製到strTo.
 *  如果size為0,strTo會變成空字串。
 */

char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
    .
    .
    .
這樣子的說明眼熟嗎?當然,因為這個函式的做法就跟燈泡上的灰塵一樣常見。這
樣子仍然是處理size輸入的最好方式嗎?不盡然,至少在寫作零錯誤程式時並不是
這樣子。

假設,舉例來說,一名程式員在她呼叫CopySubStr時把3打成了33:

CopySubStr(strDay,strDayNames + day*3,33);
這真的是個錯誤,可是依照CopySubStr的定義而言,這樣荒謬的值是完全合法的。
喔,當然你大概會在發行程式前就抓出這個錯誤,不過你不會自動發現它;得要有
人把它找出來。不要忘了,用一個在錯誤出現點附近的除錯維護碼找尋錯誤的原因
要比從一個錯誤的輸出來查看要快多了。

從零錯誤的觀點來看,如果一個參數超出範圍或無意義,就應該是非法的,因為默
許使用這樣奇怪的參數會隱藏錯誤,而非找出錯誤。一方面說來,允許鬆散的輸入
過關就是另一種防禦性程式寫作的做法。你可以為了程式的穩定留著這些防禦性的
程式碼,可是不要讓這樣有問題的輸入參數過關:

/* CopySubStr – 從字串中取出子字串。
 * 將strFrom字串前面size個字元存成放在strTo的另一個字串。
 * 如果strFrom的長度小於size,就會將整個字串複製到strTo.
 * 如果size為0,strTo會變成空字串。
 */

char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
    char *strStart = strTo;

    ASSERT(strTo != NULL  &&  strFrom != NULL);
    ASSERT(size <= strlen(strFrom));

    while (size- > 0)
        *strTo++ = *strFrom++;
    *strTo = 0';

    return (strStart);
}
有時允許無意義的參數-如長度為0-是值得的,因為這讓我們消除了呼叫端不必
要的測試。舉例來說,由於memset允許size參數為0,所以在下面的程式片段中我
們不需要那個if敘述:

if (strlen(str) != 0)           /* 將str填成空格 */
    memset(str,' ',strlen(str));
不過在你允許長度為0的時候,要小心。程式員們經常處理長度(或計數值為0)的
情形,因為他們可以這麼做,而不是因為他們應該這樣做。如果你寫了個需要長度
參數的函式,你並不需要處理長度為0的狀況。問問你自己,"程式員們多常以長度
0為參數來呼叫這個副程式?"如果答案是永遠不會,或者很少,就不要處理0的狀
況;改用除錯檢查來找出這些狀態。記住,每當你放鬆一項限制,你就減少了一個
捉住相關錯誤的機會。一開始就選擇嚴格的輸入定義來達到最大的除錯檢查效率是
個好規矩。如果你稍後發現這樣的限制太嚴苛,你可以拿掉這樣的限制而不會影響
到程式的其他部分。

我在第三章中把NULL指標的檢查加到FreeMemory中時,便使用了這樣的想法。由於
我永遠不會以NULL指標呼叫FreeMemory,對我來說,有這樣強化的檢查就更重要了
。你的觀點也許不同-這沒什麼對錯之別。只要確定你所作的是個清醒的選擇而不
只是因為你習慣如此,就好了。


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

不要優柔寡斷。

把函式參數明確定義好。


------------------------------------------------------------------------
--------
現在不要讓我失望
微軟公司有條在面試時詢問求職者技術問題的政策,對程式員來說,那就是問些程
式設計的問題。我習慣從寫出標準的tolower函式問起。我會把一份ASCII字元表拿
給我所面試的人,問說,"你該怎樣寫出一個會將大寫字元轉換成小寫字元的函式
?"我會謹慎的模糊處理符號跟小寫字元的區別,主要是想看看程式員們會怎麼處
理這些情形。這些字元會保持不動嗎?程式中怎樣檢查錯誤?符號跟小寫字元會被
忽略掉嗎?超過一半的人會寫出像這樣的東西:

char tolower(char ch)
{
    return (ch + 'a' - 'A');
}
這程式碼在ch是個大寫字元時有效,可是如果ch是別的東西,它就毀了。當我告訴
我所面談的人這一點時,他們有時會說,"我假設這字元一定是大寫的。如果這字
元不是大寫的,我會原封不動的把它傳回來。"這是個合理的做法;其他做法也差
不多。少部分人會說,"我沒想到那點。我可以修改一下,在ch不是大寫時,傳回
一個錯誤。"有時他們會讓tolower傳回NULL,或是零字元,不過清楚的勝選應該是
-1:

char tolower(char ch)
{
    if (ch >= 'A'  &&  ch <= 'Z')
        return (ch + 'a' - 'Z');
    else
        return (-1);
}
傳回-1違反我稍早所建議的介面規範,因為它把錯誤值跟正常資料混在一起了。可
是問題並不在於我所面談的人沒有做到一項他們可能沒聽過的建議,而在於他們犯
下了一個不必要的錯誤。

這帶來了另一種看法:如果函式本身傳回錯誤狀態,每個呼叫那函式的程式都得處
理那個錯誤。如果tolower會傳回-1,就不能把程式寫成底下這麼簡單了:

ch = tolower(ch);
你得寫成像這樣子:

int chNew;       /* 這必須是個int整數型態,才裝得下-1。 */

if ((chNew = tolower(ch)) != -1)
    ch = chNew;
如果你想到你得怎麼呼叫tolower,你會覺得會傳回一個錯誤狀態並不是定義這函
式的最好方法。

如果你發現自己設計了一個會傳回錯誤狀態的函式,停下來問問你自己,是否有別
的方式可以重新設計出不需要錯誤狀態的函式。比起只把tolower定義成一個"把大
寫字母轉成小寫字母再傳回來"的函式,"讓它在不能轉換時傳回原來的字元"會是
個更好的設計方式。

如果你發現沒辦法拿掉傳回錯誤狀態的設計,考慮不要讓產生錯的狀況出現。舉例
來說,你可以要求tolower的參數一定要是大寫的字母,並說其他字元都是不合格
的。你可以使用除錯檢查來核對這個參數:

char tolower(char ch)
{
    ASSERT(ch >= '  &&  ch <= ');

    return (ch + ' - ');
}
不管你重新定義函式,或是不讓引起錯誤的情況出現,你解決了呼叫者必須進行執
行期錯誤檢查的問題,讓程式碼更小而問題更少了些。


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

寫函式時要讓輸入合格而不會出錯。


------------------------------------------------------------------------
--------
跨行讀取
我再三強調,從呼叫者的觀點檢查函式介面是很重要的一件事情。當你曉得一個定
義出來的函式會從許多地方被呼叫時,不去檢查它是怎麼被使用的就顯得很愚蠢了
。getchar,realloc跟tolower的例子已經讓我們了解到這一點-這些都是用起來
很複雜的東西。不過混淆輸出跟傳回不需要的錯誤碼並不是唯一讓程式變得更複雜
的方法。有時只需要在函式的輸入上輕忽一下,就會讓程式變得更複雜。

假設你想改善程式的磁碟存取部分,你碰到一個檔案存取指標移動的呼叫是寫成這
樣的:

if (fseek(fpDocument, offset, 1) == 0)
    .
    .
    .
你曉得有個檔案指標移動的動作出現了,你也看得出來錯誤是怎麼處理的,可是這
個呼叫的可讀性怎樣?哪一種檔案指標移動在進行著-從檔案頭,檔案指標現在的
位置,還是從檔案尾?如果傳回來的值是0,那表示成功還是失敗?

假設程式員把這呼叫以預先定義的名稱呼叫:

#include <stdio.h>       /* 引入SEEK_CUR的定義 */
#define ERR_NONE 0
.
.
.
if (fseek(fpDocument, offset, SEEK_CUR) == ERR_NONE)
    .
    .
    .
這樣會清楚些嗎?當然。不過這一點也不令人驚奇-程式員們數十年前就已經了解
要避免在程式中使用奇怪的數字。我要指出的是,NULL,TRUE跟FALSE並不是為了
這種理由而存在的。它們常用,可是並不是用來當成神奇數字的文字替代式。舉例
來說,這底下的呼叫各作了什麼?

UnsignedToStr(u, str, TRUE);

UnsignedToStr(u, str, FALSE);
你大概能猜出這些呼叫會把無號數轉換成字串,可是那個布林型態的參數怎麼影響
整個轉換?如果我把呼叫寫得成這樣,會不會更清楚些?

#define BASE10 1
#define BASE16 0
.
.
.
UnsignedToStr(u, str, BASE10);
UnsignedToStr(u, str, BASE16);
當一名程式員坐下來寫這樣的函式時,這些布林(boolean)型態值的用法也許十
分清楚。首先程式原先丟出一段敘述說明,然後再開始寫程式:

/* UnsignedToStr
 * 這個函式會將一個無號數轉換成字串。
 * 如果fDecimal為TRUE,u就會被轉換成十進制數字字串;
 * 不然會被轉換成十六進制數字字串。
 */

void UnsignedToStr(unsigned u, char犘trResult, flag fDecimal)
{
    .
    .
    .
有比這樣子更清楚的做法嗎?

現實是布林型態的參數往往表示程式員們沒深思他們在作什麼。函式本身作的或者
是兩件不同的工作,由布林型態的參數決定要作哪一件,或者函式本身雖具彈性,
程式員決定只用布林型態的參數來選擇兩個他或她覺得有用到的狀況;往往這兩種
情形都成立。

如果你把UnsignedToStr當成一個處理兩件不同工作的函式,你可能會拿掉那個布
林型態的參數,而把UnsignedToStr拆成兩個不同的函式:

void UnsignedToDecStr(unsigned u, char *str);

void UnsignedToHexStr(unsigned u, char *str);
不過有個更好的解決方案-把那個布林參數改變一下型態,會讓UnsignedToStr變
成更有彈性的汎用函式。不要用TRUE或FALSE來決定輸出結果的基底,而讓使用函
式的程式員決定要轉換成哪個基底的結果:

void UnsignedToStr(unsigned u, char *str, unsigned base);
這個做法給了你一個清楚、彈性的設計,讓呼叫這函式的程式碼具可讀性,而同時
又增加函式的功能性。

這意見也許跟我稍早提到的嚴格定義參數的建議相衝突-我們把本來固定的TRUE或
FALSE輸入,變成了一個大部分可能值都不太常用到的一般性輸入。不過記住,雖
然基底參數很有彈性,你永遠可以加上一個除錯檢查來確認它的值是10或16。如果
你之後決定你也需要二進制或八進制的轉換,你可以把除錯檢查放寬,讓程式員能
夠傳入2或8的基底參數。

這遠比我看過的一些有著TRUE,FALSE,2跟-1參數的函式要好太多了!因為布林參
數不容易擴充,你可能得用這樣無意義的做法,或是改變每個已經存在的呼叫敘述



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

讓程式在呼叫時可被理解。

避免使用布林型態的參數。


------------------------------------------------------------------------
--------
標明危險性
作為對抗錯誤的最後一道防線,在你的文件中強調危險之處,並說明你希望人們怎
樣使用你的程式碼。不要這樣說明getchar,

/*      getchar – 這跟getc(stdin)的作用一樣。 */

int getchar(void)
.
.
.
那幫不了程式員們什麼忙,你應該寫成像這樣:

/*  getchar – 跟getc(stdin)有著相等效果。
 *
 *  getchar傳回stdin的下一個字元。
 *  當錯誤發生時,它會傳回一個整數的EOF值。
 *  典型的用法是
 *
 *  int ch;         // ch必須是個整數型態的變數,以裝下EOF值。
 *
 *  if ((ch = getchar()) != EOF)
 *          // 成功 – ch就是下一個字元
 *  else
 *          // 失敗 – ferror(stdin) 可以告訴你錯誤的型態
 */

int getchar(void)
.
.
.
如果你把兩份說明都丟給一名剛學C語言程式庫的程式員,你覺得哪一份比較能讓
人注意到使用getchar的危險性?你想她會自己寫個新函式來讀取字元,還式照抄
你寫的文件中的標準用法來滿足需求?

幫函式加上說明文件的另一個正面副作用是,如此一來,它可以強迫一名弔兒郎當
的程式員停下來想想其他人怎麼使用他們的函式。如果一名程式員寫了一個介面冗
笨的函式,他應該會在試著寫出"典型用法"的範例時,發現這樣的介面實在不好。
不過即使他沒察覺這樣的介面問題,只要他提供的例子完整而正確,那也就沒關係
了。如果realloc的文件說明提供了一個如後面這樣的範例,你覺得怎樣?

/* realloc(pv,size)
   ...
   一個典型的用法是

    void *pvNew;        // 用來檢查realloc失敗的情形

    pvNew = realloc( pv,sizeNew);
    if (pvNew != NULL)
    {
        // 成功 – 更新pv
        pv = pvNew;
    }
    else
    // 失敗 – 不要把原有的pv改成pvNew的NULL值
*/

void *realloc(void *pv, size_t size)
.
.
.
經由複製這樣的範例,一個不太小心的程式員就更能避免本章稍早提到的記憶體遺
失問題了。你的例子沒辦法保護所有程式員,可是這樣的說明就像藥罐子上頭的警
告,能夠影響某些人,產生一些助益。

不過不要將使用範例當成設計完善介面的替代做法。getchar跟realloc都有著容易
引人出錯的介面-它們的危險性應該被消除掉,而不光只是把它們的危險性說出來
就好了。


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

注釋說明潛在危險。


------------------------------------------------------------------------
--------
惡魔躲在細節中
設計防弊介面不難,但需要多思考些,也需要有意願放棄根深蒂固的程式寫作習慣
。本章的建議說明了介面上的簡單改變能怎樣讓程式員們不用花太多腦筋就能寫出
正確的程式來。綜觀本章,關鍵的觀念在於"讓每件事都盡可能明白而清楚。"如果
程式員了解而且計得每個細節,他們也許就不會出錯了;不過程式員們還是會犯錯
,尤其在他們忘了或根本不知道重要細節時。讓程式員們不容易無意間將細節遺漏
;設計個防弊的介面吧。


------------------------------------------------------------------------
--------
快速回顧
建立易用而易懂的函式見面:確定輸入跟輸出各址代表一種資料。在輸出入參數中
混合錯誤跟其他特殊用途的值只會弄亂你的介面。

設計強迫程式員們思索如錯誤處理等重要細節的函式介面,不要讓他們易於忽視或
遺忘細節。

考慮程式員們必須如何呼叫函式的問題。在函式介面中找尋會讓程式員們不經意的
製造錯誤的缺失。特別重要的是:努力將函式寫成一定會讓呼叫者不用處理錯誤狀
況的形式。

增加清晰性,並經由確定程式員們可以了解對函式呼叫的用法來減少錯誤的發生。
神奇數字的挑選跟布林型態參數的使用背離了這個目標。

將多功能函式拆開來。拆開後的函式名稱不只增進可讀性(像是以ShrinkMemory取
代realloc),還能以更嚴格的除錯檢查來自動找尋錯誤參數的問題。

說明你介面的用法,讓程式員們知道如何正確呼叫函式。強調危險的地方。


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

------------------------------------------------------------------------
--------
該多想想的事
本章開頭的strdup函式配置了一塊記憶體給複製的字串,可是會在記憶體配置失敗
時傳回NULL。該怎麼幫strdup設計一個防弊介面?
我提到布林輸入值的使用並不是最好的函式介面設計方式,那布林輸出值呢?例如
,如果fGetChar失敗了,它傳回一個FALSE,要求程式員呼叫ferror(stdin)來找出
錯誤原因。有沒有一個更好的getchar介面?
為何ANSI的strncpy函式會讓粗心的程式員犯錯?
如果你熟悉C++的inline含入函式限定字,描述一下它對於寫作防弊介面的貢獻。

C++提供了類似Pascal中VAR參數的&參考參數語法。不用再寫成底下這樣

flag fGetChar(char *pch);      /* 函式雛形宣告 */
.
.
.
if (fGetChar(&ch))
    ch存放了新的字元...
而可以寫成



flag fGetChar(char &ch);       /* 函式雛形宣告 */
.
.
.
if (fGetChar(ch))              /* 真正傳過去的是&ch. */
    ch存放了新的字元...
表面上,這似乎是個改進,因為程式員們不會"忘記"一般C語法中明確要求的&了。
可是這樣的用法為何不但不能防弊,反而容易產生錯誤?

標準的strcmp函式取得兩個字串參數後,將兩者的每個字元進行比較。如果兩個字
串相等,就傳回0;如果第一個字串小於第二個字串,就傳回負值;否則傳回正值
。那麼,當你呼叫strcmp時,程式碼通常長這樣子:

if (strcmp(str1, str2)  rel_op  0)
    .
    .
    .
這裡的rel_op是==,!=,>,>=,<或<=其中之一。可以這麼用,可是除非你熟悉
strcmp函式,不然這樣的程式碼一點意義也沒有。說明一下至少兩個別種字串比較
的函式介面。這些介面除了應該要能防弊,還要比strcmp介面要有可讀性。


------------------------------------------------------------------------
--------
學習計劃:
複習一下標準C程式庫,然後重新設計出更具防弊性的介面。將函式改為更能讓人
理解的名稱的優缺點更為何?


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

------------------------------------------------------------------------
--------
學習計劃:
找尋一大段程式中所有的memset,memmove,memcpy跟strn型的函式(strncpy等)
。這些呼叫中,有多少個要求函式要接受長度為0的參數?你的組織要求這樣的便
利性更甚於糾正這種用法容易產生的錯誤嗎?

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

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


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

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