【设计模式精讲】15.享元模式(Flyweight)
【摘要】:一局围棋上千颗棋子对象,每颗都揣着贴图引用、风格字符串与渲染缓存------可黑白加风格统共不过十来种,重复的信息被复制了上千份。本文从棋盘内存账讲起,给出享元模式的核心一刀:把「不随实例变化」的内部状态从对象里搬出去共享,把「随实例变化」的外部状态留给客户端随操作传入;享元因此必须不可变,工厂池负责「同键同对象」。现代 C++ 用
shared_ptr<const T>把不可变纪律上锁、用weak_ptr池实现「可回收的共享」,并讨论「先问该不该共享」。文末对照 Boost.Flyweight 的值语义句柄、AOSP 资源字符串池与图形界的纹理图集,并辨析享元与对象池的分界:复用存储不等于共享取值。【关键词】:享元、内部状态、外部状态、共享池、不可变对象、内存优化
【代码基准】:C++17
1. 一千颗棋子,抄了一千份家谱
围棋引擎里,每颗落在盘上的棋子是一个对象:
cpp
// 说明性片段
// ❌ 每颗棋子都带全套家当
struct Stone {
Color color; // 黑或白
const Texture* texture; // 棋子贴图
std::string style; // "云子"/"蛤碁石"
std::vector<char> cache; // 渲染缓存
int x, y; // 落点坐标
};
std::vector<Stone> board; // 一局轻松上千颗
算笔账:style 字符串、贴图引用、渲染缓存,每份几十到几千字节;一局两千步的复盘加上打谱功能里并存的十几局,对象数以万计,内存占用跟着「子数」线性涨。可是看一眼数据就会发现荒谬之处------这上万个对象里,颜色与风格的组合只有十来种:黑云子、白云子、黑蛤碁石......每种信息被原样复制了成百上千份,真正「每颗不同」的只有一个坐标。
GoF 对这种结构的诊断一针见血:对象里混着两类状态------随实例变化的(坐标) 与 不随实例变化的(颜色、风格、贴图)。前者是每颗棋子的本分,后者是每种棋子的家谱;把家谱抄进每颗棋子,就是「细粒度对象海量化」后内存爆炸的全部原因。享元模式下的手术刀,正是沿这条缝切下去。
顺带把 GoF 自己的场景也摆出来:文档编辑器里每个字符一个 Glyph 对象,百万字符共享 26 个字母 × 若干字体的组合,位置由文档结构(第 12 篇的组合树)外部保管------享元与组合是 GoF 点名的搭档:树管结构,享元管「树上的叶子怎么造得起」。
2. 模式意图与定义
- 一句话定义 :运用共享技术,有效地支持大量细粒度的对象。
解决的问题:海量对象中大量重复的内部状态导致的内存浪费,顺带改善缓存局部性。 - GoF 原文意图 :Use sharing to support large numbers of fine-grained objects efficiently. GoF 用词极省,全部分量压在 sharing(共享) 与 fine-grained(细粒度) 两个词上:只有对象足够小、足够多,共享才有账可算。
- Refactoring Guru 的表述 :享元模式通过 共享尽可能多的数据,减少内存占用------它搁置了「在每个对象里保存全部数据」的做法,转而在多个对象间共享公共状态。
模式的核心是一刀二分:
- 内部状态(Intrinsic State) :不随使用场景变化、可共享------棋子的颜色、风格、贴图;字符的字体、字形。存进享元对象,且必须不可变(多个使用者共享同一份,谁也不能改)。
- 外部状态(Extrinsic State) :随每次使用变化、不可共享------落子坐标、字符位置。搬出享元对象 ,由客户端保管,在调用操作时作为参数传入(
draw(x, y)而不是stone->draw())。
这条二分是充要条件:分得出内外,享元就成立;分不出(状态全随实例变),享元就无从下手 。配套的纪律有三条:享元只暴露 const 操作;享元对象只能从一个工厂(缓存池)里领,「同键必同对象」由工厂保证;客户端不 new 具体享元,也不对它存独占的执念。
3. UML 图 + 结构说明
#mermaid-svg-Eie6UqOAYU1BSsYy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Eie6UqOAYU1BSsYy .error-icon{fill:#552222;}#mermaid-svg-Eie6UqOAYU1BSsYy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Eie6UqOAYU1BSsYy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Eie6UqOAYU1BSsYy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Eie6UqOAYU1BSsYy .marker.cross{stroke:#333333;}#mermaid-svg-Eie6UqOAYU1BSsYy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Eie6UqOAYU1BSsYy p{margin:0;}#mermaid-svg-Eie6UqOAYU1BSsYy g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-Eie6UqOAYU1BSsYy g.classGroup text .title{font-weight:bolder;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster-label text{fill:#333;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster-label span{color:#333;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster-label span p{background-color:transparent;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster text{fill:#333;}#mermaid-svg-Eie6UqOAYU1BSsYy .cluster span{color:#333;}#mermaid-svg-Eie6UqOAYU1BSsYy .nodeLabel,#mermaid-svg-Eie6UqOAYU1BSsYy .edgeLabel{color:#131300;}#mermaid-svg-Eie6UqOAYU1BSsYy .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-Eie6UqOAYU1BSsYy .label text{fill:#131300;}#mermaid-svg-Eie6UqOAYU1BSsYy .labelBkg{background:#ECECFF;}#mermaid-svg-Eie6UqOAYU1BSsYy .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-Eie6UqOAYU1BSsYy .classTitle{font-weight:bolder;}#mermaid-svg-Eie6UqOAYU1BSsYy .node rect,#mermaid-svg-Eie6UqOAYU1BSsYy .node circle,#mermaid-svg-Eie6UqOAYU1BSsYy .node ellipse,#mermaid-svg-Eie6UqOAYU1BSsYy .node polygon,#mermaid-svg-Eie6UqOAYU1BSsYy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Eie6UqOAYU1BSsYy .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy g.clickable{cursor:pointer;}#mermaid-svg-Eie6UqOAYU1BSsYy g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Eie6UqOAYU1BSsYy g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Eie6UqOAYU1BSsYy .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-Eie6UqOAYU1BSsYy .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Eie6UqOAYU1BSsYy .dashed-line{stroke-dasharray:3;}#mermaid-svg-Eie6UqOAYU1BSsYy .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-Eie6UqOAYU1BSsYy #compositionStart,#mermaid-svg-Eie6UqOAYU1BSsYy .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #compositionEnd,#mermaid-svg-Eie6UqOAYU1BSsYy .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #dependencyStart,#mermaid-svg-Eie6UqOAYU1BSsYy .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #dependencyStart,#mermaid-svg-Eie6UqOAYU1BSsYy .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #extensionStart,#mermaid-svg-Eie6UqOAYU1BSsYy .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #extensionEnd,#mermaid-svg-Eie6UqOAYU1BSsYy .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #aggregationStart,#mermaid-svg-Eie6UqOAYU1BSsYy .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #aggregationEnd,#mermaid-svg-Eie6UqOAYU1BSsYy .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #lollipopStart,#mermaid-svg-Eie6UqOAYU1BSsYy .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy #lollipopEnd,#mermaid-svg-Eie6UqOAYU1BSsYy .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Eie6UqOAYU1BSsYy .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-Eie6UqOAYU1BSsYy .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Eie6UqOAYU1BSsYy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Eie6UqOAYU1BSsYy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Eie6UqOAYU1BSsYy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 只含内部状态
池化共享
客户端持引用+外部状态
<<interface>>
Stone
+draw(x, y) : void
SharedStone
-style_ string
-black_ bool
+draw(x, y) : void
StoneFactory
-pool_ map
+get(key) : Stone
Placement
+stone Stone
+x int
+y int
四个参与者:
- 享元(Flyweight)接口 :
Stone,操作签名里 自带外部状态参数 ------draw(x, y)这行签名就是二分法在类型上的投影; - 具体享元(Concrete Flyweight) :
SharedStone,只装不可变的内部状态; - 享元工厂(Flyweight Factory) :持有池子,
get(key)命中返回已有对象、未命中创建并登记------「同键同对象」的唯一保证人; - 客户端 :保管外部状态(本例的
Placement:享元引用 + 坐标),负责在调用时把外部状态递给享元。
GoF 角色表里还有一个 非共享具体享元(Unshared Concrete Flyweight):不参与共享、常作为组合结构(第 12 篇)中的容器节点,把一群共享享元编成树------文档编辑器里「行」「列」不共享,行里的「字符」共享,正是 GoF 原书的配置。
结构定性:享元模式把「对象的数量」与「状态的数量」解耦。没有它,对象数 = 使用次数;有了它,享元数 = 内部状态种类数,使用次数只影响客户端那份轻量记录(一个指针加几个标量)。
4. 传统 C++ 写法(C++11 之前)
C++98 完整形态:工厂管静态指针池、程序退出集中回收,享元禁拷贝、全 const:
cpp
// C++98/03 写法
#include <stdio.h>
#include <map>
#include <string>
#include <vector>
// ---- 享元接口:外部状态由参数传入 ----
class Stone {
public:
virtual ~Stone() {}
virtual void draw(int x, int y) const = 0;
protected:
Stone() {}
private:
Stone(const Stone&);
Stone& operator=(const Stone&);
};
// ---- 具体享元:只装内部状态,全 const ----
class SharedStone : public Stone {
public:
SharedStone(const std::string& style,
bool black)
: style_(style), black_(black) {}
void draw(int x, int y) const {
printf("[%s·%s] 落于 (%d,%d)\n",
black_ ? "黑" : "白",
style_.c_str(), x, y);
}
private:
std::string style_; // 内部状态:可共享
bool black_; // 内部状态:可共享
};
// ---- 享元工厂:池 + 「同键同对象」----
class StoneFactory {
public:
// 键形如 "B-云子":颜色 + 风格
static Stone* get(const std::string& key) {
std::map<std::string, Stone*>::iterator
it = pool_.find(key);
if (it != pool_.end())
return it->second; // 命中:共享
Stone* s = new SharedStone(
key.substr(2), key[0] == 'B');
pool_[key] = s;
return s;
}
static void shutdown() { // 集中回收
std::map<std::string, Stone*>::iterator
it = pool_.begin();
for (; it != pool_.end(); ++it)
delete it->second;
pool_.clear();
}
private:
static std::map<std::string, Stone*> pool_;
};
std::map<std::string, Stone*>
StoneFactory::pool_;
// ---- 客户端:外部状态自己保管 ----
struct Placement {
Stone* stone; // 指向共享享元
int x, y; // 外部状态
};
int main() {
std::vector<Placement> board;
board.push_back(
Placement{StoneFactory::get("B-云子"),
3, 3});
board.push_back(
Placement{StoneFactory::get("W-云子"),
15, 15});
board.push_back(
Placement{StoneFactory::get("B-云子"),
16, 2}); // 与第 1 颗同一个对象
for (std::size_t i = 0; i < board.size(); ++i)
board[i].stone->draw(board[i].x,
board[i].y);
StoneFactory::shutdown(); // 程序收尾必做
return 0;
}
注意第三次 get("B-云子") 拿回的是与第一次 同一个指针 ------两千颗黑子背后只有一份 SharedStone。传统写法的铁律:具体享元只在工厂里 new ,客户端拿到的是共享资产的引用而非所有权;享元绝不落盘可变状态 ,缓存之类的需求要么做成操作内局部量,要么挪给客户端;静态池的生命周期要显式收口 (shutdown() 挂在退出路径上)------静态对象的析构顺序无人作保,裸指针池在多个编译单元间尤其脆弱。
5. 现代 C++ 进阶写法
升级零:shared_ptr<const T>,把不可变纪律上锁 。工厂改 Meyers 单例(第 8 篇),池改 unordered_map,关键是返回类型里的那个 const------「谁也别想改享元」从注释升级成编译错误:
cpp
// 节选(Stone/SharedStone 同第 4 节)
#include <memory>
#include <string>
#include <unordered_map>
class StoneFactory {
public:
using StonePtr =
std::shared_ptr<const SharedStone>;
static StonePtr get(const std::string& key) {
auto& pool = instance().pool_;
auto it = pool.find(key);
if (it != pool.end())
return it->second;
auto s = std::make_shared<SharedStone>(
key.substr(2), key[0] == 'B');
pool.emplace(key, s);
return s;
}
private:
static StoneFactory& instance() {
static StoneFactory f; // Meyers 单例
return f;
}
std::unordered_map<std::string, StonePtr>
pool_;
};
shutdown() 消失了:池随单例析构,shared_ptr 保证「还有人在用的享元活得比池久」------这两点正是第 4 节静态裸指针池的两处暗礁。
改进一:weak_ptr 池------可回收的共享 。shared_ptr 池意味着享元 永久驻留:风格被切换后,旧风格的棋子对象仍占着内存。把池改成只记不拥:
cpp
// 节选(StonePtr 同升级零的定义)
#include <memory>
#include <string>
#include <unordered_map>
class WeakStoneCache {
public:
StonePtr get(const std::string& key) {
auto& slot = slots_[key];
if (auto hit = slot.lock())
return hit; // 还活着:直接复用
auto s = std::make_shared<SharedStone>(
key.substr(2), key[0] == 'B');
slot = s; // 只记一份weak观察哨
return s;
}
private:
std::unordered_map<
std::string,
std::weak_ptr<const SharedStone>>
slots_;
};
享元此刻活在客户端的 shared_ptr 里:人人都在用就常驻,最后一个使用者放手它就自然析构,池只是下次来问时「能不能搭个便车」的登记簿。内存压力大的长驻进程(编辑器、服务器)里,这版比永久池更稳;代价是反复「全放了又全建」的场景会抖动,两版按生命周期特征选。
改进二:先问该不该共享 。享元的账是「省下的重复量 − 新增的间接成本」,两个极端都不划算:内部状态只剩一两个标量时(比如只有颜色),Color 枚举直接内联就是最小最省的「享元」,加指针池反而多付 8 字节一次解引用;反过来,状态几乎全随实例变(每颗棋子的坐标都不同),二分切不出可共享的部分,硬上享元只会把字段搬家。工程判断三问:重复度 (同构对象有多少)、单份重量 (内部状态多大)、内外可分性(不变部分拎不拎得出来)------三问都是「是」,再动刀。
展望 :C++20 的异构查找(std::string_view 直接查 unordered_map<std::string, ...> 的池)省掉每次 get 的临时串;Boost.Flyweight 则把整件事做成了值类型(第 7 节展开),是「享元句柄」的模板化终态。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):内存占用随「种类数」而非「实例数」增长------上万个棋子对象塌缩成十来份共享状态;共享带来的局部性红利(同一份贴图反复命中缓存);与组合模式(第 12 篇)配合,能让「百万叶子的树」在内存里站得住------GoF 的文档编辑器正是这套组合拳。
- ❌ 缺点:内外状态的二分是设计成本 ------切错一刀,外部状态列表越滚越长,操作签名变成一长串参数(GoF 原话提醒:需要把整个计算上下文传给享元);不可变纪律限制表达------想「改」一个享元只能整体换新(copy-on-write),运行期频繁变化的状态被迫外移;工厂池是全局单点(第 8 篇的静态状态之弊);调试时「对象身份」失效------上千个棋盘位置共享同一对象,断点与日志都只认一份。
- 🎯 适用场景:海量细粒度对象且内部状态高度重复------文本字符与字形(GoF 原例)、棋子、粒子、地图瓦片、纹理、AST 中重复的标识符;内存是实测瓶颈(profile 说了算,不是直觉说了算);内外状态能干净分层。
〔辨析〕享元 vs 单例(第 8 篇):单例是「全类型只此一份」,享元是「每种取值一份」------单例可看作池大小恒为 1 的退化享元;两者的控制目标也不同(单例管唯一性,享元管内存账)。享元 vs 缓存:缓存为 速度 而生(重算贵),享元为 空间 而生(重复贵);weak_ptr 池同时具有两张面孔,但动机决定设计。享元 vs 对象池(内存池):对象池复用「可变对象」的存储 ------取出来填新值,同一块内存先后扮演不同对象;享元共享「不可变对象」的取值------同一对象此刻同时扮演所有使用者。把两者混为一谈,就会在池化对象上写出「改了怎么影响别人」的灵异 bug。
7. 开源项目中的身影
Boost.Flyweight:把享元做成值类型 。boost::flyweight<T> 让「共享」对使用者完全透明------它行为像 T(拷贝、比较、哈希),内部只持一个小句柄,同值对象全进程共享一份:
cpp
// 说明性片段(需包含 boost/flyweight.hpp)
#include <boost/flyweight.hpp>
#include <string>
#include <vector>
using boost::flyweight;
// 词表里 "the" 出现一万次,
// 进程内也只有一份 std::string
std::vector<flyweight<std::string>> words;
words.push_back("the");
words.push_back("the"); // 与上一项共享同一份
flyweight<std::string> a("config"), b("config");
bool same = &a.get() == &b.get(); // true
点评:这是享元的「值语义化」------客户端连指针都不看,等于编译器替你保管外部状态之外的一切。等值比较退化成句柄比对(O(1)),对海量字符串键的场景是白送的加速。代价是全局池的引入与 flyweight 装箱的模板开销------又一次「结构收益换全局状态」的经典交易。
AOSP:资源字符串池,二进制里的享元 。Android 的资源编译器 aapt 把所有资源里的字符串(名字、值、标签)去重后收进一张共享字符串池 ResStringPool,写进 resources.arsc;各资源条目只保存池内偏移,运行期按偏移取用:
cpp
// 说明性片段(节选自 AOSP 资源机制,示意)
// "button_label" 在 50 个布局里出现,
// arsc 里只有一份字符串
const char16_t* text =
resPool.stringAt(entry.stringRef);
点评:享元被做成了 文件格式与运行期的公共约定------池化不发生在代码层,而发生在产物层,连跨进程共享(zygote 预加载的资源表)都吃到了同一份红利。它示范了享元的适用面远大于「类图上的工厂池」:凡是「种类远少于实例」的数据,无论在堆上、在文件里、在网络上,都值得问一句能不能共享。
图形工业:纹理图集与字形缓存------GoF 原例的现役形态。游戏引擎把上千张小纹理拼进一张「图集」大纹理,每个精灵只存 (图集, 矩形) 的引用;文本渲染按 (字体, 字号, 码点) 缓存光栅化后的字形位图,同一字形成千上万次出现只光栅化一次。两者与本篇棋子的结构逐字对应:共享的内部状态是像素资产,外部状态是绘制位置与颜色变换。顺带一句相邻概念:图形界同样有「对象池」(粒子池、命令缓冲池),复用的是可变存储------第 6 节辨析的那条线,在工业代码里同样要划清。
三份代码合看:Boost 把享元藏进值类型、AOSP 把它铸进文件格式、图形界把它用在最热的渲染路径------「种类数代替实例数」这笔账,从 GoF 时代一直算到今天,且越算越大。
本篇小结
海量细粒度对象的世界里,先问一句「哪些状态其实不随实例变」:把这部分搬进不可变的共享对象,把随实例变的外部状态留给客户端、在调用时作为参数递回。享元的数量从此等于内部状态的种类数,内存账从「实例数 × 单份重量」塌缩为「种类数 × 单份重量」。三条纪律守住安全:享元只 const、只能从工厂领、客户端不执着所有权。现代 C++ 给了两档池:shared_ptr<const T> 永久驻留、weak_ptr 池按需回收,按生命周期特征选。而动手之前的三个问题(重复度、单份重量、内外可分性)比任何实现都重要------享元是给「实测的内存瓶颈」开的药,不是给「感觉对象有点多」开的补品。下一篇代理模式,看「贵、远、险」的资源如何由一位替身看管。
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「享元」一章,意图译文、内外状态划分、非共享享元与 Glyph 示例参考了 GoF《Design Patterns》第 4 章 Flyweight 一节。