很多年前,自己曾有修改維護一個文字型的線上多人角色扮演遊戲系統(MUD)的經驗。那時一度做了一些會引發預期以外副作用的功能修改,使得系統變得極為不穩定,一旦有任何一個使用者在操作中踩到了臭蟲,程式就會因為存取到非法的記憶體位址,而整個異常終止。由於這樣的系統雖可同時服務多人,卻僅僅只是由單一個行程所構成,一旦此行程終止,所有的線上使用者也都會瞬間離開系統,很多時候,我彷彿可以聽到各地宿舍房間裡傳來的不絕罵聲,因為線上玩家的狀態,可能都在沒有被儲存的情況下失去。

當然我也明白這是藏在某處的臭蟲所造成的,但系統終究還是得繼續運作,沒法子停下來一陣子好好檢查維修,待修正臭蟲後才上線。所以,為了「緩解」臭蟲所造成的效應,就開始試著在系統會去非法存取記憶體的所在,為函式使用到的各個指標加上了空值的檢查(Null check),因為該函式在執行時,所使用到的各指標都不應是空值,如果任一指標為空值,則系統便會異常終止。有了空值的檢查,即使系統仍然有臭蟲、行為仍然不正確,但是最起碼系統不會在一瞬間「崩潰(crash)」掉。但空值指標的問題似乎會蔓延,這使得處處的函式都開始加了空值檢查的程式碼。

出現防禦性程式設計的原因
為什麼需要在函式中,加入對指標做空值檢查的程式碼?呼叫函式的先決條件,便是這些指標必須不為空值,否則便會引起函式出錯,這可以說是被呼叫的函式和呼叫者之間的「約定」,之所以會有空值指標,是因為呼叫者違反約定。當存在此種約定,但呼叫者違反此約定時,呼叫者自然不能產生預期的行為。被呼叫者需要逐一檢查傳入參數的時候,形同對呼叫者有了不信任的情況。

除了在這樣的例子中,我們需要對指標的空值進行檢查之外,也有可能對其他類型的參數做其他類型的檢查,像是檢查整數型別的參數是否違反函式所假設的邊界,或是針對字串型別的參數檢查是否長度過長、等等。原本呼叫者和被呼叫者之間應該要存在一定的信任關係,也就是雙方皆依循設計者所訂下的「約定」,但顯然呼叫者沒有遵守此一約定,而不能遵守約定,就是因為臭蟲的關係。

為了防止呼叫者基於各種原因而傳入了預期以外的參數,使得被呼叫的函式本身產生了不正確的行為,因而在執行真正的動作之前,必須逐一檢查傳入的參數,來預防不當的後果發生。這種方式現在被稱為Defensive Programming(防禦性程式設計),因為它假設呼叫者像敵人一樣,可能會傳入有問題的參數,導致自身行為不正常,因此,在開始工作之前,先進行必要的防守。

從確保程式不受意外的輸入參數所干擾、避免產生異常行為、提高程式的穩固性的觀點來看,這種防禦性的程式設計方式似乎不賴,因為,它變得更能容忍函式之外所發生的錯誤。即使函式之外的世界有多麼的混亂,函式內仍舊可保有一派寧靜。從「未慮對,先慮錯」的角度來看,撰寫每個函式的同時,如果都能在心中抱持著防禦性的思維,也有助於設計者重新仔細的審視思考每個傳入參數的有效值域、或是參數間的有效關係。

不過,這樣的程式設計方式,並非毫無代價。首先,你總是得額外花時間、力氣,來寫下這些檢查參數用的程式碼。若只在一個函式裡寫,還不打緊,若是層層呼叫的每個函式,你都很有可能會因為這種設計風格,而必須不斷在每一層裡檢查每一個參數,這使得程式碼的簡潔性打折扣。而當你添加上冗長額外檢查的程式碼後,程式的可讀性難免會受到影響,當讀者開始閱讀函式的程式碼時,首先映入眼簾的,便是一連串參數檢查的程式碼,而不是函式真正要做的工作。此外,層層額外的檢查程式碼,也會對程式的運行效率造成影響。

除了上述問題,這種防禦性的程式設計,可能會出現將問題隱藏起來的狀況。怎麼說呢?例如,對某函式來說,明明呼叫者就應該知道該參數必須傳入非空值的指標,因為那是基於約定的結果,卻傳入了空值,說明了這是一個程式設計上的錯誤。這樣的錯誤,在非防禦性的程式設計方式下,會因為存取空指標而使系統異常終止,用最激烈的形式迫使程式設計者必須找出錯誤之所在。但在防禦性的程式設計方式下,卻讓問題的症狀大幅的緩解,即使有錯誤存在,程式仍然還能持續運作下去。

及早面對錯誤

有一種設計的方式,是希望盡早讓錯誤顯現,因為臭蟲就是臭蟲,終究是需要被修正的。而這種設計方式,正好和防禦性的程式設計哲學呈現一種對比。防禦能力太好的程式碼,很有可能讓程式設計者難以意識錯誤的存在,或是在明知有錯誤的情況下,不試著去發掘錯誤。而盡早讓錯誤顯現的設計方式,便是希望讓錯誤對系統運作造成的影響,大到會讓程式設計者關注,並且進一步趁早解決。

防禦性的程式設計方式還存在另一個根本的問題,那就是,當傳入的參數已經不符合預期時,是否意謂著,系統其實已經進入了一個錯誤、或不一致的狀態,但若因為防禦性程式設計的結果,使得錯誤被暫時掩蓋,那麼,是否會讓這一個才剛進入初始錯誤的狀態,繼續發展,將錯就錯,產生出更多的錯誤呢?這顯然是一個值得深思的問題。倘若允許在系統中進入錯誤的狀態,那麼這個錯誤在系統中持續累積、演化,最終有可能造成更大的傷害,例如操作了錯誤的資料、記錄了不正確的交易結果、等等。

有一種處理錯誤的程式設計方式,稱為 「快速失敗(Fail Fast)」,這是指系統一旦遭遇到錯誤便立即終止自身的一種方式,而急著讓系統失敗而終止,為的就是不希望錯誤再繼續擴散、擴大下去。

在Java裡頭,像NullPointerException之類的異常,若沒有特別的捕捉動作,它會從被擲出,一路被丟到call stack的最外層,導致程式終止。這就是一種快速失敗的策略。以Java VM的能力,它大可採取其他的方式來處理這樣的錯誤,但正因為基於「快速失敗」的哲學,所以Java選擇最激烈的方式,讓系統直接終止。因為系統中不應該出現具有空值的指標,它卻出現了,這顯示了程式或許開始進入了一個錯誤的狀態,為了防止更嚴重的錯誤因此而發生,因而決定直接終止系統。

「快速失敗」當然可以視為是一種「顯現錯誤」的手段,但這不代表是唯一手段。「顯現錯誤」的重點在於能否有效、夠嚴肅地提醒程式設計者留意到錯誤。此外,直接失敗卻不留下蛛絲馬跡,有時對解決錯誤幫助不夠大。在使用這種方式時來顯現錯誤時,最好盡可能提供訊息,以利錯誤排除。

有不少設計者會採用所謂的assertion,在函式中檢查重要的參數或是狀態值是否成立,若無,則會顯示錯誤訊息,甚至進一步終止程式的執行。就像你在使用Microsoft Visual C++時,常常會看到,含有Please Retry to debug the application訊息的對話窗一樣。但可以選擇在除錯模式中,才讓assertion起作用,而在發行模式予以關閉,如此一來,便不會在發行的正式版本中一遇錯誤,便立即終止。

將兩者搭配使用
區分除錯模式及發行模式下,採取不同策略,是個好的概念。在除錯模式,或說在開發階段,其實應該盡可能的愈早察覺錯誤、愈早處理錯誤愈好。而在發行模式、也就是營運階段時,或許我們不希望因為一個錯誤就致使系統整個終止,就可以提高防禦的程度。最後,若做為一個公開的API介面,由於外界的可信任程度遠較自身內部為低,防禦程度拉高是比較理想的。許多API函式在設計上,甚至會用特定回傳值來告訴呼叫者傳入的參數並不合法。

拿捏程式本身的防禦力,或抉擇盡早顯現錯誤,都需要依據不同的條件。不過,若明白這兩種策略的作用及效應,相信你能更容易判斷。

 

作者簡介


Advertisement

更多 iThome相關內容