進行 iOS 開發時,常會遇到這種情況:想保留基本功能,只加上記錄或快取,但一開始透過繼承擴充類別,不知不覺間繼承樹就變得一團亂。
持續替網路用戶端加入功能後,很容易不知不覺產生像 CachingLoggingRetryingClient 這種名稱的類別。
今天就來整理能解決這個問題的 Swift 裝飾者模式。這是一種不使用繼承,像洋蔥一樣層層疊加功能的方法。
裝飾者模式會用遵循相同協定的物件包住原始物件,在不修改原始程式碼的情況下逐層加入功能。
先說結論如下。
- 先將共同行為定義為 協定
- 裝飾者遵循該協定,並在 內部保存一個相同的協定型別
- 執行自己的工作(記錄、快取等),其餘交由內部物件 委派
- 依需求持續包裝,功能就會逐層累積
什麼是裝飾者模式?和繼承有什麼不同?
繼承表達「是~的一種」。子類別會繼承父類別的所有內容。
問題在於組合。若用繼承建立具備記錄、快取,以及兩者功能的用戶端,每種組合都會多出一個類別。
裝飾者表達的是「包住~的東西」。
包裝物件與被包裝物件共用 相同的介面,因此從外部看不出差異。不論包了幾層,使用端程式碼都維持不變。
簡單說,繼承在編譯時固定關係,而裝飾者能在執行期間自由組合。
用程式碼實作 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())
)
從內往外看即可。我們先用快取包住網路載入器,再用記錄包住它。
呼叫進來時,流程依序是記錄 → 檢查快取 →(沒有資料時)網路。
想改變順序時,只要調整包裝順序即可。NetworkLoader和 CachingLoader 都不需要修改任何一行程式碼。
不需要的功能,直接移除那一層即可。
繼承和裝飾者,什麼時候該用哪一個?
兩者都不是永遠正確的答案。以下依情境整理。
| 情境 | 建議 |
|---|---|
| 明確的「是~的一種」關係 | 繼承 |
| 自由組合、拆除功能 | 裝飾者 |
| 在執行期間開關功能 | 裝飾者 |
| 無法修改原始程式碼時 | 裝飾者 |
| 組合可能性很多時 | 裝飾者 |
尤其是記錄、快取、重試、加入驗證標頭等 非本質的附加功能,很適合使用裝飾者。
反過來,如果勉強使用裝飾者,包裝層數會增加,除錯時呼叫堆疊也會變深。使用時最好將這點納入考量。
兩個常見問題
Q. struct 和 class 該用哪一個?
如果裝飾者需要直接持有狀態(例如快取),使用 class 會比較方便。單純委派時,struct 就足夠了。上例只將快取設為 class,原因就在此。
Q. 包裝層數增加後,會有效能問題嗎?
呼叫會逐層傳遞,因此確實存在極些微的額外負擔。不過和網路或磁碟 I/O 相比幾乎可以忽略,大多數 App 不需要特別在意。
裝飾者模式歸根究柢就是定義一個協定,建立一個持有相同協定的物件,再透過它進行委派。
下次為了加入一項功能而翻找繼承樹時,不妨想起包上一層的方法。程式碼會輕量許多。推薦直接將今天的範例搬到 Playground 試試看 🙂

