组合模式:把对象装进树里,让调用方一视同仁

组合模式,说白了就是解决「部分-整体」的树形结构问题。

我刚开始接触这个模式时,觉得它有点绕。后来在项目中写菜单系统,才真正体会到它的妙处。你想想看,一个菜单项可以是一个叶子节点(比如「保存文件」),也可以是一个容器节点(比如「文件」菜单,下面还有「新建」「打开」「保存」)。

如果不用组合模式,客户端代码就得写一堆 if-else 来判断当前节点是叶子还是容器。用组合模式,客户端根本不用关心这个——叶子节点和容器节点,调用方式完全一样。

核心思想:透明与安全

组合模式的核心就两句话:

  • 透明性:叶子节点和容器节点实现相同的接口
  • 递归性:容器节点内部可以包含叶子节点,也可以包含其他容器节点

嗯,这里要注意。透明性带来的是调用方的便利,但代价是叶子节点可能要实现一些「不该有」的方法(比如 add、remove)。我见过不少团队在这个问题上纠结——到底要不要让叶子节点抛出 UnsupportedOperationException?

我个人习惯是:如果项目规模不大,直接让叶子节点空实现 add/remove 方法,调用方用起来最舒服。如果项目对安全性要求高,那就用「安全组合模式」,把 add/remove 放到容器节点独有的接口里。

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

文件系统是组合模式的经典案例。每个文件是叶子节点,每个文件夹是容器节点。你打开一个文件夹,里面可能有文件,也可能有子文件夹。

我在项目中做过一个类似的配置管理工具。配置项可以是单个键值对(叶子),也可以是一个配置组(容器),组里还能嵌套组。用组合模式后,遍历所有配置、导出配置、搜索配置,代码量直接砍了一半。

// 组件接口
public interface ConfigNode {
    String getName();
    String getValue();  // 叶子节点返回实际值,容器节点返回 null 或聚合值
    void add(ConfigNode node);
    void remove(ConfigNode node);
    List<ConfigNode> getChildren();
}

// 叶子节点:单个配置项
public class ConfigItem implements ConfigNode {
    private String name;
    private String value;

    public ConfigItem(String name, String value) {
        this.name = name;
        this.value = value;
    }

    @Override
    public String getName() { return name; }

    @Override
    public String getValue() { return value; }

    @Override
    public void add(ConfigNode node) {
        throw new UnsupportedOperationException("叶子节点不支持添加");
    }

    @Override
    public void remove(ConfigNode node) {
        throw new UnsupportedOperationException("叶子节点不支持移除");
    }

    @Override
    public List<ConfigNode> getChildren() {
        return Collections.emptyList();
    }
}

// 容器节点:配置组
public class ConfigGroup implements ConfigNode {
    private String name;
    private List<ConfigNode> children = new ArrayList<>();

    public ConfigGroup(String name) {
        this.name = name;
    }

    @Override
    public String getName() { return name; }

    @Override
    public String getValue() {
        // 容器节点返回所有子节点的值拼接
        return children.stream()
                .map(ConfigNode::getValue)
                .filter(Objects::nonNull)
                .collect(Collectors.joining(", "));
    }

    @Override
    public void add(ConfigNode node) {
        children.add(node);
    }

    @Override
    public void remove(ConfigNode node) {
        children.remove(node);
    }

    @Override
    public List<ConfigNode> getChildren() {
        return children;
    }
}

客户端怎么用?

客户端代码根本不需要知道自己在操作叶子还是容器。遍历整个配置树,直接递归调用 getValue 就行。

public class Client {
    public static void main(String[] args) {
        ConfigGroup root = new ConfigGroup("root");

        ConfigGroup dbGroup = new ConfigGroup("database");
        dbGroup.add(new ConfigItem("url", "jdbc:mysql://localhost:3306/mydb"));
        dbGroup.add(new ConfigItem("username", "root"));
        dbGroup.add(new ConfigItem("password", "123456"));

        ConfigGroup cacheGroup = new ConfigGroup("cache");
        cacheGroup.add(new ConfigItem("type", "redis"));
        cacheGroup.add(new ConfigItem("host", "127.0.0.1"));
        cacheGroup.add(new ConfigItem("port", "6379"));

        root.add(dbGroup);
        root.add(cacheGroup);

        // 递归打印所有配置
        printConfig(root, 0);
    }

    static void printConfig(ConfigNode node, int depth) {
        String indent = "  ".repeat(depth);
        System.out.println(indent + node.getName() + " : " + node.getValue());
        for (ConfigNode child : node.getChildren()) {
            printConfig(child, depth + 1);
        }
    }
}

你看,printConfig 方法里,不管是叶子节点还是容器节点,调用方式完全一样。这就是组合模式最大的价值——让客户端代码变得极其简洁

避坑指南

我曾经踩过一个坑:在容器节点的 getValue 方法里,我直接递归调用了所有子节点的 getValue。结果有一个子节点是容器,它的 getValue 又递归调用它的子节点……最后形成了一个死循环。

原因很简单:配置组 A 里包含了配置组 B,配置组 B 里又引用了配置组 A。这在业务上是不允许的,但代码层面没有做环检测。

解决方案:在 add 方法里加一个环检测,或者用 visited 集合做遍历保护。

我的建议:组合模式最适合「树形结构 + 统一操作」的场景。如果你的树只有两层(根节点下面全是叶子),那用组合模式有点大材小用。但如果层级不确定,或者未来可能扩展层级,组合模式就是最佳选择。

组合模式的变体

变体 特点 适用场景
透明组合模式 叶子节点和容器节点实现完全相同的接口 客户端需要完全透明,不关心节点类型
安全组合模式 叶子节点不实现 add/remove,容器节点单独提供 对安全性要求高,避免调用方误操作
带缓存的组合模式 容器节点缓存子节点的聚合结果 频繁读取聚合值,且子节点变化不频繁

我个人更倾向于透明组合模式。虽然叶子节点要抛出 UnsupportedOperationException,但调用方写起来最舒服。你想想看,如果调用方每次都要用 instanceof 判断节点类型,那组合模式的意义就少了一半。

组合模式与设计原则

组合模式完美体现了开闭原则:你可以新增一种节点类型(比如「符号链接」),只要它实现了 ConfigNode 接口,就能无缝接入现有的树结构。客户端代码不需要做任何修改。

它也体现了接口隔离原则:ConfigNode 接口只暴露了「树节点」该有的操作,没有多余的方法。虽然叶子节点被迫实现了 add/remove,但那是为了透明性付出的代价。

SVG 结构图:组合模式的核心逻辑

组合模式结构图 ConfigNode +add() +remove() +getValue() ConfigItem 叶子节点:单个配置项 ConfigGroup 容器节点:配置组 递归包含子节点 子节点(叶子/容器) 子节点(叶子/容器)

什么时候用组合模式?

  • 树形结构:你的数据天然就是树状的,比如组织架构、文件系统、菜单系统
  • 统一操作:客户端希望对叶子节点和容器节点执行相同的操作,比如遍历、搜索、导出
  • 递归处理:你需要在容器节点内部递归处理子节点,而且子节点可能是叶子也可能是容器

反过来,如果你的数据只有两层(根和叶子),或者客户端需要频繁区分叶子节点和容器节点做不同处理,那组合模式就不太合适。强行用反而会让代码更复杂。

嗯,组合模式就讲到这里。记住一句话:把对象装进树里,让调用方一视同仁。下次你写菜单、配置、组织架构这类东西时,不妨试试组合模式。


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