把「操作邏輯」從「資料結構」中完全抽離,讓你在不修改原始類別的前提下隨時加入新的操作。一句話記住:讓操作者去找資料,而不是讓資料知道所有操作——資料類別只需要開一扇門(accept),外來的 Visitor 進門後自己決定要做什麼。
下面兩段程式碼結構一模一樣,只差在呼叫的方法名——這就是問題本身。
// 存檔邏輯 for (Setting s : settings) { if (s instanceof VipSetting) { saveVip((VipSetting) s); } else if (s instanceof PfamSetting) { savePfam((PfamSetting) s); } } // 新增「匯出」功能?整段複製! for (Setting s : settings) { if (s instanceof VipSetting) { exportVip((VipSetting) s); } else if (s instanceof PfamSetting) { exportPfam((PfamSetting) s); } }
if-else 的改動點是乘積——每多一種操作,就要為每個型別各寫一個分支;Visitor 是相加,新增操作只多一個類別。
else if,編譯照樣通過,但執行期靜悄悄跳過——這種不會報錯的 bug 最難察覺。Java 天生只支援單一分派——方法呼叫只根據「呼叫者」的型別決定。Visitor 透過兩次呼叫,讓「呼叫者型別」與「參數型別」都能影響最終執行的方法,這就是零 instanceof 的來源。
點播放,看呼叫如何從 Client 一路跳到精確的 visit(VipSetting)。
s 的執行期實際型別(VipSetting / PfamSetting)選擇要呼叫哪個 accept() 實作。這是標準的 Java 多型行為。accept() 內部,this 的靜態型別就是子類別(VipSetting),Java 的多載解析在編譯期即可精確匹配到 visit(VipSetting)。visitor.visit(s)?因為 s 的靜態型別是基底介面 Setting,而 Java 的多載在編譯期就固定解析,找不到對應的多載。必須靠 this 在子類別內部傳遞精確型別,才能觸發正確的多載。
虛線框是介面,實線框是實作。左邊是資料體系,右邊是操作體系,兩邊只靠 accept/visit 相接。
accept() 呼叫 Visitor 的 visit(this) —— 兩者交互構成雙重分派
accept() 內呼叫 visitor.visit(this) 時,this 的靜態型別是子類別,多載可以精確命中正確的 visit() 多載版本。return visitor.visit(this) 程式碼文字相同,但 this 的靜態型別不同,所以實際觸發的 visit() 多載各不相同。這就是 Visitor Pattern 運作的核心機制。
場景:VipSetting 和 PfamDeleteSetting 兩種設定,需要支援「儲存」與「驗證」兩種操作,未來還可能新增「匯出 CSV」「API 序列化」。以下是四個建立步驟。
宣告每個子類別對應的 visit() 多載。泛型 R 讓儲存回傳 Void、驗證回傳 List<String>,同一個介面通吃不同回傳型別。
public interface SettingVisitor<R> { R visit(VipSetting setting); R visit(PfamDeleteSetting setting); // 新增 Setting 子類別 → 在這裡補一行 // 編譯器會強制所有 Visitor 實作都跟著更新 }
每個子類別的 accept() 程式碼文字完全相同,但 this 的靜態型別不同——這正是雙重分派的核心。
public interface Setting { <R> R accept(SettingVisitor<R> visitor); } public class VipSetting implements Setting { private String tierLevel; private int maxSeats; // getters... @Override public <R> R accept(SettingVisitor<R> visitor) { return visitor.visit(this); // this = VipSetting,型別精確 } } public class PfamDeleteSetting implements Setting { private boolean allowBulkDelete; // getters... @Override public <R> R accept(SettingVisitor<R> visitor) { return visitor.visit(this); // this = PfamDeleteSetting } }
每種操作獨立成類,彼此互不干擾。未來新增「匯出 JSON」只需新增一個類別,其他程式碼完全不動。
// 操作一:儲存 public class SaveVisitor implements SettingVisitor<Void> { private final SeatSettingRepository repo; @Override public Void visit(VipSetting s) { repo.saveVip(s.getTierLevel(), s.getMaxSeats()); return null; } @Override public Void visit(PfamDeleteSetting s) { repo.savePfam(s.isAllowBulkDelete()); return null; } } // 操作二:驗證(只需新增一個類別,零改動原有程式碼) public class ValidateVisitor implements SettingVisitor<List<String>> { @Override public List<String> visit(VipSetting s) { List<String> errors = new ArrayList<>(); if (s.getMaxSeats() <= 0) errors.add("maxSeats 必須大於 0"); return errors; } @Override public List<String> visit(PfamDeleteSetting s) { return List.of(); // 此型別無需驗證 } }
List<Setting> settings = List.of( new VipSetting("GOLD", 4), new PfamDeleteSetting(true) ); // 儲存 SaveVisitor saver = new SaveVisitor(repo); settings.forEach(s -> s.accept(saver)); // 驗證 ValidateVisitor validator = new ValidateVisitor(); List<String> allErrors = settings.stream() .flatMap(s -> s.accept(validator).stream()) .collect(Collectors.toList()); // 未來新增「匯出 JSON」→ 只加 JsonExportVisitor,其他全部不動
Visitor 不是萬用解法,兩欄要一起看:左邊成立的前提,正好是右邊不成立的時候。
| 優點(適合用的情境) | 缺點(應避開的情境) |
|---|---|
| 輕鬆新增操作新功能只需新增一個 Visitor 類別,所有 Element 程式碼完全不動,完美符合 OCP。 | Element 子類別常變動時成本高每新增一個 Setting 子類別,就必須改 Visitor 介面 + 所有 Visitor 實作,改動點反而更多。 |
| 強型別,消滅 instanceof編譯期即可確認你處理了所有子類別,遺漏任何 visit() 就會編譯失敗。 | 可能破壞封裝Visitor 需要讀取 Element 的內部狀態,可能被迫開放原本不該對外公開的 getter。 |
| 讓 Domain Class 保持純粹Setting 只存資料,複雜的業務邏輯(儲存、驗證、序列化)全部交給 Visitor,職責單一清晰。 | 操作種類少時過度設計只有一兩種操作卻引入 Visitor,等於憑空增加介面與類別,把簡單問題複雜化。 |
| Visitor 可以累積狀態Visitor 實例持有欄位,可在遍歷過程中累積中間結果(統計總價、收集錯誤清單等)。 | 泛型語法不直覺SettingVisitor<R> 的泛型宣告對初學者較陌生,還需要理解 Java 的多載解析規則。 |
visit(),遺漏就編譯失敗,安全性最高。這通常是選它最實際的理由。點卡片翻面看答案(共 6 張)