14、享元模式:模式动机与定义、内部状态与外部状态、C++代码实现、池化技术

模式动机:从“对象太多”说起

你有没有遇到过这种情况?系统里创建了成千上万个对象,内存飙升,GC频繁,程序卡得像幻灯片。我早年做一款棋类游戏时,就踩过这个坑。每个棋子都是一个独立对象,棋盘上几百个棋子,加上各种特效、粒子,内存直接爆了。

后来我意识到,很多对象其实长得一模一样。比如象棋里的“兵”,所有“兵”的外观、走法规则都一样。区别只是它们的位置不同。那我干嘛要创建几百个“兵”对象?存一份“兵”的共享数据,再让每个棋子记住自己的位置不就完了?

这就是享元模式的动机——通过共享大量细粒度对象,减少内存消耗。说白了,就是“抠门”地复用对象,避免重复创建。

核心思想: 将对象的状态分为内部状态(共享)和外部状态(不共享),内部状态存储在享元对象内部,外部状态由客户端维护,在调用时传入。

内部状态 vs 外部状态

这两个概念是享元模式的核心。我习惯这样区分:

  • 内部状态(Intrinsic State):对象固有的、可共享的属性。比如棋子的颜色、形状、字体。这些属性不会随上下文改变。
  • 外部状态(Extrinsic State):对象依赖上下文的属性。比如棋子在棋盘上的坐标、当前是否被选中。这些属性会随场景变化,不能共享。

你想想看,如果我把所有状态都塞进对象里,那每个对象都是独一无二的,没法共享。只有把“不变”的部分抽出来共享,“变”的部分交给客户端管理,才能达到节省内存的目的。

我的经验: 设计享元时,先问自己“这个属性会不会随着使用场景变化?”如果会,就把它踢到外部状态里去。我曾经把“字体颜色”也当成内部状态,结果不同界面需要不同颜色,搞得一团糟。后来老老实实把颜色放到外部状态,世界清净了。

C++代码实现:一个简单的享元工厂

我们以“字符”为例。假设一个文本编辑器要显示大量字符,每个字符都有字体、大小、颜色等属性。如果每个字符都独立存储,内存会爆炸。我们用享元模式来优化。

// 享元接口
class Glyph {
public:
    virtual ~Glyph() = default;
    virtual void draw(const std::string& extrinsicState) = 0; // 外部状态传入
};

// 具体享元:字符
class Character : public Glyph {
private:
    char symbol;          // 内部状态:字符本身
    std::string font;     // 内部状态:字体
    int size;             // 内部状态:字号
public:
    Character(char s, const std::string& f, int sz)
        : symbol(s), font(f), size(sz) {}

    void draw(const std::string& extrinsicState) override {
        // extrinsicState 包含颜色、位置等外部信息
        std::cout << "绘制字符 '" << symbol << "' 字体:" << font
                  << " 字号:" << size << " 外部状态:" << extrinsicState << std::endl;
    }
};

// 享元工厂
class GlyphFactory {
private:
    std::unordered_map<std::string, std::shared_ptr<Glyph>> pool;
public:
    std::shared_ptr<Glyph> getGlyph(char symbol, const std::string& font, int size) {
        // 用组合键作为唯一标识
        std::string key = std::string(1, symbol) + "_" + font + "_" + std::to_string(size);
        auto it = pool.find(key);
        if (it != pool.end()) {
            return it->second;
        }
        // 没找到就创建新享元,并加入池中
        auto glyph = std::make_shared<Character>(symbol, font, size);
        pool[key] = glyph;
        return glyph;
    }

    size_t getPoolSize() const { return pool.size(); }
};

// 客户端使用
int main() {
    GlyphFactory factory;

    // 外部状态:颜色+位置
    std::vector<std::string> externalStates = {
        "red@(10,20)", "blue@(30,40)", "red@(50,60)", "blue@(70,80)"
    };

    // 绘制多个字符,但共享内部状态相同的对象
    auto g1 = factory.getGlyph('A', "Arial", 12);
    auto g2 = factory.getGlyph('A', "Arial", 12); // 与g1共享
    auto g3 = factory.getGlyph('B', "Arial", 12);
    auto g4 = factory.getGlyph('A', "Times", 14);

    g1->draw(externalStates[0]);
    g2->draw(externalStates[1]);
    g3->draw(externalStates[2]);
    g4->draw(externalStates[3]);

    std::cout << "池中享元数量: " << factory.getPoolSize() << std::endl; // 输出3
    return 0;
}

你看,g1和g2虽然外部状态不同,但内部状态完全一样,所以它们指向同一个享元对象。池里只存了3个对象,而不是4个。如果字符量上万,节省的内存就很可观了。

注意: 享元工厂的键设计要合理。如果键太粗(比如只按字符分组),会导致不同字体的字符被错误共享;如果键太细(比如把外部状态也加进去),又失去了共享的意义。我一般只把内部状态属性作为键。

池化技术:享元模式的亲兄弟

说到享元,就不得不提池化技术。线程池、连接池、对象池……这些本质上都是享元模式的应用。它们都是把创建成本高的对象缓存起来,重复使用,避免频繁创建和销毁。

我参与过一个高并发服务器项目,每次请求都创建新的数据库连接,结果系统很快被连接数打满。后来改用连接池,固定维护10个连接,请求来了从池里取,用完归还。性能直接翻倍。

池化技术和享元模式的区别在于:

特性 享元模式 池化技术
核心目的 共享细粒度对象,减少内存 复用重量级资源,减少创建开销
状态管理 内部状态共享,外部状态分离 对象本身状态通常不共享,但复用实例
典型场景 文本编辑器字符、游戏粒子 数据库连接、线程、网络连接
实现方式 享元工厂 + 内部/外部状态 资源池 + 借/还机制

说白了,池化技术是享元模式的一种具体实践。只不过享元更强调“状态分离”,而池化更强调“资源复用”。

知识体系核心逻辑

下面这张图帮你理清享元模式的整体脉络:

享元模式核心逻辑 模式动机 减少大量相似对象的内存 核心概念 内部状态 vs 外部状态 C++实现 享元工厂 + 共享对象 内部状态(共享) • 对象固有属性 • 不随上下文变化 • 存储在享元对象内部 外部状态(不共享) • 依赖上下文的属性 • 随场景变化 • 由客户端维护并传入 池化技术:享元模式的典型应用(线程池、连接池、对象池)

避坑指南与个人经验

我曾经在一个图形编辑器项目里滥用享元模式。当时我把所有图形对象都塞进享元池,结果发现有些图形(比如用户手绘的线条)几乎每个都不一样,根本没法共享。强行共享反而让代码变得复杂,性能也没提升。

后来我总结了几条经验:

  • 不是所有对象都适合享元。 只有那些数量巨大、且内部状态重复率高的对象才值得用。如果对象本身很少,或者每个对象都独一无二,用了反而增加复杂度。
  • 外部状态不要太多。 如果外部状态占了对象状态的大头,那共享的意义就不大了。你想想看,如果每个对象90%的属性都是外部状态,那还不如直接创建独立对象。
  • 线程安全要注意。 享元对象通常是多线程共享的,如果内部状态可变,就需要加锁。我一般把内部状态设计成只读的,这样就不用考虑同步问题了。
我的习惯: 在项目初期,我不会急着用享元模式。先写一个简单版本,跑起来看看内存占用。如果发现内存确实成了瓶颈,再重构引入享元。过早优化是万恶之源,这句话在享元模式上尤其适用。

好了,享元模式就聊到这里。记住它的核心:分离内部状态和外部状态,通过共享减少对象数量。池化技术是它的好兄弟,但别忘了它们各自的适用场景。下次你遇到“对象太多”的问题时,不妨想想享元模式——也许它就是你要找的答案。

公众号:蓝海资料掘金营,微信deep3321