① 今日主题
享元模式(Flyweight Pattern)是一种结构型设计模式:它通过共享来高效支持大量细粒度对象。核心手法是把对象的状态拆成两部分——内部状态(可共享、不随环境改变)与外部状态(随使用场景改变、由客户端持有),让成千上万个「逻辑对象」复用同一批「物理对象」,从而把内存占用从 O(N) 降到 O(M),其中 M 是去重后的状态组合数,通常远小于 N。
一句话:把「千人一面的部分」抽出来共享,把「因人而异的部分」交给使用者。
② 为什么需要这个模式
先看一个典型场景:文本编辑器。一个 10 万字的文档,每个字符都需要知道自己的字体、字号、颜色和位置。最朴素的实现是「每个字符一个完整对象」:
#include <iostream>
#include <string>
#include <vector>
#include <cstdint>
// 坏设计:每个字符都持有完整的格式信息
struct Glyph {
char ch; // 字符
std::string font; // 字体名 —— 每个字符都复制一份!
int fontSize; // 字号 —— 每个字符都复制一份!
uint32_t color; // 颜色 —— 每个字符都复制一份!
int x, y; // 位置(真正随字符变化的只有它)
Glyph(char c, std::string f, int s, uint32_t col, int px, int py)
: ch(c), font(std::move(f)), fontSize(s), color(col), x(px), y(py) {}
};
int main() {
std::vector<Glyph> doc;
// 一篇 10 万字的文档,全部使用同一种字体格式
for (int i = 0; i < 100000; ++i) {
doc.emplace_back(char('a' + (i % 26)), "SimSun", 12, 0x000000,
(i % 80) * 8, (i / 80) * 16);
}
std::cout << "字符数: " << doc.size() << "\n";
std::cout << "每个 Glyph 占 " << sizeof(Glyph)
<< " 字节,总内存约 "
<< doc.size() * sizeof(Glyph) / 1024.0 / 1024.0 << " MB\n";
return 0;
}
这段代码能跑,但问题非常明显:
- 内存爆炸:10 万个字符 = 10 万个完整对象,每个都复制一份 "SimSun" 字符串和格式字段。文档越长浪费越大,而且是线性增长、没有上限。
- 缓存失效:渲染引擎的字形缓存需要按「字符 × 字体 × 字号」去重,结果每个对象都自带一份格式,缓存永远无法命中,绘制性能同样糟糕。
- 违背单一职责(SRP):Glyph 同时承担两个变化维度——「内容与格式」和「位置」。前者全文档高度重复,后者每个字符都不同。把两个维度捆在一个类里,谁都无法独立演化:想全局换主题色,必须遍历全部 10 万个对象逐个修改。
- 违背开闭原则(OCP):想新增「粗体」属性,所有对象的构造与存储都要改;想支持渐变颜色,要么加字段、要么子类化,改动面是 O(N)。
仔细想想:这 10 万个字符里,字体、字号、颜色其实只有极少数几种组合(比如正文黑体 12、标题黑体 18、注释灰体 10……)。为每一种组合保留一份格式对象,让所有字符共享它,只各自记录自己的位置——内存立刻从 10 万份降到几份。这就是享元模式的出发点。
③ 核心思想
享元模式把对象的状态一分为二:
- 内部状态(Intrinsic State):不随使用场景改变、可以被多个对象共享的部分,例如字符的字体、字号、颜色。内部状态存在享元对象内部,享元对象因此必须是不可变的(至少内部状态部分不可变)——否则一个使用者改了它,所有共享者都被殃及。
- 外部状态(Extrinsic State):随使用场景改变、不能被共享的部分,例如字符在页面上的 x/y 坐标、它是第几个段落里的第几个字。外部状态由客户端持有,在使用享元时作为参数传入。
两者的分界线只有一个问题:「这份数据在不同使用场景下会不会变?」会变 → 外部;不会变 → 内部。
生活类比:图书馆。图书馆里《设计模式》只有一本实体书,却可以同时被几十个人借阅。书的内容(内部状态)不会因为读者不同而改变;每个读者自己的阅读进度、书签位置(外部状态)各不相同,由读者各自保管。如果没有共享,图书馆就得为每个读者复印一整本书——成本高得离谱。
再比如共享单车:车本身是共享的(内部状态:车型、车况),你骑到哪、骑多久(外部状态)是你自己的事。如果每个人都要「买一辆自己的车」,城市早就停不下了。
享元模式的收益公式:逻辑对象数 N(使用者视角)≫ 物理对象数 M(内存中实际存在)。N 可以是一百万,M 可能只有几十。
④ UML / 角色关系
享元模式包含四个角色:
| 角色 | 职责 |
|---|---|
| Flyweight(抽象享元) | 声明操作接口;接口方法接收外部状态作为参数(享元自身不保存外部状态) |
| ConcreteFlyweight(具体享元) | 实现接口;内部持有内部状态(不可变、可共享) |
| FlyweightFactory(享元工厂) | 维护享元池;get() 命中即返回、未命中则创建并注册;通常配合单例使用 |
| Client(客户端) | 持有外部状态;向工厂请求享元,把享元与自己的外部状态组合使用 |
类图与协作流程(文字版):
┌────────────────────┐ ┌─────────────────────────┐
│ FlyweightFactory │ 创建/缓存 │ ConcreteFlyweight │
│ - pool: 享元池 │─────────▶ │ - 内部状态(不可变) │
│ + get(内部状态) │ │ + operation(外部状态) │
└────────────────────┘ └─────────────────────────┘
▲ ▲
│ 返回共享实例 │ 传入外部状态
│ │
┌─────┴───────────┐ ┌──────────────┴──────────┐
│ Client │────────▶│ 外部状态(客户端持有) │
└─────────────────┘ 组合使用 └─────────────────────────┘
注意两点:① 享元工厂是创建享元的唯一入口,客户端不直接 new 享元;② 外部状态从不进入享元对象——它要么作为方法参数传入,要么由客户端保存在自己的结构里。
⑤ 最小 C++ 示例(C++17)
用 C++17 重写上面的文本编辑器场景:「字符 × 字体 × 字号 × 颜色」作为内部状态(享元),「坐标」作为外部状态(客户端持有)。完整可编译:
#include <iomanip>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
// ========== 抽象享元:声明接口,操作接收外部状态 ==========
class Glyph {
public:
virtual ~Glyph() = default;
virtual void draw(int x, int y) const = 0; // x, y 是外部状态
};
// ========== 具体享元:字符 + 格式(内部状态,可共享、不可变) ==========
class CharacterGlyph final : public Glyph {
public:
CharacterGlyph(char ch, std::string font, int size, unsigned color)
: m_ch(ch), m_font(std::move(font)), m_size(size), m_color(color) {}
void draw(int x, int y) const override {
std::cout << "绘制 '" << m_ch << "' 于 (" << x << ", " << y
<< ") 字体=" << m_font << " 字号=" << m_size
<< " 颜色=#" << std::hex << std::setw(6) << std::setfill('0')
<< m_color << std::dec << std::setfill(' ') << '\n';
}
private:
char m_ch; // 内部状态:字符
std::string m_font; // 内部状态:字体(所有同款字符共享这一份)
int m_size; // 内部状态:字号
unsigned m_color; // 内部状态:颜色
};
// ========== 享元工厂:享元池,命中返回 / 未命中创建 ==========
class GlyphFactory {
public:
std::shared_ptr<const Glyph> get(char ch, const std::string& font,
int size, unsigned color) {
Key key{ch, font, size, color};
auto it = m_pool.find(key);
if (it != m_pool.end()) {
return it->second; // 命中:直接复用
}
auto glyph = std::make_shared<CharacterGlyph>(ch, font, size, color);
m_pool.emplace(std::move(key), glyph); // 未命中:创建并注册
return glyph;
}
std::size_t poolSize() const { return m_pool.size(); }
private:
struct Key { // 内部状态组合 = 池的键
char ch;
std::string font;
int size;
unsigned color;
bool operator==(const Key& o) const {
return ch == o.ch && font == o.font
&& size == o.size && color == o.color;
}
};
struct KeyHash { // 供 unordered_map 使用
std::size_t operator()(const Key& k) const {
std::size_t h = std::hash<char>{}(k.ch);
h ^= std::hash<std::string>{}(k.font) + 0x9e3779b9 + (h << 6) + (h >> 2);
h ^= std::hash<int>{}(k.size) + 0x9e3779b9 + (h << 6) + (h >> 2);
h ^= std::hash<unsigned>{}(k.color) + 0x9e3779b9 + (h << 6) + (h >> 2);
return h;
}
};
std::unordered_map<Key, std::shared_ptr<const Glyph>, KeyHash> m_pool;
};
// ========== 客户端:外部状态(坐标)与享元的组合 ==========
struct PlacedGlyph {
std::shared_ptr<const Glyph> glyph; // 指向共享享元(物理对象)
int x, y; // 外部状态(逻辑差异所在)
};
int main() {
GlyphFactory factory;
std::vector<PlacedGlyph> doc;
doc.reserve(100000);
// 一篇 10 万字的文档:字符 26 种循环,格式只有 1 种
for (int i = 0; i < 100000; ++i) {
char ch = static_cast<char>('a' + (i % 26));
doc.push_back({factory.get(ch, "SimSun", 12, 0x000000),
(i % 80) * 8, (i / 80) * 16});
}
std::cout << "文档逻辑字符数: " << doc.size() << '\n';
std::cout << "实际创建的享元对象数: " << factory.poolSize() << '\n';
std::cout << "外部状态内存: "
<< doc.size() * sizeof(PlacedGlyph) / 1024.0 / 1024.0
<< " MB(享元对象仅 26 份,可忽略)\n";
// 验证共享:doc[0] 与 doc[26] 都是 'a',应指向同一个享元
std::cout << "doc[0] 与 doc[26] 共享同一享元: "
<< (doc[0].glyph.get() == doc[26].glyph.get() ? "是" : "否") << '\n';
doc[0].glyph->draw(doc[0].x, doc[0].y); // 外部状态在使用时传入
return 0;
}
程序输出:
文档逻辑字符数: 100000
实际创建的享元对象数: 26
外部状态内存: 2.29 MB(享元对象仅 26 份,可忽略)
doc[0] 与 doc[26] 共享同一享元: 是
绘制 'a' 于 (0, 0) 字体=SimSun 字号=12 颜色=#000000
对比一下(同样是 10 万字文档):
| 方案 | 物理对象数 | 格式信息内存 | 换主题色 |
|---|---|---|---|
| 朴素实现(每字符一对象) | 100,000 | 约 5.3 MB(含重复字体字符串) | 遍历 10 万对象逐个改 |
| 享元模式 | 26 | 约 0.1 KB(26 份格式) | 改 26 个享元即可 |
⑥ Qt 实战示例(Qt6 + C++17)
先看 Qt 自己怎么用享元思想。Qt 里最贴近享元的是隐式共享(Implicit Sharing,写时复制 COW):QString、QFont、QPalette、QPixmap、QImage、QByteArray 等大量值类型内部通过 d-pointer + 引用计数共享同一份数据——复制一个 QString 只复制一个指针,只有写入时才深拷贝。可以把「被共享的数据」看作享元,把「每个 QString 值」看作持有一个共享引用的客户端。
更严格意义上的享元工厂在字体系统里:QFontDatabase 是字体信息的中央访问点(本质是单例),Qt 内部通过字体引擎缓存(QFontEngineCache)保证「同一字体描述符 → 同一个字体引擎实例」,所有控件绘制时共享同一套字形数据。此外 QPixmapCache 全局缓存 pixmap,QIcon 内部按 模式(Normal/Disabled) × 状态(On/Off) × 尺寸 缓存多个 QPixmap——一套图标资源在成千上万个按钮之间共享,而不是每个按钮各自加载一份。
下面写一个自建的 Qt 实战:一个字符画布,2000 个字符只使用 2 个「样式享元」(正文黑体 / 标题蓝色粗体),外部状态(字符 + 坐标)由画布持有。工程完整可构建。
style.h —— 具体享元 + 享元工厂
#pragma once
#include <QColor>
#include <QFont>
#include <QPainter>
#include <memory>
#include <vector>
// 具体享元:可共享的绘制样式(内部状态:字体 + 颜色,不可变)
class TextStyle {
public:
TextStyle(QFont font, QColor color)
: m_font(std::move(font)), m_color(std::move(color)) {}
const QFont& font() const { return m_font; }
const QColor& color() const { return m_color; }
// 外部状态(绘制位置等)由调用方传入
void apply(QPainter& painter) const {
painter.setFont(m_font);
painter.setPen(m_color);
}
private:
QFont m_font; // 内部状态(QFont 本身是隐式共享类,复制极廉价)
QColor m_color; // 内部状态
};
// 享元工厂:按 (字体, 颜色) 去重,保证每种样式只有一个实例
class TextStyleFactory {
public:
std::shared_ptr<const TextStyle> get(const QFont& font, const QColor& color) {
for (const auto& s : m_pool) {
if (s->font() == font && s->color() == color) {
return s; // 命中:复用已有享元
}
}
auto style = std::make_shared<TextStyle>(font, color);
m_pool.push_back(style); // 未命中:创建并注册
return style;
}
std::size_t poolSize() const { return m_pool.size(); }
private:
std::vector<std::shared_ptr<const TextStyle>> m_pool;
};
canvas.h —— 画布(客户端,持有外部状态)
#pragma once
#include <QWidget>
#include <memory>
#include <vector>
#include "style.h"
// 外部状态:字符 + 坐标 + 指向共享样式的指针
struct PlacedChar {
QString ch; // 外部状态:字符
int x, y; // 外部状态:坐标
std::shared_ptr<const TextStyle> style; // 指向共享享元
};
class Canvas : public QWidget {
Q_OBJECT
public:
explicit Canvas(QWidget* parent = nullptr);
protected:
void paintEvent(QPaintEvent* event) override;
private:
TextStyleFactory m_factory; // 享元工厂(成员,保证生命周期)
std::vector<PlacedChar> m_chars; // 外部状态集合
};
canvas.cpp —— 实现
#include "canvas.h"
Canvas::Canvas(QWidget* parent) : QWidget(parent) {
// 2000 个字符,但样式只有 2 种 → 物理享元对象只有 2 个
for (int i = 0; i < 2000; ++i) {
const QFont font = (i % 2 == 0) ? QFont(QStringLiteral("SimSun"), 12)
: QFont(QStringLiteral("SimHei"), 14, QFont::Bold);
const QColor color = (i % 2 == 0) ? QColor(Qt::black) : QColor(Qt::darkBlue);
auto style = m_factory.get(font, color); // 从工厂取共享享元
const QChar ch = QChar(QLatin1Char(static_cast<char>('A' + (i % 26))));
m_chars.push_back({QString(ch), (i % 50) * 12, (i / 50) * 18, std::move(style)});
}
}
void Canvas::paintEvent(QPaintEvent*) {
QPainter painter(this);
for (const auto& c : m_chars) {
c.style->apply(painter); // 共享样式只负责设置画笔状态
painter.drawText(c.x, c.y, c.ch); // 外部状态(字符+位置)各自绘制
}
}
main.cpp
#include <QApplication>
#include "canvas.h"
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
Canvas canvas;
canvas.resize(640, 760);
canvas.setWindowTitle(QStringLiteral("Day 11 享元模式 —— 字符渲染"));
canvas.show();
return app.exec();
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(FlyweightDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(FlyweightDemo
main.cpp
canvas.h
canvas.cpp
style.h
)
target_link_libraries(FlyweightDemo PRIVATE Qt6::Widgets)
TextStyleFactory::poolSize() 恒为 2——所有字符共享同一份样式数据,这正是享元在真实 GUI 程序里的形态。⑦ 代码执行流程
以 Qt 示例为例,享元模式的完整执行流程分四步:
① 初始化(首次请求):Canvas 构造函数遍历 2000 个字符,向 TextStyleFactory::get() 请求样式,传入内部状态(字体、颜色)。工厂第一次遇到某种「字体 × 颜色」组合时,make_shared 创建享元并注册进池子。
② 复用(后续请求):遇到相同的内部状态组合时,工厂在池中命中,直接返回已有实例——不执行任何 new。因此无论请求多少次,池中永远只有 2 个样式对象。
③ 组合:客户端把享元指针与自己的外部状态(字符、坐标)组装成 PlacedChar 存入 m_chars 容器。此时「共享的物理对象」与「各自的外部状态」已经一一挂接。
④ 使用:paintEvent 中循环取出每个 PlacedChar,调用 style->apply(painter) 设置绘制状态(享元被 2000 个字符共享),再用外部状态 drawText(x, y, ch) 完成绘制——同一个享元、不同的位置,画出 2000 个字符。