IThome 鐵人賽
設計模式
Prototype
Creational Patterns
Codetopia
Codetopia 的清晨六點,城市還在沉睡,但 **樣板局(Prototype Office)**早已燈火通明。
Chloe|樣板管理員的咖啡剛泡好,螢幕上就跳出了今日的挑戰:27 份「社會住宅標準戶型」與 12 份「共享商辦樓層」的緊急申請案。這些需求乍看之下大同小異,細看卻各有玄機——大約 80% 的結構完全相同,剩下那 20% 則需要根據地段、法規進行客製化微調。
如果每一份都從零開始畫設計圖、跑模擬、做驗算......那不僅是初始化成本高到市長會來關切,過程中任何微小的人為失誤,風險都會被無限放大。
Chloe 的方案向來乾脆俐落。她指著辦公室牆上掛著的數十份「核准藍本」說:「不用重新發明輪子。從藍本庫複製一份,再把差異化條款套上去,一鍵就能生成一份可直接送審的新個體。」
聽起來很完美,對吧?(旁白低語:故事如果這麼順利,我就沒戲唱了。)
關鍵的挑戰,也是今天我們要一次踩完、一次填平的坑,全都藏在「複製」這個動作裡:
深拷貝 vs. 淺拷貝:到底該複製到多深?一不小心,就會變成「改了A,B也跟著壞」的靈異事件。
外部資源處理:設計圖引用的外部檔案、網路連線怎麼辦?直接複製句柄,就像給兩戶人家同一把鑰匙,災難的開始。
唯一識別重編:複製出來的新戶型,總得有個新門牌號碼吧?忘了重編,資料庫就要天下大亂了。
(是的,複製貼上,從來就不是簡單的 Ctrl+C、Ctrl+V。)
小小回扣:昨天 Day 5 的都更署靠著 Builder 模式,專注於「分步施工、流程固定」的複雜建案。而今天的樣板局,走的不是一步步蓋的路線,而是先複製一戶精裝修的標準樣品屋,再進場做細節微調。這兩種模式,針對的工程壓力與場景,截然不同。
🧭 術語卡(今日導航)
Prototype (GoF):以 clone()
方法產生新物件,藉此避免昂貴的初始化過程,並讓呼叫端與具體類別解耦。
Deep vs Shallow Copy:深拷貝會遞迴複製所有可變的成員物件,創造一個完全獨立的新個體;淺拷貝則只複製參考(指標),新舊個體會共享內部的可變物件。
Template Message / Config Snapshot (EIP/EDA):在中觀層面,將原型視為一個「事件模板」或「設定快照」,以此為基礎派生出具體的事件或任務。
Spawn from Blueprint (MAS):在宏觀層面,由一個「代理藍本」來 spawn
(派生)出多個擁有相同初始能力與信念的現場代理。
故事很快就出現了轉折。樣板局的客服電話被打爆了:
「喂?我們是 A 區的社宅案,怎麼回事?隔壁 B 區的建案一修改高度限制,我們家的戶型圖也跟著變矮了?!」
讓我們把時間倒帶,看看災難是如何發生的。原來,Chloe 的一位新同事為了求快,直接用了淺拷貝來複製「藍本A」。這份藍本裡的 rules
欄位,是一個共享的可變串列(list)。
後果:
狀態污染:A 案的工程師修改了 rules
,因為是共享的,B 案的 rules
也立刻被污染了。
資源競爭:更糟的是,有人連檔案句柄(file handle)也一起複製了。兩份設計圖搶著寫入同一個暫存檔,最後在驗收系統直接炸裂,上演「檔案已關閉」的戲碼。
ID 撞號:clone
完忘了重編唯一的戶型 ID,導致兩份戶型圖用同一個 ID 寫入城市的資料湖,後來的資料直接覆蓋了前者,造成資料品質稽核報告上整片怵目驚心的紅字。
這就是典型的「複製貼上」變「複製災難」。我們來看看這段充滿壞味道的程式碼:
import copy, uuid, tempfile, os, requests
class Plan:
def __init__(self, rules, id=None, f=None, sess=None, kind="A"):
self.rules = list(rules)
self.id = id or str(uuid.uuid4())
self.f = f # 外部資源:檔案句柄
self.sess = sess # 外部資源:網路連線 Session
self.kind = kind
# ❌ 壞味道 1:淺拷貝,連帶複製了 ID 和外部資源句柄
def clone_bad(self):
return copy.copy(self)
# ❌ 壞味道 2:在 clone 方法裡塞滿了業務邏輯分支
def clone_branch_bad(self):
p = Plan(self.rules, self.id, self.f, self.sess, self.kind)
if p.kind == "A":
p.rules.append({"k": "a_only", "v": "x"})
elif p.kind == "B":
p.rules += [{"k": "b1", "v": "y"}]
return p
# --- 災難現場重現 ---
# 準備一個包含可變規則、檔案句柄、HTTP Session 的基礎藍本
tmp = tempfile.NamedTemporaryFile(delete=False)
base = Plan(
[{"k": "height_max", "v": "10"}],
f=tmp,
sess=requests.Session(),
kind="B"
)
# ① 互相污染:a 和 base 共享同一個 rules list
a = base.clone_bad()
a.rules.append({"k": "noise", "v": "<=50"})
print(f"淺拷貝後,base 的 rules 也被污染了: {base.rules}")
# ② 資源競爭/雙重關閉:a 和 base 操作同一個檔案
a.f.write(b"a_writes\n")
base.f.write(b"b_writes\n")
a.f.close()
try:
base.f.write(b"boom\n")
except Exception as e:
print(f"外部資源競爭,base 想寫入已關閉的檔案: {e}")
# ③ ID 撞號覆蓋:a 和 base 的 ID 完全相同
db = {base.id: base}
db[a.id] = a
print(f"資料筆數 (因為 ID 撞號被覆蓋): {len(db)}")
# ④ clone 內含分支,職責不清
b = base.clone_branch_bad()
print(f"分支膨脹,clone 方法被迫了解業務細節: {b.kind}, rules count: {len(b.rules)}")
# ⑤ 濫用 deepcopy,連第三方資源也想硬拷,直接拋出異常
try:
copy.deepcopy(base)
except Exception as e:
# 註記:此處行為視環境而異,但無論是否拋錯,都不應 deepcopy 任何外部連線資源。
print(f"深拷貝不當,無法複製網路 Session: {e}")
os.unlink(tmp.name)
✅ 正解方向:
clone
的職責應該非常單純——只做「資料結構的選擇性深拷貝」。外部資源由專門的 Provider 或工廠重新取得;唯一 ID 由下游系統或 ID 工廠重新編配;而差異化微調的策略,應該從clone
方法中移出去。
在 Chloe 清理完現場後,她召集了所有同事,在白板上寫下了 Prototype 模式的真正精神。
定義:
Prototype 模式,是透過一個「藍本註冊表(Prototype Registry)」取得樣板物件,對樣板呼叫 clone()
方法來產生一個新的個體,最後再對新個體套用所需的「差分(overrides)」。它的核心價值在於,可以大幅降低昂貴的初始化成本(例如:載入複雜的物件圖、大量的 I/O 開銷、耗時的計算預熱),同時也降低了呼叫端對具體類別的耦合。
✅ 當物件的初始化成本非常高昂,或其內部結構(物件圖)非常複雜,而新個體大多是基於一個標準樣板進行微調時。
✅ 當你需要避免呼叫端直接 new
一個具體類別時。改由一個註冊表,透過 registry.get("key").clone()
的方式來取得新物件,更具彈性。
✅ 當希望將系統的「缺省狀態」明確化,並以此作為政策或配置快照的基線時。藍本本身就是一份完美的「預設值」文件。
⛔ 當物件牽涉到外部資源句柄或唯一性識別(如 socket、file descriptor、GUID)時。請不要直接複製它們!這些應該改由工廠模式重新申請與配置,以確保資源的正確管理與唯一性。
⛔ 當複製一個物件的成本,其實比重新創建一個還要高的時候。或者,當物件的生命週期與擁有權歸屬不清楚時,直接複製很容易釀成「共享可變狀態」的災難。
⛔ 當 clone()
之後,總是需要接上大量、複雜的條件分支來進行微調時。這意味著差分邏輯可能比 clone
本身還複雜,更適合改用 Strategy 或 Decorator 模式,或將差分策略外移處理。
Builder:專注於分步構建的過程。它的主角是 Director(定義流程)和 Builder(定義工法),適合處理「流程固定,但表現多樣」的複雜物件。
Prototype:專注於快速複製標準戶。它的流程不是主角,差分微調才是。它假設已經有一個「完整」的樣板存在。
導播, 鏡頭拉一下!讓我們從三個不同的尺度,看看「複製藍本」這件事在 Codetopia 是如何運作的。
如何閱讀:先看微觀層面,一個類別是如何實現 clone
的;再拉到中觀,看看一個「模板事件」是如何派生出具體事件並被派送出去的;最後到宏觀層面,看看一個「代理藍本」是如何派生出多個前線代理,投入城市協作的。
視角 | 觀念/模式 | 在城市的說法 |
---|---|---|
微觀 (GoF) | Prototype :以 clone 產生新物件 |
樣板局的「建築藍本」,可被複製與微調 |
中觀 (EIP/EDA/Actor) | Template Message / Config Snapshot |
市府發布「事件模板」,各單位填入細節後成為具體事件 |
宏觀 (MAS) | Spawn from Blueprint |
由「巡檢代理藍本」 spawn 出多個前線代理 |
這是在 Chloe 指導下,同事重構後的程式碼。它清晰地展示了 Prototype 模式的正確實踐方式。
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Dict, List, Protocol, runtime_checkable
# 定義一個 Prototype 應該要有的行為契約
@runtime_checkable
class Prototype(Protocol):
def clone(self) -> "Prototype": ...
@dataclass
class Rule:
key: str
value: str
# 一個具體的藍本類別,它必須能自我複製
@dataclass
class ZoningPlan(Prototype):
district: str
rules: List[Rule] = field(default_factory=list)
# 範例:外部資源只保留 ID,不複製句柄
# attachment_id: str | None = None
def clone(self) -> "ZoningPlan":
# 關鍵:對內部可變的資料結構(如 list of objects)做「深拷貝」
# 確保複製出來的新個體,其內部狀態是完全獨立的
return ZoningPlan(district=self.district, rules=deepcopy(self.rules))
# 藍本註冊表,用來管理所有可供複製的樣板
class PrototypeRegistry:
def __init__(self) -> None:
self._store: Dict[str, Prototype] = {}
def register(self, key: str, proto: Prototype) -> None:
self._store[key] = proto
def get(self, key: str) -> Prototype:
# 這裡回傳的是藍本自身,呼叫端需自行 clone
return self._store[key]
# --- 正確的使用情境 ---
# 1. 建立並註冊一個基礎藍本
base_plan = ZoningPlan("A", [Rule("height_max", "10"), Rule("green_ratio", ">=30%")])
registry = PrototypeRegistry()
registry.register("Plan#A", base_plan)
# 2. 從註冊表取得藍本,並 clone 出一個新個體
copy1: ZoningPlan = registry.get("Plan#A").clone() # type: ignore
# 3. 對新個體進行微調,這完全不會影響到原始藍本
copy1.rules.append(Rule("noise_max", "<=50dB"))
# 4. 處理外部資源:交給專門的 Provider
# attachment = FileProvider.fetch(copy1.attachment_id)
# 5. 重新編配唯一識別(例如:由下游系統或 ID 工廠負責)
# new_id = id_factory.generate()
# copy1.id = new_id
# 設計語義:clone 的職責是「結構複製,且不含外部資源句柄」;
# 唯一識別的賦予、外部資源的取得,都交給下游的專職工廠或 Provider。
# --- 結構分離的快速驗證 ---
from copy import deepcopy
assert base_plan.rules is not copy1.rules
_bak = deepcopy(base_plan.rules)
copy1.rules[0].value = "999"
assert base_plan.rules == _bak
print("✅ 結構分離驗證通過:修改複製品不會影響原始藍本。")
動手做:請為 ZoningPlan
新增一個巢狀的 amenities: List[Amenity]
屬性(Amenity
也是一個 class),並修改 clone
方法以實作真正的深拷貝,最後寫一個分離性測試來驗證修改 copy1.amenities[0]
不會影響到 base_plan.amenities[0]
。
設計題:請設計一個 PrototypeRegistry
,它要支援兩種取得藍本的方式:一種是「快照凍結」(拿到藍本被註冊那一刻的狀態),另一種是「增量覆蓋」(拿到藍本後,再套用一個 delta 更新檔)。比較這兩種做法在效能與記憶體上的取捨。
判準題:請列出 5 條你認為應該「立即改用工廠模式,而不是繼續用 clone」的硬性規範。(例如:當物件包含網路連線時、當物件需要唯一序號時...)
🚩 淺拷貝共享可變狀態:只要看到 copy.rules is base.rules
為 True
,警報就該響了。這意味著兩邊互相污染只是時間問題。
🚩 複製外部資源句柄:clone()
方法的實現中,若看到直接複製檔案、Socket 或資料庫連線等欄位,這是一個巨大的風險信號,可能導致資源競爭或雙重關閉。
🚩 clone
後識別未重編:複製出來的多份個體,若其 ID 或唯一鍵完全相同,這將會污染資料湖,是資料品質的殺手。
🚩 把大量條件分支塞進 clone()
:如果 clone()
方法裡充滿了 if-elif-else
來處理不同型別的特例,代表它的職責太重了,違反了開放封閉原則。
面對「需要大量相似個體,但又有少量變體」的場景,你的直覺會是?
A. 先建立一個 PrototypeRegistry,把標準樣板管起來。
B. 先做一個「參數化的工廠函式」,用參數來控制變體。
請在留言區選擇 A 或 B,並用一句話說明你的理由!
EIP/EDA (中觀):在事件驅動架構中,我們可以將藍本視為 Template Message 或 Config Snapshot。一個上游事件可以只是一個模板,下游的消費者接收後,根據自身情境 clone
並填入具體欄位,生成一個全新的、可執行的事件。
Actor/MAS (宏觀):在多代理系統中,這對應著 spawn from blueprint 的概念。一個「藍本代理」持有著標準的能力、信念與初始狀態。當需要部署新的現場代理時,直接從藍本 spawn
出來,確保了所有新代理都有一致的「出廠設定」。
與 Day 5 的協作:當城市需要大量製造相似的元件,並且這些元件還需要「依循相同的流程進一步加工」時,Prototype 和 Builder 可以組成一條高效的生產線。先用 Prototype 快速「產生量」,再把這些半成品交給 Builder 進行「分步深化」。
讓我們用一套嚴格的驗收腳本,來證明 Chloe 的新流程徹底解決了最初的災難。這套腳本必須驗證:結構的分離性、外部資源的解耦,以及唯一識別的重編。
驗收條件:
派生戶型必須與原始藍本不共享任何可變的 rules
物件。
clone()
的產物不得攜帶任何外部資源句柄(如檔案、Socket)。
派生戶型最終必須擁有一個新的唯一識別(由下游系統編配)。
# --- 驗收腳本 ---
# (接續 6. 最小實作 的 context)
# 驗收 1:分離性 - 記憶體位址不同
assert base_plan.rules is not copy1.rules, "驗收失敗:淺拷貝,共享了同一個 rules 物件!"
# 驗收 2:分離性 - 內容各自獨立
original_rules_copy = deepcopy(base_plan.rules)
copy1.rules[0].value = "999" # 修改複製品
assert base_plan.rules == original_rules_copy, "驗收失敗:深拷貝不完全,修改複制品影響了原始藍本!"
# 驗收 3:外部資源解耦 (示意)
# 假設 clone() 後的物件不應包含 session 屬性
# assert not hasattr(copy1, "session"), "驗收失敗:clone() 攜帶了外部資源句柄!"
print("\n[驗收通過] ✅ 所有複製個體均通過分離性與資源解耦測試!")
# 下游系統再進行 ID 編配與資料登錄...
Chloe 的團隊導入這套流程後,27+12 份設計案在半小時內全數生成並通過驗收,再也沒有發生過「改A壞B」的靈異事件。
為了確保這套複製流程的長期穩定,我們需要建立對應的自動化測試。
分離性測試 (Isolation Test):這是最重要的測試。當你修改 copy1.rules[0].value
時,必須斷言 base_plan.rules[0].value
維持不變。這能確保深拷貝的正確性,特別是在處理巢狀物件時。
註冊表契約測試 (Registry Contract Test):驗證 registry.get(key).clone()
絕不會回傳一個與註冊表內藍本共享任何可變結構的物件。clone
是契約的一部分,必須被遵守。
資源規範測試 (Resource Policy Test):透過一個假的 Provider (Fake/Mock) 來驗證 clone()
的產物不會攜帶任何外部資源句柄。例如,斷言 clone
後的物件上找不到 db_connection
或 file_handle
這類屬性。
二十字摘要:以藍本複製勝於創建;深拷貝防共享,外部資源改由工廠重取。
Chloe 靠著對 Prototype 模式的深刻理解,不僅解決了眼前的危機,還為樣板局建立了一套更穩健的工作流程。但城市的挑戰永無止境,明天,我們將走進城市的「轉接站」,看看工程師們是如何讓那些古老的系統不需修改一行程式碼,也能完美接上最新的介面,敬請期待 Day 7|Adapter:轉接站,讓老系統舊瓶裝新酒!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
╔═════════════════════════════╗
║ 🎯 <<interface>> ║
║ Prototype ║
║ ║
║ ➕ clone(): Prototype ║
╚═════════════╤═══════════════╝
│ implements
▼
╔═════════════════════════════════════════╗
║ 🏢 ZoningPlan ║
╠═════════════════════════════════════════╣
║ 🏷️ district: str ║
║ 📋 rules: Rule[] ║
║ 📎 attachments: bytes? ║
╠═════════════════════════════════════════╣
║ ➕ clone(): ZoningPlan ║
╚═════════════════════════════════════════╝
▲
│ stores
╔═════════════╧═══════════════════════════╗
║ 📚 PrototypeRegistry ║
╠═════════════════════════════════════════╣
║ ➕ register(key: str, ║
║ proto: Prototype) ║
║ ➕ get(key: str): Prototype ║
╚═════════════════════════════════════════╝
💡 重點提醒:
• ZoningPlan 對 rules 做深拷貝 🔄
• 外部資源不複製句柄,只複製快照 📸
• 或改以工廠重新取得 🏭
👨💼 規劃師 📚 藍本註冊表 📤 事件發布器
║ ║ ║
║ ║ ║
║═══❶ get("Plan#A") ══▶║ ║
║◀══════ proto ════════║ ║
║ ║ ║
║══❷ clone() ═════════▶║ ║
║◀══════ copy ═════════║ ║
║ ║ ║
║══❸ apply(overrides) ═══════════════════════▶║
║ ║ ║
║══❹ publish(PlanCreated{copy}) ═════════════▶║
║◀═══════════════ ack ════════════════════════║
║ ║ ║
▼ ▼ ▼
✅ 完成 🔄 待用 📨 已發送
🔄 流程說明:
❶ 從註冊表取得藍本原型
❷ 呼叫 clone() 產生副本
❸ 套用客製化參數
❹ 發布為具體事件
📞 DF 🎯 PlanBlueprint 🏢 SiteAgent[A] 🏢 SiteAgent[B]
(黃頁服務) Agent
║ ║ ║ ║
║ ║ ║ ║
║═══❶ lookup ═══════════▶║ ║ ║
║ (capability= ║ ║ ║
║ "blueprint") ║ ║ ║
║ ║ ║ ║
║ ║═══❂ spawnFrom ═════▶║ ║
║ ║ (copyA) ║ ║
║ ║ ║ ║
║ ║═══❂ spawnFrom ═════════════════════▶ ║
║ ║ (copyB) ║ ║
║ ║ ║ ║
║ ║◀═══✅ ready(A) ═════║ ║
║ ║ ║ ║
║ ║◀═══✅ ready(B) ═════════════════════ ║
║ ║ ║ ║
▼ ▼ ▼ ▼
📋 已註冊 🎯 藍本就緒 🚀 A區啟動 🚀 B區啟動
🌟 協作流程:
❶ DF 尋找可用的藍本代理
❂ 藍本代理複製自身並部署到各區
✅ 各區代理確認就緒狀態
📋 Base Plan 📋 Clone (淺拷貝)
╔═══════════════╗ ╔═══════════════╗
║ rules: ═══════╬════════════════▶║ rules: ║
║ [Rule1] 📝 ║ ║ [同一個] 📝 ║
║ [Rule2] 📝 ║ ║ [物件] 📝 ║
║ id: "123" 🏷️ ║ ║ id: "123" 🏷️ ║ ← ❌ ID撞號!
║ file: fd1 📁 ║ ║ file: fd1 📁 ║ ← ❌ 資源競爭!
╚═══════════════╝ ╚═══════════════╝
║ ║
╚═══════ 💥 共享狀態 ═════════════╝
(災難根源)
🚨 災難後果:
• 修改 Clone.rules[0] → Base.rules[0] 也被改變! 😱
• 關閉 Clone.file → Base.file 也被關閉! 💀
• 資料庫出現重複 ID,後蓋前! 🔄
📋 Base Plan 📋 Clone (深拷貝)
╔═══════════════╗ ╔═══════════════╗
║ rules: 📝 ║ 🔄 copy ║ rules: 📝 ║
║ [Rule1] ║ ═════════▶ ║ [Rule1'] ║
║ [Rule2] ║ ║ [Rule2'] ║
║ id: "123" 🏷️ ║ ║ id: "456" 🏷️ ║ ← ✅ 重新編配
║ file: null 📁 ║ ║ file: null 📁 ║ ← ✅ 不複製句柄
╚═══════════════╝ ╚═══════════════╝
║ ║
╚═══════ 🛡️ 獨立狀態 ════════════╝
(安全隔離)
🎯 正確實踐:
• 修改 Clone → Base 不受影響 ✅
• 資源由工廠重新取得 🏭
• 各自擁有獨立的 ID 🆔
• 記憶體完全隔離 🔒
🏆 效益對比:
┌────────────┬──────────┬──────────┐
│ 項目 │ 淺拷貝 │ 深拷貝 │
├────────────┼──────────┼──────────┤
│ 狀態隔離 │ ❌ │ ✅ │
│ 資源安全 │ ❌ │ ✅ │
│ ID 唯一性 │ ❌ │ ✅ │
│ 效能開銷 │ 低 │ 適中 │
└────────────┴───────────┴──────────┘