l

2016年7月20日 星期三

為什麼防禦性程式設計不好?

July 20 10:10~11:58

擷取

 

防禦性程式設計(defensive programming)建議開發人員在程式碼中包含越多越好的檢查程式以便保護系統模組不會當掉。就算呼叫者(caller)與被呼叫者(callee)都做了相同的檢查也沒關係,反正重複的檢查就算沒有幫助,也不會造成傷害。

▼看個簡單的例子,Storage類別的store()函數負責儲存一個備份。它首先呼叫makeCopy()得到一個Copy物件,然後把這個物件存到Stack裡面。在呼叫Stack的push()函數之前,先確定Copy物件不是null,這裡做了一個防禦性檢查,以免將null物件存到Stack裡面。

螢幕截圖 2016-07-20 11.57.29

 

▼接著看Stack類別的push()函數,假設push()也對傳入的參數做了檢查,在這個例子中,等於store()函數(呼叫者)與push()函數(被呼叫者)都採取防禦性程式設計。多一次檢查總比沒有檢查好吧,從store()函數的角度來看,它怎麼會知道push()函數有沒有對傳入的參數做檢查?所以深為一個負責任的呼叫者,自行檢查傳入的參數也是很正常的作法。

螢幕截圖 2016-07-20 10.49.46

***

依合約設計(Design by Contract;DBC)的發明人Bertrand Meyer在1992年發表於IEEE Computer上面的一篇文章〈Applying “Design by Contract”〉討論過防禦性程式設計的問題。重複的檢查並非沒有傷害,首先它增加了程式的複雜度。程式碼越多,程式的複雜度就越高。試想假設有10個不同的地方都用到push()函數,每一個呼叫者都要重複做一次檢查,這是一種很明顯的「重複程式碼壞味道」。

再者,store()函數的作法看起來好像很負責的檢查Copy物件不是null才呼叫push()函數,但是卻沒考慮到當Copy是null的時候的錯誤處理。也就是說防禦性程式設計原本希望提升系統的強健度,但很可能不小心反而導致忽略應該處理的錯誤狀況。

請鄉民們花個一分鐘思考一下防禦性程式設計的問題是什麼?

***

防禦性程式設計的出發點是:「不信任」。因為你不能信任呼叫者所傳入的參數是否合法,所以你要檢查。因為你不能信任被呼叫者的強健度(應對異常狀況的能力),所以回傳給呼叫者的結果你也要檢查。就好像一個公司的老闆不信任員工,只好不斷的稽查員工的進度、報銷帳目、上下班時間等等。這樣的公司,效率一定不好。

依合約設計的想法很簡單,它把程式設計視為兩個實體(entity)之間的互動,也就是「呼叫者」(caller)與被「呼叫者」(callee)。聽起來有點像廢話,但接下來才是重點,開發人員要幫呼叫者與被呼叫者之間訂定「合約」(contract),以便建立「信任關係」

合約個概念很簡單,就跟房東與房客需要簽訂租約一樣。或是接案子的甲方、乙方之間需要簽訂契約。兩者的責任如下:

  • 呼叫者:又稱為客戶(client),負責保證合約的的前置條件(pre-condition)需要成立。
  • 被呼叫者:又稱為提供者、供應商(supplier),負責保證合約的的後置條件(post-condition)需要成立。

舉個例子,你(供應商)接了一個App開發外包案,你和業主(客戶)簽了一個合約。業主負責支付50萬(前置條件),你就保證三個月後交付App給他(後置條件)。

***

有了合約的概念,客戶與供應商之間的責任就很清楚了。如果前置條件沒有被滿足,就代表客戶有bug(沒付錢)。如果後置條件沒有被滿足,就代表供應商有bug(沒交貨)。上了法院誰對誰錯很清楚,一切依據合約辦事。

▼回到Storage類別的例子,假設push()函數的合約如下(require代表前置條件,enaure代表後置條件),表示呼叫push()的人必須負責確定傳入的參數不是null:

螢幕截圖 2016-07-20 11.29.47

 

▼如果store()函數沒有對傳入push()的參數做任何檢查,而程式執行的時候因為makeCopy()有bug導致Copy物件的值是null,以下程式便會產生exception。這種exception叫做preconditon violation exception(違反前置條件的例外),代表呼叫者(也就是store()函數)有bug。有bug怎麼辦?就改程式把bug修好。

螢幕截圖 2016-07-20 11.43.13

 

▲看起來這個版本的store()函數好像比較不負責,因為沒有對傳入push()的參數做檢查。但如果採取依合約設計的方法,可以進一步的去探討makeCopy()函數的合約。如果makeCopy()的後置條件保證傳回值不會是null,那麼store()就可以放心的不檢查傳給push()的參數也可以確保程式的正確性。

***

信任這兩個字,是人際關係與程式開發的基石。如果可以透過合約建立程式模組之間的信任關係,便可達到疑者不用,用者不疑。否則只能採取防禦性設計,學習明太祖朱元璋的作法,設立錦衣衛,全國各處布滿眼線。

***

友藏內心獨白:當明太祖好像也不錯耶XD。

10 則留言:

  1. 東廠不是太祖設立的,是成祖。

    回覆刪除
  2. 作者已經移除這則留言。

    回覆刪除
    回覆
    1. (1) 你要怎麼控制 [呼叫者] ? -->文章中沒有說要「控制」呼叫者。
      (2) 未來程式使用你的物件時,如何全盤了解你怎麼做 ? -->如果是採用支援DDBC的語言,則可以從contract中自動產生文件,知道每一個method的pre-condition和post-condition。
      (3) 因為忽略而產生的 BUG 要如何快速解決? -->你指的忽略是 caller 沒有滿足 pre-conditioin嗎?如果是我文章內有提到,系統會丟出 preconditon violation exception。
      (4) 我不懂你的例子要表達什麼。
      (5) 再來如果你傳入的參數有誤,你希望在哪一種情況解決 bug? -->依據DBC,如果這個參數被描述成pre-condition,則剛剛已經說過了,會產生preconditon violation exception,語意比你提到的三種情況都還要清楚。
      (6) 你引用的文章跟我說得是同一件事。

      刪除
    2. 沒看清楚就回了,抱歉 XD
      我以為你是要把所有的檢查都拿掉
      仔細看之後發現的確是描述同一件事沒錯

      刪除
  3. 這應該不是「好或不好」,而是指出「潛在問題」。沒有完美的流程或方法論,使用的人要適應 project 的特性選擇「合適」的方法。如果一個沒幾萬行,或開發時間只有幾個月,之後也不太 maintain 的 project,by contract 有點脫褲子放屁。但如果是一個幾十萬行、上百萬行,需要執行好幾年,不斷改版,長久發展下去的 project,考慮的重心可能回到維護的成本,而不是開發成本。一個有幾年歷史的系統,往往開發人員都換了好幾批。就算有詳細的 document,也沒幾個人有能力全部讀完。 by contract 能減少對歷史誤解的機會。這類 project 在實務上,很大比例的 bug 來自於對歷史的誤解或無知而造成的 regression。對於一些有 run CI 的 project,開發者應該會發現,即使在 local 檢查過了,還是有很高比例的 changeset 無法一次就通過 CI 系統的檢查。這就足以說明 by contract 的好處。

    另一方面,使用 contract 的 overhead,有很大一部分是工具、程式庫和程式語言造成的。在沒有工具和語言的協助下(很遺憾大部分 project 都這樣), contract 很難寫的完整,也不知 library 的檢查是否完整。而且相同的 contract 往往需要一直重複寫。

    如果能討論多種不同情境下的狀況和可能性,這文章會更有價值。

    回覆刪除
  4. Teddy大:

    請教一下,「因為你不能信任呼叫者所傳入的參數是否合法,所以你要檢查。因為你不能信任被呼叫者的強健度(應對異常狀況的能力),所以傳給呼叫者的參數你也要檢查。」

    這段我唸起來不是很順,最後的文字是不是「所以傳給"被"呼叫者的參數你也要檢查」才對呢?

    回覆刪除
    回覆
    1. 已修正為「所以回傳給呼叫者的結果你也要檢查」,謝謝指正。

      刪除
  5. 防衛性編程檢查造成的重覆性是不可避免的
    例如考量裝飾或多重代理或組合等模式場景
    無法預先知道在未來的運用會有幾層嵌套變化
    推遲到非檢查不可的情況,再補充時往往掛一漏萬
    倒不如及早養成習慣的好

    回覆刪除