13. 享元模式(Flyweight):共享对象,内部状态与外部状态,C++中的字符串常量池
享元模式,说白了就是「共享对象」。
我刚开始学设计模式时,觉得这名字挺玄乎的。后来在项目中做游戏开发,要渲染成千上万个树,每个树都有纹理、颜色、位置。如果每棵树都单独存一份纹理数据,内存直接爆炸。这时候享元模式就派上用场了。
核心思想:把对象拆成两半
享元模式的核心,就是把对象的状态分成两类:
- 内部状态(Intrinsic State):可以共享的、不变的部分。比如树的纹理、颜色。
- 外部状态(Extrinsic State):不能共享的、变化的部分。比如树的位置、大小。
你想想看,如果每个树对象都存一份纹理,那10000棵树就有10000份纹理。但纹理其实是一样的,存一份就够了。这就是享元模式要解决的问题。
关键点:内部状态存储在享元对象内部,外部状态由客户端管理,在使用时传入。
C++中的字符串常量池
说到享元模式,C++程序员最熟悉的例子就是字符串常量池了。
我记得有一次,我在优化一个日志系统。代码里到处是字符串字面量,比如 "ERROR"、"WARNING"、"INFO"。我一开始没在意,后来用工具一分析,发现这些字符串在内存里重复出现了几百次。
为什么会这样?
因为C++编译器会把相同的字符串字面量合并到常量区。比如:
const char* s1 = "Hello";
const char* s2 = "Hello";
// s1 和 s2 指向同一块内存
这就是享元模式在编译器层面的应用。字符串常量池就是享元工厂,它保证相同的字符串只存一份。
小技巧:如果你用 std::string,它内部也可能做小字符串优化(SSO),但不会自动去重。如果你需要大量重复字符串,可以考虑自己实现一个字符串池。
代码示例:游戏中的树
咱们来看一个实际例子。假设你在开发一个森林场景,要渲染10000棵树。
// 享元类:树的纹理
class TreeModel {
public:
TreeModel(const std::string& texture, const std::string& color)
: texture_(texture), color_(color) {}
void render(int x, int y, float scale) const {
// 使用内部状态(纹理、颜色)和外部状态(位置、大小)渲染
std::cout << "渲染树: " << texture_ << " " << color_
<< " 位置(" << x << ", " << y << ") 缩放" << scale << std::endl;
}
private:
std::string texture_; // 内部状态:可共享
std::string color_; // 内部状态:可共享
};
// 享元工厂
class TreeModelFactory {
public:
std::shared_ptr<TreeModel> getModel(const std::string& texture, const std::string& color) {
std::string key = texture + ":" + color;
auto it = models_.find(key);
if (it != models_.end()) {
return it->second;
}
auto model = std::make_shared<TreeModel>(texture, color);
models_[key] = model;
return model;
}
private:
std::unordered_map<std::string, std::shared_ptr<TreeModel>> models_;
};
// 客户端:树实例
class Tree {
public:
Tree(std::shared_ptr<TreeModel> model, int x, int y, float scale)
: model_(model), x_(x), y_(y), scale_(scale) {}
void render() const {
model_->render(x_, y_, scale_);
}
private:
std::shared_ptr<TreeModel> model_; // 共享的享元对象
int x_, y_; // 外部状态:不共享
float scale_; // 外部状态:不共享
};
你看,10000棵树,只有几种不同的纹理和颜色组合。享元工厂保证每种组合只创建一次。内存占用从10000份纹理数据,降到了几种。
享元模式的结构图
享元模式的优缺点
| 优点 | 缺点 |
|---|---|
| 大幅减少内存占用,尤其当对象数量巨大时 | 增加了系统复杂度,需要区分内部/外部状态 |
| 提高性能,减少对象创建和销毁的开销 | 外部状态的管理增加了客户端负担 |
| 适合大量相似对象的场景 | 如果内部状态变化频繁,享元模式反而得不偿失 |
避坑指南
我曾经踩过一个坑:在实现享元工厂时,用了裸指针管理享元对象。结果某个模块提前释放了对象,其他模块还在用,直接崩溃。
后来我改用 std::shared_ptr,配合 std::weak_ptr 做缓存,才彻底解决。记住:享元对象的生命周期管理一定要谨慎。
什么时候用享元模式?
- 系统中有大量相似对象,内存占用成为瓶颈
- 这些对象可以拆分成内部状态和外部状态
- 内部状态相对稳定,不会频繁变化
- 你愿意为了节省内存,增加一点代码复杂度
嗯,其实享元模式没那么神秘。说白了就是「能省就省」。C++的字符串常量池、线程池、连接池,本质上都是享元思想的应用。
我个人习惯在写游戏、图形渲染、文本编辑器这类需要大量对象的系统时,优先考虑享元模式。它不一定能让你写出更快的代码,但一定能让你写出更省内存的代码。
最后一个小建议:不要为了用模式而用模式。如果你的对象只有几十个,享元模式带来的复杂度可能比收益还大。先测量,再优化。