軟體設計

[SOLID #1] SOLID 五個字母,別背誦而是理解(SRP・OCP・LSP)

面試時被問過「請說明 SOLID 原則」這個問題的人,應該都有過一次吧。

閱讀 3 分鐘
[SOLID #1] SOLID 五個字母,別背誦而是理解(SRP・OCP・LSP) 封面圖

面試時被問過「請說明 SOLID 原則」這個問題的人,應該都有過一次吧。

但老實說,就算能把五項原則逐一背出來回答,能接著說「所以要怎麼用在自己的程式碼裡?」的人並不多。

這次不講教科書定義,而是用實務場景拆解 SOLID。內容較多,因此分成上下兩篇。今天先談前面三個字母:SRP、OCP、LSP。

SOLID 是 Robert Martin(Uncle Bob)整理出的物件導向設計五大原則首字母縮寫。

  • SRP — 單一職責原則
  • OCP — 開放封閉原則
  • LSP — Liskov 替換原則
  • ISP — 介面隔離原則
  • DIP — 相依性反轉原則

五項原則看似各自談不同事情,但貫穿其中的訊息只有一個:發生變更時,縮小需要修改的程式碼範圍。


SRP — 單一職責原則

教科書定義是「類別只能有一項職責」,但 Uncle Bob 本人後來改用更好的說法。

模組只能對單一角色負責。

這裡的角色,是要求變更這段程式碼的人或部門。假設有這樣一個類別。

class Employee {
    func calculatePay() { ... }   // 會計部門要求修改
    func reportHours() { ... }    // 人資部門要求修改
    func save() { ... }           // 開發團隊(DBA)要求修改
}

三個方法的負責人完全不同。如果因應會計部門的需求修改 calculatePay時碰到共用邏輯,人資部門的報表就可能悄悄出錯。實務上發生這種事故時,也很難找出原因。

SRP 將這件事整理成:「要求修改的人不同,程式碼也要分開。」把薪資計算、工作報表和儲存分成不同類別後,會計部門的需求就不會影響人資功能。


OCP — 開放封閉原則

定義是「對擴充開放,對修改封閉」。第一次聽起來像禪問答,但用程式碼來看就很清楚。

// 每次增加付款方式都必須修改這個函式
func pay(method: String, amount: Int) {
    if method == "card" { ... }
    else if method == "kakaopay" { ... }
    else if method == "naverpay" { ... }
    // 新付款方式 = 修改 else if = 既有程式碼
}

在這種結構中,若要加入 Toss Pay,就必須打開並修改原本運作正常的函式。每次修改都要承擔破壞既有付款方式的風險。

// 只要「新增」付款方式即可的結構
let payments: [String: Payment] = [
    "card": CardPayment(),
    "kakaopay": KakaoPayment(),
    "naverpay": NaverPayment(),
    // 加入 Toss Pay = 一行註冊新類別
]

func pay(method: String, amount: Int) {
    payments[method]?.process(amount: amount)
}

不碰既有程式碼(對修改封閉),只要加入新類別就能擴充功能(對擴充開放)。訣竅是只在經常變動的地方採用這種結構。全部程式碼都套用的話,就會違反上一篇提到的 YAGNI。

加入 Toss Pay,只要多一個類別就完成
加入 Toss Pay,只要多一個類別就完成
用新增而不是修改來因應,就是 OCP
用新增而不是修改來因應,就是 OCP

LSP — Liskov 替換原則

名稱看起來最可怕,但意思其實很簡單。

子類別即使直接替換到使用父類別的地方,也不能讓程式壞掉。

最有名的反例就是矩形與正方形問題。若按照「正方形是矩形」的數學常識進行繼承,就會發生這種事。

class Rectangle {
    var width = 0
    var height = 0
    func setWidth(_ w: Int) { width = w }
    func setHeight(_ h: Int) { height = h }
}

class Square: Rectangle {
    override func setWidth(_ w: Int) { width = w; height = w }  // 因為是正方形
    override func setHeight(_ h: Int) { width = h; height = h }
}

// 期待矩形的程式碼
func test(_ rect: Rectangle) {
    rect.setWidth(5)
    rect.setHeight(4)
    print(rect.width * rect.height) // 20期待 Square如果是正方形 16
}

放入 Square 的瞬間,「寬 5、高 4 時面積為 20」這個理所當然的期待就被打破了。即使 is-a 關係看似成立,只要破壞行為契約,就不該使用繼承,這就是 LSP。

實務上的訊號是這樣:如果子類別覆寫父類別方法後丟出例外,或留下空實作,那麼這個繼承很可能違反 LSP。

原來正方形沒辦法放進矩形的位置
原來正方形沒辦法放進矩形的位置

結束上篇

把今天介紹的三項原則各留下一句話吧。

  • SRP — 要求修改的人不同,程式碼也要分開。
  • OCP — 讓經常變動的地方能以新增而非修改來因應。
  • LSP — 子類別不要破壞父類別的契約。若非破壞不可,代表繼承就是錯的。

下一篇會介紹剩下的兩個字母:ISP(介面隔離)與 DIP(相依性反轉)。尤其 DIP 直接連結到現代框架為什麼都使用相依性注入,應該會很有趣。