Day 11:享元模式(Flyweight Pattern)

用「共享」对抗「海量」:把对象的内部状态抽出来共用,把外部状态交给使用者。

结构型模式 GoF 23 · 第 11/23 天 2026-08-23

① 今日主题

享元模式(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)
运行效果:窗口里出现 40 行 × 50 列的彩色字符阵列(黑白两色交替)。虽然创建了 2000 个逻辑字符,但 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 个字符。

⑧ 为什么这样设计

享元模式的结构不是随意定的,每一处设计都在回应一个具体问题:

⑨ 不使用会怎样

享元解决的是「对象数量 / 内存膨胀」问题(不是 if-else 膨胀——那是策略、状态模式的领域)。不使用的后果,随场景不同而不同:

核心代价对比:朴素方案 —— 代码直观但内存线性爆炸、缓存失效、改动面 O(N);享元方案 —— 需要先做状态拆分设计、工厂多一层间接,但内存收敛、缓存命中、改动面 O(组合数)。

⑩ 何时使用

适合使用(满足 2 条以上值得考虑):

不适合使用:

过度设计提醒:先 profile 内存!确认「重复字段确实是瓶颈」再上享元。如果对象只有几百个、内存余量充足,享元带来的工厂与不可变约束就是纯成本。经验法则:对象数量 < 10⁴ 且无内存压力时,优先保持简单。

⑪ 与其他模式区别

对比核心区别一句话记忆
享元 vs 单例单例保证「同类全局唯一」;享元保证「每种内部状态组合唯一」——享元对象可以有多个(每种组合一个),只是享元工厂常实现为单例单例管「一个类一个实例」,享元管「一个键一个实例」
享元 vs 原型原型通过克隆产生新对象(每个克隆体独立);享元复用同一对象(所有使用者共享同一份数据)原型增加对象数量,享元减少对象数量
享元 vs 对象池对象池复用「昂贵对象的创建/销毁开销」(时间维度:取出独占、用完归还、状态可变);享元共享「不可变数据」减少内存(空间维度:同时被多方使用)连接池是对象池,字体缓存是享元

最容易混淆的是享元与对象池:两者都有「池」和「复用」,但对象池中的对象取出后是独占的(两个线程不能同时用同一个连接),而享元对象同时被所有人共享——这也是享元必须不可变的原因。

⑫ Qt 源码中的体现

说明:Qt 官方文档把「隐式共享」作为独立概念讲述,并不直接称之为享元模式,但其底层「共享不可变数据 + 引用计数」与享元同源;严格的享元形态(显式工厂 + 外部状态)在 Qt 中以字体引擎缓存、QPixmapCache 为代表。

⑬ 面试常见问题

Q1:享元模式为什么能节省内存?

A:因为它把对象状态拆成内部状态与外部状态。内部状态(可共享部分)只保存一份,所有逻辑对象通过引用共享它;外部状态(真实差异)由客户端持有,是任何方案都省不掉的。省掉的正是「重复存储的可共享数据」,物理对象数从 O(N) 收敛到 O(状态组合数)。

Q2:内部状态和外部状态如何区分?

A:判断标准只有一个——「这份数据在不同使用场景下会不会变」。不会变、可共享的存享元内部(内部状态,必须不可变);会变、随场景而异的由客户端持有、使用时传入(外部状态)。例如字符的字体是内部状态,坐标是外部状态。

Q3:享元模式和对象池有什么区别?

A:对象池解决「创建/销毁开销」,池中对象被取出后独占使用、状态可变、用完归还(时间维度复用);享元解决「内存占用」,享元对象同时被多个客户端共享、不可变(空间维度共享)。典型例子:数据库连接池是对象池,字体缓存是享元。

Q4:享元对象为什么必须不可变?

A:共享意味着同一对象同时被多个客户端引用,若内部状态可变,任何一个客户端修改都会波及其余所有共享者,造成难以排查的隐性耦合。不可变才能保证共享安全,也让多线程读取无需加锁。

Q5:享元工厂如何保证线程安全?

A:三种常见做法——① 加锁:用 std::mutex 保护池的读写;② 预创建:启动时一次性建好所有享元,之后只读;③ 线程局部池:每个线程维护自己的池,避免争用。C++ 里还可用 std::call_once 初始化单例工厂。

⑭ 今日练习(15-30 分钟)

需求:报表样式缓存

实现一个表格渲染器:生成 5000 个「单元格」对象,每个单元格由 样式(字体/字号/颜色/是否加粗)+ 文本 + 行号列号 组成。要求用享元模式保证内存中只有 4 个样式物理对象(标题 / 表头 / 正文 / 强调),并输出:逻辑对象数、物理样式对象数、工厂命中率(命中次数 ÷ 总请求次数)。

扩展(可选):① 给样式增加「主题」维度,实现一键切换主题后所有单元格样式同步变化(验证享元的 OCP 收益);② 把工厂的线性查找换成 QHash / std::unordered_map。

提示:

  • 内部状态 = 样式组合;外部状态 = 文本 + 行列号。先想清楚分界线再动手。
  • 工厂的键可以是结构体,也可以是字符串拼接(如 "SimSun|12|#000000|0")。
  • 命中率 = (总请求数 − 池大小) ÷ 总请求数;验证时故意让 5000 个单元格轮流使用 4 种样式。

⑮ 今日总结

一句话记忆:把「千人一面的部分」抽出来共享,把「因人而异的部分」交给使用者。

代码特征信号(看到这些就该想到享元):

明日预告:Day 12 代理模式(Proxy Pattern)——给真实对象配一个「替身 / 占位」,把访问控制、延迟加载、日志记录、权限校验从业务对象里剥离出去。看 Qt 里的 QProxyStyle、QAbstractProxyModel 如何用代理优雅地扩展框架,明天见!