第11章:组合模式——让部分与整体统一对待

11.1 模式动机:为什么需要组合模式?

先问大家一个问题:你在开发文件系统时,怎么处理文件和文件夹?

文件可以双击打开,文件夹可以双击展开。它们的行为有相似之处,但又有区别。最麻烦的是,当你需要计算整个目录的大小时,你得递归遍历所有子文件夹和文件。

我早期做项目时,就遇到过这种场景。当时要做一个资源管理器,代码写得那叫一个痛苦——到处都是if (isFile) {...} else if (isDirectory) {...}。每次新增一种资源类型,就得改一堆判断逻辑。

组合模式就是来解决这个问题的。它的核心思想很简单:让客户端可以一致地对待单个对象和组合对象。说白了,就是让文件和文件夹用同一个接口操作。

组合模式的定义:将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

11.2 透明式 vs 安全式:两种实现风格

组合模式有两种经典实现方式。嗯,这里要注意,它们各有优劣,没有绝对的好坏。

11.2.1 透明式组合模式

透明式,就是把所有操作都定义在抽象基类中。不管是叶子节点还是容器节点,接口都一样。

这样做的好处是:客户端完全不用关心对象类型。但坏处也很明显——叶子节点本来不需要Add()Remove()这些方法,却不得不实现它们。

我曾经踩过的坑:用透明式时,叶子节点的Add()方法只能抛异常或空实现。结果有次新同事没看文档,直接调了叶子节点的Add(),运行时才发现异常。这种问题很难排查。

11.2.2 安全式组合模式

安全式,就是把容器特有的操作(Add、Remove等)只放在容器类中。叶子节点压根没有这些方法。

这样更安全,但客户端需要做类型判断——你得知道当前对象是叶子还是容器。

对比维度 透明式 安全式
接口统一性 完全统一 部分统一
类型安全性 低(运行时可能出错) 高(编译期检查)
客户端复杂度 低(无需类型判断) 高(需要类型判断)
适用场景 叶子操作与容器操作高度一致 叶子与容器行为差异较大

我个人习惯用安全式。虽然客户端代码稍微复杂点,但至少不会在运行时出幺蛾子。你想想看,线上系统出个空指针异常,那可比多写几行if判断要命多了。

11.3 C++代码实现:安全式组合模式

下面给出一个完整的C++实现。我选安全式,因为实际项目中更实用。

// 抽象组件
class FileSystemNode {
public:
    virtual ~FileSystemNode() = default;
    virtual void Display(int depth) = 0;
    virtual long long GetSize() = 0;
};

// 叶子节点:文件
class File : public FileSystemNode {
private:
    std::string name;
    long long size;
public:
    File(const std::string& n, long long s) : name(n), size(s) {}
    
    void Display(int depth) override {
        std::cout << std::string(depth * 2, ' ') << "- " << name 
                  << " (" << size << " bytes)" << std::endl;
    }
    
    long long GetSize() override {
        return size;
    }
};

// 容器节点:文件夹
class Directory : public FileSystemNode {
private:
    std::string name;
    std::vector<std::unique_ptr<FileSystemNode>> children;
public:
    Directory(const std::string& n) : name(n) {}
    
    void Add(std::unique_ptr<FileSystemNode> node) {
        children.push_back(std::move(node));
    }
    
    void Remove(int index) {
        if (index >= 0 && index < children.size()) {
            children.erase(children.begin() + index);
        }
    }
    
    void Display(int depth) override {
        std::cout << std::string(depth * 2, ' ') << "+ " << name 
                  << " (directory)" << std::endl;
        for (auto& child : children) {
            child->Display(depth + 1);
        }
    }
    
    long long GetSize() override {
        long long total = 0;
        for (auto& child : children) {
            total += child->GetSize();
        }
        return total;
    }
};

使用提示:注意这里用了std::unique_ptr管理子节点生命周期。如果你需要共享所有权,可以换成std::shared_ptr。但一般情况下,组合模式中父节点拥有子节点,unique_ptr就够了。

11.4 文件系统示例:实战演练

来看一个完整的例子。假设我们要模拟这样一个目录结构:

root/
├── documents/
│   ├── report.docx (500KB)
│   └── notes.txt (50KB)
├── photos/
│   ├── vacation.jpg (2MB)
│   └── family.jpg (1.5MB)
└── readme.txt (1KB)

用组合模式来构建:

int main() {
    // 创建根目录
    auto root = std::make_unique<Directory>("root");
    
    // 创建documents目录
    auto docs = std::make_unique<Directory>("documents");
    docs->Add(std::make_unique<File>("report.docx", 500));
    docs->Add(std::make_unique<File>("notes.txt", 50));
    
    // 创建photos目录
    auto photos = std::make_unique<Directory>("photos");
    photos->Add(std::make_unique<File>("vacation.jpg", 2048));
    photos->Add(std::make_unique<File>("family.jpg", 1536));
    
    // 组装
    root->Add(std::move(docs));
    root->Add(std::move(photos));
    root->Add(std::make_unique<File>("readme.txt", 1));
    
    // 显示目录结构
    std::cout << "Directory structure:" << std::endl;
    root->Display(0);
    
    // 计算总大小
    std::cout << "\nTotal size: " << root->GetSize() << " KB" << std::endl;
    
    return 0;
}

输出结果:

Directory structure:
+ root (directory)
  + documents (directory)
    - report.docx (500 bytes)
    - notes.txt (50 bytes)
  + photos (directory)
    - vacation.jpg (2048 bytes)
    - family.jpg (1536 bytes)
  - readme.txt (1 bytes)

Total size: 4135 KB

看到没?不管是文件还是文件夹,调用Display()GetSize()的方式完全一样。这就是组合模式的威力——递归遍历让代码变得极其简洁。

11.5 核心知识体系

下面这张图总结了组合模式的核心逻辑:

组合模式核心结构 FileSystemNode File (叶子) - Display() - GetSize() Directory (容器) - Display() - GetSize() - Add() / Remove() 递归包含 客户端通过统一接口操作,无需区分叶子与容器 容器内部递归调用子节点的相同方法

11.6 避坑指南与最佳实践

最后,分享几个实战经验:

  • 不要滥用组合模式:只有树形结构才适合。如果你的对象关系是网状的,组合模式反而会带来麻烦。
  • 注意内存管理:C++中要明确所有权。我一般用unique_ptr,如果子节点需要被多个父节点共享,才考虑shared_ptr
  • 缓存计算结果:如果GetSize()频繁调用,可以考虑在Directory中缓存总大小,只在子节点变化时更新。
  • 迭代器支持:如果容器节点需要遍历,可以配合迭代器模式使用,让客户端更方便地访问子节点。

核心要点回顾:

  • 组合模式让部分与整体被一致对待
  • 透明式接口统一但牺牲安全性
  • 安全式更可靠但客户端需做类型判断
  • 递归遍历是组合模式的灵魂
  • 文件系统是组合模式的经典应用场景

好了,组合模式就讲到这里。记住一句话:当你发现代码里到处都是类型判断时,就该考虑组合模式了


公众号:蓝海资料掘金营,微信deep3321