组合模式:树形结构,透明式与安全式

组合模式,说白了就是处理「部分-整体」关系的一种设计思路。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹,子文件夹里又能继续嵌套——这种树形结构,用组合模式来建模再合适不过了。

我个人习惯把组合模式理解为「让树枝和树叶用同样的接口」。这样客户端就不用关心自己操作的是单个对象还是组合对象,统一对待就行。

核心思想

组合模式的核心就三个角色:

  • Component(抽象构件):定义叶子节点和容器节点的公共接口
  • Leaf(叶子节点):没有子节点的对象,比如文件
  • Composite(容器节点):有子节点的对象,比如文件夹

嗯,这里要注意:叶子节点和容器节点都继承自同一个抽象接口,但容器节点额外维护一个子节点列表。

两种实现风格

我在项目中遇到过两种实现方式,分别叫透明式和安全式。它们各有优劣,我分别讲讲。

透明式组合模式

透明式把所有方法都定义在抽象基类里,包括添加、删除子节点的方法。叶子节点虽然不支持这些操作,但也要提供空实现或者抛出异常。

class FileSystemNode {
public:
    virtual ~FileSystemNode() = default;
    virtual void display(int depth = 0) = 0;
    virtual void add(FileSystemNode* node) {
        throw std::runtime_error("不支持添加子节点");
    }
    virtual void remove(FileSystemNode* node) {
        throw std::runtime_error("不支持删除子节点");
    }
};

class File : public FileSystemNode {
    std::string name;
public:
    File(const std::string& n) : name(n) {}
    void display(int depth = 0) override {
        std::cout << std::string(depth, '-') << name << std::endl;
    }
};

class Folder : public FileSystemNode {
    std::string name;
    std::vector<FileSystemNode*> children;
public:
    Folder(const std::string& n) : name(n) {}
    
    void add(FileSystemNode* node) override {
        children.push_back(node);
    }
    
    void remove(FileSystemNode* node) override {
        auto it = std::find(children.begin(), children.end(), node);
        if (it != children.end()) children.erase(it);
    }
    
    void display(int depth = 0) override {
        std::cout << std::string(depth, '-') << name << "/" << std::endl;
        for (auto child : children) {
            child->display(depth + 2);
        }
    }
};

透明式的优点是客户端代码统一,不用区分文件和文件夹。但缺点也很明显——叶子节点有add/remove方法,调用就会抛异常,这其实违反了接口隔离原则。

我曾经踩过的坑: 用透明式时,团队里新来的同事不知道叶子节点不能add,直接调了File对象的add方法,运行时抛异常才排查半天。后来我改成安全式,编译期就能发现问题。

安全式组合模式

安全式把add/remove方法只放在容器节点里,抽象基类只定义公共操作。这样叶子节点压根没有add/remove方法,编译期就杜绝了误调用。

class FileSystemNode {
public:
    virtual ~FileSystemNode() = default;
    virtual void display(int depth = 0) = 0;
};

class File : public FileSystemNode {
    std::string name;
public:
    File(const std::string& n) : name(n) {}
    void display(int depth = 0) override {
        std::cout << std::string(depth, '-') << name << std::endl;
    }
};

class Folder : public FileSystemNode {
    std::string name;
    std::vector<FileSystemNode*> children;
public:
    Folder(const std::string& n) : name(n) {}
    
    void add(FileSystemNode* node) {
        children.push_back(node);
    }
    
    void remove(FileSystemNode* node) {
        auto it = std::find(children.begin(), children.end(), node);
        if (it != children.end()) children.erase(it);
    }
    
    void display(int depth = 0) override {
        std::cout << std::string(depth, '-') << name << "/" << std::endl;
        for (auto child : children) {
            child->display(depth + 2);
        }
    }
};

安全式的缺点是客户端代码需要做类型判断。比如遍历时,你得用dynamic_cast或者typeid来判断当前节点是文件还是文件夹,才能调用add/remove。

两种风格的对比

对比维度 透明式 安全式
接口统一性 完全统一,客户端无需区分 需要区分叶子与容器
编译期安全性 低,误调用在运行时暴露 高,误调用在编译期暴露
代码复杂度 基类方法多,叶子有冗余实现 基类简洁,客户端逻辑稍复杂
适用场景 客户端极少区分叶子与容器 客户端经常需要区分类型

文件系统模拟的完整示例

我写一个安全式的完整例子,模拟一个简单的文件系统。这样你看起来更直观。

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

class FileSystemNode {
public:
    virtual ~FileSystemNode() = default;
    virtual void display(int depth = 0) = 0;
    virtual size_t getSize() = 0;
};

class File : public FileSystemNode {
    std::string name;
    size_t size;
public:
    File(const std::string& n, size_t s) : name(n), size(s) {}
    
    void display(int depth = 0) override {
        std::cout << std::string(depth, ' ') << "📄 " << name 
                  << " (" << size << " bytes)" << std::endl;
    }
    
    size_t getSize() override { return size; }
};

class Folder : public FileSystemNode {
    std::string name;
    std::vector<FileSystemNode*> children;
public:
    Folder(const std::string& n) : name(n) {}
    
    ~Folder() {
        for (auto child : children) delete child;
    }
    
    void add(FileSystemNode* node) {
        children.push_back(node);
    }
    
    void remove(FileSystemNode* node) {
        auto it = std::find(children.begin(), children.end(), node);
        if (it != children.end()) {
            children.erase(it);
        }
    }
    
    void display(int depth = 0) override {
        std::cout << std::string(depth, ' ') << "📁 " << name << "/" << std::endl;
        for (auto child : children) {
            child->display(depth + 2);
        }
    }
    
    size_t getSize() override {
        size_t total = 0;
        for (auto child : children) {
            total += child->getSize();
        }
        return total;
    }
};

// 使用示例
int main() {
    Folder* root = new Folder("root");
    Folder* docs = new Folder("docs");
    Folder* src = new Folder("src");
    
    docs->add(new File("readme.txt", 1024));
    docs->add(new File("notes.md", 2048));
    
    src->add(new File("main.cpp", 4096));
    src->add(new File("utils.h", 1024));
    
    root->add(docs);
    root->add(src);
    root->add(new File("Makefile", 512));
    
    root->display();
    std::cout << "总大小: " << root->getSize() << " bytes" << std::endl;
    
    delete root;
    return 0;
}

组合模式的知识体系

下面这张图帮你理清组合模式的核心脉络:

组合模式知识体系 Component display() / getSize() Leaf(文件) display() / getSize() Composite(文件夹) add() / remove() / display() 包含子节点 透明式 • 所有方法定义在基类 • 叶子节点有空实现或抛异常 • 客户端无需区分类型 • 运行时可能出错 安全式 • 容器方法只在容器类中 • 叶子节点没有add/remove • 编译期就能发现错误 • 客户端需要类型判断

实际项目中的选择建议

我个人更倾向于安全式。原因很简单——编译期能发现的问题,就不要拖到运行时。尤其是团队协作的项目,你没法保证每个人都清楚哪些节点能add。

不过如果你做的是一个内部工具,团队就你一个人,透明式写起来确实更省事。少写几个dynamic_cast,代码看起来也清爽。

我的一个小技巧: 用安全式时,可以在Folder的add方法里加一个断言,检查传入的节点不是自己或者自己的祖先,防止循环引用。我曾经在递归遍历时遇到死循环,排查了半天才发现是add了父文件夹。

组合模式的适用场景

  • 文件系统、目录树
  • GUI组件树(窗口包含面板,面板包含按钮)
  • XML/JSON文档的节点结构
  • 组织架构树(部门包含子部门,子部门包含员工)
  • 菜单系统(菜单包含菜单项,菜单项可以包含子菜单)

组合模式的核心价值在于:让客户端用一致的方式处理单个对象和组合对象。你不需要写if-else来判断当前操作的是文件还是文件夹,直接调用display()就行。这种统一性,说白了就是多态的魅力。

嗯,最后提醒一句:组合模式虽然好用,但别滥用。如果你的树只有两层,或者叶子节点和容器节点的行为差异很大,硬套组合模式反而会让代码变得别扭。设计模式是工具,不是教条。

专注资料整理