TL;DR 避免 Defensive Programming,愛用 Fail Fast 策略。
什麼是防禦性程式設計,在 Erlang’s Programming Rules and Conventions: Do not program “defensively” 裡這樣說明:
防禦性程式發生在程式設計師不相信系統的輸入(input)資料時。一般來說我們不需要測試輸入的資料來確保功能正常。系統中大部分程式碼應該可以假設輸入的資料是正確的,只有一小部分的程式需要真的檢查資料,這通常發生在資料第一次輸入的時候。只要資料在輸入進系統時被檢查過,那麼就應該可以假設它是正確的。
也就是說,當你不相信輸入時,你就得去檢查它。有經驗的程式設計師應該都非常熟悉這種作法,他的缺點在於這常是多餘的資源浪費,不但浪費效能,也增加程式碼維護的成本。好比說一個方法已經檢查過參數了,該方法又繼續把參數傳遞給別的方法時,可能又要再檢查一次。如果所有方法都要針對參數重複做嚴格的檢查,豈不是非常囉嗦。所以 Erlang’s Programming Rules 建議你在系統裡不要檢查,crash 與否的責任在於呼叫方,在於資料一開始輸入進系統的時候,而不是這個方法。
但是,總是有需要檢查的時候,特別是資料第一次輸入進系統的邊界情況,以應用程式來說,就是指使用者的輸入; 以 Library 來說,就是指 Public API。這時候有兩種策略來對付不乖的參數: 1. 修正或略過資料的錯誤(defensive, compensate) 2.丟出錯誤(offensive, fail fast)
讓我們來看看例子吧。以 Check Null 型的問題來說,有三種寫法:
# defensive programming
# 如果參數不符合條件,就略過或修正它
def resize_image(image)
if image && image.is_a?(File) # 檢查 image 不是 nil,而且是 File
# execute code
else
# ingore, nothing happened.
end
end
# non-defensive programming
# 不檢查,錯了就讓他 crash 吧
def resize_image(image)
# execute code
end
# offensive programming (fail fast)
# 檢查,有錯就馬上失敗讓你知道
def resize_image(image)
raise "The image is invalid" unless ( image && image.is_a?(File) )
# execute code
end
Daniel Roop 在 Why Defensive Programming is Rubbish 認為 defensive 策略是一種 Hide the Problem Programming,我覺得講的很好。為什麼呼叫方會傳不對的值進來呢? 這是應該要去 trace 的問題,而不是把問題隱藏起來。長久以往下來,整個系統的不確定性就會越來越多,也就越來越難維護。
比較建議的作法是,如果是系統內可以相信呼叫方,那就不要檢查了。如果是邊界情況需要檢查,就做 offensive programming,也就是 “Fail Fast”,把條件限制當做一種契約,如果呼叫方(caller)不照規矩來,整個方法就會 fail 掉,這樣的好處是可以提早發現問題,並且在問題發生的第一時間就可以修好,讓整個系統朝向可以互相信任的方向成長。
再舉一個更惡名昭彰的 defensive programming 的例子:
# defensive programming
def run
# execute code
rescue # 救回所有例外
end
此例中,無論發生什麼錯誤,這個方法都會回傳 nil,非常安全不會 crash。但是即使 execute code 的地方打錯字也不會怎樣,徹底把問題隱藏起來了。
可是….
可是我們設計API的時候,特別是像 Ruby 這種動態語言,常常允許參數很有彈性耶? 例如
# 參數 i 除了可以是數字之外,也可以是字串,例如 "123"
def process_integer(i)
var = i.to_i # 轉數字
# process var
end
# 參數 s 除了可以是字串之外,也可以是 Symbol 或數字等
def process_string(s)
var = s.to_s # 轉字串
# process var
end
# 參數 a 除了可以是陣列之外,也可以只傳一個元素或nil
def process_array(a)
arr = Array(a) # 讓 arr 一定是陣列
arr.each do |x|
# process x
end
end
好吧,這種算是”有意 (intentional)”的 API 設計,我們擴大了參數的彈性,而且呼叫方(caller)明確知道可以這樣呼叫(最好搭配API文件)。在 Confident Ruby 這本書裡,提到了很多 Ruby 適用的技巧。
Contract by Design
另一種解決檢查參數前置性條件的根本方法,叫做”契約式設計 Contract by Design”,也就是在語言層面去宣告前置條件。不過,大部分的程式語言都沒有內建,Ruby 是有一些第三方套件(利用Ruby的meta-programming特性來做),不過用的人就不多了。
結論
- 在系統邊界內,盡量相信輸入,不做檢查
- 如果需要檢查,採用有錯誤就中斷掉的方式(Fail-fast),避免 defensive programming 把問題藏起來
後話
另一個會讓整個系統充滿防禦性風格的情況,可能發生在團隊感情不好的環境。為什麼呢? 因為溝通不良,加上沒有適當的規範,所以寫code的時候只好處處防著別人,為了怕 crash 要麻煩別人(caller),只好額外費工加一大堆多餘的條件檢查和處理,把問題都埋起來好讓方法不會crash…
參考資料
註: 有些人認為 “fail fast” 也算是 defensive programming 的一招。我這裡採用對比的 offensive programming 說法。