C++享元模式实战:内存优化与共享对象设计
2026/9/9 21:44:36 网站建设 项目流程

C++程序员应该都有过这种经历:程序跑着跑着内存涨到好几个G,打开任务管理器一看,全是重复堆出来的对象。前阵子我在一个文本渲染模块里就撞上了这件事——一个文档里几十万字,每个字都保存一份完整的字体信息,内存直接爆炸。后来把所有相同样式的文本对象抽出来共享,内存立刻降了一个量级。这就是享元模式(Flyweight Pattern)的功劳。

享元模式是设计模式里“看似简单,但真正用好需要踩不少坑”的那种。核心思路一句话:多个对象如果拥有一模一样的属性,就别让它们各自持有数据,而是共享同一份数据。C++里做这个尤其讲究,因为C++有值语义、指针、智能指针、多线程,一个地方没想清楚,要么内存没降下来,要么线程安全出问题,要么对象生命周期崩了。

这篇不是教科书式的概念复述,而是我实际项目中怎么拆解需求、怎么建模、怎么写代码、怎么排查问题的记录。适合三类人看:准备C++面试但要梳理设计模式八股的人、手头有大量重复对象想优化内存的开发者、以及想把享元模式真正落到项目里的朋友。代码部分我会给足完整示例,直接复制也能跑。

1. 理解享元模式:原理与适用场景

1.1 享元模式到底是什么

打个比方,图书馆里有一本《深入浅出C++》,不可能给每个读者都买一本,而是馆里存一本,谁来借谁就看这本。享元模式就是把这个思路搬到程序里:很多地方需要“内容完全相同”的对象,那就只创建一个共享实例,所有使用方都指向这一个实例。

这个模式最早是应用在文档编辑器里的。一个文档可能有很多字符,每个字符都有字体、字重、字号等属性,如果每个字符单独保存一份字体属性,一篇一万字的文档就要一万个字体对象。但事实上,一篇文档里可能只有三五种字体样式,剩下的全是重复。享元模式把这些重复的样式抽成共享对象,比如“正文宋体12号”只存一份,所有使用该样式的字符都引用它,内存占用直接从线性变成常数级。

理解享元的关键,是要分清楚“哪些东西应该共享”和“哪些东西不能共享”。这个分类在模式里叫内部状态(intrinsic state)和外部状态(extrinsic state)。

1.2 内部状态和外部状态怎么分

内部状态是存储在享元对象内部、不随环境变化的信息。还是拿字符举例:字体名、字号、是否加粗,这是字符本身具备的格式属性。一份“黑体18号加粗”的样式,放在第1个字符还是第1000个字符都是一样的,所以它可以作为内部状态放进共享对象里。

外部状态是依赖上下文、调用时才传入的信息。比如字符在页面上的x、y坐标,或者当前字符的颜色。同样是“黑体18号加粗”,出现在第10行第5列和第100行第30列,位置完全不同,位置就不适合放进共享对象里,需要在渲染时由调用方传进来。

区分的判断标准很简单:这个属性会不会随着使用位置的不同而改变?如果会,就是外部状态;如果无论谁用都一样,就是内部状态。判断错了,享元模式的收益就会大打折扣。如果内部状态定义得太少,共享率低,内存省得有限;定义得太多,把外部变化的信息也塞进去,多个使用方就会互相干扰,产生诡异的数据串改。

1.3 享元和普通缓存、对象池的区别

我刚接触享元时,总把它和对象池、缓存搞混。它们确实都是“减少重复创建”的思路,但目标完全不同。

对象池解决的是“创建和销毁对象的开销问题”,典型场景是数据库连接池:连接对象本身可能不同,但创建成本高,所以循环利用。享元解决的是“大量同构对象的内存占用问题”,强调对象逻辑上相同,所以共享同一份。

缓存解决的是“重复计算结果的问题”,它存储的是计算结果,不改变对象结构。享元则是从对象建模层面,直接把对象拆成了共享部分和非共享部分。

画个粗浅的对应关系:对象池省的是时间,缓存省的是计算,享元省的是空间。

1.4 什么场景不适合用享元

享元不是银弹。如果业务对象绝大多数属性都是外部状态,共享价值很低,强行用享元反而引入额外复杂度。比如一个日结账单对象,里面90%字段是日期、金额、用户ID,这些几乎每个对象都不同,共享出来没什么用。

再有就是如果共享对象本身有状态会变化,或者调用方需要修改共享对象的数据,享元模式很容易出幺蛾子。它要求共享对象是只读的,或者至少在并发环境下能保证数据的一致性。我在项目里定的规则是:享元对象一旦创建,除了工厂内部的缓存更新逻辑,业务代码一律不允许改它的内部状态。

2. C++享元模式的结构设计与接口建模

2.1 三个核心角色

一个标准的C++享元实现,通常包含抽象享元(Flyweight)、具体享元(ConcreteFlyweight)和享元工厂(FlyweightFactory)三个部分。

抽象享元定义了一个接口,接口方法接收外部状态作为参数。具体享元实现这个接口,内部持有内部状态数据,并把这些数据应用到外部状态下。享元工厂负责维护一个对象池,通过key查找对象,如果对象已存在就返回共享实例,如果不存在就创建并放入池中。

写C++的人往往会在这里纠结:抽象享元到底要不要定义一个基类?我的经验是,如果项目里只有一种具体享元,基类不是必须的,直接用具体类就行。但如果你预见到未来会有多种享元类型(比如文档系统里有文本样式、图片样式、表格样式),提前定义一个接口会好得多。判断标准就是你业务上有没有多态需求,没有就不必为了“设计模式完整性”硬加虚函数。

2.2 内部状态存储与const正确性

享元对象内部状态一旦初始化就不应该再被修改,否则一个使用方改了样式,所有引用它的字符全都会变。这是最隐蔽的坑。

我在代码里会用两种手段来防护。第一,享元对象的所有字段设置为private,只通过构造函数一次性初始化;第二,对外暴露的方法标记为const,确保业务代码拿到的只是只读视图。

class TextStyle { public: TextStyle(std::string font, int size, bool bold) : font_(std::move(font)), size_(size), bold_(bold) {} // 渲染时调用,只读访问内部状态 void apply() const { // 这里只会读取 font_ size_ bold_ } private: std::string font_; int size_; bool bold_; };

这里的const不是写给人看的装饰,是编译期帮我们挡住误操作的保障。任何尝试修改内部状态的代码,在编译器这一关就会被拦下来。

2.3 智能指针管理生命周期

C++和Java、Go不一样,没有垃圾回收,享元对象什么时候销毁、谁来负责销毁,必须想清楚。我强烈建议用shared_ptr管理享元对象的生命周期,工厂和调用方都持有同一份shared_ptr,最后一个引用消失时对象自动释放,避免了手动delete悬垂指针的噩梦。

using TextStylePtr = std::shared_ptr<TextStyle>;

有人可能会纠结为什么不用unique_ptr。工厂需要把对象存在对象池里,同时还需要把对象返回给调用方,也就是说同一个对象必须被“两个地方”以上同时持有,这种场景是unique_ptr很难处理的。除非你愿意每次从工厂取对象时拷贝一份,但那样又丢失了共享的意义。所以共享所有权明确选shared_ptr更合适。

有人会拿unique_ptr的数组特例来问,比如“我用unique_ptr生成了动态char数组,但函数参数要求char类型,能直接传吗”。答案是unique_ptr本身不提供直接转char给外部裸指针的接口,你得用get()方法,但裸指针的生命周期仍然归unique_ptr管理。这种类型转换问题在享元模式里也常见,主要是一片混乱的代码里既有裸指针又有智能指针。统一的建议是:工厂内部和外部接口都用shared_ptr或const引用,绝对不要在接口参数里直接把智能指针里面的裸指针透传出去让外部长期保存,否则后续维护一定出事。

2.4 工厂的实现细节

享元工厂是共享逻辑的核心,一般用哈希表做对象池。key是用来唯一标识内部状态的复合键,比如字体名加字号加粗体标志拼接成的字符串,或者自定义的结构体。

class TextStyleFactory { public: TextStylePtr GetTextStyle(const std::string& font, int size, bool bold) { std::string key = font + "|" + std::to_string(size) + "|" + (bold ? "b" : "n"); auto it = pool_.find(key); if (it != pool_.end()) { return it->second; } auto style = std::make_shared<TextStyle>(font, size, bold); pool_[key] = style; return style; } private: std::unordered_map<std::string, TextStylePtr> pool_; };

这个实现的优点是代码直观,缺点是字符串拼key有性能开销。如果Get被调用的频率极高,这块字符串分配就会成为热点。后面我讲性能优化时会聊怎么改,但先记住,字符串拼key是简单方案,不适合极端高性能场景。

工厂设计里有一个容易被忽略的地方:要把工厂本身设计成能跨作用域复用的对象。如果每次调用都创建一个新工厂,池子就废了。通常工厂作为一个全局单例,或者作为上层模块的成员变量。在C++里我更倾向于后者,把工厂注入到需要使用它的渲染器里,避免全局单例的初始化顺序问题和测试替身难以替换的问题。

3. 实战:文本编辑器的字符享元实现

3.1 需求与整体设计

假设我们要做一个简单的文本渲染模块。文档里有大量字符,每个字符有字体名、字号、是否加粗这三个内部属性,以及字符在页面上的横向位置、纵向位置和颜色这三个外部属性。

在非享元设计下,每个字符对象都完整保存这六份数据,内存计算很容易。一个字符对象在64位系统上,字符串对象加上整数和布尔,保守估计要占150字节以上。十万个字符就是15MB,看起来还行,但如果渲染的是一个复杂的带格式的文档,字符数上千万,内存直接奔着1.5G去了。真正要命的是,这1.5G里面有大量重复样式,可能90%的字符共用一个默认样式。

享元设计下,我们把字体样式作为内部状态共享,位置和颜色作为外部状态在渲染时传入。

3.2 完整代码实现

先定义外部上下文,包含渲染坐标和颜色。

#include <iostream> #include <memory> #include <string> #include <unordered_map> #include <vector> #include <cstdint> // 外部状态:随使用场景变化 struct RenderContext { int x; int y; uint32_t color; RenderContext(int x_, int y_, uint32_t c) : x(x_), y(y_), color(c) {} };

再定义享元对象本身,也就是内部状态。

// 内部状态:字体样式,所有使用相同样式的字符共享这一个对象 class TextStyle { public: TextStyle(std::string font, int size, bool bold) : font_(std::move(font)), size_(size), bold_(bold) {} const std::string& font() const { return font_; } int size() const { return size_; } bool bold() const { return bold_; } void render(const RenderContext& ctx) const { std::cout << "渲染字符 [字体=" << font_ << " 字号=" << size_ << " 加粗=" << (bold_ ? "是" : "否") << "] 位置=(" << ctx.x << "," << ctx.y << ") 颜色=0x" << std::hex << ctx.color << std::dec << std::endl; } private: std::string font_; int size_; bool bold_; };

然后是享元工厂。

using TextStylePtr = std::shared_ptr<TextStyle>; class TextStyleFactory { public: TextStylePtr Get(const std::string& font, int size, bool bold) { std::string key = font + "|" + std::to_string(size) + "|" + (bold ? "b" : "n"); auto it = pool_.find(key); if (it != pool_.end()) { return it->second; } TextStylePtr style = std::make_shared<TextStyle>(font, size, bold); pool_.emplace(std::move(key), style); return style; } size_t unique_count() const { return pool_.size(); } private: std::unordered_map<std::string, TextStylePtr> pool_; };

最后是客户端调用,模拟一个字符数组的渲染过程。

int main() { TextStyleFactory factory; // 文档中有6个字符,但样式只有两种 std::vector<TextStylePtr> chars; for (int i = 0; i < 3; ++i) { chars.push_back(factory.Get("SimSun", 12, false)); } for (int i = 0; i < 3; ++i) { chars.push_back(factory.Get("SimHei", 14, true)); } std::cout << "工厂内实际创建的样式数量: " << factory.unique_count() << std::endl; std::cout << "字符引用的样式地址是否相同:" << std::endl; for (size_t i = 0; i < chars.size(); ++i) { std::cout << "第" << i << "个字符 -> 样式地址 " << chars[i].get() << std::endl; } // 渲染时需要传入外部状态 chars[0]->render(RenderContext(10, 20, 0xFF0000)); chars[1]->render(RenderContext(30, 20, 0xFF0000)); std::cout << "两个相同样式字符的地址是否一致: " << (chars[0].get() == chars[1].get() ? "是" : "否") << std::endl; return 0; }

3.3 运行结果与内存分析

运行上面的代码,核心输出是:

工厂内实际创建的样式数量: 2 第0个字符 -> 样式地址 0x55a2c1e1dc20 第1个字符 -> 样式地址 0x55a2c1e1dc20 第2个字符 -> 样式地址 0x55a2c1e1dc20 第3个字符 -> 样式地址 0x55a2c1e1dc30 第4个字符 -> 样式地址 0x55a2c1e1dc30 第5个字符 -> 样式地址 0x55a2c1e1dc30 两个相同样式字符的地址是否一致: 是

前三个字符共享同一份“SimSun 12 不加粗”样式,后三个字符共享同一份“SimHei 14 加粗”样式。6个字符实际创建的对象只有2个。

内存上的收益很直观。非享元实现下6个字符有6个完整样式对象,假如每个样式对象占用N字节。享元实现下只有2份样式对象,剩余的4份用共享指针代替,每个shared_ptr大小是16字节,可以算一笔账:如果一个样式对象整体占200字节,6个字符非享元是1200字节;享元后是2200+616=496字节。字符越多、复用率越高,差距越大。

有人可能会说,位置和颜色的三元组没有共享,那实际上每个字符还是有外部状态。这个说法对,但外部状态通常比内部状态省空间。内部状态里包含字符串、多个整数,外部状态往往是几个整数坐标。外部状态留在调用栈上,不用堆内存,内存压力就小得多。

3.4 扩展:支持Unicode和更多格式

上面的例子省略了字符本身。真正做文本渲染时,一个字符的“形状”也可以视为内部状态。同一个Unicode码点加同一套字体样式,对应的字形对象应该完全一致。整个键可以从“字体+字号+加粗”扩展为“字体+字号+加粗+Unicode码点”。

这个扩展其实就是在工厂的key里加入码点,代码层面的改动很小。我在实际项目里还会把行高、字间距、下划线这种格式属性也加进去,唯一原则就是:凡是不随渲染位置变化的属性,都塞进内部状态。

4. 进阶:多线程环境下的享元工厂

4.1 线程安全问题的来源

单线程版本的工厂在项目中往往不够用,尤其是渲染系统大多是多线程的:多个线程同时提交字符渲染任务,需要并发地从工厂获取样式对象。

问题出在unordered_map上面。两个线程同时调Get,发现同一个key不存在,然后同时创建了两个TextStyle对象,同时写入map。轻则对象地址不同,享元共享失效;重则map内部结构被并发写破坏,直接崩溃。

4.2 最简单的互斥锁方案

最直接的办法是给Get加上互斥锁。

std::mutex mutex_; TextStylePtr Get(const std::string& font, int size, bool bold) { std::string key = ...; { std::lock_guard<std::mutex> lock(mutex_); auto it = pool_.find(key); if (it != pool_.end()) { return it->second; } } TextStylePtr style = std::make_shared<TextStyle>(font, size, bold); { std::lock_guard<std::mutex> lock(mutex_); auto res = pool_.emplace(key, style); return res.first->second; } }

先查锁再创建再查锁,这个写法能避免重复创建,但代码比较啰嗦。而且把create_style放在锁外,要确保创建过程不依赖map状态,否则还得回锁里验证。

4.3 更省心的call_once和局部static单例

如果工厂本身只需要一个全局实例,C++11开始有更简洁的方案:函数内的局部static变量初始化是线程安全的。

TextStyleFactory& GetFactory() { static TextStyleFactory instance; return instance; }

这只能保证工厂实例的创建是线程安全的,不能保证工厂内部map的并发读写安全。所以你仍然需要在Get里加锁。

还有一种做法是结合std::call_once,在第一次访问时初始化池子,之后只读访问。但只读访问的前提是所有享元对象在启动阶段就预创建完毕,这在业务里不一定可行,因为新样式可能在运行中期才出现。

4.4 读多写少场景的shared_mutex

实际业务里,Get操作大部分时间是“读”——对象早就在池子里了,只有第一次遇到新样式时才“写”。这种读多写少的场景,用独占锁会把所有读线程串行化,性能不好。C++17提供了shared_mutex,支持多线程同时读、写时独占。

std::shared_mutex rw_mutex_; TextStylePtr Get(const std::string& font, int size, bool bold) { std::string key = ...; { std::shared_lock<std::shared_mutex> read_lock(rw_mutex_); auto it = pool_.find(key); if (it != pool_.end()) { return it->second; } } TextStylePtr style = std::make_shared<TextStyle>(font, size, bold); { std::unique_lock<std::shared_mutex> write_lock(rw_mutex_); auto res = pool_.emplace(key, style); return res.first->second; } }

代码里两次加锁,第一次读锁,读不到就创建,第二次拿写锁确认插入。这种模式和双检锁的思路一脉相承,好处是多个线程同时读时完全并发,只有新样式出现时才短暂阻塞写入。在渲染线程多、样式复用率高的场景下,性能比全互斥锁好不少。

4.5 进一步调优的思路

如果Get的调用频率到了每秒百万次级别,连shared_mutex可能都是瓶颈。这时候有几个方向可以走。

一是用std::unordered_map预留空间,在工厂初始化时调用reserve(预期样式数),减少rehash时的锁持有时间。二是把key换成整数ID,用一个字符串到整数的映射做两阶段查找,避免每次都构造临时string。三是把池子改成分段锁,按key的hash值把map拆成多个桶,每个桶一把锁,进一步降低锁竞争。四是在绝对极端的场景下,可以用无锁哈希表,但那属于另一个技术深度了,业务上99%的情况用shared_mutex已经够了。

我在调优时有一条原则:先测性能,再谈优化。不要一开始就上无锁方案,简单方案在真实场景里的表现往往超出预期,因为瓶颈可能在别的地方,比如字符串分配或者缓存缺失。

5. 常见问题与避坑经验

5.1 内部状态被意外修改

这是享元模式最容易翻车的地方。你在一个字符上修改了样式,结果整篇文档所有相同样式的字符全都变了。如果这个修改正好是期望的,那很走运;如果只是改一个字符的颜色,但颜色被错误地放进了内部状态,那就是大型线上事故。

解决办法主要有两个。第一,内部状态设计完成后做一次评审,逐个字段问“这个字段会不会随使用场景变化”。第二,给所有内部状态的读取接口加上const,从编译层面阻断误操作。

5.2 智能指针的生命周期陷阱

shared_ptr不是万能的。如果工厂返回了shared_ptr,而外部某个模块把这个shared_ptr长期保存,工厂想“回收”某个享元对象时却发现外部还在引用,对象无法释放。这会造成池子越来越大,最终退化成“共享了一次,但永远不销毁”的泄漏。

反过来还有一种风险:如果工厂里存的是裸指针,外部存的是shared_ptr,外部引用归零后对象被释放,工厂里的裸指针就成了悬垂指针,下次Get命中就是访问已释放内存。

我现在的做法是:工厂内部存shared_ptr,工厂外部接口返回shared_ptr,唯一例外是内部做只读渲染时用const引用来接收,不额外增加引用计数。这样所有权归属清晰,不会悬垂,也不至于让池子无限膨胀得太离谱。

5.3 key设计混乱导致错误共享

用字符串拼key最怕大小写不一致、空格差异、分隔符冲突。比如“宋体|12|b”和“宋体|12|B”会被当成两个key,共享失败,内存优化效果打折扣。更隐蔽的是字体名里本身就包含分隔符,比如一个字体叫“A|Black”,key拼接后就会产生歧义。

我建议用结构体做key,重载operator==std::hash,避免字符串拼接的歧义和性能开销。结构体key加上hash特化,代码虽然多一点,但长期维护价值高。

5.4 外部状态残留

享元对象本身不存储外部状态,外部状态是调用时传进去的。但很多新手在封装时,习惯把渲染函数写成一个成员函数,接收RenderContext并修改自己的成员变量。这就是灾难。下一个字符如果不显式设置外部状态,就会沿用上一个字符的位置和颜色。

解决方法是强制外部状态以参数形式传入,绝对不允许享元对象持有外部状态缓存。这一点我在代码评审时看到过无数次,属于高频踩坑点。

5.5 面试追问怎么答

C++面试里设计模式经常被问,享元模式出现概率不低。面试官一般会问三个问题:什么是享元模式、内部状态和外部状态的区别、C++实现时需要注意什么。

我的回答思路是:先讲共享思想,用文本渲染举例;然后明确内外部状态的区分标准;最后一定要提C++特有的坑,比如生命周期管理用shared_ptr、并发访问加锁、内部状态用const保护。能提出来这些点,说明你是真写过,不是在背八股。

5.6 什么时候该弃用享元

享元模式的复杂度藏在共享里。如果你的场景共享率不足20%、内部状态字段经常变化、或者外部状态占用的内存比内部状态还大,享元模式带来的收益就很有限,反而让代码变得难懂。这时候直接按值创建对象、靠编译器优化,可能是更划算的选择。

我在项目里判断是否引入享元,会先做个简单的统计:样本对象里有多少比例是完全重复的。如果重复率达到50%以上,才有动手的必要。如果业务增长后情况变化,也会考虑把享元部分拆出去回退成普通对象,避免过度设计。

6. VSCode配置C++环境,跑通上面的Demo

6.1 环境准备

很多初学者看到代码会卡在“怎么跑起来”这一步。VSCode配置C++环境其实就三步:装编译器、装扩展、配置任务。

Windows上我推荐MinGW-w64,找x86_64架构的版本安装。安装完成后在终端里执行g++ --version能输出版本号就说明环境变量配好了。Mac上用clang,一般自带,执行clang++ --version验证。Linux更简单,sudo apt install g++ cmake装上直接能用。

VSCode必装两个扩展:C/C++和CMake Tools。前者负责语法提示和调试,后者负责编译工程。

6.2 关键配置文件

.vscode目录下创建tasks.json,里面指定编译命令。最省事的方式是:

{ "version": "2.0.0", "tasks": [ { "label": "build flyweight demo", "type": "shell", "command": "g++", "args": [ "-std=c++17", "-g", "main.cpp", "-o", "flyweight_demo" ], "group": { "kind": "build", "isDefault": true } } ] }

C++语法提示需要一个c_cpp_properties.json文件,指定编译器路径和标准。如果你用CMake,还需要一个CMakeLists.txt

cmake_minimum_required(VERSION 3.16) project(FlyweightDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(flyweight_demo main.cpp)

6.3 运行验证

配置文件就位后,按Ctrl+Shift+B编译,终端里看到可执行文件生成后,再按F5启动调试或者直接在终端运行./flyweight_demo。如果能看到工厂唯一样式数量和共享地址信息,整个链路就算通了。

如果编译报错找不到头文件,先检查插件是否能识别编译器路径,多半是c_cpp_properties.json的compilerPath配错了。如果中文输出乱码,Windows下把源码文件编码改成UTF-8,并在终端执行chcp 65001切换代码页。

我个人在实际操作中的体会是,享元模式在C++项目里的最大价值不是“省了内存”,而是逼着你把对象的可变和不可变部分彻底分清楚。这个思维习惯一旦养成,对理解值语义、常量正确性、并发模型都有直接帮助。最后再分享一个小技巧:不要在已经写完一大坨代码之后才重构出享元,而是当你在写一个类的时候发现它有明显的重复创建特征,就停下来想想能不能抽一个工厂出来。哪怕最后没做成享元,那个过程也会让你对自己写的代码有更深的掌控感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询