14. 享元模式:模式动机与定义、角色与结构、C++代码实现、池化技术应用
14.1 模式动机与定义
先聊聊一个我踩过的坑。几年前我接手一个游戏引擎的优化任务,场景里要渲染上千棵树木。每棵树都有自己的位置、颜色、大小,但它们的网格模型和纹理是完全一样的。最初的设计是每棵树都独立持有完整的模型数据——结果内存直接爆了。
你想想看,一千棵树,每棵树都存一份几兆的网格数据,这合理吗?不合理。真正需要区分的,只是每棵树的位置和朝向这些“外部状态”。而网格和纹理这些“内部状态”,完全可以共享。
这就是享元模式要解决的问题。它的核心思想很简单:通过共享细粒度对象来减少内存消耗。说白了,就是把对象拆成两部分——共享的部分(内部状态)和变化的部分(外部状态)。共享的部分大家共用,变化的部分由调用方传入。
14.2 角色与结构
享元模式涉及四个角色,我习惯把它们分成两组来理解:
- 享元接口(Flyweight):声明一个接口,用于接收外部状态并执行操作。
- 具体享元(ConcreteFlyweight):实现享元接口,存储内部状态。这部分是可以共享的。
- 非共享具体享元(UnsharedConcreteFlyweight):有些对象不适合共享,但为了统一接口,也继承自享元接口。
- 享元工厂(FlyweightFactory):创建并管理享元对象。它维护一个池子,当请求一个享元时,先检查池子里有没有,有就直接返回,没有就创建。
结构上其实不复杂,我画了一张图帮你理清关系:
14.3 C++代码实现
直接上代码。还是用那个树木的例子,这样你更容易理解。
// 享元接口
class TreeModel {
public:
virtual ~TreeModel() = default;
virtual void render(float x, float y, float scale) const = 0;
};
// 具体享元——共享的树木模型
class SharedTreeModel : public TreeModel {
public:
SharedTreeModel(const std::string& meshData, const std::string& textureData)
: mesh_(meshData), texture_(textureData) {
// 内部状态:网格和纹理,所有树共享
}
void render(float x, float y, float scale) const override {
// 外部状态:位置和缩放,由调用方传入
std::cout << "渲染树木: 网格=" << mesh_
<< ", 纹理=" << texture_
<< ", 位置=(" << x << ", " << y << ")"
<< ", 缩放=" << scale << std::endl;
}
private:
std::string mesh_; // 内部状态
std::string texture_; // 内部状态
};
// 享元工厂
class TreeModelFactory {
public:
~TreeModelFactory() {
for (auto& pair : pool_) {
delete pair.second;
}
}
const TreeModel* getTreeModel(const std::string& type) {
auto it = pool_.find(type);
if (it != pool_.end()) {
std::cout << "复用已有模型: " << type << std::endl;
return it->second;
}
// 创建新模型
std::string mesh = type + "_mesh";
std::string texture = type + "_texture";
SharedTreeModel* model = new SharedTreeModel(mesh, texture);
pool_[type] = model;
std::cout << "创建新模型: " << type << std::endl;
return model;
}
private:
std::unordered_map<std::string, SharedTreeModel*> pool_;
};
// 客户端代码
class Tree {
public:
Tree(const TreeModel* model, float x, float y, float scale)
: model_(model), x_(x), y_(y), scale_(scale) {}
void render() const {
model_->render(x_, y_, scale_);
}
private:
const TreeModel* model_; // 共享的享元对象
float x_, y_, scale_; // 外部状态,由客户端持有
};
// 使用示例
int main() {
TreeModelFactory factory;
// 创建1000棵橡树
std::vector<Tree> forest;
for (int i = 0; i < 1000; ++i) {
const TreeModel* model = factory.getTreeModel("oak");
forest.emplace_back(model, i * 1.0f, 0.0f, 1.0f);
}
// 创建1000棵松树
for (int i = 0; i < 1000; ++i) {
const TreeModel* model = factory.getTreeModel("pine");
forest.emplace_back(model, 0.0f, i * 1.0f, 1.0f);
}
// 渲染所有树
for (const auto& tree : forest) {
tree.render();
}
return 0;
}
14.4 池化技术应用
说到享元模式,就不得不提池化技术。其实享元工厂本身就是一个对象池。我在实际项目中,池化技术用得最多的是这三个场景:
| 池化类型 | 共享对象 | 外部状态 | 典型应用 |
|---|---|---|---|
| 对象池 | 数据库连接、线程 | 连接参数、任务数据 | 连接池、线程池 |
| 缓存池 | 计算结果、资源数据 | 查询参数、上下文 | Redis缓存、内存缓存 |
| 实例池 | 游戏对象、UI控件 | 位置、状态、属性 | 子弹池、粒子系统 |
我曾经在一个网络库中实现过连接池,本质上就是享元模式。每个连接对象是共享的,但每次请求的地址和端口是外部状态。嗯,这里要注意:池化技术虽然省内存,但带来了线程安全问题。共享对象如果被多个线程同时修改,那就麻烦了。
再补充一个实际案例。我在做UI框架时,每个按钮、文本框都是一个控件对象。如果每个窗口都创建独立的控件实例,内存开销很大。后来我把控件的样式(字体、颜色、边框)作为内部状态共享,只把文本内容和位置作为外部状态。这样,一个对话框里几百个控件,实际上只用了十几种样式对象。
总结一下享元模式的使用要点:
- 适用场景:系统中存在大量相似对象,且这些对象可以拆分为内部状态和外部状态
- 内部状态:存储在享元对象中,不随环境改变,可以共享
- 外部状态:由客户端维护,每次调用时传入,不可共享
- 工厂管理:通过工厂创建和管理享元对象,避免重复创建
说白了,享元模式就是“抠门”的艺术——能省就省,能共享绝不重复创建。但也要注意,不是所有场景都适合用。如果对象数量不多,或者内部状态和外部状态耦合太紧,强行用享元反而会让代码变得复杂。这个度,需要你在实际项目中慢慢体会。