做后台系统的那些年,我遇到过至少五次几乎一模一样的需求:菜单权限树、部门组织架构、商品类目、评论的楼中楼、审批流程的分支节点。每一次的界面长得不一样,业务名词也不一样,但剥开表皮往下看,数据结构是同一副骨架——一棵树,树上既有“能装东西的枝干”,也有“装不了东西的叶子”,而客户端代码总想用一套逻辑把整棵树处理完。
组合模式(Composite Pattern)就是干这件事的。它把“单个对象”和“对象的组合”放在同一个抽象之下,让调用方不需要在每一层都问一句“你现在到底是单个还是一堆”。这篇内容写给已经会用面向对象、但每次碰到树形结构还是忍不住写一堆 if-else 和 instanceof 的同学;也写给想搞清楚设计模式到底怎么落到业务代码里、而不是停留在背概念阶段的朋友。我会从一个被写烂的菜单渲染函数讲起,一路拆到接口怎么定、坑在哪、框架源码里它长什么样。
1. 从一个被 if-else 撑爆的菜单渲染函数说起
1.1 需求刚来的时候,谁都觉得简单
需求最初的样子通常很朴素:后端返回一个菜单列表,前端要把它渲染成侧边栏。一级菜单下面是二级菜单,二级下面可能还有三级,有的菜单能点(跳页面),有的菜单只是个分组标题(点不动,只能展开)。第一版代码写出来大概是这样:
public void render(List<Menu> menus, int level) {
for (Menu m : menus) {
if (m.getChildren() == null || m.getChildren().isEmpty()) {
// 叶子节点,渲染成可点击的链接
System.out.println(indent(level) + "<a href='" + m.getUrl() + "'>" + m.getName() + "</a>");
} else {
// 容器节点,渲染成分组标题,然后递归
System.out.println(indent(level) + "<div class='group'>" + m.getName() + "</div>");
render(m.getChildren(), level + 1);
}
}
}
看着还行对吧?问题在于这个
if
会长出亲戚。产品经理过来说,按钮权限也要挂在菜单上,按钮是叶子,但它没有 URL,得单独渲染;再过两周,说有个“外链菜单”,点击是新开窗口,得加个 target 属性;再过一个月,说某些菜单是“虚拟分组”,本身不显示,只用来做权限归类。
于是渲染函数里开始出现
if (m.getType() == 1) ... else if (m.getType() == 2) ...
,判断条件从“有没有子节点”变成“节点类型是什么”,再变成“节点类型 + 有没有子节点 + 有没有 URL”的组合。每加一种节点,这个函数就要动一次,而且改完之后你还不敢确定有没有漏掉某个分支。
真正致命的地方在于: 判断节点类型的逻辑散落在每一个需要处理菜单的地方 。渲染要判断一次,权限校验要判断一次,导出 Excel 要判断一次,埋点统计要判断一次。四处判断,四处可能不一致。
1.2 组合模式想干的事:把“单个”和“一堆”的差异关进笼子
组合模式的定义我背不出来原话,但它的意图一句话能说清: 把对象组织成树形结构来表示“部分—整体”的层次,让客户端对单个对象和对整棵子树的调用方式完全一致。
翻译成大白话:你调
render()
的时候,不用管手里这个东西是一片叶子还是一整棵子树,它自己知道该怎么渲染自己。叶子说“我就是一个链接”,容器说“我先把标题打出来,然后挨个让我的孩子们自己渲染”。
生活里的类比特别好找。公司发通知这件事,你作为员工收到通知,和部门收到通知,走的其实是同一套流程——通知最终都会落到每一个人头上,只是部门这一层多了一步“往下转发”。你不会因为收件人写的是“研发部”就换一套传达方式,也不会因为写的是“张三”就换一套。组合模式就是把“往下转发”这个动作封装进容器节点自己身上。
代码上的变化是:客户端那个
if (m.getChildren() == null || ...)
消失了,取而代之的是一句
node.render(level)
。至于 node 是叶子还是容器,由 node 自己的实现决定。这一句话的变化,看起来只是少了一个判断,实际上是
把变化点从调用方挪到了实现方
。以后再加“外链菜单”“虚拟分组”,只需要新写一个实现类,所有调用方一行都不用改。
这就是开闭原则在结构层面的体现,也是组合模式最有价值的地方——它不是为了让你少写代码,而是为了让你在加节点类型的时候不用回头去翻所有调用点。
1.3 什么样的需求该考虑组合模式:几个识别信号
不是所有树都值得上组合模式。我自己的判断标准是看客户端代码里有没有这几个信号,中两个以上,基本就该考虑了。
信号一:出现“这是单个还是多个”的类型判断。
instanceof
、
getType() ==
、
children == null
、
size() > 0
,这些判断只要成片出现,说明客户端在替数据结构做决策,这个决策应该下沉。
信号二:同一段处理逻辑在多个地方重复写了递归。 渲染写一次递归,统计数量写一次递归,导出写一次递归,查找写一次递归。递归本身不是问题,问题是每一处递归都在重复“先判断是不是容器,是就往下走”这套样板。
信号三:新增一种节点类型,需要改动超过一个类。 这是最硬的指标。如果加一种叶子节点,你要改渲染器、改校验器、改导出器,那说明节点类型这个概念没有被抽象住。
信号四:层次深度不确定,且未来可能继续加深。 需求说“目前最多三级”,通常意味着半年后会有第四级。如果代码里写死了三层循环,那迟早要重写。
反过来,什么情况
不该
用?树的结构非常稳定、只有一种节点类型、未来基本不会扩展,那用一个简单的
List<Node>
加一次遍历就够了,套上接口和抽象类反而增加阅读成本。组合模式解决的是“结构会变、类型会加”的场景,结构不变的时候它是纯粹的负担。
2. Component、Leaf、Composite:三个角色到底怎么分工
2.1 抽象构件定义的是“共同语言”,不是“大杂烩”
组合模式里那个被所有节点实现的接口(有的书叫 Component,有的叫 Node),它的职责是定义 客户端能用什么方式对待这棵树上的任意一个节点 。注意这句话的重心是“客户端”,不是“节点自己”。
这意味着接口里该放什么,取决于调用方需要什么统一操作。菜单场景里,客户端需要的是“渲染自己”和“告诉我你的权限标识”,那就放这两个。定价场景里,客户端需要“算原价”“算折后价”“把清单打印出来”,那就放这三个。
我在项目里见过一种写法,把所有可能用到的操作一股脑塞进 Component:
getName()
、
getUrl()
、
getIcon()
、
getSort()
、
getParentId()
、
addChild()
、
removeChild()
、
isLeaf()
、
getDepth()
……结果 Leaf 实现类里一半方法是
return null
或者
throw new UnsupportedOperationException()
。这种接口看着“统一”,实际是把容器的能力和叶子的能力搅在一起,反而不统一了。
判断标准很简单:
这个方法,叶子节点和容器节点是不是都能给出一个有意义、说得通的实现?
getName()
能,叶子有名字,容器也有名字。
getUrl()
不能,一个分组标题哪来的 URL,那它就不该出现在公共接口里,应该下沉到叶子特有的接口或者用
Optional
处理。
接口不是越全越好。每多一个方法,每个实现类就多一份负担,而且这份负担会随着实现类数量线性放大。
2.2 叶子节点和容器节点:一个只管自己,一个只管转发
职责边界这块,我的经验是两条铁律。
叶子节点不持有子节点列表,也不假装自己能装东西。
叶子就是叶子,它的所有方法都只围绕自己那点数据展开。
originalPrice()
直接返回自己的价格,
print()
直接打印自己这一行,没有任何循环。
容器节点不关心孩子具体是什么类型。
容器的
finalPrice()
实现长这样:把所有孩子的
finalPrice()
加起来,再乘上自己的折扣。它不知道孩子里哪些是单品、哪些是子套餐,它只知道“你们都能算出折后价,那我把结果收上来就行”。这就是多态带来的好处,容器完全不需要
instanceof
。
容器里唯一需要额外注意的是 聚合的顺序和时机 。有的业务要求先算孩子再算自己(后序),有的要求先处理自己再往下(前序)。这个区别在纯计算的场景里不明显,但在带副作用的场景里(比如权限继承、折扣叠加)会直接导致结果不同。后面第 5 章会专门说这个坑。
还有一个细节:容器的
add
方法要不要做参数校验?要,而且要做的不只是空校验。加
null
进去,后面遍历的时候才 NPE,排查成本很高;把自己加到自己身上,直接就是个死循环;把祖先节点加进来,树就变成环了。这三个校验我在第 3 章给了具体代码,成本很低但能挡住绝大部分低级事故。
2.3 透明式和安全式:
add
到底该定义在哪一层
这是组合模式绕不开的一个取舍,也是面试里最常被问的一点。两种做法的分歧就在一个问题:
addChild
、
removeChild
这两个方法,定义在 Component 上,还是只定义在 Composite 上?
透明式
:定义在 Component 上。好处是客户端拿到的引用完全统一,不管拿的是叶子还是容器,都能调
add
,编译器不报错。坏处是叶子调用
add
没有意义,只能在实现里空着或者抛异常,属于运行时才暴露的问题。
安全式
:只定义在 Composite 上。好处是类型安全,叶子根本没有这个方法,编译器帮你挡住误用。坏处是客户端在需要加子节点的时候,必须先判断“这个引用能不能加”,
if (node instanceof Bundle)
又回来了。
| 对比维度 | 透明式 | 安全式 |
|---|---|---|
add
/
remove
定义位置
| Component 接口 | 仅 Composite 类 |
| 客户端使用体验 | 完全统一,零判断 | 需要类型判断或强转 |
| 误用成本 | 运行时异常,可能上线才炸 | 编译期报错,改不动就得改代码 |
| 接口干净程度 | 接口有“叶子用不上”的方法 | 接口只保留通用能力 |
| 适合的场景 | 节点类型少、容器操作简单、客户端代码量大 | 容器操作复杂、团队新人多、误用代价高 |
我个人的选择倾向是
默认安全式,特殊情况下用透明式
。理由是:
add
这个操作的调用点通常不多,一般集中在构建树的那一段代码里,多写一次类型判断的成本很低;而
operation
这类方法的调用点是大量的、分散的,才是真正需要“统一”的地方。用一个不常用操作的便利性,去换整个接口的干净程度,不划算。
不过有个例外:如果你在做的东西本身就是给外部用的 SDK,调用方根本不知道你的节点分几种,那透明式更友好,把复杂度留在内部,对外就一个
add
,谁都别想那么多。
3. 手写一版能跑的组合模式:套餐定价与清单打印
光说原理没意思,拿一个带业务味道的例子走一遍。我不用烂大街的文件系统,换一个更贴近电商的场景: 商品套餐定价 。
需求是这样:平台上有单品(比如一瓶水、一包纸巾),也有套餐。套餐里可以放单品,也可以放另一个套餐,嵌套层数不限。每个单品有自己的原价和折扣率,每个套餐有自己的整体折扣(在子项折后价之和的基础上再打折)。现在要能算出任意一个套餐的原价总额、最终价,还要能把整份清单按层级打印出来。
3.1 抽出公共接口:客户端只认这一套
先定接口。客户端需要四件事:知道名字、算原价、算最终价、打印自己。这四个操作,单品和套餐都能给出有意义的实现,所以都放进去。
import java.math.BigDecimal;
public interface PricingNode {
String getName();
/** 原价合计 */
BigDecimal originalPrice();
/** 最终成交价 */
BigDecimal finalPrice();
/** 按层级打印清单 */
void print(String indent);
}
注意这里没有
add
、没有
getChildren
。这就是安全式的选择——容器特有的能力不往公共接口上塞。
单品实现起来很直接,它没有任何循环:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class SingleItem implements PricingNode {
private final String name;
private final BigDecimal price;
private final BigDecimal discountRate; // 0.90 表示九折
public SingleItem(String name, BigDecimal price, BigDecimal discountRate) {
this.name = name;
this.price = price;
this.discountRate = discountRate;
}
@Override
public String getName() { return name; }
@Override
public BigDecimal originalPrice() { return price; }
@Override
public BigDecimal finalPrice() {
return price.multiply(discountRate).setScale(2, RoundingMode.HALF_UP);
}
@Override
public void print(String indent) {
System.out.println(indent + "- " + name
+ " | 原价 " + originalPrice()
+ " | 折后 " + finalPrice());
}
}
这里有个容易忽略的点:
setScale(2, RoundingMode.HALF_UP)
必须加。金额计算如果不做精度控制,
BigDecimal
会给你吐出
19.90000000000000000000001
这种东西,累加多轮之后误差会放大。而且舍入必须统一在叶子层做,如果叶子不舍入、容器层才舍入,那不同层级的舍入结果会不一致,对账的时候能让人崩溃。
3.2 容器节点:聚合逻辑 + 三个防御性校验
套餐的实现是整篇文章的核心。它要维护子节点列表,要递归聚合,还要挡住三种非法添加。
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Bundle implements PricingNode {
private final String name;
private final BigDecimal bundleDiscount; // 套餐整体折扣
private final List<PricingNode> children = new ArrayList<>();
public Bundle(String name, BigDecimal bundleDiscount) {
this.name = name;
this.bundleDiscount = bundleDiscount;
}
public Bundle add(PricingNode node) {
if (node == null) {
throw new IllegalArgumentException("不能添加 null 节点");
}
if (node == this) {
throw new IllegalArgumentException("不能把套餐添加到自己身上");
}
// 如果待加入节点的子树里包含了当前套餐,加入后就成环了
if (containsNode(node, this)) {
throw new IllegalArgumentException("检测到循环引用:" + node.getName());
}
children.add(node);
return this;
}
public Bundle remove(PricingNode node) {
children.remove(node);
return this;
}
public List<PricingNode> getChildren() {
return Collections.unmodifiableList(children);
}
private boolean containsNode(PricingNode root, PricingNode target) {
if (root == target) {
return true;
}
if (root instanceof Bundle) {
for (PricingNode child : ((Bundle) root).children) {
if (containsNode(child, target)) {
return true;
}
}
}
return false;
}
@Override
public String getName() { return name; }
@Override
public BigDecimal originalPrice() {
BigDecimal sum = BigDecimal.ZERO;
for (PricingNode child : children) {
sum = sum.add(child.originalPrice());
}
return sum.setScale(2, RoundingMode.HALF_UP);
}
@Override
public BigDecimal finalPrice() {
BigDecimal sum = BigDecimal.ZERO;
for (PricingNode child : children) {
sum = sum.add(child.finalPrice());
}
return sum.multiply(bundleDiscount).setScale(2, RoundingMode.HALF_UP);
}
@Override
public void print(String indent) {
System.out.println(indent + "= " + name
+ " | 原价 " + originalPrice()
+ " | 折后 " + finalPrice());
for (PricingNode child : children) {
child.print(indent + " ");
}
}
}
三个校验值得单独说说。
node == this
这个判断挡住的是最直白的一种错误,构建树的时候手滑把当前对象传进去了。没有这个判断的话,
originalPrice()
会直接无限递归,栈溢出,而且堆栈信息里看不出问题在哪,因为每一层的调用长得一模一样。
containsNode(node, this)
挡的是更隐蔽的一种:A 加 B,然后 B 加 A。单看每一句代码都合法,组合起来就成环了。这个校验的复杂度是 O(n),相对于构建树的频次来说完全可接受,但如果是热路径上高频 add,就得考虑加个祖先链缓存了。
node == null
的校验容易被当成废话,但我在线上真的见过空指针堆在某个深层递归里,日志只有一行 NPE,完全看不出是哪一层加进去的空对象,最后靠二分注释代码才定位到。加一行判断,省两天排查。
3.3 客户端代码:那个
if
消失了
现在看看调用方长什么样。
public class Demo {
public static void main(String[] args) {
Bundle springFestival = new Bundle("春节礼盒", new BigDecimal("0.95"));
springFestival.add(new SingleItem("坚果 500g", new BigDecimal("59.90"), new BigDecimal("0.88")));
springFestival.add(new SingleItem("茶叶礼罐", new BigDecimal("128.00"), new BigDecimal("0.90")));
Bundle dailyPack = new Bundle("日常洗护小包", new BigDecimal("0.92"));
dailyPack.add(new SingleItem("洗手液", new BigDecimal("19.90"), new BigDecimal("0.95")));
dailyPack.add(new SingleItem("纸巾两包", new BigDecimal("12.50"), new BigDecimal("1.00")));
springFestival.add(dailyPack);
PricingNode root = springFestival;
root.print("");
System.out.println("整单原价合计:" + root.originalPrice());
System.out.println("整单最终价:" + root.finalPrice());
}
}
注意
PricingNode root = springFestival;
这一行,变量类型是接口,不是
Bundle
。这就是组合模式的价值所在——客户端后续所有的
print
、
originalPrice
、
finalPrice
调用,都不需要知道 root 底下是一棵多深的树。
如果这时候业务方跑来说,要加一种“赠品”,赠品原价是 0 但需要显示出来,怎么办?新写一个
GiftItem implements PricingNode
,
originalPrice()
返回 0,
println
的时候带上“赠品”标记,然后
springFestival.add(new GiftItem(...))
就完了。
Bundle
一行不用改,
Demo
一行不用改。
这就是我前面讲的“变化点从调用方挪到了实现方”。你付出的代价是多写一个类,换来的是所有调用点的稳定性。
3.4 客户端不想写递归?给个扁平化遍历
有个现实问题:
print
方法里用了递归,客户端如果想对每个节点做点别的事情(比如统计 SKU 数量、检查有没有下架商品),难道每个操作都写一遍递归?
不用。可以提供一个扁平化方法,把树拍平成列表,客户端拿着列表随便遍历,一次都不用写递归。
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public final class PricingNodes {
private PricingNodes() {}
/** 深度优先前序遍历,用显式栈代替递归,避免深层树栈溢出 */
public static List<PricingNode> flatten(PricingNode root) {
List<PricingNode> result = new ArrayList<>();
Deque<PricingNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
PricingNode current = stack.pop();
result.add(current);
if (current instanceof Bundle) {
List<PricingNode> kids = ((Bundle) current).getChildren();
// 逆序入栈,保证出栈顺序和声明顺序一致
for (int i = kids.size() - 1; i >= 0; i--) {
stack.push(kids.get(i));
}
}
}
return result;
}
public static long countLeaf(PricingNode root) {
return flatten(root).stream()
.filter(n -> !(n instanceof Bundle))
.count();
}
}
这里有个小陷阱:
instanceof
又出现了。有人会觉得这不就打脸了吗,前面说组合模式不该有类型判断。其实不是。
组合模式消灭的是“业务逻辑里的类型判断”,不是消灭所有
instanceof
。
像扁平化这种结构性操作,本质上就是在区分容器和叶子,这种地方出现
instanceof
是合理的,而且被收敛在一个工具类里,只此一处,改起来也方便。
另外那个逆序入栈的写法,第一次看容易懵。因为栈是后进先出,正序 push 进去,弹出来就是反的,子节点的打印顺序会颠倒。逆着遍历再 push,出栈就是正序。这个细节在需要保序的场景(比如按排序字段输出清单)里必须注意,否则测试会告诉你“顺序不对”但看不出是哪里的问题。
4. 那些不叫组合模式、但天天在用的组合结构
4.1 前端那棵 DOM 树,就是教科书级的透明式实现
做前端的可能没读过组合模式这四个字,但一定天天在用它。文档对象模型里,一个元素节点可以有子节点,一个文本节点不能有子节点,可它们继承了同一套节点接口,都有
parentNode
、
childNodes
、
appendChild
、
removeChild
、
cloneNode
。
当你写
el.appendChild(child)
的时候,
el
一定是容器吗?不一定,接口层面是统一的,运行时如果是文本节点就会报错。这正是透明式组合模式的典型特征:接口统一,运行时校验。而且这个设计是有道理的——前端世界里新增节点类型的频率太高了,如果每个操作都要先判断类型,业务代码会很啰嗦。
组件树同理。一个页面组件下面挂着布局组件,布局组件下面挂着列表组件,列表组件下面挂着每一项。整个渲染过程就是一次深度优先遍历,每个组件自己知道怎么处理自己,父组件不需要知道子组件是函数式还是类式,只需要知道“你能渲染出东西来”。这套思路和前面套餐定价的
print
完全是一回事。
4.2 桌面 UI 库里的 Container 与 Component
早期 Java 桌面开发的类库里,
Container
继承自
Component
,同时又能
add(Component)
。这个继承关系本身就是组合模式的形态:容器是一种特殊的组件,它除了具备组件的通用能力(绘制、事件响应),还能装别的组件(包括别的容器)。
这个设计把“透明式”发挥到了极致——你拿到一个
Component
引用,可以直接
add
进去,编译器不拦你,至于它到底能不能装,运行时才知道。这种设计在 UI 库这种场景里是合适的,因为 UI 构建代码量巨大,每加一个控件都要判断类型的话,代码会变得没法看。
顺着这个思路往下想,你会发现很多“树形配置”的东西都是同一个套路:工作流引擎里的节点(开始节点、审批节点、分支网关节点、结束节点),低代码平台里的页面组件(容器组件、表单组件、展示组件),甚至游戏里的场景图(Scene Graph,父节点变换会作用到所有子节点)。判定的标准只有一个: 是不是存在“容器”和“元素”两类东西,且它们需要被同一套逻辑处理。
4.3 表达式树:组合模式最优雅的落地形态
如果要我挑一个组合模式用得最舒服的场景,我会选表达式树。
想想一个查询条件:
(状态 = 已支付 AND 金额 > 100) OR (会员等级 >= 3 AND 城市 IN (...))
。这个结构天然是树:AND 节点和 OR 节点是容器,具体的比较条件是叶子。求值的时候,容器的逻辑极其简单——AND 节点把孩子的布尔结果取与,OR 节点取或,NOT 节点取反。叶子节点各自去取数据做比较。
它的优雅之处在于, 加一种新的逻辑连接符(比如 XOR)只需要新写一个容器类,加一种新的比较操作(比如模糊匹配)只需要新写一个叶子类,求值引擎的入口代码永远是一行 :
public interface Condition {
boolean evaluate(Context ctx);
}
public class AndCondition implements Condition {
private final List<Condition> children = new ArrayList<>();
public AndCondition add(Condition c) {
children.add(c);
return this;
}
@Override
public boolean evaluate(Context ctx) {
for (Condition c : children) {
// 短路:任意一个为 false 直接返回,后面的子树根本不用求值
if (!c.evaluate(ctx)) {
return false;
}
}
return true;
}
}
注意
evaluate
里那个短路返回。这个优化在组合模式里特别自然——容器知道自己孩子的语义,AND 遇到 false 就可以停,OR 遇到 true 就可以停。如果换成扁平化的列表遍历,这种短路能力就丢失了。这也是组合模式相比“把树拍平再处理”的一个隐藏优势:
结构信息保留了,就能做剪枝
。
我在一个规则校验的项目里吃过这个亏。一开始图省事,把所有规则拍平成列表逐个跑,结果某些规则依赖前面规则的结果,拍平之后顺序一变就全乱了。后来改回树形结构,用组合模式重新组织,AND 组内的规则可以并行、可以短路,OR 组内必须串行,这些语义全部由结构本身表达了,代码反而更短了。
5. 组合模式最容易翻车的四个地方
5.1 递归深度:树不深的时候没问题,深了就来不及了
组合模式天然带递归。写的时候觉得“层级不会太深”,上线之后发现有个用户把目录嵌套了三百层,或者某个分类被批量导入工具搞出了一个几千节点的链。递归一旦爆栈,抛出来的是
StackOverflowError
,这个异常的可怕之处在于:它没法被正常 catch 住恢复,而且堆栈打印出来几百行全是同一个方法,日志系统直接给你截断。
我遇到过一次,某个树的深度到了两千多层,
print
方法直接挂了。排查的时候完全看不出是数据问题还是代码问题,最后是写了个脚本统计最大深度才定位到。
解决办法有两个。
一是把递归改成显式栈
,就像 3.4 节的
flatten
那样,用
Deque
自己维护遍历状态,堆内存代替栈内存,深度基本不受限。
二是在入口处做深度限制
,超过某个阈值(比如 64 层)直接报错,把问题挡在数据入口而不是运行到一半。两个办法不冲突,我通常都做:遍历用迭代,同时加个深度校验兜底。
改写成迭代的时候有个细节要注意: 后序遍历(先处理孩子再处理自己)改成迭代会比较绕 ,因为你需要记录“孩子处理完了没有”。常见做法是每个节点入栈两次,第二次出栈时才执行自己的逻辑。这个技巧不难但容易写错,写完一定要拿一棵三层的小树对一下输出顺序。
5.2 父子双向引用:方便是方便,内存泄漏也是真泄漏
很多实现为了让子节点能往上找父节点,会在容器
add
的时候顺手设置
child.setParent(this)
。这样查找祖先很方便,但埋了两个雷。
第一个雷是
内存泄漏
。如果父节点被回收了,子节点还持有父节点的强引用,那父节点就回收不掉;反过来如果子节点被移除了但父引用没清,父节点会一直持有整棵子树。在长生命周期的缓存里(比如把菜单树缓存起来),这个问题会慢慢累积。解决办法是用弱引用持有父节点,或者在
remove
的时候显式把
parent
置空。
第二个雷是
序列化爆炸
。父子互相引用,用 JSON 序列化的时候直接无限递归,报
StackOverflowError
或者循环引用异常。带父子指针的树形结构,序列化时必须在某一侧加忽略注解,或者手动转成 DTO 再输出。
我的做法是:
能不给父指针就不给
。需要往上查找的场景,交给外部的
Map<child, parent>
索引去维护,把关系存在树外面,而不是让节点自己互相纠缠。这样节点本身是干净的、可序列化的、不会被回收问题困扰的,查找能力也没丢。
5.3 循环引用:树变成环,递归永不结束
第 3 章里
add
的第三个校验就是干这个的。为什么单独拎出来说?因为环的形成往往是
间接的、跨越多层调用
的,单看任何一处代码都合理。
比如某个运营后台支持“把 A 分类移动到 B 分类下面”,接口实现是
b.add(a)
。如果运营手滑把 B 拖到了 A 的子孙节点上,那 A 的子树里就包含了 B,此时
b.add(a)
就成环了。表现出来就是页面转圈圈,后端 CPU 打满,日志里啥也没有。
除了
add
时的祖先校验,还有一个兜底手段:
遍历时记录已访问集合
。
public static List<PricingNode> flattenSafe(PricingNode root) {
List<PricingNode> result = new ArrayList<>();
Set<PricingNode> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Deque<PricingNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
PricingNode cur = stack.pop();
if (!visited.add(cur)) {
// 已经访问过,说明有环,直接跳过或抛异常
continue;
}
result.add(cur);
if (cur instanceof Bundle) {
for (PricingNode child : ((Bundle) cur).getChildren()) {
stack.push(child);
}
}
}
return result;
}
注意这里用的是
IdentityHashMap
而不是普通的
HashSet
。因为节点类通常没有重写
equals
和
hashCode
,用普通集合比较的是引用,行为是一致的;但如果哪天有人为了业务需要重写了
equals
(比如按 ID 判等),两个不同节点可能被判成同一个,环检测就误报了。用身份比较更稳妥。
5.4 遍历顺序与缓存:父节点算出来的结果,孩子改了它不知道
这是组合模式在 有状态场景 下最隐蔽的坑。单纯的计算型用法(每次调用都重新递归算一遍)没有这个问题,但一旦为了性能加了缓存,问题就来了。
典型写法:
Bundle.finalPrice()
第一次算完存进
cachedPrice
字段,下次直接返回。结果运营改了某个单品的价格,父套餐的价格还是旧的。这种 bug 在测试环境很难复现,因为测试数据一般是构建完就查,不会改完再查,只有线上真实操作才会触发。
解决办法有两条路。
一是脏标记向上传播
:子节点变化时,沿着父引用一路把祖先的缓存标记为失效。这需要父指针,也就意味着要接受 5.2 节说的问题。
二是不缓存
,或者把缓存放在树外面的一个
Map
里,由调用方在数据变更时主动清空。我更倾向第二条,因为组合模式的树通常节点数量有限(几百到几千),每次重算的成本完全可以接受,为了省这点计算引入一致性风险不划算。
如果确实要缓存,那至少要保证缓存的粒度是“一次请求内”。把树的计算结果放进请求上下文,请求结束就丢弃,不要跨请求持有。这样即使数据变了,下一个请求拿到的也是新结果。
另外遍历顺序本身也会影响聚合结果。前面提到的“先算孩子再算自己”和“先算自己再算孩子”,在纯加法场景下没区别,但在带非交换操作(比如折扣叠加、权限继承、继承属性覆盖)的场景下结果完全不同。我的建议是: 在容器的聚合方法里显式写明顺序,并且用注释说明为什么是这个顺序 ,别让后来人靠猜。
6. 组合模式和它的几个近亲,到底怎么区分
6.1 装饰器和组合:结构像,意图完全不同
这两个模式在代码结构上高度相似——都是“实现同一个接口,同时持有一个同类型引用”。很多人第一次看类图会搞混。
区别在于 持有的数量和目的 。装饰器持有 一个 被装饰对象,目的是在它的行为前后加点什么(加日志、加缓存、加权限校验),它是一条链,不是一棵树。组合持有 一组 子对象,目的是表达层次结构,它是一棵树。
从调用链看更清楚:装饰器的调用是线性的,A 包 B,B 包 C,请求一层层往里传,每层都能拦截和修改;组合的调用是发散的,一个容器的
render
会触发所有孩子的
render
,孩子之间互不影响,也没有“传给下一个”的概念。
还有个直观的判据:装饰器模式下,你通常
不会
对同一个对象重复调用
add
来堆功能;组合模式下,
add
是核心操作,加得越多树越大。
6.2 组合加访问者:结构稳定、操作爆炸时的解药
组合模式有个天然的软肋:
每加一种新操作,就要改一遍接口和所有实现类
。前面说加一种节点类型很方便,但如果是加一种“操作”呢?比如原来只有
print
,现在要加
exportExcel
、
renderToJson
、
checkIntegrity
、
collectMetrics
,那
PricingNode
接口就要膨胀四次,每个实现类都要补四个方法。如果实现类有十几个,这就是几十处改动。
访问者模式就是为这个场景准备的。它把“操作”从节点类里抽出来,变成一个独立的访问者对象,节点只保留一个
accept(Visitor v)
方法,具体做什么由访问者决定。
public interface NodeVisitor<R> {
R visitItem(SingleItem item);
R visitBundle(Bundle bundle);
}
public interface PricingNode {
String getName();
<R> R accept(NodeVisitor<R> visitor);
}
这么改完之后,加新操作就是新写一个 Visitor 实现,节点类一行不动。代价是加新节点类型变麻烦了——每个 Visitor 都要补一个方法。所以这是个权衡: 节点类型稳定、操作多变,用访问者;操作稳定、节点类型多边,就老老实实用组合模式自带的虚方法分发。
实际项目里,树的结构往往比操作更稳定(菜单就那么几种,但要在十几个地方用到),所以“组合 + 访问者”的组合出现频率比想象中高。
6.3 组合和责任链:树形流程里的分工
审批流、风控规则链这类场景,经常需要两者配合。整体结构是棵树(有并行分支、有串行节点),但树上的某一条路径又需要按顺序处理。
这时候典型做法是: 用组合模式组织整体结构,用责任链模式处理叶子节点内部的处理顺序 。容器负责“我这几个孩子是并行还是串行”,叶子负责“我这一环具体做什么判断”。别把两个模式的职责混在一起,混着写出来的代码会非常难维护。
我见过一个反例,把整棵树拍成一条长链,每个节点记着“下一个是谁”,结果分支逻辑完全没法表达,最后靠一堆
if
硬编码跳转,改一次需求要动十个地方。这是典型的“用错模式”。
| 场景特征 | 更适合的模式 |
|---|---|
| 单个对象和组合对象需要统一处理 | 组合模式 |
| 需要给对象动态添加职责,且不改变接口 | 装饰器模式 |
| 请求需要沿链传递,直到被某个处理者处理 | 责任链模式 |
| 数据结构稳定,但操作种类频繁增加 | 组合 + 访问者 |
| 需要按顺序遍历一个聚合对象,且不想暴露内部结构 | 组合 + 迭代器 |
7. 我现在判断要不要上组合模式,只问一句话
踩过几次坑之后,我总结出一个特别土但特别准的判断方法:
打开客户端代码,看看有没有出现“你现在是单个还是一堆”这类判断。
如果有,而且这个判断出现在两个以上的地方,那就该上组合模式;如果只出现一次,老老实实写个
if
挺好,别为了模式而模式。
还有一个经验是,组合模式的收益主要体现在 扩展节点类型 这件事上,而不是体现在“代码变短”上。如果你手上的树未来一年都不会加新节点类型,那它带来的收益接近于零,反而多了一层抽象让人读代码多花时间。模式是给变化准备的,没有变化的地方,抽象就是纯粹的债务。
最后分享一个小技巧:写组合模式的时候,先别管接口怎么定,直接把客户端代码写出来——写那个你希望调用方看到的样子。比如我希望调用方只写
root.print()
和
root.finalPrice()
,那接口就该这么定。写完客户端,再倒推接口,比闷头设计接口再去适配调用方要顺手得多。这个顺序反过来做,往往会设计出一堆“看起来通用但没人用”的方法。
本文深入解析组合模式(Composite Pattern)在树形结构中的应用,聚焦菜单权限、组织架构等典型场景。通过对比if-else冗余代码与组合模式的优雅实现,阐明Component、Leaf、Composite三角色职责划分,详解透明式与安全式接口设计取舍。涵盖防御性校验、扁平化遍历、递归深度控制、循环引用规避、缓存一致性等关键实践要点,并辨析其与装饰器、访问者、责任链模式的本质区别。强调该模式适用于节点类型频繁扩展、多处需统一操作树结构的业务场景。
370

被折叠的 条评论
为什么被折叠?



