荔园在线

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

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


发信人: jek (Super Like Cat), 信区: Program
标  题: 完美程式設計指南--全能編譯器
发信站: 荔园晨风BBS站 (Thu Apr  4 06:56:24 2002), 转信

想像一下:如果編譯器可以找出你程式中的所有問題,那你的程式還會有多少錯誤
在裡頭?我並不是在說語法錯誤而已,而是所有問題,無論是多麼隱匿的問題。

如果你犯了個筆誤,而編譯器能找到它,而且能給你一個如下的錯誤訊息:

->line 23:  while (i <= j)
                     ^^
  Off-by-one error: This should be '<'
或編譯器能在你的演算法中找到錯誤:

->line 42:  int itoa(int i, char *str)
                ^^^^
  Algorithm error: itoa fails when i is -32768
或假設編譯器能在你傳入錯誤參數時提醒你:

->line 318:  strCopy = memcpy(malloc(length), str, length);
                              ^^^^^^
  Invalid argument: memcpy fails when malloc returns NULL
好吧,這也許有點牽強,不過如果編譯器作得到這點,你想要寫出無錯誤的程式會
事件多麼容易的事呢?至少跟一般程式員所經歷過的寫作過程比起來,那不會變得
很簡單嗎?

如果你從間諜衛星的攝影機偷看一間普通的軟體工作室,你會發現程式員們龜縮在
他們的鍵盤前面追蹤已知的程式錯誤,而另一邊,你會發現測試人員正在程式的最
新內部測試版上找尋臭蟲,用奇奇怪怪的招式輸入垃圾,等著捕捉錯誤。你甚至可
能發現測試人員正在檢查有沒有老問題又浮現檯面了。如果你認為這種找尋程式錯
誤的方法比起使用一套假想編譯器來捕捉錯誤要花費更多努力,你說得沒錯;除了
努力,還需要許多運氣。

運氣?

是的,運氣。當一名測試人員發現一個問題,難道不是因為他或她注意到某些數字
錯了,或某個功能表現不如預期,或者程式當掉了嗎?再看一下假想編譯器告訴你
的那些錯誤訊息。有測試人員在程式執行時能夠看得到那個運算子的筆誤嗎?他們
又如何能看到另外兩個錯誤呢?

這聽來嚇人,但是測試人員在丟東西餵給程式時,會希望有些隱藏的錯誤自己跑出
來。"對啦,可是我們的測試人員比起那一種測試者更出色。他們使用程式碼涵蓋
測試工具,自動化的測試套件,隨機攻擊程式,顯示捕捉功能,還有許多其他的工
具。"也許這樣子說得沒錯,但是看看這些工具能作什麼吧。涵蓋分析告訴測試者
,程式的哪個部分還沒測試過;測試人員用這些訊息來檢查你程式的新輸入跟產生
的結果。然後其他工具只是自動化了"敲敲看會不會有反應"的找尋問題策略。

不要誤解我,我不是說這些測試人員作錯了。我是在說,把程式當成個黑箱子來測
試是件困難的事情,因為測試者所能做到的就是餵給程式一些輸入,然後等著看有
什麼東西跑出來。就好像在判斷一個人是不是瘋了一樣,你發問;你聽取答案;然
後你作出判斷。最後,你永遠不能真正確定結果,因為你不知道在另一個人腦袋裡
到底有些什麼東西正在運作中。你永遠會懷疑,"我問夠問題了嗎?我問對問題了
嗎?"

不要依賴於黑箱測試。試著模仿一個全能編譯器,消除運氣成分,爭取每個自動找
到問題的機會。

注意你用的語言
你上一次看到一份頂尖的文書處理器的廣告是哪時候?廣告商的說辭大概會長得像
這樣:"無論你在寫信給小孩的老師或是構思下一部偉大的英文小說,
WordSmasher都能不費吹灰之力辦好它,而且找到您作品中的筆誤。本程式提供驚
人的233,000字拼字辭典-比其他競爭軟體至少多出51,000字。現在就去店裡頭買
份WordSmasher,這個從原子筆發明以來最具革命性的寫作工具。"

身為使用者,我們已經被行銷宣傳騙得相信拼字辭典愈大愈好,但這是不對的。在
任何紙製字典中,你都能找到em,abel跟si這些字,但是你真的希望你的拼字檢查
器允許這些字在更常見的me,able跟is之間出現嗎?如果你在我寫的東西裡頭看到
suing這字,這種天文奇蹟般的錯誤一定是我把using寫錯了。我不在乎是不是真的
有suing這個字;至少,在我的文章裡,那是個錯字。

幸運的,高級拼字檢查器能讓你從字典中刪除如em般討厭的字,讓你能夠將那樣的
字當成錯字處理。好的編譯器也是一樣-它們能夠你標示一些可視為合法的C語言
用法,因為這些用法通常被用錯了。這樣的編譯器可以偵測到底下這個while迴圈
中放錯了的分號:

/* memcpy - copy a nonoverlapping memory block. */

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

    while (size- > 0);
        *pbTo++ = *pbFrom++;

    return (pvTo);
}
你能分辨出縮排之後的那個分號是錯的,可是編譯器看到的是一個沒作任何事的
white敘述,這是完全合法的。你有用到空敘述的時候,也有不會用到的時候。要
捕捉到一個非你想要的空敘述,編譯器通常提供了一個警告選項,如果你用了它,
就會在碰到類似這樣的錯誤時警告你。而當你真的想要用到空敘述時,你也可以依
照編譯器使用手冊中建議的翻譯避開它-用個會在程式碼最佳化時被移除的常數表
示式(如NULL;),或使用空白區塊{ },或使用其他編譯器支援的解決方案。我在
這裡使用{ }。

char *strcpy(char *pchTo, char *pchFrom)
{
    char *pchStart = pchTo;

    while (*pchTo++ = *pchFrom++)
        {}

    return (pchStart);
}

------------------------------------------------------------------------
--------
結果:
你得到了空敘述提供的彈性,而編譯器將你不想要的空敘述自動當成錯誤。不允許
一種空敘述的存在與為了統一使用zeroes的拼法而將zeros從拼字辭典中刪除差不
了多少。


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

另一個常見的問題是無意間錯用的指派敘述。C的彈性讓你能在運算式的任何地方
使用指派敘述,不過如果你不夠小心,這樣額外的彈性將會造成你的困擾。讓我們
來看個常見的錯誤:

if (ch = t')
    ExpandTab();
雖然這程式碼很清楚的是用來比較ch是不是跳位字元,它真正作的卻是將ch的內容
設定為這個字元。當然編譯器不會產生任何錯誤,因為這段程式碼合乎C語言的語
法。

有些編譯器可以讓你禁止簡單指派敘述出現在&&跟||運算式間與如if,for跟
while等其他控制運算式中,幫你抓到這樣的錯誤。這個功能背後的點子是,如果
一名程式寫作者不小心把==打成了=,就可以從上面那五種狀況中捕捉到這種錯誤


這樣的選項不阻止你在運算式中對變數內容進行設定,但你必須明白指定一個值如
0或nul字元來進行比較,才能避開這種警告。因此,回到前面那個strcpy的例子,
我們不把迴圈寫成底下會產生警告訊息的這樣子

while (*pchTo++ = *pchFrom++)
    {}
而是寫成底下這樣子:

while ((*pchTo++ = *pchFrom++) != 0')
    {}
還好現在的商業版編譯器不會對這種比較運算式產生額外的程式碼,因為這是多餘
的,可以被最佳化略掉的。你可以放心依賴有這類警告選項的編譯器提供的最佳化
功能。再一次的,這樣的想法是禁止有安全替代方式的危險性程式寫法被使用,即
使兩種寫法是合法的。

另一類型的錯誤屬於"參數錯誤"的類型。好幾年前,當我還在學C語言時,我習慣
這樣子用fputc:

fprintf(stderr, "Unable to open file %s.\n", filename);
.
.
.
fputc(stderr, '\n');
這樣子看來還好,可是fputc的參數順序不對。不知道怎麼搞的,我以為檔案資料流
指標(stderr)永遠是任何檔案資料流處理函式的第一個參數。這是不對的,所以
我經常丟垃圾給這些副程式。幸運的,ANSI C提供了自動捕捉這類錯誤的編譯方法
:函式雛形宣告。

由於ANSI標準要求所有程式庫函式都有雛形宣告,你可以在stdio.h這表頭檔中找
到fputc的雛形宣告,它應該長得像這樣子:

int fputc(int c, FILE *stream);
如果你將stdio.h包含進檔案裡,並呼叫了fputc,編譯器就會比較你傳入的每個參
數是不是預期的那樣子,如果型態不一致,它就會產生錯誤。在我的例子中,由於
我是在一個int參數的位置傳入一個FILE *參數,雛形宣告檢查就會捕捉到我早期
犯下的fputc錯誤。

ANSI C會要求標準函式要滿足雛形宣告的要求,可是它不要求你我所寫的函式也同
樣有著雛形宣告:那純粹是選擇性的。如果你想在自己的程式碼中找到參數使用的
錯誤,你就必須維持一份最新的函式雛形宣告。

現在我聽到有程式員抱怨必須維護雛形宣告的更新,特別當他們將使用傳統C語言
寫的專案移植到ANSI C的開發平台時。這樣的抱怨有時是情有可原的,可是仔細想
想:如果你不使用雛形宣告,你就得仰賴傳統的測試方法,期望這樣子可以在你的
程式中捕捉到任何參數使用的錯誤。問問你自己,哪樣子比較重要?是替你自己省
下一些維護功夫重要,還是能夠在你編譯程式碼時捉到錯誤重要?如果你還不能決
定,考慮一下使用雛形宣告可以產生更好的程式執行碼,因為:ANSI C標準允許編
譯器依據雛形宣告的資訊進行程式最佳化。


------------------------------------------------------------------------
--------
強化雛形宣告
不幸的,雛形宣告不能在你把兩個相同型態的參數弄反時警告你。例如,如果
memchr函式有這樣的雛形宣告:

void *memchr(const void *pv, int ch, int size);
你可能會把ch跟size兩個參數弄反了,而編譯器不會發出任何警告。不過如果在你
的介面與雛形宣告上使用更精確的資料型態,你就能強化雛形宣告所提供的錯誤檢
查。例如,底下的雛形就能在你弄反了ch跟size兩個參數時警告你:

void *memchr(const void *pv, int ch, int size);
這樣的缺點是得使用更精細的資料型態,讓你得經常把參數轉型成必要的資料型態
-即使它們的順序是對的-來避開這種不重要的型態不符警告。


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

在傳統C語言中,編譯器在編譯時對於檔案外頭的函式沒有那麼多資訊,可是它還
是需要產生呼叫這些函式的程式碼,而這些呼叫顯然必須生效。編譯器作者使用了
一種正規化的呼叫方式來解決這個問題,這麼做雖然讓函式呼叫一定生效,但是編
譯器卻經常需要產生額外的程式執行碼來支撐這種做法的運作。不過如果你打開編
譯器的"所有函式都要有雛形宣告"的警告選項,編譯器就可以它認為最有效率的呼
叫方式來產生執行碼,因為它知道程式中每個函式的參數用法。

空敘述警告,錯誤指派警告,以及雛形宣告檢查都只是許多C語言編譯器中都能找
到的選項;通常它們提供更多警告選項。選擇性編譯器警告的重點在於讓你了解哪
些地方可能有錯誤,就像一個拼字檢查器告訴你哪邊可能有拼錯字一樣。

Peter Lynch,可以說是1980年代最棒的共同基金經理,曾經說過投資者跟賭客的
差別在於投資者把握所有機會,無論機會多小,來讓優勢傾向自己;而賭客,在他
看來,只依靠運氣。將這觀念套到程式寫作上,打開所有選擇性的編譯器警告訊息
吧。不要問"我應該打開這個警告選項嗎?"而是問"為什麼不打開這個警告選項?
"除非你有個很好的理由,不然把每個警告訊息都打開吧。


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

打開所有選擇性的編譯器警告。


------------------------------------------------------------------------
--------
lint-這東西還不錯
一個幾乎不費事而更完美的錯誤找尋法是使用lint,這本來是個用來檢查C語言原
始碼檔案,然後報告原始碼中有哪裡缺乏移植性的工具。不過現在,大部分的
lint工具都更完美許多,不只是能夠標示出有移植性問題的程式碼,還能找出雖然
具可攜性而且看來完全合法,卻似乎有問題的語句寫法。無意留下的空敘述、錯誤
的指派敘述跟呼叫參數錯誤,這些前面段落裡提到的錯誤都在它的捕捉範圍內。

不幸的,許多程式員還是將lint當成會吐出一大堆他們不在乎的警告訊息的可攜性
檢查工具而已;這個工具的惡名讓人裹足不前。如果你就是這樣子誤解lint的程式
員之一,也許你應該重新評估你的意見了。不管怎麼說,你覺得哪個工具比較貼近
我們稍早講的假想編譯器?是你用的編譯器,還是lint?

事實上,一旦你將原始碼丟給lint檢查過並把它修改成一點警告訊息也不會出現的
樣子,要讓它繼續維持讓lint一點也找不出錯誤是很簡單的-只要在你把修改過的
程式更新到原始碼正本以前,先跑一遍lint就好了。一兩個星期後,你不用想太多
就寫得出來lint找不出半點錯誤的程式碼了。當你達到這個目標後,你就學到lint
提供的所有優點,而不會有半點頭痛了。


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

用lint來抓你的編譯器可能漏掉了的錯誤。


------------------------------------------------------------------------
--------
可是我改的都很簡單
有次我跟本書一位技術檢閱者一起吃午餐,它問我要不要加上一段單元測試的內容
。我說我不想,因為單元測試雖然跟寫出零錯誤程式有關,不過那實在應該歸類到
別類去:如何設計程式的測試。

他說,"不,你誤解我的意思了。我以為你正想指出程式員該在將他們所修改的程
式碼更新到原始碼正本之前,實際進行單元測試。我團隊裡的一名程式員才剛讓一
隻臭蟲溜進我們的原始碼正本裡,因為他沒在改過東西後進行單元測試。"

這真是令人驚訝,因為大部分微軟的專案領導者都期望程式員在將改過的東西放進
原始碼正本以前進行單元測試。

"你問過他為何不作測試嗎?"我說。

我的朋友從吃飯中抬起頭看。"他說他沒寫任何新東西-他只是搬動了一些老程式
。他說他沒想過他需要測試一下。"

這故事讓我想起,有個程式員在改過東西後,連編譯都沒作,就把程式碼更新到原
始碼正本裡頭。這問題是我發現的,因為我沒辦法不碰到錯誤就讓這程式專案編譯
過關。當我問那名程式員他怎麼漏掉一個編譯錯誤的,他說,"這修改很簡單,我
不覺得我會犯錯。"

沒有任何一個這樣的錯誤應該出現在原始碼正本裡,因為這些都是可以被自動捉到
的錯誤,幾乎不用費任何力氣就可以捉到了。為何程式員們會犯下這些錯誤?主要
是因為他們對於自己正確寫出程式的能力太具自信了。

有時你會覺得你可以略過一個預防錯誤的設計,但當你抄捷徑時,你就是在找麻煩
。我想有許多程式員寫好程式後根本連編譯一下都沒有-我只是剛好碰到了一個-
只因為略過單元測試的誘惑強了些,誰叫他們修改的部分都很簡單呢?

如果你發現自己想要跳過一步可以簡單找到錯誤的步驟,最好停下來,並且搬出每
個你用得了的工具來好好檢查一番。單元測試是用來抓臭蟲的,可是如果你把它略
掉了,當然就什麼蟲都抓不到了。


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

有單元測試就作,不要跳過去。


------------------------------------------------------------------------
--------
沒蛋捲囉
你認識多少個程式寫作者喜歡花時間找尋錯誤跟修正臭蟲而不是花時間寫新的程式
?我確定有這樣的程式寫作者,不過我從來也沒碰到過。如果跟我認識的程式員們
說他們不用再找別的臭蟲了,他們誰也不願意把中國菜叫外帶冷掉的一點也不好吃


當你寫程式時,將本章所說的假想編譯器的觀念記在心裡,把握每個自動或者不需
費勁就能抓到錯誤的機會。想想編譯器發出的錯誤訊息,lint發出的錯誤訊息,跟
單元測試的失敗。你需要什麼技巧來找尋這些錯誤?幾乎什麼也不用。如果沒有臭
蟲需要高深的技巧或重大努力就能找到,那還會有多少錯誤出現在你推出的產品裡
頭?

如果你想快速而簡單的找到程式中的錯誤,使用那些你的程式發展工具提供來告訴
你哪邊有錯誤的功能吧。你愈快知道哪邊有錯誤,你就能愈快修好它們,並且進行
下一件更有趣的工作。


------------------------------------------------------------------------
--------
快速回顧
最好的程式除錯方式就是儘早而且盡可能簡單的找到錯誤,採用花最小力氣的自動
錯誤捕捉方式。
?
努力減少程式員捉到錯誤所需的技巧量,編譯器的選擇性警告或lint的警告訊息不
需要任何程式設計技巧就能捉到程式錯誤。


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

------------------------------------------------------------------------
--------
該想想的事
假設你正使用編譯器選項禁止在while條件式中使用指派敘述。為何這樣子會抓到
底下的這個錯誤?

while (ch=getchar() != EOF)
    .
    .
    .
你已經件事過如何使用編譯器來捕捉無意中產生的空敘述與指派敘述。對下面這些
會讓編譯器選擇性的產生警告的常見問題給些建議,你該如何避開這些警告?

a. if(flight==063) 你認為你正在測試Flight 63,可是實際上因為起頭那個0讓
編譯器認為063是個八進制數字,而造成你實際上是在測試Flight 51。

b. if (pb != NULL & *pb != 0xFF) 你不小心把&&打成了&,讓*pb != 0xFF在pb
為NULL時一樣會被執行到。

c. quot = numer /* pdemon; 不管你的意思是什麼,那個/*是被當成注釋的開頭
解釋的。

d. word=bHigh<<8 + bLow;由於運算子順序的規則,不管你的原意為何,這式子被
解釋成word=bHigh<<(8+bLow);

編譯器怎樣自動警告你"孤立的else敘述"的可能錯誤?你該怎樣避開這種錯誤?
再來看一下底下這段錯誤程式:

if (ch = '\t')
    ExpandTab();
你可以另一種常見方式來抓到這種錯誤而不用禁止if敘述中的指派用法。你可以將
兩個運算元的順序反過來看看:

if ('\t' = ch)
    ExpandTab();
如果你把==打成了=,編譯器這時就會抱怨,因為你不能把任何東西指派給一個常
數。這種解決方案有多徹底呢?為何這個方法不如編譯器的編譯選項那樣自動化?


C語言的前置處理器也可能產生非預期的結果。例如,UINT_MAX巨集定義在
limits.h中,可是你如果忘了引用這個表頭檔,#if編譯底下的指示將會什麼錯誤
訊息也沒發出就出錯了-前置處理器將把未定義的UINT_MAX替換成0,而測試結果
就不正確了:

.
.
.
#if UINT_MAX > 65535u
    .
    .
    .
#endif

------------------------------------------------------------------------
--------
學習計劃:
為了簡化維護雛形宣告的工作,有些編譯器會在編譯程式時自動產生表頭宣告。如
果你的編譯器不支援這種選項,寫個會替你作這件事的工具吧。標準的函式寫法會
讓這樣的工具多好寫呢?


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

------------------------------------------------------------------------
--------
學習計劃:
如果你的編譯器沒支援本章節所提到的警告選項(包括前面習題裡提到的),鼓勵
你的編譯器製造商支援這些選項吧。也順便催你的編譯器製造商提供選擇性開關特
定警告訊息的方法,以便讓你能夠開關不同類型的選擇性錯誤。為何這麼做是必須
的呢?

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

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


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

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