我第一次听到“享元”这俩字。
是在一个老同事嘴里。
他说得很轻。
但语气很重。
“这功能不用你写得多优雅。”
“你只要别把机器弄 OOM。”
那是很多年前。
内存还很贵。
也没有谁会在服务里随手给你开一百多个 GiB。
那会儿更常见的现场是。
你在 Windows 上写一个富客户端。
你在游戏里画一片森林。
你在文本编辑器里摆一页纸。
屏幕上有成千上万个小东西。
每个都“像是一个对象”。
然后你就真的给它们每个都 new 了一个对象。
接着系统就开始喘。
风扇先笑。
你再笑不出来。
GoF 把这种“对象太多”的灾难。
收拾成了一个模式。
起了个名字。
Flyweight。
中文翻译成“享元”。
听起来挺文雅。
但它干的事很粗暴。
把大家都一样的那部分。
抽出来。
让大家共用。
你可以把它理解成。
“抠门”。
而且是工程上那种正经的抠。
优化不是为了更快。
很多时候只是为了还能跑。
1. 先从事故现场说起:每个字符都背着一套字体
我们先不谈抽象。
先谈一段你很可能写过的代码。
你要做一个文本渲染。
最直觉的做法是。
每个字符一个对象。
对象里啥都放。
struct Glyph {
char ch;
int fontId;
int fontSize;
int color;
int x;
int y;
};
写起来很爽。
因为你随时可以拿到所有信息。
画的时候也不需要问别人。
void draw(const Glyph& g);
但你很快会发现。
x/y 每个都不一样。
这没办法。
你就是要摆位置。
可 fontId/fontSize/color 呢?
一页文档里。
同一段文字。
可能几千个字符。
字体一样。
字号一样。
颜色也一样。
你却给每个字符都拷了一份。
这事一开始不痛。
等你打开一个几十万字的大文件。
或者你在 UI 里同时开十几个 Tab。
它就开始痛了。
而且是那种。
你刚想定位问题。
系统就把你进程干掉的痛。
2. 享元的第一刀:把“大家都一样的那部分”搬出去
Flyweight 的核心分工。
只有两类状态。
内部状态。
外部状态。
内部状态是共享的。
外部状态是每次调用时传进来的。
这句话说得像教科书。
我换成工程语言。
你别把位置这种“每个对象都不同”的东西。
塞进共享对象里。
你也别把字体这种“多数对象都一样”的东西。
反复拷贝。
先把样式抽成一个单独的对象。
让它尽量“不可变”。
struct GlyphStyle {
int fontId;
int fontSize;
int color;
};
字符对象变瘦。
瘦得只剩下“共用的指针”。
class Glyph {
public:
explicit Glyph(std::shared_ptr<const GlyphStyle> style)
: style_(std::move(style)) {}
void draw(char ch, int x, int y) const;
private:
std::shared_ptr<const GlyphStyle> style_;
};
你注意这里的 draw。
x/y 不再存起来。
每次画的时候传进来。
这就是外部状态。
而 style_ 是内部状态。
它被共享。
共享意味着什么?
意味着你要开始关心“同样的 style,只能有一份”。
这就轮到工厂上场了。
3. Flyweight Factory:那位专门负责“别重复造”的人
享元模式里。
最容易被忽略的不是 Glyph。
而是工厂。
因为你写了 shared_ptr。
不代表你真的共享了。
你可能只是“每个字符都 shared 一份自己的 style”。
那就等于没省。
工厂要干的事很简单。
按 key 缓存。
同一个 key。
永远返回同一份内部状态。
struct StyleKey {
int fontId;
int fontSize;
int color;
bool operator==(const StyleKey&) const = default;
};
struct StyleKeyHash {
std::size_t operator()(const StyleKey& k) const {
std::size_t h = 0;
h = h * 1315423911u + std::hash<int>{}(k.fontId);
h = h * 1315423911u + std::hash<int>{}(k.fontSize);
h = h * 1315423911u + std::hash<int>{}(k.color);
return h;
}
};
class GlyphFactory {
public:
std::shared_ptr<const GlyphStyle> style(int fontId, int fontSize, int color) {
StyleKey key{fontId, fontSize, color};
if (auto it = cache_.find(key); it != cache_.end()) {
return it->second;
}
auto s = std::make_shared<GlyphStyle>(GlyphStyle{fontId, fontSize, color});
cache_.emplace(key, s);
return s;
}
private:
std::unordered_map<StyleKey, std::shared_ptr<const GlyphStyle>, StyleKeyHash> cache_;
};
到这一步。
你就能把“一百万个字符”。
压成“几十种 style + 一百万次 draw”。
内存压力会掉得很明显。
你甚至能肉眼看到。
程序从“打开文件卡两秒”。
变成“打开文件不卡了”。
老程序员这时候往往会来一句。
“看吧,抠门也能救命。”
4. 第二个更像享元的场景:一片森林,其实就几十种树
文字渲染是 GoF 的经典例子。
但我见得更多的。
是游戏。
或者地图。
或者各种“可视化大屏”。
你会有一堆树。
一堆草。
一堆石头。
每棵树的位置不一样。
旋转不一样。
缩放也不一样。
但模型、贴图、材质。
往往就那么几套。
你如果给每棵树都存一份 mesh。
那就不是森林。
是内存泄洪。
先把“长得一样的那部分”抽出来。
struct TreeModel {
std::string mesh;
std::string texture;
};
树实例只存外部状态。
struct Tree {
float x;
float y;
float scale;
std::shared_ptr<const TreeModel> model;
};
渲染时。
model 负责“怎么长”。
实例负责“站哪”。
这时候你会发现。
Flyweight 的味道其实很像。
“把大对象当成只读资源”。
把小对象当成“引用 + 坐标”。
你以为你在建模。
其实你在做内存布局。
5. 享元最容易踩的坑:你以为你在共享,其实你在共享 bug
享元不好用的时候。
通常不是因为它难。
是因为它很像“省钱”。
省着省着。
就有人开始往共享对象里塞可变状态。
比如。
你把 x/y 也塞进 Glyph 里。
然后为了省对象。
你让多个字符共用一个 Glyph。
接着你就会得到一种很诡异的现象。
你在屏幕上画一排字。
最后只剩一个字。
而且全都叠在同一个位置。
这不是笑话。
这是真实发生过的事故。
共享对象的一个硬规矩是。
内部状态尽量不可变。
不可变不只是“看起来别改”。
它是为了让共享有意义。
也是为了让并发不爆炸。
所以你会看到我用了 std::shared_ptr<const T>。
这不是为了时髦。
是为了让类型系统替你挡一部分手。
6. Flyweight 和对象池、缓存、intern 到底什么关系
很多人第一次学享元。
会说。
“这不就是缓存吗?”
也对。
享元的落地形态。
经常就是缓存。
但它更强调一个边界。
把“对象的身份”拆成两层。
共享的那层。
和每次变化的那层。
对象池想解决的更多是。
频繁 new/delete 的成本。
以及碎片。
它也会复用对象。
但它复用的是“可变对象本身”。
享元更希望复用的是“不可变的内部状态”。
两者都叫复用。
但风险不一样。
享元做对了。
你省内存。
对象池做对了。
你省分配开销。
你也会在很多地方见到类似的手法。
比如字符串驻留。
intern。
同一个字符串内容。
全局只存一份。
大家拿指针。
你说它是不是享元。
我一般不争。
味道确实很像。
7. 收个尾:当你开始在对象里反复存“同样的东西”,享元多半就在门口
享元模式很少是“架构师的灵光一闪”。
它更像是。
你系统长到一定规模。
内存开始报警。
你不得不承认。
有些对象。
其实不需要独一无二。
它们只需要。
在某些维度上“看起来不一样”。
真正不一样的。
是外部状态。
真正一样的。
应该被共享。
等你哪天在代码里看见。
某个大对象里塞着一堆重复字段。
每个实例都在拷贝同样的资源。
你先别急着开优化 flag。
也别急着换 allocator。
停一下。
问自己一句。
这玩意。
是不是该“抠”一点。
是不是该把“大家都一样的那部分”。
搬出去。
让它们共享。
那就是 Flyweight。