0%

SOLID 筆記 - (5)開放封閉原則 OCP Open Closed Principle

1. 開放封閉原則 OCP Open Closed Principle

  • Software entities(classes,modules,functions,etc.)

    should be open for extension but closed for modification

  • 軟體實體(類別、模組、函式等)應該開放擴充封閉修改

  • 藉由增加新的程式碼來擴充系統的功能,而不是藉由修改原本已經存在的程式碼來擴充系統。

2. 關於OCP的基本精神

  • 一個”類別”需要開放,意味著 該類別可以被擴充!

    • 可以透過繼承輕鬆做到
      • 我有一個類別我繼承它,然後把新的功能寫在新的類別裡,也剛好符合SRP
      • 新的版本用新的類別,舊的版本用舊的類別,舊的CODE絕對不會壞掉。
    • C# 還有擴充方法可以輕鬆擴充既有類別
  • 一個”類別”需要封閉,意味著有其他人正在使用這個類別!

    • 如果程式已經編譯,但又已經有人在使用原本的類別
    • 封閉修改可以有效避免未知的問題發生

2.1. 補充說明:擴充方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System;
using System.Globalization;

public static class StringExtensions
{
public static string ToTitleCase(this string str)
{
if (string.IsNullOrEmpty(str))
{
return str;
}

TextInfo textInfo = CultureInfo.CurrentCulture.TextInfo;
return textInfo.ToTitleCase(str.ToLower());
}
}

// 使用範例
public class Program
{
public static void Main()
{
string text = "hello world";
Console.WriteLine(text.ToTitleCase()); // 輸出:Hello World
}
}

3. 常見的設計問題

  • 耦合力過高、擴充不易

4. 關於OCP的實作方式

  • 採用分離與相依的技巧(相依於抽象)
    • 抽象型別可以是一般類別抽象類別介面
      • 類別A相依於這個抽象型別,原本寫的code還是在類別B、C、D,只是再相依於這個抽象型別
      • 如果你在過去專案有看到大量的interface是因為他希望你做到更好的擴充
    • 缺點:需要針對原有程式碼進行重構

image-20241108175120685

5. 關於OCP的C#範例

  • 透過抽象類別限制其修改,並透過繼承開放擴充不同實作
1
2
3
4
5
public abstract class DataProvider{
public abstract int OpenConnection();
public abstract int CloseConnection();
public abstract int ExecuteCommand();
}

5.1. 隨堂測驗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
void Main()
{
DataProvider DataProviderObject = new SqlDataProvider();
DataProviderObject.OpenConnection();
DataProviderObject.ExecuteCommand();
DataProviderObject.CloseConnection();

DataProviderObject = new OracleDataProvider();
DataProviderObject.OpenConnection();
DataProviderObject.ExecuteCommand();
DataProviderObject.CloseConnection();
}

abstract class DataProvider
{
public abstract int OpenConnection();
public abstract int CloseConnection();
public abstract int ExecuteCommand();
}

class SqlDataProvider : DataProvider
{
public SqlDataProvider()
{
Console.WriteLine("\nCreate SqlDataProvider");
}
public override int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public override int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public override int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

class OracleDataProvider : DataProvider
{
public OracleDataProvider()
{
Console.WriteLine("\nCreate OracleDataProvider");
}
public override int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public override int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public override int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

class OledbDataProvider : DataProvider
{
public override int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public override int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public override int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

改為interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void Main()
{
DataProvider DataProviderObject = new SqlDataProvider();
DataProviderObject.OpenConnection();
DataProviderObject.ExecuteCommand();
DataProviderObject.CloseConnection();

DataProviderObject = new OracleDataProvider();
DataProviderObject.OpenConnection();
DataProviderObject.ExecuteCommand();
DataProviderObject.CloseConnection();
}
interface DataProvider
{
public int OpenConnection();
public int CloseConnection();
public int ExecuteCommand();
}

class SqlDataProvider : DataProvider
{
public SqlDataProvider()
{
Console.WriteLine("\nCreate SqlDataProvider");
}
public int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

class OracleDataProvider : DataProvider
{
public OracleDataProvider()
{
Console.WriteLine("\nCreate OracleDataProvider");
}
public int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

class OledbDataProvider : DataProvider
{
public int OpenConnection()
{
Console.WriteLine("\nSql Connection opened successfully");
return 1;
}
public int CloseConnection()
{
Console.WriteLine("\nSql Connection closed successfully");
return 1;
}

public int ExecuteCommand()
{
Console.WriteLine("\nSql Connection executed successfully");
return 1;
}
}

img

6. 關於OCP的使用時機

6.1. 範例程式:強調穩定

假設我們有一個Animal基礎類別,並希望在不修改此類別的前提下擴展不同的動物。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class Animal
{
public abstract string MakeSound();
}

public class Dog : Animal
{
public override string MakeSound()
{
return "Bark";
}
}

public class Cat : Animal
{
public override string MakeSound()
{
return "Meow";
}
}

當我們需要加入新動物時,例如鳥類,可以通過擴展Animal類別來實現,而不修改現有的類別。

1
2
3
4
5
6
7
public class Bird : Animal
{
public override string MakeSound()
{
return "Chirp";
}
}

img

6.2. 範例程式:加入新需求的屬性或方法

假設現在需要在Animal類別中加入Move(移動)行為,我們可以透過新的基礎類別來擴展,而不是修改原本的Animal類別。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class Animal
{
public abstract string MakeSound();
}

public abstract class MovingAnimal : Animal
{
public abstract string Move();
}

public class Dog : MovingAnimal
{
public override string MakeSound()
{
return "Bark";
}

public override string Move()
{
return "Run";
}
}

此例中,我們使用新的MovingAnimal類別,讓具有移動行為的動物繼承此類別,保持Animal類別的封閉性,並對擴展開放。

img

6.3. 範例程式:擔心修改既有程式破壞現有系統運作

假設原來的系統有一個PaymentProcessor類別,只支持信用卡支付:

1
2
3
4
5
6
7
public class PaymentProcessor
{
public void ProcessCreditCard(decimal amount)
{
// 處理信用卡付款邏輯
}
}

如果需要支持其他支付方式(如支付寶或PayPal),可以透過設計接口來擴展各種支付方式,而不修改PaymentProcessor的原始代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface IPaymentMethod
{
void Pay(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
public void Pay(decimal amount)
{
Console.WriteLine("Processing credit card payment of " + amount);
}
}

public class AlipayPayment : IPaymentMethod
{
public void Pay(decimal amount)
{
Console.WriteLine("Processing Alipay payment of " + amount);
}
}

public class PaymentProcessor
{
public void ProcessPayment(IPaymentMethod paymentMethod, decimal amount)
{
paymentMethod.Pay(amount);
}
}

img

7. OCP討論事項

  • 當您剛接受維護一份2年前的程式碼,你會怎樣做?

    • 修改之前寫過的類別?
    • 擴充之前寫過的類別?
    • 直接修改舊有原始碼,會有哪些風險存在呢?
  • 如何讓系統在擴充需求時更簡單、更容易、更安全?

  • C#可以透過interface實踐OCP原則嗎?如何做到?

  • 如何進行抽象化設計?多少人用過C#抽象類別?

  • 保哥語錄

    • 5000行的code在那裡,內聚力超低(肯定沒有SRP原則),你也不敢改它,更應該套用OCP原則,封閉修改。寫新的類別去繼承它,然後讓新的程式(需求)去呼叫新的類別。

    • 繼承它之後叫class_v1…v99

    • OCP 的價值是系統的穩定度,不去改原本的code

    • OCP 的成本是類別越來越多

    • OCP 的可讀性不一定差

      • 你光看程式就知道它的版本XD
    • interface 與 abstract 哪個好?

      • 不一定,要看你怎麼寫
      • 抽象類別可包含實作也意味著與其它類別的耦合性度較高
        • 你可以想share一段code,所以就會選擇抽象類別。
      • interface的最大好處就是不能寫code在裡面,也意味著耦合度比較低

7.1. 隨堂測試

起初只是一個簡單的需求,因此程式開發時,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class AppEvent
{
public void GenerateEvent(string message)
{
Logger fooLogger = new Logger();
fooLogger.Log(message);
}
}

public class Logger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}

這樣的程式碼並沒有問題,需求僅此。

但後來客戶想要增加Log輸出到檔案的功能,你會如何改寫程式碼?

沒學過SOLID的開發者,可能會這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class AppEvent
{
public void GenerateEvent(string message)
{
Logger fooLogger = new Logger("Console");
fooLogger.Log(message);
}
}

public class Logger
{
//這裡採用的是修改原始程式碼的方式,進行變更需求之功能擴充!
private readonly string Target;

public Logger(string target)
{
Target = target;
}

public void Log(string message)
{
//此時,若又想要增加將訊息傳送到遠端Web API或Storage呢?
if (Target == "Console")
Console.WriteLine(message);
else if (Target == "File")
File.WriteAllText("MyLog", message);
else
throw new NotImplementedException();
}
}

這樣的寫法是否有符合SRP精神?現在已經有兩個理由以上改這段(一個是console、一個是file),這樣的寫法已經不符合SRP

接下來,採用分離與相依的技巧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public interface ILogger
{
//定義所有Log都會有Log行為
void Log(string message);
}

//符合SRP
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}

//符合SRP
public class FileLogger : ILogger
{
public void Log(string message)
{
File.WriteAllText("MyLog", message);
}
}

public class AppEvent
{
private readonly ILogger _logger;

public AppEvent(string loggerType)
{
this._Logger = LoggerFactory.CreateLogger(loggerType);
}

public void GenerateEvent(string message)
{
_Logger.Log(message);
}
}

//工廠模式
public class LoggerFactory
{
public static ILogger CreateLogger(string loggerType)
{
if (loggerType == "Console")
return new ConsoleLogger();
else if (loggerType == "File")
return new FileLogger();
else
throw new NotImplementedException();
}
}

image-20241111223144806

如果我們再加上Web API Log 要改的只有LoggerFactory,很符合SRP精神。