在我的前一篇專欄〈函數式風格錯誤處理〉,談到了避免檢查null而應實現速錯(Fail fast)的概念,對null的檢查範例經常出現在反對防禦性程式設計(Defensive Programming)的討論中,似乎使用了防禦性設計就是罪惡一般。

然而程式設計不是理想國,防禦性仍有必要,只是時機不應模糊,做法也不應隱藏發生的錯誤。

防禦性程式設計的問題
防禦性程式設計一詞,被廣泛地套用在各種防止程式出錯的措施,包括了前置條件檢查、錯誤處理與後置狀態確認等。

在這邊,我想狹義地針對前置條件檢查做探討,也就是在呼叫函式前檢查引數,或者在函式一開頭檢查參數值,這也是多數開發者爭議之處,是否該檢查函式的輸入,在輸入錯誤時盡可能做妥善處理,以避免程式功能上的失常或系統崩潰(Crash)。而反對防禦性設計的理由在於,很多時候開發者難以選擇處理錯誤輸入的方案,產生種種問題。

問題的原因之一,在於不適當地修正前置條件。

以函式設計為例,開發者在撰寫函式之時,無論有意無意,其實都假設了函式流程執行前應滿足的某些前置條件,只有在滿足這些條件下,函式定義的流程以能順利進行;在發現客戶端呼叫函式的引數不符合前置條件而造成錯誤時,開發者採取的防禦措施之一是:「修正它」,像是在檢查到參數值為null時,就不假思索地提供預設值,然而預設值可能是不適當或是隱含地,呼叫函式的客戶端可能對這個隱含行為一無所知。

問題原因之二,是容易產生令人困惑的結果。

有些開發者在發現參數值不符合前置條件而造成錯誤後,會以條件判斷限定在參數值符合前置條件下,方可執行原先函式定義的流程,然而忽略了不符合的情況,這令客戶端在呼叫函式的引數不符前置條件時,就靜悄悄地結束了,困惑地誤以為函式執行沒有效果。

防禦性設計的問題根源,在於不相信客戶端的輸入,或者一開始沒有明確定義函式執行的前置條件,因而逕自安插前置條件的檢查程式碼,由於函式會繼續呼叫其他函式,因此錯誤輸入會不斷傳播,因而檢查前置條件的程式碼會蔓延,使得可讀性降低,重複地對相同的前置條件做檢查,也會造成程式效率低落。

前置條件不足時如何處理?
大多數反對防禦性設計的討論,最終都指向它試圖隱藏前置條件不足時該呈現的錯誤,因此解決方式是思考如何明確提供前置條件不足時的方案。

如果函式的參數確實可有預設值,可重載(Overload)另一函式以預設值來呼叫原函式,客戶端呼叫函式時,就可明確選擇是否使用有預設值的重載版本,必要時,甚至參數也可以是〈函數式風格錯誤處理〉中談到的Option型態,明確地提示客戶端在Option沒有實際包含值時,函式中會有預設值來替代。預設值必須明確載明在文件中,在不支援重載機制的語言中,像是動態語言,檢查前置條件不足下,提供預設值無可避免,更有賴於明確的文件聲明。

即便定義了重載函式,或者明確地在文件中載明預設值,客戶端仍可能以錯誤引數呼叫了不正確的函式,此時可思考參數值不正確時,是否引發語言內建的例外拋出。舉例來說,如果客戶端呼叫函式時,傳入整數索引,這索引在函式流程中,會因存取陣列引發ArrayIndexOutOfBoundsException,因此可不檢查。

如果客戶端傳入的引數錯誤不會引發任何例外,但會使得程式流程進入不正常狀態或結果,那可以進行參數值檢查。而在參數值不正確時拋出IllegalArgumentException,也就是防禦性設計下仍可實現速錯概念,防禦性設計不隱藏錯誤的發生。

防禦性設計有時能從可讀性觀點思考。舉例來說,如果呼叫函式時傳入null,而後續函式流程會因此而拋出NullPointerException,許多反對防禦性設計的討論會提到,在這種情況下不要檢查null,任其拋出例外;然而,實際上函式中拋出NullPointerException,到底是因為參數為null而引發,或者是函式中其它變數為null所引發呢?為了使語義明確,在檢查到參數為null時,撰寫程式拋出IllegalArgumentException,反倒是個明確的做法。

誰該檢查前置條件?

防禦性設計本身並非不良,而是防禦性設計本身不能是隱含的,也就是不可私自地修正前置條件,也不可隱藏錯誤。那麼誰該進行前置條件檢查?如果開發者撰寫的函式,在參數值不符合前置條件下,仍可以執行,並進入錯誤流程或產生錯誤結果,那麼就可以進行檢查並拋出適當例外,如此呼叫函式的客戶端,就會知道有錯誤發生。

那麼客戶端是否該於呼叫函式前進行檢查,以避免函式拋出例外?若是為了語義明確,在檢查前置條件不符下,可主動拋出更明確的例外,這建議用於將參數值直接傳給後續函式時;如果是函式流程中計算出的中間值用以呼叫函式,則建議以try-catch處理,在catch中重新包裝為更明確語義的例外。

主動檢查前置條件是屬於事先預防錯誤,如果必要,可以將檢查流程封裝為獨立函式,增加程式的可讀性。如果不是為了主動拋出更明確的例外,則不建議檢查前置條件,任憑例外向上傳播,在適當的系統邊界再加以處理,避免重複性的前置條件檢查,或不必要的try-catch處理。

實際在防禦性設計時討論到速錯,與討論例外處理時有些例外不應處理,而應任其向上傳播,有著相同的概念,那就是這種情況下都是因為程式有臭蟲(Bug)存在,在解決掉臭蟲前,任何預防或事後補救都是不建議的。

如果臭蟲(Bug)的根源來自客戶端不正確的輸入,那麼在客戶端可接觸到的邊界統一檢查,或者是呈現出客戶端可理解的錯誤,才是正確的做法。

JDK1.4新增了assert關鍵字,其運用的場合之一就是前置條件檢查,當使用assert進行斷言而條件不成立時,會拋出AssertError,Error表示開發者不應該試圖補救,而應該檢查哪邊出現了臭蟲。

有些語言也有assert的類似功能,而且通常也可以設定為停用,確認系統不用再擔心不正確輸入時,可關閉該功能,避免不必要的檢查影響效率。

防禦性思考、溝通與建立規範
在一些場合中,像是考慮到安全的場合,因為惡意使用者會特意製造不正確的輸入,讓程式依舊運行,但產生開發者非預期的結果,此時防禦性程式設計有絕對的必要性。

類似地,有時客戶端並非故意地傳遞了不正確的引數,程式仍可能持續運行但產生不正確結果,採取防禦性設計主動拋出錯誤,反倒是必要的。會出現不適當的防禦性設計,追根究底都是由於沒有適當規範,加上開發者間溝通不良所造成,因為防禦性設計的出發點,就是不信任客戶端。

防禦性地思考本身並非不好,防患未然是件好事,在設計函式時多思考一些前置條件,以及誰該負責前置條件的滿足等問題,有助於開發者認清手中函式該負責的職責;如果檢查的職責無法釐清時,應多加溝通並建立規範,而不是逕自採取處理錯誤輸入的方案。

某些程度來說,防禦性設計與例外處理是類似的,一個是事先預防,一個是事後補救,如果沒有良好的溝通與一致的規範,開發者採取各自的預防或補救方案,非但無法解決問題,兩者最後都會是隱藏了錯誤,而使得系統難以除錯。

 

專欄作者

熱門新聞

Advertisement