🎯 什麼情境該想到我

當你「需要把某個時刻的狀態存下來,之後能還原」的時候——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 就不要做。這是有實質成本的功能,不是「順便加一下」。

🔗 相關工具