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个连接,请求来了从池里取,用完归还。性能直接翻倍。
池化技术和享元模式的区别在于:
| 特性 | 享元模式 | 池化技术 |
|---|---|---|
| 核心目的 | 共享细粒度对象,减少内存 | 复用重量级资源,减少创建开销 |
| 状态管理 | 内部状态共享,外部状态分离 | 对象本身状态通常不共享,但复用实例 |
| 典型场景 | 文本编辑器字符、游戏粒子 | 数据库连接、线程、网络连接 |
| 实现方式 | 享元工厂 + 内部/外部状态 | 资源池 + 借/还机制 |
说白了,池化技术是享元模式的一种具体实践。只不过享元更强调“状态分离”,而池化更强调“资源复用”。
知识体系核心逻辑
下面这张图帮你理清享元模式的整体脉络:
避坑指南与个人经验
我曾经在一个图形编辑器项目里滥用享元模式。当时我把所有图形对象都塞进享元池,结果发现有些图形(比如用户手绘的线条)几乎每个都不一样,根本没法共享。强行共享反而让代码变得复杂,性能也没提升。
后来我总结了几条经验:
- 不是所有对象都适合享元。 只有那些数量巨大、且内部状态重复率高的对象才值得用。如果对象本身很少,或者每个对象都独一无二,用了反而增加复杂度。
- 外部状态不要太多。 如果外部状态占了对象状态的大头,那共享的意义就不大了。你想想看,如果每个对象90%的属性都是外部状态,那还不如直接创建独立对象。
- 线程安全要注意。 享元对象通常是多线程共享的,如果内部状态可变,就需要加锁。我一般把内部状态设计成只读的,这样就不用考虑同步问题了。
好了,享元模式就聊到这里。记住它的核心:分离内部状态和外部状态,通过共享减少对象数量。池化技术是它的好兄弟,但别忘了它们各自的适用场景。下次你遇到“对象太多”的问题时,不妨想想享元模式——也许它就是你要找的答案。