完美程式设计指南.docx
- 文档编号:15501290
- 上传时间:2023-07-05
- 格式:DOCX
- 页数:36
- 大小:39.63KB
完美程式设计指南.docx
《完美程式设计指南.docx》由会员分享,可在线阅读,更多相关《完美程式设计指南.docx(36页珍藏版)》请在冰点文库上搜索。
完美程式设计指南
完美程式設計指南
用編譯器來自動抓蟲是很棒的事,但是我打賭,如果你檢查過程式專案中抓到的那些臭蟲,你會發現編譯器只抓到了其中一小部份。
我打賭,如果你將臭蟲隔離開來,你會發現程式現在大概會執行得很正常。
還記得下面這個第一章中的程式片段嗎?
strCopy=memcpy(malloc(length),str,length);
這程式在任何狀態下都會正常運作,除非malloc配置記憶體失敗。
當記憶體配置失敗時,malloc會傳給memcpy一個NULL指標,而memcpy沒辦法處理這種狀況。
如果你夠幸運,在你推出這個產品之前,你就會看到這個系統當掉;否則,你的顧客也會碰到程式當掉的災難。
1
編譯器抓不到像這樣的錯誤,也沒有編譯器能夠幫你抓到演算法中的錯誤,檢驗你的假設,或在資料傳遞時進行一般性的查核工作。
找尋這種錯誤是困難的事情,需要一名有技巧的程式員或測試人員整個把它們挖出來。
不過要自動找到這類錯誤是容易的事情,如果你知道怎麼做的話。
兩個版本的故事
讓我們更進一步,看看你該怎麼抓到像上頭那個memcpy敘述中的錯誤。
最簡單的解決辦法就是讓memcpy檢查NULL指標是不是被當成了參數使用,如果是,就丟個錯誤訊息出來,並中止程式執行。
底下就是我們自己改良過的新版memcpy:
/*複製一段非重疊的記憶體。
*/
void*memcpy(void*pvTo,void*pvFrom,size_tsize)
{
byte*pbTo=(byte*)pvTo;
byte*pbFrom=(byte*)pvFrom;
if(pvTo==NULL||pvFrom==NULL)
{
fprintf(stderr,"Badargsinmemcpy\n");
abort();
}
while(size->0)
*pbTo++=*pbFrom++;
return(pvTo);
}
使用這個函式,沒人會漏掉將NULL指標傳給memcpy函式的錯誤。
剩下來的問題只是這種測試方式把程式變大而且變慢了些。
如果你覺得這是另一個有醫比沒醫更糟糕的狀況,我想你是對的;這種測試方式並不實用。
這時C語言前置處理器就派上用場了。
如果你有兩個版本的程式,結果會怎樣?
一個發行版本又快又好,另一個包含額外檢查碼的版本則又胖又慢。
你可以在同一份原始碼中維護兩個版本的程式,只要使用C語言的前置處理器來條件性的加入或移除檢查程式碼就好了。
2
舉例來說,你可以讓NULL指標的測試只有在DEBUG符號被定義時才會被編譯到:
void*memcpy(void*pvTo,void*pvFrom,size_tsize)
{
byte*pbTo=(bytea)pvTo;
byte*pbFrom=(bytea)pvFrom;
#ifdefDEBUG
if(pvTo==NULL||pvFrom==NULL)
{
fprintf(stderr,"Badargsinmemcpy\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巨集:
#ifdefDEBUG
void_Assert(char*,unsigned);/*prototype*/
#defineASSERT(f)if(f){}else_Assert(__FILE__,__LINE__)
#else
#defineASSERT(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,unsigneduLine)
{
fflush(NULL);
fprintf(stderr,"\nAssertionfailed:
%s,line%u\n",
strFile,uLine);
fflush(stderr);
abort();
}5
程式結束前,你得呼叫fflush來完全寫出緩衝區中等待輸出的東西。
呼叫fflush(NULL)是非常重要的,因為這樣可以確保錯誤訊息在其他緩衝區的東西都被送去寫入後才出現。
現在,如果你使用NULL指標呼叫memcpy,ASSERT將抓到這個錯誤,並印出如下的訊息
Assertionfailed:
string.c,line153
這顯示出了assert跟ASSERT的差別。
標準巨集會顯示一個類似上頭的訊息,可是它也會顯示不成立的那個測試運算式。
底下就是我常用的一個編譯器產生的assert不成立時的訊息:
Assertionfailed:
pvTo!
=NULL&&pvFrom!
=NULL
Filestring.c,line153
將條件運算視野一起印出來的唯一問題是當你使用assert時,程式中也會包含一份這個條件式的文字給_Assert列印。
那編譯器怎麼存放這字串?
麥金塔、MS-DOS跟Windows的編譯器一般都會將字串放在整體資料區域內,在麥金塔上的整體資料區域大小限制一般是32K,在MS-DOS跟16-bitWindows上,這個大小限制是64K.對於如MicrosoftWord跟MicrosoftExcel這類的大程式來說,這些除錯維護字串會迅速吃光整體資料區域的空間。
譯註:
在MacOS7.0的32-bitaddressing跟Win95/98/NT下,類似的限制幾乎是不存在了。
不過不要忘了,即使沒有限制了,這些除錯訊息所用到的字串還是要吃記憶體的。
6
當然有解決方法,不過最簡單的就是從錯誤訊息中省掉那個條件運算式的字串。
畢竟,當你看過string.c的第153航後,你就知道問題在哪裡,你可以在那裡頭找到原因。
如果你想看看怎樣標準的assert是怎麼定義的,你可以看到系統上提供的assert.h檔案。
ANSI標準的Rationale段落也談到assert,並提供一個可能實作。
P.J.Plauger也在他的書TheStandardCLibrary(PrenticeHall,1992)中提到實作標準assert的巧妙之處。
不管你最後怎麼定義自己的除錯維護巨集敘述,用它來核對傳給函式的參數正確性。
如果你在每個函式的進入點都檢查資料的正確性,程式的錯誤不用多久就會被找到。
最棒的,是這些錯誤都是在發生時就被自動抓到的。
用除錯檢查巨集來核對函式參數。
"未定義"就要"釐清"
如果你要停下來看ANSIC怎麼定義memcpy副程式的,你會看到最後一行寫著,"在互相覆蓋的記憶體區塊間進行資料搬移動作時的行為是未定義的。
"其他書籍將這種未定義說得不太一樣,像StandardC(MicrosoftPress,1989),P.J.Plauger跟JimBrodie說,"陣列的元素可以任意順序存取跟存放。
"
簡單說來,這些書在說的就是,如果你依賴memcpy在處理重疊的記憶體塊時的某種特定行為,你假定這種在不同電腦系統間或甚至同個編譯器的不同版本間可能有所變化的行為是不變的。
7
我確定有程式員謹慎的使用著這種未定義的行為,不過我想大部分的程式員聰明的避免這麼作。
那些不避開這種未定義行為的人最好學著避開它。
大部分程式員將未定義行為視作非法行為,這時除錯檢查巨集就派上用場了。
如果你在想呼叫memmove時使用memcpy,你不想多了解一點這樣子有什麼不同嗎?
你可以加強memcpy的功能,加上一個除錯檢查巨集來檢查兩個記憶體區間是不是完全沒重疊到:
/*memcpy--複製一塊非重疊的記憶體區域。
*/
void*memcpy(void*pvTo,void*pvFrom,size_tsize)
{
byte*pbTo=(bytea)pvTo;
byte*pbFrom=(bytea)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=(chara)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副程式就不用任何關於使用環境的假設而能在任何一種ANSIC編譯器上用得很好:
/*memset–將記憶體填滿那個位元組型態參數的值。
*/
void*memset(void*pv,byteb,size_tsize)
{
byte*pb=(byte*)pv;
while(size->0)
*pb++=b;
return(pv);
}
不過對許多環境來說,你能寫個更快速的memset副程式,將一個較大的資料型態填滿那個位元組型態參數的值,再拿這個較大的資料型態用較少的迴圈次數填滿幾乎同樣多的記憶體。
底下的例子,在68000微處理器上,就能以前一頁本來那個可移植版的memset跑得快四倍:
/*
*longfill–將記憶體填滿long參數的值,並傳回一個指標指向
*最後一個填入的長整數之後的一個下長整數的位址。
*/
long*longfill(long*pl,longl,size_tsize);
/aprototype*/
void*memset(void*pv,byteb,size_tsize)
{
byte*pb=(bytea)pv;
if(size>=sizeThreshold)
{
unsignedlongl;
l=(b<<8)?
b;/*將一個長整數的四個位元組都填成b的值。
*/
l=(l<<16)?
l;
pb=(bytea)longfill((longa)pb,l,size/4);
size=size%4;
}
while(size->0)
*pb++=b;
return(pv);
}
上面這個副程式相當簡單,除了那個對sizeThreshold的檢查可能有點不清楚。
不清楚的東西為什麼要留著呢?
想想,將一個長整數的四個位元組填成我們要的值也是需要時間的,再加上呼叫longfill函式的損耗時間,對sizeThreshold的檢查確保memset只有在能比原先的做法跑得更快時,才會使用新的做法。
新版本memset唯一的問題是,它使用了一堆對編譯器與作業系統的假設。
這程式假設長整數一定是四個位元組長的,而一個位元組一定是八個位元寬的。
這樣的假設在許多電腦上都成立,而且現在在微電腦上也近乎每一部上頭都成立。
可是,那不代表你應該讓程式依賴於這樣的假設之上,因為唯一靠得住的假設,就是你的假設現在可能成立,可是幾年後卻可能不成立。
譯註:
從十六位元的MS-DOS,Windows3.1到三十二位元的MacOS與Win32環境跟目前的大部分Unix版本下,大部分的編譯器都將長整數當成32位元長的。
但在微軟公司1998年五月訂定的Win64環境的資料型態標準中,長整數的長度延伸成了64位元長。
在一些64位元微處理機的系統上,如DECAlpha,也有編譯器將長整數的長度訂為64位元長的。
所以依賴這種位元長度的假設可以說是相當危險的一件事情。
12
有些程式員會將這副程式改寫成底下這樣,來改善可攜性:
void*memset(void*pv,byteb,size_tsize)
{
byte*pb=(bytea)pv;
if(size>=sizeThreshold)
{
unsignedlongl;
size_tsizeLong;
l=0;
for(sizeLong=sizeof(long);sizeLong->0;)
l=(l< b; pb=(bytea)longfill((longa)pb,l,size/ sizeof(long)); size=size%sizeof(long); } while(size->0) *pb++=b; return(pv); } 這程式看來比較具有可攜性,因為它使用了大量的sizeof運算子,可是用看的一點意義也沒有;你還是得在它移植到另一個環境後,重新檢查一遍。 如果你將這程式在MacintoshPlus(譯按: 好幾年以前的一種標準型麥金塔電腦)或任何其他68000電腦上執行,這程式在pv一開始就指向奇數位址時就當掉。 因為byte*跟long*兩種資料型態在68000上是不能完全互通的-你不能把一個長整數存放在奇數位址上,不然就會引發微處理器的匯流排位址失誤。 譯註: 32位元微處理器如Motorola68000跟SunSparc要求長整數一定要存放在偶數位址上,不然就會引發Busfault,匯流排位址失誤。 好一點的作業系統會處理這個失誤,把程式關閉掉,差一點的作業系統乾脆直接當機了事。 從Motorola68020允許長整數存放在奇數位址以後,這種位址存取失誤就變成可以選擇性關閉觸發了;其實在Intel80386以後的微處理器也都支援開啟這樣的位址存取失
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 完美 程式 设计 指南
![提示](https://static.bingdoc.com/images/bang_tan.gif)