開放封閉原則(Open-Closed Principle,OCP)的定義是:軟體實體(類別、模組、函式)應該對擴充開放、對修改封閉——需求改變時,靠新增程式碼擴充模組的行為,不改動已經能運作的程式碼。封閉有範圍:模組只對設計者選定的那幾類變更封閉,下一節說明為什麼不可能對所有變更封閉。這條原則由 Bertrand Meyer 在 1988 年的《Object-Oriented Software Construction》提出。它與 Single Responsibility Principle(單一職責原則) 常一起使用:單一職責原則決定模組的邊界畫在哪裡,開放封閉原則決定其中哪一類變更要做成擴充點。

概念位置

對擴充開放與對修改封閉看起來互相矛盾:要讓模組有新的行為,通常就得改它。Robert C. Martin 在 1996 年的〈The Open-Closed Principle〉裡給的解法是「抽象是關鍵」:使用端只依賴一個固定的抽象(例如一個介面或抽象類別),新的行為由新的實作提供。使用端依賴的抽象不變,所以使用端不必修改;實作可以一直增加,所以行為可以擴充。用單一職責原則的語言說,單一職責原則畫出模組的邊界,抽象是這條邊界上對外公開的部分,各個實作留在邊界內,其他模組不需要知道。

同一篇文章另有一段〈Strategic Closure〉,是這條原則的使用條件:沒有一個有規模的程式能百分之百封閉,不論模組多「封閉」,總有某一類變更是它擋不住的。Martin 的例子是一個依序畫出所有圖形的函式,它對「新增一種圖形」封閉,對「先畫完所有圓形再畫方形」這種順序規則的變更就不封閉。所以封閉要有選擇:設計者要挑出要封閉的那幾類變更,Martin 給的依據是對使用者與產業的了解,判斷哪幾類變更最可能發生。

「封閉要有選擇」這個條件讓開放封閉原則與 Divergent Change(發散式變更) 接在一起:修改紀錄是「哪幾類變更最可能發生」最直接的證據。一類變更在紀錄裡已經反覆發生、每次都要改同一個模組,就是 Divergent Change 的形態,也是值得封閉的候選。替紀錄裡從沒出現過的那一類變更預先做擴充點,現在就要多寫並維護一層抽象,換到的是一個未必發生的變更變便宜;這是 YAGNI 處理的那種取捨,它在什麼條件下成立見 YAGNI 的真實適用條件。

可觀察訊號與例子

需要開放封閉原則的訊號,是同一類變更反覆要求修改同一段程式碼:

  • 依類型分支的函式:一個 if 或 switch 依格式(PDF、Excel、HTML)或類型分派,每新增一種格式就要在這段程式碼裡多加一個分支,並重新確認沒有改壞其他分支。改成每種格式各一個實作、使用端只依賴共同的抽象之後,新增格式變成新增一個實作。
  • 集中登記檔:新增一種實作之後,還要回到一份集中的清單登記它。這份清單仍然對「新增實作」不封閉,並行的需求也會在它的尾端同時追加而衝突(處置見從修改紀錄畫模組邊界〈熱點的分類與處理方式〉);用自動登記(依命名規則或目錄掃描)可以讓這份清單也不必再改,代價是登記關係不再寫在一個看得到的地方。
  • 共用的全域狀態:Martin 在同一篇文章裡指出,依賴全域變數的模組,對任何可能改寫那個變數的模組都無法封閉;依賴那個全域變數的模組很少、或那個全域變數不可能被不一致地使用時,損害才小。

各語言的寫法不同:Python 用抽象基類或 Protocol,Go 用 interface,Dart 用 abstract class。Python 的例子在開放封閉原則與認知負擔。

設計責任

套用開放封閉原則的設計責任是選對要封閉的變更。判斷問句是:這個擴充點要擋的那一類變更,在過去的修改紀錄裡發生過幾次,三種結果各有處置:

  • 已經反覆發生(紀錄裡有兩次以上、每次都改同一處):做擴充點。
  • 只發生過一次:看對使用者與產業的了解判斷它會不會再發生——例如報表格式由客戶要求、而客戶還會再要新格式——判斷會再發生才做;判斷不出來就先照原樣改,等它第二次發生。
  • 從沒發生過:先不做。

擴充點做出來之後,抽象本身成了很多實作共同依賴的對象,修改抽象的成本隨實作數增加,所以抽象只放那一類變更裡每個實作都需要的操作,不放眼前某一個實作才有的細節。