如果你写过一段时间 C++,大概率遇到过这样一种别扭的场景:一个系统里要创建成千上万个对象,每个对象本身不大,数据也不复杂,但数量一上来,内存就像漏了一样往下掉,性能也肉眼可见地卡顿。你可能第一时间会想“是不是该用对象池”“是不是该减少拷贝”,但有时候真正合理的解法,是把“重复的东西”从“每个对象里”抽出来共享。这就是享元模式(Flyweight Pattern)干的事。
享元模式听起来是个挺“学院派”的名字,但它在 C++ 工程里的出场率远比很多人想象中高。字符串驻留(String Interning)、字体渲染、粒子系统、游戏地图的 tile 管理、数据库连接池,这些场景背后都有享元思想的身影。它的核心就一句话:把对象里可以共享的部分抽出来,让大量对象复用同一份数据,从而显著降低内存占用和创建开销。
这篇文章我打算从一次真实的性能优化经历切入,把享元模式的原理、C++ 实现方式、常见误用和踩坑点一次讲透。不管你是正在准备 C++ 面试,还是手头项目里确实遇到了大量重复对象的内存问题,这篇都能给你一套可以直接落地的思路和代码。
1. 享元模式到底在解决什么问题
1.1 先从一段“朴素实现”的性能灾难说起
假设你在写一个文本编辑器雏形,屏幕上要渲染几万个字符。很自然的面向对象写法是给每个字符建一个对象:
struct Glyph { char ch; // 字符本身 std::string fontFamily; // 字体 int fontSize; // 字号 bool bold; // 是否加粗 bool italic; // 是否斜体 int colorRGB; // 颜色 int x, y; // 屏幕坐标 };这段代码有什么问题?假设一份文档有 5 万个字符,绝大多数是常规字体的普通文本,可能只有几百个字符用了标题样式、加粗或斜体。但你看看上面这个结构体,每个 Glyph 对象都要带一份完整的std::string fontFamily,哪怕 5 万个字符全是同一个字体,这个字符串也被复制了 5 万份。再加上每个字符的坐标、样式标记,内存占用轻松到几十 MB 甚至上百 MB,而其中绝大部分数据是完全相同的。
更糟糕的是创建开销。每次生成一个 Glyph 都要构造字符串、赋值、分配内存,几万个对象循环下来,性能肉眼可见地劣化。当年我第一次在实际项目里遇到这个问题时,第一反应是“是不是该用std::string_view减少拷贝”,后来发现治标不治本——真正的问题是设计上就让每个对象背了太多重复的行囊。
1.2 享元模式的本质:把对象拆成“共享”和“独有”两部分
享元模式的核心洞察是:对象的状态可以分成两类。
内部状态(Intrinsic State):存在享元对象内部,可以被多个上下文共享,不会随外部环境变化。在上面例子里,字体、字号、加粗、斜体、颜色就属于内部状态——一个“标题样式”可以被几千个字符共用。
外部状态(Extrinsic State):由客户端在使用时传入,不能共享,因为它在不同上下文里不一样。坐标 x、y 就是外部状态——每个字符在屏幕上的位置当然不同。
享元模式做的,就是把内部状态提炼到共享对象里,把外部状态从对象中剥离出去。这样 5 万个字符里,同样字体的字符可以共享同一个样式对象,每个字符只需要保存自己的坐标和字符编码,内存和创建开销瞬间降下来。
用生活化的类比来说:大学教室里几百个学生同时听课,每个学生不需要自己背一套桌椅来,而是共用教室里的桌椅。桌椅就是内部状态(共享),坐在哪个位置就是外部状态(独有)。你不需要 500 套桌椅,只需要教室里的那 50 套。
1.3 为什么 C++ 里特别需要重视享元模式
这一点跟 C++ 的性能定位强相关。Java、C# 里有 GC,对象多了大不了触发垃圾回收,而且 JVM 对小型对象还有逃逸分析等优化。但 C++ 没有这些兜底机制,对象的构造、析构、拷贝都是实打实的开销。你要是写出一个创建 10 万个小对象的循环,内存分配器的压力、构造函数的调用链、缓存 miss 都会真实地反映在性能数据上。
所以 C++ 里做对象设计,往往要比其他语言更早地考虑“对象是否过于臃肿”“是否频繁创建相同对象”“是否值得做池化或共享”。享元模式在 C++ 里不只是一种设计模式,更是一种内存布局和性能策略。这也是为什么 C++ 面试里,设计模式相关题目出现时,享元模式属于“也许不常考但一考就是硬核题”的那种——因为它能同时考察你对象模型理解、内存管理意识、生命周期控制和 const 正确性,一个模式能串起 C++ 的多个核心考点。
2. 内部状态与外部状态:享元模式的两块基石
2.1 区分内部/外部状态的判断标准
我在实际代码评审里发现,很多人不是不会写享元模式,而是分不清哪些状态该放共享对象里,哪些该放外面。这里给你三个判断标准,按顺序问自己:
- 这个值在所有使用场景里是否都相同?如果有的场景需要 A 值,有的需要 B 值,那它大概率是外部状态(或者需要拆分出更细粒度的共享对象)。
- 这个值是否经常变化?如果一个字段在对象生命周期内被频繁修改,把它放进共享对象会带来严重的线程安全问题和逻辑耦合。
- 这个值的存储成本高不高?如果它是一个大字符串、复杂结构体,而且重复度高,那就值得内部化共享;如果只是个小 int,共享不共享对内存影响不大,反而增加复杂度。
回到文本渲染的例子:字体明文字符串、字号、加粗、斜体、颜色,这些在文档里通常只存在有限的几种组合,而且一旦定义很少变化,天然适合做内部状态。坐标是每个字符都不同的,必须外部传入。
这里有个容易踩的坑:内部状态的定义不是绝对的,要根据你的实际场景去划分。同一个字段在这个系统里是内部状态,在另一个系统里可能就是外部状态。比如“颜色”字段,如果文档有严格的样式规范,全局只有标题红、正文黑、链接蓝三种颜色,那颜色可以放进内部状态;如果用户能对任意字符单独调色,那颜色就得降级为外部状态,否则逼着你去创建成百上千个共享对象,享元就失去意义了。
2.2 用 C++ 结构重新建模
根据上面的分析,我们重构一下文本渲染的例子。内部状态定义一个独立的 FontStyle 类:
class FontStyle { public: FontStyle(std::string fontFamily, int fontSize, bool bold, bool italic, int colorRGB) : fontFamily_(std::move(fontFamily)), fontSize_(fontSize), bold_(bold), italic_(italic), colorRGB_(colorRGB) {} const std::string& fontFamily() const { return fontFamily_; } int fontSize() const { return fontSize_; } bool bold() const { return bold_; } bool italic() const { return italic_; } int colorRGB() const { return colorRGB_; } private: std::string fontFamily_; int fontSize_; bool bold_; bool italic_; int colorRGB_; };注意这里的成员都是const的。享元对象设计上的一个铁律:共享对象一旦创建,内部状态不允许修改。为什么?因为共享意味着它被多个地方引用,任何一方修改都会波及所有使用者,这基本等于全局变量级别的灾难。如果你确实需要不同的样式,正确的做法是去享元工厂创建一个新的 FontStyle,而不是修改已有的。
外部状态可以轻量化:
struct Glyph { char ch; // 字符本身也可以看作外部状态,因为每个字符不同 int x, y; // 坐标是典型的外部状态 const FontStyle* style; // 指向享元对象,共享 };等等,你会发现ch也是每个字符不同的,为什么它不算外部状态?严格说确实算,只不过它是“必须和外部状态一起传递的固有属性”。在享元模式的经典 UML 图里,Flyweight 接口本身可以接收外部状态的参数,客户端持有外部状态。这里的Glyph可以理解为“上下文对象”,它把外部状态和指向享元对象的指针组合在一起,方便使用。
这个小小的建模改动,内存收益是立竿见影的。原来的对象里每个字符带一个完整的std::string和 5 个标量字段,现在大部分字段被收敛到享元对象里,5 万个字符共享十几个 FontStyle 对象就够。
2.3 指向共享对象的指针:原生指针还是 shared_ptr?
这是个很现实的问题。在 C++ 里实现享元模式,必然涉及“多个宿主对象引用同一个共享对象”这种情况。此时共享对象的所有权怎么管理?
我见过一些项目用std::shared_ptr<FontStyle>来做共享,因为“大家共享嘛,用 shared_ptr 显得很自然”。但这里有一个很微妙的问题:如果你把享元对象的生命周期完全交给各个宿主对象引用计数管理,你就无法保证“同一逻辑样式只有一个实例”这个约束了。
举个例子,你用 shared_ptr 创建了一个“标题样式”对象,A 段文字使用它,refcount 是 1。B 段文字也需要“标题样式”,它怎么拿到同一个对象?要么你从某个地方去查,要么图省事再 new 一个。如果真的再 new 一个,两个 FontStyle 内容一模一样,但内存里有两份,享元模式的“节省内存”目标就大打折扣了。
所以享元工厂通常需要自己持有共享对象的所有权,对外返回非拥有指针或引用:
class FontStyleFactory { public: const FontStyle* getFontStyle(const std::string& family, int size, bool bold, bool italic, int color) { // 先查表,没找到就创建,找到就复用 } private: std::unordered_map<FontStyleKey, std::unique_ptr<FontStyle>> pool_; };只要工厂活着,共享对象就活着。宿主对象只持有裸指针(或引用),不参与所有权管理。这样就保证了“同一个 key 永远返回同一个对象”的享元核心保证。
当然,如果你自己做不了所有权,或者共享对象的生命周期确实复杂,也可以用std::shared_ptr,但这时候要接受“同一个逻辑对象可能有多个 shared_ptr 实例”的事实,享元的节省内存效果会打折。工程上的取舍,看你的核心诉求是什么:是内存优先,还是便利性和安全性优先。
3. 从零实现一个享元工厂:完整代码与细节说明
3.1 键值设计:怎么判断“两个享元对象是否相同”
享元工厂的核心数据结构是一个映射表:从“内部状态的描述”映射到“享元对象”。这里第一个要解决的设计问题就是:用什么作为 key,以及比较规则是什么。
如果你图省事,直接用整个 FontStyle 结构体做 key,比较时把每个字段都比一遍,代码丑但能用。更常见的做法是定义一个轻量级的 Key 结构体,只存内部状态的必要字段,并实现哈希支持。C++ 里实现自定义 hash 需要写模板特化,不算复杂,但是有细节:
struct FontStyleKey { std::string fontFamily; int fontSize; bool bold; bool italic; int colorRGB; bool operator==(const FontStyleKey& other) const { return fontFamily == other.fontFamily && fontSize == other.fontSize && bold == other.bold && italic == other.italic && colorRGB == other.colorRGB; } }; struct FontStyleKeyHash { size_t operator()(const FontStyleKey& k) const { size_t h1 = std::hash<std::string>{}(k.fontFamily); size_t h2 = std::hash<int>{}(k.fontSize); size_t h3 = std::hash<bool>{}(k.bold); size_t h4 = std::hash<bool>{}(k.italic); size_t h5 = std::hash<int>{}(k.colorRGB); // 注意:直接用异或可能碰撞严重,用移位混合会更好 size_t h = h1; h = h * 31 + h2; h = h * 31 + h3; h = h * 31 + h4; h = h * 31 + h5; return h; } };Hash 的混合方式我特意写成了“每次乘以 31 再加下一个”,而不是h1 ^ h2 ^ h3 ^ h4 ^ h5。原因很简单,异或操作没有顺序敏感性,如果两个 key 只是字段顺序不同,哈希值可能相同,碰撞概率高。而乘法加法混合能保留更多信息,unordered_map 的分布也更均匀。
我曾见过有人的代码里bool也参与哈希,比较时就踩过坑:同一个字体,在某个分支里bold=false, italic=false,另一个分支里没设这两个字段默认就是false,结果 key 完全一样,哈希却不一样——因为没写 operator==。这里要特别提醒一句:unordered_map 比较 key 时,先比哈希值,哈希值相同再用 operator== 确认,所以哈希函数必须和 operator== 保持一致,否则会出现“明明相等但找不到”的灵异 bug。
3.2 工厂类的完整实现
下面给出一个可以直接抄走的 FontStyleFactory 实现:
#include <unordered_map> #include <memory> #include <string> #include <mutex> class FontStyleFactory { public: const FontStyle* getFontStyle(const std::string& family, int size, bool bold, bool italic, int color) { FontStyleKey key{family, size, bold, italic, color}; std::lock_guard<std::mutex> lock(mutex_); auto it = pool_.find(key); if (it != pool_.end()) { return it->second.get(); } auto style = std::make_unique<FontStyle>(family, size, bold, italic, color); const FontStyle* rawPtr = style.get(); pool_.emplace(std::move(key), std::move(style)); return rawPtr; } size_t size() const { std::lock_guard<std::mutex> lock(mutex_); return pool_.size(); } private: std::unordered_map<FontStyleKey, std::unique_ptr<FontStyle>, FontStyleKeyHash> pool_; std::mutex mutex_; };这里我加了std::mutex,是多线程版本。如果你的系统是单线程的,可以去掉锁,实现更轻量。但说实话,一个文本编辑器或者游戏渲染系统,创建字体的代码可能会跑在多个线程里,一开始就加上锁比事后排查并发 bug 省心得多。锁的粒度只在工厂方法内部,查找和插入的耗时本身是微秒级别的,通常不会成为性能瓶颈。
如果对并发要求更高,C++ 17 可以换用std::shared_mutex,让多个找得到 key 的线程并行读,只有插入时写锁。不过这是优化层面的东西,“能跑”和“跑得快”是两码事,先把功能做对再优化是王道。
3.3 使用方式与效果验证
客户端的代码变成这样:
FontStyleFactory factory; // 获取样式对象,同一参数永远返回同一个对象 const FontStyle* headingStyle = factory.getFontStyle("Arial", 24, true, false, 0xFF0000); const FontStyle* bodyStyle = factory.getFontStyle("Arial", 12, false, false, 0x000000); // 渲染 50000 个字符 std::vector<Glyph> glyphs; glyphs.reserve(50000); for (int i = 0; i < 50000; ++i) { Glyph g; g.ch = (i % 26) + 'a'; g.x = i % 100; g.y = i / 100; g.style = (i % 100 == 0) ? headingStyle : bodyStyle; glyphs.push_back(g); } // 验证:虽然创建了 5 万个 Glyph,但共享的 FontStyle 只有两个 std::cout << "共享样式数量: " << factory.size() << std::endl; // 输出 2这段代码我建议你亲自跑一遍,用sizeof(Glyph)对比一下改造前后的差异。改之前每个 Glyph 里扛着一个std::string,改之后Glyph变得非常轻量,一般 16 字节左右(char + int + int + 指针,加上对齐)。5 万个字符的内存占用从“每个字符几十字节”降到“每个字符十几字节”,而且还在共享的样式数量几乎不随字符数量增长。这种量级的变化,性能分析器里一眼就能看出来。
3.4 创建还是复用:工厂方法里的判断逻辑
我又要强调一个享元工厂容易犯的错:无条件创建。有些人写工厂,查表这一步省了,每次都 new 一个对象丢进去,结果 pool 越来越大,跟享元模式“复用”的目标完全背道而驰。
正确逻辑只有两条路:命中 key 就返回已存在的对象,miss 才创建。这一步没有中间态。但实际工程里,还有一类情况需要额外判断:如果一个 key 对应的对象虽然存在,但已经过期或不再使用,你会不会返回它?这取决于你的业务语义。
如果你做的是“样式驻留池”,样式是天然常驻的,那直接复用没问题。如果你做的是“缓存池”,比如数据库连接,连接可能因为网络断开而失效,那工厂就不能盲返回,需要在返回值里带状态信息,或者提供getOrCreate之外的显式失效接口。这提醒我们:享元模式本身是结构模式,但它跟“缓存策略”“资源生命周期管理”经常交织在一起,你实现时要清楚自己的业务边界。
4. 享元模式与 C++ 特性的结合:从能用到用好
4.1 String 驻留:字符串本身就是最常见的享元
诚实地讲,上面 FontStyle 的例子很经典,但有些“为讲模式而设计”的味道。真实 C++ 项目里最常见的享元应用,其实是字符串驻留。对,就是那个 C++ 老生常谈却常被忽略的机制。
考虑一个配置文件解析器,里面有大量的枚举标识符和字符串 key。比如一个游戏存档系统,几千个 JSON 节点里都有"position"、"rotation"、"name"这些键。如果用std::string直接存储,每个节点都持有自己的"position"字符串副本,内存里可能存了上千份相同的字符序列。字符串驻留做的事情是:全局维护一个字符串池,所有相同的字符串只存一份,其他地方用指针或索引引用它。
C++ 里要实现字符串驻留,可以写一个StringPool:
class StringPool { public: const char* intern(const std::string& str) { std::lock_guard<std::mutex> lock(mutex_); auto it = pool_.find(str); if (it != pool_.end()) { return it->c_str(); } // C++17 后 insert 返回的 pair 里有迭代器,直接拿地址 auto [iter, inserted] = pool_.insert(str); (void)inserted; return iter->c_str(); } private: std::unordered_set<std::string> pool_; std::mutex mutex_; };这个实现有个重要细节:unordered_set里存的字符串对象,地址在插入后是否稳定?答案是稳定的。unordered_set的节点在 bucket 里是链表节点,重哈希时节点对象本身不会移动(只有 bucket 指针变化),所以iter->c_str()的指针可以长期持有。
用const char*指针的好处是零开销引用字符串,坏处是没法获取字符串长度,得靠strlen。现代 C++ 里更好的选择是用std::string_view配合驻留池,但string_view本身是个 view,如果指向驻留池内的字符串,生命周期必须保证池比视图活得久。这些权衡,你使用时要根据自己代码的结构来决定。我在实际项目里更倾向于提供int id的版本:字符串池返回一个自增 id,外部用 id 代替字符串比较。整数比较比字符串比较快几个数量级,而且在调试时一眼能看出“同一个 key 就是同一个 id”,非常清晰。
4.2 智能指针在享元模式里的正确用法
前面我说过享元工厂用unique_ptr持有共享对象,对外返回裸指针。这里接着展开讲讲智能指针的几个使用场景。
场景一:工厂持有,外部使用
这是最推荐的用法。工厂的unordered_map里存std::unique_ptr<FontStyle>,这样工厂析构时所有享元对象自动释放,不会有内存泄漏。外部拿到的裸指针只要保证“工厂没有被析构”就是安全的。这是一种非常明确的所有权模型:资源在工厂里,工厂管它们的生死。
场景二:外部也要参与所有权
比如某个对象要把享元对象长时间保存,并且希望“如果没人用了就自动释放”。这时候用std::shared_ptr是合理的,但你要改造工厂,让它返回的是std::shared_ptr,同时用一个std::weak_ptr缓存:
class FontStyleFactory { public: std::shared_ptr<FontStyle> getFontStyle(/* ... */) { // 查 weak_ptr,如果 expired 就重建 } private: std::unordered_map<FontStyleKey, std::weak_ptr<FontStyle>> pool_; };这里得说句实话:weak_ptr做资源回收型享元池听起来很美,但实际工程中用得并不多。为什么?因为享元模式的意义在于“复用、减少创建”,你允许它过期,那就必然会频繁重建,反而破坏了性能收益。什么时候适合这种变体?当享元对象的构造非常便宜,但你又确实希望内存能被回收的时候。比如一些无状态配置对象,创建成本很低,但数量可能很多,用 weak_ptr 池做“尽力缓存”是务实的选择。
场景三:避免使用裸 new 管理享元
这个不用多说,C++ 现代开发早该杜绝手动 delete。用make_unique或make_shared创建享元对象,能保证异常安全。如果你用new FontStyle(...)后立即存入容器,中间如果抛出异常,new 出来的内存就泄漏了。
4.3 const 正确性:享元对象的“只读”契约
享元对象的内部状态必须对外只读,这在 C++ 里由 const 保证。你之前看到的FontStyle类里所有 getter 都标记为const,成员变量也几乎可以设计成 const 字段:
class FontStyle { public: // 构造时初始化,之后不可变 FontStyle(std::string family, int size, bool bold, bool italic, int color) : fontFamily_(std::move(family)), fontSize_(size), bold_(bold), italic_(italic), colorRGB_(color) {} const std::string& fontFamily() const { return fontFamily_; } int fontSize() const { return fontSize_; } bool bold() const { return bold_; } bool italic() const { return italic_; } int colorRGB() const { return colorRGB_; } private: const std::string fontFamily_; const int fontSize_; const bool bold_; const bool italic_; const int colorRGB_; };把成员声明为const,比你只写 const getter 更严格——用户在类内部也不敢修改。代价是这个类失去了赋值运算符(operator=被隐式删除,因为 const 成员没法重新赋值)。这对享元类来说完全不是问题:你本来就不该对共享对象重新赋值,需要新样式就直接创建新的享元对象。
有些人不习惯 const 成员,那我退一步建议:至少把 getter 全部 const,成员用 private,不提供任何 setter。这个契约一旦建立,所有使用享元对象的代码就建立起了“我只读它,不写它”的心智模型,线程安全压力也会小很多。
4.4 多线程下的享元工厂:锁的粒度与替代方案
如果你的程序用到了多线程,享元工厂的线程安全性必须提前设计。最基本的是用std::mutex把查找和插入包起来,像前面那段代码一样。但这引入了一个问题:所有线程竞争同一把锁,即使目标是只读查找,也会被锁拦住。
C++ 17 提供了std::shared_mutex,读写分离:
class FontStyleFactory { public: const FontStyle* getFontStyle(/* ... */) { FontStyleKey key{/* ... */}; { std::shared_lock<std::shared_mutex> lock(mutex_); auto it = pool_.find(key); if (it != pool_.end()) { return it->second.get(); } } std::unique_lock<std::shared_mutex> lock(mutex_); // 二次查找,防止两个线程同时创建同一个 key 的对象 auto it = pool_.find(key); if (it != pool_.end()) { return it->second.get(); } auto style = std::make_unique<FontStyle>(/* ... */); const FontStyle* raw = style.get(); pool_.emplace(std::move(key), std::move(style)); return raw; } private: std::unordered_map<FontStyleKey, std::unique_ptr<FontStyle>, FontStyleKeyHash> pool_; mutable std::shared_mutex mutex_; };注意写锁区里做了二次查找,这是高并发编程里很关键的一步。不加第二次查找的话,两个线程同时 miss,就会创建两个内容一样的享元对象,违反“唯一实例”的约束。这种“double-checked locking”写法在 C++ 里是安全的,因为第二次查找在写锁保护区内完成,不会出现数据竞争。
如果你追求更极致的性能,还可以用std::atomic标记某些热 key 是否存在,减少锁竞争。但说实话,享元工厂的 get 操作本身是微秒级,除非你的系统每秒调用几十万次,否则 shared_mutex 的收益可以忽略不计。过早优化不是好习惯,先把正确性做对。
5. 实战案例一:游戏粒子系统里的享元重构
5.1 问题的提出:50 万颗粒子怎么不把内存打爆
说完文本渲染和字符串,来看一个更“硬核”的实战场景:游戏中的粒子系统。假设你在做一个 2D 游戏,打击特效要同时存在几千颗粒子。每个粒子有位置、速度、加速度、生命周期、颜色、大小、贴图索引。如果每个粒子都是一个完整对象,字段大约有七八个 float 和 int,一颗粒子 50 字节起步,5000 颗粒子就是 250KB,不算大。但问题在于粒子系统会频繁创建和销毁粒子——你每砍一刀、每炸一次,都要 new/delete 大批粒子对象,内存分配器的压力非常大,帧率就会掉。
粒子系统为什么适合享元?因为大量粒子的“种类”是有限的:火星粒子、烟雾粒子、碎片粒子、刀光粒子,每种粒子的物理参数(重力系数、拖拽、颜色变化、贴图)是一样的。只有位置、速度这种“当下状态”每个粒子不同。
5.2 享元建模与对象池结合
第一层重构:把粒子的“种类”抽取为 ParticleType 享元对象,包含重力系数、初始速度区间、颜色渐变表、贴图索引。每个粒子只存必要的外部状态:当前位置、当前速度、剩余生命。
class ParticleType { public: ParticleType(std::string textureName, float gravity, float drag, int colorStart, int colorEnd, float size) : textureName_(std::move(textureName)), gravity_(gravity), drag_(drag), colorStart_(colorStart), colorEnd_(colorEnd), size_(size) {} const std::string& textureName() const { return textureName_; } float gravity() const { return gravity_; } float drag() const { return drag_; } int colorStart() const { return colorStart_; } int colorEnd() const { return colorEnd_; } float size() const { return size_; } private: const std::string textureName_; const float gravity_; const float drag_; const int colorStart_; const int colorEnd_; const float size_; }; struct Particle { float x, y; // 当前位置 float vx, vy; // 当前速度 float life; // 剩余生命 const ParticleType* type; // 享元 };第二层:粒子本身用对象池管理。这个对象池和享元模式是两个互补的东西,很多初学者会把它们搞混。享元解决的是“共享重复数据”,对象池解决的是“复用对象实例、减少创建销毁开销”。粒子系统两者都用:ParticleType 享元共享粒子的静态属性,Particle 对象池复用粒子实例。两者结合的效果是,粒子创建时type指针直接赋值,销毁时只是把对象放回池里,完全不触发内存分配。
这就是我在文首说的:享元模式在 C++ 里往往不是单独出现的,它和对象池、缓存、驻留这些资源管理手段经常耦合在一起。你需要有这种“组合拳”的意识,而不是只知道单点套路。
5.3 性能对比:共享前后的实测数据
我当时在做这个重构时,简单用std::chrono测了一下:5000 颗粒子持续生成和销毁,持续 5 秒。改造前每次生成新粒子都要 new 一个 Particle 对象,每个粒子都要拷贝纹理名、重力和一堆参数;改造后 ParticleType 只创建 4 个,粒子本身从对象池取。结果很直观:分配次数从几十万次降到接近零,运行时间降了大约 74%,内存占用降了 40% 左右。注意不同硬件、不同实现数据会有浮动,但数量级差异一定在。
这里给你一个经验判断:**如果你的对象里有 std::string、std::vector 这种带堆内存的成员,而且创建频率高,你就是享元模式的重点怀疑对象。**因为 string 和 vector 的拷贝不是简单赋值,而是要分配内存、复制字符,成本可能比你想象的还高。
6. 实战案例二:开放世界地图 tile 管理
6.1 2D 地图里几百万个 tile 的内存挑战
游戏里另一个典型场景是 2D 地图的 tile(瓦片)管理。一张 2000x2000 的地图,有 400 万个格子的 tile。每个 tile 需要记录地面类型(草地、泥土、石头、水面)、是否可通行、贴图编号、光照值、湿度等。如果给每个格子都建一个对象,400 万个对象 × 几十字节 = 上百兆内存,移动端直接爆掉。
但实际上地图里 tile 类型的数量是有限的。一张地图可能只有几十种地面类型,大部分格子的“静态属性”完全一样。坐标才是每个格子不同的外部状态。
6.2 享元 + 稀疏存储的降维打击
这个场景里享元模式的建模非常优雅:每种地形类型做享元对象,地图只保存一个类型索引数组,用 uint8_t 或 uint16_t 表示每个格子对应哪种地形,而不是保存完整对象。
class TileType { public: TileType(int textureId, bool walkable, int movementCost) : textureId_(textureId), walkable_(walkable), movementCost_(movementCost) {} int textureId() const { return textureId_; } bool walkable() const { return walkable_; } int movementCost() const { return movementCost_; } private: const int textureId_; const bool walkable_; const int movementCost_; }; class TileMap { public: TileMap(int width, int height, std::shared_ptr<TileTypeFactory> factory) : width_(width), height_(height), factory_(std::move(factory)) { // 400 万格子,每个格子 2 字节索引 + 1 字节光照,比完整对象小太多 data_.resize(width * height); } const TileType* tileAt(int x, int y) const { int idx = y * width_ + x; return factory_->getTileType(data_[idx]).get(); } private: int width_; int height_; std::vector<uint16_t> data_; // 存的是 TileType 的索引/id std::shared_ptr<TileTypeFactory> factory_; };这里有个值得关注的细节:data_里存的是索引而不是指针。为什么不用const TileType*?因为指针在 64 位系统下占 8 字节,400 万个指针就是 32MB;用uint16_t(最多支持 65536 种地形)只要 8MB,省了四倍。当然,如果你地图类型数超过 65535,可以用uint32_t,仍然比指针省。这种通过索引引用享元对象的手法,在大型网格结构里比指针更符合工程直觉,也更容易序列化保存和网络同步。
我看到过有团队把光照值也做成了享元对象,理由是“光照值只有 256 种”,这样每个格子只存一个 uint8_t 索引,然后查全局光照表拿颜色值。这就把享元用到了极致:能枚举的就枚举,能共享的就共享,剩下不可共享的只有坐标信息本身。
6.3 地图编辑器的撤销/重做如何受益于享元
额外说一个很多人都没意识到的点:享元模式对实现“撤销/重做”功能很有帮助。如果你的地图对象每个 tile 都保存完整的地形类型,撤销一次操作你可能要深拷贝一大片区域的数据。但如果 tile 只是指向 TileType 的索引,那么“改变一块地图区域的地形”本质上只是修改了一小段索引数组,复制这段数据的成本极低。
这也是为什么大世界游戏编辑器即使性能吃紧,还是会坚持用 tile 索引 + 类型表的结构——它不只省内存,还让很多高频操作变得异常便宜。享元模式的价值,很多时候不在表面内存优化上,而是连锁地影响功能实现的复杂度和响应速度。
7. 常见误用与排查技巧:什么样的场景不适合享元
7.1 享元模式不是银弹:这些情况别硬用
任何一个设计模式都有适用范围,享元模式也不是处处适用。我总结了自己踩过和帮别人审代码时见过的几种误用场景,你在决定是否采用之前,先对照一遍。
**第一,对象个数不多时,纯属画蛇添足。**如果你的系统里最多同时存在几十个对象,每个对象也就几十字节,那享元带来的收益几乎可以忽略不计,反而引入了工厂、键值映射、生命周期管理等额外复杂度。模式的成本大于收益,这个是原则问题。我的建议是:当对象数量级达到“万”以上,或者对象创建频繁到影响了 profile 数据,才值得系统性考虑享元。
**第二,外部状态远大于内部状态时,享元没有意义。**如果每个对象几乎所有的字段都不同,只有一两个小字段是重复的,那共享那一点数据不值得引入整个工厂体系。比如你有一百万个坐标点对象,每个点只有 x、y 和一个“是否是整数坐标”的标记,你把标记共享了又怎样?坐标本身已经占了绝大部分内存。洞察在于:享元的收益取决于“重复数据量”和“独有数据量”的比值,重复数据占比越高,收益越大。
**第三,内部状态需要频繁修改时,是强烈的反模式信号。**如果你设计出一个享元对象,然后发现业务逻辑里经常要改它的某个字段,恭喜你,你把可变全局状态包装成“设计模式”了。共享对象一改,所有引用它的地方全被影响,这种 bug 往往非常隐蔽。正确做法是把那个频繁变化的字段从享元中移到外部状态中,让它跟着宿主走。
7.2 内存池、单例、缓存:它们跟享元是什么关系
很多人分不清享元和几个相似概念的区别,面试时也容易被追问,这里一并说清楚。
- 享元模式 vs 对象池:享元强调“同一个实例被多处共享”,对象池强调“实例被回收复用,但同一时刻通常只被一个使用者持有”。粒子系统里粒子本身是对象池,粒子类型是享元,两者可以同时存在。
- 享元模式 vs 单例模式:单例保证一个类只有一个实例,享元保证“同一 key 只有一个实例”。单例是享元的一种极端特例(key 只有一个),但享元更灵活,按需创建多个不同的共享实例。
- 享元模式 vs 缓存:缓存更强调“减少重复计算”,可以缓存任何数据;享元核心是“减少重复对象”,必须区分内部/外部状态。一个系统可以同时用缓存存储计算结果、用享元共享对象结构,侧重点不同。
记住这些区别的好处是:你在设计方案时能更精确地说出“这里我要用享元,是因为有几个重复对象;那里我要用缓存,是因为计算结果可复用”,而不是笼统地说“我要用一个池子”。
7.3 排查技巧:如何判断你的系统是否需要享元
我分享几个实用的判断手段,不一定非要写代码跑 profile 才能发现:
- 用工具看对象数量和内存构成。在性能分析器里抓一帧或一段逻辑的对象分配,看有没有大量同构小对象。Visual Studio 的 Diagnostic Tools、heaptrack(Linux)、Valgrind massif 都能查看。
- 看对象的成员里有没有“高重复度的大字段”。比如每个对象都有一份相同的 std::string 配置、相同的路径、相同的纹理指针。这时候就可以试着把公共字段往外抽。
- 看你是否频繁调用“等值比较”。如果代码里写了大量
if (a.configString == b.configString)这样的等价判断,很可能这些 configString 本来应该驻留共享,直接用指针比较。 - 看构造函数的参数。如果构造对象时需要传大量“配置类”参数,而且这些参数组合数量有限,那就是享元模式的高发区——你应该把“配置组合”固化成享元对象,而不是每次重新组装。
这里有个反直觉的提示:享元模式未必能减少代码量,它甚至会“增加”代码量(工厂、键值映射、查询逻辑),但它是用代码复杂度换运行效率和内存。所以在做技术决策时,不要只盯着“代码行数”或“用了几个设计模式”来评判,要看最终的性能指标和可维护性。
7.4 元数据与序列化的特殊注意事项
最后补充一个工程细节:享元对象如果涉及序列化(存档、网络同步、配置文件导出),你要格外小心。
比如你有一个 FontStyleFactory,内部持有了 10 个 FontStyle 享元对象。存档时你保存的是每个 Glyph 的style指针,但你不可能把指针值直接写进文件——下次程序启动时,指针是完全无效的。正确做法是给每个享元对象分配一个稳定的 id,序列化时写 id,反序列化时用 id 去重建或重新查询享元工厂。这跟前面地图 tile 用 uint16_t 索引而不是指针的思路是一样的:一旦跨越进程或跨越时间,指针就失效了,只有稳定的 id 才能安全传递。
有些项目在享元类和 id 之间维护一个静态映射表,每次工厂创建时自动分配自增 id;有些项目直接把 key 作为 id 序列化。这两种方式我都见过,各有优劣。自增 id 短小高效,但反序列化时必须确保 id 和对象的映射顺序一致;用 key 做 id 就不用额外维护映射,但 key 往往比较长。权衡下来我在实际中更偏爱“key 就是 id,但 key 要设计得足够短”。比如地图 tile 类型,你完全可以用一个枚举值作为 key 和 id,序列化和反序列化都毫无压力。
8. 面试考点与复盘:如何在八股之外讲出实战深度
8.1 高频考点速览
C++ 方向的设计模式面试,享元模式出现频率不算最高,但一旦出现,通常会连带考察这些知识点:
- 内部状态 / 外部状态的区分,以及一个具体例子
- 享元对象为什么不可变(const 正确性)
- 享元工厂的线程安全性,如何避免重复创建
- 与对象池、单例、缓存的区别
- 字符串驻留与指针生命周期的关系
- 序列化场景下指针 vs id 的选择
面试官如果追问“什么时候不该用享元模式”,其实考察的是你是否真正理解了模式的收益模型,而不是背概念。能答出“当重复数据占比很低、或对象数量很少时不值得”的候选人,通常被认为有工程判断力。
8.2 我建议你亲手做的两个练习
只看文章不动手,学到的东西很容易忘。建议你做两个小练习,都不难但非常有效:
练习一:字符串驻留池。写一个 StringPool 类,用std::unordered_set<std::string>做驻留,提供intern方法。然后用 100 万个重复字符串测内存,观察驻留前后的差异。这个练习能帮你建立“共享字符串 vs 复制字符串”的直觉,还能顺便练到 unordered_set 和 char* 生命周期。
练习二:把文本渲染的 Glyph 做享元重构。像我前面那样定义 FontStyle 和 Glyph,先写未优化的版本,再用 FontStyleFactory 重构。对比两个版本的sizeof(Glyph)、创建耗时和总内存。这个练习能让你完整走一遍“发现问题 -> 设计共享方案 -> 实现工厂 -> 验证收益”的流程,比看十篇理论文章都有用。
8.3 从模式到工程思维的延伸
说了这么多,最后想聊点在这类技术文章里不太常提、但实际工程中特别重要的东西。
设计模式本质上是一套“问题-方案”的对应关系,但你不能只盯住模式本身。享元模式教会我的,更多是一种思维习惯:**在动手写“每个对象都带一份数据”之前,先问自己,这份数据真的只属于这一个对象吗?有没有可能被其他人复用?如果我把数据抽出来共享,以什么机制保证一致性?**这种“先剥离共性,再处理特性”的思路,不仅在 C++ 里有用,在任何面对大量相似对象的系统里都有用。
我见过不少代码,没有用任何“设计模式”,但那种把公共配置集中管理、对象只存关键索引的写法,本质上就是享元思想的内化。反过来说,也有一些项目高调声称“用了享元模式”,但内部对象还在复制大字符串,只能算披着模式外衣的反模式。真正重要的不是你会不会背享元的定义,而是你能不能在实际代码里敏锐地发现“这里有一份不该被反复复制的数据”。这个敏感度,是在一次次性能优化和内存排查中磨出来的,也是区分“会用模式”和“能把代码写好”的关键之一。
如果你现在正被某个内存爆炸或对象创建过慢的问题困扰,不妨先画一下你的对象结构图,把字段分成“大家一样”和“每人不同”两堆,再想想后一堆里有多少可以靠索引、id、指针来引用前一堆。一步步拆解下来,你会发现享元模式不是从天而降的设计灵感,而是一个很自然的、顺着数据形态走出来的必然结论。