ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++享元模式实战:从原理到内存优化

C++享元模式实战:从原理到内存优化

如果让我从自己做过的一个项目说起,事情就简单多了。当时我在做一块地图类模块,地图上的地块对象数量大得吓人,每块地都带着坐标、贴图路径、碰撞参数、地形属性等一串字段。对象实例一多,内存直接飙升,优化来优化去都没什么起色。后来我把“贴图资源、碰撞参数、地形规则”这类在多个地块之间高度重复的数据单独抽出来,让每个地块只保存自己的坐标和指向共享数据的句柄,内存占用立刻降了一个量级。那次重构用的核心思想,就是标题里写的这个:享元模式 C++。

这篇文章我不会把享元模式讲成教科书上的定义,而是结合可编译的代码、工程里踩过的坑和几笔收益测算,把“享元模式 C++”从原理到落地一次讲清楚。适合已经能熟练写C++、但还没系统性接触过结构型模式优化思路的开发者,也适合手上正有大数量对象内存问题、想找个现成方案的工程人员。

1. 核心概念:先搞清楚哪些状态该共享

1.1 为什么需要享元,而不是一直new

“直接创建对象不就好了吗?”这是新手最常问的问题。直接new不是不行,而是当对象数量达到百万级的时候,内存开销和构造开销会变成不可忽视的成本。

可以这样理解:假设我做一个文本渲染模块,屏幕上每显示一个字符都要创建一个字符对象,里面包含字体、字号、颜色,再加上x、y坐标。一个对象按40字节算,一万个字符就是400KB,看起来不多;但如果是几十万字符的大文档,或者成千上万个粒子对象,内存就会变成十几MB甚至上百MB的负担。

可仔细看一眼会发现,这上万个字符里,字体组合其实只有十几种。真正“每个字符不一样”的只有坐标。至于字体、字号、颜色,绝大多数是完全重复的。享元模式做的就是这件事:把对象拆成两部分,一份是“重复出现的固有信息”,一份是“使用时才有的上下文信息”。前者设计成共享对象,后者由调用方持有,两者配合起来才构成一个完整逻辑对象。

同样一万个字符,经过这种拆分后,共享样式可能只有12个或更少,真正逐字符创建的对象只需要保存“指针 + 坐标”这种超小结构。内存占用从“样式规模 × 实例数量”变成“样式规模 + 轻量实例数量”,收益非常直接。

1.2 内部状态与外部状态,这条线不能画错

享元模式里有专门术语,所有资料都会反复强调:

  • 内部状态:存储在享元对象内部,对任何使用场景都保持不变。比如字体名、字号、颜色、贴图路径、材质参数、碰撞体积。这类状态是真正能被共享的部分。
  • 外部状态:与使用场景强相关,随实例不同而变化,由客户端持有,并在调用时通过参数传入。比如坐标、偏移、当前动画帧、朝向角度。

我见过不少失效案例,问题几乎都出在同一点:把本应该作为外部状态的内容写进了享元对象里。比如有人给共享样式里加了一个“位置”字段,结果第一个字符在x=1,后面所有字符都跟着变成x=1。排查半天,发现本质就是把内部状态和外部状态的边界画错了。

判断方法其实很简单:如果某个状态在多个使用场景中会不一样,它就是外部状态;只有永远不随上下文变化的状态,才有资格放进享元对象。共享的东西必须是“无差异的”,否则它就不该共享。

1.3 典型场景和反面场景

我实际用过的几个场景可以列出来供参考:

场景内部状态(可共享)外部状态(不共享)
文本编辑器/终端渲染字体、字号、颜色、字形属性字符坐标、光标偏移
游戏地形/棋局棋盘贴图、Mesh、碰撞参数、地形规则地块坐标、地块归属
粒子系统粒子贴图、混合模式、更新逻辑粒子位置、速度、生命期
数据库连接复用连接配置、网络句柄当前会话参数、事务状态

反过来看,有两种情况不适合用享元。第一种是对象之间的重复率极低,比如每个对象几乎都是独有数据,共享收益很小,反而多出工厂管理和查找开销。第二种是对象状态经常在原地被修改,共享就会互相污染,这种场景应该复制而不是共享。还有一种情况是对象数量总共只有几十个,系统内存毫无压力,引入享元只是为了“用设计模式”而用,纯属增加复杂度。

2. 在C++里实现享元,绕不开的三个设计点

2.1 用指针、引用还是值来共享

用C++实现享元,第一步要考虑“怎么表示多个对象指向同一个东西”。

如果直接用值类型,比如TextStyle style;,赋值会发生拷贝,两个对象天然就是两份内容,共享无从谈起。想实现共享,必须用指针或引用。

引用在C++里不能让容器很方便地保存和重赋值,所以工程实践里我更习惯用指针。具体用裸指针还是智能指针,取决于生命周期归属。如果工厂持有的享元生命周期一定长于所有使用者,裸指针就够用;如果使用者可能乱序释放、顺序不可控,我会建议工厂统一用shared_ptr管理,返回时也返回shared_ptr,至少避免悬垂访问。

注意:享元里的“共享”意味着内存地址完全相同,不是“值相等”。判断是否真正共享时,直接比较地址是最稳的办法。

2.2 工厂负责创建,也负责生命周期

享元模式通常不会让客户端直接new,而是提供一个工厂类做统一入口。工厂内部维护一个池,第一次遇到某组内部状态就创建并保存,后面再遇到直接返回已有对象。这样就能保证“相同内部状态的享元全局只有一份”。

工厂同时要负责生命周期。我的习惯是:享元对象的创建和销毁都只在工厂内部进行。客户端拿到返回的指针后只负责使用,坚决不做释放。如果池里的享元可能被外部引用,就用shared_ptr持有,避免对象在仍有使用者时被销毁。

这里有个很容易踩的坑:有些团队为了让“不用了就能释放”就把享元对象引用计数降到0就删除,结果对象还被其他客户端引用着,程序直接崩溃。享元的生命周期语义应该是“池中存活 + 引用计数”,与对象池的“借出归还”有很大差别,后面会专门说。

2.3 池的Key怎么设计更合理

工厂查重需要比较“内部状态是否相同”,池的Key设计方案直接影响性能和可维护性。

最直观的做法是把内部状态的几个字段拼成一个字符串,例如"SimSun#12#0xFF0000"。优点是简单直观,调试时一眼能看懂;缺点是每次都构造字符串,对高频查找不太友好。如果状态字段只有一两个,字符串开销还能接受;字段一多,拼接成本就上来了。

更高效的做法是自定义一个结构体作为Key,实现operator==并提供一个哈希函数,直接放进unordered_map。这样不产生临时字符串,查询更快。代价是代码量多了一点。如果内部状态字段很少,也可以用std::tuple加自定义哈希,但可读性通常不如结构体清晰。

Key选择会直接影响工厂效率。在创建海量对象的场景里,如果每次get都拼一次字符串,可能抵消一部分共享带来的收益。我自己在项目和文档处理场景里踩过这个坑,后来改成结构体Key,性能才稳定下来。

3. 完整示例:一个足够真实的C++字符样式共享池

3.1 场景设定

假设我正在做一个文档渲染的模拟模块,需要生成两万个字符对象,但样式组合只有两种:宋体12号红色、黑体16号绿色。如果每个字符都完整保存一份样式,内存浪费非常明显。

这个例子用C++实现,核心是三部分:共享样式类、样式工厂类、使用工厂的字符对象集合。代码基于C++11/17,可以直接复制编译。

3.2 代码实现与关键说明

#include <iostream> #include <memory> #include <string> #include <unordered_map> #include <vector> // 1. 享元对象:字符样式。内部状态就是字体、字号、颜色。 class CharacterStyle { public: CharacterStyle(std::string font, int size, unsigned int color) : font_(std::move(font)), size_(size), color_(color) {} const std::string& font() const { return font_; } int size() const { return size_; } unsigned int color() const { return color_; } private: std::string font_; int size_; unsigned int color_; }; // 2. 工厂:负责创建并缓存享元对象 class CharacterStyleFactory { public: std::shared_ptr<const CharacterStyle> get( const std::string& font, int size, unsigned int color) { // 用结构体或字符串作为Key都可以,这里用字符串便于演示 std::string key = font + "#" + std::to_string(size) + "#" + std::to_string(color); auto it = pool_.find(key); if (it != pool_.end()) { return it->second; } auto style = std::make_shared<const CharacterStyle>(font, size, color); pool_[key] = style; return style; } size_t pool_size() const { return pool_.size(); } private: std::unordered_map<std::string, std::shared_ptr<const CharacterStyle>> pool_; }; // 3. 外部对象:字符实例,保存坐标和指向享元的智能指针 struct Character { int x; int y; std::shared_ptr<const CharacterStyle> style; }; void print_character(const Character& ch) { std::cout << "char(" << ch.x << ", " << ch.y << ") " << "font=" << ch.style->font() << " size=" << ch.style->size() << "\n"; } int main() { CharacterStyleFactory factory; std::vector<Character> characters; // 生成 10000 个宋体 12 号红色字符 for (int i = 0; i < 10000; ++i) { characters.push_back({ i / 100, i % 100, factory.get("SimSun", 12, 0xFF0000) }); } // 生成 10000 个黑体 16 号绿色字符 for (int i = 0; i < 10000; ++i) { characters.push_back({ i / 100, i % 100, factory.get("SimHei", 16, 0x00FF00) }); } std::cout << "字符对象总数: " << characters.size() << "\n"; std::cout << "共享样式数量: " << factory.pool_size() << "\n"; // 验证两个相同样式的字符确实共享同一个享元对象 bool same_style = (characters[0].style == characters[9999].style); std::cout << "第1个字符和第10000个字符是否共享样式: " << (same_style ? "是" : "否") << "\n"; print_character(characters[0]); print_character(characters[10000]); return 0; }

代码里我特意让CharacterStyle不可修改,所有成员都是const风格,通过shared_ptr<const CharacterStyle>暴露。这个设计很重要:享元对象的内部状态一旦创建就不允许改动,所有变化都靠外部状态补充,共享才不会出问题。

比较characters[0]和characters[9999]是否共享样式时,比较的是shared_ptr本身,也就是比较内存地址,这能直接验证享元的唯一性。如果拿值类型去比较,就得不到这种效果。

3.3 运行结果与内存估算

运行输出大致是:

字符对象总数: 20000 共享样式数量: 2 第1个字符和第10000个字符是否共享样式: 是 char(0, 0) font=SimSun size=12 char(0, 0) font=SimHei size=16

如果不做享元,两万个对象各自保存字体、字号、颜色,一个对象大约40字节,总内存约800KB多。用享元之后,两个共享样式对象只占不到100字节,两万个字符实例各只保存坐标和智能指针,大约每个16字节,总内存约320KB。这个例子规模小,收益看起来只是几倍差距;如果对象数量上千万,差距就是几十MB甚至几百MB。

4. 工程现场:我在实际写代码时踩过的坑

4.1 遇到的第一类坑:内部状态被意外修改

有个项目里,我把共享的地形参数对象直接暴露给了客户端,客户端在处理某一块地块时,顺手改了一个字段,比如把“是否可通行”改成了false。结果地图上所有共享该地块类型的区域全部变成了不可通行。

那次排查了很久,最后才意识到是共享对象被某个调用方局部修改了。修复方式也很直接:把享元对象设计成只读的,所有内部状态字段对外不可写。如果调用方确实需要基于某个共享模板做局部变化,就复制一份生成独立对象,不要直接改共享数据。

教训就是:享元对象的内部状态必须是不可变的。谁想改,谁就复制。共享出来的东西,本质上以“只读”为前提。

4.2 遇到的第二类坑:悬垂指针和生命周期混乱

早期我用裸指针做享元返回,工厂持有池子,客户端直接用指针。后来有个模块释放顺序不稳定,客户端在某个异常分支里把工厂的享元对象delete了,紧接着其他客户端继续访问这块内存,程序随机崩溃。

后来我把返回类型统一改成shared_ptr<const CharacterStyle>,工厂池也持有shared_ptr,客户端无法主动释放享元,只能等引用全部结束再由智能指针管理。这样虽然多了一点引用计数的开销,但生命周期安全了很多。

建议:不要让客户端对享元对象执行任何释放操作。要么工厂完全控制生命周期,要么统一使用shared_ptr。两者选其一,不要混用。

4.3 第三类坑:把享元模式当成对象池

享元和对象池很容易混淆,实际是两种完全不同的目的。对象池解决的是“创建和销毁成本高”的问题,对象可以复用,但复用时状态会被重置;享元解决的是“内存中同时存在大量重复数据”的问题,共享对象本身不随使用场景变化。

我见过有人拿着享元工厂当连接池用,每次用完把连接“还回池里”,结果连接对象里的会话状态也被当作内部状态保存,下一次复用拿到全是脏数据。这就是把两个模式的语义搅在一起了。连接池应该关注资源复用,享元应该关注数据共享,如果一个对象需要频繁重置状态再复用,请不要用享元。

5. 收益到底有多大:一份简单的数学账

5.1 不复杂的内存占用估算

假设有N个实例,每个实例保存M字节内部状态和E字节外部状态。不做享元时总内存大约是N × (M + E)。做了享元后,假设内部状态种类只有K种,那么总内存大约是K × M + N × E,另外加上存索引或指针的少量开销。

两者差值就是N × M - K × M = (N - K) × M。当N远大于K的时候,节省的内存就非常可观。这也是为什么享元在“大量实例 + 少量变化组合”的场景中特别有效。

我做过一个实际估算:一百万个对象,每个对象内部状态占32字节,不共享时是32MB;如果内部状态只有100种组合,共享后内部状态只占3.2KB,几乎可以忽略。省下来的内存直接决定这个程序能不能继续扩展下去。

5.2 哪些情况下收益不升反降

享元不是无条件划算。工厂查询本身有开销,每次get都要做Key查找;如果内部状态组合特别多,池子膨胀,查找成本会上升。更关键的是,如果创建对象时每次都要动态分配shared_ptr,分配和析构的开销也会被放大。

还有一种常见情况:如果外部状态占的比重本来就很大,内部状态占比很小,共享收益就不明显。比如一个对象90%的数据都是坐标和临时数据,只有10%是可共享的,那共享优化的天花板就在那里,不要指望它解决全部内存问题。

顺便说一句,如果实例数是几千甚至更少,内存毫无压力,引入享元的收益几乎为零。把同一个模式硬套上去,只会让代码多一层抽象,反而不如直接创建对象来得清爽。

6. 享元模式和其他兄弟模式的组合玩法

6.1 配合工厂方法,统一创建入口

享元工厂本身就是一个工厂方法思想的体现。如果项目里创建对象的过程比较复杂,比如还要读配置、做默认值填充,我会在享元工厂内部再拆一层工厂方法,让不同子类或不同策略来生成不同内部状态的享元。这样既保持了享元池的查重逻辑,又让“对象怎么构造”的细节和“对象怎么共享”的逻辑分离。

6.2 配合单例模式,全局共享更省心

有些项目中享元工厂只需要一个实例,全局所有模块都从同一个工厂获取共享对象。这种场景可以把工厂做成单例,或者封装成静态方法。但要注意线程安全:如果多线程同时调用get,池子的读写在无锁情况下会产生数据竞争。至少要加一把互斥锁,或者对池子做初始化后只读的优化。

我实际做过的做法是:如果共享数据在启动阶段确定,之后不再新增,可以先把池子预热完,再进入只读状态,这样运行时完全不需要加锁。如果运行时还会创建新样式,那就必须考虑锁或线程局部池。

6.3 配合原型模式,快速产生带变化的副本

享元解决共享,原型解决复制。当一个调用方拿到共享的样式后,确实需要修改或扩展某些字段,比如字号的临时放大、颜色的高亮处理,我不会直接改享元对象,而是用原型模式克隆一份,然后在新对象上修改。

两个模式一配合,就形成很舒服的组合:大量纯默认场景使用享元共享,少数特殊场景使用原型克隆,既不互相污染,又能兼顾性能和灵活性。

我自己在执行类似需求时,会在享元对象上提供一个clone()方法,返回一个独立副本。这样调用方思路清晰:需要共享就调用工厂拿引用,需要独立就调用clone()拿副本,边界永远不会乱。

关于享元模式,还有一点我想特别提一下。工程里做优化的第一原则永远是先测量再动手,不要凭感觉觉得“对象多就该用享元”。先用工具确认内存瓶颈确实来自重复的内部状态,再看对象数量级和状态组合数之间的关系,最后再决定是否引入工厂、池子和智能指针。方向上没问题,但细节决定这个模式是帮你省内存,还是帮你的代码库增加一整套需要维护的复杂度。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进