☰
C++享元模式实战:五大变体与工程踩坑记录
2026/9/26 13:50:32 网站建设 项目流程

说实话,很多人一听到“享元模式”就想到“共享对象省内存”,然后就不深究了。但真正在C++工程里打过仗的人都清楚,享元模式在C++里能玩出的变体,远比GoF书上那一页纸要丰富得多。我最早对这件事有体感,是在做渲染引擎时:场景里有几十万个重复的树木模型,如果把每棵树的网格、材质、名字全部拷贝一份,不仅内存爆炸,缓存也直接被击穿。后来把公共资源抽出来共享,问题立刻缓解,但紧接着又遇到线程安全、生命周期、外部状态怎么传等一系列麻烦。这篇文章我就把C++里享元模式常见的几种变体串一遍,从经典写法到驻留式、懒加载、写时复制、编译期常量表,全部配上能跑的代码和踩坑记录,适合写过一些C++、想搞清楚共享对象到底怎么设计的人。

1. 享元模式到底在解决什么问题

1.1 内部状态与外部状态先掰开

享元模式的核心不是“共享”,而是“把对象拆成两部分”:一部分是任何场景下都不变的内部状态,另一部分是每次使用时才确定的外部状态。

内部状态适合共享,因为它不会因为使用场景不同而变化。比如一段文字里的字体信息:在同一个文本编辑器里,“黑体 12号 加粗”这个描述可能在几万个字符上重复出现。如果把完整的字体格式对象塞进每个字符里,那就是灾难。共享的、只读的字体对象就是内部状态。

外部状态则是字符的位置、缩放、颜色这些每个字符都不同的数据。它们不能放进共享对象里,否则共享对象就被污染了。经典做法是把外部状态作为参数传给操作函数,而不是塞进对象成员里。

这两者的边界看起来很清晰,但实际工程里经常有人搞混。比如有人把“当前选中状态”塞进共享的字体对象,结果一个按钮选中了,所有用这个字体的地方全变了,排查半天才发现是共享对象被写了。内部状态必须是不可变的,至少从对外接口上应该是不可变的。

1.2 什么时候值得上享元

不是所有场景都值得用享元。我总结出来四个前提,缺一个都别硬上。

第一,对象数量大,而且存在大量重复。如果整个程序里只有几十个同类对象,共享省下的内存可以忽略不计,但引入的复杂度是真的。

第二,对象能拆成内部和外部两部分。如果所有数据在生命周期内都无法拆分,享元模式就没有发力点。

第三,对象创建成本高,或者占用空间大。这里的成本包含内存分配、初始化计算、文件读取。如果对象创建只是几行赋值,那你更应该解决的是别的问题。

第四,外部状态可以被显式传递。享元对象本身没有外部状态,调用时必须由调用方提供。如果业务场景里根本没法把状态捎带上,那说明对象设计本身就不适合享元。

有一个常见的误解是把享元模式和对象池混为一谈。对象池是“时间上复用”,同一个对象被取走用完再归还,同一时刻只有一个使用者。享元是“空间上共享”,同一个对象可以同时被无数调用方使用,只是大家靠外部状态区分彼此。搞混这两点,后面代码会很难看。

1.3 享元、单例和原型,别在启动会就开始打架

单例模式是某个类在整个程序里只有一个实例,它通常负责某种全局能力。享元模式是一组相似对象共享重复部分,工厂里可能有一堆不同的享元实例,只是每一个实例可能被几百个地方引用。原型模式是拷贝原型来创建新对象,和享元正好相反,享元追求“别拷贝”。所以经常有面试题问“单例和享元什么关系”,我的回答是:享元工厂本身经常做成单例,但工厂里的产物是多个享元实例。一个是仓库,一个是仓库里的货。

2. 经典写法先走一遍

2.1 一个能跑的字符格式享元

直接给一个最小可复现的例子。假设我们做一个文本编辑器,每个字符串需要知道字体、字号、颜色。共享部分抽出来,做成一个不可变格式对象。

#include <memory> #include <string> #include <unordered_map> class CharFormat { public: CharFormat(std::string font, int size, uint32_t rgb) : font_(std::move(font)), size_(size), color_(rgb) {} const std::string& font() const { return font_; } int size() const { return size_; } uint32_t color() const { return color_; } private: std::string font_; int size_; uint32_t color_; }; class CharFormatFactory { public: std::shared_ptr<const CharFormat> get(const std::string& key) { if (auto it = cache_.find(key); it != cache_.end()) { return it->second; } auto fmt = std::make_shared<CharFormat>(parseKey(key)); cache_.emplace(key, fmt); return fmt; } private: static CharFormat parseKey(const std::string& key) { // 实际项目里可以在这里按约定格式解析:font:size:color if (key == "title") return CharFormat("Black", 24, 0x111111); if (key == "body") return CharFormat("SimSun", 12, 0x222222); return CharFormat("Fallback", 10, 0x333333); } std::unordered_map<std::string, std::shared_ptr<const CharFormat>> cache_; };

使用方这边,每个字符里不再存完整的格式对象,只存一个std::shared_ptr<const CharFormat>,加上自己的坐标和内容。

struct DisplayChar { std::shared_ptr<const CharFormat> fmt; char32_t codepoint; float x; float y; };

这个结构比“每个字符都存一个几十字节的格式对象”要小得多。而且由于所有body字符都指向同一个CharFormat,绘制时缓存命中率也会好很多。

2.2 工厂在哪里,生命周期跟谁走

经典写法里,工厂负责保证“相同键返回相同对象”,这是享元模式最重要的约定。一旦某个调用方绕过工厂直接new了一个格式对象,共享就破裂了。所以我的习惯是把构造函数放到私有区,只让工厂创建。这样虽然代码多几行,但能从编译期杜绝滥用。

生命周期方面,经典GoF实现用的是普通指针,整个池由程序死后统一释放。但在现代C++里我强烈建议用std::shared_ptr<const T>作为返回类型。原因很简单:共享对象存活周期不确定,谁也不知道某个外部组件会不会持有很久。如果工厂持有的只是弱引用,外部组件释放完毕后共享对象会自动消失,池子不会无限膨胀。如果你的业务场景里共享对象固定不变、数量极少,用unique_ptr+raw pointer返回也可以,但前提是你能保证“池对象一定比使用者活得久”。

2.3 经典写法最容易踩的坑

第一个坑是“修改共享对象”。很多人拿到std::shared_ptr<CharFormat>就随手setSize,一旦这个格式对象被300个字符共享,一次修改让整篇文档字体全变。解决办法很简单:共享对象一律以const接口暴露;就算想改,也应该是“新创建一个格式对象替换缓存”,而不是原地改。缓存里的键保持不变,值指向新的不可变对象,这样不会产生破坏性副作用。

第二个坑是“比较对象用属性而不是指针”。假设两个CharFormat内容相同,但来自不同工厂实例,属性逐个比较会带来无谓开销。如果大家都从同一个工厂拿对象,shared_ptr的指针比较就能判断“是否为同一个享元”,这比读属性快得多。这也是后面驻留式变体的理论基础。

3. 变体一:驻留式享元

3.1 字符串驻留的核心思想

字符串驻留是享元模式最典型的变体之一,很多语言里叫 intern。核心思想是:同一个字符串内容只在内存里保存一份,所有用到它的地方都指向同一份数据。这样有两个巨大好处:内存占用显著降低;字符串相等比较退化成指针比较,从O(n)变成O(1)。

这个技巧在解析器、编译器、游戏引擎的碰撞材质标签、网络协议的字段名等场景里非常实用。比如你解析一份配置文件,里面“position”出现了十万次,如果每次都存一份std::string,光是字符缓冲区就浪费一大片。用驻留池之后,所有“position”都指向同一个const char*。

对比经典享元,驻留式享元的内部状态就是“字符串内容”,外部状态就是“这个字符串是用作键、还是值、还是日志标记”。本质上,驻留池就是一个专门缓存字符串的享元工厂。

3.2 实现一个线程安全的Interner

下面这个实现是生产项目里可以直接用的“最小可用版”。它内部放一个std::unordered_set<std::string>,返回池内稳定字符串的指针。注意unordered_set的节点在哈希表扩容时不会移动,指向元素内容的指针是稳定的,这一点是安全返回const std::string*的前提。

#include <mutex> #include <string_view> #include <string> #include <unordered_set> class StringInterner { public: static StringInterner& instance() { static StringInterner pool; return pool; } const std::string* intern(std::string_view text) { std::lock_guard<std::mutex> lock(mutex_); auto [it, inserted] = pool_.emplace(text); (void)inserted; return &*it; } const std::string* intern(const char* text) { return intern(std::string_view(text)); } private: StringInterner() = default; std::mutex mutex_; std::unordered_set<std::string> pool_; };

使用方式很直接:

const std::string* key1 = StringInterner::instance().intern("health"); const std::string* key2 = StringInterner::instance().intern("health"); if (key1 == key2) { // 不需要 strcmp,指针相等就知道是同一个字符串 }

这里的关键点是:key1 == key2比较的是指针值,而不是字符串内容。内容相同且来自同一个驻留池,必定返回同一个指针;内容不同才可能返回不同指针。注意这里不能用compare来判断内容相同与否,只能用来做“是否为同一份”。

3.3 无限增长与淘汰问题

驻留池最讨厌的问题就是只增不减。调试日志里的临时字符串、随机拼接出来的消息,如果全去 intern,内存会缓慢泄漏,最终变成“活火山”。我的经验是:只对“数量有限、重复度高、生命周期长”的字符串做驻留。比如 JSON 字段名、枚举名、协议标签。日志模板、动态拼接文案这类字符串绝对不要放进去。

如果确实需要淘汰,推荐两个方向。一是引用计数型驻留,给每个字符串记录使用者数量,空闲后回收。二是带LRU策略的驻留池,但这个问题在C++里做起来比较繁琐,需要把unordered_set换成双向链表加哈希表,大多数项目不具备这种必要性。我的判断标准很简单:如果池子几千到几万条就能覆盖业务,就别去做淘汰;如果上百万元素还停不下来,你该反思是不是把不该驻留的东西放进来了。

4. 变体二:外部状态寄宿的三种写法

4.1 把外部状态包装成上下文对象

经典实现里,外部状态是零散参数,比如Draw(font, x, y)。但真实业务里外部状态往往有七八个字段:坐标、缩放、透明度、图元类型、渲染层级。全摊开成参数让函数签名变得很长。

这时候可以把外部状态打包成一个上下文对象:

struct RenderContext { float x, y; float scale; float alpha; int layer; }; class TileRenderer { public: void draw(const TileType& sharedType, const RenderContext& ctx) const { // 用共享类型 + 外部上下文一起计算绘制结果 } };

这样享元对象只保存“图块的类型、纹理索引、网格引用”,变化的部分全部收敛进RenderContext。上下文对象可以被多个绘制函数复用,甚至可以在函数间继续传递,不会有“每个调用方都要重写一遍十个参数”的痛。

4.2 用索引代替指针

这种做法可能很多C++程序员没往享元方向想,但它确实是享元的一个强力变体。大量实体遍历场景里,指针访问容易造成缓存不友好,因为你追踪mesh->position时,mesh 对象散落在堆各处。如果把共享资源全部放进连续数组,外部状态用uint32_t index而不是指针去引用,访问顺序会变得非常规整。

struct MeshData { // 顶点数据、AABB、材质信息 }; class MeshRegistry { public: uint32_t registerMesh(MeshData data) { meshes_.push_back(std::move(data)); return static_cast<uint32_t>(meshes_.size() - 1); } const MeshData* get(uint32_t index) const { return &meshes_[index]; } private: std::vector<MeshData> meshes_; };

每个实体实例不再持有std::shared_ptr<MeshData>,只持有一个uint32_t meshIndex。实体与实体之间共享的是同一个网格数据,区别只在索引。这个方案有两个额外好处:数组里的共享数据天然连续,遍历时命中率高;索引是平凡类型,拷贝、序列化、比较都极其便宜。

4.3 三种写法的取舍

写法优势劣势典型场景
零散参数传入最直观,调用点信息量大参数太多时难以维护参数少、调用点少
上下文对象结构清晰,便于扩展可能被各处传递造成冗余渲染、碰撞检测等复杂上下文
索引引用缓存友好,序列化友好需要额外的注册表管理生命周期大量实体遍历、ECS架构

我的建议是:共享资源数量少、调用频繁时用上下文对象;共享资源数量大、需要批量遍历时用索引。不要一开始就想着“最优雅”,先让外部状态能干净地传进共享对象方法里,后面再重构也来得及。

5. 变体三:共享实例也走懒加载

5.1 线程安全工厂的两种实现

享元工厂往往也是单例,所以线程安全是个绕不开的话题。C++11之后,最省心的方案是Meyers Singleton,一个函数内静态局部变量就够了,标准保证初始化线程安全。

class SharedResource { public: static SharedResource& instance() { static SharedResource res; return res; } };

它背后的原理是c++11的magic static:函数第一次进入时,另一个线程同时进入会被阻塞直到初始化完成。这个机制在绝大多数场景下都足够,不需要自己写双检锁。

但享元工厂内部查询缓存时,还需要自己保护容器。简单办法是加std::mutex,先查缓存,命中直接返回;没命中就创建,再放回去。这里要注意:std::mutex的临界区不能包住“创建对象”这种耗时操作,尤其是对象还要读文件、调网络时,会拖慢所有其他线程。实际做法是把查询压进临界区,对象创建放到临界区外,然后二次确认再插入。

5.2 用weak_ptr做自动回收的享元池

一个更现代的共享池设计是:工厂持有std::weak_ptr,外部持有std::shared_ptr<const T>。当外部引用全部释放时,缓存里的弱引用自然悬空,下次请求时会重建对象。这样保证了“不再有人用就该释放”的资源生命周期,池子不会无限膨胀。

class FontCache { public: std::shared_ptr<const FontData> get(const std::string& name) { std::lock_guard<std::mutex> lock(mutex_); auto it = cache_.find(name); if (it != cache_.end()) { if (auto ptr = it->second.lock()) { return ptr; } } auto fresh = loadFontFromDisk(name); cache_[name] = fresh; return fresh; } private: std::mutex mutex_; std::unordered_map<std::string, std::weak_ptr<const FontData>> cache_; };

weak_ptr提升为shared_ptr的过程是线程安全的,所以不用担心另一个线程正好释放掉最后一份引用。这个模式在游戏引擎的资源管理里非常常见,本质上就是“带引用计数的全局享元”。

5.3 为什么只返回const

懒加载共享池里特别容易犯的一个错误是返回std::shared_ptr<FontData>,也就是可变对象。使用者拿到后改几个字段,这就是在改全世界共享的东西。正确的做法是从池里出来的对象一律是std::shared_ptr<const FontData>。如果真要修改,就重新构造一个对象放进池子,用新版本替换旧版本。这个思路其实和函数式编程里的不可变数据很像,本质上是用“不可变性”换取“可共享性”。

有些人觉得返回 const 太限制,总想“偶尔改一下”。我想说,那就把这个“偶尔”显式化:用独立接口重新加载一份数据,替代缓存里的旧对象。这样代码里数据流是单向的,不会出现“A改了对象,B无辜受影响”的幽灵 bug。

6. 变体四:不可变对象与写时复制

6.1 不可变原则让共享变得安全

享元模式与不可变对象简直是天作之合。如果一个对象从头到尾都不会被修改,那么把它共享给一百个线程、一千个实体都无所谓,因为不存在竞争。

但业务里并非所有数据都能做到真正不可变。有时候一个对象绝大多数场景下只需要读取,只有极少数场景需要局部修改。如果每次需要修改时都深拷贝整个对象,开销很大。这时候就可以用写时复制:多份逻辑对象共享同一个底层缓冲区,只有在写操作发生时才会复制出新的独立缓冲区。

这个思路从语义上看,仍然是“多个状态共存”,底层数据却可以共享。它是享元模式在“内部状态共享”和“外部状态独立”之间做的一种动态平衡。

6.2 手写一个能用的COW容器

我写了一个简化版的像素缓冲,用来演示COW。核心是引用计数管理层加detach()方法。

#include <atomic> #include <cstddef> #include <cstdint> class PixelBuffer { public: PixelBuffer(size_t w, size_t h) : ref_(new Ref(w, h)) {} PixelBuffer(const PixelBuffer& other) : ref_(other.ref_) { ref_->refs.fetch_add(1); } PixelBuffer& operator=(const PixelBuffer& other) { if (this == &other) return *this; release(); ref_ = other.ref_; ref_->refs.fetch_add(1); return *this; } ~PixelBuffer() { release(); } uint32_t pixel(size_t x, size_t y) const { return ref_->pixels[y * ref_->width + x]; } void setPixel(size_t x, size_t y, uint32_t color) { detach(); ref_->pixels[y * ref_->width + x] = color; } private: struct Ref { size_t width, height; std::atomic<size_t> refs; uint32_t* pixels; Ref(size_t w, size_t h) : width(w), height(h), refs(1), pixels(new uint32_t[w * h]) {} ~Ref() { delete[] pixels; } }; void release() { if (ref_->refs.fetch_sub(1) == 1) { delete ref_; } } void detach() { if (ref_->refs.load() > 1) { Ref* copy = new Ref(ref_->width, ref_->height); std::copy(ref_->pixels, ref_->pixels + ref_->width * ref_->height, copy->pixels); release(); ref_ = copy; } } Ref* ref_; };

使用方可以快速复制PixelBuffer,因为复制只是把引用计数加一。只有真正调用setPixel时,如果发现还有其他人在用同一块内存,才复制新缓冲区。这在“纹理大量共享、个别实体要染色”的场景里特别高效。

6.3 SSO、COW与复制开销的三方取舍

C++标准库对字符串的处理给了我们一个很好的启发:小对象用SSO,直接在栈上存储,不分配堆;大对象用COW,共享底层缓冲;如果修改频繁且对象中等大小,就直接深拷贝。库设计者经过多年实践,最终在后来的标准实现里弱化了COW,因为原子引用计数本身有成本,而且多线程下每次读也要担心计数器竞争。

我个人的原则是:如果对象大小在几百字节以内,除非拷贝频率极高,否则直接按值拷贝更省心。如果对象是KB级以上且读取多、写极少,COW就是很好的策略。判断标准永远是“实际瓶颈在哪里”,而不是“这个设计能不能表现我很懂”。

7. 变体五:编译期常量与模板带来的享元

7.1 编译器的vtable本身就是享元

很多人在讨论享元模式时会忘记,C++编译器早就在帮你做这件事。每个带虚函数的类,所有实例共享同一个虚函数表,实例里只需要保存一个指向vtable的指针。这本质上就是“一键享元”:类的内存布局里,函数指针段被共享了,实例只保留一个索引式指针。

同理,std::char_traits<char>、locale 的 facet、type_info这些都是标准库内部的享元。std::type_info::operator==跨DLL时能靠字符串比较,但在同一份代码里通常就是指针比较。所以设计自己的享元系统时,如果发现自己想手动存储一组函数指针,可以考虑直接封装在类里,让编译器为你生成共享的vtable。代价是多一层虚调用,收益是结构大幅简化。

7.2 constexpr数据表

比vtable更“硬核”的享元是编译期常量表。如果你的内部状态在编译期就完全确定,就可以直接把它放进只读内存,所有实例共享这份数据,而且不需要任何工厂或者线程安全处理。

struct ItemTypeInfo { const char* name; uint32_t iconId; float weight; bool stackable; }; static constexpr ItemTypeInfo kItemTypes[] = { {"apple", 101, 0.2f, true}, {"sword", 202, 3.5f, false}, {"shield", 303, 5.0f, false}, }; class Item { public: Item(uint32_t typeIndex, uint32_t count) : typeIndex_(typeIndex), count_(count) {} const ItemTypeInfo& type() const { return kItemTypes[typeIndex_]; } private: uint32_t typeIndex_; uint32_t count_; };

每个道具对象存的是typeIndex_,而道具属性全在kItemTypes里。这就是“索引式享元”的编译期版本。常量表天然线程安全,没有分配,没有析构,还顺手把对象的存储空间压到了最小。你能感觉到,这种思路和ECS里的组件类型注册表是完全一致的。

7.3 静态初始化顺序陷阱

编译期常量表很安全,但如果你忍不住用了跨编译单元的全局静态对象,就要小心static initialization order fiasco。比如一个全局std::unordered_map作为注册表,另一个全局对象在构造时往这个表里插入数据,两个全局对象的初始化顺序不保证,“用的比造的早”就会崩溃。

解决办法是换成函数内静态局部变量:

std::unordered_map<std::string, uint32_t>& typeRegistry() { static std::unordered_map<std::string, uint32_t> registry; return registry; }

这个函数第一次调用时才会构造注册表,并且C++11保证构造线程安全。所以凡是“享元池”或“注册表”这类全局设施,我都建议用这个姿势,而不是裸的全局对象。这是老项目里最容易埋雷的地方,我见过不止一次因为初始化顺序导致的诡异崩溃。

8. 常见问题速查与我的实践体会

8.1 高频问题速查表

常见问题根因解决办法
修改共享对象导致全局变化可变共享对象只暴露const接口,修改时重建替换
共享池内存不断上涨无限缓存无回收用weak_ptr或LRU,或限定驻留范围和总量
两个线程同时第一次请求共享对象未同步初始化magic static或初始化锁,双检锁二次确认
全局池在另一个全局对象构造时还没就绪静态初始化顺序函数内static局部变量替代全局对象
指针比较判断内容相同失效不从同一个池拿严进严出,禁止绕过工厂创建实例
外部状态太多导致接口冗长全部平铺参数用上下文对象包装或使用索引注册表
享元对象生命周期被外部拖住直接返回裸指针使用shared_ptr,或让池持有weak_ptr

8.2 我自己踩过的两次坑

第一次是在一个跨平台工具里做字符串驻留。我把所有解析出来的字符串都丢进Interner,结果跑了一晚上的服务内存涨了十几个G,因为用户输入的注释内容五花八门,完全不适合驻留。教训就是:驻留式享元只适合有限枚举语义,不适合开放式内容。

第二次是共享纹理时的可变问题。我当时返回了非const的纹理对象,粒子系统为了做闪光效果直接改了纹理的亮度数据,结果其他所有使用同一纹理的实体全部变亮,整个场景像开了滤镜。后来我把纹理缓冲改成COW方案,修改前先复制,问题才彻底消失。这件事让我养成了一个习惯:任何从享元工厂出来的对象,第一版接口就必须是const的。可不可变的问题,等有充分理由再放开,不要一开始就图方便。

说回享元模式本身,它不是一个需要背下来的UML图,而是一种资源管理直觉。C++里你最终会发现,真正好用的不是某个模式的教条版本,而是经过实际需求裁剪后的变体。驻留池、索引表、weak_ptr缓存、constexpr常量数组,它们都在不同维度上诠释着同一个思想:不要重复保存相同的东西,把变化的部分留给调用方。实际项目里最舒服的状态,往往是这些变体自然组合在一起。比如一个渲染系统,资源表是编译期常量,运行时资源池是懒加载weak_ptr缓存,实体用索引引用资源,每次计算结果放进上下文对象传递。这套组合跑起来之后,你才会真正理解为什么享元模式在C++里能活得这么好。

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

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

立即咨询