组合模式:透明组合与安全组合、叶子节点与容器节点,以及文件系统树形结构实现

组合模式,说白了就是处理「部分-整体」层次结构的一种设计模式。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹,子文件夹又能继续嵌套。这种树形结构在软件开发中太常见了。

我个人习惯把组合模式理解为「让客户端用统一的方式对待单个对象和组合对象」。嗯,这里要注意,这个模式的核心不在于「组合」本身,而在于「一致性」。

为什么需要组合模式?

我在项目中遇到过这样一个场景:一个图形编辑器,里面有圆形、矩形这样的基本图形,也有由多个图形组合成的复杂图形。用户希望拖拽、缩放、旋转操作对单个图形和组合图形都一样。如果不用组合模式,代码里到处都是 if (obj instanceof Group) { ... } else { ... } 这样的判断,维护起来非常痛苦。

组合模式解决了这个问题。它让叶子节点(单个对象)和容器节点(组合对象)实现同一个接口,客户端调用时根本不需要关心操作的是叶子还是容器。

两种实现风格:透明组合 vs 安全组合

这里有个经典的分歧点——透明组合和安全组合。我刚开始学的时候也纠结过,到底用哪个?

对比维度 透明组合 安全组合
接口定义 叶子节点和容器节点共用完整接口 叶子节点只暴露叶子操作,容器节点暴露容器操作
叶子节点 有 add/remove 等方法,但抛出异常或空实现 没有 add/remove 等方法
客户端使用 完全透明,无需类型判断 需要 instanceof 判断才能调用容器方法
安全性 运行时可能调用非法方法 编译期就能避免非法调用
我的推荐 适合叶子节点行为差异小的场景 适合对安全性要求高的场景

核心原则:透明组合追求「接口统一」,安全组合追求「类型安全」。没有绝对的好坏,取决于你的业务场景。

文件系统树形结构实现

我们拿文件系统来实战。这个例子太经典了,我每次讲组合模式都会用它。先看整体结构:

组合模式 - 文件系统结构 FileSystemNode +getName() +getSize() +display() File(叶子节点) +getName() +getSize() +display() 没有 add/remove 方法 Directory(容器节点) +getName() +getSize() +display() +add() +remove() +getChild() 容器内部 File: readme.txt Directory: src File: main.java 安全组合:叶子节点不暴露容器方法,编译期保证类型安全

我选择安全组合来实现文件系统。为什么?因为文件系统中,文件(叶子)和文件夹(容器)的行为差异很大。文件不能添加子节点,文件夹可以。如果强行让文件实现 add() 方法然后抛异常,反而容易误导调用者。

我的经验:曾经在一个项目中用了透明组合,结果新同事调用了文件的 add 方法,虽然抛了异常但日志里全是错误堆栈。后来改成安全组合,编译期就发现问题,省了不少排查时间。

Java 实现代码

// 抽象组件
public abstract class FileSystemNode {
    protected String name;
    
    public FileSystemNode(String name) {
        this.name = name;
    }
    
    public abstract long getSize();
    public abstract void display(String indent);
    
    public String getName() {
        return name;
    }
}

// 叶子节点 - 文件
public class File extends FileSystemNode {
    private long size;
    
    public File(String name, long size) {
        super(name);
        this.size = size;
    }
    
    @Override
    public long getSize() {
        return size;
    }
    
    @Override
    public void display(String indent) {
        System.out.println(indent + "📄 " + name + " (" + size + " bytes)");
    }
}

// 容器节点 - 目录
public class Directory extends FileSystemNode {
    private List<FileSystemNode> children = new ArrayList<>();
    
    public Directory(String name) {
        super(name);
    }
    
    public void add(FileSystemNode node) {
        children.add(node);
    }
    
    public void remove(FileSystemNode node) {
        children.remove(node);
    }
    
    public FileSystemNode getChild(int index) {
        return children.get(index);
    }
    
    @Override
    public long getSize() {
        long totalSize = 0;
        for (FileSystemNode child : children) {
            totalSize += child.getSize();
        }
        return totalSize;
    }
    
    @Override
    public void display(String indent) {
        System.out.println(indent + "📁 " + name + "/");
        for (FileSystemNode child : children) {
            child.display(indent + "  ");
        }
    }
}

// 客户端使用
public class Client {
    public static void main(String[] args) {
        Directory root = new Directory("root");
        
        File readme = new File("readme.txt", 1024);
        root.add(readme);
        
        Directory src = new Directory("src");
        File main = new File("main.java", 2048);
        File utils = new File("utils.java", 1536);
        src.add(main);
        src.add(utils);
        
        root.add(src);
        
        root.display("");
        System.out.println("总大小: " + root.getSize() + " bytes");
    }
}

C++ 实现代码

#include <iostream>
#include <vector>
#include <memory>
#include <string>

// 抽象组件
class FileSystemNode {
protected:
    std::string name;
public:
    FileSystemNode(const std::string& name) : name(name) {}
    virtual ~FileSystemNode() = default;
    virtual long long getSize() const = 0;
    virtual void display(const std::string& indent) const = 0;
    std::string getName() const { return name; }
};

// 叶子节点 - 文件
class File : public FileSystemNode {
private:
    long long size;
public:
    File(const std::string& name, long long size) 
        : FileSystemNode(name), size(size) {}
    
    long long getSize() const override {
        return size;
    }
    
    void display(const std::string& indent) const override {
        std::cout << indent << "📄 " << name 
                  << " (" << size << " bytes)" << std::endl;
    }
};

// 容器节点 - 目录
class Directory : public FileSystemNode {
private:
    std::vector<std::shared_ptr<FileSystemNode>> children;
public:
    Directory(const std::string& name) : FileSystemNode(name) {}
    
    void add(std::shared_ptr<FileSystemNode> node) {
        children.push_back(node);
    }
    
    void remove(std::shared_ptr<FileSystemNode> node) {
        auto it = std::find(children.begin(), children.end(), node);
        if (it != children.end()) {
            children.erase(it);
        }
    }
    
    long long getSize() const override {
        long long totalSize = 0;
        for (const auto& child : children) {
            totalSize += child->getSize();
        }
        return totalSize;
    }
    
    void display(const std::string& indent) const override {
        std::cout << indent << "📁 " << name << "/" << std::endl;
        for (const auto& child : children) {
            child->display(indent + "  ");
        }
    }
};

// 客户端使用
int main() {
    auto root = std::make_shared<Directory>("root");
    
    auto readme = std::make_shared<File>("readme.txt", 1024);
    root->add(readme);
    
    auto src = std::make_shared<Directory>("src");
    auto main = std::make_shared<File>("main.cpp", 2048);
    auto utils = std::make_shared<File>("utils.cpp", 1536);
    src->add(main);
    src->add(utils);
    
    root->add(src);
    
    root->display("");
    std::cout << "总大小: " << root->getSize() << " bytes" << std::endl;
    
    return 0;
}

避坑指南

我曾经在一个电商项目中用组合模式处理商品分类。分类下面有商品(叶子),也有子分类(容器)。一开始我用了透明组合,结果商品对象暴露了 addCategory() 方法,测试同学不小心调用了,虽然没报错但数据乱了。后来改成安全组合,问题迎刃而解。

注意:组合模式不是万能的。如果你的树形结构层级很深(比如超过10层),递归遍历可能导致栈溢出。这时候可以考虑用迭代器模式或者显式栈来替代递归。

什么时候用组合模式?

  • 树形结构:文件系统、组织架构、菜单系统、UI组件树
  • 统一操作:希望客户端用相同方式处理单个对象和组合对象
  • 递归组合:组合对象内部可以包含其他组合对象,形成递归结构

嗯,组合模式其实不难,关键是要想清楚你的场景适合透明还是安全。我个人更倾向安全组合,虽然客户端代码多了一点点类型判断,但换来的是编译期的安全保障。你想想看,与其在运行时排查「为什么文件不能添加子节点」,不如在写代码时就避免这种可能性。

最后说一句,组合模式经常和迭代器模式、访问者模式搭配使用。迭代器帮你遍历树,访问者帮你对树中节点执行不同操作。这些我们后面会讲到。


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