news 2026/10/26 2:34:53

C++设计模式之享元模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++设计模式之享元模式

前言


享元模式(flyweight pattern)要解决的是「大量细粒度对象占用过多内存」的问题。它的做法是把对象的状态拆成两部分:可以在多个上下文之间共享的「内在状态」,以及随上下文变化的「外在状态」。内在状态存放在享元对象里,创建后不再改变;外在状态不放进享元,而是由客户端在调用时传入。


常见误解有三个。第一,把享元模式等同于缓存。缓存关心的是「算过的结果别再算」,通常会淘汰;享元关心的是「相同的对象只保留一份」,共享的是对象本身。第二,认为只要用std::shared_ptr就是享元。共享指针只是所有权手段,如果每个对象仍然各存一份完整状态,那只是共享引用,不是享元。第三,把享元对象做成可变的,今天改字体、明天改颜色,共享立刻变成互相污染。


判断能否用享元,可以问两个问题:这个状态在多个对象之间是否完全相同?把它抽出去之后,剩下的状态是否足以完成操作?两个答案都是「是」,才适合拆分。本文以文本渲染为例,给出工厂、享元和客户端的完整实现。示例以 C++17 为准。


一、内在状态与外在状态的划分


以文本渲染为例。屏幕上每个字符都需要知道:字符本身、字体、字号、位置、颜色。其中字符、字体、字号在大量字符之间重复,位置和颜色则每个字符都不同。把前三个抽成享元,十万个字符可能只需要几十个享元对象;位置和颜色留在客户端,随绘制调用传入。


划分的标准不是「这个字段大不大」,而是「它是否随上下文变化」。如果某个字段对所有使用场景都相同,它就应该进入内在状态;如果它每次调用都不同,放进享元只会让享元无法共享。介于两者之间的字段要谨慎:一旦把它放进享元,就必须保证所有共享者都接受同一个值。




状态例子存放位置是否可变



内在状态字符、字体、字号享元对象内部不可变

外在状态坐标、颜色、透明度客户端或调用参数可变

工厂索引组合键享元工厂的哈希表只读



不可变是享元的硬性要求。多个客户端持有同一个享元对象的引用,只要有一个客户端能修改它,其他客户端看到的语义就变了,而且这种 bug 极难定位。实现上应当把享元的数据成员设为私有,只提供 const 成员函数,工厂返回std::shared_ptr<const Glyph>。


二、工厂与共享所有权


享元对象不能由客户端直接创建,否则同一个组合会出现多份实例,共享失效。创建统一交给工厂:工厂用「内在状态的组合」作为键,先在池里查找,命中就返回已有对象的共享指针,未命中才创建并登记。


键的设计有两个坑。其一是分隔符冲突:把字符、字体、字号拼成"a|serif|12",如果字体名本身含有竖线,不同的组合可能拼出相同的键。稳妥的做法是用std::tuple配合自定义哈希,或者对每段做长度前缀。其二是键的增长:字体名和字号如果来自用户输入,键空间可能无限扩张,池会越来越大。享元假设内在状态的取值有限,如果这个假设不成立,就需要给池设上限或改用其他方案。


所有权用std::shared_ptr比较自然:工厂和客户端都持有引用,最后一个引用释放时对象销毁。工厂里的池是否长期持有引用,取决于设计——如果池持有shared_ptr,享元会一直存活到工厂销毁;如果池只持有weak_ptr,没有客户端使用时对象可以被回收,但下次请求会重新创建。前者实现简单,后者内存更省,代价是多了锁和过期检查。


std::shared_ptr的控制块是线程安全的,但对被指向对象的访问不是。多个线程同时调用工厂的get会同时读写哈希表,必须加锁;享元对象本身不可变,多个线程同时读取它是安全的。


三、完整示例


下面的程序实现一个字符享元工厂和一个简单的文本渲染器。渲染器持有工厂的引用,每个字符只保存享元指针和坐标,绘制时把坐标传给享元。


// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>

// 享元:内在状态,创建后不可变
class Glyph {
public:
Glyph(char ch, std::string font, int size)
: ch_(ch), font_(std::move(font)), size_(size) {}

// 外在状态通过参数传入,不存进对象
void draw(int x, int y) const {
std::cout << "draw '" << ch_ << "' font=" << font_
<< " size=" << size_ << " at (" << x << ',' << y << ")\n";
}

char character() const noexcept { return ch_; }
const std::string& font() const noexcept { return font_; }
int size() const noexcept { return size_; }

private:
char ch_;
std::string font_;
int size_;
};

// 享元工厂:相同组合只保留一份
class GlyphFactory {
public:
std::shared_ptr<const Glyph> get(char ch,
const std::string& font,
int size) {
const std::string key = makeKey(ch, font, size);
auto it = pool_.find(key);
if (it != pool_.end()) {
return it->second;
}

std::shared_ptr<const Glyph> glyph =
std::make_shared<Glyph>(ch, font, size);
pool_.emplace(key, glyph);
return glyph;
}

std::size_t size() const noexcept { return pool_.size(); }

private:
static std::string makeKey(char ch, const std::string& font, int size) {
return std::string(1, ch) + '|' + font + '|' + std::to_string(size);
}

std::unordered_map<std::string, std::shared_ptr<const Glyph>> pool_;
};

// 客户端:只保存享元指针和外在状态
struct PlacedGlyph {
std::shared_ptr<const Glyph> glyph;
int x;
int y;
};

class TextRenderer {
public:
TextRenderer(GlyphFactory& factory, std::string font, int size)
: factory_(factory), font_(std::move(font)), size_(size) {}

void append(char ch, int x, int y) {
glyphs_.push_back({factory_.get(ch, font_, size_), x, y});
}

void render() const {
for (const PlacedGlyph& p : glyphs_) {
p.glyph->draw(p.x, p.y);
}
}

private:
GlyphFactory& factory_;
std::string font_;
int size_;
std::vector<PlacedGlyph> glyphs_;
};

int main() {
GlyphFactory factory;

TextRenderer text(factory, "serif", 12);
text.append('H', 0, 0);
text.append('i', 10, 0);
text.append('H', 20, 0);

text.render();
std::cout << "unique glyphs: " << factory.size() << '\n';
return 0;
}

推演一下共享过程。第一次append('H', ...)时,工厂的池为空,创建H/serif/12的享元并登记。append('i', ...)创建另一个。第二次append('H', ...)命中已有的键,返回同一个shared_ptr,不会新建对象。三个字符对应两个享元,factory.size()为 2。


PlacedGlyph里存的是shared_ptr<const Glyph>,即使工厂被销毁,只要还有客户端持有指针,享元仍然有效。如果希望工厂销毁时回收所有享元,可以把池改成std::weak_ptr,但那样get返回的指针不能直接交给客户端长期保存,需要先lock()成shared_ptr。


GlyphFactory::get不是线程安全的:find和emplace之间没有保护,两个线程可能同时判断「不存在」并各自创建一个对象,最后一个覆盖前一个,导致池里出现重复条目。多线程场景要在整个get外加std::mutex。


四、优缺点与适用边界


享元的优点很直接:把大量重复状态合并成少量共享对象,减少内存占用和构造开销。它的代价也很明显:



  • 客户端必须负责保存和传递外在状态,调用接口变复杂。

  • 共享对象的生命周期需要仔细管理,用shared_ptr会引入引用计数的开销。

  • 工厂的池会长期持有对象,键空间大时内存不会自动下降。

  • 对象不可变,无法针对单个实例做定制。


适用场景通常满足两个条件:对象数量大,且对象中重复的状态占比高。文本编辑器的字形、地图应用中的瓦片、游戏里的粒子类型、棋盘上的棋子类型都是典型例子。如果对象只有几十个,或者每个对象的状态几乎都不同,引入享元只会增加复杂度。


享元和几个相邻概念的区别可以用一张表说清:




概念共享什么生命周期典型用途



享元不可变的内在状态工厂或客户端持有大量相似对象

单例整个对象只有一个实例程序级全局配置、日志器

对象池可复用的可变对象借出与归还数据库连接、线程

缓存计算结果或对象可淘汰加速重复查询



常见坑点


坑点一:把外在状态存进享元。


❌Glyph里保存x_、y_,每次绘制前先改坐标再调用draw(),共享对象互相覆盖位置。


✅ 坐标作为draw的参数传入,享元只保存字符、字体、字号。


坑点二:享元对象可以被修改。


❌ 提供setFont()、setSize(),一个客户端改了字体,所有引用者都受影响。


✅ 数据成员私有、只提供 const 成员函数,工厂返回shared_ptr<const Glyph>。


坑点三:工厂返回std::unique_ptr。


❌std::unique_ptr<Glyph> get(...),同一个享元无法被多个客户端同时持有。


✅ 返回std::shared_ptr<const Glyph>,共享所有权。


坑点四:工厂的查找与插入没有同步。


❌ 两个线程同时find失败并各自emplace,池里出现重复对象。


✅ 用std::mutex保护整个get,或使用线程安全的并发容器。


坑点五:键空间无上限。


❌ 字体名和字号完全来自用户输入,每种组合都建一个享元,池无限增长。


✅ 限定内在状态的取值范围,或给池设置上限并做淘汰。


坑点六:用分隔符拼接键导致冲突。


❌ch + "|" + font + "|" + size,字体名含竖线时不同组合可能拼出同一个键。


✅ 用std::tuple作键并自定义哈希,或对每段加长度前缀。


坑点七:把享元当成单例。


❌ 全局只允许存在一个Glyph,所有字符被迫共用同一个字形。


✅ 享元是「每种内在状态一个实例」,单例是「整个类型一个实例」。


坑点八:对象本来就少,还硬套享元。


❌ 只有十几个配置对象,却引入工厂、池、共享指针,接口复杂了一倍。


✅ 先确认对象数量和重复比例,收益不明显时直接用普通对象。


总结




角色职责关键约束



享元保存内在状态、提供操作不可变、可共享

享元工厂按键查找或创建享元键唯一、并发安全

客户端保存外在状态并传给享元不修改享元

池持有享元引用控制增长、明确生命周期



享元模式的本质是「把可共享的状态抽出来,把变化的部分留在外面」。它不是一个可以无脑套用的优化,而是一种基于状态划分的重构:只有当重复状态足够多、共享带来的收益超过接口复杂度的成本时,才值得引入。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 2:34:46

智能制造数字化转型:五化四型十场景实践指南

1. 智能制造数字化转型全景解析这个102页的PPT浓缩了当前制造业数字化转型的核心方法论与实践路径。作为深耕工业互联网领域多年的从业者&#xff0c;我完整参与过3个灯塔工厂的数字化改造项目&#xff0c;深刻体会到"五化四型十场景"框架在实际落地中的价值。这份方…

作者头像 李华
网站建设 2026/10/26 2:34:15

2026年MES系统核心技术架构与实施指南

1. 制造业数字化转型中的MES系统定位车间里那台老式打卡机终于被拆下来了&#xff0c;取而代之的是每个工位上的平板终端。这个场景正在2026年的制造工厂里加速上演&#xff0c;而背后支撑这场变革的核心系统正是MES&#xff08;制造执行系统&#xff09;。作为连接ERP计划层与…

作者头像 李华
网站建设 2026/10/26 2:32:33

博世BMI160六轴传感器自写驱动:I2C/SPI双总线与避坑指南

简介&#xff1a;面向嵌入式开发者的博世BMI160陀螺仪自写驱动参考代码&#xff0c;聚焦STM32F030平台与传感器之间的I2C通信集成&#xff0c;适合已具备单片机外设开发基础、希望快速打通六轴姿态采集链路的工程师学习借鉴。压缩包共3个文件&#xff0c;约6KB&#xff0c;以C源…

作者头像 李华
网站建设 2026/10/26 2:31:41

LVS+Keepalived+NFS构建高可用Web集群实战

1. 项目概述在互联网服务架构中&#xff0c;Web集群的高可用性直接关系到业务连续性和用户体验。传统单点部署的Web服务器在面对突发流量或硬件故障时往往力不从心&#xff0c;而基于LVSKeepalivedNFS的技术栈组合&#xff0c;能够构建一个既具备负载均衡能力&#xff0c;又能实…

作者头像 李华
网站建设 2026/10/26 2:31:20

商贸企业订货管理系统选型指南与数字化转型实践

1. 商贸企业数字化转型的痛点与需求商贸企业正面临前所未有的市场变革压力。根据中国连锁经营协会发布的《2022中国零售业数字化转型白皮书》&#xff0c;超过78%的中小商贸企业已经意识到数字化转型的紧迫性&#xff0c;但实际落地率不足30%。这种认知与实践的落差&#xff0c…

作者头像 李华