對于斷言,相信大家都不陌生,大多數編程語言也都有斷言這一特性。簡單地講,斷言就是對某種假設條件進行檢查。?在 C 語言中,斷言被定義為宏的形式(assert(expression)),而不是函數,其原型定義在?
其中,assert 將通過檢查表達式 expression 的值來決定是否需要終止執行程序。也就是說,如果表達式 expression 的值為假(即為 0),那么它將首先向標準錯誤流 stderr 打印一條出錯信息,然后再通過調用 abort 函數終止程序運行;否則,assert 無任何作用。
原型定義:
?
#include?void?assert(?int?expression?);
?
默認情況下,assert?宏只有在 Debug 版本(內部調試版本)中才能夠起作用,而在 Release 版本(發行版本)中將被忽略。
當然,也可以通過定義宏或設置編譯器參數等形式來在任何時候啟用或者禁用斷言檢查(不建議這么做)。同樣,在程序投入運行后,最終用戶在遇到問題時也可以重新起用斷言。
這樣可以快速發現并定位軟件問題,同時對系統錯誤進行自動報警。對于在系統中隱藏很深,用其他手段極難發現的問題也可以通過斷言進行定位,從而縮短軟件問題定位時間,提高系統的可測性。
盡量利用斷言來提高代碼的可測試性
在討論如何使用斷言之前,先來看下面一段示例代碼:
?
void?*Memcpy(void?*dest,?const?void?*src,?size_t?len) { ????char?*tmp_dest?=?(char?*)dest; ????char?*tmp_src?=?(char?*)src; ????while(len?--) ????????????*tmp_dest?++?=?*tmp_src?++; ????return?dest; }
?
對于上面的 Memcpy 函數,毋庸置疑,它能夠通過編譯程序的檢查成功編譯。從表面上看,該函數并不存在其他任何問題,并且代碼也非常干凈。
但遺憾的是,在調用該函數時,如果不小心為 dest 與 src 參數錯誤地傳入了 NULL 指針,那么問題就嚴重了。輕者在交付之前這個潛在的錯誤導致程序癱瘓,從而暴露出來。否則,如果將該程序打包發布出去,那么所造成的后果是無法估計的。
由此可見,不能夠簡單地認為“只要通過編譯程序成功編譯的就都是安全的程序”。當然,編譯程序也很難檢查出類似的潛在錯誤(如所傳遞的參數是否有效、潛在的算法錯誤等)。
面對這類問題,一般首先想到的應該是使用最簡單的if語句進行判斷檢查,如下面的示例代碼所示:
?
void?*Memcpy(void?*dest,?const?void?*src,?size_t?len) { ????if(dest?==?NULL) ????{ ????????fprintf(stderr,"dest?is?NULL "); ????????abort(); ????} ????if(src?==?NULL) ????{ ????????fprintf(stderr,"src?is?NULL "); ????????abort(); ????} ????char?*tmp_dest?=?(char?*)dest; ????char?*tmp_src?=?(char?*)src; ????while(len?--) ????????*tmp_dest?++?=?*tmp_src?++; ????return?dest; }
?
現在,通過“if(dest == NULL)與if(src == NULL)”判斷語句,只要在調用該函數的時候為 dest 與 src 參數錯誤地傳入了NULL指針,這個函數就會檢查出來并做出相應的處理,即先向標準錯誤流 stderr 打印一條出錯信息,然后再調用 abort 函數終止程序運行。
從表面看來,上面的解決方案應該堪稱完美。但是,隨著函數參數或需要檢查的表達式不斷增多,這種檢查測試代碼將占據整個函數的大部分(這一點從上面的 Memcpy 函數中就不難看出)。
這樣代碼看起來非常不簡潔,甚至可以說很“糟糕”,而且也降低了函數的執行效率。
面對上面的問題,或許可以利用 C 的預處理程序有條件地包含或不包含相應的檢查部分進行解決,如下面的代碼所示:
?
void?*MemCopy(void?*dest,?const?void?*src,?size_t?len) { ????#ifdef?DEBUG ????if(dest?==?NULL) ????{ ????????fprintf(stderr,"dest?is?NULL "); ????????abort(); ????} ????if(src?==?NULL) ????{ ????????fprintf(stderr,"src?is?NULL "); ????????abort(); ????} ????#endif ????char?*tmp_dest?=?(char?*)dest; ????char?*tmp_src?=?(char?*)src; ????while(len?--) ????????*tmp_dest?++?=?*tmp_src?++; ????return?dest; }
?
這樣,通過條件編譯“#ifdef DEBUG”來同時維護同一程序的兩個版本(內部調試版本與發行版本),即在程序編寫過程中,編譯其內部調試版本,利用其提供的測試檢查代碼為程序自動查錯。而在程序編完之后,再編譯成發行版本。
上面的解決方案盡管通過條件編譯“#ifdef DEBUG”能產生很好的結果,也完全符合我們的程序設計要求,但是仔細觀察會發現,這樣的測試檢查代碼顯得并不那么友好,當一個函數里這種條件編譯語句很多時,代碼會顯得有些浮腫,甚至有些糟糕。
因此,對于上面的這種情況,多數程序員都會選擇將所有的調試代碼隱藏在斷言 assert 宏中。其實,assert 宏也只不過是使用條件編譯“#ifdef”對部分代碼進行替換,利用 assert 宏,將會使代碼變得更加簡潔,如下面的示例代碼所示:
?
void?*MemCopy(void?*dest,?const?void?*src,?size_t?len) { ????assert(dest?!=?NULL?&&?src?!=NULL); ????char?*tmp_dest?=?(char?*)dest; ????char?*tmp_src?=?(char?*)src; ????while(len?--) ????????????*tmp_dest?++?=?*tmp_src?++; ????return?dest; }
?
現在,通過“assert(dest !=NULL && src !=NULL)”語句既完成程序的測試檢查功能(即只要在調用該函數的時候為 dest 與 src 參數錯誤傳入 NULL 指針時都會引發 assert),與此同時,對 MemCopy 函數的代碼量也進行了大幅度瘦身,不得不說這是一個兩全其美的好辦法。
實際上,在編程中我們經常會出于某種目的(如把 assert 宏定義成當發生錯誤時不是中止調用程序的執行,而是在發生錯誤的位置轉入調試程序,又或者是允許用戶選擇讓程序繼續運行等)需要對 assert 宏進行重新定義。
但值得注意的是,不管斷言宏最終是用什么樣的方式進行定義,其所定義宏的主要目的都是要使用它來對傳遞給相應函數的參數進行確認檢查。
如果違背了這條宏定義原則,那么所定義的宏將會偏離方向,失去宏定義本身的意義。與此同時,為不影響標準 assert 宏的使用,最好使用其他的名字。例如,下面的示例代碼就展示了用戶如何重定義自己的宏 ASSERT:
?
/*使用斷言測試*/ #ifdef?DEBUG /*處理函數原型*/ void?Assert(char?*?filename,?unsigned?int?lineno); #define?ASSERT(condition) if(condition) ????NULL;? else ????Assert(__FILE__?,?__LINE__) /*不使用斷言測試*/ #else #define?ASSERT(condition)?NULL #endif void?Assert(char?*?filename,?unsigned?int?lineno) { ????fflush(stdout); ????fprintf(stderr," Assert failed:?%s, line %u ",filename,?lineno); ????fflush(stderr); ????abort(); }
?
如果定義了 DEBUG,ASSERT 將被擴展為一個if語句,否則執行“#define ASSERT(condition) NULL”替換成 NULL。
這里需要注意的是,因為在編寫 C 語言代碼時,在每個語句后面加一個分號“;”已經成為一種約定俗成的習慣,因此很有可能會在“Assert(__FILE__,__LINE__)”調用語句之后習慣性地加上一個分號。
實際上并不需要這個分號,因為用戶在調用 ASSERT 宏時,已經給出了一個分號。面對這種問題,我們可以使用“do{}while(0)”結構進行處理,如下面的代碼所示:
?
#define?ASSERT(condition) do{??? ????if(condition) ???????NULL;? ????else ???????Assert(__FILE__?,?__LINE__); }while(0) 現在,將不再為分號“;”而擔心了,調用示例如下: void?Test(unsigned?char?*str) { ????ASSERT(str?!=?NULL); ????/*函數處理代碼*/ } int?main(void) { ????Test(NULL); ????return?0; }
?
很顯然,因為調用語句“Test(NULL)”為參數 str 錯誤傳入一個 NULL 指針的原因,所以?ASSERT?宏會自動檢測到這個錯誤,同時根據宏?__FILE__?和?__LINE__?所提供的文件名和行號參數在標準錯誤輸出設備 stderr 上打印一條錯誤消息,然后調用 abort 函數中止程序的執行。運行結果如圖 1 所示。
圖 1 調用自定義 ASSERT 宏的運行結果
如果這時候將自定義 ASSERT 宏替換成標準 assert 宏結果會是怎樣的呢?如下面的示例代碼所示:
?
void?Test(unsigned?char?*str) { ????assert(str?!=?NULL); ????/*函數處理代碼*/ }
?
毋庸置疑,標準 assert 宏同樣會自動檢測到這個 NULL 指針錯誤。與此同時,標準 assert 宏除給出以上信息之外,還能夠顯示出已經失敗的測試條件。運行結果如圖 2 所示。
圖 2 調用標準 assert 宏的運行結果
從上面的示例中不難發現,對標準的 assert 宏來說,自定義的 ASSERT 宏將具有更大的靈活性,可以根據自己的需要打印輸出不同的信息,同時也可以對不同類型的錯誤或者警告信息使用不同的斷言,這也是在工程代碼中經常使用的做法。當然,如果沒有什么特殊需求,還是建議使用標準 assert 宏。
盡量在函數中使用斷言來檢查參數的合法性
在函數中使用斷言來檢查參數的合法性是斷言最主要的應用場景之一,它主要體現在如下 3 個方面:
1. 在代碼執行之前或者在函數的入口處,使用斷言來檢查參數的合法性,這稱為前置條件斷言。
2. 在代碼執行之后或者在函數的出口處,使用斷言來檢查參數是否被正確地執行,這稱為后置條件斷言。
3. 在代碼執行前后或者在函數的入出口處,使用斷言來檢查參數是否發生了變化,這稱為前后不變斷言。
例如,在上面的 Memcpy 函數中,除了可以通過“assert(dest !=NULL && src!=NULL);”語句在函數的入口處檢查 dest 與 src 參數是否傳入 NULL 指針之外,還可以通過“assert(tmp_dest>=tmp_src+len||tmp_src>=tmp_dest+len);”語句檢查兩個內存塊是否發生重疊。如下面的示例代碼所示:
?
void?*Memcpy(void?*dest,?const?void?*src,?size_t?len) { ????assert(dest!=NULL?&&?src!=NULL); ????char?*tmp_dest?=?(char?*)dest; ????char?*tmp_src?=?(char?*)src; ????/*檢查內存塊是否重疊*/ ????assert(tmp_dest>=tmp_src+len||tmp_src>=tmp_dest+len); ????while(len?--) ????????????*tmp_dest?++?=?*tmp_src?++; ????return?dest; }
?
除此之外,建議每一個 assert 宏只檢驗一個條件,這樣做的好處就是當斷言失敗時,便于程序排錯。?試想一下,如果在一個斷言中同時檢驗多個條件,當斷言失敗時,我們將很難直觀地判斷哪個條件失敗。因此,下面的斷言代碼應該更好一些,盡管這樣顯得有些多此一舉:
?
assert(dest!=NULL); assert(src!=NULL);
?
最后,建議 assert 宏后面的語句應該空一行,以形成邏輯和視覺上的一致感,讓代碼有一種視覺上的美感。同時為復雜的斷言添加必要的注釋,可澄清斷言含義并減少不必要的誤用。
避免在斷言表達式中使用改變環境的語句
默認情況下,因為 assert 宏只有在 Debug 版本中才能起作用,而在 Release 版本中將被忽略。因此,在程序設計中應該避免在斷言表達式中使用改變環境的語句。如下面的示例代碼所示:
?
int?Test(int?i) { ????assert(i++); ????return?i; } int?main(void) { ????int?i=1; ????printf("%d ",Test(i)); ????return?0; }
?
對于上面的示例代碼,由于“assert(i++)”語句的原因,將導致不同的編譯版本產生不同的結果。
如果是在 Debug 版本中,因為這里向變量 i 所賦的初始值為 1,所以在執行“assert(i++)”語句的時候將通過條件檢查,進而繼續執行“i++”,最后輸出的結果值為 2;如果是在 Release 版本中,函數中的斷言語句“assert(i++)”將被忽略掉,這樣表達式“i++”將得不到執行,從而導致輸出的結果值還是 1。
因此,應該避免在斷言表達式中使用類似“i++”這樣改變環境的語句,使用如下代碼進行替換:
?
int?Test(int?i) { ????assert(i); ????i++; ????return?i; }
?
現在,無論是 Debug 版本,還是 Release 版本的輸出結果都將為 2。
避免使用斷言去檢查程序錯誤
在對斷言的使用中,一定要遵循這樣一條規定:對來自系統內部的可靠的數據使用斷言,對于外部不可靠數據不能夠使用斷言,而應該使用錯誤處理代碼。換句話說,斷言是用來處理不應該發生的非法情況,而對于可能會發生且必須處理的情況應該使用錯誤處理代碼,而不是斷言。
在通常情況下,系統外部的數據(如不合法的用戶輸入)都是不可靠的,需要做嚴格的檢查(如某模塊在收到其他模塊或鏈路上的消息后,要對消息的合理性進行檢查,此過程為正常的錯誤檢查,不能用斷言來實現)才能放行到系統內部,這相當于一個守衛。
而對于系統內部的交互(如子程序調用),如果每次都去處理輸入的數據,也就相當于系統沒有可信的邊界,這樣會讓代碼變得臃腫復雜。
事實上,在系統內部,傳遞給子程序預期的恰當數據應該是調用者的責任,系統內的調用者應該確保傳遞給子程序的數據是恰當且可以正常工作的。這樣一來,就隔離了不可靠的外部環境和可靠的系統內部環境,降低復雜度。
但是在代碼編寫與測試階段,代碼很可能包含一些意想不到的缺陷,也許是處理外部數據的程序考慮得不夠周全,也許是調用系統內部子程序的代碼存在錯誤,造成子程序調用失敗。
這個時候,斷言就可以發揮作用,用來確診到底是哪部分出現了問題而導致子程序調用失敗。在清理所有缺陷之后,就建立了內外有別的信用體系。等到發行版的時候,這些斷言就沒有存在的必要了。因此,不能用斷言來檢查最終產品肯定會出現且必須處理的錯誤情況。
看下面一段示例代碼:
?
char?*?Strdup(const?char?*?source) { ????assert(source?!=?NULL); ????char?*?result=NULL; ????size_t??len?=?strlen(source)?+1; ????result?=?(char?*)malloc(len); ????assert(result?!=?NULL); ????strcpy(result,?source); ????return??result; }
?
對于上面的 Strdup 函數,相信大家都不陌生。其中,第一個斷言語句“assert(source!=NULL)”用來檢查該程序正常工作時絕對不應該發生的非法情況。
換句話說,在調用代碼正確的情況下傳遞給 source 參數的值必然不為 NULL,如果斷言失敗,說明調用代碼中有錯誤,必須修改。因此,它屬于斷言的正常使用情況。
而第二個斷言語句“assert(result!=NULL)”的用法則不同,它測試的是錯誤情況,是在其最終產品中肯定會出現且必須對其進行處理的錯誤情況。
即對 malloc 函數而言,當內存不足導致內存分配失敗時就會返回 NULL,因此這里不應該使用 assert 宏進行處理,而應該使用錯誤處理代碼。如下面問題將使用 if 判斷語句進行處理:
?
char?*?Strdup(const?char?*?source) { ????assert(source?!=?NULL); ????char?*?result=NULL; ????size_t??len?=?strlen(source)+1; ????result?=?(char?*)malloc(len); ????if?(result?!=?NULL) ????{ ????????????strcpy(result,?source); ????} ????return??result; }
?
總之記住一句話:斷言是用來檢查非法情況的,而不是測試和處理錯誤的。因此,不要混淆非法情況與錯誤情況之間的區別,后者是必然存在且一定要處理的。
盡量在防錯性程序設計中使用斷言來進行錯誤報警
對于防錯性程序設計,相信有經驗的程序員并不陌生,大多數教科書也都鼓勵程序員進行防錯性程序設計。在程序設計過程中,總會或多或少產生一些錯誤,這些錯誤有些屬于設計階段隱藏下來的,有些則是在編碼中產生的。
為了避免和糾正這些錯誤,可在編碼過程中有意識地在程序中加進一些錯誤檢查的措施,這就是防錯性程序設計的基本思想。其中,它又可以分為主動式防錯程序設計和被動式防錯程序設計兩種。
主動式防錯程序設計是指周期性地對整個程序或數據庫進行搜查或在空閑時搜查異常情況。它既可以在處理輸入信息期間使用,也可以在系統空閑時間或等待下一個輸入時使用。如下面所列出的檢查均適合主動式防錯程序設計。
??內存檢查:如果在內存的某些塊中存放了一些具有某種類型和范圍的數據,則可對它們做經常性檢查。
??標志檢查:如果系統的狀態是由某些標志指示的,可對這些標志做單獨檢查。
??反向檢查:對于一些從一種代碼翻譯成另一種代碼或從一種系統翻譯成另一種系統的數據或變量值,可以采用反向檢查,即利用反向翻譯來檢查原始值的翻譯是否正確。
??狀態檢查:對于某些具有多個操作狀態的復雜系統,若用某些特定的存儲值來表示這些狀態,則可通過單獨檢查存儲值來驗證系統的操作狀態。
??連接檢查:當使用鏈表結構時,可檢查鏈表的連接情況。
??時間檢查:如果已知道完成某項計算所需的最長時間,則可用定時器來監視這個時間。
??其他檢查:程序設計人員可經常仔細地對所使用的數據結構、操作序列和定時以及程序的功能加以考慮,從中得到要進行哪些檢查的啟發。
被動式防錯程序設計則是指必須等到某個輸入之后才能進行檢查,也就是達到檢查點時才能對程序的某些部分進行檢查。一般所要進行的檢查項目如下:
??來自外部設備的輸入數據,包括范圍、屬性是否正確。
??由其他程序所提供的數據是否正確。
??數據庫中的數據,包括數組、文件、結構、記錄是否正確。
??操作員的輸入,包括輸入的性質、順序是否正確。
??棧的深度是否正確。
??數組界限是否正確。
??表達式中是否出現零分母情況。
??正在運行的程序版本是否是所期望的(包括最后系統重新組合的日期)。
??通過其他程序或外部設備的輸出數據是否正確。
雖然防錯性程序設計被譽為有較好的編碼風格,一直被業界強烈推薦。但防錯性程序設計也是一把雙刃劍,從調試錯誤的角度來看,它把原來簡單的、顯而易見的缺陷轉變成晦澀的、難以檢測的缺陷,而且診斷起來非常困難。從某種意義上講,防錯性程序設計隱瞞了程序的潛在錯誤。
當然,對于軟件產品,希望它越健壯越好。但是調試脆弱的程序更容易幫助我們發現其問題,因為當缺陷出現的時候它就會立即表現出來。
因此,在進行防錯性程序設計時,如果“不可能發生”的事情的確發生了,則需要使用斷言進行報警,這樣,才便于程序員在內部調試階段及時對程序問題進行處理,從而保證發布的軟件產品具有良好的健壯性。
一個很常見的例子就是無處不在的 for 循環,如下面的示例代碼所示:
?
for(i=0;i?
在幾乎所有的 for 循環示例中,其行為都是迭代從 0 開始到“count-1”,因此,大家也都自然而然地編寫成了上面這種防錯性版本。但存在的問題是:如果 for 循環中的索引 i 值確實大于 count,那么極有可能意味著代碼中存在著潛在的缺陷問題。
由于上面的 for 循環示例采用了防錯性程序設計方式,因此,就算是在內部測試階段中出現了這種缺陷也很難發現其問題的所在,更加不可能出現系統報警提示。同時,因為這個潛在的程序缺陷,極有可能會在以后讓我們吃盡苦頭,而且非常難以診斷。
那么,不采用防錯性程序設計會是什么樣子呢?如下面的示例代碼所示:
?
for(i=0;i!=count;i++) { ????/*處理代碼*/ }?
很顯然,這種寫法肯定是不行的,當 for 循環中的索引 i 值確實大于 count 時,它還是不會停止循環。
對于上面的問題,斷言為我們提供了一個非常簡單的解決方法,如下面的示例代碼所示:
?
for(i=0;i?
不難發現,通過斷言真正實現了一舉兩得的目的:健壯的產品軟件和脆弱的開發調試程序,即在該程序的交付版本中,相應的程序防錯代碼可以保證當程序的缺陷問題出現的時候,用戶可以不受損失;而在該程序的內部調試版本中,潛在的錯誤仍然可以通過斷言預警報告。
因此,“無論你在哪里編寫防錯性代碼,都應該盡量確保使用斷言來保護這段代碼”。當然,也不必過分拘泥于此。例如,如果每次執行 for 循環時索引 i 的值只是簡單地增 1,那么要使索引i的值超過 count 從而引起問題幾乎是不可能的。在這種情況下,相應的斷言也就沒有任何存在的意義,應該從程序中刪除。
但是,如果索引 i 的值有其他處理情況,則必須使用斷言進行預警。由此可見,在防錯性程序設計中是否需要使用斷言進行錯誤報警要視具體情況而定,在編碼之前都要問自己:“在進行防錯性程序設計時,程序中隱瞞錯誤了嗎?”如果答案是肯定的,就必須在程序中加上相應的斷言,以此來對這些錯誤進行報警。否則,就不要多此一舉了。
用斷言保證沒有定義的特性或功能不被使用
在日常軟件設計中,如果原先規定的一部分功能尚未實現,則應該使用斷言來保證這些沒有被定義的特性或功能不被使用。例如,某通信模塊在設計時,準備提供“無連接”和“連接”這兩種業務。但當前的版本中僅實現了“無連接”業務,且在此版本的正式發行版中,用戶(上層模塊)不應產生“連接”業務的請求,那么在測試時可用斷言來檢查用戶是否使用了“連接”業務。如下面的示例代碼所示:
?
/*無連接業務*/ #define?CONNECTIONLESS?0 /*連接業務*/ #define?CONNECTION?????1 int?MessageProcess(MESSAGE?*msg) { ????assert(msg?!=?NULL); ????unsigned?char?service; ????service?=?GetMessageService(msg); ????/*使用斷言來檢查用戶是否使用了“連接”業務*/ ????assert(service?!=?CONNECTION); ????/*處理代碼*/ }?
謹慎使用斷言對程序開發環境中的假設進行檢查
在程序設計中,不能夠使用斷言來檢查程序運行時所需的軟硬件環境及配置要求,它們需要由專門的處理代碼進行檢查處理。而斷言僅可對程序開發環境(OS/Compiler/Hardware)中的假設及所配置的某版本軟硬件是否具有某種功能的假設進行檢查。
例如,某網卡是否在系統運行環境中配置了,應由程序中正式代碼來檢查;而此網卡是否具有某設想的功能,則可以由斷言來檢查。
除此之外,對編譯器提供的功能及特性的假設也可以使用斷言進行檢查,如下面的示例代碼所示:
?
/*int類型占用的內存空間是否為2*/ assert(sizeof(int)==?2); /*long類型占用的內存空間是否為4*/ assert(sizeof(long)==4); /*byte的寬度是否為8*/ assert(CHAR_BIT==8);?
之所以可以這樣使用斷言,那是因為軟件最終發行的 Release 版本與編譯器已沒有任何直接關系。
最后,必須保證軟件的 Debug 與 Release 兩個版本在實現功能上的一致性,同時可以使用調測開關來切換這兩個不同的版本,以便統一維護,切記不要同時存在 Debug 版本與 Release 版本兩個不同的源文件。
當然,因為頻繁調用 assert 會極大影響程序的性能,增加額外的開銷。因此,應該在正式軟件產品(即 Release 版本)中將斷言及其他調測代碼關掉(尤其是針對自定義的斷言宏)。在調試結束后,可以通過在包含#include
的語句之前插入?#define NDEBUG?來禁用assert調用,示例代碼如下: ?
#include?#define?NDEBUG #include? 審核編輯:湯梓紅??
評論
查看更多