Bertrand Meyer 在 1988 年提出、後經 Robert C. Martin 推廣的定義是:Software entities should be open for extension, but closed for modification(軟體實體應該對擴展開放,對修改封閉)。

「對擴展開放」意味著當需求出現新的變化時,我們可以為系統加入新的行為;「對修改封閉」意味著加入新行為時,我們不需要改動已經存在且正常運作的程式碼;這兩個要求乍聽矛盾,既然不能改動既有程式碼,又怎麼增加新行為呢?OCP 的概念是:透過多型與抽象,讓新行為以「新增類別」的方式注入系統,而非以「修改現有邏輯」的方式侵入系統。


與 OOP 的關係:多型(Polymorphism)與抽象(Abstraction)

OCP 的實現幾乎完全仰賴 OOP 的多型機制,想理解這個關係要先從多型的本質說起;多型讓我們可以寫出這樣的程式碼:

1// 呼叫端只知道 shape 是某種 Shape,不知道它具體是哪一種
2void DrawShape(IShape shape)
3{
4    shape.Draw(); // 執行時才決定呼叫哪個 Draw()
5}

DrawShape 這個方法依賴的是抽象(IShape 介面),而不是任何具體的形狀類別;未來若要新增「六邊形」,只需要新增一個實作 IShapeHexagon 類別,不需要改動 DrawShape;這就是 OCP 的運作原理:抽象是穩定的,具體實作是可以擴展的。

換言之,OCP 是多型概念重要的應用場景:用「新增類別」取代「修改現有邏輯」,讓已通過測試的程式碼保持穩定。


「修改」的代價

為什麼要這麼費心避免修改既有程式碼?每次修改都有潛在的風險:

  • 迴歸錯誤(Regression):即使改動很小,也可能在意想不到的地方破壞原有邏輯。若缺乏完整的測試覆蓋,這些錯誤可能在上線後才被發現。
  • 測試負擔:每次修改都需要重新測試相關的所有路徑,確保沒有引入新的錯誤。修改的範圍越廣,測試的成本越高。
  • 審查成本:在團隊開發中,修改現有的核心邏輯通常需要更嚴格的 Code Review,增加協作成本。

OCP 的目標是讓已穩定運作的程式碼成為不動的基礎,新需求只透過新增程式碼來實現,從而將變動的風險控制在最小範圍。


程式碼範例

違反 OCP:if-else 地獄

以下是一個支付系統的常見設計,每次新增支付方式都必須修改核心的 Process 方法:

 1public class PaymentProcessor
 2{
 3    public PaymentResult Process(string paymentType, decimal amount, PaymentDetails details)
 4    {
 5        if (paymentType == "CreditCard")
 6        {
 7            // 信用卡:驗證卡號、呼叫刷卡 API、處理 3D 驗證...
 8            var cardNumber = details.CardNumber;
 9            if (!IsValidCardNumber(cardNumber))
10                return PaymentResult.Fail("無效卡號");
11            // 呼叫信用卡 API...
12            return PaymentResult.Success("信用卡付款完成");
13        }
14        else if (paymentType == "PayPal")
15        {
16            // PayPal:OAuth 授權、呼叫 PayPal API...
17            var token = GetPayPalToken(details.Email);
18            // 呼叫 PayPal API...
19            return PaymentResult.Success("PayPal 付款完成");
20        }
21        else if (paymentType == "LinePay")
22        {
23            // LinePay:產生 QR Code、等待確認...
24            return PaymentResult.Success("LINE Pay 付款完成");
25        }
26        // 若要新增「街口支付」,必須再加一個 else if...
27        // 若要新增「Apple Pay」,又要再加一個...
28        // 每次新增都要修改這個方法,每次都要重新測試所有支付流程
29        
30        return PaymentResult.Fail($"不支援的支付方式:{paymentType}");
31    }
32
33    private bool IsValidCardNumber(string cardNumber) => cardNumber.Length == 16;
34    private string GetPayPalToken(string email) => "mock_token";
35}

這個設計的問題在於 PaymentProcessor 這個類別承擔了「知道所有支付方式的具體實作」的責任;每次業務上新增一種支付方式,工程師就必須打開這個類別、修改這個方法、重新執行所有支付相關的測試;隨著支付方式越來越多,這個方法會不斷膨脹,成為系統中最難維護的部分之一。

遵守 OCP:策略模式(Strategy Pattern)

重構的關鍵在於「將可變的部分(不同支付方式的邏輯)抽象化,讓不變的部分(處理流程的骨架)保持穩定」:

  1// 第一步:定義抽象介面,描述「所有支付方式都必須能做什麼」
  2// 這個介面是穩定的——它定義了系統的擴展點
  3public interface IPaymentStrategy
  4{
  5    // 這個支付策略是否可以處理指定的支付類型?
  6    bool CanHandle(string paymentType);
  7
  8    // 執行付款,回傳結果
  9    PaymentResult Pay(decimal amount, PaymentDetails details);
 10}
 11
 12// 第二步:為每種支付方式實作獨立的類別
 13// 每個類別只關心自己支付方式的邏輯,完全不知道其他支付方式的存在
 14
 15public class CreditCardPayment : IPaymentStrategy
 16{
 17    public bool CanHandle(string paymentType) => paymentType == "CreditCard";
 18
 19    public PaymentResult Pay(decimal amount, PaymentDetails details)
 20    {
 21        // 信用卡的完整處理邏輯都在這裡
 22        if (!IsValidCardNumber(details.CardNumber))
 23            return PaymentResult.Fail("無效卡號");
 24
 25        Console.WriteLine($"信用卡扣款 {amount:C},卡號末四碼:{details.CardNumber[^4..]}");
 26        return PaymentResult.Success("信用卡付款完成");
 27    }
 28
 29    private bool IsValidCardNumber(string cardNumber) => cardNumber?.Length == 16;
 30}
 31
 32public class PayPalPayment : IPaymentStrategy
 33{
 34    private readonly string _clientId;
 35    private readonly string _secret;
 36
 37    public PayPalPayment(string clientId, string secret)
 38    {
 39        _clientId = clientId;
 40        _secret = secret;
 41    }
 42
 43    public bool CanHandle(string paymentType) => paymentType == "PayPal";
 44
 45    public PaymentResult Pay(decimal amount, PaymentDetails details)
 46    {
 47        var token = GetOAuthToken(details.Email);
 48        Console.WriteLine($"PayPal 扣款 {amount:C},帳號:{details.Email}");
 49        return PaymentResult.Success("PayPal 付款完成");
 50    }
 51
 52    private string GetOAuthToken(string email) => $"oauth_{email}_token";
 53}
 54
 55// ✨ 新增支付方式:只需新增一個類別,完全不修改任何現有的程式碼
 56// 這就是「對擴展開放」的體現
 57public class LinePayPayment : IPaymentStrategy
 58{
 59    public bool CanHandle(string paymentType) => paymentType == "LinePay";
 60
 61    public PaymentResult Pay(decimal amount, PaymentDetails details)
 62    {
 63        Console.WriteLine($"LINE Pay 扣款 {amount:C}");
 64        return PaymentResult.Success("LINE Pay 付款完成");
 65    }
 66}
 67
 68public class JKOPayPayment : IPaymentStrategy
 69{
 70    public bool CanHandle(string paymentType) => paymentType == "JKOPay";
 71
 72    public PaymentResult Pay(decimal amount, PaymentDetails details)
 73    {
 74        Console.WriteLine($"街口支付扣款 {amount:C}");
 75        return PaymentResult.Success("街口支付付款完成");
 76    }
 77}
 78
 79// 第三步:PaymentProcessor 本身對修改封閉
 80// 它不知道任何具體的支付方式,只依賴抽象的 IPaymentStrategy
 81// 無論新增多少種支付方式,這個類別永遠不需要改動
 82public class PaymentProcessor
 83{
 84    private readonly IEnumerable<IPaymentStrategy> _strategies;
 85
 86    // 所有可用的支付策略從外部注入(搭配 DIP)
 87    public PaymentProcessor(IEnumerable<IPaymentStrategy> strategies)
 88        => _strategies = strategies;
 89
 90    public PaymentResult Process(string paymentType, decimal amount, PaymentDetails details)
 91    {
 92        var strategy = _strategies.FirstOrDefault(s => s.CanHandle(paymentType));
 93
 94        if (strategy is null)
 95            return PaymentResult.Fail($"不支援的支付方式:{paymentType}");
 96
 97        return strategy.Pay(amount, details);
 98    }
 99}
100
101// 使用端:透過 DI Container 或手動組裝
102var processor = new PaymentProcessor(new IPaymentStrategy[]
103{
104    new CreditCardPayment(),
105    new PayPalPayment("client_id", "secret"),
106    new LinePayPayment(),
107    new JKOPayPayment()   // 新增街口支付:只需在這裡加一行,其餘不動
108});

延伸:模板方法模式(Template Method Pattern)

除了策略模式,模板方法模式是另一個實現 OCP 的經典手段,它適合「流程固定,但某些步驟的實作可變」的場景。

策略模式的擴展點是整個行為(透過組合注入不同的策略物件),而模板方法的擴展點是流程中的某些步驟(透過繼承覆寫特定方法):

 1// 抽象父類別定義流程骨架,這個骨架對修改封閉
 2public abstract class DataImporter
 3{
 4    // 公開方法定義不變的流程,呼叫端永遠只呼叫這個
 5    public void Import(string source)
 6    {
 7        Console.WriteLine("開始匯入...");
 8        var raw = ReadData(source);       // 步驟一:讀取原始資料(可變)
 9        var data = ParseData(raw);        // 步驟二:解析資料格式(可變)
10        ValidateData(data);               // 步驟三:驗證資料(有預設行為,可選覆寫)
11        SaveData(data);                   // 步驟四:儲存資料(可變)
12        Console.WriteLine($"匯入完成,共 {data.Length} 筆。");
13    }
14
15    // 這些方法是「擴展點」,子類別透過覆寫它們來提供不同的實作
16    protected abstract string ReadData(string source);
17    protected abstract object[] ParseData(string raw);
18    protected abstract void SaveData(object[] data);
19
20    // 提供預設實作的步驟:子類別可以選擇是否覆寫
21    protected virtual void ValidateData(object[] data)
22    {
23        if (data.Length == 0)
24            throw new InvalidOperationException("匯入資料不得為空");
25        // 預設只驗證資料不為空,子類別可加入更嚴格的驗證
26    }
27}
28
29// 新增 CSV 匯入:只需新增這個類別,DataImporter 的流程骨架完全不動
30public class CsvDataImporter : DataImporter
31{
32    protected override string ReadData(string path)
33    {
34        Console.WriteLine($"從檔案讀取:{path}");
35        return File.ReadAllText(path);
36    }
37
38    protected override object[] ParseData(string raw)
39    {
40        // 解析 CSV 格式
41        return raw.Split('\n').Skip(1).Cast<object>().ToArray(); // 跳過標題列
42    }
43
44    protected override void SaveData(object[] data)
45    {
46        Console.WriteLine($"儲存 {data.Length} 筆 CSV 資料至資料庫");
47    }
48}
49
50// 新增 JSON API 匯入:同樣只需新增類別
51public class JsonApiDataImporter : DataImporter
52{
53    private readonly HttpClient _http;
54
55    public JsonApiDataImporter(HttpClient http) => _http = http;
56
57    protected override string ReadData(string url)
58    {
59        Console.WriteLine($"從 API 讀取:{url}");
60        return _http.GetStringAsync(url).Result;
61    }
62
63    protected override object[] ParseData(string raw)
64    {
65        // 解析 JSON 格式
66        return JsonSerializer.Deserialize<object[]>(raw) ?? Array.Empty<object>();
67    }
68
69    protected override void SaveData(object[] data)
70    {
71        Console.WriteLine($"儲存 {data.Length} 筆 JSON 資料至資料庫");
72    }
73
74    // 覆寫驗證步驟,加入 API 資料特有的驗證規則
75    protected override void ValidateData(object[] data)
76    {
77        base.ValidateData(data); // 先執行預設驗證
78        Console.WriteLine("執行 API 資料額外驗證...");
79    }
80}

OCP 的適用時機與陷阱

不要過早抽象

OCP 不代表「所有邏輯都要抽象化」,第一次撰寫功能時,幾乎不可能預測所有未來的變化方向;若一開始就為各種假設的擴展點建立抽象,反而會引入大量不必要的複雜度,讓程式碼更難理解,這稱為過早抽象(Premature Abstraction)。

Uncle Bob 提出了一個實用的判斷標準:被修改兩次之後,才開始抽象化。

第一次有新需求時可以選擇直接修改;第二次出現類似的新需求時,這就是一個告知訊號「這個地方會持續變化」,此時再引入 OCP 的保護機制,往往是最合適的時機。

判斷是否需要 OCP 的問題清單

在決定是否引入 OCP 的抽象層時,可以問自己:

  • 這個邏輯是否曾經因為「新增同一類型的功能」而被修改過?
  • 這個邏輯是否預期在未來會持續增加同類型的選項?
  • 若每次新增功能都需要修改這段程式碼,風險是否可以接受?

若以上問題的答案都是否,那可能暫時不需要引入 OCP 的抽象。


重點回顧

  • OCP 的核心手段是多型:透過介面或抽象類別定義穩定的擴展點,讓新功能以新增類別的方式注入,而非修改現有邏輯。
  • 常用的設計模式:策略模式(行為的整體替換)、模板方法模式(流程骨架中特定步驟的替換)。
  • 違反 OCP 的典型症狀:程式碼中充斥著 if-elseswitch,且每次新增功能都需要打開同一段程式碼修改。
  • OCP 不代表永遠不修改,它代表「已穩定的核心邏輯不應因新功能而被打擾」。
  • 過早抽象是另一個極端,判斷是否需要 OCP 的最佳時機是「第二次遇到同類型的擴展需求時」。