# 享元模式(Flyweight Pattern)详解 享元模式是**结构型设计模式**,核心思想是**通过共享细粒度的不可变对象,最大限度减少对象创建数量,从而大幅降低内存占用、提升运行效率**。它专门用于解决「系统中存在大量重复相似对象,导致内存占用过高」的场景,是典型的「以极少量时间换大量空间」的优化型设计模式。 享元模式的核心实现手段是:**将对象的状态拆分为「内部共享状态」和「外部可变状态」**,内部状态固化在享元对象中全局共享,外部状态由调用方维护、使用时动态传入,从而让海量业务场景可以复用同一个享元实例。 ## 一、解决的核心痛点:海量相似对象的内存爆炸 当系统中需要创建成千上万甚至百万级的细粒度对象时,如果每个对象都完整保存所有属性,会产生极大的内存冗余,甚至直接撑爆内存。这类场景的共性是:**对象绝大多数属性完全重复,只有极少属性随场景变化**。 典型场景: - 文字编辑器:十万级字符中,相同字的字体、字号、字形完全重复,只有显示坐标不同 - 围棋 / 五子棋:上百个落子只有黑白两种颜色,棋子材质、大小完全一致,只有落子坐标不同 - 游戏粒子特效:爆炸、烟雾特效产生上千个粒子,纹理、颜色、大小完全相同,只有位置、速度不同 - 图像处理:批量处理任务中,大量任务复用同一种采样算法、同一份调色板参数 如果不做优化,对象数量和内存占用会线性增长;使用享元模式后,内存占用仅和「内部状态的种类数」相关,和业务规模无关。 ## 二、核心概念:内部状态 vs 外部状态 状态拆分是享元模式的灵魂,必须严格区分两类状态: ### 1. 内部状态(Intrinsic State) - 存储在享元对象内部,**创建后不可变、全局共享** - 与具体使用场景无关,是对象的固有属性 - 示例:棋子的颜色、字符的字体字号、粒子的纹理、下采样算法的逻辑 ### 2. 外部状态(Extrinsic State) - 不由享元对象保存,**由调用者维护,使用时作为参数传入** - 随使用场景动态变化,不可共享 - 示例:棋子的落子坐标、字符的显示位置、粒子的运动速度、待处理的图像数据 > 铁则:享元对象只保存内部状态,绝对不能存储外部状态;内部状态必须是不可变的(Immutable),一旦创建不允许修改,否则一处修改会导致所有共享该享元的业务全部异常。 ## 三、模式结构与核心角色 标准享元模式包含 4 个核心角色,客户端永远通过工厂获取享元,禁止直接 new 具体享元对象: | 角色 | 定位 | 核心职责 | | ---------------------------------- | ------------ | ------------------------------------------------------------ | | **抽象享元(Flyweight)** | 享元统一接口 | 定义对外的业务方法,方法通常会接收外部状态作为入参 | | **具体享元(Concrete Flyweight)** | 共享对象本体 | 实现抽象享元接口,保存内部状态,是可被全局复用的实例 | | **享元工厂(Flyweight Factory)** | 享元池管理器 | 维护享元缓存池,请求的享元已存在则直接返回,不存在则创建并缓存;是保证共享逻辑生效的核心 | | **非共享享元(可选)** | 不共享的享元 | 一般用于组合结构的特殊节点,实际开发中极少使用 | ## 四、C++ 完整实现示例 以经典「围棋棋子」场景为例,采用现代 C++ 写法,智能指针管理生命周期,工厂通过哈希表维护享元池。 ```cpp #include #include #include #include // ============================================== // 抽象享元:棋子统一接口 // ============================================== class ChessPiece { public: virtual ~ChessPiece() = default; // 业务方法:落子,x/y 是外部传入的坐标(外部状态) virtual void place(int x, int y) const = 0; // 获取内部状态:棋子颜色 virtual std::string getColor() const = 0; }; // ============================================== // 具体享元:具体棋子实例 // ============================================== class ConcreteChessPiece : public ChessPiece { public: explicit ConcreteChessPiece(std::string color) : color_(std::move(color)) {} void place(int x, int y) const override { std::cout << "在(" << x << "," << y << ")落子,棋子颜色:" << color_ << std::endl; } std::string getColor() const override { return color_; } private: // 内部状态:颜色,创建后只读,全局共享 const std::string color_; }; // ============================================== // 享元工厂:管理享元池,唯一合法的享元获取入口 // ============================================== class ChessPieceFactory { public: // 获取享元:池中存在则复用,不存在则创建 std::shared_ptr getPiece(const std::string& color) { auto it = pool_.find(color); if (it != pool_.end()) { return it->second; } // 新建享元并加入缓存 auto piece = std::make_shared(color); pool_[color] = piece; std::cout << "[工厂] 创建新的" << color << "棋子享元对象" << std::endl; return piece; } // 查询当前享元池大小 size_t poolSize() const { return pool_.size(); } private: // 享元缓存池:key 为内部状态标识,value 为享元对象 std::unordered_map> pool_; }; // ============================================== // 客户端使用 // ============================================== int main() { ChessPieceFactory factory; std::cout << "===== 第一轮落子(10黑5白) =====" << std::endl; for (int i = 0; i < 10; ++i) { auto black = factory.getPiece("黑色"); black->place(i, i * 2); // 外部状态:坐标,每次动态传入 } for (int i = 0; i < 5; ++i) { auto white = factory.getPiece("白色"); white->place(i + 3, i + 1); } std::cout << "\n===== 第二轮落子 =====" << std::endl; auto black2 = factory.getPiece("黑色"); black2->place(10, 10); auto white2 = factory.getPiece("白色"); white2->place(10, 11); std::cout << "\n享元池中总对象数:" << factory.poolSize() << std::endl; // 输出:2 // 仅 2 个享元对象,支撑了 17 次落子业务 return 0; } ``` ### 运行效果说明 整个程序全程只创建了「黑色」「白色」 2 个享元对象,却完成了 17 次落子操作。如果不用享元模式,17 次落子需要创建 17 个对象;如果是 10000 次落子,依然只需要 2 个享元,内存节省效果随规模增长愈发显著。 ## 五、结合你的场景:图像处理中的享元实践 在你之前接触的图像处理业务中,享元模式有非常贴合的落地场景: 1. **算法对象复用**:下采样、滤波、增强等算法对象本身无状态,仅包含计算逻辑,可以做成享元全局共享,避免每个处理任务都重复创建算法实例。 2. **调色板 / 查找表共享**:批量处理同格式图像时,灰度映射表、颜色查找表完全一致,可作为享元全局复用,无需每张图像都存储一份。 3. **卷积核 / 算子缓存**:常用的高斯核、 Sobel 算子等参数固定的计算单元,适合做成享元对象池。 ## 六、关键注意事项 1. **内部状态必须只读** 享元对象不提供任何修改内部状态的接口,确保共享安全性。如果需要差异化配置,必须通过外部状态传入。 2. **享元工厂的线程安全** 多线程场景下,享元池的读写存在数据竞争,需要对工厂的 `get` 方法加锁保护,或采用无锁并发设计。 3. **合理控制享元粒度** 拆分过细会导致享元种类过多,共享收益下降,反而增加系统复杂度;拆分过粗则内存冗余严重,需要在复杂度和内存收益之间做平衡。 ## 七、优缺点分析 ### 优点 1. 大幅减少对象创建数量,显著降低内存占用,业务规模越大优化效果越明显 2. 集中管理共享对象,便于统一维护和升级 3. 外部状态与享元解耦,可独立扩展,不影响共享逻辑 ### 缺点 1. 增加系统架构复杂度,需要精准拆分内外状态,对设计能力要求更高 2. 享元工厂存在少量查询开销,属于典型的「以时间换空间」 3. 牺牲了对象的独立性,业务逻辑更抽象,调试难度略有上升 ## 八、易混淆模式对比 ### 享元模式 vs 对象池模式 | 对比维度 | 享元模式 | 对象池模式 | | -------- | -------------------------------- | -------------------------------------------- | | 核心目标 | 节省内存,全局共享同一个对象 | 节省创建销毁开销,复用对象实例 | | 使用方式 | 同一时刻多个使用者共享同一个实例 | 同一时刻一个对象仅分配给一个使用者,用完归还 | | 对象状态 | 内部状态不可变,外部状态动态传入 | 对象状态可修改,归还时重置 | | 典型场景 | 字体、字符、资源缓存、算法实例 | 线程池、连接池、数据库连接 | ### 享元模式 vs 单例模式 - 单例:全局唯一实例,目的是控制实例数量,不涉及状态拆分 - 享元:全局可存在多个享元实例(如黑、白棋子),核心是共享复用与状态分离 ## 标准示例 ```cpp //抽象享元角色(Flyweight) //定义享元的接口,声明操作这些外部状态的方法,通常包括一个接受外部状态作为参数的方法。 class Flyweight { public: virtual ~Flyweight() {} virtual void operation(const Context& context) const = 0; }; //具体享元角色(ConcreteFlyweight) //实现抽象享元角色定义的接口,存储享元内部状态(如果有的话)。具体享元对象可以共享,因为它只依赖于内部状态,而不依赖于外部状态。 class ConcreteFlyweight : public Flyweight { public: void operation(const Context& context) const override { // 根据context中的外部状态进行操作 std::cout << "ConcreteFlyweight: Operation with context " << context.state << std::endl; } }; //享元工厂(FlyweightFactory) //创建并管理享元对象,确保享元对象可以被正确地共享。当客户端请求享元对象时,工厂会检查是否已经存在符合条件的享元对象,如果有,则返回现有的对象;如果没有,则创建新的享元对象。 class FlyweightFactory { private: std::map> pool; public: std::shared_ptr getFlyweight(const std::string& key) { if(pool.find(key) == pool.end()) { pool[key] = std::make_shared(); } return pool[key]; } }; //客户端(Client) //维持一个对享元对象的引用,并处理外部状态。客户端不直接与享元对象交互,而是通过上下文(Context)传递外部状态给享元对象。 class Context { public: explicit Context(std::string state) : state(std::move(state)) {} std::string getState() const { return state; } private: std::string state; }; int main() { FlyweightFactory factory; std::shared_ptr fly1 = factory.getFlyweight("key1"); Context context1("State1"); fly1->operation(context1); std::shared_ptr fly2 = factory.getFlyweight("key1"); // 重复获取相同的享元 Context context2("State2"); fly2->operation(context2); // 共享的享元对象,但操作基于不同的外部状态 return 0; } ``` --- C++ 享元模式详解 享元模式(Flyweight Pattern)是**结构型设计模式**之一,核心思想是**运用共享技术高效支持大量细粒度对象的复用**,通过分离对象的「内部不变状态」与「外部可变状态」,让相同内部状态的对象在内存中只保留一份,从而大幅降低内存占用。它是典型的「空间换时间」优化模式,在游戏引擎、文字渲染、资源管理等场景应用极广。 ## 一、核心思想与基本概念 ### 1. 解决的问题:大量细粒度对象的内存膨胀 当系统中存在海量相似对象时,如果每个对象都独立存储完整数据,会造成严重的内存浪费。 典型场景: - 文字编辑器:一篇文章有十万个字符,若每个字符都存字形、字体、字号、颜色、位置,内存开销巨大; - 游戏地图:地图上有上万棵树、上千个粒子,若每个对象都存完整的 3D 模型、纹理数据,内存会直接爆炸; - 棋盘游戏:一盘棋有大量棋子,若每个棋子都存样式、图片资源,完全没有必要。 这些场景的共性是:**对象数量极大,但大部分内容高度重复**。享元模式就是把重复的内容抽离出来共享,只给每个对象保留独有的少量信息。 ### 2. 核心概念:内部状态 vs 外部状态 享元模式的灵魂是状态分离,这也是理解它的关键: - **内部状态(Intrinsic State)**:存储在享元对象内部,**不随场景变化、可共享**的不变属性。例如字符的字形编码、树木的 3D 模型与纹理、棋子的外观。同一种享元的内部状态完全相同,内存中只存一份。 - **外部状态(Extrinsic State)**:随场景变化、**不可共享**的属性,由客户端维护,在调用享元方法时作为参数传入。例如字符的位置与颜色、树木的坐标与缩放、棋子的落点坐标。 ### 3. 本质 用「共享不变对象 + 外置可变状态」替代「大量重复对象」,以极小的调用开销为代价,换取数倍甚至数十倍的内存节省。 ## 二、模式角色与结构 享元模式包含 5 个核心角色: | 角色 | 作用 | | ------------------------------------------- | ------------------------------------------------------------ | | **Flyweight(抽象享元)** | 声明享元的公共接口,规定接收外部状态的方法 | | **ConcreteFlyweight(具体享元)** | 实现抽象享元接口,存储内部状态,所有内部状态不可修改 | | **UnsharedConcreteFlyweight(非共享享元)** | 不需要共享的享元,通常用于组合享元结构,自身不共享但内部组件可共享 | | **FlyweightFactory(享元工厂)** | 维护享元池(缓存),负责创建和管理享元对象,保证相同内部状态的享元只创建一次 | | **Client(客户端)** | 维护外部状态,通过工厂获取享元对象,调用时传入外部状态 | ## 三、经典 C++ 实现(继承 + 工厂缓存) ### 示例场景 游戏大地图中需要渲染上万棵树木,树木的模型、纹理、树种是内部共享状态,坐标、缩放是外部状态。使用享元模式后,同一种树在内存中只存一份模型数据。 ```cpp #include #include #include #include // 1. 抽象享元 class Tree { public: virtual ~Tree() = default; // 外部状态通过参数传入:位置坐标、缩放比例 virtual void draw(int x, int y, double scale) const = 0; }; // 2. 具体享元:共享的树木模型(仅存内部状态) class TreeModel : public Tree { private: // 内部状态:不变、可共享 std::string type; // 树种 std::string texture; // 纹理资源 // 实际场景中这里会存大量模型数据、顶点信息等大对象 public: TreeModel(std::string t, std::string tex) : type(std::move(t)), texture(std::move(tex)) {} void draw(int x, int y, double scale) const override { std::cout << "绘制[" << type << "] 位置:(" << x << "," << y << ") 缩放:" << scale << " 纹理资源:" << texture << std::endl; } }; // 3. 享元工厂:管理享元池 class TreeFactory { private: // 享元池:key 由内部状态组合生成,value 为享元对象 std::unordered_map> pool; public: // 获取享元:不存在则创建,已存在则直接复用 std::shared_ptr getTree(const std::string& type, const std::string& texture) { std::string key = type + "_" + texture; if (pool.find(key) == pool.end()) { pool[key] = std::make_shared(type, texture); std::cout << "[工厂] 创建新享元: " << key << std::endl; } return pool[key]; } // 获取当前享元池大小 size_t getPoolSize() const { return pool.size(); } }; // 客户端使用 int main() { TreeFactory factory; // 模拟地图上 5000 棵松树、5000 棵橡树,共 10000 棵树 std::cout << "===== 生成松树 =====" << std::endl; auto pine = factory.getTree("松树", "pine_albedo.png"); for (int i = 0; i < 5000; ++i) { // 每棵树的坐标是外部状态,由客户端维护 int x = rand() % 2000; int y = rand() % 2000; pine->draw(x, y, 1.0); } std::cout << "\n===== 生成橡树 =====" << std::endl; auto oak = factory.getTree("橡树", "oak_albedo.png"); for (int i = 0; i < 5000; ++i) { int x = rand() % 2000; int y = rand() % 2000; oak->draw(x, y, 1.2); } std::cout << "\n===== 统计 =====" << std::endl; std::cout << "树木总数量: 10000" << std::endl; std::cout << "享元池实际对象数: " << factory.getPoolSize() << std::endl; // 输出:2。一万棵树仅占用 2 份模型内存,内存节省极其显著 return 0; } ``` ### 关键说明 1. 享元对象的内部状态在创建后**不可修改**,否则会影响所有引用它的地方; 2. 工厂是享元模式的核心入口,客户端禁止直接 new 具体享元,必须通过工厂获取; 3. 外部状态完全由客户端管理,享元本身不存储,只在调用时使用。 ## 四、进阶:非共享享元与复合享元 并非所有享元都必须共享,实际工程中经常出现「复合享元」结构。 例如文字排版场景:单个字符是简单享元(共享),而「一行文字」是复合享元,它由多个字符享元组成,同时包含行号、对齐方式等独有属性。复合享元本身不共享,但它内部的字符组件全部来自享元池。 ```cpp // 复合享元:一行文本 class TextLine : public Tree { // 继承自统一抽象接口 private: std::vector> chars; // 内部组件都是共享享元 int lineNumber; // 外部状态:行号 public: void addChar(std::shared_ptr c) { chars.push_back(std::move(c)); } void draw(int x, int y, double scale) const override { int offset = x; for (auto& c : chars) { c->draw(offset, y, scale); offset += 12; } } }; ``` 这种结构既享受了内部组件的共享收益,又保留了整体的灵活性,是享元模式在复杂系统中的常见形态。 ## 五、现代 C++ 优化实现 ### 1. 线程安全的享元工厂 多线程环境下,享元池的读写必须加锁,避免并发创建重复对象: ```cpp #include class ThreadSafeTreeFactory { private: std::unordered_map> pool; mutable std::mutex mtx; public: std::shared_ptr getTree(const std::string& type, const std::string& texture) { std::string key = type + "_" + texture; std::lock_guard lock(mtx); // 加锁保护 if (pool.find(key) == pool.end()) { pool[key] = std::make_shared(type, texture); } return pool[key]; } }; ``` ### 2. 弱引用享元池(自动回收) 如果享元可能长期不用,可使用 `weak_ptr` 实现自动回收:当没有外部 `shared_ptr` 引用时,享元对象自动销毁,节省内存。再次需要时重新创建。 ```cpp class AutoRecycleFactory { private: std::unordered_map> pool; std::mutex mtx; public: std::shared_ptr getTree(const std::string& type, const std::string& texture) { std::string key = type + "_" + texture; std::lock_guard lock(mtx); auto& weak = pool[key]; auto shared = weak.lock(); if (!shared) { shared = std::make_shared(type, texture); weak = shared; } return shared; } }; ``` ### 3. 性能优化 - 使用 `std::string_view` 作为查找 key,避免字符串拷贝; - 内部状态全部声明为 `const`,从语法上保证不可修改; - 对于枚举类型的内部状态,直接用枚举值作为 key,性能远高于字符串。 ## 六、适用场景 1. **系统存在大量相似细粒度对象**,且对象数量导致内存占用过高; 2. **对象大部分状态可外部化**,且外部状态可以由客户端独立维护; 3. **剥离外部状态后**,可以用少量共享对象替代海量原对象; 4. **资源密集型场景**:游戏纹理 / 模型 / 音效缓存、字体字形库、图标库、数据库连接池、线程池等。 ## 七、优缺点分析 ### 优点 1. **大幅节省内存**:这是最核心的优势,对象数量越大、内部状态重复度越高,收益越明显; 2. **减少对象创建开销**:共享对象只需创建一次,避免重复构造与析构的性能损耗; 3. **集中管理资源**:所有共享对象由工厂统一管理,便于统一优化、预热与监控。 ### 缺点 1. **增加系统复杂度**:需要严格分离内部与外部状态,提升了架构设计与理解成本; 2. **调用开销增加**:每次调用享元都需要传入外部状态,增加了参数传递成本; 3. **线程安全风险**:共享对象若被意外修改,会影响所有引用它的模块,多线程下需要额外保障; 4. **查找开销**:享元池的哈希查找有轻微性能损耗,极端高频场景需要权衡。 ## 八、与相似模式对比 | 模式 | 核心目的 | 对象状态 | 生命周期 | 核心区别 | | ---------- | -------------------------- | -------------------------- | -------------------- | ---------------------------------------- | | 享元模式 | 节省内存,共享细粒度对象 | 内部状态不变,外部状态外置 | 长期驻留共享使用 | 强调**多对象共享同一份实例**,状态分离 | | 对象池模式 | 复用对象,减少创建销毁开销 | 对象状态可变,归还时重置 | 循环借用与归还 | 强调**实例复用**,同一时间只供一个使用者 | | 单例模式 | 全局唯一实例 | 状态全局共享 | 程序全程唯一 | 只有**一个**对象,享元是**一组**共享对象 | | 原型模式 | 快速复制创建新对象 | 每个对象独立,状态可修改 | 每个对象独立生命周期 | 复制产生新对象,不共享实例 | ## 九、总结 享元模式的本质是**分离变与不变,用共享替代重复**,是针对「海量细粒度相似对象」的专项内存优化方案。 在现代 C++ 开发中,严格遵循 GoF 类结构的享元模式并不多见,但**享元思想无处不在**:游戏引擎的资源管理器、UI 框架的图标缓存、字体渲染的字形库、编程语言的字符串常量池,本质都是享元模式的工程化落地。 使用的核心判断标准:**当系统因大量相似对象导致内存瓶颈,且这些对象存在大量可抽离的不变属性时,享元模式是最优解**。如果只是为了减少对象创建开销,优先考虑对象池。