
1. 什么时候该用享元模式先从一个内存失控的现场说起我接触享元模式不是从哪本设计模式书上学到的而是被一段内存疯狂上涨的代码逼着去查的。当时我在做一个2D地图编辑器地图上要用上万棵树木、岩石、房屋的纹理贴图来渲染场景预览。一开始图省事每生成一个景物对象就把它的纹理指针、碰撞体积、名字、描述、颜色属性全部塞进对象里。数据量一上来内存直接用掉好几个G编辑器拖起来像在搬砖。后来我才意识到这上万个对象里真正不一样的可能只有坐标和随机缩放值剩下的纹理、碰撞体积、名字、描述几乎全部重复。它们每一份都在堆上占着自己的一块内存白白浪费。这不就是教科书里说的“大量细粒度对象并且它们拥有大量重复状态”吗享元模式Flyweight Pattern解决的核心问题就是在这种场景下把重复状态提取出来共享让内存占用从“每对象一份”变成“同一状态全局一份”。1.1 一个真实的失控场景咱们先把数字摆出来感受一下问题的量级。假设每个景物对象里有这些字段纹理ID4字节、碰撞体积结构体32字节、名字的std::string算上堆上字符平均30字节、描述std::string平均50字节、坐标8字节、缩放4字节。如果把所有东西都做成独立对象粗略估算每个对象要占130字节左右。一万个对象就是1.3MB听起来不多但如果地图是500x500格的编辑器景物数量到几十万甚至上百万那内存就是几百MB到上GB的差距。更何况地图编辑器还要挂着一堆缓存、历史记录、图层数据内存压力一下子就上来了。真正的关键点在于如果这一百万物件里其实只有十几种不同的纹理和碰撞体类型那它们存了100万份重复的纹理ID和碰撞体积这就是巨大的浪费。如果把“类型信息”抽出来做成十几个共享对象每个景物对象只保留自己的坐标、缩放这些真正个性化的信息那内存占用直接就降了两个数量级。这就是享元模式能带来的实际收益不是理论上的“省一点点”而是量级上的变化。1.2 享元模式到底“享”什么很多人会把享元模式和“缓存”“单例”“对象池”混在一起但其实它有一个非常明确的关注点缓存和对象池关心的是“创建成本高所以复用实例”而享元模式关心的是“内存占用高因为重复状态太多所以共享同一份状态”。它不关心怎么加快创建速度它关心的是如何让重复数据只存在一份。享元模式共享的是对象的状态中的“内在状态”Intrinsic State也就是那些与对象所处环境无关、本质上属于这个对象类别的数据。比如字符渲染中的字形数据地图景物中的纹理ID粒子系统中的粒子纹理这些数据放在共享对象里不会出问题。而每个调用场景里会变化的属性比如坐标、角度、透明度属于“外部状态”Extrinsic State不能放进共享对象必须由调用方在调用时传入或者放到单独的轻量级结构里。一句话总结这个模式的设计逻辑把对象拆成“共享的和不共享的”共享部分做成一个池子不共享部分在使用时通过参数传进来。听起来很简单但代码落地的时候有很多细节比原理本身更值得琢磨后面我一步步拆开讲。2. 把对象拆成两半内在状态与外在状态的拆分逻辑享元模式能否成功百分之八十取决于状态拆分做得好不好。这一章我会详细说说怎么判断一个字段到底该放在享元对象内部还是该由外部传参以及如果拆错了会出现什么后果。2.1 内在状态可以被共享的“同一部分”判断一个字段能不能作为内在状态最粗暴也最有效的标准是如果这个字段对同一个共享对象的所有使用者来说都完全一致那它就可以放进去。还是拿字符渲染举例一段文档里有几千个“A”字符。如果这些“A”在同一个字体、同一个字号、同一种颜色下显示那“A”的字形数据、字体ID、字号、颜色就是内在状态。不管这个“A”出现在文档的第几行第几列它的字形都不会变。这样的数据提取出来放进一个共享的Character对象里非常合适。在C具体实现里我强烈建议把内在状态成员声明成const或者在构造函数初始化之后就不再提供修改入口。原因很简单享元对象是共享的一旦某个使用者改了它内部的数据所有引用它的使用者都会看到变化。这个影响面可能是全局的而且极难追踪。把字段设成const至少编译期就能阻止一大半误操作。另一个常见的内在状态是“类型信息”和“配置信息”。比如游戏里的武器模板攻击力、射程、弹药类型对所有同类型武器实例都是一样的。这种模板数据天然适合做享元。实际项目中很多人的做法是用一个全局的配置表然后在每个对象里拷贝一份配置字段这其实就是在浪费内存。正确的做法是让每个对象持有一个指向共享配置的句柄或指针而不是把配置字段全部复制进来。2.2 外在状态由调用方负责的“差异部分”外在状态是和“当前使用场景”绑定的数据。字符渲染里的坐标、旋转、透明度、缩放地图景物里的实例坐标、朝向、随机尺寸这些字段不能进共享对象。原因也很直白如果把这些放进共享对象那就意味着所有使用同一个共享对象的地方都必须使用同一组坐标、同一个旋转角度这完全违背了“不同位置渲染同一个字符”的需求。而且如果把坐标放进共享对象然后又想在不同位置使用它你只能不断修改共享对象的内部状态这个行为在多线程环境里会直接引发数据竞争在单线程环境里也会让代码变成一团乱麻。在实际编码时外在状态通常有两种传递方式。第一种是每次调用时通过函数参数传入比如draw(x, y)里的x和y这个最直接。第二种是把外在状态包装成单独的小结构体由调用方持有例如struct VertexData { float x; float y; float scale; float rotation; };然后调用共享对象的render(vertexData)方法。第二种方式适合外在状态字段比较多的情况避免draw这类方法的参数列表长得失控。但无论哪种方式外在状态的生命周期必须由调用方管理与享元对象的生命周期完全分离。2.3 拆错了会怎样一个反例我说一个我实际见过的反例。某个项目想把游戏里所有子弹对象做成享元但设计时把子弹的“当前位置”也放进了共享对象。结果就是同一时刻所有同种子弹的位置变成一样的屏幕上出现了一堆叠在一起的子弹。后来他们试图通过修改共享对象的位置来区分又引入了一堆同步问题。最后一查子弹对象本身数量并不大共享根本没必要强行用享元反而把逻辑搞复杂了。这个反例说明两件事第一拆分前要确认对象到底“重复”在哪别把个性化状态硬塞进共享部分第二如果对象数量本身不大状态拆分又带来大量额外传参那就别强行套用享元模式。模式是工具不是目的。3. 完整案例一个字符渲染器的C实现这一章给出一个可以直接编译运行的C实现用字符渲染这个经典场景把享元模式的代码骨架串起来。案例不算复杂但包含了享元接口、具体享元类、享元工厂、外部状态传递这几个核心要素。3.1 需求设定和类设计假设我们要做一个简单的文本编辑器渲染器。屏幕上会出现大量字符每个字符有自己的字符码、字体ID、字号、颜色以及绘制时的坐标。如果每个字符都创建一个独立对象那一篇10万字的文档就会有10万个对象哪怕每个对象只存几十字节也是不小的开销。我们的设计目标相同字符码、相同字体ID、相同字号、相同颜色的字符只创建一个共享的Glyph对象。绘制时传入坐标这个外部状态而坐标不缓存到对象内部。类图大致是这样的用文字描述Glyph是抽象接口定义draw(int x, int y)CharacterGlyph是具体享元类内部保存字符码、字体ID、字号、颜色GlyphFactory持有对象池根据参数查找或创建CharacterGlyph。3.2 享元工厂的关键实现先定义Glyph接口和CharacterGlyph类#include iostream #include memory #include unordered_map #include vector class Glyph { public: virtual ~Glyph() default; virtual void draw(int x, int y) const 0; };class CharacterGlyph : public Glyph { public: CharacterGlyph(char ch, int font, int size, unsigned int color) : m_ch(ch), m_font(font), m_size(size), m_color(color) {} void draw(int x, int y) const override { std::cout 绘制字符 m_ch 在 ( x , y ), 字体ID m_font , 字号 m_size , 颜色# std::hex m_color std::dec \n; } private: char m_ch; // 字符码 int m_font; // 字体ID int m_size; // 字号 unsigned int m_color; // 颜色值 };然后是享元工厂。工厂是享元模式里的关键角色它维护一个池子对外提供“查到了就返回已有对象没查到就创建并存入池子”的逻辑。struct GlyphKey { char ch; int font; int size; unsigned int color; bool operator(const GlyphKey other) const { return ch other.ch font other.font size other.size color other.color; } }; struct GlyphKeyHash { size_t operator()(const GlyphKey key) const { size_t h 17; h h * 31 static_castsize_t(key.ch); h h * 31 static_castsize_t(key.font); h h * 31 static_castsize_t(key.size); h h * 31 static_castsize_t(key.color); return h; } }; class GlyphFactory { public: std::shared_ptrGlyph getGlyph(char ch, int font, int size, unsigned int color) { GlyphKey key{ch, font, size, color}; auto it m_pool.find(key); if (it ! m_pool.end()) { return it-second; } auto glyph std::make_sharedCharacterGlyph(ch, font, size, color); m_pool.emplace(key, glyph); return glyph; } size_t poolSize() const { return m_pool.size(); } private: std::unordered_mapGlyphKey, std::shared_ptrGlyph, GlyphKeyHash m_pool; };这里用GlyphKey作为map的键而不是用字符串拼接是为了避免A | 12这类字符串键带来的构造开销和潜在哈希碰撞。自定义结构体加哈希函数性能更稳语义也更清晰。这里要注意的是GlyphKey必须提供operator因为unordered_map的查找依赖相等比较。哈希函数中用了简单的乘法散列实际项目里可以用std::hash组合或者更成熟的hash算法但核心思路一致让相同参数的key必然映射到相同哈希值。3.3 客户端调用与共享验证客户端这样使用int main() { GlyphFactory factory; std::vectorstd::shared_ptrGlyph doc; // 同一个字符配置复用同一个对象 doc.push_back(factory.getGlyph(H, 1, 12, 0xFF0000)); doc.push_back(factory.getGlyph(I, 1, 12, 0xFF0000)); doc.push_back(factory.getGlyph(H, 1, 12, 0xFF0000)); // 与第一个H共享 doc.push_back(factory.getGlyph(!, 2, 16, 0x00FF00)); for (int i 0; i static_castint(doc.size()); i) { doc[i]-draw(i * 10, 0); } std::cout 文档中字符数: doc.size() \n; std::cout 池中实际对象数: factory.poolSize() \n; return 0; }输出是绘制字符 H 在 (0, 0), 字体ID1, 字号12, 颜色#ff0000 绘制字符 I 在 (10, 0), 字体ID1, 字号12, 颜色#ff0000 绘制字符 H 在 (20, 0), 字体ID1, 字号12, 颜色#ff0000 绘制字符 ! 在 (30, 0), 字体ID1, 字号12, 颜色#ff0000 文档中字符数: 4 池中实际对象数: 3可以看到文档里有4个字符但池里只有3个对象因为两个H的参数完全相同实际上共享了同一个CharacterGlyph实例。坐标(0,0)和(20,0)完全靠传入的draw(int x, int y)体现没有污染共享对象。3.4 为什么不直接用一个map工厂的必要性有人可能会问为什么不直接用unordered_mapGlyphKey, shared_ptrGlyph每次调find就行了非要包一个GlyphFactory类。这其实是有讲究的。工厂的价值在于把“池化逻辑”和“业务使用方”解耦。使用方只需要说“我要一个字符H字体1号12号红色”不需要关心这个对象到底是从池子里拿的还是新创建的。将来如果池子策略变了比如要加线程安全锁、要做淘汰策略、要记录命中率只改工厂内部就行所有使用方代码不用动。另外工厂还可以方便地加入“提前预创建”逻辑。如果你能提前知道系统里只会出现固定的几种字形你可以在初始化阶段一次性把这些东西都丢进池子后面使用方过来直接命中连创建分支都不用走。这种预热的灵活度是裸用map无法清晰表达的。4. C实现里绕不开的几个坑这部分是我最想说的。享元模式从原理上没什么难的几个小时就能看懂但真正在自己项目里落地的过程中有几个C特有的坑一个比一个隐蔽。下面我按我踩坑的先后顺序来讲。4.1 共享对象“被污染”的隐患第一个坑也是最容易犯的因为共享对象是被多方引用的如果代码里有人调用了它的非const修改方法所有引用方都会看到变化。而且这种问题常常不是立刻爆发的而是在某个角落某个错误的时间点被一个本该只影响局部的逻辑改了内部状态然后飘到整个程序。我建议在具体享元类里把所有内在状态成员都用const保护或者至少提供只读接口禁止修改。如果确实存在某些需要动态调整的共享状态必须给工厂加一个明确的“更新共享对象”流程并且所有使用方都要知晓这个变更会全局生效。更严格一点的做法是不在享元对象内部提供任何setter所有可变量都走外部状态参数。这样享元对象内部天然不可变共享带来的风险就小了一大半。4.2 并发环境下的工厂线程安全如果你的程序是多线程的享元工厂的getGlyph必须考虑线程安全。前面那个简单实现如果两个线程同时请求一个尚未被创建的同参数对象它们可能同时发现池子里没有然后各自创建一个新对象这叫竞态条件。结果就是共享对象实际上出现了多个实例池子的意义被削弱甚至可能导致指针地址不一致引发后续比较逻辑出错。解决办法有好几种。最简单的在getGlyph函数里加一个std::mutex锁住整个查找和创建过程std::mutex m_mutex; std::shared_ptrGlyph getGlyph(char ch, int font, int size, unsigned int color) { std::lock_guardstd::mutex lock(m_mutex); GlyphKey key{ch, font, size, color}; auto it m_pool.find(key); if (it ! m_pool.end()) { return it-second; } auto glyph std::make_sharedCharacterGlyph(ch, font, size, color); m_pool.emplace(key, glyph); return glyph; }这个方案实现简单对于大部分场景性能也够用。如果查询频率极高、锁竞争严重可以考虑double-checked locking或者per-key锁但那些方案的复杂度会明显上升我建议只有profile确实显示锁是瓶颈时才去优化。另外一个更好的方案是提前预热在程序启动阶段把所有可能用到的共享对象都创建好之后运行期所有getGlyph都只读查询连锁都可以省掉。前提是你提前知道会用到哪些组合。4.3 对象生命周期与指针选择享元池一旦建立这些对象什么时候销毁就成了一件需要想清楚的事。最简单的策略是让工厂持有shared_ptr调用方也拿shared_ptr或shared_ptr拷贝。这样当所有引用者都释放时共享对象自动析构不会泄漏。但也有一种常见写法是工厂内部持有unique_ptr或直接裸指针返回裸指针给调用方。这种写法的风险在于调用方可能把裸指针存到一个生命周期比工厂更长的容器里工厂一旦析构这些指针就全部悬空变成野指针。我在项目里见过不少这样的崩溃排查起来非常痛苦。所以我的建议是要么用shared_ptr并明确工厂的池子也持有引用要么直接规定好“池子生命周期覆盖所有使用方生命周期”并严格保证使用方不能持有越过池子生命周期的引用。还有一种情况是需要在某个时刻主动清空池子。比如游戏切场景时要卸载旧场景的所有资源。这时候你不能简单地把map清空因为可能有其他代码仍持有着这些共享对象的shared_ptr。清空工厂持有的引用确实会让引用计数降低但不会让对象立刻析构。这里的经验是清池子之前先确保所有业务代码已经释放了对享元对象的引用否则对象不会被真正销毁资源泄露依旧存在。4.4 key设计不当导致的错误共享第三个坑在key的设计上。一开始我用字符串拼接比如把字符、字体、字号、颜色拼成“H|1|12|ff0000”。看起来没问题但如果某项内容是用户输入的字符串那我必须考虑分隔符冲突。例如如果字符本身可以包含|或者字体ID可以是字符串而不是数字那么“A|1”和“A|1”到底代表什么可能产生歧义。更麻烦的是如果key的某个字段本身是不定长字符串直接用字符串拼接做key哈希碰撞和内存开销都会增加。所以推荐用结构体key就像前面GlyphKey那样。但结构体key还有件事要注意如果你给结构体写了operator一定也要保证所有字段都参与了比较。漏掉某个字段会导致两个明明不同的配置被当成同一个返回同一个共享对象这种错误共享特别隐蔽表现是某些实例用了相同的字体或颜色很难排查。我在开发中习惯在GlyphKey里用std::tie来生成operator这样不会漏字段bool operator(const GlyphKey other) const { return std::tie(ch, font, size, color) std::tie(other.ch, other.font, other.size, other.color); }这样既保证所有字段全部参与比较代码还简洁。哈希函数那边也要注意只要两个key相等哈希值必须相同这是哈希表的基本要求。用上面那种把每个字段都混进h的写法一般不会出问题。5. 和单例、对象池、缓存的对照它到底和谁更像享元模式这个词很多人听过但真放到项目里选择的时候容易和单例模式、对象池模式、缓存概念混在一起。这章把它们的边界理清后面选型就不会再纠结。5.1 享元模式和单例模式的边界单例模式的核心是“整个进程只有一个实例”。它不管对象是否重复不管状态是否可共享它就是要保证全局唯一通常用来管理全局配置、日志器、线程池这类资源。而享元模式的核心是“相同状态的多个逻辑对象共享同一个物理实例”。单例往往只有一个享元往往有多个只是它们被合理地池化比如一个字符渲染器里有几百个字符Glyph每一个都不会重复但整体有几百个对象存在。用一句话区分问自己“这个对象是需要全局唯一还是相同参数的对象不重复创建”如果是全局唯一用单例如果是相同参数复用用享元工厂。5.2 享元模式和对象池的本质区别对象池更关注“创建和销毁成本高所以提前创建一批对象用的时候借用完归还”。典型例子是数据库连接池、线程池。对象池里的对象并不要求“参数相同”它可能是一组预先创建好的同构资源谁拿到都能用用完必须还回来否则池子会耗尽。享元模式则完全不关注“借还”。它默认共享对象是只读的谁都可以持有引用不需要归还。享元对象通常不允许被独占修改因为共享意味着多方共用。如果业务逻辑要求某块资源只能被一个使用者独占、用完释放那是对象池的活不是享元的活。维度享元模式对象池核心目标减少重复状态带来的内存开销减少创建/销毁带来的性能开销对象是否相同相同参数的对象共享同一个实例池中的对象可能不同但可重复使用使用方式引用共享无需归还借用和归还可能被占用对象可变性通常不可变只读可复用可修改后归还典型例子字符渲染、游戏地图像素、粒子纹理线程池、连接池、缓冲区池这里我补充一个实际选择经验如果对象本身字段很少、很轻量那用对象池比享元更合理如果对象字段多、重复度高享元的收益才明显。5.3 享元模式与缓存的关系缓存是一个更通用的概念把昂贵的计算结果保存起来下次直接复用。享元模式可以看作缓存在对象创建领域的一种具体应用。不同的是缓存通常关心“时间局部性”——短时间内同一份数据会被反复读取所以我把它缓存起来享元模式关心的是“空间局部性与相似性”——大量对象有相同的内在状态所以共享它。它们在实际工程里也经常叠加使用。比如我们做一个字体渲染模块可以用一个LRU缓存来保存最近用过的Glyph防止池子无限扩张也可以用享元模式保证相同配置的Glyph始终只有一个。两者解决的问题不同可以共存。准确判断自己属于哪种需求选型才不会跑偏。6. 不适用场景与我的个人经验最后这一章我想很直接地说说什么样的项目不要上享元模式以及我做过多个项目之后对模式使用的真实感受。6.1 什么情况下不要用享元模式第一对象数量不大内存根本没压力。如果你的对象只有几十个几百个每个对象重复状态顶多十几个字节那共享与否无关紧要。强行引入享元工厂、池化逻辑、状态拆分只会增加代码阅读难度和出错概率。设计模式是有成本的享元模式尤其如此因为状态拆分一旦铺开所有调用方都要改变调用方式这个改动成本往往比省下的那点内存贵得多。第二内在状态很难稳定确定或者共享对象经常需要变化。如果一个对象的内在属性在不同场景下表现不同或者经常被修改那共享就变得很危险。多个使用者会互相看到对方的修改最终导致数据错乱。除非你愿意为每个新的“修改过的共享状态”重新创建新的享元对象否则这地方不适合享元。第三外在状态过多每次调用都需要传一大堆参数导致接口臃肿。如果外在状态有十几个字段那每次调用代码都会很长可读性下降还不如直接让每个对象保存自己的状态。虽然可以把外在状态封装成结构体传但封装本身又增加了复杂度。另外如果频繁创建和销毁才是瓶颈而不是内存占用那优先考虑对象池或者其他复用策略享元模式帮不上忙。6.2 适合使用享元的典型场景反过来说有几个场景用享元模式几乎是“天生匹配”的。一个是图形渲染尤其是文字渲染、地图瓦片、地形纹理大量重复的图像单元就是标准享元场景。另一个是编辑器里的节点、控件样式同一套样式被几十万个节点引用共享样式对象能省下非常可观的内存。还有粒子系统里的纹理和粒子类型如果粒子数量几十万且存在大量同种粒子把粒子的“类型”抽成享元每个粒子实例只存坐标、速度、生命值这样的设计既省内存又清晰。C标准库和很多底层库里也能看到类似思想。std::string的COW写时复制实现就出现过类似共享底层缓冲的机制编译器里的字符串驻留string interning也是把相同字符串共享成同一个实例。这些都可以看作享元思想在特定场景下的实践。6.3 我的务实建议和踩坑总结我个人在C项目里应用享元模式时会先做三步判断。第一步用profiler或者估算工具确认内存确实是因为大量重复对象才爆的而不是因为某个简单的泄漏。第二步把所有对象字段列出来逐个打标“内在状态”或“外在状态”如果一半以上字段标了“外在状态”我会重新考虑是否值得拆。第三步在代码里明确“享元对象不可变”的约定并在code review时重点检查有没有人绕过const去修改内在状态。实际开发中我还发现一个容易被忽略的收益享元模式会强迫你抽象出“类型”和“实例”的边界。当你把纹理、碰撞体、名字这些共性抽出来时你其实是在为对象建立一套“分类系统”这对后续做系统优化、数据驱动配置、甚至网络同步都很有帮助。很多时候享元模式的价值不只是省内存还在于让设计变得更干净。如果非要说一句最有价值的经验那就是不要为了模式而模式。先有bug、有数据、有性能问题然后用享元模式去解决它才会真正理解这个模式存在的意义。如果哪天你在评审代码时看到有人准备用享元模式但他说不清自己到底要解决什么内存问题那大概率是在过度设计。这时候作为有经验的人你要做的不是夸他“用了设计模式”而是帮他把需求重新捋一遍。