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

组合模式,说白了就是处理「树形结构」的一把利器。我刚开始接触这个模式时,觉得它不就是个递归嘛,有什么好讲的?直到我在一个权限管理系统中,被菜单树的遍历搞得焦头烂额,才真正体会到组合模式的妙处。

你想想看,现实世界中到处都是树形结构:公司的组织架构、文件系统的目录、GUI 里的容器和控件……这些结构都有一个共同特点——部分和整体需要被一致对待

什么是组合模式?

组合模式允许你将对象组合成树形结构,来表示「部分-整体」的层次结构。它让客户端可以统一地处理单个对象和组合对象。

嗯,这里要注意一个核心思想:叶子节点和容器节点对外暴露相同的方法。这样调用方就不用关心你操作的是单个文件还是一个文件夹,直接调用同一个方法就行。

核心角色:
  • Component(抽象构件):定义叶子节点和容器节点的公共接口
  • Leaf(叶子节点):没有子节点的对象
  • Composite(容器节点):有子节点的对象,实现子节点管理

一个真实的例子:文件系统

我在做一个文档管理系统时,需要处理文件夹和文件的统一操作。文件夹里可以包含文件,也可以包含子文件夹。这不就是典型的组合模式吗?

先看抽象构件:

public abstract class FileSystemNode {
    protected String name;
    
    public FileSystemNode(String name) {
        this.name = name;
    }
    
    public abstract void display(int depth);
    public abstract long getSize();
    public abstract void add(FileSystemNode node);
    public abstract void remove(FileSystemNode node);
    public abstract FileSystemNode getChild(int index);
}

叶子节点——文件:

public class File extends FileSystemNode {
    private long size;
    
    public File(String name, long size) {
        super(name);
        this.size = size;
    }
    
    @Override
    public void display(int depth) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < depth; i++) {
            sb.append("-");
        }
        System.out.println(sb.toString() + name + " (" + size + " bytes)");
    }
    
    @Override
    public long getSize() {
        return size;
    }
    
    // 叶子节点不支持添加/删除子节点
    @Override
    public void add(FileSystemNode node) {
        throw new UnsupportedOperationException("文件不支持添加子节点");
    }
    
    @Override
    public void remove(FileSystemNode node) {
        throw new UnsupportedOperationException("文件不支持删除子节点");
    }
    
    @Override
    public FileSystemNode getChild(int index) {
        throw new UnsupportedOperationException("文件没有子节点");
    }
}

容器节点——文件夹:

public class Directory extends FileSystemNode {
    private List<FileSystemNode> children = new ArrayList<>();
    
    public Directory(String name) {
        super(name);
    }
    
    @Override
    public void display(int depth) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < depth; i++) {
            sb.append("-");
        }
        System.out.println(sb.toString() + name + "/");
        for (FileSystemNode child : children) {
            child.display(depth + 2);
        }
    }
    
    @Override
    public long getSize() {
        long totalSize = 0;
        for (FileSystemNode child : children) {
            totalSize += child.getSize();
        }
        return totalSize;
    }
    
    @Override
    public void add(FileSystemNode node) {
        children.add(node);
    }
    
    @Override
    public void remove(FileSystemNode node) {
        children.remove(node);
    }
    
    @Override
    public FileSystemNode getChild(int index) {
        return children.get(index);
    }
}

客户端调用:

public class Client {
    public static void main(String[] args) {
        Directory root = new Directory("根目录");
        Directory docs = new Directory("文档");
        Directory pics = new Directory("图片");
        
        File readme = new File("readme.txt", 1024);
        File photo = new File("photo.jpg", 2048000);
        File logo = new File("logo.png", 512000);
        
        docs.add(readme);
        pics.add(photo);
        pics.add(logo);
        root.add(docs);
        root.add(pics);
        
        root.display(0);
        System.out.println("总大小: " + root.getSize() + " bytes");
    }
}

透明式 vs 安全式组合模式

这里有个坑,我当年踩过。组合模式有两种实现方式,各有优劣。

透明式组合模式

刚才的代码就是透明式。所有节点(包括叶子节点)都暴露同样的方法。好处是客户端完全不用区分节点类型,统一调用。坏处是叶子节点不得不实现一些无意义的方法,比如 add()remove(),只能抛异常。

我的建议:如果树形结构比较简单,叶子节点数量不多,透明式用起来很爽。客户端代码会非常干净。

安全式组合模式

安全式把管理子节点的方法放到容器节点中,抽象构件只定义公共方法。

public abstract class FileSystemNode {
    protected String name;
    
    public FileSystemNode(String name) {
        this.name = name;
    }
    
    public abstract void display(int depth);
    public abstract long getSize();
}

public class File extends FileSystemNode {
    // 只有 display 和 getSize,没有 add/remove
}

public class Directory extends FileSystemNode {
    private List<FileSystemNode> children = new ArrayList<>();
    
    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);
    }
}

安全式的好处是接口职责清晰,叶子节点不会有不相关的方法。但客户端需要做类型判断:

if (node instanceof Directory) {
    ((Directory) node).add(new File("test.txt", 100));
}

说实话,我个人更倾向于透明式。虽然叶子节点抛异常有点丑,但客户端代码的简洁性更重要。你想想看,如果树形结构嵌套很深,到处都要做 instanceof 判断,那代码得多难看。

我曾经踩过的坑:在一个电商系统的类目管理中,我用了安全式组合模式。结果业务逻辑里到处都是 if-else 判断节点类型,维护起来简直噩梦。后来重构时改成了透明式,配合统一的异常处理,代码清爽多了。

组合模式的核心逻辑

下面这张图展示了组合模式的核心结构:

组合模式核心结构 Component +operation() Leaf(叶子节点) +operation() 没有子节点 Composite(容器节点) +operation() +add() +remove() +getChild() 包含 Client 统一调用 operation() 客户端无需区分叶子节点和容器节点,统一调用 operation()

什么时候用组合模式?

我总结了几条经验:

  • 需要表示树形结构:比如菜单、目录、组织架构
  • 希望客户端统一处理:不管单个对象还是组合对象,调用方式一样
  • 递归操作频繁:比如遍历、统计、搜索

组合模式的优缺点

优点 缺点
简化客户端代码,统一处理单个和组合对象 透明式设计违反接口隔离原则(叶子节点有不必要的方法)
新增节点类型方便,符合开闭原则 树形结构过深时,递归性能可能成为瓶颈
天然支持递归操作,代码简洁 类型安全方面需要额外注意(尤其是安全式)
我的经验:组合模式最适合用在「结构相对稳定」的树形场景。如果树的结构经常变化,或者节点类型频繁增减,那就要慎重了。我曾经在一个动态表单系统中强行使用组合模式,结果被频繁的需求变更搞得欲哭无泪。

说白了,组合模式就是让你用一棵树来组织对象,然后像操作单个对象一样操作整棵树。它不是什么高深的技术,但用好了能让代码结构清晰很多。下次遇到树形结构,不妨试试这个模式。