C++ 设计模式学习笔记(六):对象的唯一性、共享与历史快照
同一个对象是否应当只有一份?多个使用者能否共享它?修改后怎样回到之前的状态?这三个问题都涉及对象和状态,却有不同的目标:唯一性是约束,共享是复用,快照是保存历史。
本文把 P12 单例、P13 享元与 P19 备忘录放在一起阅读,依据三讲完整音轨转录及课件抽样核对,属于 AI 辅助视频笔记 vibe watching。课程的并发推导和复制讨论保留在正文中;函数局部静态对象、值快照及异常边界等现代 C++ 补充会单独说明。
来源档案
讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P12《单件模式》(单例)、P13《享元模式》、P19《备忘录》。
转载账号为“川中一郎”,合集标题《C++设计模式》,标识 BV15pxQzSE8j;上传者与原讲者分别署名。讲者账号发布的《C++设计模式》试听可核对课程署名;本次整理的转载合集用于定位上述分 P。链接失效时,可用“李建忠 C++设计模式 GeekBand”及讲次标题检索。
单例:谁来保证“只能有一个”
P12 将单例放在对象性能相关的主题下。创建多个对象有时会带来不必要的成本,有时则直接违反业务约束。若每个使用者都靠自觉避免重复创建,这个约束就很难维持。
单例把创建控制放回类型内部:不允许外部随意构造,保存唯一实例,通过统一入口取得它。课堂从私有构造函数、禁止复制、一个初始为空的静态实例指针开始。访问函数第一次发现指针为空时创建,之后直接返回已经存在的实例。
这里首先需要判断唯一性的范围。一个进程内唯一,不能自动保证多个进程、多个服务副本或整个分布式系统唯一。如果真正的约束是“某个用户会话只有一个对象”,把它做成全局单例也可能选错范围。
从竞争到锁,再到双重检查
课堂逐步加入并发条件:线程 A 和 B 都看到空指针,然后分别创建对象,唯一性就被破坏。把检查与创建一起放进互斥锁,可以让这一过程串行执行。
但如果每次取得已经创建好的实例都要加锁,高频访问时可能有额外成本。是否值得优化应该测量,不能把“锁有开销”扩大成“所有带锁实现都很慢”。
接着出现双重检查锁定:先在锁外看一次是否为空,已经存在便直接返回;只有为空时才进入锁,取得锁后再检查一次。第二次检查不能省略,因为另一个线程可能已经在等待期间完成了创建。
这套控制流看起来合理,但如果共享指针只是普通指针,问题仍未解决。课堂以分配内存、构造对象、发布指针的顺序说明风险:其他线程可能过早观察到非空地址,却没有安全地观察到完整初始化状态。
用 C++ 内存模型的语言补充,未同步的普通指针读写本身就可能形成数据竞争。仅在创建路径加锁,并不能让锁外读取自动安全。把指针声明成 volatile 也不能提供所需的线程同步关系。
课程并没有停在错误的双重检查版本
这一点值得明确记录:P12 后半段展示了使用原子指针、互斥锁以及 acquire/release fence 的版本。其思路是用原子访问处理共享指针,并建立构造结果发布与读取之间的同步,而不是仅仅再加一个 if。
因此,课程想说明的是并发正确性还包含可见性与发布顺序。不能把本讲归纳成“推荐用裸指针双重检查”,也不应把历史上某些编译器或其他语言对 volatile 的特殊处理直接套用到标准 C++。
不过,能够正确写出一种底层实现,不意味着业务代码必须手写它。内存序代码需要严格推导,审查与维护成本也属于设计成本。
现代 C++ 的简单实现,以及它没有解决的事
如果需求确实是进程内、首次使用时构造一个普通实例,现代 C++ 可以采用函数局部静态对象。下面是完整、可独立运行的 C++20 小例子:
#include <cassert>
class Settings final {
Settings() = default;
public:
Settings(const Settings&) = delete;
Settings& operator=(const Settings&) = delete;
static Settings& instance() {
static Settings settings;
return settings;
}
int page_size() const noexcept { return 20; }
};
int main() {
auto& first = Settings::instance();
auto& second = Settings::instance();
assert(&first == &second);
assert(first.page_size() == 20);
}
标准规定,多个线程同时到达尚在初始化的局部静态变量声明时,需要等待初始化完成;如果初始化抛异常,后续进入时可以再次尝试。这个保证针对初始化过程。C++ 标准草案:局部静态变量初始化
如果以后给 Settings 增加可修改成员,多个线程同时修改它们仍然需要同步。取得同一个地址,与安全地并发使用这个地址指向的状态,是两件事。
生存期也不能忽略。局部静态对象通常在程序终止阶段析构,其他静态对象的析构逻辑如果还依赖它,就要考虑终止顺序。一个构造函数间接再次请求尚在初始化的同一个单例,也不是一种安全的递归初始化技巧。
此外,全局访问入口容易隐藏依赖。业务对象内部随处调用 instance(),测试替换和不同配置并行运行都会更困难。即便最终只有一个实例,仍可在装配处创建或取得它,再通过构造参数显式传入使用者。唯一实例与全局可访问并不是必须捆绑的设计决定。
课堂也提到通过受保护构造允许特定子类的变体,以及禁止复制、克隆以免破坏唯一性。普通业务场景应先确认是否真的需要这些变化,再选择实现;单例并不是一种值得默认套给管理类的装饰。
享元:大量细粒度对象,其实重复保存了相同数据
P13 关注另一种数量问题。对象可以有很多份,但其中某些相同状态没必要重复存储。课程用文本系统的字体对象说明:一段文本可能有非常多字符,而实际使用的字体种类很少。如果每个字符都独立保存一份完整字体信息,重复成本就会随字符数增长。
享元把可共享部分提取出来,通过工厂或池按键复用。课堂的 FontFactory 保存从名称到字体对象的映射:请求某种字体时,已经存在就返回已有对象,否则创建并放进池中。
这里的唯一性是“同一个键对应的共享表示”,并不是整个字体类型只能有一个对象。不同字体仍然需要不同实例,这与单例的目标不同。
先区分什么能共享,再决定怎么缓存
现代术语通常把对象中可共享的部分称为内在状态,与一次具体使用有关的部分称为外在状态。对文本而言,字体定义可以共享,字符的位置、选区状态和当前布局结果未必适合一起共享。
一个真实字体键可能需要包括字体家族、字号、粗细等信息。课堂用字符串名称作简化,不意味着只要字体名称相同,任何配置都可以复用同一对象。键遗漏了影响行为的属性,就会把本来不同的对象错误合并。
共享对象最好具有明确的不可变语义。如果一个字符把共享字体改成粗体,其他字符也被改变,这通常不是调用者想要的结果。正确做法可以是请求另一个描述粗体的共享对象,而不是修改原对象。
享元因此不是简单地给所有对象换成 shared_ptr。智能指针只表达部分生存期关系,不决定哪些状态允许共享,也不保证对象不可变。
内存节省需要把池的成本一起算进去
课程通过字段数量、对象大小和对象总数估算内存,强调细小的重复在大规模下也会明显。计算时还需要考虑对齐、虚表指针、堆分配器开销,以及对象内部动态分配的数据,不能只把源代码中的字段大小相加。
反过来,共享池也有键、查找结构、同步和引用管理的成本。只有少数小对象时,建立复杂池可能更贵;当重复率低时,池还可能接近“把所有对象多保存了一份索引”。
池的生存期应当有明确边界。若永远保留所有见过的键,长期运行的进程可能不断增长。可以让池与一次文档处理共同生存,也可以根据使用情况设计淘汰策略。需要跨池生命周期继续使用对象时,再选择适当的共享所有权或句柄协议。
课程用字符串共享、线程池等例子帮助理解复用,但不能把所有池化都机械归为经典享元。享元尤其关注大量细粒度对象中内在状态的共享;线程池还承担任务调度和并发资源控制。课程关于字符串写时复制的历史性描述,也不宜直接当成现代 std::string 的实现事实。
备忘录:保存历史,同时让内部状态继续封装
P19 的目标从减少对象,转向恢复对象。用户执行若干操作后,希望撤销,回到之前的状态。如果外部保存者必须逐个读取对象的内部字段,再自行拼装恢复,就把对象实现暴露给了外部。
备忘录让原发器自己创建快照,并由它解释快照、恢复状态。外部的管理者只负责保存、选择和请求恢复,不需要理解内部字段。
课堂用 Originator 的一个字符串字段代表真实状态,CreateMemento 生成快照,主程序保存它,修改原对象后再调用 SetMemento 恢复。这里的字符串是简化表示,并不是说真实系统的全部状态必须拼成一个字符串。
原发器、备忘录和管理者分别回答三个问题:谁理解状态,保存什么,何时恢复。这种职责划分比快照对象的具体类名更重要。
一个完整例子:不透明的文档快照
下面的原创 C++20 示例保存文档文本与游标。Snapshot 可以由外部保存和复制,但内部字段只向 Document 开放。它表示程序接口层面的封装,不是对进程内存的加密保护。
#include <cassert>
#include <cstddef>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
class Document {
std::string text_;
std::size_t cursor_ = 0;
public:
class Snapshot {
friend class Document;
std::string text_;
std::size_t cursor_;
Snapshot(std::string text, std::size_t cursor)
: text_(std::move(text)), cursor_(cursor) {}
public:
Snapshot(const Snapshot&) = default;
Snapshot& operator=(const Snapshot&) = default;
};
void replace(std::string text, std::size_t cursor) {
if (cursor > text.size()) {
throw std::out_of_range("cursor outside document");
}
text_.swap(text);
cursor_ = cursor;
}
Snapshot save() const { return Snapshot(text_, cursor_); }
void restore(const Snapshot& snapshot) {
// 先完成可能抛异常的复制,再提交状态。
std::string ready = snapshot.text_;
text_.swap(ready);
cursor_ = snapshot.cursor_;
}
const std::string& text() const noexcept { return text_; }
std::size_t cursor() const noexcept { return cursor_; }
};
int main() {
Document document;
std::vector<Document::Snapshot> history;
document.replace("draft", 5);
history.push_back(document.save());
document.replace("revised", 3);
history.push_back(document.save());
document.replace("published", 9);
document.restore(history.front());
assert(document.text() == "draft");
assert(document.cursor() == 5);
document.replace("another change", 0);
document.restore(history.back());
assert(document.text() == "revised");
assert(document.cursor() == 3);
document.restore(history.front());
assert(document.text() == "draft");
}
外部历史列表没有访问快照字段。文档继续编辑时,已保存的字符串副本不受影响,因此可以多次恢复同一份历史。恢复时先完成字符串复制,再交换内容并更新游标;复制失败不会把文档留在“文本已换、游标未换”的半恢复状态。
这只是快照机制,尚不是完整的撤销系统。若要支持撤销后重做,需要定义当前历史位置;撤销后再次编辑时,还要决定如何处理原来的未来分支。历史容量上限、合并连续输入等也属于管理者的策略。
快照成本与可恢复范围
课程把一次快照扩展到连续保存 50 次,提醒读者不能只看类图。一个状态很大的对象,每次完整复制可能带来明显的内存与延迟成本。
选择性保存、序列化、增量记录和不可变数据共享,都可能改变成本结构,但各有条件。序列化不会自动更快,也不自动解决资源句柄、循环引用、版本变化和恢复顺序。Base64 是编码,不是压缩或加密,更不能替代访问控制。
复制正确性的关键是历史独立性。如果快照仍引用原对象随后会修改的同一块数据,保存的就不是真正稳定的历史。另一方面,安全共享不可变数据并不会破坏快照,不必为了“深拷贝”三个字无条件复制所有对象。
课堂对较早的逐字段备忘录实现提出了批评,强调可以利用更合适的存储形式。保留的是“由原对象封装状态的保存与恢复”这一意图,并不能由此得出备忘录已经过时或序列化永远更好。
还应限定恢复的范围。把内存中的文档恢复到旧文本,不会自动撤回已经发送的邮件,也不会让外部数据库事务倒退。包含外部副作用的撤销往往需要补偿操作,甚至可能根本无法完全逆转。
唯一、共享与快照可以协作,但不能混淆
| 模式 | 核心问题 | 最容易误以为自动得到的能力 |
|---|---|---|
| 单例 Singleton | 某个范围内只允许一个实例 | 所有成员操作线程安全 |
| 享元 Flyweight | 大量对象复用相同内在状态 | 共享即可降低所有成本 |
| 备忘录 Memento | 封装地保存和恢复历史状态 | 任意操作都能够撤销 |
单例可能管理一个享元池,但池并不必须全局唯一。备忘录可以共享不可变享元,以减少重复快照的成本,但必须避免共享可变状态污染历史。
与其他篇的关系也很清楚:原型从已有状态创建一个新对象,备忘录为已有对象保存可恢复的历史;状态模式根据当前阶段选择行为,备忘录保存的是阶段或数据本身;命令可以组织撤销请求,而备忘录可以为命令提供执行前的状态。
这些模式没有取代生命周期设计。对象由谁创建和销毁,谁能修改它,跨线程如何访问,保存多少历史、保留多久,都需要在实现中有明确答案。模式帮助把问题分开,程序的正确性仍然依赖这些具体契约。
来源
资料处理范围:三讲音轨全文转录与阅读,结合代码及课件抽样画面校正术语;单例讲的原始锁、双重检查和原子发布版本均纳入核对。现代 C++ 示例用于解释语义,未照搬课程省略生存期处理的教学代码。