你的递归树遍历每次都写一遍——组合模式一个接口就能抹平叶子节点和组合节点的差异

做过一个权限系统,菜单结构长这样:

复制代码
系统管理

├── 用户管理


│   ├── 新增用户


│   ├── 编辑用户


│   └── 删除用户


├── 角色管理


│   ├── 新增角色


│   └── 分配权限


└── 日志查看


├── 操作日志


└── 登录日志

最初的设计很直觉------两个类:

java 复制代码
class MenuItem {

String name;


String url;


String permission;


}


class MenuGroup {


String name;


List items;


List
   
     subGroups;
   


}

遍历和渲染的时候麻烦来了:

java 复制代码
public String renderMenu(List
  
    groups) {

   
StringBuilder html = new StringBuilder("

   

* 
      ");
      



      
  for (MenuGroup group : groups) {


      
  html.append("

      

* ").append(group.name);



  html.append("

       
  * 
          ");
          



          
    for (MenuItem item : group.items) {


          
    html.append("

          

  * "%29.append%28item.url%29.append%28"

    "%29.append%28item.url%29.append%28"


  * ");
          



          
    }


          
    // 还有子分组...


          
    for (MenuGroup sub : group.subGroups) {


          
    html.append("

          

  * ").append(sub.name);

     
    html.append("

           
    * 
              ");
              

       
              
      for (MenuItem item : sub.items) {
       
              
      // ... 又嵌套一层
       
              
      }
       
              
      html.append("

             


  * ");
          



          
    }


          
    html.append("

         


* ");
      



      
  }


      
  html.append("

     

");
   



   
return html.toString();


   
}


  

三层嵌套已经写成这样了,如果再来一层------组织架构里部门下面有子部门,子部门下面还有小组,小组下面还有------你需要写一个递归函数,而且每次写树形结构都要重新写一遍。

组合模式解决的就是这个问题:**让单个对象和组合对象可以被一致对待**。

组合模式的核心------一个接口搞定一切

java 复制代码
// 抽象组件------叶子和组合节点都实现这个接口

interface MenuComponent {


String getName();


String render();      // 核心:叶子和组合节点都有这个方法


void add(MenuComponent component);    // 默认抛异常


void remove(MenuComponent component); // 默认抛异常


}


// 叶子节点


class MenuItem implements MenuComponent {


private String name;


private String url;


public MenuItem(String name, String url) {


this.name = name;


this.url = url;


}


@Override


public String getName() { return name; }


@Override


public String render() {


return "


* "%20+%20url%20+%20"

* ";



  }


  @Override


  public void add(MenuComponent c) {


  throw new UnsupportedOperationException("叶子节点不能添加子节点");


  }


  @Override


  public void remove(MenuComponent c) {


  throw new UnsupportedOperationException("叶子节点不能删除子节点");


  }


  }


  // 组合节点


  class Menu implements MenuComponent {


  private String name;


  private List
     
       children = new ArrayList<>();
     


  public Menu(String name) {


  this.name = name;


  }


  @Override


  public String getName() { return name; }


  @Override


  public String render() {


  StringBuilder html = new StringBuilder();


  html.append("


* ").append(name).append("
     
  * 
        ");
        



        
    // 关键:递归调用 render(),不管是叶子还是组合节点


        
    for (MenuComponent child : children) {


        
    html.append(child.render());


        
    }


        
    html.append("

       


* ");



  return html.toString();


  }


  @Override


  public void add(MenuComponent component) {


  children.add(component);


  }


  @Override


  public void remove(MenuComponent component) {


  children.remove(component);


  }


  }

现在构建菜单树和渲染都简洁了:

java 复制代码
MenuComponent root = new Menu("系统管理");

MenuComponent userMgr = new Menu("用户管理");


userMgr.add(new MenuItem("新增用户", "/user/add"));


userMgr.add(new MenuItem("编辑用户", "/user/edit"));


userMgr.add(new MenuItem("删除用户", "/user/delete"));


MenuComponent roleMgr = new Menu("角色管理");


roleMgr.add(new MenuItem("新增角色", "/role/add"));


roleMgr.add(new MenuItem("分配权限", "/role/assign"));


root.add(userMgr);


root.add(roleMgr);


// 渲染------一行搞定,递归自动处理嵌套


System.out.println(root.render());

这就是组合模式的价值:**客户端代码不需要区分叶子还是组合节点,统一调用 render() 就行**。

安全性和透明性的取舍

上面那个实现有个明显的问题------MenuItemadd()remove() 抛异常。这在设计模式里是一个经典的权衡:

**透明式组合**(上面的做法):add()remove() 定义在抽象接口里,叶子节点抛异常。调用方不需要做类型判断,但可能在运行时炸。

java 复制代码
// 透明式------不需要 instanceof 判断

menuComponent.render();  // 管你是叶子还是组合,都能调


menuComponent.add(child); // 叶子节点会抛异常,但编译不报错

**安全式组合**:add()remove() 只定义在 Menu 类里,不在接口中。调用方需要判断类型,但编译期就能发现错误。

java 复制代码
// 安全式------需要判断类型

if (menuComponent instanceof Menu) {


((Menu) menuComponent).add(child);  // 编译期安全


}


menuComponent.render();  // 这个叶子也能做

怎么选?如果你的树结构在运行时基本不变(菜单、组织结构、文件系统),透明式更方便。如果是动态构建的复杂树,安全式更稳妥------至少不会在生产环境 UnsupportedOperationException

文件系统------组合模式的教科书

文件系统的设计天生就是组合模式:

java 复制代码
interface FileSystemNode {

String getName();


long getSize();


void ls(String indent);


}


class File implements FileSystemNode {


private String name;


private long size;


public File(String name, long size) {


this.name = name;


this.size = size;


}


@Override


public String getName() { return name; }


@Override


public long getSize() { return size; }


@Override


public void ls(String indent) {


System.out.println(indent + name + " (" + size + " bytes)");


}


}


class Directory implements FileSystemNode {


private String name;


private List
   
     children = new ArrayList<>();
   


@Override


public String getName() { return name; }


@Override


public long getSize() {


// 递归汇总所有子节点大小


return children.stream()


.mapToLong(FileSystemNode::getSize)


.sum();


}


@Override


public void ls(String indent) {


System.out.println(indent + name + "/");


for (FileSystemNode child : children) {


child.ls(indent + "  ");


}


}


public void add(FileSystemNode node) {


children.add(node);


}


}

计算一个目录的总大小------递归自动穿透到所有子目录和文件。Directory.getSize() 不需要知道子节点是 File 还是 Directory

Java 标准库的 java.io.File 也在一定程度上体现了组合模式的思路------listFiles() 返回的可以是文件也可以是目录,不过没有抽象出统一的组件接口。

规则引擎------一个不常见但很实用的变体

组合模式不只是处理树形 UI。在规则引擎里判定条件本身就是一棵树:

java 复制代码
// 抽象条件

interface Condition {


boolean evaluate(Map
   
     context);
   


}


// 原子条件------叶子


class EqualCondition implements Condition {


private String field;


private Object value;


@Override


public boolean evaluate(Map
   
     ctx) {
   


return value.equals(ctx.get(field));


}


}


class GreaterThanCondition implements Condition {


private String field;


private double threshold;


@Override


public boolean evaluate(Map
   
     ctx) {
   


return ((Number) ctx.get(field)).doubleValue() > threshold;


}


}


// 组合条件------AND 和 OR


class AndCondition implements Condition {


private List
   
     conditions;
   


@Override


public boolean evaluate(Map
   
     ctx) {
   


return conditions.stream().allMatch(c -> c.evaluate(ctx));


}


}


class OrCondition implements Condition {


private List
   
     conditions;
   


@Override


public boolean evaluate(Map
   
     ctx) {
   


return conditions.stream().anyMatch(c -> c.evaluate(ctx));


}


}

用起来:

java 复制代码
// 定义一个规则:金额 > 1000 且 (用户等级是 VIP 或 信用分 > 80)

Condition rule = new AndCondition(List.of(


new GreaterThanCondition("amount", 1000),


new OrCondition(List.of(


new EqualCondition("level", "VIP"),


new GreaterThanCondition("creditScore", 80)


))


));


Map
   
     order = Map.of(
   


"amount", 1500,


"level", "VIP",


"creditScore", 75


);


boolean pass = rule.evaluate(order);  // true

这个设计的精妙之处在于:AND/OR 本身就是组合节点,evaluate() 递归穿透整棵树。你可以用 JSON 或 DSL 来定义规则树,存储到数据库里,然后在运行时动态构建条件树------不会写一行多余的 if-else。

什么时候不该用组合模式

组合模式有适用边界,强行用只会反效果:

**叶子节点和组合节点的行为差异太大时。** 如果你发现自己在写一堆 if (node instanceof Leaf) 判断,组合模式的价值已经没了。老老实实用两个不同的类,让调用方显式处理。

**树的遍历规则不统一时。** 组合模式隐含了一个假设:对每个节点的操作可以统一递归。如果子节点的处理逻辑需要依赖父节点的状态(比如路径上下文),统一接口会让代码很别扭。

**层级不深、结构不复杂时。** 两层三层的简单树,直接嵌套 List 就够了,引入接口和两个实现类属于过度设计。

一段能让代码干净三倍的写法

Java 17 之后,可以用 sealed interface 和 switch pattern matching 让组合模式更安全:

java 复制代码
sealed interface TreeNode permits Leaf, Branch {

String render();


}


record Leaf(String name, String value) implements TreeNode {


@Override


public String render() {


return "" + name + ": " + value + "";


}


}


record Branch(String name, List
   
     children) implements TreeNode {
   


@Override


public String render() {


String childHtml = children.stream()


.map(TreeNode::render)


.collect(Collectors.joining());


return "

  

   
#### " + name + "

" + childHtml + "
  
";




}


}


// 调用方可以用 switch 做模式匹配


String render(TreeNode node) {


return switch (node) {


case Leaf l -> l.render();


case Branch b -> b.render();


};


}

sealed interface 限制了只能有 Leaf 和 Branch 两个实现,编译器会帮你在 switch 里检查完整性。这解决了透明式组合的安全问题------你不需要抛 UnsupportedOperationException,因为 Leaf 上根本没有 add() 方法。


「爪爪代码冒险记」是我们正在开发的微信小程序,用卡皮巴拉漫画把 23 个设计模式讲成冒险故事。组合模式那关的主题是「家族树」------卡皮巴拉要帮小动物们绘制族谱,发现爷爷、爸爸、孙子其实都是「家庭成员」。搜一下「爪爪代码冒险记」可以看到开发进展,或者等我后面的文章。