Codetopia 海港音樂祭的最後一個音符剛落下,熱情的安可聲還在空中迴盪,但市府的後台已經炸開了鍋。市長的緊急指令透過加密頻道傳來,語氣不容置疑:「十分鐘後,我要在辦公桌上看到三份即時報表!」
哪三份?
① 安全稽核:所有舞台、路障、臨時電纜是否符合安全規範?
② 成本分攤:那些臨時設施的租金和工程人員的加班費,怎麼精確分攤到各個表演區域?
③ 個資遮罩:活動期間登記的志工與 VIP 清單,必須依法規完成去識別化處理,才能歸檔。
Gavin|資產維護工程師,此刻的表情比斷線的伺服器還要凝重。他手上的「城市資產樹」——那個由 舞台 (Stage)
、區域 (Zone)
、路障 (RoadBlock)
、電纜 (Cable)
等元素構成的穩定結構——已經安穩運行好幾個月了。要為這幾十種元素,同時加上三種完全不同的行為邏輯?這意味著什麼?
(旁白內心戲:「這意味著要修改幾十個 class 檔案,然後祈禱CI/CD大神保佑不要改壞任何東西......在十分鐘內?」)
Gavin 的手指已經懸在鍵盤上,準備開始一場史詩級的複製貼上大戰。
就在這時,一個冷靜的聲音從他身後傳來。「Gavin,停下來。別碰那些元素。」
是 Vera|稅務稽核官,她手上拿著一杯咖啡,神情自若。「我們不需要動手術,只需要派幾個『訪客』進去逛一圈就行了。」
「訪客?」Gavin 一臉困惑。
「對,」Vera 推了推眼鏡,「把你的新行為做成『訪客』,讓它們去拜訪你的資產樹。稽核、算錢、打馬賽克......各司其職,互不干擾。資產樹本身,一根毛都不用動。」
砰! 這簡直是黑暗中的一道閃電。在固定結構上,外掛源源不絕的新操作,這不正是應對需求變化的終極戰術嗎?
Visitor (訪客/外掛行為):當一組物件結構相對固定時,可以將作用於這些物件的「新操作」封裝成一個獨立的 Visitor 物件。透過
element.accept(visitor)
這一巧妙的雙重派發 (double dispatch) 機制,我們就能在不修改元素類別的前提下,為它們新增無限的行為。核心精神是:對新增操作開放,對修改元素封閉。(旁批:但請記住,這份契約的代價是,當你需要新增元素型別時,所有現存的 Visitor 族群可能都需要同步擴充!)Element (元素):被訪問的物件,是固定結構中的節點。在今天的故事裡,就是
Stage
、Zone
、RoadBlock
、Cable
等資產。Visitor (訪客):封裝了特定行為的物件,例如
SafetyCheckVisitor
、CostSplitVisitor
、PiiRedactionVisitor
。常見親戚:Visitor 模式常常與 Composite (組合模式) 聯手,用來處理樹狀結構;與 Iterator (迭代器) 合作,統一遍歷路徑;與 Memento (備忘錄) 搭配,為稽核的狀態留下快照。
讓我們倒帶一下,如果當時 Vera 沒有及時出現,Gavin 的「快狠準」方案會把 Codetopia 帶向何方?
Gavin 心一橫,決定在每個元素類別(Stage
, Zone
, RoadBlock
, Cable
...)上,直接新增三個 public 方法:auditSafety()
、calculateCost()
、redactPii()
。看起來很直觀,對吧?
災難就此開始:
違反開放封閉原則:市長隔天又要一份「碳排估算」報表。Gavin 只好含淚加班,再次修改所有的元素類別,新增 estimateCarbonFootprint()
方法。城市資產每多一種,這份修改清單就變得更長。
行為邏輯四散:單單一個「安全稽核」的邏輯,現在像天女散花一樣,分散在十幾個不同的 class 檔案裡。想修改一條稽核規則?祝你在程式碼的汪洋大海中好運。這根本是測試工程師的地獄。
分支地獄:很快地,程式碼裡到處都是 if (element instanceof Stage) { ... } else if (element instanceof RoadBlock) { ... }
的醜陋分支。而這些判斷,本該是由 Visitor 的雙重派發機制優雅地消滅掉的!
驗收時刻:當「碳排估算」的需求真的下來時,Gavin 的分支已經改不動了。整個資產模組的部署被死死卡住,因為每次小修改都可能引發雪崩式的錯誤。市長的報表?大概要等到下個世紀了。
Vera 的提案,正是設計模式中的 Visitor (訪問者模式)。它的核心思想,一句話就能概括:
當資料結構 (元素型別) 趨於穩定,但需要對其定義的新操作 (行為) 卻層出不窮時,就用 Visitor。
對固定結構頻繁新增操作:當你有一個穩定的物件結構(例如:城市資產樹、抽象語法樹 AST、XML/JSON 文件樹),且需要頻繁地在其上執行各種報表生成、驗證、轉換等操作時。
需要將相關行為收斂一處:想把「成本計算」的所有邏輯都放在 CostVisitor
這一個檔案裡,而不是散落在各個 Asset
子類中。這讓邏輯的維護、測試和理解都變得極其容易。
確保元素唯讀與操作併行:一個優良的 Visitor 實作,應只讀取元素狀態,而不修改它。這種「無副作用」的特性,使得我們可以在同一個不可變的資料快照上,安全地併行執行多個不同的訪客,極大地提升效率。(旁批:需要輸出的副作用,如寫檔或呼叫 API,應交給後置的 Exporter 或 Command 物件處理;訪客間若需共享資料,也應透過唯讀的 Context 物件注入,絕不共享可變物件!)
元素型別經常變動:如果你的資產樹三天兩頭就要新增一種元素(例如今天加 無人機燈光秀 (DroneLight)
),那 Visitor 模式會讓你很痛苦。因為每新增一個 Element
子類,你可能就必須去修改所有 Visitor
的介面,為新元素新增一個 visit...()
方法。
只是簡單的策略切換:如果你的問題核心只是在同一個操作中切換不同的演算法(例如計算費用時,分「平日價」和「假日價」),那用更輕巧的 Strategy (策略模式) 就足夠了。
問題核心是多方物件的複雜互動:如果你的場景重點在於協調多個不同物件之間的複雜通訊與流程,避免網狀依賴,那應該交給 Mediator (中介者模式) 來處理。
導播,鏡頭拉一下!讓我們用三個不同的縮放層級,看看 Visitor 模式在 Codetopia 的架構中是如何運作的。
(旁白提示:單趟遍歷的時間複雜度為 O(N×V),其中 N 是節點數,V 是訪客數;空間複雜度為 O(H),H 是樹的高度。)
視角 | 觀念/模式 | 在城市的說法 |
---|---|---|
微觀 (GoF) | Visitor Pattern (雙重派發) | 稽核員 Vera (Visitor) 拜訪各項資產 (Element) |
中觀 (EIP/EDA) | Pipeline & Filters / Itinerary-based Routing | 一個稽核任務 (Message) 帶著行程單,依序通過各個訪客站點 |
宏觀 (MAS) | Agent with specific capability | 稽核總代理 (Vera) 動態指派一組專職訪客代理去執行任務 |
微觀 (GoF)|UML 類別圖 (Visitor 的雙重派發結構)
中觀 (EIP/EDA)|一次運行多個訪客的稽核流程
Vera 迅速在白板上寫下一段 Python 程式碼,展示了她的「訪客」提案如何落地。
(旁白提示:此版本將遍歷責任外移到 run_audit_one_pass 這個 Runner 函式中,元素的 accept() 方法僅專注於雙重派發這一件事。請勿在 accept() 內再遞迴呼叫子節點,以免重複拜訪!)
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Protocol
from decimal import Decimal, ROUND_HALF_UP
# --- 1) 元素階層(這部分結構是固定的,不再輕易改動) ---
class Visitor(Protocol):
"""定義訪客的介面,每個具體元素都有一個對應的 visit 方法"""
def visit_stage(self, e: "Stage"): ...
def visit_zone(self, e: "Zone"): ...
def visit_roadblock(self, e: "RoadBlock"): ...
def visit_cable(self, e: "Cable"): ...
class Asset(ABC):
"""所有資產元素的抽象基底,只定義一個 accept 方法"""
@abstractmethod
def accept(self, v: Visitor): ...
@dataclass
class Stage(Asset):
name: str
zones: List["Zone"] = field(default_factory=list)
def accept(self, v: Visitor):
v.visit_stage(self)
@dataclass
class Zone(Asset):
name: str
items: List[Asset] = field(default_factory=list)
volunteers: List[str] = field(default_factory=list) # 假設這裡含有個資
def accept(self, v: Visitor):
v.visit_zone(self)
@dataclass
class RoadBlock(Asset):
id: str
rented_hours: int
staff_count: int
def accept(self, v: Visitor):
v.visit_roadblock(self)
@dataclass
class Cable(Asset):
id: str
meters: int
amperage: int
def accept(self, v: Visitor):
v.visit_cable(self)
# --- 2) 訪客家族:所有的新增行為都收斂在這裡! ---
class BaseVisitor(ABC):
"""提供一個 no-op (無操作) 的基底,讓具體訪客只需覆寫關心的方法"""
def visit_stage(self, e: Stage): pass
def visit_zone(self, e: Zone): pass
def visit_roadblock(self, e: RoadBlock): pass
def visit_cable(self, e: Cable): pass
class SafetyCheckVisitor(BaseVisitor):
"""安全稽核訪客:只關心路障和電纜"""
def __init__(self): self.issues = []
def visit_roadblock(self, e: RoadBlock):
# 根據工安標準,每個路障至少需配置 2 名人員
if e.staff_count < 2:
self.issues.append(f"RoadBlock {e.id}: 人員配置不足 ({e.staff_count} < 2)")
def visit_cable(self, e: Cable):
if e.amperage > 32:
self.issues.append(f"Cable {e.id}: 電流過載 ({e.amperage}A > 32A)")
class CostSplitVisitor(BaseVisitor):
"""成本分攤訪客:眼中只有錢"""
def __init__(self):
self.total_cost = Decimal("0")
def visit_roadblock(self, e: RoadBlock):
self.total_cost += Decimal("200.0") * e.rented_hours
def visit_cable(self, e: Cable):
self.total_cost += Decimal("5.5") * e.meters
class PiiRedactionVisitor(BaseVisitor):
"""個資遮罩訪客:只關心志工名單"""
def __init__(self): self.redacted_count = 0
def visit_zone(self, e: Zone):
# 在真實世界,這裡會執行遮罩邏輯;此處簡化為計數
self.redacted_count += len(e.volunteers)
class CarbonFootprintVisitor(BaseVisitor):
"""碳排估算訪客:練習題的參考解答"""
def __init__(self): self.co2_kg = Decimal("0")
def visit_roadblock(self, e: RoadBlock): # 運輸&待機
self.co2_kg += Decimal("2.5") * e.rented_hours
def visit_cable(self, e: Cable): # 用電近似
self.co2_kg += Decimal("0.02") * e.meters
# --- 3) 執行器:單趟遍歷,一次執行多個訪客,效能 UP! ---
def run_audit_one_pass(root: Asset, visitors: List[Visitor]):
"""使用顯式的迭代器(此處用堆疊模擬),單趟走完資產樹"""
stack = [root]
while stack:
current_asset = stack.pop()
for v in visitors:
current_asset.accept(v)
# 這裡的 isinstance 僅用於 Composite 結構的向下展開,
# 行為的差異則完全交給 Visitor 的雙重派發,避免了 if/elif 分支地獄。
if isinstance(current_asset, Stage):
# 為確保輸出可重現,固定使用 LIFO+反向壓棧,得到左到右的拜訪順序
stack.extend(reversed(current_asset.zones))
elif isinstance(current_asset, Zone):
stack.extend(reversed(current_asset.items))
def run_festival_audit(root: Stage):
# 建立訪客實例
visitors = [
SafetyCheckVisitor(),
CostSplitVisitor(),
PiiRedactionVisitor(),
CarbonFootprintVisitor()
]
# 執行單趟稽核
run_audit_one_pass(root, visitors)
safety_v, cost_v, pii_v, carbon_v = visitors
# 彙整結果。邏輯層保留 Decimal,呈現層才做格式化
cost = cost_v.total_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
co2 = carbon_v.co2_kg.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return {
"safety_report": safety_v.issues,
"cost_report": f"${cost}", # 幣別與稅率口徑由訪客內部策略決定
"pii_report": f"{pii_v.redacted_count} items redacted",
"carbon_report": f"{co2} kg CO2e"
}
# 建立一個模擬的資產樹
if __name__ == "__main__":
# 建立一個模擬的資產樹(僅示範;測試時不會自動執行)
main_stage = Stage("主舞台", zones=[
Zone("搖滾區", items=[
RoadBlock("RB-01", rented_hours=8, staff_count=3),
Cable("C-01", meters=100, amperage=30)
], volunteers=["Ava", "Bob"]),
Zone("後台區", items=[
RoadBlock("RB-02", rented_hours=12, staff_count=1), # <-- 安全問題
Cable("C-02", meters=50, amperage=40) # <-- 安全問題
], volunteers=["Charlie"])
])
# 執行稽核!
reports = run_festival_audit(main_stage)
print(reports)
元素狂長症:當你發現自己新增 Element
子類的頻率遠高於新增 Visitor
時,這是一個強烈的訊號,表示你的抽象可能選錯了,Visitor 模式正在成為你的負擔。
肥胖訪客:一個 Visitor 類別同時負責驗證、計算、寫入檔案、發送通知......這違反了單一職責原則。應該將它們拆分為「純計算的 Visitor」和處理副作用的「Command 物件」或輸出器。
濫用 RTTI:如果在 Visitor 的方法中還看到大量的 isinstance()
或 typeof
檢查來決定行為,那表示你沒有完全理解雙重派發的精髓,模式的優雅之處被破壞了。
跨訪客秘密通訊:如果 Visitor A 需要偷偷讀取 Visitor B 的內部狀態才能工作,這是一個危險的設計。應該透過一個共享的、明確的 Context
或 ReportDTO
物件來傳遞資訊,而不是讓訪客之間產生隱晦的耦合。
動手實作:市長的新需求來了——「碳排估算報表」。請你新增一個 CarbonFootprintVisitor
,規則自訂(例如:舞台耗電、路障運輸會產生碳排)。關鍵挑戰是:你不准修改任何 Asset
相關的類別!(提示:解答已包含在範例程式碼中。)
情境思考:在 3) 笑中帶淚
的故事中,如果可憐的 Gavin 已經把 auditSafety()
等方法直接加到了元素類別上,現在 Vera 要來拯救他。你會建議他如何一步步地將程式碼重構 (Refactor) 到正確的 Visitor 模式?第一步該做什麼?
小投票:如果現在又有一個需求,是要在稽核過程中「儲存稽核結果到不同的格式 (JSON, XML, CSV)」。你會選擇:
A. 讓每個 Visitor (Safety, Cost) 自己負責三種格式的輸出?
B. 再新增三個 JsonOutputVisitor, XmlOutputVisitor, CsvOutputVisitor,讓它們去訪問「報表物件」?
請在心裡選擇 A 或 B,並思考你的理由。
為我們的稽核流程加上一個簡單的保護網是很有必要的。這裡是一個使用 pytest
的極簡範例,確保我們的安全規則有被正確觸發:
# test_audit.py
# (假設上面的程式碼存在名為 festival_audit 的檔案中)
from festival_audit import run_festival_audit, Stage, Zone, RoadBlock, Cable
def test_safety_rules_for_insufficient_staff():
"""測試:當路障人員不足時,應產生安全警示"""
# 建立一個有問題的資產樹
test_stage = Stage("測試舞台", zones=[
Zone("問題區", items=[
RoadBlock("RB-99", rented_hours=1, staff_count=1) # 人員不足
])
])
reports = run_festival_audit(test_stage)
# 斷言:安全報告中應包含關於 "RB-99" 的警示
assert any("RB-99" in issue for issue in reports["safety_report"])
今天的 Visitor 模式看似只解決了物件層級的問題,但將視角拉高,它其實是更宏大架構思想的縮影:
與 Memento (備忘錄):在執行任何稽核 Visitor 之前,先對整個資產樹使用 Memento 模式創建一個不可變的快照 (snapshot_id = snapshot_service.freeze(main_stage)
)。將這個 ID 傳給所有訪客 (v.set_snapshot_id(snapshot_id)
),可以確保每次稽核的結果都基於同一個基準點,完全可重現、可追溯,為下一章的故事完美鋪陳。(旁批:快照應以不可變資料結構,如 frozen dataclass
或序列化後的 blob 保存,避免審計期間因共享物件被改動而失真。)
Pythonic 訪客:在 Python 的生態系中,除了傳統的 Protocol
介面,我們還有更靈活的選擇。例如,我們可以不定義固定的 Visitor
介面,而是透過命名約定來動態分派。這只需一行程式碼就能實現:getattr(visitor, f'visit_{type(element).__name__.lower()}', lambda e: None)(element)
。這種方式在新增元素時,只需為關心的訪客添加對應方法即可,無需驚動整個訪客家族。
升維至多代理系統 (MAS):在更宏觀的視角下,Vera 可以扮演一個 AuditorAgent
的角色。當接到任務時,她不是親自執行,而是去城市的「黃頁服務 (Directory Facilitator)」查詢所有已註冊的、具備稽核能力的 VisitorAgent
,然後將任務並行派發給它們,最後再彙整結果。
九分鐘後,四份完美格式化的報表準時出現在市長的桌上。Gavin 看著 Vera,眼神充滿了敬佩。今天,他們不僅解決了一場危機,更為 Codetopia 的架構武器庫中,增添了一件強大的兵器。
今日核心:固定結構、外掛行為——Visitor 讓變化不再是惡夢。
稽核報表是搞定了,但如果跑完報表才發現,其中一個步驟的資料來源是錯的,整個流程都想反悔,該怎麼辦?難道只能手動回滾嗎?
別擔心!明天,我們將拜訪 Codetopia 最神秘的機構——「時光局 (Memento)」,學習如何優雅地存檔與還原,讓我們擁有穿梭時空、一鍵回溯的超能力!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌─────────────────────────────────────────────────────────────────────┐
│ Visitor Pattern │
└─────────────────────────────────────────────────────────────────────┘
Asset Visitor
┌──────────────┐ ┌─────────────────┐
│ <<interface>>│ │ <<interface>> │
│ │ │ │
│ +accept(v) │ │ +visit_stage() │
└──────┬───────┘ │ +visit_zone() │
│ │ +visit_roadb..()│
│ │ +visit_cable() │
┌─────┴─────┐ └────────┬────────┘
│ │ │
┌───▼───┐ ┌───▼───┐ │
│ Stage │ │ Zone │ ┌───────┴───────┐
│ │ │ │ │ │
└───┬───┘ └───┬───┘ ┌─────▼──────┐ ┌─────▼──────┐
│ │ │Safety Check│ │ Cost Split │
│ ┌────▼─────┐ │ Visitor │ │ Visitor │
│ │RoadBlock │ └────────────┘ └────────────┘
│ └──────────┘ │ │
│ ┌──────▼──────┐ ┌─────▼──────┐
┌───▼───┐ │PII Redaction│ │ Carbon │
│ Cable │ │ Visitor │ │ Footprint │
│ │ └─────────────┘ │ Visitor │
└───────┘ └────────────┘
雙重派發流程:
asset.accept(visitor) ──→ visitor.visit_xxx(asset)
│ │
└─── 第一次派發 └─── 第二次派發
(根據 asset 類型) (根據 visitor 類型)
┌─────────────────────────────────────────────────────────────────────┐
│ 稽核流程:單趟遍歷多訊客 │
└─────────────────────────────────────────────────────────────────────┘
AuditRunner AssetIterator SafetyCheck CostSplit PiiRedaction
│ │ │ │ │
│ 遍歷所有資產 │ │ │ │
├──────────────→ │ │ │ │
│ │ │ │ │
│ ╔═══════════════════════════════════════════════════════════╗
│ ║ For each asset ║
│ ╚═══════════════════════════════════════════════════════════╝
│ │ │ │ │
│ ←─────────────┤ asset │ │ │
│ │ │ │ │
│ accept(SafetyCheck) │ │ │
├──────────────────────────────→ │ │
│ │ │ │ │
│ accept(CostSplit) │ │ │
├─────────────────────────────────────────→ │ │
│ │ │ │ │
│ accept(PiiRedaction) │ │ │
├────────────────────────────────────────────────────────→ │
│ │ │ │ │
│ ╔═══════════════════════════════════════════════════════════╗
│ ║ Loop End ║
│ ╚═══════════════════════════════════════════════════════════╝
│ │ │ │ │
│ 彙整三份報表 │ │ │ │
├──────────→ │ │ │ │
│ │ │ │ │
優勢:
✓ 單次遍歷 O(N) - 高效能
✓ 訊客互不干擾 - 並行友善
✓ 新增行為無需改動原結構
┌─────────────────────────────────────────────────────────────────────┐
│ Codetopia 資產樹 │
└─────────────────────────────────────────────────────────────────────┘
🎪 主舞台 (Stage)
│
┌───────────────┴───────────────┐
│ │
🎵 搖滾區 (Zone) 🎭 後台區 (Zone)
│ │
┌────┴────┐ ┌────┴────┐
│ │ │ │
🚧 RoadBlock 📡 Cable 🚧 RoadBlock 📡 Cable
RB-01 C-01 RB-2 C-02
8hrs,3人 100m,30A 12hrs,1人⚠️ 50m,40A⚠️
志工名單: 志工名單:
• Ava, Bob • Charlie
稽核結果展示:
┌─────────────────────┬─────────────────────────────────────────┐
│ 稽核項目 │ 結果 │
├─────────────────────┼─────────────────────────────────────────┤
│ 🔒 安全稽核 │ • RB-02: 人員配置不足 (1 < 2) │
│ │ • C-02: 電流過載 (40A > 32A) │
├─────────────────────┼─────────────────────────────────────────┤
│ 💰 成本分攤 │ $1,875.00 │
├─────────────────────┼─────────────────────────────────────────┤
│ 🔒 個資遮罩 │ 3 items redacted │
├─────────────────────┼─────────────────────────────────────────┤
│ 🌱 碳排估算 │ 33.0 kg CO2e │
└─────────────────────┴─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 🚩 反模式紅旗 │
└─────────────────────────────────────────────────────────────────────┘
❌ 錯誤做法:在每個 Asset 類別直接加方法
Stage Zone RoadBlock
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│+auditSafety │ │+auditSafety │ │+auditSafety │
│+calcCost │ ──→ │+calcCost │ ──→ │+calcCost │
│+redactPii │ │+redactPii │ │+redactPii │
│+carbonfPrint│ │+carbonfPrint│ │+carbonfPrint│
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
邏輯分散 測試困難 違反 OCP
✅ 正確做法:Visitor 模式集中行為
Asset樹 (不變) ←→ Visitor群 (可擴充)
┌─────────────────┐ ┌─────────────────┐
│ 🎪 Stage │ │ SafetyCheck │
│ ├── 🎵 Zone │ ──── accept() ───→ │ CostSplit │
│ │ ├── 🚧 RB │ │ PiiRedaction │
│ │ └── 📡 C │ │ CarbonFootprint │
└─────────────────┘ └─────────────────┘
固定結構 彈性行為
好處:
✓ 新增行為不動原結構 ✓ 邏輯集中易維護
✓ 單一職責原則 ✓ 開放封閉原則
✓ 並行執行安全 ✓ 測試覆蓋容易