軟體設計

Swift Visitor 模式:需要雙重分派的時機

進行 iOS 開發時,總會遇到需要持續對圖形或節點等多種類型物件加入運算的情況。

閱讀 4 分鐘
Swift Visitor 模式:需要雙重分派的時機 封面圖

進行 iOS 開發時,總會遇到需要持續對圖形或節點等多種類型物件加入運算的情況。

你通常會用 if let、as?逐一檢查型別;每增加一種型別,就得再次修改分支。

本文透過範例整理 Swift Visitor 模式,以及其核心雙重分派究竟何時有必要。

Swift 選擇方法時,只會查看接收器(接收訊息的物件)的動態型別。

當「是哪種圖形」和「是哪種運算」兩個維度同時變動時,單一分派就不夠了。雙重分派會透過兩次呼叫,在執行階段決定兩個型別。


Swift 的方法分派,為什麼只靠一次還不夠?

在 Swift 中呼叫 shape.draw() 時,會執行符合 shape實際型別的 draw()。

這就是單一分派:只根據接收器型別 ****選擇方法。

問題會在運算有多個時出現。

圖形包含圓形、矩形、三角形;運算包含繪製、計算面積、輸出 JSON。兩個維度相乘後,組合數會快速增加。

因此常會寫出這樣的程式碼。

// 每增加一種圖形運算,程式就會持續變長 if-else
func export(_ shape: Shape) -> String {
    if let c = shape as? Circle { return drawCircle(c) }
    if let r = shape as? Rect   { return drawRect(r) }
    return "" // 每次新增圖形,都得再次修改這裡
}

如果新增一個圖形,就得開啟這個函式,再插入一個分支。

有五個運算,就等於有五個這樣的函式。

if-else 累積到這種程度時,我會把它視為一個訊號。
if-else 累積到這種程度時,我會把它視為一個訊號。

什麼時候需要雙重分派?

我的判準很簡單。

當方法要執行的行為同時依賴兩個型別時,就是需要雙重分派的時機。

前面的例子正是如此:要執行的程式碼同時取決於「圖形型別 × 運算型別」。

Swift 的預設分派只查看接收器型別,因此另一個維度只能用 as?這類型別檢查手動處理。

這種手動分支就是 code smell。

雙重分派連第二個型別的決定也交給語言的多載,開發者不必再自行檢查型別。

反過來說,如果只有一個運算,或型別不會再增加,就不必使用這個模式。


Swift Visitor 模式要如何實作?

關鍵是把呼叫分成兩次,所以才稱為雙重分派。

protocol Shape { func accept(_ v: Visitor) -> String }
struct Circle: Shape { func accept(_ v: Visitor) -> String { v.visit(self) } }
struct Rect: Shape   { func accept(_ v: Visitor) -> String { v.visit(self) } }

protocol Visitor {
    func visit(_ c: Circle) -> String   // 圖形型別在這裡決定
    func visit(_ r: Rect) -> String      // 運算則由多載決定
}

一起看看流程。

第一次呼叫 shape.accept(v)會決定 圖形的實際型別。執行階段會選擇呼叫 Circle的 accept,還是 Rect的版本。

第二次呼叫 v.visit(self)時,self的靜態型別已確定為 Circle或Rect。因此能正確選出多載的 visit。

兩次呼叫彼此銜接,便能精確決定兩個型別。

兩次呼叫交會之處:雙重分派
兩次呼叫交會之處:雙重分派

想新增運算時,只要建立一個採用 Visitor 的新結構即可。既有圖形程式碼完全不用修改。


Visitor 模式該不該用?優缺點比較

沒有完美的模式,接著看看取捨。

分類 型別分支(as? / switch) Visitor 模式
新增運算 修改各處的分支 新增一個 Visitor
新增型別 新增一行分支 需要修改所有 Visitor
程式碼可讀性 分支增加後變複雜 依運算清楚分離
初始撰寫成本 低 相對較高

如表所示,Visitor 模式在運算經常增加、型別保持穩定時最能發揮效果。

相反地,如果型別(圖形)持續增加,反而更費工,因為所有 Visitor 都要修改。

accept 再 visit,只要兩次就完成
accept 再 visit,只要兩次就完成

常見問題(Q&A)

Q. Swift 已經有泛型,還需要 Visitor 嗎?

泛型在編譯時確定型別時很強大。但當實際型別在執行階段混雜於[Shape]陣列中時,雙重分派仍能發揮作用。

Q. 用 enum 和 switch 不也可以嗎?

可以。如果型別固定,enum + switch 反而更簡潔。Visitor 更適合運算持續增加的情況。


遇到同時依賴兩個型別的行為時,就是該想起雙重分派與 Swift Visitor 模式的時候。

如果現在有 as?分支不斷增加的程式碼,請參考今天的範例試著重構,結構會更容易維護 🙂

延伸閱讀