軟體設計

Swift 裝飾者模式:不使用繼承層層疊加功能

進行 iOS 開發時,常會遇到這種情況:想保留基本功能,只加上記錄或快取,但一開始透過繼承擴充類別,不知不覺間繼承樹就變得一團亂。

閱讀 4 分鐘
Swift 裝飾者模式:不使用繼承層層疊加功能 封面圖

進行 iOS 開發時,常會遇到這種情況:想保留基本功能,只加上記錄或快取,但一開始透過繼承擴充類別,不知不覺間繼承樹就變得一團亂。

持續替網路用戶端加入功能後,很容易不知不覺產生像 CachingLoggingRetryingClient 這種名稱的類別。

今天就來整理能解決這個問題的 Swift 裝飾者模式。這是一種不使用繼承,像洋蔥一樣層層疊加功能的方法。

裝飾者模式會用遵循相同協定的物件包住原始物件,在不修改原始程式碼的情況下逐層加入功能。

先說結論如下。

  1. 先將共同行為定義為 協定
  2. 裝飾者遵循該協定,並在 內部保存一個相同的協定型別
  3. 執行自己的工作(記錄、快取等),其餘交由內部物件 委派
  4. 依需求持續包裝,功能就會逐層累積

什麼是裝飾者模式?和繼承有什麼不同?

繼承表達「是~的一種」。子類別會繼承父類別的所有內容。

問題在於組合。若用繼承建立具備記錄、快取,以及兩者功能的用戶端,每種組合都會多出一個類別。

裝飾者表達的是「包住~的東西」。

包裝物件與被包裝物件共用 相同的介面,因此從外部看不出差異。不論包了幾層,使用端程式碼都維持不變。

簡單說,繼承在編譯時固定關係,而裝飾者能在執行期間自由組合。


用程式碼實作 Swift 裝飾者模式

我們以建立一個載入資料的 DataLoader 為例。首先是所有物件都要遵循的協定。

protocol DataLoader {
    func load(id: String) -> Data?
}

// 執行實際工作的基本實作
struct NetworkLoader: DataLoader {
    func load(id: String) -> Data? {
        print("向網路發出 \(id) 請求")
        return Data("payload-\(id)".utf8)
    }
}

到這裡都只是一般的協定與實作。

現在是重點。裝飾者遵循 DataLoader,同時在內部保存另一個 DataLoader

// 記錄日誌的裝飾者
struct LoggingLoader: DataLoader {
    let wrapped: DataLoader   // 包裝目標

    func load(id: String) -> Data? {
        print("[記錄] load 開始: \(id)")
        let result = wrapped.load(id: id)  // 將實際工作委派出去
        print("[記錄] load 完成: \(id)")
        return result
    }
}

LoggingLoader只做自己的工作(寫入記錄),真正的載入則交給 wrapped

有了這個結構,就不必在意包住的是什麼,只要是 DataLoader 即可。

只執行自己的工作,其餘委派給內部物件的結構
只執行自己的工作,其餘委派給內部物件的結構

將功能層層疊加

這次再建立一個快取裝飾者,實際把功能疊起來。

final class CachingLoader: DataLoader {
    let wrapped: DataLoader
    private var cache: [String: Data] = [:]

    init(wrapping loader: DataLoader) { self.wrapped = loader }

    func load(id: String) -> Data? {
        if let hit = cache[id] { return hit }        // 快取中有資料就立即返回
        let data = wrapped.load(id: id)              // 沒有就委派
        cache[id] = data
        return data
    }
}

組合的部分正是裝飾者模式的精髓。

let loader = LoggingLoader(
    wrapped: CachingLoader(wrapping: NetworkLoader())
)

從內往外看即可。我們先用快取包住網路載入器,再用記錄包住它。

呼叫進來時,流程依序是記錄 → 檢查快取 →(沒有資料時)網路。

包了幾層,呼叫就會逐層向內傳遞
包了幾層,呼叫就會逐層向內傳遞
從內到外依序疊加網路、快取與記錄
從內到外依序疊加網路、快取與記錄

想改變順序時,只要調整包裝順序即可。NetworkLoaderCachingLoader 都不需要修改任何一行程式碼。

不需要的功能,直接移除那一層即可。


繼承和裝飾者,什麼時候該用哪一個?

兩者都不是永遠正確的答案。以下依情境整理。

情境 建議
明確的「是~的一種」關係 繼承
自由組合、拆除功能 裝飾者
在執行期間開關功能 裝飾者
無法修改原始程式碼時 裝飾者
組合可能性很多時 裝飾者

尤其是記錄、快取、重試、加入驗證標頭等 非本質的附加功能,很適合使用裝飾者。

反過來,如果勉強使用裝飾者,包裝層數會增加,除錯時呼叫堆疊也會變深。使用時最好將這點納入考量。


兩個常見問題

Q. struct 和 class 該用哪一個?

如果裝飾者需要直接持有狀態(例如快取),使用 class 會比較方便。單純委派時,struct 就足夠了。上例只將快取設為 class,原因就在此。

Q. 包裝層數增加後,會有效能問題嗎?

呼叫會逐層傳遞,因此確實存在極些微的額外負擔。不過和網路或磁碟 I/O 相比幾乎可以忽略,大多數 App 不需要特別在意。


裝飾者模式歸根究柢就是定義一個協定,建立一個持有相同協定的物件,再透過它進行委派。

下次為了加入一項功能而翻找繼承樹時,不妨想起包上一層的方法。程式碼會輕量許多。推薦直接將今天的範例搬到 Playground 試試看 🙂

延伸閱讀