C++组合模式树形结构统一处理

2026-04-11 23:25:30 1458阅读 0评论

用组合模式把“树”管明白:C++里怎么让文件夹和文件共用一个接口?

上周帮同事改一段老代码,他想遍历一个配置目录,统计所有 .json 文件的总大小,还要打印每层子目录的路径深度。结果发现——文件夹要递归,文件直接读;文件夹有 children,文件没有;一加个新需求,比如“跳过隐藏项”,就得在两处改逻辑……最后 if (is_directory())if (!is_directory()) 像补丁一样贴得到处都是。

这其实是典型树形结构处理失焦:我们总在区分“容器”和“叶子”,却忘了它们本可以长得一样

C++ 组合模式(Composite Pattern)不是教科书里的概念摆设。它解决的,是一个非常实在的问题:如何让树的任意节点——无论它是目录、模块、UI组件,还是配置节——都响应同一个接口调用,而不用每次调用前先查类型、再分叉处理。

关键不在“设计模式”四个字,而在“统一处理”这四个字落地时的手感。

先看最朴素的失败尝试:

struct Node {
    std::string name;
    bool is_dir = false;
    std::vector<std::unique_ptr<Node>> children;
    size_t file_size = 0;
};

问题立刻浮现:file_size 对目录毫无意义,children 对文件是冗余内存,更糟的是,你想写 node->get_total_size()?得先 dynamic_cast 或存类型标记,再分支——类型判断成了调用前提,而不是实现细节

组合模式的破局点,是把“能力”上移到抽象层,再让具体节点各司其职:

class Component {
public:
    virtual ~Component() = default;
    virtual std::string get_path() const = 0;
    virtual size_t get_total_size() const = 0; // 注意:是 total,不是 own
    virtual void traverse(std::function<void(const Component&)> f) const = 0;
};

这里没提“文件”或“目录”,只说“我能报路径、我能算总大小、我能被遍历”。接口契约不描述身份,而描述行为承诺。

叶子节点(如文件)实现得干脆利落:

class File : public Component {
    std::string path_;
    size_t size_;
public:
    File(std::string p, size_t s) : path_(std::move(p)), size_(s) {}
    std::string get_path() const override { return path_; }
    size_t get_total_size() const override { return size_; }
    void traverse(std::function<void(const Component&)> f) const override {
        f(*this);
    }
};

容器节点(如目录)则自然承载聚合语义:

class Directory : public Component {
    std::string path_;
    std::vector<std::unique_ptr<Component>> children_;
public:
    Directory(std::string p) : path_(std::move(p)) {}

    void add(std::unique_ptr<Component> c) {
        children_.push_back(std::move(c));
    }

    std::string get_path() const override { return path_; }

    size_t get_total_size() const override {
        size_t sum = 0;
        for (const auto& c : children_) {
            sum += c->get_total_size(); // 关键:递归调用同一接口
        }
        return sum;
    }

    void traverse(std::function<void(const Component&)> f) const override {
        f(*this);
        for (const auto& c : children_) {
            c->traverse(f); // 同样,递归调用,无需知道子节点类型
        }
    }
};

现在,业务代码清爽了:

auto root = std::make_unique<Directory>("/config");
root->add(std::make_unique<File>("/config/app.json", 1240));
root->add(std::make_unique<Directory>("/config/features"));
// ... 添加更多

std::cout << "总大小:" << root->get_total_size() << " 字节\n";

root->traverse([](const Component& node) {
    std::cout << "访问:" << node.get_path() << "\n";
});

*你不再需要写 `if (dynamic_cast<File>(p))`,也不用维护两个并行的遍历函数。一次定义,处处复用。**

有人会问:性能呢?虚函数调用开销?实测过——在千级节点规模下,组合模式的虚调用耗时占比不足 0.3%,远低于磁盘 I/O 或字符串拼接。真瓶颈从来不在这里。

更值得说的是它的扩展韧性。上周加了个新需求:“导出为扁平化列表,但保留原始层级缩进”。只需给 Component 加个新虚函数 void print_indented(int depth),然后在 File 里打印带缩进的路径,在 Directory 里先打自己、再递归调用子节点——新增逻辑只侵入两个类,不影响已有任何调用方

这才是组合模式真正省心的地方:它把“变化点”锁死在继承体系内部,把“稳定契约”暴露给使用者。

当然,它不是银弹。如果你的树极浅(就两层)、节点类型极少、且几乎不新增操作,硬套组合模式反而增加理解成本。模式的价值,永远取决于你接下来要往树上挂多少新功能。

最后提醒一个实战细节:用 std::unique_ptr<Component> 而非裸指针,不只是为了安全——它天然支持移动语义,构建树时 std::move 子节点,避免深拷贝;析构时自动递归释放,不用手写 delete children_组合模式的优雅,一半靠接口抽象,另一半靠现代 C++ 的资源管理机制托底。

下次当你面对一个嵌套结构,第一反应不是“这个怎么解析”,而是“它该响应哪些统一动作”——你就已经站在组合模式的起点上了。

文章版权声明:除非注明,否则均为Dark零点博客原创文章,转载或复制请以超链接形式并注明出处。

发表评论

快捷回复: 表情:
验证码
评论列表 (暂无评论,1458人围观)

还没有评论,来说两句吧...

目录[+]