用备忘录模式实现struct的撤销/重做:Originator(如Config)提供Save()和Restore()方法,Memento(如ConfigMemento)仅存必要字段且不可变,Caretaker用栈管理多个快照,避免序列化陷阱与并发问题。

Go 里怎么让 struct 支持“撤销/重做”?用备忘录模式最直接
备忘录模式不是为了炫技,而是解决一个具体问题:你有一组字段频繁变更的 struct(比如编辑器状态、表单数据、配置快照),需要随时保存当前状态,并在之后精确还原——不靠深拷贝暴力塞内存,也不靠数据库来回查。
核心思路是把「状态提取」和「状态存储」拆开:Memento 只存原始字段值(通常用私有结构体+构造函数封装),原对象(Originator)负责生成和恢复,Caretaker 负责保管多个 Memento 实例。
-
Memento必须是不可变的,否则外部修改会污染快照;Go 里靠只读字段 + 不暴露指针实现 - 别直接把整个
struct序列化成 JSON 存Memento——反序列化时类型信息丢失,且无法做字段级控制 - 如果结构体含
sync.Mutex、chan、func等不可序列化字段,必须显式忽略,否则panic
为什么不能用 json.Marshal + json.Unmarshal 替代备忘录?
看起来省事,但实际踩坑率极高:字段类型变化后反序列化失败、零值覆盖非空默认值、嵌套结构体中指针字段变成 nil、时间字段精度丢失……更麻烦的是,你没法控制哪些字段参与快照。
备忘录模式强制你显式声明要保存的字段,等于做了一次轻量级契约约束:
立即学习“go语言免费学习笔记(深入)”;
- 所有参与快照的字段必须在
Memento结构体中显式定义,类型一一对应 - 恢复时通过构造函数传参,编译器能检查字段是否缺失或类型错位
- 如果某字段只是临时计算结果(如
cachedHash),天然就不会进Memento,不用额外标注json:"-"
示例:一个带版本号和时间戳的配置结构体
// Originator
type Config struct {
Name string
Timeout int
Version uint64
LastEdit time.Time
mu sync.RWMutex // 不可序列化,也不该存快照
}
// Memento —— 只暴露必要字段,且无导出方法
type ConfigMemento struct {
name string
timeout int
version uint64
lastEdit time.Time
}
func (c *Config) Save() *ConfigMemento {
c.mu.RLock()
defer c.mu.RUnlock()
return &ConfigMemento{
name: c.Name,
timeout: c.Timeout,
version: c.Version,
lastEdit: c.LastEdit,
}
}
func (c *Config) Restore(m *ConfigMemento) {
c.mu.Lock()
defer c.mu.Unlock()
c.Name = m.name
c.Timeout = m.timeout
c.Version = m.version
c.LastEdit = m.lastEdit
}
多个快照怎么管理?别手写 slice,用栈结构更安全
用户点十次“撤销”,你要倒着恢复十次状态。用 []*Memento 手动 push/pop 容易越界或漏清空,推荐封装成栈:
- 用
type HistoryStack []*Memento定义,附带Push()、Pop()、Peek()方法 -
Pop()必须返回指针而非值,避免复制大结构体;同时检查 len == 0 防 panic - 如果允许“重做”,栈外还得配一个
redoStack,每次Undo()后把当前状态压入redoStack - 注意:
Caretaker不持有Originator引用,只管存取Memento,职责隔离才清晰
性能敏感场景下,备忘录模式容易被忽略的开销点
每次 Save() 都是字段级赋值,看似轻量,但以下情况会悄悄拖慢:
- 结构体含大数组或长字符串(>1KB),赋值即内存拷贝;此时应考虑只存 diff(需额外逻辑)或改用引用计数 + 写时复制
- 高频调用(如每毫秒一次)+ 大量快照留存 → GC 压力陡增;建议加数量上限(如最多存 50 个),老的自动丢弃
- 跨 goroutine 使用时,
Memento本身虽不可变,但若它包含指向外部数据的指针(比如data []byte指向共享缓冲区),仍可能引发竞态——务必确认所有字段都是值类型或已 deep copy
真正难的从来不是写完第一个 Save() 和 Restore(),而是想清楚:这个快照里,哪些字段变了才算“状态变”,哪些变了只是中间过程。边界划不清,后面全是 bug。










