防禦性程式發生在程式設計師不相信輸入的參數,所以對其做檢查,有可能在呼叫者(caller)和被呼叫者(callee)都做了相同的檢查來避免出錯,一般來說我們不需要測試輸入的資料來確保功能正常。系統中大部分的程式碼應該可以假設輸入的資料是正確的,只要在資料輸入進系統時被檢查過,應該就可以假設他是正確的。
多餘的檢查看似可以提升穩定性,但也隨之而來的是
- 資源浪費
- 效能浪費
- 增加維護難度
防禦性設計的出發點是「不信任」,我們當然要避免可能發生的邊界情況,以 Public API 來說有兩種策略應付不乖的參數
- 修正或略過資料的錯誤(defensive, compensate)
- 丟出錯誤(offensive, Fail-fast)
# defensive programming
# 如果參數不符合條件,就略過或修正它
def generate_account(currency)
if currency && currency.include?(["btc", "eth"]) # 檢查 currency 種類,如果傳進來的不是預期的就跳過
# create account
else
# ignore, nothing happend.
end
end
# non-defensive programming
def generate_account(currency)
# create account
end
# offensive programming (fail fast)
# early return,檢查完出錯直接噴錯讓你知道,而且有明確的訊息
def generate_account(currency)
raise "currency: #{currency} is invalid." if currency.blank? || !currency.include?(["btc", "eth"])
# create account
end
過度的防禦性設計更易造成問題的掩蓋,為什麼可以傳不在預期的 value 進來卻沒事,或是只記一條 Log?
這應該是要去 trace 的問題,因為這樣長久下來整個系統除了多了一大堆檢查的冗餘 code 以外,不確定性越來越多也就越來越難以維護。
另一種情況是濫用 rescue 捕捉 exception,如果程式碼裡面經常看到 begin rescue
,這就是一種 bad smell。
在調用外部接口時,一定要抓 exception,但有一部份是新手常犯的錯誤,會在自己的邏輯外在套上 begin rescue
,而且不明確捕捉類型,當你問他要抓哪種錯誤的時候通常是答不出,對於自己寫的程式碼邏輯錯誤不應該一次全家桶的捕獲,而是查出錯誤來源,從源頭解決問題。
# 不明確捕捉 exception, 任何錯誤情況都回 false
def add_funds!(account, amount)
begin
account.update(amount: amount)
rescue
false
end
end
在 call 這個 method 時,我拿到 false
回應時我可能會去看為什麼?
然後直到我看到這段 code 時我更無法確定
- update 失敗?
- 沒傳 account?
- 沒傳 amount?
- amount 數字過大或過小?
- amount 型態錯誤?
此例中,無論發生什麼錯誤都不會有事不會 crash,只是徹底把問題隱藏起來。
所以說 Overly Defensive Programming 基本上就是 Hide the Problem Programming
然而 Fail-fast 的作法應該是盡快的將錯誤 exception,拋給 caller
既然是 caller 亂傳參數或是參數有問題,當然請 caller 根治問題,而我執行方也就直接 fail 掉不跟你囉唆這樣。
def add_funds!(account, amount)
raise "amount is valid" if amount <= 0
raise "account is locked" if account.is_locked?
account.update(amount: amount)
end
雖然有些人也會認為 Fail-fast 的手段也是 defensive programming 的一種,但我覺得防禦性設計本身沒有問題,有問題的部分是在於「隱藏問題」,而很常見的原因都是 exception handling 亂用導致。
結論
避免 Overly Defensive Programming,請用 Fail-fast 來增加找到錯誤的效率。
- 系統邊界內,盡量相信輸入,不做檢查
- 需要檢查的部分,採用錯誤直接中斷的作法,避免隱藏問題
參考來源