荔园在线

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

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


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

當你在寫一本離奇小說時,你希望每一頁的內容都能牽動讀者的注意力。你想喚起
讀者心中的驚奇、恐懼與懸疑感。如果你寫,"某人走了過來,把Joe刺傷了",你
大概會讓讀者們睡著。要讓讀者保持閱讀興趣,你得讓她感受到Joe聽到背後的腳
步聲一步步靠近時的恐懼感。你得讓她經歷Joe在那些腳步聲愈來愈接近時的那種
心跳加速的感受。你得讓讀者經歷Joe在聽到腳步聲愈來愈近時那般感受,在她們
心中升起一股驚慌感。最重要的,你得讓讀者想著:Joe逃得掉嗎?

在離奇小說中使用驚奇跟懸疑的效果是很重要的,可是把這兩種效果用在程式中就
太恐怖了。當你寫程式時,"企圖"應該明顯而無趣得讓程式員們知道接下來會發生
什麼事。如果你的程式必須有個某人走了過來刺傷Joe的情節,那這個某人走過來
,然後刺傷Joe的劇情就必須是你真正要求的東西,你不能旁生枝節。這很短,很
清楚,而且告訴你每件你所必須知道的事情。

不過有時候,程式員們抗拒將程式寫得如此清晰無趣,想要用怪異的小技倆來達到
不正常的目的,完成某些超乎限制的工作。

在本章中,我們會看到幾種既不簡單也不無趣的程式寫作風格。這些範例賣弄小聰
明的技倆,用途一點也不明顯。當然,這些方式全帶來潛藏的錯誤。

對速度的需求
這是我們在上一章中看到的零錯誤版memchr:

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);
}
大部分程式員們愛玩的一個遊戲就是"我怎樣讓程式跑得更快?"這遊戲玩起來不賴
,可是如我們在書中看到的,如果你做得太離譜,它就會帶來不可預期的後果。

如果你拿上頭的memchr程式碼來玩這種遊戲,你會問自己,"我怎樣加速那個迴圈
的執行?"只有三個可能:拿掉長度的檢查,拿掉字元測試,或者拿掉指標遞增的
部分。這三個似乎都不可能拿掉,可是你還是可以-如果你想試著用傳統大膽的做
法的話。

看看那個長度的檢查,只有當你在記憶體前面size個位元組裡頭找不到ch而可以傳
回NULL時,你才需要這個檢查。只要你能保證一定找得到ch,並保證在"找不到
ch"的情形出現時,在那塊記憶體的末端有個ch會被找到,你就可以拿掉這個檢查


void *memchr(void *pv, unsigned char ch, size_t size)
{
    unsigned char *pch = (unsigned char *)pv;
    unsigned char *pchPlant;
    unsigned char chSave;

    /* pchPlant指向memchr正在搜尋中的記憶體的第一個字元。
     * 把ch放在那個位置,保證即使在在要搜尋的記憶體範圍內
     * 沒有找到ch,memchr也可以找到一個ch。
     */
    pchPlant = pch + size;
    chSave = *pchPlant;       /* 保留原來的字元。*/
    *pchPlant = ch;

    while (*pch != ch)
        pch++;
    *pchPlant = chSave;       /* 把本來的字元放回來。*/
    return ((pch == pchPlant) ? NULL : pch);
}
這樣子寫得夠精巧吧?把pchPlant指向的字元換掉,你可以保證memchr會找到ch,
而且讓你拿將長度檢查拿掉,加倍迴圈執行的速度。

不過這樣寫夠穩定嗎?這樣寫夠紮實嗎?

memchr的新寫法看來夠穩定,特別是它小心將改變的字元保留著,可是這一版的
memchr比蝙蝠俠的小玩意兒有著更多問題。初學者們,想想這些問題:

?
如果pchPlant指向唯讀記憶體,將ch寫入*pchPlant的動作就無效了,而且這函式
會在前面size+1個位元組中找不到ch時傳回不合格的指標。
?
如果pchPlant指向記憶體對應週邊的位址範圍內,將ch寫入*pchPlant可能會產生
可怕的結果,從讓軟碟機磁頭馬達停下來或啟動,到讓工廠的機器人瘋狂亂動都有
可能發生。
?
如果pch指向隨機存取記憶體的最後size個位元組處,pch跟size的值都是合格的,
可是pchPlant會指向不存在或有寫入保護的記憶體位址。在*pchPlant處寫入ch會
造成記憶體存取失誤,或什麼事情也不會發生,而讓函式在ch沒出現在頭size+1個
字元內時執行失敗。
?
pchPlant如果指向並行的多工執行程序共享的記憶體,當一個程式把ch寫到
*pchPlant處時,可能會把另一個多工執行程序要執行時可能用到的資料毀掉。

最後一種可能性尤其麻煩,因為在並行的多工執行程序間有許多方式可以把系統當
死。如果你在搜尋一塊先前配置好的記憶體時把記憶體管理器的資料結構毀了,會
發生什麼事?如果並行處理的多工執行程序之中一個-以一條執行緒或是一個硬體
中斷處理程式為例-接下來獲得執行權了,它最好不要呼叫記憶體管理器,因為系
統可能會當掉。如果你用memchr來檢查一個整體變數陣列,而它把其他task使用的
相鄰變數給毀了,會發生什麼事?又如果程式的兩份執行程序試著同時搜尋共享的
資料,會發生什麼事?這些情形的任何幾種都可以把你的程式當死。

當然,你可能不會明白最佳化過的memchr會帶來潛伏的問題,因為除非它更動了記
憶體的關鍵部分,不然它會動作得好好的。不過當像最佳化過的memchr函式帶來問
題時,要找出問題的根源卻有如在沙漠風暴中尋找出路一樣困難:畢竟,執行
memchr的多工執行程序會動作得好好的;出問題的是別的多工執行程序-那個用到
被更動的記憶體的-會當掉,而且完全沒證據能夠指出memchr才是元兇。

如果你本來不曉得價值50,000美金的內電路模擬器是用來作什麼的,現在你知道了
-我們可以用內電路模擬器保留每個時脈週期的紀錄,以及電腦所用到的每個指令
跟資料,直到程式當掉為止。你可能要好多天才能看完模擬器執行上頭那個有問題
的程式的輸出結果,如果你能堅持到底,而且不在模擬的結果中瞎找,你應該能找
出問題何在。

不過為何要那麼辛苦呢?找出那種問題的另一個做法簡單多了:不要參考到不屬於
自己的記憶體就好了。記住:"參考"代表讀跟寫。讀取未知的記憶體位址可能會跟
其他多工執行程序互動產生可怕的結果,而參考到唯讀記憶體、不存在記憶體的位
址或是記憶體對映週邊的存取位址都可能讓你的程式當掉。


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

不要參考不屬於自己的記憶體。


------------------------------------------------------------------------
--------
拿到了鑰匙的小偷還是小偷
有夠奇怪,我認識一些程式員們,他們雖然不會去動到不屬於自己的記憶體,可是
他們會覺得把FreeWindowTree副程式寫成底下這樣很好:

void FreeWindowTree(window *pwndRoot)
{
    if (pwndRoot != NULL)
    {
        window *pwnd;
        /* 釋放pwndRoot的子視窗... */
        pwnd = pwndRoot->pwndChild;
        while (pwnd != NULL)
        {
            FreeWindowTree(pwnd);
            pwnd = pwnd->pwndSibling;
        }

        if (pwndRoot->strWndTitle != NULL)
            FreeMemory(pwndRoot->strWndTitle);
        FreeMemory(pwndRoot);
    }
}
看看那個while迴圈,看出問題在哪裡了嗎?當FreeWindowTree透過串列釋放每個
子視窗時,它先釋放pwnd,然後又在下面這行中參考了已釋放的記憶體塊

pwnd = pwnd->pwndSibling
但是在pwnd釋放後,pwnd->pwndSibling的值是什麼?當然是垃圾,不過有些程式
員們不這麼認為。稍早的記憶體內容既然不是垃圾,他們也沒去動到記憶體,他們
想,那記憶體中的內容應該還是有效的。他們並非什麼都沒作,他們把記憶體釋放
掉了。

我從來不了解為什麼有些程式員相信被釋放了的記憶體中的內容還是可以使用。這
跟你用自己另外打造的鑰匙進入自己住過的出租公寓,或者開走一部你賣掉了的車
子有什麼不同?你不能安全的參考釋放了的記憶體,就如我在第三章中提到過了的
,記憶體管理器會使用可用記憶體鏈來存放自己用的一些資訊。


------------------------------------------------------------------------
--------
資料的存取權限
你看過的任何程式設計手冊中可能都沒提到,不過任何程式中的資料都預先安排了
讀寫的權限。這種存取權限並沒有公開宣告;這種權限定義並沒有標示在你宣告的
每個變數前面,而是由你的子系統設計跟函式介面所決定的。

舉例來說,在呼叫函式的程式員跟寫出函式的有效宣告的程式員間有個共識,

如果呼叫者「我」,傳給「你」,被呼叫者,一個輸入的指標,你同意將這輸入指
標當成個常數,並保證不會對它進行寫入動作。更進一步,如果我傳給你一個輸出
的指標,你同意將這個輸出指標當成一個只能寫、不能讀的物件,承諾不會從它指
向的地方讀取資料。最後,無論輸出或輸入指標,你都同意將限制輸出或輸入指標
所指向的記憶體的參考。

返回時,我,呼叫者,同意將唯讀的輸出結果當成常數,保證不會寫入資料進去。
我更進一不同意限制對輸出指標所指向的必須記憶體進行參考的動作。

換句話說,"你不要把我的東西搞混,我也不會把你的東西搞混。"記住這點:當你
違反一個預先定好的讀寫權限時,你就冒著讓相信每個程式員都會遵守這些規矩的
人所寫的程式出錯的風險。一名呼叫如memchr般函式的程式員不應有必要擔心
memchr會在不常見的情形下出現怪異的行為。


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

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

不要參考你釋放掉的記憶體。


------------------------------------------------------------------------
--------
只拿走你需要的東西
在上一章,我提出了一個UnsToStr函式的實作方式,像這樣:

/* UnsToStr – 將無號數轉換成字串。 */

void UnsToStr(unsigned u, char *str)
{
    char *strStart = str;
    do
        *str++ = (u % 10) + '0';
    while ((u /= 10) > 0);
    *str = 0';

    ReverseStr(strStart);
}
上頭的程式碼是UnsToStr的簡單寫法,不過一定有程式員們會覺得這樣子寫不好,
因為這程式將數字反向轉換成字串,還要呼叫ReverseStr來把結果反轉回來。這樣
似乎是浪費時間的做法,如果你要先把數字反著放,放完再反轉回來,那為何不直
接把數字從尾端放回來,這樣就不用再多一次反轉的步驟了,不是嗎?底下就是省
略反轉步驟的寫法:

void UnsToStr(unsigned u, char *str)
{
    char *pch;

    /* u超出範圍?請改用UlongToStr吧... */
    ASSERT(u <= 65535);

    /* 將數字反過來放在str中。
     * 先從字串裝得下u的最大位數的位置放起。
     */
    pch = &str[5];
    *pch = 0';
    do
        *--pch = (u % 10) + '0';
    while ((u /= 10) > 0);

    strcpy(str, pch);
}
有些程式員覺得這樣寫比較好,因為這樣跑起來比較有效率而易懂。UnsToStr變得
更有效率,因為strcpy(你還是需要這個函式)比ReverseStr快,特別在那些可以
把這樣的呼叫編譯成內含指令而非函式呼叫的編譯器上。這樣的程式碼易於了解,
因為C語言的程式寫作者們熟悉strcpy. 當程式員們看到ReverseStr時,他們就像
看到一名住院的朋友還可以走路一樣躊躇失措。

問題何在?如果UnsToStr如此完美,我還要跟你提這些作什麼?它當然不完美,事
實上,新的UnsToStr有個嚴重的缺陷。

告訴我,str參數指向多大的記憶體?你不知道。你不知道一個指標指向多大的記
憶體,這在C的介面中是很常見的事情。在呼叫者與實作之間的共識是str會指向能
夠裝下u轉換後的文字的記憶體。可是最佳化過的UnsToStr假設str會指向能夠裝下
u所能代表的最大位數的空間,實際上可能不是如此。如果呼叫者寫成底下這樣,
那會如何?

DisplayScore()
{
    char strScore[3];     /* UserScore從0到25。*/

    UnsToStr(UserScore, strScore);
    .
    .
    .
由於UserScore從來不會產生超過三個字元長度的字串(兩位數字跟一個零字元)
,對程式員來說,把strScore設計成長度為三個元素的字元陣列十分合理。可是
UnsToStr會假設strScore是個六元素的字元陣列ㄝ而將strScore之後的三個位元組
的記憶體內容毀掉。在DisplayScore的例子中,如果你的機器上的堆疊是向下增長
的類型,UnsToStr通常會把堆疊框中存放的DisplayScore呼叫者的返回位址或且呼
叫者的堆疊框指標毀掉。你的機器大概會當掉,而你會注意到這個問題。如果
strScore如果不是個區域變數,你大概不會發現UnsToStr已經把strScore後頭的記
憶體內容攪亂了。

我確定有程式員會說把strScore宣告得大得只夠裝下他們所需要的最長可能字串是
危險的做法。可是只有在程式員把程式寫得如同剛剛看到的UnsToStr版本一般時,
那樣的做法才會是危險的。事實上不用這樣囉唆,你可以寫個UnsToStr跑得既安全
又有效率,只要在區域緩衝區內把字串建立好,再把結果複製到str去就好了:

void UnsToStr(unsigned u, char *str)
{
    char strDigits[6];                /* 轉換緩衝區 */
    char *pch;

    /* u超出範圍?請改用UlongToStr吧... */
    ASSERT(u <= 65535);

    /* 將數字反過來放在str中。
     * 先從字串裝得下u的最大位數的位置放起。
     */
    pch = &strDigits[5];
    *pch = '\0';
    do
            *-pch = (u % 10) + '0';
    while ((u /= 10) > 0);

    strcpy(str, pch);
}
你得記得,除非另外定義,不然如str般的指標不會指向你能用來當作工作緩衝區
的記憶體。如str這般為了效率需求而存在的指標是以參考位址的方式傳遞的,而
不是以位址值的方式傳遞的。


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

不要使用輸出記憶體作為工作緩衝區。


------------------------------------------------------------------------
--------
把自己的東西放好
當然,有程式員會想,即使在UnsToStr中呼叫strcpy都太浪費了。為何不乾脆傳回
一個指向已經建立好了的字串的指標,省下把字串複製到別處的功夫呢?

char *strFromUns(unsigned u)
{
    static char *strDigits = "?????";   /* 5個字元 + '\0'*/
    char *pch;

    /* u超出範圍?請改用UlongToStr吧... */
    ASSERT(u <= 65535);

    /* Store the digits in strDigits from back to fromt. */
    pch = &strDigits[5];
    ASSERT(*pch == '\0');
    do
        *--pch = (u % 10) + '0';
    while ((u /= 10) > 0);

    return (pch);
}
這個程式幾乎跟我們在上一節中到的那個版本相同,除了strDigits被宣告成靜態
的,使它的內容在strFromUns返回時也一樣繼續保存著。

不過想像一下:如果你要實作一個用來轉換兩個無號數成字串的函式,你用下面的
方式來寫

strHighScore = strFromUns(HighScore);
.
.
.
strThisScore = strFromUns(Score);
這裡頭有什麼問題呢?你用strFromUns來轉換Score,你把strHighScore指向的字
串毀了。

你可以說,這個錯誤在呼叫strFromUns的程式碼中而非strFromUns本身,不過記得
我們在第五章提到的東西嗎?函式能夠正常運作還不夠正確;它們必須避免程式員
犯錯才行。我會說,這個strFromUns至少有個介面錯誤,因為你我都知道有些程式
員會使用剛剛那種有問題的做法。

即使一名程式員了解strFromUns產生的字串內容的缺點,他們還是可能在不了解自
己在作什麼之前製造出問題來。假設一名程式員呼叫了strFromUns,然後呼叫另一
個函式,在這後來的函式中也不知不覺的呼叫了strFromUns,那她本來的那個字串
就會被毀了。或者假設有好幾條執行緒同時在跑,有條執行緒呼叫了strFromUns,
而把另一條執行緒還在使用的字串給清除掉。

而這些問題跟strFromUns製造的一個問題比較起來,不過是小巫見大巫而已。什麼
問題?strFromUns會在你的程式專案發展大了以後爆炸開來。如果你決定把
strFromUns的呼叫放到一個函式中:

?
你必須確定呼叫這個函式的程式碼(以及呼叫這些程式碼的程式)不會用到
strFromUns傳回來的字串。換句話說,你必須確定沒有函式會在你函式上頭的呼叫
鏈中用到strFromUns裡頭的緩衝區。
?
你也必須確定你不會呼叫任何呼叫strFromUns的函式,免得毀了一個你正在使用的
字串。當然,這就是說,你不能呼叫一個會呼叫strFromUns的函式。


------------------------------------------------------------------------
--------
整體緩衝區的問題
strFromUns的例子說明了你再將資料透過指向靜態記憶體的指標傳回來時所要面對
的危險。這個例子中沒提到的是,同樣的危險也存在你將資料透過靜態緩衝區傳遞
時。你可以把strFromUns改寫成將一個數值字串放在一個整體緩衝區中,或是一個
你的程式一開頭就用malloc配置好了的永久緩衝區中,可是那樣子並不能去除
strFromUns的危險性,因為程式員們還是可能在一串呼叫鏈中使用這個函是兩次,
而讓第一次呼叫得到的字串毀掉。

基本原則就是,不要在整體緩衝區中傳遞資料,除非你一定得這麼做。如果你強迫
呼叫函式的程式碼得提供一個指向輸出緩衝區的指標,你就可以避免掉整個問題。



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

如果你在一個函式中加上一個strFromUns的呼叫而不檢查這兩點限制,你就冒著製
造錯誤的風險,這夠糟糕了吧。想像一下,如果程式員們一定得照著那兩點限制作
的話,修正錯誤跟加入新功能會有多困難呢?每當他們改變了對你函式的呼叫鏈,
進行維護的程式員就得重新核對一下這兩個限制。你覺得他們會去檢查這些東西嗎
?很難吧。這些程式員甚至不知道他們應該檢查這兩個限制。畢竟,他們只是在修
正錯誤,重寫程式碼跟加上新功能而已;他們怎麼會曉得一個他們可能從來沒看過
或用過的strFromUns函式有什麼必要的使用限制?

類似strFromUns這樣的函式會一再造成問題,因為他們的設計方式讓程式員們在維
護程式時容易產生問題。當然,再程式轅門找出strFromUns類型的錯誤時,這些錯
誤並不是由strFromUns內部產生的,而是因為strFromUns的使用方式不正確。程式
員們並不會重寫strFromUns來修正真正的問題所在,而會修正各個不正確使用
strFromUns的錯誤,讓strFromUns在程式中維持原樣,繼續製造新的問題...。


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

不要透過靜態(或整體)變數的記憶體傳遞資料。


------------------------------------------------------------------------
--------
有用的寄生蟲
在公開的緩衝區中傳遞資料有危險,不過如果你小心而且幸運的話,還是可以避開
這樣的問題。不過寫作依賴其他函式的內部運作機制的寄生函式不只是危險而已,
還是不負責任的。如果你改變了宿主函式,你就把寄生函式一起毀了。

我看過最好的寄生函式範例是在一個被廣泛推廣並移植到不同平台上使用的FORTH
程式語言標準實作內,裡頭到處都是寄生函式。在1970年代晚期跟1980年代早期,
FORTH Interest Groups試著透過FORTH-77標準的免費公開實作來提昇FORTH語言的
使用量。這些FORTH的實作定義了三個標準函式:將記憶體填寫成一個給定的位元
組值的FILL函式;一個從記憶體塊前端開始複製記憶體內容的CMOVE函式;還有一
個從記憶體尾端開始複製記憶體內容的<CMOVE函式。CMOVE跟<CMOVE特別被定義了
從頭開始跟從尾開始的動作,讓程式員們知道在複製重疊的記憶體塊時要用哪個函
式。

在這些FORTH的實作版本中,CMOVE是用最佳化的組合語言寫成的,不過為了可攜性
,FILL是用FORTH本身寫成的。CMOVE的程式碼(在這邊翻譯成C語言,以便閱讀)
就如你所期望看到的樣子:

/* CMOVE – 將記憶體從頭開始複製。*/

void CMOVE(byte *pbFrom, byte *pbTo, size_t size)
{
    while (size- > 0)
        *pbTo++ = *pbFrom++;
}
而FILL的寫法真是令人吃驚:

/* FILL – 填入一塊記憶體。*/

void FILL(byte *pb, size_t size, byte b)
{
    if (size > 0)
    {
        *pb = b;
        CMOVE(pb, pb+1, size-1);
    }
}
FILL呼叫CMOVE來辦事,除非你事先就知道它是這樣做的,不然還真是會讓你吃一
驚。這樣的做法是"靈巧"或"糟糕",端看你怎麼認定。如果你覺得FILL寫得很靈巧
,想想:FORTH會要你把CMOVE寫成一個從頭搬移記憶體的函式,如果你為了效率考
量,把CMOVE寫成用長整數來搬移記憶體,而不是用位元組來搬,那會發生什麼事
情?答案是,你當然寫出了一個零錯誤又跑得飛快的CMOVE函式,可是把每個呼叫
FILL的函式都給毀了。對我來說,那一點也不靈巧,而是糟糕透了。

讓我們假設你知道CMOVE絕對不會被改寫,你還在CMOVE前頭放了一段警告敘述,告
訴其他程式員說FILL會依賴CMOVE的內部運作機制,不要去動到CMOVE。這樣的做法
只解決了與CMOVE相關的一半問題。

假設你在開發一個簡單的四軸工廠機器人的控制程式,每個軸的位置都有256種變
化。這樣子的機器人有個簡單的設計方式,就是用四個位元組的記憶體對映輸出入
方式,讓不同的記憶體位址控制不同的機械軸。要改變一個機械軸的位置,你直接
把0到255間的值寫到對應的記憶體位址就好了。要取得一個機械軸的位置(在讓機
器軸移到新位置時,這樣的資訊是有用的),你從對應的記憶體位置讀取一個位元
組就好了。

如果你要把四個機械軸都歸位回(0,0,0,0)的位置,理論上你可以這樣寫

FILL(pbRobotArm, 4, 0); /* 把機器軸歸位。*/
當然,這樣的程式不會有效,因為FILL的定義方式-FILL會先寫個0給第一個機械
軸,然後把垃圾丟給剩下三個軸,讓機器人抓狂。為何如此?如果你檢查一下
FILL的寫法,你會發現它用複製之前寫入的位元組到現在位置的方式來填寫記憶體
。可是當FILL讀到第一個位元組時-它當然期望這位元組的值還是0-它會讀到第
一個機械軸的位置值,由於在寫入0到讀取這位址的時間內機械軸還沒移到0點位置
,它讀到的值大概也不會是0. 結果FILL把這個可能還玫歸零的位置值寫入第二個
機械軸的控制位址,第三個跟第四個機械軸也同樣收到了類似的垃圾結果。

如果FILL的寫法要讓上頭的動作有效,你必須保證它會從它寫入的記憶體中讀到它
所寫入的相同值,可是對記憶體對映輸出入的周邊位址來說,這是不能保證成立的
事情。


------------------------------------------------------------------------
--------
除錯檢查讓程式員誠實
如果CMOVE用了個除錯檢查來查核它的參數是否有效(就是說,檢查資料來源不會
在複製到目的地以前被破壞),寫出FILL函式的程式員就會在它第一次測試程式時
碰到除錯檢查發出的錯誤訊息。

這給了這名程式員兩種選擇:以合理的演算法重寫FILL,或是將除錯檢查從CMOVE
中拿掉。幸運的,只有少數程式員為了讓這樣簡單的FILL寫法能動,而會將CMOVE
中的除錯檢查拿走。


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

而我的想法是,FILL的做法是錯誤的,因為它使用了別的函式中的隱藏細節,並濫
用的這些它所應該不曉得的東西。FILL不會在隨機記憶體以外的記憶體位址上正常
運作只是小事。最主要的,它示範了違背"簡單又無聊"的程式碼寫作原則時,你會
碰到哪些麻煩。


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

不要寫出寄生函式。


------------------------------------------------------------------------
--------
攪拌油漆的老做法
粉刷房子時的一個老做法是拿一把螺絲起子打開油漆蓋,然後用螺絲起子攪拌油漆
。我也知道這做法;我有一堆染了不同顏色的螺絲起子。為何人們即使知道不應該
,還是會用螺絲起子來攪拌油漆?我會告訴你為什麼:因為螺絲起子拿起來方便,
也能有效攪拌油漆。有些程式設計技巧就像這樣,既方便,又保證有用,不過就如
同螺絲起子般,並不是被用在這些技巧本來的用途上。

讓我們看看底下的程式片段,把比較的結果當成了計算表示式的一部份:

unsigned atou(char *str);   /* atoi的無號數版本 */

/* atoi – 將一個ASCII字串轉換成一個整數。 */

int atoi(char *str)
{
    /* str的格式是 "[空格][+/-]數字"。 */

    while (isspace(*str))
        str++;
    if (*str == '-')
        return (-(int)atou(str + 1));

    /* 如果有個正號 '+',跳過它。 */
    return ((int)atou(str + (*str == '+')));
}
這段程式加上了一個測試(*str=='+')的比較到字串結果的後頭,跳過了開頭一個
選擇性的正號。你可以把程式寫成這樣,因為ANSI標準說任何相對運算的結果都是
0或1. 不過有些程式員沒有了解到一點,ANSI標準就像稅法只是告訴你怎樣計算稅
金而已,它並不是告訴你什麼事情可以作而什麼事情不能作的法典。你的做法可以
相當貼近這兩者建議的方式而實際上違背它們的指示。

這例子中的真正問題不在程式中,而是在程式寫作者的態度上。如果一名程式員覺
得在計算表示式上使用邏輯評估的結果會比較方便,那他或她還會覺得有哪些捷徑
是不可以走的,無論那些捷徑有多安全?


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

不要濫用程式語言提供的語法方便性。


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

------------------------------------------------------------------------
--------
標準的改變
當FORTH-83標準推出時,一些FORTH程式員發現他們的程式不會動了。布林(
boolean)結果值本來是在FORTH-77中定義成0跟1的,為了一些不同的理由,在
FORTH-83中變成了0跟-1. 結果,這個改變讓一些依賴true為1的程式跑不起來。

FORTH程式員們並不孤單。

USCD Pascal在1970年代晚期跟1980年代早期也是相當熱門的程式開發工具。如果
你在一台微電腦上使用Pascal,你有相當大的機率是用個USCD版本的實作在寫東西
。有天,USCD Pascal的程式員們收到了一份更新版的編譯器,結果許多人發現他
們的程式不會動了。編譯器的作者們,為了不知道什麼理由,把true的值改變了。


誰能保證,在未來的某些標準中,C語言不會有所改變?即使不是C語言改變了,而
是C++或你改用的某個衍生語言有了類似的改變呢?


------------------------------------------------------------------------
--------
APL併發症
那些不清楚C語言程式怎麼轉譯成機械碼的程式員們常會試著用精簡的C語法來改善
機械碼的品質。它們認為,如果你寫出最短的C語言敘述,你就應該會得到最短的
機械碼。在你的C程式大小跟對應的機械碼大小間有個關聯存在,不過這個關聯在
你針對一行行的程式分別討論時,就不成立了。

你還記得 第六章中的uCycleCheckBox函式 嗎?

unsigned uCycleCheckBox(unsigned uCur)
{
    return ((uCur<=1) ? (uCur?0:1) : (uCur==4)?2:(uCur+1));
}
uCycleCheckBox也許簡短,可是就如我已經指出了的,它會產生多得可怕的機械碼
。又如我們上一節中看到的return敘述,你以為它的機械碼會有多長?

return ((int)atou(str + (*str == '+')));
將比較的結果加到一個指標上可能會產生不錯機械碼,如果你使用一個好的最佳化
編譯器,而且你的目的平台可以產生0/1的測試結果而不用分岔執行路線的話。如
果你的環境不滿足這些條件,那你的編譯器將在暗中把這個比較式展開成?:運算,
並產生有如你寫了個底下的敘述般的機械碼:

return ((int)atou(str + ((*str == '+') ? 1 : 0)));
由於?:運算只是把if-else敘述隱藏起來,你會得到比你把上頭的東西寫得成底下
那個既明顯又無聊而簡單的版本要更糟糕的機械碼:

if (*str == '+')          /* 如果有個正號 '+',跳過它。 */
    str++;
return ((int)atou(str));
當然還有別的方法可以最佳化這個程式。我看過一些程式員用了一個if敘述跟一個
||運算子來取代兩行的if敘述:

(*str != '+')  ||  str++;     /* 如果有個正號 '+',跳過它。*/
return ((int)atou(str));
這樣的程式有效,因為C有著走評估捷徑的規則,不過在一個if敘述中把程式塞進
一行內部保證你會得到更好的機械碼;如果你的編譯器透過機械碼組合的副作用來
產生0或1的結果,那使用||可能會得到更糟的機械碼結果。

一條簡單的準則是用||來處理邏輯運算式,用?:來處理條件表示式,用if來處理條
件敘述。遵循這個撙哲可能十分無趣,可是你的程式大概會更有效率而更好維護。


如果你得到了致命的"一行搞定"的疾病(又稱作"APL症狀"),讓你經常以奇怪的表
示式來讓C語言的程式碼全塞在一行裡頭,好好作一次瑜珈運動,深呼吸,然後再
重複下面這句話,"有效率的程式碼是可以寫成好幾行的。有效率的程式碼是可以
寫成好幾行的......。"


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

簡短的C語言程式不保證產生有效率的機械碼。


------------------------------------------------------------------------
--------
寫程式不要輕浮
有些電腦專家沒辦法用日常英語寫出技術文件來。他們不會說"這錯誤會當死你的
系統",而會說,"這樣的軟體失誤會造成系統失控或讓系統停止執行"。這些專家
使用像"原則程式檢驗"跟"失誤分類法"這類他們認為是程式員日常用語的辭彙。這
些專家除了不能幫助讀者解決問題,反而讓讀者掩埋在晦澀難懂的詞語中。

技術寫作者並不是唯一傾向讓事情更混亂的人;有些程式員真的努力寫著讓人二丈
金剛摸不著頭腦的程式碼,認為這樣只不過是讓別人看不懂程式,卻能讓別人佩服
他們的能力。例如,後頭這函式怎麼動作的?

void *memmove(void *pvTo, void *pvFrom, size_t size)
{
    byte *pbTo   = (byte a)pvTo;
    byte *pbFrom = (byte a)pvFrom;

    ((pbTo > pbFrom) ? tailmove : headmove)(pbTo, pbFrom,
        size);

    return (pvTo);
}
如果把程式重寫成這樣,你會比較了解它在作什麼嗎?

void *memmove(void *pvTo, void *pvFrom, size_t size)
{
    byte *pbTo   = (byte *)pvTo;
    byte *pbFrom = (byte *)pvFrom;

    if (pbTo > pbFrom)
        tailmove(pbTo, pbFrom, size);
    else
        headmove(pbTo, pbFrom, size);

    return (pvTo);
}
第一個例子看來不像是用合法的C語法寫成的,可是它確實符合C的語法規定,而且
有好些機會讓編譯器產生出比第二個例子更小的執行碼。不過有多少程式員能理解
第一個程式是怎麼運作的?如果他們得維護那個程式,會發生什麼事情?就算你把
程式寫對了,把程式寫得很小,你也沒幫助別人搞懂你在寫什麼,說不定你用組合
語言來手動最佳化那個程式還比較好些。

這裡有另一個可以搞混許多程式員的例子:

while (expression)
{
    int i = 33;                 /* 宣告區域變數... */
    char str[20];

    .
    .                           /* 程式... */
    .

}
機智問答時間!i在迴圈中每次都會被初始化,或是只有在迴圈進入時才會被初始
化?你不用想就能答出正確答案嗎?如果你不確定,你的公司真是間好公司-即使
用C語言寫程式的專家也要花一小段時間來想想C語言中初始化變數的規則。

如果我把程式改變一下呢?

while (expression)
{
    int i;                      /*      宣告區域變數... */
    char str[20];

    i = 33;                     /* 程式... */
    .
    .
    .

}

------------------------------------------------------------------------
--------
誰來維護程式?
在微軟公司,一個人寫作新程式碼的量直接正比於對開發中產品內部運作的了解程
度;了解愈多的人就寫愈多新程式碼,而比較少管程式的維護。當然,如果你對專
案了解很少,你就得花許多時間來看別人寫的程式,修正別人的錯誤,對現有功能
加上少許區域性的改良。這樣的安排合理,畢竟你如果不曉得一個專案裡的東西是
怎麼寫成的,你怎麼能好好在專案中加上一個重要功能?

這樣安排的不利面是,有如一般通則,經驗老到的程式員寫新程式而油菜鳥來維護
程式。只有在經驗老到的程式員了解他們得負起讓維護程式的人能夠維護程式的責
任時,這樣實際的安排才有用。

不要誤解我的意思,我不是說你該把C語言的程式寫得很幼稚,才能讓菜鳥都看得
懂你在寫什麼;那太蠢了。我要說的事,當你可以把程式寫得很普通易懂的時候,
你就應該避免將程式寫得很艱深難懂。如果你把程式寫得易於了解,新手也能夠維
護你的程式,而不會製造新的問題出來,你也不用一直跟人解釋程式是怎麼動作的
了。


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

你對 i 在迴圈中每次都被設定成33有任何疑問嗎?你團隊中的其他程式員會質疑
這樣的寫法嗎?當然不會。

程式員們常常忘記他們的程式有兩種讀者:使用程式的顧客跟維護程式的程式員。
會忘記顧客的程式員不多,可是從我經年閱讀的程式中判斷,我認為程式員常會忘
掉第二種讀者,那些幫他們維護程式的人。

你應該寫出可維護的程式碼的觀念並不新奇。程式員們知道他們應該寫出這樣的程
式,可是他們並不完全明白,如果他們用的語言只有C的專家才看得懂,那他們的
程式就真的不好維護。畢竟,可維護的程式碼就定義來講,是能夠讓維護程式的程
式員能夠簡單了解而且修改而不會製造任何問題的。無論他們應該作什麼,這些維
護程式員們通常都是專案中的新成員,而不是那些已經待了好一陣子的專家。

寫程式時,不要忘了那些維護程式員們。


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

寫出一般程式員看得懂得程式。


------------------------------------------------------------------------
--------
不要玩弄小把戲
我們已經看過數種有問題的程式寫法,其中有許多第一眼看來很正常。不過如我們
所知,在第二眼,或甚至第五眼,你可能都看不出來這些精巧的程式中有什麼隱藏
的問題存在。如果你發現自己寫著連你自己都覺得在玩弄把戲的程式,停下來找尋
另一種解決方式吧。如果你的程式讓你覺得自己在玩弄把戲,你的直覺正在警告你
有些東西不對勁。接受你的直覺,如果你發現自己認為一部份程式碼是很酷的把戲
,你就是在對自己說有個演算法雖然產生出正確的結果,卻沒有它本來所應該的寫
得清楚。也就是說,對你而言,程式中的錯誤也不會清楚顯露出來。

真正精巧的是那些寫得很無趣的程式。你會有更少問題,而維護程式員會喜歡你這
麼做。


------------------------------------------------------------------------
--------
快速回顧
如果你在處理不屬於自己的資料,就不要對它寫入,即使只是暫時性的寫入。雖然
你會認為讀取資料應該總是安全的,記住從記憶體映對週邊讀取可能會危害到你的
硬體裝置。
?
不要參考釋放掉的記憶體。有太多方式會讓對已釋放記憶體的使用帶來問題。

效率的誘惑會讓人想把資料透過整體緩衝區或靜態緩衝區傳遞,不過這種做法充滿
了危險。如果你寫的函式對製造出只對呼叫者有用的資料,就將那些資料傳給呼叫
者,或者保證你不會意外改變那些資料。

不要依賴其他函式的特定實作來寫作函式。我們看過的那個FILL副程式呼叫CMOVE
的做法完全沒保障。這樣不合理的事情只適合出現在差勁的程式寫法。

當你寫程式時,用你的程式設計語言寫出清楚精確的程式碼,因為別人可能會用到
你的程式。避免用讓人看不懂的程式寫作語法,即使語言標準保證那樣的寫法可以
正常動作。記住,標準是會改變的。

邏輯上,C語言中有效率的表示式應該會產生出相似的有效率機械碼。不過這條邏
輯並不總是成立的。在你將明白易懂的多行C語言程式改寫塞到一行中前,確定你
會得到更好帶來麻煩的機械碼。即使你確定要這樣做,記住區域性的效率增長很少
帶來顯著的整體改善,而且通常不值得因此把程式搞得讓人看不懂。

最後,不要用律師寫作契約的方式寫程式。如果一名普通的程式員不能看懂你的程
式,那就是說你寫得太複雜了;用簡單的語言寫法重寫吧。


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

------------------------------------------------------------------------
--------
該想想的事
C語言程式寫作者經常改變傳給函式的參數。為何這樣的做法不違背輸入資料的寫
入權限?
我已經提過底下那個strFromUns函式的主要缺陷-回想一下,它會將資料透過不受
保障的緩衝區傳回。除了這主要的問題之外,strDigits的宣告方式有什麼特別危
險的地方嗎?

char *strFromUns(unsigned u)
{
    static char *strDigits = "?????";  /* 5個字元 +
    char *pch;                          '\0' */

    /* u超出範圍?請改用UlongToStr吧... */
    ASSERT(u <= 65535);

    /* 將strDigits中的數字從尾到頭寫入。 */
    pch = &strDigits[5];
    ASSERT(*pch == '\0');
    do
        a--pch = (u % 10) + '0';
    while ((u /= 10) > 0);

    return (pch);
}
我在一份期刊上看過某個程式,我注意到一個使用memset函式來將三個區域變數清
除為0的函式:

void DoSomething(...)
{
    int i;
    int j;
    int k;

    memset(&k, 0, 3*sizeof(int));  /* 將i,j跟k設定為0。 */
     .
     .
     .
這樣的程式在某些編譯器下會動作,可是你為何應該避免用這種做法?

你的電腦可能會把作業系統的一部份存放在唯讀記憶體中,為何直接跳過不必要的
負擔,略過系統介面而直接呼叫唯讀記憶體中的副程式會帶來危險?
C傳統上允許程式員傳入比函式期望收到的更少的參數。有些程式員用這個特性來
最佳化不需要全部參數的呼叫。例如,

.
     .
     .
    DoOperation(opNegAcc);    /* 不需要傳入val。*/
     .
     .
     .

void DoOperation(operation op, int val)
{
    switch (op)
    {
    case opNegAcc:
        accumulator = -accumulator;
        break;

    case opAddVal:
        accumulator += val;
        break;
     .
     .
     .
雖然這樣的最佳化有用,為何你應該避免這樣做?

底下的除錯檢查是正確的。為何它應該被重新寫過?

ASSERT((f & 1) == f);
看看另一個用下面程式碼的memmove版本:

((pbTo > pbFrom) ? tailmove : headmove)(pbTo, pbFrom, size);
你該怎麼重寫memmove,讓它保留上頭的效率而更易懂?

底下的組合語言程式說明常見的一種函式呼叫簡略法。為何你用這種做法是在找麻
煩?

move  r0,#PRINTER
        call  Print+4
        .
        .
        .
Print:  move  r0,#DISPLAY  ; (四個位元組的指令)
        .

        .                  ; r0 == 裝置代號
        .
底下的組合語言程式碼說明另一種偶爾出現的小技倆。這段程式碼跟上一個習題的
程式碼一樣,依賴Print程式的內部實作方式。你為何應該避免這樣寫?

instClearR0 = 0x36A2          ; clear r0指令的十六進制碼
        .
        .
        .
        call  Print+2         ; 輸出到PRINTER
        .
        .
        .
Print:  move  r0,#instClearR0 ; (四個位元組的指令)
        comp  r0,#0           ; 0==PRINTER,非0 ==DISPLAY
        .
        .
        .







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

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


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

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