void CMyDialog::OnStart() { CWnd* pBtn = GetDlgItem( IDC_ADD_BTN); if( pBtn ){ pBtn->EnableWindow(FALSE); } … } void CMyDialog::OnAdd() { ...... } |
由于GetDlgItem()返回的是一個CWnd的指針,按照文檔的描述,如果指定的控件不存在,該函數會返回一個NULL指針,為了確保不會調用NULL指針的函數,我們先檢查了返回的指針是否為NULL。
一切看上去很美,這段程序永遠不會使你的程序崩潰。然而,不會崩潰的程序,不一定是沒有問題的程序。假設在MyDialog中Add按鈕被定義成IDC_BTN_ADD,并且不湊巧,在這個項目的另一個Dialog里也有一個Add按鈕,而且它的ID被定義為IDC_ADD_BTN,所以你的程序在編譯和連結時都不會有錯誤。當用戶使用時,也不會注意到有什么不妥,只是Dialog上的某個按鈕沒有變成灰色,沒有人會注意到它的。
然而,它并不符合你的設計,也許在程序的其他地方,你假設在任務開始后,OnAdd()函數不會被調用到。這些問題一直隱藏著,直到有一天,用戶報告說按Add按鈕,加入某些數據后,按Ok,結果程序崩潰了。你在自己的機器上試了一下,由于之前你沒有按過Start按鈕,所以你一直復制不出這個問題。經過幾個來回的email或者電話交流,你找到了復制錯誤的方法,并且奇怪為什么Add按鈕沒有被禁止呢,奇怪??忙活半天后,你發現原來是一個ID寫錯了。
一個很小的錯誤,修正它也許只要兩分鐘,找到它卻花費了你幾十分鐘甚至更長。然而,這一切是可以避免的。這里我們要避免的不是說寫錯ID,粗心大意的錯誤,人人都會犯,而且會不停的犯。但是如果錯誤能夠被及時發現,就會剩下許多時間。
造成以上問題的原因是我們在代碼中加入了一些防御性的代碼,這些代碼保護了程序員犯的錯誤。如果GetDlgItem()返回NULL,一定是由于程序員的錯誤。由于錯誤被掩蓋起來,所以當問題被暴露出來時就已經面目全非了。
一個比較好的做法是除去防御性代碼,讓問題及早暴露:
void CMyDialog::OnStart() { GetDlgItem( IDC_ADD_BTN)->EnableWindow(FALSE); … } |
這樣的結果是:一按Start按鈕,程序立刻就崩潰了。的確,崩潰是很嚴重的錯誤,在Bug List里它的優先級是比較高的(僅次于造成整個OS崩潰)。但是,既然有錯誤,遲早要崩潰的,還不如早一點崩潰。至少早一點崩潰可以使你很快就發現問題,找到問題。有經驗的程序員都清楚,一觸即發的問題并不可怕,可怕的是那些偶然發生,不容易復制的問題。
需要在函數里檢查參數的合法性嗎?
在實現一個函數時,出于“健壯性”的考慮,我們經常會在函數的入口處加入許多參數檢查代碼。比如以下的一個例子:
class CItemManager { protected: int m_nCount; … public: int GetItemCount(); CItem* GetItem( int nIndex ); }; CItem* CItemManager::GetItem( int nIndex ) { if( nIndex < 0 || nIndex >= m_nCount ) return NULL; … return pItem; } class CItemManager { protected: int m_nCount; … public: int GetItemCount(); CItem* GetItem( int nIndex ); }; CItem* CItemManager::GetItem( int nIndex ) { if( nIndex < 0 || nIndex >= m_nCount ) return NULL; … return pItem; } |
在實現GetItem()時,你首先檢查了參數的合法性,如果不合法就返回一個NULL指針。這樣你的函數在任何的輸入情況下都不會導致程序崩潰,一切看上去完美無缺,無可挑剔。但是,這樣做真的能使我們的程序更健壯嗎?
我們從調用者的角度來分析一下。為什么調用者會傳入一個不合法的參數呢?一種情況是調用者的程序有bug;另一種情況是調用者不確定index是不是合法,但是他不想多寫兩行代碼來判斷index的合法性,他希望GetItem()能夠一次都給辦了:即能檢查index的合法性,又能返回CItem的指針。
考慮第一種情況,也許調用者寫了如下的代碼:
int index; … CItem* pItem = im.GetItem( index ); if( pItem ){//should be executed … } |
這是一段危險的函數,index變量在使用之前沒有初始化,但是這段程序不會,永遠也不會使程序崩潰,這要感謝實現CItemManager和使用CItemManager的程序員,他們都習慣于寫“健壯的”代碼。但是,這段程序卻不會按照我們想象的那樣運行。本該執行的代碼并不是每次都被執行到,因為誰也不確定index變量里存的是什么東西。這段代碼是健壯的,他不會使程序崩潰,但是程序的運行過程卻是不確定的。一旦出現問題,這個問題即不容易復制,也不容易確定錯誤原因,它的表現形式往往出乎你的意料。
考慮第二種情況,調用者通過其他函數得到了一個index,然后他想取得這個index下的CItem指針,但是他不想多寫兩行代碼去判斷這個index的合法性,他想:“如果這個index是合法的就請返回給我這個index下的CItem指針,如果不合法就返回一個NULL好了“。這樣做只要一行代碼就夠了,他省下了一行代碼,也許不止一行,因為在很多地方都需要呼叫GetItem()這個函數,所以,他省下了許多行代碼。他會這樣使用GetItem():
void SomeFunc( int nIndex ) { CItem* pItem = im.GetItem( nIndex ); if( pItem ){ //do something. } } void SomeFunc( int nIndex ) { CItem* pItem = im.GetItem( nIndex ); if( pItem ){ //do something. } } |
如果CItemManager的實現者和使用者是同一個程序員,我們經常會寫出像上面的代碼,畢竟可以省下一行代碼,而且上面的代碼看去還不錯,簡潔明了。但是仔細推敲一下, 我們發現,按照以上的要求實現的GetItem()是一個不良的設計。
首先,它違反了單一職責原則(SRP)。按照以上的要求實現的GetItem()其實完成了兩項功能:第一項功能用來判斷index是否合法;第二項功能用來取得指定index下的CItem指針。GetItem()只應該負責取得指定index下的CItem指針,檢查index的合法性應該交給其他的函數。這里,調用者可以通過GetItemCount()來判斷index的合法性。
其次,函數的返回值具有二義性。如果函數返回NULL,那么這個NULL可以代表index不合法,也可以解釋為,指定的index下的值就是NULL,因為從編譯器的角度NULL也是一個CItem指針。這里GetItem()混合了兩種功能的返回值,而且第一項功能的返回值使用了第二項功能的返回值中的一個特例。這樣的設計破壞了程序的完整性。假如CItemManager不是管理CItem指針,而是管理CItem對象,你就不會那樣設計GetItem()函數了。
如果真的想在GetItem()里實現index的合法性檢查,那么GetItem()的定義應該改成這樣:
bool GetItem( int index, CItem* & pItem ); |
如果index不合法,函數返回false;如果index合法,函數返回true,并且pItem返回該index下的CItem指針。經過這么一改,返回值的二義性被消除了,但是你是否覺得,GetItem()的語義已經有點變味了,這更像是在實現FindItem了。然而,按照index去Find一個Item似乎又不合理,我們進入了一個兩難的境地。
退一步海闊天空。在GetItem()里檢查index的合法性,并不會讓我們的程序更健壯。一個比較好的做法是,由調用者負責index的合法性檢查。
所以 SomeFunc應該改成這樣:
void SomeFunc( int nIndex ) { if( nIndex >= im.GetItemCount( ) ) return; CItem* pItem = im.GetItem( nIndex ); pItem->…; } void SomeFunc( int nIndex ) { if( nIndex >= im.GetItemCount( ) ) return; CItem* pItem = im.GetItem( nIndex ); pItem->…; } |
而GetItem()的實現應該改成這樣:
CItem* CItemManager::GetItem( int nIndex ) { ASSERT( nIndex >= 0 && nIndex < m_nCount ); … return pItem; } |
以上的實現我們使用了ASSERT()來檢查參數的合法性,當參數不合法時,程序會被終止。ASSERT()斷言只有在調試版才有效,所以程序不能依賴它來做錯誤處理。ASSERT()在這里的作用是,一方面在調試程序的時候,能夠幫助我們盡早的發現錯誤。錯誤越早被發現,越容易被解決;另一方面,按照Robert Martin在Agile Software Development一書中所述,軟件具有三項職責,最后一項便是:和閱讀它的人溝通1。這些斷言代碼可以向閱讀代碼的人傳遞這樣的信息,當程序運行到這里的時候,必須滿足這些條件。
我是否在鼓勵不要寫防御性代碼?
讀到這里,你也許覺得我在鼓勵不要在函數里檢查參數的合法性,不要寫防御性代碼。是這樣嗎?答案顯然是否定的。我要強調的是不要盲目的加入防御性代碼,這樣做并不能增強系統的健壯性。當要加入防御性代碼的時候,你需要
分析一下,這個條件是應該假設的,還是應該防御的。對于應該假設的條件,可以使用ASSERT()斷言來檢查,對于應該防御的條件,必須用專門的代碼來處理。
那么如何判斷一個條件是應該假設的,還是應該防御的呢?這讓我想起了榮耀先生(optimizer)的兩篇沉思錄,《你防御了嗎?》2和《別人的棺材》3。
《你防御了嗎?》說的是作者寫的一個用于顯示SQL語句的程序,作者假設輸入的SQL語句不會超過4000個字符,結果有一天的確有人輸入了超過4000個字符的SQL語句,然后程序崩潰了。這引起了作者對防御性編程的思考。
《別人的棺材》說的正好是一個相反的例子。有A,B,C等模塊,A負責分析,執行指令,B負責生成指令。這樣的設計十分合理, B不用考慮指令是否合法,由A負責指令的檢查、分析,然后再執行。但是,也許負責B模塊的人覺得A模塊不可靠或是效率太低,所以他也加入了對指令的分析模塊。作者認為這樣的設計會產生冗余,當需要修改指令分析流程時,許多模塊需要修改,系統變得難于維護。
在網上的評論中,有的人認為這兩篇文章相互矛盾。我覺得相反,這兩篇文章恰巧揭示出需要防御和假設的兩種情況。對于前一個例子,應該防御的條件,作者做了假設;對于后一個例子,B模塊應該假設,卻多做了一份防御。
當我們做假設的時候,切忌不能憑空假設,我們必須清楚誰對這個假設負責。所謂對假設負責,其實就是在劃分系統的職責。當我們假設一個條件時,就是把保證這個條件成立的職責分配到外部系統。在做這種劃分的時候,我們應該確信外部系統有這個能力,并且這種劃分是合理的。在《你防御了嗎?》中,作者把保證SQL語句不會超過4000個字符的職責交給最終用戶,這顯然不可靠。
當我們要防御一個條件時,切忌不要過度防御,過度防御雖然不會造成程序崩潰,但是會影響系統的結構!秳e人的棺材》中,B模塊就屬于過度防御。造成過度防御的原因,我以為主要有兩點:一點是由于程序員的“悲觀”態度和簡單分析造成的。在悲觀的態度下,程序員認為一切條件都不可靠,然后,不加分析的一概做防御處理;另一點是由于社會原因造成的,我猜想《別人的棺材》中,作者就碰到了這類由于團隊內部溝通上造成的。我也碰到過類似的情況,以GetItem()為例,本來我們在GetItem里是不檢查index的合法性的。但是突然有一天,另一名使用這個函數的工程師告訴你,程序在GetItem里崩潰了,你調試后,告訴他,他必須負責檢查index的合法性。然而,也許他是你的老板,或者你是個“菜鳥”,你爭執不過他,最后只好你修改代碼,加入index的檢查代碼,這樣程序再崩潰的時候,至少不會在你寫的代碼里,“萬事大吉”了。
結束
當我們追求一個目標時,由于時間很長,過程艱難,到后來真正的目標往往會變得模糊不清。什么才是健壯的程序?能夠正確運行的程序才是健壯的程序。在追求寫出健壯的程序時,我們往往只考慮程序會不會崩潰,更有甚者,只考慮程序會不會崩潰在自己寫的代碼里,這離健壯程序的目標已經偏離了許多。這時有必要停下來,想一想,反思一下我們的目標和經歷的過程。這篇文章就是我的一次反思。
延伸閱讀
文章來源于領測軟件測試網 http://www.kjueaiud.com/