🎯 什麼情境該想到我
當你「需要把某個時刻的狀態存下來,之後能還原」的時候——undo、草稿、交易回滾、遊戲存檔、除錯重現。
⚙️ 怎麼用
意圖
在不破壞封裝性的前提下,捕獲一個物件的內部狀態,並在該物件之外保存這個狀態,以便之後能將它恢復到原先的狀態。
★ 「在不破壞封裝性的前提下」是重點。
GoF 的原版用 C++ 的 friend 來讓 Originator 能讀寫 Memento 的私有內容,而 Caretaker 不能。
Originator(被保存的物件)──創建/還原──▶ Memento(不透明的快照)
▲
Caretaker(保管者)───────持有但不窺視────────┘
★ Go 的長相:用未匯出欄位做封裝
package editor
type Editor struct {
content string
cursor int
// ... 其他私有狀態
}
// ★ Memento:欄位全部未匯出 → 套件外拿到它也看不到內容
type Memento struct {
content string
cursor int
}
func (e *Editor) Save() Memento {
return Memento{content: e.content, cursor: e.cursor}
}
func (e *Editor) Restore(m Memento) {
e.content = m.content
e.cursor = m.cursor
}// ── 另一個套件 ──
type History struct{ stack []editor.Memento } // ★ 能保管,但讀不到裡面
func (h *History) Push(m editor.Memento) { h.stack = append(h.stack, m) }
func (h *History) Pop() (editor.Memento, bool) {
if len(h.stack) == 0 { return editor.Memento{}, false }
m := h.stack[len(h.stack)-1]
h.stack = h.stack[:len(h.stack)-1]
return m, true
}★ Go 的「套件層級可見性」剛好對應 GoF 想要的效果:
同套件內 Originator 能存取 Memento 的私有欄位,套件外只能傳遞它。
比 C++ 的friend或 Java 的巢狀類別乾淨得多。
★ 深拷貝是必要的(最常見的 bug)
type Doc struct {
lines []string // ★ 參考型別
meta map[string]string
}
// ❌ 錯:Memento 和 Doc 共用同一個底層陣列
func (d *Doc) Save() Memento { return Memento{lines: d.lines, meta: d.meta} }
// 之後 d.lines[0] = "x" → ★ 快照也跟著變了,undo 完全無效
// ✅ 對
func (d *Doc) Save() Memento {
return Memento{
lines: slices.Clone(d.lines),
meta: maps.Clone(d.meta),
}
}★ 完整的深淺拷貝陷阱見 原型模式 與 工具-Go切片的共享與陷阱。
★ 記憶體:三種降低成本的手法
| 手法 | 做法 | 代價 |
|---|---|---|
| 限制歷史長度 | 只留最近 N 個快照 | 無法回到更早 |
| ★ 增量快照 | 只存「和上一版的差異」(diff) | 還原要從頭套用所有 diff |
| ★ 檢查點 + 增量 | 每 N 步存完整快照,中間存 diff | ★ 實務上的標準做法——還原只要從最近的檢查點開始 |
| ★ 持久化資料結構 | 不可變結構 + 結構共享(immutable + structural sharing) | ★ 快照幾乎免費(只是一個指標),但需要改寫資料結構 |
★ 「檢查點 + 增量」的思路到處都是:
資料庫的 checkpoint + WAL(見 工具-預寫日誌與崩潰復原)、
Git 的 packfile、影片的 keyframe + delta frame。
它是「空間 vs 還原時間」的通用解法。
TypeScript 的長相
// ★ 不可變狀態讓備忘錄幾乎免費
type State = Readonly<{ content: string; cursor: number }>;
const history: State[] = [];
function update(s: State, patch: Partial<State>): State {
history.push(s); // ★ 舊的 s 不會被改,直接存參考就是快照
return { ...s, ...patch };
}
function undo(): State | undefined { return history.pop(); }★ 這就是 Redux time-travel debugging 的原理:
狀態不可變 → 每一個歷史狀態都還在 → 快照是零成本的。
不可變性把「備忘錄模式」從一個需要設計的東西變成了免費的副產品。
★ 備忘錄 vs 命令(undo 的兩種路線)
| 備忘錄(快照) | 命令模式(反向操作) | |
|---|---|---|
| 怎麼 undo | 還原到存下來的狀態 | 執行反向操作 |
| 正確性 | ★ 一定對 | ⚠️ 取決於反向操作寫得對不對 |
| 記憶體 | ★ 高(要存狀態) | 低(只存操作) |
| 不可逆的副作用 | 一樣處理不了 | 一樣處理不了 |
| 適合 | 狀態小、操作複雜 | 狀態大、操作可乾淨反轉 |
★ 實務建議:先用快照。 它一定對,而「記憶體不夠」是之後才需要解的問題(用增量或檢查點)。
反向操作的正確性錯誤極難發現——使用者按了 undo,狀態看起來對了,但某個欄位沒還原。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 深拷貝是必要的(見上)。這是最常見也最難察覺的 bug。
- ★ 外部副作用無法被快照還原。已寄出的信、已扣的款、已呼叫的第三方 API。
→ undo 只能還原「你自己的狀態」;外部世界要用補償(見 命令模式 與 工具-分散式事務的取捨)。 - 快照包含敏感資料:undo 歷史、草稿、除錯快照都可能含 PII,要納入資料保留政策與刪除流程。
- ★ 快照會很大。文字編輯器每個按鍵存一份全文 → 一份 1 MB 的文件按 1000 次就是 1 GB。
一定要用增量或檢查點。 - 序列化的快照有版本問題:存到磁碟的 memento,程式改版後結構變了還原不回來
→ 加版本號並保留遷移邏輯(見 工具-資料編碼與演進)。 - 並行下要決定快照的一致性點。狀態在被存的過程中被改 → 快照是撕裂的。
→ 存的時候要持鎖,或用不可變狀態(根本不會有這問題)。 - 不需要 undo 就不要做。這是有實質成本的功能,不是「順便加一下」。
🔗 相關工具
- 命令模式 —— undo 的另一條路線(反向操作)
- 原型模式 —— 深拷貝的完整說明
- 狀態模式 —— 狀態機的快照
- 工具-Go切片的共享與陷阱 —— 淺拷貝陷阱
- 工具-預寫日誌與崩潰復原 —— 檢查點 + 增量的同一個思路
- 工具-資料編碼與演進 —— 序列化快照的版本相容
- 值物件 —— 不可變性讓快照免費
- 設計模式