资讯动态

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

发布时间:2026/10/12 22:54:41 来源:尧图企业网站定制
如果让我从自己做过的一个项目说起事情就简单多了。当时我在做一块地图类模块地图上的地块对象数量大得吓人每块地都带着坐标、贴图路径、碰撞参数、地形属性等一串字段。对象实例一多内存直接飙升优化来优化去都没什么起色。后来我把“贴图资源、碰撞参数、地形规则”这类在多个地块之间高度重复的数据单独抽出来让每个地块只保存自己的坐标和指向共享数据的句柄内存占用立刻降了一个量级。那次重构用的核心思想就是标题里写的这个享元模式 C。这篇文章我不会把享元模式讲成教科书上的定义而是结合可编译的代码、工程里踩过的坑和几笔收益测算把“享元模式 C”从原理到落地一次讲清楚。适合已经能熟练写C、但还没系统性接触过结构型模式优化思路的开发者也适合手上正有大数量对象内存问题、想找个现成方案的工程人员。1. 核心概念先搞清楚哪些状态该共享1.1 为什么需要享元而不是一直new“直接创建对象不就好了吗”这是新手最常问的问题。直接new不是不行而是当对象数量达到百万级的时候内存开销和构造开销会变成不可忽视的成本。可以这样理解假设我做一个文本渲染模块屏幕上每显示一个字符都要创建一个字符对象里面包含字体、字号、颜色再加上x、y坐标。一个对象按40字节算一万个字符就是400KB看起来不多但如果是几十万字符的大文档或者成千上万个粒子对象内存就会变成十几MB甚至上百MB的负担。可仔细看一眼会发现这上万个字符里字体组合其实只有十几种。真正“每个字符不一样”的只有坐标。至于字体、字号、颜色绝大多数是完全重复的。享元模式做的就是这件事把对象拆成两部分一份是“重复出现的固有信息”一份是“使用时才有的上下文信息”。前者设计成共享对象后者由调用方持有两者配合起来才构成一个完整逻辑对象。同样一万个字符经过这种拆分后共享样式可能只有12个或更少真正逐字符创建的对象只需要保存“指针 坐标”这种超小结构。内存占用从“样式规模 × 实例数量”变成“样式规模 轻量实例数量”收益非常直接。1.2 内部状态与外部状态这条线不能画错享元模式里有专门术语所有资料都会反复强调内部状态存储在享元对象内部对任何使用场景都保持不变。比如字体名、字号、颜色、贴图路径、材质参数、碰撞体积。这类状态是真正能被共享的部分。外部状态与使用场景强相关随实例不同而变化由客户端持有并在调用时通过参数传入。比如坐标、偏移、当前动画帧、朝向角度。我见过不少失效案例问题几乎都出在同一点把本应该作为外部状态的内容写进了享元对象里。比如有人给共享样式里加了一个“位置”字段结果第一个字符在x1后面所有字符都跟着变成x1。排查半天发现本质就是把内部状态和外部状态的边界画错了。判断方法其实很简单如果某个状态在多个使用场景中会不一样它就是外部状态只有永远不随上下文变化的状态才有资格放进享元对象。共享的东西必须是“无差异的”否则它就不该共享。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实现核心是三部分共享样式类、样式工厂类、使用工厂的字符对象集合。代码基于C11/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_ptrconst 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_sharedconst CharacterStyle(font, size, color); pool_[key] style; return style; } size_t pool_size() const { return pool_.size(); } private: std::unordered_mapstd::string, std::shared_ptrconst CharacterStyle pool_; }; // 3. 外部对象字符实例保存坐标和指向享元的智能指针 struct Character { int x; int y; std::shared_ptrconst 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::vectorCharacter 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_ptrconst CharacterStyle暴露。这个设计很重要享元对象的内部状态一旦创建就不允许改动所有变化都靠外部状态补充共享才不会出问题。比较characters[0]和characters[9999]是否共享样式时比较的是shared_ptr本身也就是比较内存地址这能直接验证享元的唯一性。如果拿值类型去比较就得不到这种效果。3.3 运行结果与内存估算运行输出大致是字符对象总数: 20000 共享样式数量: 2 第1个字符和第10000个字符是否共享样式: 是 char(0, 0) fontSimSun size12 char(0, 0) fontSimHei size16如果不做享元两万个对象各自保存字体、字号、颜色一个对象大约40字节总内存约800KB多。用享元之后两个共享样式对象只占不到100字节两万个字符实例各只保存坐标和智能指针大约每个16字节总内存约320KB。这个例子规模小收益看起来只是几倍差距如果对象数量上千万差距就是几十MB甚至几百MB。4. 工程现场我在实际写代码时踩过的坑4.1 遇到的第一类坑内部状态被意外修改有个项目里我把共享的地形参数对象直接暴露给了客户端客户端在处理某一块地块时顺手改了一个字段比如把“是否可通行”改成了false。结果地图上所有共享该地块类型的区域全部变成了不可通行。那次排查了很久最后才意识到是共享对象被某个调用方局部修改了。修复方式也很直接把享元对象设计成只读的所有内部状态字段对外不可写。如果调用方确实需要基于某个共享模板做局部变化就复制一份生成独立对象不要直接改共享数据。教训就是享元对象的内部状态必须是不可变的。谁想改谁就复制。共享出来的东西本质上以“只读”为前提。4.2 遇到的第二类坑悬垂指针和生命周期混乱早期我用裸指针做享元返回工厂持有池子客户端直接用指针。后来有个模块释放顺序不稳定客户端在某个异常分支里把工厂的享元对象delete了紧接着其他客户端继续访问这块内存程序随机崩溃。后来我把返回类型统一改成shared_ptrconst 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()拿副本边界永远不会乱。关于享元模式还有一点我想特别提一下。工程里做优化的第一原则永远是先测量再动手不要凭感觉觉得“对象多就该用享元”。先用工具确认内存瓶颈确实来自重复的内部状态再看对象数量级和状态组合数之间的关系最后再决定是否引入工厂、池子和智能指针。方向上没问题但细节决定这个模式是帮你省内存还是帮你的代码库增加一整套需要维护的复杂度。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑