1. 菜单系统的打印需求,为什么让我想起了组合模式
处理过树形结构的同学应该都有这种体验。菜单也好,文件目录也好,组织架构也好,第一版写的时候都挺爽,越写到后面越痛苦。为什么?因为树形结构的“部分”和“整体”长得太像了:一个文件夹能包含子文件夹和文件,一个菜单能包含子菜单和菜单项,一个部门能包含子部门和员工。你在业务里处理它们的时候,总得不停判断“这个节点到底还有没有孩子”,然后分两条路走。
我最开始接触组合模式(Composite Pattern)就是被一个菜单系统逼的。产品经理给的菜单层级不固定,有的菜单挂了两层,有的挂了一层,还有那种单个菜单项。需求也很简单:把整个菜单树按层级打印出来。但就这个“简单”需求,用最朴素的写法,很快就把客户端的代码写得没法看。
1.1 同一个打印需求,两种不同的写码姿势
先看大多数人的第一版。假设有两个类,一个叫Menu,一个叫MenuItem,Menu内部有个List,里面既能放Menu又能放MenuItem。打印的时候,客户端大概长这样:
java复制for (Object obj : allMenus) {
if (obj instanceof Menu) {
Menu menu = (Menu) obj;
System.out.println(menu.getName());
printAllMenu(menu.getSubMenus()); // 递归处理
} else if (obj instanceof MenuItem) {
MenuItem item = (MenuItem) obj;
System.out.println(" " + item.getName());
}
}
这种代码有个问题:客户端必须知道每个节点的具体类型,才能决定怎么处理。更糟糕的是,如果以后加了Separator(菜单分隔线)、Button(快捷按钮)这类新节点,这个if-else链还要继续加分支。用户每多提一个需求,你就要多改一处打印逻辑,而且很难保证所有遍历点都改全。
而组合模式的做法是:让Menu和MenuItem都实现同一个抽象组件接口,接口里统一声明print()方法。Menu的print()先打印自己的名称,再遍历子节点、逐个调用它们的print();MenuItem的print()就只打印自己的名称。客户端什么都不用判断,调一个方法就完事。
java复制// 客户端遍历
for (MenuComponent component : allMenus) {
component.print();
}
第二版代码的可维护性明显好很多,新加节点类型时,只要新的节点类实现print(),原有代码全部不用动。这就是组合模式最核心的一点:让客户端用一致的方式对待叶子节点和复合节点。
1.2 组合模式的本质:树形结构 + 多态递归
所谓组合模式,GoF的定义是:将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
拆开来看,就三个关键词:
- 树形结构:业务数据天然是树,父子节点通过组合关系嵌套。
- 统一接口:叶子节点和复合节点实现同一个抽象接口。
- 递归调用:复合节点的业务方法内部会调用子节点的同名方法,让请求沿着树往下传。
理解了这三点,你基本就理解了组合模式的全部内容。它不是什么高深魔法,本质上是“多态 + 递归”这两个基础概念在树形结构上的一次漂亮组合。
1.3 组合模式里的三个角色
组合模式的类图非常简单,就三个角色:
- Component(抽象组件):声明叶子节点和复合节点的公共接口,比如
print()、getName()。它可以是一个抽象类,也可以是一个接口。 - Leaf(叶子节点):树的最末端,没有子节点。实现
Component声明的业务方法。 - Composite(复合节点):内部维护一个子节点集合,实现
Component声明的业务方法,并且通常还会提供add()、remove()、getChild()这些管理子节点的操作。
你去看JDK里的java.awt.Container和java.awt.Component,其实就是组合模式在GUI里的典型应用。一个Container可以包含其他Component,而Component又是一个抽象基类。类似的还有javax.swing.JMenu和JMenuItem的关系。说明这个模式不是停留在教材里的概念,而是深入到了JDK源码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 透明式和安全式:组合模式里最纠结的一个设计决策
看组合模式的资料时,你一定会遇到两个概念:透明式(Transparent)和安全式(Safe)。这是关于**“add/remove这类管理子节点的方法,到底该放在抽象类里还是只放在复合类里”**的两种不同设计取舍。
2.1 透明式:接口统一了,但叶子节点得学会“装死”
透明式的做法是:把add()、remove()、getChild()这些管理方法全部声明在Component抽象类里,也就是叶子节点和复合节点都有这些方法。好处是客户端不用做任何类型判断,调用add()之前不需要关心拿到的到底是叶子还是复合节点,反正接口上都有。
坏处也很明显:Leaf叶子节点根本没有子节点,你让它的add()怎么办?只能抛异常,或者空实现。这就等于叶子节点要被迫实现一堆自己用不上的方法。
java复制public class MenuItem extends MenuComponent {
@Override
public void add(MenuComponent component) {
throw new UnsupportedOperationException("菜单项不能添加子节点");
}
@Override
public void r
