C++/Qt 设计模式 · 第 17 天

Day 17:备忘录模式(Memento Pattern)

在不破坏封装的前提下,把对象的内部状态"拍成照片"存到外面,想回到过去时再"洗出来"——状态的时间旅行,由对象自己掌控。

①今日主题

备忘录模式(Memento Pattern)定义:在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后可以将对象恢复到之前保存的状态。

一句话:快照由本人拍照、由保管员保存——只有发起人自己懂自己的内部结构。它是行为型模式家族中专门解决"撤销 / 回滚 / 存档"问题的模式,也是实现 Undo/Redo 的两大经典路线之一(另一条是命令模式,Day 14 已学)。

本课是行为型模式的第 5 站。我们已经学过责任链(Day 13)、命令(Day 14)、迭代器(Day 15)、中介者(Day 16)。今天的备忘录与前四者都不同:它既不改变请求形态,也不改变通信方式,而是把"时间"变成了可操作的对象——状态不再是转瞬即逝的,而是可以被捕获、保存、恢复的一等公民。学完今天的内容,你会彻底理解 Qt 的撤销框架(QUndoStack)、QTextCursor 的 saveState()/restoreState() 背后共同的哲学。

②为什么需要这个模式?

先看一个真实场景:开发一个带撤销功能的文本编辑器。最直觉的写法是——把 Document 的字段全部设为公有,客户端想备份时直接"抄一遍":

// 坏味道版本:撤销/存档逻辑散落在客户端,对象内部状态被撕开
struct DocSnapshot {                    // "备忘录"由客户端自己定义
    std::string text;
    int cursor = 0;
    bool bold = false;
};

class Document {
public:
    std::string text;   // 公有字段——为了能备份,封装被牺牲了
    int cursor = 0;
    bool bold = false;
};

// 客户端"手工快照":每新增一个字段,这里就要同步改三处
Document doc;
doc.text = "hello";
doc.bold = true;

auto snap = std::make_unique<DocSnapshot>(doc.text, doc.cursor, doc.bold);
// ... 用户一顿乱改 ...
doc.text = snap->text;     // 恢复也得手工逐个字段赋值
doc.cursor = snap->cursor;
doc.bold = snap->bold;

这段代码至少有四个致命问题:

备忘录模式给出的答案是:把"拍照"和"恢复"的权利交还给对象自己(Originator),把"保管"的职责交给一个只会存取、不会偷看的第三者(Caretaker)。封装保住了,职责也清晰了。

③核心思想

核心思想用三个角色概括:

生活类比:游戏存档点。你在打 Boss 之前走到存档水晶前,游戏把当前血量、金币、装备、地图位置全部写进存档文件——你不需要知道存档文件里那串二进制是什么格式,游戏程序自己知道。打 Boss 失败了,你选择"读取存档",一切回到存档那一刻。这里:游戏角色 = Originator,存档文件 = Memento,你的"存档/读档"操作和存档列表 = Caretaker。存档文件里的字节对玩家是不透明的,只有游戏引擎能解读。

再类比收银小票:你的购物车(Originator)内容被打印成小票(Memento),收银员(Caretaker)只负责收银和留底,不会去改小票上的数字;结账出错时凭小票把购物车恢复原状。

一句话记忆:"锁和钥匙都留在自己手里,只把保险柜交给别人保管。"

④UML / 角色关系

类图关系如下(文字版):

┌────────────┐  createMemento(): Memento   ┌──────────────────┐
│ Originator │ ───────────────────────────► │     Memento      │
│   (发起人) │ ◄─────────────────────────── │   (备忘录,不透明)│
│   Editor    │  restore(m: Memento)         │ 内部只对发起人可见 │
└────────────┘                              └────────┬─────────┘
                                                    │ push / pop
                                                    ▼
                                          ┌──────────────────┐
                                          │    Caretaker     │
                                          │  (管理者)History │
                                          │  只管存取,不读内部│
                                          └──────────────────┘
角色示例类职责
Originator 发起人Editor拥有状态;createMemento() 生成快照;restore() 恢复状态;状态变化与快照逻辑都封装在自己内部
Memento 备忘录EditorMemento不可变的状态容器;构造函数/内部数据只对 Originator 开放(friend 或嵌套类),对外仅提供只读窄接口
Caretaker 管理者History保存、弹出备忘录(栈/数组/文件);绝不读写备忘录内部;可以按需扩展为多级撤销、限容撤销栈

实现要点(C++ 特有):C++ 中实现"内部只对发起人可见"有两种主流方式——① friend 友元:备忘录类把 Originator 声明为友元,构造函数设为私有;② 嵌套类:备忘录作为 Originator 的私有嵌套类,外部只能通过 Originator 的接口拿到"不透明句柄"(如 shared_ptr)。两种都能保证 Caretaker 拿着备忘录却无从下手修改。

⑤最小 C++ 示例(C++17 完整可编译)

我们用"带存档点的文本编辑器"演示:Editor 是发起人,EditorMemento 是备忘录(用 friend 保护内部),History 是管理者(一个简单的快照栈)。代码使用 C++17、shared_ptr 管理备忘录生命周期,杜绝悬垂与泄漏:

#include <algorithm>   // std::min
#include <iostream>
#include <memory>
#include <string>
#include <vector>

// ── 备忘录:不透明的状态快照,只有 Editor(发起人)能读写内部 ──
class EditorMemento {
    friend class Editor;                 // 只有发起人可以访问私有成员
    EditorMemento(std::string text, int cursor)
        : text_(std::move(text)), cursor_(cursor) {}   // 私有构造:管理者无法自行创建
    std::string text_;
    int cursor_;
public:
    // 对外只暴露只读描述,不泄露可修改的内部数据(窄接口)
    std::string describe() const {
        return "text=\"" + text_ + "\", cursor=" + std::to_string(cursor_);
    }
};

// ── 发起人:自己负责生成快照与恢复 ──
class Editor {
public:
    void write(const std::string& s) {
        text_ += s;
        cursor_ = static_cast<int>(text_.size());   // 光标移到末尾
    }
    void moveCursor(int pos) {
        cursor_ = std::min(pos, static_cast<int>(text_.size()));
    }
    // 拍快照:把当前状态封进备忘录。只有 Editor 能构造它(friend)
    std::shared_ptr<EditorMemento> createMemento() const {
        return std::make_shared<EditorMemento>(text_, cursor_);
    }
    // 恢复:从备忘录里取出状态,重新赋值(friend 访问私有成员)
    void restore(const std::shared_ptr<EditorMemento>& m) {
        text_   = m->text_;
        cursor_ = m->cursor_;
    }
    void print() const {
        std::cout << "Editor{text=\"" << text_ << "\", cursor=" << cursor_ << "}\n";
    }
private:
    std::string text_;
    int cursor_ = 0;
};

// ── 管理者:只存不读,绝不碰备忘录内部 ──
class History {
public:
    void push(std::shared_ptr<EditorMemento> m) {
        snaps_.push_back(std::move(m));
    }
    std::shared_ptr<EditorMemento> pop() {
        auto m = std::move(snaps_.back());
        snaps_.pop_back();
        return m;
    }
    bool empty() const { return snaps_.empty(); }
private:
    std::vector<std::shared_ptr<EditorMemento>> snaps_;
};

int main() {
    Editor ed;
    History history;

    ed.write("Hello");
    history.push(ed.createMemento());     // 检查点 1:{"Hello", 5}
    ed.write(", C++");
    history.push(ed.createMemento());     // 检查点 2:{"Hello, C++", 10}
    ed.write(" Memento");
    ed.moveCursor(0);                     // 用户把光标挪回开头
    ed.print();                           // 当前最新状态

    ed.restore(history.pop());            // 撤销一步:回到检查点 2
    ed.print();
    ed.restore(history.pop());            // 再撤销一步:回到检查点 1
    ed.print();
    return 0;
}

程序输出:

Editor{text="Hello, C++ Memento", cursor=0}
Editor{text="Hello, C++", cursor=10}
Editor{text="Hello", cursor=5}

注意几个关键设计:createMemento() 是 const 成员——拍照不改变对象;restore() 是唯一能改回状态的地方;History 从头到尾只调用 push/pop,连 describe() 都没用过——它把备忘录当黑匣子。这就是封装:管理者握有全部快照,却对内容一无所知,也无从破坏。

⑥Qt 实战示例(Qt6 + C++17)

先讲一个冷知识:Qt 自己就是备忘录模式的忠实用户。最直接的是 QTextCursor::saveState() / restoreState(int)——它把光标的位置、选区等状态压缩成一个不透明的 int 返回给你,你把它存在任何地方,之后一行代码就能恢复:

QTextCursor cur = edit->textCursor();
int state = cur.saveState();        // 备忘录:一个不透明的 int 状态码
// ... 任意滚动、查找、修改选区 ...
cur.restoreState(state);            // 回到保存时的位置与选区

此外:QTextDocument 内置 setUndoRedoEnabled(true) + undo()/redo(),内部就是为文档维护状态快照栈;QUndoStack/QUndoCommand(命令模式)的每个命令在 undo() 时通常也要靠"保存的前置状态"恢复现场——这正是命令模式 + 备忘录模式的经典组合;QDataStream 可以把任意状态序列化进 QByteArray,实现"可持久化的备忘录"(存盘/网络传输)。

下面写一个完整的 Qt6 Widgets 小应用:迷你记事本,带"保存检查点 / 恢复检查点"两个按钮。备忘录 DocMemento 保存文本 + 光标位置,管理者就是 MainWindow 里的 QVector 历史栈。

mainwindow.h

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QString>
#include <QVector>

class QTextEdit;

// 备忘录:文档快照(文本 + 光标位置)。窄接口:管理者只能"读",不能改
class DocMemento {
    friend class MainWindow;   // 只有发起人(MainWindow)能构造和恢复
    DocMemento(const QString& text, int cursor) : text_(text), cursor_(cursor) {}
public:
    QString text() const { return text_; }      // 只读窄接口
    int cursor() const { return cursor_; }
private:
    QString text_;
    int cursor_ = 0;
};

class MainWindow : public QMainWindow {
    Q_OBJECT
public:
    explicit MainWindow(QWidget* parent = nullptr);
private slots:
    void saveCheckpoint();      // 生成备忘录并入栈
    void restoreCheckpoint();   // 弹出最近备忘录并恢复
private:
    QTextEdit* edit_ = nullptr;
    QVector<DocMemento> history_;   // 管理者:只管存取,不读内部
};

#endif // MAINWINDOW_H

mainwindow.cpp

#include "mainwindow.h"
#include <QAction>
#include <QMessageBox>
#include <QStatusBar>
#include <QTextEdit>
#include <QToolBar>

MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
    edit_ = new QTextEdit(this);
    setCentralWidget(edit_);

    QToolBar* bar = addToolBar("检查点");
    QAction* saveAct    = bar->addAction("💾 保存检查点");
    QAction* restoreAct = bar->addAction("↩️ 恢复检查点");
    connect(saveAct,    &QAction::triggered, this, &MainWindow::saveCheckpoint);
    connect(restoreAct, &QAction::triggered, this, &MainWindow::restoreCheckpoint);
    statusBar()->showMessage("输入文字,随时保存/恢复检查点");
}

void MainWindow::saveCheckpoint() {
    // 发起人亲自拍照:文本 + 光标位置,封装进备忘录
    history_.append(DocMemento(edit_->toPlainText(),
                               edit_->textCursor().position()));
    statusBar()->showMessage(QString("已保存检查点 #%1").arg(history_.size()));
}

void MainWindow::restoreCheckpoint() {
    if (history_.isEmpty()) {
        QMessageBox::information(this, "提示", "还没有任何检查点");
        return;
    }
    const DocMemento m = history_.takeLast();   // 弹出最近一份备忘录
    edit_->setPlainText(m.text());              // 通过窄接口取值恢复
    QTextCursor cur = edit_->textCursor();
    cur.setPosition(qMin(m.cursor(), edit_->toPlainText().size()));
    edit_->setTextCursor(cur);
    statusBar()->showMessage("已恢复到最近检查点");
}

main.cpp

#include <QApplication>
#include "mainwindow.h"

int main(int argc, char* argv[]) {
    QApplication app(argc, argv);
    MainWindow w;
    w.resize(640, 420);
    w.show();
    return app.exec();
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(memento_demo VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)          # Q_OBJECT 需要 moc 自动生成

find_package(Qt6 REQUIRED COMPONENTS Widgets)

add_executable(memento_demo
    main.cpp
    mainwindow.cpp
    mainwindow.h
)
target_link_libraries(memento_demo PRIVATE Qt6::Widgets)

运行行为:输入几段文字 → 点"保存检查点"(底部状态栏显示 #1、#2…)→ 继续修改或删除 → 点"恢复检查点",文本与光标一起回到最近一次保存的状态,连点可逐级回退。整个程序里,DocMemento 的内部字段只被 MainWindow(friend)读写,QVector 这个管理者只是"存了又取"——如果有人想给程序加"检查点列表"窗口,直接遍历 history_ 的窄接口即可,完全不需要知道备忘录怎么存的。

⑦代码执行流程

以最小示例的 main() 为线索,梳理完整运行流程:

整个流程中,数据只朝两个方向流动:Editor → 备忘录(拍照,拷贝),备忘录 → Editor(恢复,回拷)。Caretaker 只是中间的一只手,碰不到也看不懂货物——这正是模式设计最精妙的地方。

⑧为什么这样设计?

⑨不使用会怎样?(反例)

如果没有备忘录模式,撤销/存档功能通常会退化成以下几种坏味道:

这些坏味道的共同根源:状态的知识(语义)与状态的存放(存储)被搅在了一起,且落到了不该负责的类手里。备忘录模式正是把这两者重新各归其位。

⑩何时使用

适合使用(3-5 个场景):

不适合使用(2-4 个场景):

⚠️ 过度设计提醒:① 快照只保存"恢复所需的最小字段集",不要全量拷贝;② 限容历史栈(如 QUndoStack 的 setUndoLimit)防止内存无限增长;③ Qt 项目里能复用 QUndoStack 就优先复用框架,别手写第三套撤销轮子;④ 备忘录不是唯一撤销方案——"成本低选备忘录,差异大选命令"。

⑪与其他模式的区别

对比模式核心差异能否组合
Command 命令模式(Day 14)命令封装"操作 + 撤销逻辑",可只存增量差异;备忘录封装"状态快照",不含任何操作逻辑。撤销:命令=逆操作,备忘录=回到过去。✅ 经典组合:QUndoCommand 的 undo() 借助备忘录恢复变更前状态
Prototype 原型模式(Day 5)原型克隆整个对象(含行为、引用关系)产生独立副本;备忘录只捕获数据状态用于恢复。克隆=平行宇宙,恢复=时间旅行。✅ 备忘录内部可借原型克隆实现深拷贝
State 状态模式(Day 19 预告)State 的"状态"是行为模式——不同状态不同行为;备忘录的"状态"是数据快照。一个管"现在怎么表现",一个管"过去怎么回来"。✅ 状态机可配合备忘录实现状态回退
序列化(QDataStream/JSON)序列化是通用的、公开结构的持久化;备忘录强调封装 + 窄接口 + 发起人掌控。序列化是"打印出来",备忘录是"拍立得"。✅ 备忘录 + 序列化 = 可落盘的永久存档

最容易混淆的是 Command 与 Memento:两者都能做撤销,但一个是"重放逆操作",一个是"恢复快照"。判断口诀:撤销逻辑能不能写成"把状态倒回某个值"?能→备忘录;撤销需要精确重算操作差异?→命令。生产级的撤销系统(含 Qt 的 QUndoStack)通常两者兼用。

⑫Qt 源码中的体现

诚实说明:Qt 里没有一个叫 Memento 的类,但撤销体系(QTextDocument、QUndoStack)的灵魂就是备忘录思想——把"恢复所需的状态"从对象中提取出来、保管在对象之外、由对象自己解读。理解了这个模式,Qt 的撤销机制在你眼里就不再是黑盒。

⑬面试常见问题

Q1:备忘录模式的三个角色分别是什么?为什么管理者不能直接访问备忘录内部?

A:发起人 Originator(拥有状态、生成/恢复快照)、备忘录 Memento(不透明的状态容器)、管理者 Caretaker(保存/管理快照)。管理者若可访问内部,封装即被破坏:快照可能被篡改导致恢复结果不可信;同时"如何解读状态"是发起人的私有知识,管理者本就不该知道——让不懂的人碰数据,是事故的温床。

Q2:C++ 中如何实现"只有发起人能访问备忘录内部"?

A:三种主流做法:① friend 友元:备忘录构造函数设为私有,Originator 声明为友元(本课示例);② 私有嵌套类:备忘录作为 Originator 的私有嵌套类,外部只能拿到不透明指针;③ 窄接口 + pimpl:对外只暴露只读方法。共同点:管理者只能"存取"、不能"构造/修改"。

Q3:用备忘录和用命令模式实现撤销,各有什么优劣?

A:备忘录存状态快照:实现简单直观、恢复绝对精确,但全量拷贝、内存开销大;命令存操作与逆操作:可只存差异、天然支持重做与命令合并,但实现复杂、逆操作难写时易出错。生产系统常组合:命令负责记录"做了什么",备忘录负责保存"撤销所需的前置状态"——QUndoStack 正是如此。

Q4:备忘录内存占用太大,怎么优化?

A:① 只保存恢复所需的最小字段集;② 增量快照(只存自上次以来的变化);③ 改用命令模式存差异;④ 序列化压缩(QDataStream + qCompress)后落盘;⑤ 限制历史深度(限容栈,QUndoStack::setUndoLimit)。

Q5:备忘录的深拷贝 / 浅拷贝有哪些陷阱?

A:快照必须保证拷贝语义正确:若备忘录与原对象共享指针成员,恢复后两者互相干扰,还会引发悬垂指针、双重释放。C++ 实践:状态字段使用 std::string / std::vector / QString 等值语义容器,杜绝裸指针;确需指针成员时实现深拷贝并遵循 RAII;宁可让备忘录"重"一点,也不要"假"一点。

⑭今日练习(15-30 分钟)

需求:实现一个"游戏存档"系统。

  • Hero 类:血量 hp、金币 coins、坐标 (x, y),提供 hurt()(扣血)、addCoins()(加钱)、move()(移动)等方法。
  • 打 Boss 前自动存档(createMemento);Boss 战失败后读档恢复原状;打赢则清空本次存档。
  • 支持至少 3 个存档位,可列出存档列表(只读描述,如 "HP:80 Coins:120 (3,5)")。
  • 硬性要求:Hero 的内部字段对存档管理器完全不可见,管理器只能存取,不能读改内部。

提示 1:备忘录用 friend class Hero 或 Hero 的私有嵌套类实现,管理器只持有 std::shared_ptr 列表。

提示 2:描述用只读 std::string describe() const 返回;恢复走 Hero::restore(memento);想一想:打赢 Boss 后该不该清空存档?这正好考验你对"管理者职责"的理解。

(不提供完整答案——自己写一遍,撤销/存档的肌肉记忆才算真正长在身上。)

⑮今日总结

一句话记忆:快照由本人拍照、由保管员保存——只有发起人自己懂自己的状态。

代码特征信号(看到这些,就该想到备忘录模式):

明日预告:Day 18 观察者模式(Observer Pattern)——一对多依赖的通知机制:一个对象状态变化,所有依赖它的对象自动收到通知。它是 Qt 信号槽(signal/slot)的灵魂,也是事件驱动架构的基石。明天见!