Defensive Programming 防禦性程式設計

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特性來做),不過用的人就不多了。

結論

  1. 在系統邊界內,盡量相信輸入,不做檢查
  2. 如果需要檢查,採用有錯誤就中斷掉的方式(Fail-fast),避免 defensive programming 把問題藏起來

後話

另一個會讓整個系統充滿防禦性風格的情況,可能發生在團隊感情不好的環境。為什麼呢? 因為溝通不良,加上沒有適當的規範,所以寫code的時候只好處處防著別人,為了怕 crash 要麻煩別人(caller),只好額外費工加一大堆多餘的條件檢查和處理,把問題都埋起來好讓方法不會crash…

參考資料

註: 有些人認為 “fail fast” 也算是 defensive programming 的一招。我這裡採用對比的 offensive programming 說法。

參與討論

7 則留言

  1. Hi 大大您好,對文中有部份內容想請教

    我覺得大大是否把 defensive programming & exception handling 混在一起了?
    defensive programming 是一種把傷害降低的手段(潛水艇的艙門, 核電廠電壓超載),而修正錯誤也不是要把錯誤隱藏,

    文中利用 rescue 吃案是屬於 exceptional-neutral 的部份,以這個例子來說 defensive programming 不好,這樣頗有張飛打岳飛之感 ?

  2. 我認為 defensive programming 只是把問題隱藏起來的手段(一種workaround),而不是在把傷害降低耶。用正確的 exception/failure handling 才是在解決問題跟降低傷害,文中用 rescue 吃案則是一種 exception handling 的誤用(濫用)。

  3. Hi 大大,感謝回應,有幾點還想要請教
    1. 文中的 rescue 案例您回應是說他其實是一種exception handling 的誤用,但文中是以” 惡名昭彰的 defensive programming “下標題?

    2. 可否說明 defensive programming 為什麼稱作是把問題隱藏?他怎樣隱藏?隱藏這個動作是屬於 defensive programming 部份, 還是exception/failure handling 的部份?

  4. 1. 他是一種exception handling 的誤用,也是惡名昭彰的 defensive programming
    2. 我這整篇 blog 的論點就是在講 defensive programming 在把真正的問題隱藏,你要不要再讀一次 XD 或是讀一下我文章中引述的 Daniel Roop 文章論點。

    隱藏這個動作是屬於 defensive programming 部份,有各種寫法可以達成 defensive programming,寫不好的 exception handling 只是其中一種寫法,例如我文章中的第一個 defensive programming 例子就不是用 exception handling。

發佈留言

發表迴響