軟體設計

[SOLID #2] SOLID 原則實務篇(下):ISP・DIP,以及相依性注入存在的理由

上一篇介紹了 SOLID 的前三個字母 SRP・OCP・LSP,今天來整理剩下的兩個字母。

閱讀 3 分鐘
[SOLID #2] SOLID 原則實務篇(下):ISP・DIP,以及相依性注入存在的理由 封面圖

上一篇介紹了 SOLID 的前三個字母 SRP・OCP・LSP,今天來整理剩下的兩個字母。

它們是 ISP(介面隔離原則)與 DIP(相依性反轉原則)。

尤其是 DIP,它是使用 Swinject 或 Factory 等 Swift DI 函式庫時一定會遇到的「相依性注入(DI)」理論根基。讀完這一篇,你就會明白那些函式庫為什麼會設計成那個樣子。


ISP:介面隔離原則

先從定義開始。

不應強迫用戶端依賴自己不使用的方法。

這句話聽起來有點抽象,但看看實際案例就能立刻理解。假設我們建立一個複合事務機協定。

protocol Machine {
    func print(_ doc: Document)
    func scan(_ doc: Document)
    func fax(_ doc: Document)
}

// 舊式印表機只能列印...
class OldPrinter: Machine {
    func print(_ doc: Document) { /* 正常運作 */ }
    func scan(_ doc: Document) { fatalError("無法掃描") }  // 勉強實作
    func fax(_ doc: Document) { fatalError("無法傳真") }   // 勉強實作
}

這是在強迫只能列印的印表機連掃描和傳真也要實作。最後會出現只丟出例外的假方法,而這同樣違反了上一篇提到的 LSP。肥大的介面就這樣連續違反兩個原則。

處方很簡單:按照角色拆分介面。

protocol Printer { func print(_ doc: Document) }
protocol Scanner { func scan(_ doc: Document) }
protocol Fax { func fax(_ doc: Document) }

class OldPrinter: Printer { ... }                      // 僅列印
class MultiFunction: Printer, Scanner, Fax { ... }     // 全功能複合事務機

各自只承諾自己能做到的事。實務上常見的徵兆是:採用協定時出現「這個方法跟我們無關……」的空實作,那就表示該拆分協定了。


DIP:相依性反轉原則

這是個常因名稱而被誤解的原則。定義只有兩行。

高階模組不應依賴低階模組。兩者都應依賴抽象化。

抽象化不應依賴細節。細節應依賴抽象化。

來看程式碼。假設訂單服務直接使用寄送電子郵件的函式庫。

// 高階模組(商業邏輯)直接依賴低階模組(實作細節)
import SendGrid

class OrderService {
    private let mailer = SendGridClient(apiKey: apiKey)

    func completeOrder(_ order: Order) {
        // 訂單處理...
        mailer.send(to: order.email, message: "訂單完成")  // SendGrid被綁定
    }
}

這個結構有兩個問題。若要把 SendGrid 換成其他服務,就必須打開商業邏輯;而每次測試都會真的寄出郵件。

套用 DIP 後,箭頭方向會反轉。

// 抽象化由高階模組(訂單領域)定義
protocol NotificationSender {
    func send(to: String, message: String) async throws
}

class OrderService {
    private let sender: NotificationSender

    init(sender: NotificationSender) {  // 只依賴抽象化
        self.sender = sender
    }

    func completeOrder(_ order: Order) async throws {
        try await sender.send(to: order.email, message: "訂單完成")
    }
}

// 細節(SendGrid)遵循抽象化
class SendGridSender: NotificationSender { ... }
class SlackSender: NotificationSender { ... }
class FakeSender: NotificationSender { ... }  // 測試用

原本指向「訂單服務 → SendGrid」的相依關係,現在變成「訂單服務 → 協定 ← SendGrid」。因為細節朝領域低頭,所以稱為「反轉」。

箭頭方向反轉的地方,就是 DIP 的全部
箭頭方向反轉的地方,就是 DIP 的全部
箭頭方向改變,就是「反轉」的真實含義
箭頭方向改變,就是「反轉」的真實含義

所以才會有 DI 框架

這裡自然會出現一個問題:「那麼,SendGridSender()是誰建立的?」

代替我們完成組裝的,就是相依性注入(DI)容器。Swift 生態中的 Swinject、Factory 等 DI 函式庫,確切來說做的就是這件事。讓類別只依賴協定,並由函式庫負責插入實際的實作。

DIP 是原則(方向),DI 則是實現它的技術(工具)。掌握這個區分,面試回答就能提升一個層次。

實作可以插拔,組裝交給框架處理
實作可以插拔,組裝交給框架處理

SOLID 完整總結

把分成兩篇介紹的 SOLID 濃縮成五行,就是這樣。

原則 一句話總結
SRP 要求修改的人不同,就把程式碼分開
OCP 經常變動的地方,以新增取代修改來應對
LSP 子類別不可破壞父類別的契約
ISP 不要強迫實作永遠用不到的方法
DIP 不要讓商業邏輯依賴細節

五個原則最後都指向同一個方向:縮小變更的影響範圍。

不過,如果把這些原則機械式套用到所有程式碼上,反而會違反 KISS 和 YAGNI。原則是應該挑選實際經常變更的地方來使用的工具。我認為,這種平衡感才是真正的實力。

延伸閱讀