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

📅 2026/6/19 16:30:02
你的递归树遍历每次都写一遍——组合模式一个接口就能抹平叶子节点和组合节点的差异
做过一个权限系统菜单结构长这样系统管理├── 用户管理│ ├── 新增用户│ ├── 编辑用户│ └── 删除用户├── 角色管理│ ├── 新增角色│ └── 分配权限└── 日志查看├── 操作日志└── 登录日志最初的设计很直觉——两个类class MenuItem {String name;String url;String permission;}class MenuGroup {String name;Listitems;List subGroups;}遍历和渲染的时候麻烦来了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().append(item.name).append();}// 还有子分组...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();}三层嵌套已经写成这样了如果再来一层——组织架构里部门下面有子部门子部门下面还有小组小组下面还有——你需要写一个递归函数而且每次写树形结构都要重新写一遍。组合模式解决的就是这个问题**让单个对象和组合对象可以被一致对待**。组合模式的核心——一个接口搞定一切// 抽象组件——叶子和组合节点都实现这个接口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;}Overridepublic String getName() { return name; }Overridepublic String render() {return name ;}Overridepublic void add(MenuComponent c) {throw new UnsupportedOperationException(叶子节点不能添加子节点);}Overridepublic 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;}Overridepublic String getName() { return name; }Overridepublic 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();}Overridepublic void add(MenuComponent component) {children.add(component);}Overridepublic void remove(MenuComponent component) {children.remove(component);}}现在构建菜单树和渲染都简洁了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()就行**。安全性和透明性的取舍上面那个实现有个明显的问题——MenuItem的add()和remove()抛异常。这在设计模式里是一个经典的权衡**透明式组合**上面的做法add()和remove()定义在抽象接口里叶子节点抛异常。调用方不需要做类型判断但可能在运行时炸。// 透明式——不需要 instanceof 判断menuComponent.render(); // 管你是叶子还是组合都能调menuComponent.add(child); // 叶子节点会抛异常但编译不报错**安全式组合**add()和remove()只定义在Menu类里不在接口中。调用方需要判断类型但编译期就能发现错误。// 安全式——需要判断类型if (menuComponent instanceof Menu) {((Menu) menuComponent).add(child); // 编译期安全}menuComponent.render(); // 这个叶子也能做怎么选如果你的树结构在运行时基本不变菜单、组织结构、文件系统透明式更方便。如果是动态构建的复杂树安全式更稳妥——至少不会在生产环境UnsupportedOperationException。文件系统——组合模式的教科书文件系统的设计天生就是组合模式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;}Overridepublic String getName() { return name; }Overridepublic long getSize() { return size; }Overridepublic void ls(String indent) {System.out.println(indent name ( size bytes));}}class Directory implements FileSystemNode {private String name;private List children new ArrayList();Overridepublic String getName() { return name; }Overridepublic long getSize() {// 递归汇总所有子节点大小return children.stream().mapToLong(FileSystemNode::getSize).sum();}Overridepublic 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。在规则引擎里判定条件本身就是一棵树// 抽象条件interface Condition {boolean evaluate(Map context);}// 原子条件——叶子class EqualCondition implements Condition {private String field;private Object value;Overridepublic boolean evaluate(Map ctx) {return value.equals(ctx.get(field));}}class GreaterThanCondition implements Condition {private String field;private double threshold;Overridepublic boolean evaluate(Map ctx) {return ((Number) ctx.get(field)).doubleValue() threshold;}}// 组合条件——AND 和 ORclass AndCondition implements Condition {private List conditions;Overridepublic boolean evaluate(Map ctx) {return conditions.stream().allMatch(c - c.evaluate(ctx));}}class OrCondition implements Condition {private List conditions;Overridepublic boolean evaluate(Map ctx) {return conditions.stream().anyMatch(c - c.evaluate(ctx));}}用起来// 定义一个规则金额 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 让组合模式更安全sealed interface TreeNode permits Leaf, Branch {String render();}record Leaf(String name, String value) implements TreeNode {Overridepublic String render() {return name : value ;}}record Branch(String name, List children) implements TreeNode {Overridepublic 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 个设计模式讲成冒险故事。组合模式那关的主题是「家族树」——卡皮巴拉要帮小动物们绘制族谱发现爷爷、爸爸、孙子其实都是「家庭成员」。搜一下「爪爪代码冒险记」可以看到开发进展或者等我后面的文章。