前言
享元模式(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,所有字符被迫共用同一个字形。
✅ 享元是「每种内在状态一个实例」,单例是「整个类型一个实例」。
坑点八:对象本来就少,还硬套享元。
❌ 只有十几个配置对象,却引入工厂、池、共享指针,接口复杂了一倍。
✅ 先确认对象数量和重复比例,收益不明显时直接用普通对象。
总结
| 角色 | 职责 | 关键约束 |
|---|
| 享元 | 保存内在状态、提供操作 | 不可变、可共享 |
| 享元工厂 | 按键查找或创建享元 | 键唯一、并发安全 |
| 客户端 | 保存外在状态并传给享元 | 不修改享元 |
| 池 | 持有享元引用 | 控制增长、明确生命周期 |
享元模式的本质是「把可共享的状态抽出来,把变化的部分留在外面」。它不是一个可以无脑套用的优化,而是一种基于状态划分的重构:只有当重复状态足够多、共享带来的收益超过接口复杂度的成本时,才值得引入。