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 介面),而不是任何具體的形狀類別;未來若要新增「六邊形」,只需要新增一個實作 IShape 的 Hexagon 類別,不需要改動 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-else或switch,且每次新增功能都需要打開同一段程式碼修改。 - OCP 不代表永遠不修改,它代表「已穩定的核心邏輯不應因新功能而被打擾」。
- 過早抽象是另一個極端,判斷是否需要 OCP 的最佳時機是「第二次遇到同類型的擴展需求時」。