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份纹理数据,降到了几种。

享元模式的结构图

享元模式结构图 客户端 管理外部状态 享元工厂 创建/管理享元对象 享元对象 存储内部状态 请求享元 返回/创建 使用享元 + 传入外部状态 内部状态 纹理、颜色 可共享、不变 外部状态 位置、大小 不共享、变化 工作流程 1. 客户端向享元工厂请求享元对象 2. 工厂检查是否已存在,存在则返回,否则创建 3. 客户端使用享元对象,并传入外部状态

享元模式的优缺点

优点 缺点
大幅减少内存占用,尤其当对象数量巨大时 增加了系统复杂度,需要区分内部/外部状态
提高性能,减少对象创建和销毁的开销 外部状态的管理增加了客户端负担
适合大量相似对象的场景 如果内部状态变化频繁,享元模式反而得不偿失

避坑指南

我曾经踩过一个坑:在实现享元工厂时,用了裸指针管理享元对象。结果某个模块提前释放了对象,其他模块还在用,直接崩溃。

后来我改用 std::shared_ptr,配合 std::weak_ptr 做缓存,才彻底解决。记住:享元对象的生命周期管理一定要谨慎。

什么时候用享元模式?

  • 系统中有大量相似对象,内存占用成为瓶颈
  • 这些对象可以拆分成内部状态和外部状态
  • 内部状态相对稳定,不会频繁变化
  • 你愿意为了节省内存,增加一点代码复杂度

嗯,其实享元模式没那么神秘。说白了就是「能省就省」。C++的字符串常量池、线程池、连接池,本质上都是享元思想的应用。

我个人习惯在写游戏、图形渲染、文本编辑器这类需要大量对象的系统时,优先考虑享元模式。它不一定能让你写出更快的代码,但一定能让你写出更省内存的代码。

最后一个小建议:不要为了用模式而用模式。如果你的对象只有几十个,享元模式带来的复杂度可能比收益还大。先测量,再优化。


专注资料整理