在不破坏封装的前提下,把对象的内部状态"拍成照片"存到外面,想回到过去时再"洗出来"——状态的时间旅行,由对象自己掌控。
备忘录模式(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)。封装保住了,职责也清晰了。
核心思想用三个角色概括:
createMemento() 把自己的状态"拍成照片"(生成备忘录),restore(memento) 拿着照片"回到过去"(恢复状态)。因为只有它懂自己的内部结构,所以拍照和恢复必须由它亲自动手。生活类比:游戏存档点。你在打 Boss 之前走到存档水晶前,游戏把当前血量、金币、装备、地图位置全部写进存档文件——你不需要知道存档文件里那串二进制是什么格式,游戏程序自己知道。打 Boss 失败了,你选择"读取存档",一切回到存档那一刻。这里:游戏角色 = Originator,存档文件 = Memento,你的"存档/读档"操作和存档列表 = Caretaker。存档文件里的字节对玩家是不透明的,只有游戏引擎能解读。
再类比收银小票:你的购物车(Originator)内容被打印成小票(Memento),收银员(Caretaker)只负责收银和留底,不会去改小票上的数字;结账出错时凭小票把购物车恢复原状。
一句话记忆:"锁和钥匙都留在自己手里,只把保险柜交给别人保管。"
类图关系如下(文字版):
┌────────────┐ 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 拿着备忘录却无从下手修改。
我们用"带存档点的文本编辑器"演示: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 自己就是备忘录模式的忠实用户。最直接的是 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() 为线索,梳理完整运行流程:
ed.write("Hello") 修改文本并把光标移到末尾——Originator 的内部状态第一次确定下来(此时 {"Hello", 5})。ed.createMemento() 在 Editor 内部执行——因为是 const 成员,拍照过程不触碰任何状态;通过 friend 权限构造出 EditorMemento,把 text_ 与 cursor_ 拷贝进快照,返回 shared_ptr。外部(包括 History)拿到的是一个不透明对象,只能调用 describe() 只读描述。history.push(...) 把快照指针存入 vector 尾部。此后 Editor 继续演化(追加 " Memento"、移动光标),快照与 Editor 各走各的、互不影响——因为备忘录持有的是值的拷贝而非引用。ed.restore(history.pop())——History 弹出栈顶快照;Editor 通过 friend 权限读取 m->text_、m->cursor_ 并重新赋值,状态回到过去;随后快照离开作用域,shared_ptr 自动释放,无泄漏。重复该步骤即可逐级回退,形成完整的撤销链。整个流程中,数据只朝两个方向流动:Editor → 备忘录(拍照,拷贝),备忘录 → Editor(恢复,回拷)。Caretaker 只是中间的一只手,碰不到也看不懂货物——这正是模式设计最精妙的地方。
如果没有备忘录模式,撤销/存档功能通常会退化成以下几种坏味道:
if (操作是插入) 删除插入的字符; else if (操作是删除) 重新插入...。每加一种操作,撤销链就多一个分支;操作与撤销的对应关系散落在各处,漏写一个分支就是幽灵 Bug。std::vector<std::string> textHistory_;、std::vector<int> cursorHistory_;……每个字段配一条历史数组。Document 同时承担"业务对象"和"历史数据库"两个身份,越改越臃肿,职责混乱。这些坏味道的共同根源:状态的知识(语义)与状态的存放(存储)被搅在了一起,且落到了不该负责的类手里。备忘录模式正是把这两者重新各归其位。
适合使用(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)通常两者兼用。
setUndoRedoEnabled(true) 后,undo()/redo() 由文档内部维护的状态历史驱动——每步编辑前都保留恢复所需的状态,正是备忘录思想的内部实现。诚实说明: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;宁可让备忘录"重"一点,也不要"假"一点。
需求:实现一个"游戏存档"系统。
提示 1:备忘录用 friend class Hero 或 Hero 的私有嵌套类实现,管理器只持有 std::shared_ptr 列表。
提示 2:描述用只读 std::string describe() const 返回;恢复走 Hero::restore(memento);想一想:打赢 Boss 后该不该清空存档?这正好考验你对"管理者职责"的理解。
(不提供完整答案——自己写一遍,撤销/存档的肌肉记忆才算真正长在身上。)
代码特征信号(看到这些,就该想到备忘录模式):
createMemento()/createSnapshot() 与 restore(memento) 方法;明日预告:Day 18 观察者模式(Observer Pattern)——一对多依赖的通知机制:一个对象状态变化,所有依赖它的对象自动收到通知。它是 Qt 信号槽(signal/slot)的灵魂,也是事件驱动架构的基石。明天见!