组合模式:树形结构、透明式与安全式、实战:菜单系统

组合模式,说白了就是处理「部分-整体」关系的一种设计模式。你想想看,树形结构在代码里太常见了——文件夹套文件、菜单套子菜单、组织架构套部门。如果每次都要区分「叶子节点」和「容器节点」,代码会变得又臭又长。

组合模式的核心思想很简单:让客户端统一对待单个对象和组合对象。嗯,这里要注意,不是所有树形结构都适合用组合模式,得看你的业务场景。

组合模式的结构

组合模式有三个核心角色:

  • Component(抽象构件):定义叶子节点和容器节点的公共接口
  • Leaf(叶子节点):没有子节点的具体构件
  • Composite(容器节点):有子节点的具体构件,负责管理子节点

我在项目中遇到过不少团队把组合模式用歪了——要么把所有方法都塞进抽象类,导致叶子节点不得不实现一堆空方法;要么把管理子节点的方法只放在容器节点里,客户端调用时还得做类型判断。这两种做法,其实对应了组合模式的两种变体:透明式安全式

透明式 vs 安全式

对比维度 透明式 安全式
接口定义 所有方法都在抽象构件中定义 管理子节点的方法只在容器节点中定义
叶子节点 需要实现空方法或抛异常 不需要实现管理方法
客户端调用 无需类型判断,统一调用 需要判断是叶子还是容器
安全性 运行时可能抛异常 编译期就能发现问题
我个人习惯 适合简单场景 适合复杂业务

为什么会有这两种变体?说白了就是「接口统一性」和「类型安全性」之间的权衡。我建议你根据实际场景来选择——如果树形结构比较简单,叶子节点不会误调用管理方法,透明式更简洁;如果业务复杂,安全式能帮你提前发现错误。

实战:菜单系统

我们拿一个菜单系统来实战。菜单项可以是叶子节点(比如一个具体的菜品),也可以是容器节点(比如一个分类,下面还有子分类和菜品)。

先看抽象构件:

public abstract class MenuComponent {
    protected String name;
    protected String description;
    
    public MenuComponent(String name, String description) {
        this.name = name;
        this.description = description;
    }
    
    // 公共方法
    public String getName() { return name; }
    public String getDescription() { return description; }
    
    // 管理子节点的方法——透明式风格
    public void add(MenuComponent component) {
        throw new UnsupportedOperationException("叶子节点不支持添加操作");
    }
    
    public void remove(MenuComponent component) {
        throw new UnsupportedOperationException("叶子节点不支持删除操作");
    }
    
    public MenuComponent getChild(int i) {
        throw new UnsupportedOperationException("叶子节点不支持获取子节点");
    }
    
    // 核心业务方法
    public abstract void print();
    
    // 获取价格——叶子节点实现,容器节点可以返回子节点价格总和
    public double getPrice() {
        throw new UnsupportedOperationException("容器节点请重写此方法");
    }
}

嗯,这里要注意,我用了透明式的写法——所有管理方法都在抽象类中定义了默认实现,叶子节点不需要关心这些方法,但如果误调用就会抛异常。这种做法在简单场景下很实用。

接下来是叶子节点——具体的菜品:

public class MenuItem extends MenuComponent {
    private double price;
    private boolean isVegetarian;
    
    public MenuItem(String name, String description, 
                    double price, boolean isVegetarian) {
        super(name, description);
        this.price = price;
        this.isVegetarian = isVegetarian;
    }
    
    @Override
    public double getPrice() {
        return price;
    }
    
    @Override
    public void print() {
        System.out.print("  " + getName());
        if (isVegetarian) {
            System.out.print("(素)");
        }
        System.out.println(", " + getPrice() + "元");
        System.out.println("    -- " + getDescription());
    }
}

然后是容器节点——菜单分类:

import java.util.ArrayList;
import java.util.List;

public class Menu extends MenuComponent {
    private List<MenuComponent> menuComponents = new ArrayList<>();
    
    public Menu(String name, String description) {
        super(name, description);
    }
    
    @Override
    public void add(MenuComponent component) {
        menuComponents.add(component);
    }
    
    @Override
    public void remove(MenuComponent component) {
        menuComponents.remove(component);
    }
    
    @Override
    public MenuComponent getChild(int i) {
        return menuComponents.get(i);
    }
    
    @Override
    public double getPrice() {
        // 计算所有子节点的价格总和
        return menuComponents.stream()
                .mapToDouble(MenuComponent::getPrice)
                .sum();
    }
    
    @Override
    public void print() {
        System.out.println("\n" + getName());
        System.out.println(", " + getDescription());
        System.out.println("---------------------");
        
        for (MenuComponent component : menuComponents) {
            component.print();
        }
    }
}

你看,容器节点重写了 add、remove、getChild 方法,而叶子节点不需要关心这些。客户端调用时,完全不需要知道当前操作的是叶子还是容器:

public class MenuClient {
    public static void main(String[] args) {
        MenuComponent allMenus = new Menu("全部菜单", "所有菜品");
        
        MenuComponent lunchMenu = new Menu("午餐菜单", "午餐供应");
        MenuComponent dinnerMenu = new Menu("晚餐菜单", "晚餐供应");
        
        lunchMenu.add(new MenuItem("宫保鸡丁", "经典川菜", 38.0, false));
        lunchMenu.add(new MenuItem("番茄炒蛋", "家常美味", 22.0, true));
        
        dinnerMenu.add(new MenuItem("水煮鱼", "麻辣鲜香", 68.0, false));
        dinnerMenu.add(new MenuItem("清炒时蔬", "清淡健康", 28.0, true));
        
        allMenus.add(lunchMenu);
        allMenus.add(dinnerMenu);
        
        // 统一调用,无需类型判断
        allMenus.print();
        System.out.println("\n总价: " + allMenus.getPrice() + "元");
    }
}

输出结果:

全部菜单
, 所有菜品
---------------------

午餐菜单
, 午餐供应
---------------------
  宫保鸡丁, 38.0元
    -- 经典川菜
  番茄炒蛋, 22.0元
    -- 家常美味

晚餐菜单
, 晚餐供应
---------------------
  水煮鱼, 68.0元
    -- 麻辣鲜香
  清炒时蔬, 28.0元
    -- 清淡健康

总价: 156.0元

核心要点:组合模式让客户端可以用一致的方式处理单个对象和组合对象。你想想看,如果没有组合模式,我们得写一堆 if-else 来判断当前节点是叶子还是容器,代码会变得非常脆弱。

组合模式的核心逻辑

下面这张图展示了组合模式在菜单系统中的核心逻辑:

组合模式核心逻辑 — 菜单系统 MenuComponent 抽象构件 MenuItem 叶子节点 print() / getPrice() Menu 容器节点 add() / remove() / print() 继承 继承 包含子节点 客户端 统一调用,无需类型判断 依赖 示例树形结构 全部菜单(容器) ├─ 午餐菜单(容器) │ ├─ 宫保鸡丁(叶子) │ └─ 番茄炒蛋(叶子) └─ 晚餐菜单(容器)

避坑指南:我曾经在一个电商项目中,把商品分类和商品本身都塞进了同一个组合树里。结果发现,商品分类有几千个,商品有几十万个,每次遍历整棵树都慢得不行。后来才意识到,组合模式适合层次浅、节点少的场景。如果树很深或者节点很多,建议用迭代器模式或者直接上数据库递归查询。

透明式 vs 安全式——怎么选?

我个人的经验是:

  • 选透明式:如果树形结构比较简单,叶子节点和容器节点的行为差异不大,而且你希望客户端代码尽量简洁。比如上面的菜单系统,叶子节点不会误调用 add 方法,透明式完全够用。
  • 选安全式:如果业务复杂,叶子节点和容器节点的接口差异很大,或者团队里有新人容易误操作。安全式把管理方法只放在容器节点中,客户端调用时必须先判断类型,虽然代码多几行,但更安全。

你想想看,透明式就像把所有工具都放在一个工具箱里,新手可能拿错;安全式就像把不同工具分开放,虽然拿的时候多走两步,但不会拿错。嗯,没有绝对的好坏,看你的团队和场景。

注意事项:组合模式不是万能的。如果你的树形结构经常变化(比如频繁增删节点),或者需要复杂的遍历逻辑(比如按条件过滤),组合模式会让代码变得难以维护。这时候,我建议你考虑访问者模式或者迭代器模式。

组合模式的优缺点

优点 缺点
客户端代码简洁,统一处理叶子节点和容器节点 可能违反接口隔离原则(叶子节点需要实现不需要的方法)
新增节点类型方便,符合开闭原则 树形结构复杂时,性能可能成为瓶颈
天然适合树形结构的业务场景 不适合层次过深或节点过多的场景

组合模式,说白了就是「把树形结构藏起来,让客户端只看到统一的接口」。我在多个项目中都用过它——菜单系统、文件系统、组织架构、权限树……每次用之前我都会问自己一个问题:客户端真的需要区分叶子节点和容器节点吗?如果答案是「不需要」,那组合模式就是你的菜。


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