iThome 網站首載:避免隱藏錯誤的防禦性設計
在我的前一篇專欄〈函數式風格錯誤處理〉,談到了避免檢查
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的類似功能,而且通常也可以設定為停用,在確認系統不用再擔心不正確輸入時,可關閉斷言功能,以避免不必要的檢查影響了效率。- 防禦性思考、溝通與建立規範
在一些場合中,像是考慮到安全的場合,因為惡意使用者會特意製造不正確的輸入,讓程式依舊運行,但產生開發者非預期的結果,此時防禦性程式設計有絕對的必要性。類似地,有時客戶端並非故意地傳遞了不正確的引數,程式仍可能持續運行但產生不正確結果,採取防禦性設計主動拋出錯誤反倒是必要的;會出現不適當的防禦性設計,追根究底都是由於沒有適當規範,加上開發者間溝通不良所造成,因為防禦性設計的出發點就是不信任客戶端。
防禦性地思考本身並非不好,防患未然是件好事,在設計函式時多思考一些前置條件,以及誰該負責前置條件的滿足等問題,有助於開發者認清手中函式該負責的職責;如果檢查的職責無法釐清時,應多加溝通並建立規範,而不是徑自採取處理錯誤輸入的方案。某些程度來說,防禦性設計與例外處理是類似的,一個是事先預防,一個是事後補救,如果沒有良好的溝通與一致的規範,開發者採取各自的預防或補救方案,非但無法解決問題,兩者最後都會是隱藏了錯誤而使得系統難以除錯。