簡單說明SOLID法則

作為一名後端工程師,我們常聽過不少開發上的規範,比方說常見的

  • DRY原則──指的是Don’t repeat yourself.,不要重複寫同樣的內容。
  • KISS原則──指開發的產品程式碼應該要越簡單越好,簡單的好處不僅可以降低錯誤的發生率,也帶來較好維護、好理解、好測試等等優點。

但我們這次要提到的不是以上這兩個,而是SOLID。
SOLID是一名後端工程師在成長的過程中一定會聽到的單字,特別指物件導向設計的設計原則,SOLID的目的是使軟體設計更為便於理解、更便於修改、而且更好維護的五項設計原則。我們就一起來看看他們吧。

S 單一職責原則(Single Responsibility Principle)


SRP主張一個類別應該只有一個職責,也就是說一個類別應該只有一個改變的原因。也有人這樣解釋:一個類別應只對唯一的一個角色負責。
這聽起來很難不讓人不茫然,我的理解是這樣的,SRP是指一個類別應該只做一件事,舉例來說:

假設有一個物件同時負責了將物件回傳給前端,以及物件存入資料庫這兩件事。當今天規格需要調整時,就可能造成,新增某欄位後前端壞掉後端也存不進去的問題,因為同一個模組負責了兩件事情 (回傳給前端的物件 + 存入資料庫的物件)。
比較好的做法是切割這兩個功能,使其成為一個獨立的類別。

實務上來說,這並不好實現,在設計時,雖然我們希望一個類別相當單純,但往往又會希望這個功能是可以好重複利用的,我們會很想要把一些重複的、不屬於該類別的處理加入該類別中,這最終導致了單一職責原則遭破壞。
所以SRP未必是一定得遵守的原則,反倒像是一種理想形式,只能透過經驗去學習如何切出一個乾淨的、職責分明的類別。

O 開放封閉原則 (Open-Closed Principle)


OCP主張一個軟體在面對擴展時是開放的,且擴充時不應修改到原有的程式。它必須是好修改的,就算要改,也不該影響現有程式功能。 舉例來說:

我們有一個車輛介面(Vehicle),並有一個make()製造的方法,所有的汽車(Toyota、Honda)都是透過CarFactory的assemble()方法去組裝一台汽車。今天,假設我們有一個新的類別Mazda,他加入時, 不應該要更改CarFactory的內容。
所有的調整、商業邏輯要集中在Mazda,並且沒有資格參加這個會議

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Vehicle {
void make();
}
class Toyota implements Vehicle{
void make() {
// 實作省略
}
}
class Honda implements Vehicle{
void make() {
// 實作省略
}
}
class CarFactory {

public void assemble(Vehicle vehicle) {
vehicle.make();
}
}

通常定義介面、或是一個繼承關係,可以實現OCP,定義中提到的擴展開放──就是指加入新的類別時不應影響到基底的功能。如果真的要加入的話,定義一個新的類別,透過繼承原先類別來完成它。
對修改封閉的意思剛好反過來,假設你想要調整CarFactory、就變得相當困難,因為一旦修改CarFactory,所有的汽車類別都會受到影響,因此對修改是封閉的。

聽起來是不是理所當然?但實際上也不太容易看到這樣的程式碼,通常的情況是,大家常常覺得在基底類別中加入一個if做特例處理就好,這導致了現實常常是一堆if if if在共用的類別中。要乾淨地拆開,既不違反KISS又要能符合OCP往往並不簡單。
最好的情況是,當下在設計初期,就考慮到抽象化類別的可能性,但這部分也需要經驗去判斷。

L 里氏替換原則 (Liskov Substitution Principle)


LSP主張,子類別必須能夠替換父類別,並且行為不受影響。原則有三:

  1. 子類別的先決條件 (Preconditions) 不應被加強。────子類別應該要可以完全取代父類別、簡單來說就是不可以限制父類的定義,如果父類別的方法是接收Vehicle、子類別不可以覆寫並限制只能放入Mazda。
  2. 子類別的後置條件 (Postconditions) 不應被削弱。────子類別不可以削弱父類別的定義、與剛才相反,如果父類別的方法是接收Mazda,子類別覆寫時不可以改為Vehicle
  3. 父類別的不變條件 (Invariants) 必須被子型態所保留。────子類別不可以修改父類別的定義。
    簡單來說呢,當你子類別想要做與父類別截然不同的事,你應該要思考,這個物件是否真的需要的是繼承嗎?還是應該拆開、或是套用Interface。

讓我們看一個壞例子

1
2
3
4
5
6
7
8
9
class Gandalf extends Wizard {
@Override
public void attack() {
戰吼();
迴旋斬();
}
}
// 搞屁啊為什麼甘道夫不是巫師嗎?為什麼會使用近戰技能?
// 聽我說榮恩,這玩意兒比法杖好用多了

I 介面隔離原則 (Interface Segregation Principle)


ISP主張,因為模組之間的依賴不應有用不到的功能。
這是為了避免Code smell(程式碼異味)的一個原則,有一種設計上的問題要做God Object,這個God Object就跟上帝一樣什麼都能做,這種物件存在最終會導致,今天God Object需要調整時,你將會感受痛苦與絕望。因為它被使用到的地方太多,而你極有可能無法去確保修改之後系統會發生什麼不可預料的事情。腐敗就此開始。

當出現這樣的問題時,應該適當予以分割,我們可以透過介面來進行分割,把模組分得更符合本身的角色,也讓使用介面的角色只能分別接觸到應有的功能。
多使用介面來進行解藕、把實作隱藏起來、保持抽象,有助我們程式的彈性。

D 依賴反轉原則 (Dependency Inversion Principle)


DIP主張,

  • 高層模組不應依賴低層模組,它們都應依賴於抽象介面。
  • 抽象介面不應該依賴於具體實作,具體實作應依賴抽象介面。

我當初看到就覺得,這什麼鬼?可不可以講人話?再說反轉是什麼,新的遊戲王陷阱卡嗎?
為了更好地說明,讓我們一步步解釋這概念吧。

高層、低層的概念是指抽象化的程度,比方說

1
2
3
4
5
6
7
8
class ExportService {
void exportDoc(Document document) {
//
}
}
interface Document {

}

當中ExportService是一個高層模組,exportDoc()負責輸出文件,而他不需要管文件到底是.txt、.doc、.docx,因為它並不直接依賴低階模組的內容,而是透過介面去操作它。
低階模組就像是Txt、Word等等實作Document的類別,內部定義了實作的內容。
所以,ExportService(高階模組)不應該依賴Word(低階模組),而是依賴Document這個介面。

第二點,可以參考以下程式

1
2
3
interface Database {
Connection getConnection(); // 強依賴於 JDBC 的 Connection
}

這個介面依賴了實際的內容,應該抽象化,比方說

1
2
3
4
interface Database {
void saveData(String data);
String fetchData(String query);
}

透過抽象化,讓介面可以更靈活。
透過實現DIP的原則,有以下好處

  1. 提高靈活性:系統可以更輕鬆地替換具體實作,例如更換資料庫或支付服務。
  2. 提升可測試性:高層模組依賴抽象介面,可以通過模擬物件(Mock)來進行單元測試。
  3. 降低耦合:高層與低層模組相互隔離,修改低層模組時不需要修改高層模組。

以上就是我整理的內容有關SOLID的一些概念解說,那今天就講到這裡了。

參考資源

  1. https://medium.com/jastzeonic/soild-%E4%BA%94%E5%8E%9F%E5%89%87%E9%82%A3%E4%B8%80%E5%85%A9%E4%BB%B6%E4%BA%8B%E6%83%85-4410b72e37f3
  2. https://www.explainthis.io/zh-hant/swe/solid

簡單說明SOLID法則
https://clark1945.github.io/2025/01/04/簡單說明SOLID法則/
Author
Clark Liu
Posted on
January 4, 2025
Licensed under