Skip to content
Go back

C# 备忘录模式完整指南:状态快照与回滚

你需要把一个对象的内部状态存下来,将来随时能恢复,但又不想把私有字段暴露给外部——这就是备忘录模式(Memento Pattern)精确解决的问题。

换个更具体的说法:你做文字编辑器要支持撤销,做游戏要支持存档,做工作流要支持事务回滚——这些场景的技术本质其实一样:在某个时间点给对象拍一张内部状态的快照,保存起来,必要时再还回去,并且外面的代码全程不知道对象肚子里有哪些字段。

这篇指南从备忘模式的三个角色出发,覆盖基础实现、撤销/重做系统、序列化快照、嵌套类封装,以及生产环境下的实际考量。每一步都附带可以直接跑的 C# 代码。


备忘录模式的三个角色

备忘录模式有三个参与者,各自职责分得很清楚:

Originator(发起者):要被保存和恢复状态的那个对象。它是唯一知道内部结构的人,也只有它能创建和消费备忘录。

Memento(备忘录):快照本身,是一个不透明的容器。Caretaker 可以持有它、传递它,但不能查看或修改里面的数据。

Caretaker(看管者):负责存储备忘录,决定什么时候拍快照、什么时候恢复。它从不窥探备忘录的内部内容。

这三个角色像一套约定好的交接流程:Caretaker 说「帮我存一下」,Originator 就生成一个 Memento 交给 Caretaker;Caretaker 说「回到之前」,就把 Memento 还给 Originator 恢复。


什么时候该用

不适合的场景:状态对象很大且快照频繁(内存受不了)、状态结构特别简单(就一个字段就别搬出整个模式了)、或者你只需要细粒度的变更追踪而不是完整快照。


基础实现

以文本编辑器为例子。编辑器有内容文本和光标位置两个状态字段,我们想随时存、随时恢复。

备忘录类

public sealed class EditorMemento
{
    public string Content { get; }
    public int CursorPosition { get; }

    public EditorMemento(string content, int cursorPosition)
    {
        Content = content;
        CursorPosition = cursorPosition;
    }
}

Originator(编辑器本身)

public sealed class TextEditor
{
    public string Content { get; private set; }
    public int CursorPosition { get; private set; }

    public TextEditor()
    {
        Content = string.Empty;
        CursorPosition = 0;
    }

    public void Type(string text)
    {
        Content = Content.Insert(CursorPosition, text);
        CursorPosition += text.Length;
    }

    public void MoveCursor(int position)
    {
        if (position < 0 || position > Content.Length)
            throw new ArgumentOutOfRangeException(nameof(position));

        CursorPosition = position;
    }

    public EditorMemento Save()
    {
        return new EditorMemento(Content, CursorPosition);
    }

    public void Restore(EditorMemento memento)
    {
        Content = memento.Content;
        CursorPosition = memento.CursorPosition;
    }
}

Originator 掌控了快照的创建和恢复两个方向。外部代码不需要知道编辑器里到底哪几个字段构成它的”状态”——这完全是 Originator 自己的事。

Caretaker(历史管理器)

public sealed class EditorHistory
{
    private readonly Stack<EditorMemento> _history = new();

    public void Save(TextEditor editor)
    {
        _history.Push(editor.Save());
    }

    public void Undo(TextEditor editor)
    {
        if (_history.Count == 0)
        {
            Console.WriteLine("Nothing to undo.");
            return;
        }

        EditorMemento memento = _history.Pop();
        editor.Restore(memento);
    }

    public bool HasHistory => _history.Count > 0;
}

串联起来

TextEditor editor = new();
EditorHistory history = new();

history.Save(editor);
editor.Type("Hello, ");
Console.WriteLine($"Content: '{editor.Content}'");

history.Save(editor);
editor.Type("World!");
Console.WriteLine($"Content: '{editor.Content}'");

history.Undo(editor);
Console.WriteLine($"After undo: '{editor.Content}'");

history.Undo(editor);
Console.WriteLine($"After second undo: '{editor.Content}'");

输出:

Content: 'Hello, '
Content: 'Hello, World!'
After undo: 'Hello, '
After second undo: ''

Caretaker 管历史,编辑器自己管创建和消费快照,备忘录在两者之间当信使。三个角色各自的边界没有交叉。


撤销/重做:双栈结构

基础 Caretaker 只能撤销。完整撤销/重做需要两个栈——一个做 undo,一个做 redo。关键细节:执行 undo 时先存当前状态到 redo 栈执行新的操作时清空 redo 栈

public sealed class UndoRedoManager
{
    private readonly Stack<EditorMemento> _undoStack = new();
    private readonly Stack<EditorMemento> _redoStack = new();

    public void SaveState(TextEditor editor)
    {
        _undoStack.Push(editor.Save());
        _redoStack.Clear();  // 新操作让 redo 历史失效
    }

    public void Undo(TextEditor editor)
    {
        if (_undoStack.Count == 0) return;

        _redoStack.Push(editor.Save());
        EditorMemento memento = _undoStack.Pop();
        editor.Restore(memento);
    }

    public void Redo(TextEditor editor)
    {
        if (_redoStack.Count == 0) return;

        _undoStack.Push(editor.Save());
        EditorMemento memento = _redoStack.Pop();
        editor.Restore(memento);
    }

    public bool CanUndo => _undoStack.Count > 0;
    public bool CanRedo => _redoStack.Count > 0;
}

用户撤销了几步之后又开始编辑新内容,redo 栈直接清空——这和你熟悉的每一个主流应用的撤销/重做行为一致。


序列化方式做备忘录

当对象状态很深或者带嵌套集合时,手动拷贝每个字段会很痛苦。这时候用序列化做备忘录就简单很多——把整个状态序列化为 JSON 字符串存起来,恢复时再反序列化回去。

public sealed class GameCharacter
{
    public string Name { get; set; } = string.Empty;
    public int Health { get; set; }
    public int Level { get; set; }
    public List<string> Inventory { get; set; } = new();

    public string SaveToMemento()
    {
        var state = new CharacterState
        {
            Name = Name,
            Health = Health,
            Level = Level,
            Inventory = new List<string>(Inventory)
        };
        return JsonSerializer.Serialize(state);
    }

    public void RestoreFromMemento(string memento)
    {
        CharacterState? state =
            JsonSerializer.Deserialize<CharacterState>(memento);

        if (state is null)
            throw new InvalidOperationException(
                "Failed to deserialize memento.");

        Name = state.Name;
        Health = state.Health;
        Level = state.Level;
        Inventory = new List<string>(state.Inventory);
    }

    private sealed class CharacterState
    {
        public string Name { get; set; } = string.Empty;
        public int Health { get; set; }
        public int Level { get; set; }
        public List<string> Inventory { get; set; } = new();
    }
}

Caretaker 存的是字符串,按名字索引:

public sealed class GameSaveManager
{
    private readonly Dictionary<string, string> _saves = new();

    public void SaveCheckpoint(string name, GameCharacter character)
    {
        _saves[name] = character.SaveToMemento();
    }

    public void LoadCheckpoint(string name, GameCharacter character)
    {
        if (!_saves.TryGetValue(name, out string? memento))
            return;

        character.RestoreFromMemento(memento);
    }

    public IReadOnlyCollection<string> ListCheckpoints()
    {
        return _saves.Keys;
    }
}

这种方式的优点:嵌套对象和集合自动处理、存档是人类可读的 JSON 方便调试、新增字段不需要改备忘录类。代价是序列化比直接字段拷贝慢。高频快照场景先做一次性能摸底再决定。


嵌套类封装:让备忘录彻底不透明

上面的基础实现里,备忘录属性是 public 的——任何拿到备忘录引用的代码都能读里面的数据。如果你需要更严格的封装,C# 的嵌套类可以做到 GoF 原意里的那种”完全不透明”。

public sealed class Document
{
    private string _title;
    private string _body;
    private DateTime _lastModified;

    public Document(string title, string body)
    {
        _title = title;
        _body = body;
        _lastModified = DateTime.UtcNow;
    }

    public void UpdateTitle(string title)
    {
        _title = title;
        _lastModified = DateTime.UtcNow;
    }

    public IDocumentMemento Save()
    {
        return new DocumentMemento(_title, _body, _lastModified);
    }

    public void Restore(IDocumentMemento memento)
    {
        if (memento is not DocumentMemento dm)
            throw new ArgumentException("Invalid memento type.", nameof(memento));

        _title = dm.Title;
        _body = dm.Body;
        _lastModified = dm.LastModified;
    }

    private sealed class DocumentMemento : IDocumentMemento
    {
        public string Title { get; }
        public string Body { get; }
        public DateTime LastModified { get; }

        public DocumentMemento(string title, string body, DateTime lastModified)
        {
            Title = title;
            Body = body;
            LastModified = lastModified;
        }
    }
}

public interface IDocumentMemento
{
    // 故意留空——一个不透明的标记接口
}

IDocumentMemento 是一个没有任何成员的标记接口。外部代码只能拿着这个接口引用来传递,但完全读不到任何字段。只有 Document 自己可以把接口引用转型到私有的 DocumentMemento 类去访问属性。

Caretaker 操作的是接口,完全不了解里面装了什么:

public sealed class DocumentHistory
{
    private readonly Stack<IDocumentMemento> _snapshots = new();

    public void Save(Document document)
    {
        _snapshots.Push(document.Save());
    }

    public void Undo(Document document)
    {
        if (_snapshots.Count == 0) return;

        IDocumentMemento memento = _snapshots.Pop();
        document.Restore(memento);
    }
}

这种实现多了一点点复杂度(嵌套类 + 空接口),换来的是最强的封装保证。Caretaker 没办法窥视备忘录内部,外部代码也没办法凭空构造伪造的快照。


利与弊

好处:

代价:


常见问题

Q:备忘录模式跟简单克隆有什么区别?

简单克隆(ICloneable 或拷贝构造函数)适用于一次性的备份。备忘录模式多了结构化——它把快照和 Originator 清楚分开,提供了历史管理的干净接口,还能精确控制到底哪些状态被捕捉。如果你在做撤销/重做或存档系统,备忘录模式的结构化回报来得很快。

Q:备忘录模式怎么保持封装?

通过让备忘录内部数据只对 Originator 可见。最严格的实现里,备忘录是 Originator 内部的 private nested class,外部代码(包括 Caretaker)只能通过一个没有任何成员的标记接口来持有它。Caretaker 可以存、可以取,但不能读也不能改内部的任何东西。

Q:备忘录模式和命令模式做撤销的区别?

命令模式通过存储逆向操作来实现撤销——每个命令知道怎么反转自己。备忘录模式通过存储状态快照来实现撤销——直接把对象恢复到之前的状态。命令模式在状态大的时候更省内存(只存变更部分),备忘录模式实现更简单(不需要给每个操作写反向逻辑)。实践中两者经常组合:命令负责动作执行,备忘录负责在命令执行前拍快照。

Q:怎么控制备忘录的内存使用?

几个可用策略:给历史数量设上限(超出门槛移除最旧的);使用增量备忘录只存变更部分而非完整快照;对快照做压缩或序列化减小内存占用;实现分层淘汰策略——近的快照存完整细节,旧的快照合并或丢弃。

Q:备忘录能持久化到磁盘或数据库吗?

可以。序列化方式的备忘录就是为这个设计的。把 Originator 的状态序列化为 JSON、XML 或二进制格式,存到文件或数据库里。注意版本问题——如果 Originator 的状态结构在存档和加载之间变了,需要一个处理旧格式快照的迁移策略。

Q:备忘录模式怎么和依赖注入配合?

Caretaker 可以注册为 scoped 或 singleton 服务,取决于历史是要按请求还是全局保存。Originator 一般是你的领域对象,由业务逻辑创建,不从容器解析。如果需要持久化,可以把存储服务注入到 Caretaker 里。Memento 对象本身就是普通数据载体,不需要 DI 注册。


小结

备忘录模式是行为型模式里职责最清楚的那一个:Originator 创建和消费快照,Memento 带着状态走,Caretaker 管历史。三个角色边界分明,实现也不复杂。

在你自己的代码库里找一下——有没有手动逐字段做备份的代码?有没有临时变量存状态等”万一出事就恢复”的操作?这些都可以用备忘录模式整理成一套清楚的结构。

如果和命令模式搭配,能做出完整的撤销/重做流水线;和中介者模式搭配,能在多个对象之间协调状态快照。


参考


Tags


Previous

Mediator 与 Observer 模式对比:C# 中如何选对对象通信方式

Next

C# 中介者模式最佳实践:让代码组织经得起时间考验