本章介紹 SOLID 原則中最重要的一項——開放/封閉原則(OCP),由 Bertrand Meyer 於 1988 年提出。OCP 是物件導向設計的核心,遵循此原則所獲得的好處,正是物件導向技術被廣泛採用的根本原因。

原則定義#

軟體實體(類別、模組、函式等)應該對擴展開放,對修改封閉。 (Software entities should be open for extension, but closed for modification.)

這看似矛盾的兩個目標,透過**抽象(Abstraction)**來實現:

  • 對擴展開放(Open for Extension):模組的行為可以被擴展,以滿足新的需求
  • 對修改封閉(Closed for Modification):擴展行為時,不需要修改模組的現有源碼

違反症狀#

當系統違反 OCP 時:

  • 每次新增功能都必須修改現有程式碼
  • 一個小變更引發一連串的修改——觸及多個模組
  • 系統呈現僵化性(Rigidity):原本應該只是「加東西」的工作,變成了「到處改」

抽象是關鍵#

在 C# 中,可以建立固定的抽象(抽象基底類別或介面),它代表一組無限制的可能行為。模組依賴於這個抽象,因此對修改是封閉的;同時,新的衍生類別可以提供新的行為,因此對擴展是開放的。

STRATEGY 模式#

Figure 9.2: STRATEGY pattern: Client is both open and closed.

Client 依賴 ClientInterface(抽象),而 Server 實作這個介面。當需要新的行為時,只需新增一個實作 ClientInterface 的類別,不需要修改 Client

TEMPLATE METHOD 模式#

另一種實現 OCP 的方式:基底類別定義演算法的骨架,將可變的步驟宣告為抽象方法,由衍生類別提供具體實作。基底類別對修改封閉,衍生類別提供擴展。

Shape 應用程式範例#

違反 OCP 的程序式做法#

public void DrawAllShapes(IList shapes)
{
    foreach (object shape in shapes)
    {
        if (shape is Square)
            DrawSquare((Square)shape);
        else if (shape is Circle)
            DrawCircle((Circle)shape);
    }
}

每次新增一種圖形(如三角形),都必須修改 DrawAllShapes——新增一個 else if 分支。這段程式碼對修改不是封閉的

遵循 OCP 的物件導向做法#

public interface IShape
{
    void Draw();
}

public class Square : IShape
{
    public void Draw()
    {
        // 繪製正方形
    }
}

public class Circle : IShape
{
    public void Draw()
    {
        // 繪製圓形
    }
}

public void DrawAllShapes(IList<IShape> shapes)
{
    foreach (IShape shape in shapes)
        shape.Draw();
}

新增三角形時,只需建立新的 Triangle 類別並實作 IShape——DrawAllShapes 完全不需要修改。

預測變更與「自然」結構#

注意: 沒有任何模型對所有情境都是「自然的」。對一種變更封閉的設計,可能對另一種變更完全開放。例如上述的 OO 解法對「新增圖形種類」封閉,但如果需求是「所有圓形必須在正方形之前繪製」,那現有的設計就違反 OCP 了。

「上當一次」策略#

既然無法預見所有可能的變更,Martin 建議採用**「fool me once」**策略:

  1. 先寫出最簡單的程式碼
  2. 當第一次面臨某類變更時,重構程式碼以保護未來同類型的變更
  3. 不要試圖預測所有變更——「We permit ourselves to be fooled once」

刺激變更的手段#

為了盡早發現需要保護的變更類型:

  • 先寫測試(Test First):測試本身就是一種客戶端,迫使你思考如何讓程式碼可測試,進而促進解耦
  • 短迭代週期(Short Cycles):每隔數天就交付功能,讓問題盡早浮現
  • 先開發功能再開發基礎設施:讓基礎設施服務於實際需求,而非假設的需求
  • 頻繁向利害關係人展示:盡早獲得回饋

使用抽象達成明確封閉#

當排序需求出現(例如圓形必須在正方形之前繪製)時,可以讓 Shape 實作 IComparable

public interface IShape : IComparable<IShape>
{
    void Draw();
}

但這樣每個 Shape 都需要知道排序邏輯,不夠靈活。

資料驅動的做法#

更好的方式是使用資料驅動(Data-Driven)的封閉策略,將排序規則抽離到外部資料表:

public class ShapeComparer : IComparer<IShape>
{
    private static Dictionary<Type, int> priorities = new Dictionary<Type, int>();

    static ShapeComparer()
    {
        priorities[typeof(Circle)] = 1;
        priorities[typeof(Square)] = 2;
    }

    public int Compare(IShape s1, IShape s2)
    {
        int p1 = priorities[s1.GetType()];
        int p2 = priorities[s2.GetType()];
        return p1.CompareTo(p2);
    }
}

這樣新增圖形種類時,只需在優先權表中加入一行,不需要修改任何現有的 Shape 類別或 DrawAllShapesDrawAllShapes 與各 Shape 類別都對繪製順序的變更保持封閉。

重點: 完全的封閉是不可能的。設計者必須策略性地選擇對哪些變更封閉。經驗、敏銳度和對使用者及產業的了解,是做出正確判斷的關鍵。

本章小結#

OCP 是物件導向設計中最核心的原則。抵抗過早的抽象善用抽象同樣重要——在沒有看到變更模式之前就抽象化,不但無法防止未來的問題,反而會引入不必要的複雜性。敏捷開發者只在臭味出現時才行動,利用測試、短迭代與持續重構,在最適當的時機引入最適當的抽象。