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 remove(MenuComponent component) {
// 空实现
}
}
这种“接口里有方法但调用就报错”的设计,对调用方很不友好。如果客户端不小心对叶子节点调了add(),程序直接抛异常。你不能说这是Bug,因为设计上就是这么规定的,但代价是客户端要额外做防御性的异常捕获,或者提前判断类型——那又绕回了instanceof。
2.2 安全式:接口干净了,但客户端要做类型判断
安全式的做法是:Component抽象类里只声明业务方法,比如print()、getName(),把add()、remove()、getChild()这些管理操作下放到Composite复合类里。
好处是接口语义清晰:叶子节点的方法就是干叶子该干的事,复合节点的方法才和管理子节点有关,不会有“调用就报错”的情况。
坏处是你得多写一个层次的类型判断。比如打印完菜单后,如果还想往某个节点里加新子菜单,就得先instanceof Menu判断一下,再强转、再调用add()。这样一来,客户端代码就又出现了我们第一小节里想消灭的if-else,只不过这次只出现在“需要管理子节点”的场景里,比之前少很多。
2.3 我的选型标准:管理操作频率高不高
到底选透明式还是安全式,我的判断标准很简单:你的客户端调用add()、remove()的频率高不高。
- 如果业务里大量出现“往一个未知节点添加子节点”这种操作,比如从配置文件里动态构建菜单树,那我倾向用透明式。虽然叶子节点的
add()要抛异常,但构建过程可以统一走一个递归方法,代码写起来顺畅。 - 如果客户端主要是遍历和读取,很少做增删,那我倾向用安全式。接口更干净,叶子节点不会被一堆无意义的方法污染,调用方也更安全。
说实话,这个选择没有绝对的对错。我之前见过一个团队的分歧:架构师坚持要用安全式,理由是“接口隔离原则”,但业务方写构建逻辑的时候天天跟instanceof打交道,写得很烦。最后是折中了一下——在Composite里加一个方法返回自身类型,方便业务方调用,勉强把两边都安抚住了。这种折中在实际项目里很常见,设计原则落到代码里,还是要为业务效率让路的。
3. 完整实现:一个Java版的菜单系统
说了这么多理论,直接上一个可以跑的Java实现。我用Java 8+的语法来写,代码不算长,但各个角色都有,方便你直接用。
3.1 Component抽象类
java复制import java.util.Iterator;
public abstract class MenuComponent {
public String getName() {
throw new UnsupportedOperationException();
}
public String getDescription() {
throw new UnsupportedOperationException();
}
public double getPrice() {
throw new UnsupportedOperationException();
}
public void print() {
throw new UnsupportedOperationException();
}
// ===== 以下方法仅复合节点需要,这里给出默认实现 =====
public void add(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}
public void remove(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}
public MenuComponent getChild(int i) {
throw new UnsupportedOperationException();
}
}
注意这里我做了个折中:所有方法都给了默认的UnsupportedOperationException实现。这有点像透明式,但实际上叶子节点不用重写add和remove,它们继承默认的抛异常行为就行。复合节点只需要重写自己关心的管理方法。这样既保证了客户端在编译期可以看到所有方法(透明式的好处),又不强制叶子节点写一堆空方法(安全式的影子)。
这种“提供默认异常实现”的做法在JDK集合框架里也很常见,AbstractCollection的很多方法都是这么处理的。
3.2 MenuItem叶子节点
java复制public class MenuItem extends MenuComponent {
private String name;
private String description;
private double price;
public MenuItem(String name, String description, double price) {
this.name = name;
this.description = description;
this.price = price;
}
@Override
public String getName() {
return name;
}
@Override
public String getDescription() {
return description;
}
@Override
public double getPrice() {
return price;
}
@Override
public void print() {
System.out.println(" " + getName() + "(" + getPrice() + "元)");
System.out.println(" 描述:" + getDescription());
}
}
叶子节点很简单,只实现自己的名字、描述、价格和打印逻辑。它的print()不递归,因为它没有孩子。
3.3 Menu复合节点
java复制import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class Menu extends MenuComponent {
private String name;
private String description;
private List<MenuComponent> menuComponents = new ArrayList<>();
public Menu(String name, String description) {
this.name = name;
this.description = description;
}
@Override
public String getName() {
return name;
}
@Override
public String getDescription() {
return description;
}
@Override
public void add(MenuComponent menuComponent) {
menuComponents.add(menuComponent);
}
@Override
public void remove(MenuComponent menuComponent) {
menuComponents.remove(menuComponent);
}
@Override
public MenuComponent getChild(int i) {
return menuComponents.get(i);
}
@Override
public void print() {
System.out.println("【菜单】" + getName() + " - " + getDescription());
System.out.println("----------------------");
for (MenuComponent component : menuComponents) {
component.print();
}
}
}
Menu内部的print()是组合模式的核心:它先打印自己的信息,然后遍历子节点,逐个调用print()。因为子节点可能是MenuItem,也可能是另一个Menu,所以这里天然形成递归——Menu里面的Menu会继续往下一层遍历。这就是多态带来的好处:调用方不需要知道子节点到底是叶子还是复合,统一调print()就行。
3.4 客户端测试
java复制public class Waitress {
// 客户端只需要持有顶层的 MenuComponent
private MenuComponent allMenus;
public Waitress(MenuComponent allMenus) {
this.allMenus = allMenus;
}
public void printMenu() {
allMenus.print(); // 一行代码,搞定整棵菜单树的打印
}
public static void main(String[] args) {
// 构建菜单树
MenuComponent breakfastMenu = new Menu("早餐菜单", "早上7点到9点供应");
MenuComponent lunchMenu = new Menu("午餐菜单", "中午11点到14点供应");
MenuComponent dinnerMenu = new Menu("晚餐菜单", "晚上17点到20点供应");
MenuComponent allMenus = new Menu("总菜单", "今天的所有菜品");
allMenus.add(breakfastMenu);
allMenus.add(lunchMenu);
allMenus.add(dinnerMenu);
// 给午餐菜单添加子菜单
MenuComponent dessertMenu = new Menu("甜品菜单", "午餐后供应的甜品");
lunchMenu.add(dessertMenu);
// 给各层菜单添加菜单项
breakfastMenu.add(new MenuItem("豆浆油条", "经典中式早餐", 6.0));
breakfastMenu.add(new MenuItem("煎饼果子", "加蛋加肠", 12.0));
lunchMenu.add(new MenuItem("宫保鸡丁", "微辣,配米饭", 28.0));
lunchMenu.add(new MenuItem("西红柿炒蛋", "家常味道", 18.0));
dessertMenu.add(new MenuItem("芒果布丁", "新鲜芒果制作", 15.0));
dessertMenu.add(new MenuItem("提拉米苏", "意式经典", 25.0));
dinnerMenu.add(new MenuItem("清蒸鲈鱼", "活鱼现杀", 58.0));
dinnerMenu.add(new MenuItem("地三鲜", "东北风味", 22.0));
Waitress waitress = new Waitress(allMenus);
waitress.printMenu();
}
}
运行结果会递归打印出整棵菜单树。你可以试一下,如果在dessertMenu下再加一个子菜单,代码一行都不用改,照样能正确打印——这就是组合模式“对扩展开放”的直观体现。
4. 树形结构里的几个隐藏陷阱,遇到一个都得折腾半天
组合模式的类图和实现看起来简单,但放到真实业务里,有几个坑是你一定会踩的。我把它们单独拎出来说,每一条都是实际项目中出过问题的。
4.1 循环引用:父节点被当成子节点加回来了
组合模式里最经典的坑就是循环引用。假如菜单树已经构建到三层,某个粗心的同事在代码里执行了:
java复制lunchMenu.add(allMenus); // 把顶层节点又加回了子节点
这就形成了一个循环:allMenus -> lunchMenu -> allMenus。下一步如果调用print(),会无限递归下去,直到StackOverflowError把程序砸挂。
单机演示的时候这种错误不难发现,但真实业务里,菜单、组织架构这类数据很多是从数据库里加载的,节点之间的父子关系来自外部的数据配置,你根本无法保证数据没有脏数据。所以数据库里的树形数据加载到内存时,必须做环路检测。
我的经验做法是:在add()方法里加一个检查,看看要添加的节点是不是当前节点的祖先。
java复制private boolean isAncestor(MenuComponent component) {
// 向上遍历祖先链,判断 component 是否已经在当前树中
// 这里省略具体实现,核心是遍历 parent 链
return false;
}
@Override
public void add(MenuComponent menuComponent) {
if (menuComponent == this || isAncestor(menuComponent)) {
throw new IllegalArgumentException("不能添加自身或祖先节点作为子节点");
}
menuComponents.add(menuComponent);
}
注意组合模式的节点在纯设计里没有parent指针,但真实业务实现时,你大概率要给每个节点加一个parent引用,不然删除节点、查找根节点、计算深度都会很麻烦。加了parent之后,环路检测就方便了,从当前节点一路向上找,总能找到根节点,如果途中碰到了要添加的节点,说明存在环路。
4.2 删除节点时的“连带效应”
组合模式里的remove()默认逻辑很简单:从List中移除,子节点也跟着从根上断了。但这里有个隐蔽问题:如果子节点还被其他对象引用着,它并不会被GC回收,只是从这棵树的遍历路径里消失而已。
真实场景里更麻烦的是“删除父节点时,子节点要不要跟着删”的问题。比如删除一个部门,那部门下的员工和子部门怎么办?是全部级联删除,还是把子部门往上提一级?组合模式本身不负责回答这个问题,它只管结构,不管业务规则。所以你在设计remove()时,一定要先想清楚业务上要的是什么语义,再决定递归删除还是提升删除。这个决策一旦定错了,后面数据修复的代价会非常大。
4.3 递归深度和重复遍历的性能问题
组合模式依赖递归,递归的隐患就是栈溢出。菜单系统这种层级最多三四层,问题不大,但如果是文件目录系统,一个很深的目录结构可能有好几十层,再叠加多线程环境下栈帧的压力,递归调用就不那么安全了。
另外,如果你频繁执行类似“统计整棵树有多少个节点”的操作,每次都是从根开始递归遍历,数据量大了以后性能会比较难看。我见过一个项目,权限菜单树有三万多个节点,每次判断某个用户有没有权限访问某个菜单,都要从根节点递归一遍,接口直接被拖慢到2秒以上。后来加了缓存,把每个节点的后代集合做了持久化,才解决这个问题。
比较常见的优化手段有几种:
- 加缓存:把遍历结果缓存起来,树内容发生变化时清掉缓存。
- 改迭代:对于深度较大的树,用显式栈实现深度优先遍历,代替递归调用。
- 后置计算:在构建树的时候就同步计算并保存子树大小、子树深度等元数据,避免实时遍历。
5. 组合模式不是单打独斗,它跟这些模式搭伙很顺手
组合模式很少单独出现在大型系统里。面试时如果只是背出类图,那只是及格水平,能把下面这些搭配讲明白,才算真正的“懂”。
5.1 组合模式 + 迭代器模式:把递归遍历藏起来
组合模式只保证“调用一致”,但如果你想把整棵树的所有节点扁平化地遍历一遍,比如打印所有菜品的名字,仍然需要递归。这时可以让复合节点返回一个迭代器,把所有子节点压平。
JDK里MenuComponent就可以增加一个createIterator()方法,Menu返回一个深度优先的迭代器,内部用栈来维护遍历状态。
java复制public class CompositeIterator implements Iterator<MenuComponent> {
private Stack<Iterator<MenuComponent>> stack = new Stack<>();
public CompositeIterator(Iterator<MenuComponent> iterator) {
stack.push(iterator);
}
@Override
public boolean hasNext() {
if (stack.isEmpty()) {
return false;
}
Iterator<MenuComponent> iterator = stack.peek();
if (!iterator.hasNext()) {
stack.pop();
return hasNext();
}
return true;
}
@Override
public MenuComponent next() {
if (hasNext()) {
Iterator<MenuComponent> iterator = stack.peek();
MenuComponent component = iterator.next();
if (component instanceof Menu) {
stack.push(component.createIterator());
}
return component;
}
throw new NoSuchElementException();
}
}
这个迭代器本身也是个组合模式的体现——hasNext()在栈顶迭代器没有元素时,会弹栈再递归判断,天然处理了多层级的情况。客户端拿到这个迭代器后,就再也不需要自己写递归了。
5.2 组合模式 + 访问者模式:把操作从节点里剥离出来
组合模式一个不太舒服的地方是:业务操作(比如print()、导出JSON、统计价格)都得写在节点类里。随着操作越来越多,节点类会越来越臃肿。这时可以用访问者模式来解救。
做法是给MenuComponent增加一个accept(Visitor visitor)方法,复合适配器visit(Menu menu),叶子适配器visit(MenuItem item)。以后要增加“导出JSON”这个操作,不需要改任何节点类,只需要新增一个JsonVisitor实现访问者接口就行。
这种“操作与数据分离”的思路,在大一点的项目里很值得用。不过要注意,访问者模式会让结构变得复杂,如果只是两三个操作,硬上访问者反而得不偿失。
5.3 组合模式 + 装饰器模式:给整棵树加一层“滤镜”
组合模式的节点实现比较死板,如果你想给某些节点加个特殊效果——比如菜单里想标记“推荐”标签、打折信息,最简单粗暴的做法是改MenuItem加字段。但有些场景下动态的包装比改类更合适。
装饰器模式和组合模式的结合点在于:装饰器本身也可以是一个MenuComponent。你写一个DiscountDecorator extends MenuComponent,内部包装一个MenuComponent,在print()之前先输出“折扣价”,再委托给被包装的节点。由于装饰器实现了MenuComponent接口,你甚至可以把装饰器当普通节点加进Menu里。这样一来,整棵树的某个分支得到了“装饰”,其他分支不受影响,而且完全不需要修改原来的节点类。
6. 哪些场景真的该用,哪些场景用了反而难受
每个设计模式都有它的适用边界。我见过不少为了用模式而用模式的代码,组合模式也不例外。这里给出我对使用场景的判断标准,都是实际项目中总结出来的。
6.1 适合用的场景
- 业务模型天然是树形结构。文件系统、菜单、组织架构、分类目录、表达式树、GUI容器层级,这类场景的“部分-整体”关系是客观存在的,用组合模式几乎不需要经过“设计”,它本来就是最自然的建模方式。
- 客户端需要一视同仁地处理叶子节点和复合节点。如果处理逻辑差异很大,组合模式的好处就不明显了。
- 树的结构相对稳定。不要求结构频繁大改,增删操作主要集中在叶子层级。
6.2 不适合用的场景
- 节点类型差异太大。如果叶子节点和复合节点的业务方法几乎没有重叠,强行抽象一个公共接口,会把接口搞得又大又空,违反接口隔离和里氏替换原则。这个时候不如踏实分成两部分处理。
- 层级过深或数据量巨大。组合模式自带的递归遍历模式,在大规模树形数据下容易遇到性能瓶颈。不是说不能优化,但要提前想好缓存和迭代方案,不要在架构设计时才补救。
- 数据模型以关系型存储为主,且经常需要按SQL查询子树。这种情况真正的核心在持久层和查询优化,组合模式帮不上什么忙,反而会误导你把重心放错地方。
6.3 判断要不要用的“三步自查法”
我目前的做法很简单:拿到一个需求先不急着套模式,而是先走三步自查。
先问自己这棵树是不是天然存在,是不是业务真实结构强相关的树形关系。再问客户端处理叶子节点和复合节点是不是经常用同一种方式。最后问有没有第三处类似的“按类型分叉”的代码出现。如果三个问题都是肯定答案,那组合模式基本跑不掉。
“第三处”这个判断标准是我自己定的。第一处写if type == ... else ...是正常实现,第二处出现时你意识到可能要抽象,但先忍住,到第三处时再重构。过早抽象的结果往往是抽象错了方向,等到场景真的出现三次以上,规律看得足够清楚了再动手,才是比较稳妥的工程节奏。
最后再分享一个小技巧。如果你要在网上找组合模式的资料,可以用“Composite Pattern”或者“树形结构设计模式”去搜,能避开很多质量参差不齐的中文资料。看源码的话,推荐看JDK里java.awt.Container的add()和getComponents()方法,那是组合模式在JDK里最朴实无华的生产级实现,比任何教学代码都有说服力。
