10|组合:让叶子与容器接受同一种操作
配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为
com.hanserwei.patterns.composite。
系列导航:Java 25 设计模式学习指南
博客导航里,栏目可以包含文章,也可以包含子栏目。统计整个知识库有多少篇文章时,调用方若不断判断“这是文章还是栏目”,每增加一种嵌套层次都要重复处理结构细节。
我们希望对根节点调用一次 articleCount,内部自然完成递归。叶子提供基本结果,组合节点聚合子节点,客户端无需关心树的深度。
从问题提炼设计意图
组合模式把对象组织成树形结构,并让单个对象与组合对象实现共同接口,使调用方能够统一操作局部与整体。
对象职责与协作关系
| 示例角色 | 职责 |
|---|---|
ContentNode |
组件接口,声明文章计数操作 |
ArticleNode |
叶子,每个节点贡献一篇文章 |
Section |
组合节点,保存子节点并递归聚合 |
Demo |
只把根节点当作 ContentNode 使用 |
classDiagram
ContentNode <|.. ArticleNode
ContentNode <|.. Section
Section o-- ContentNode : children
Demo ..> ContentNode : counts
代码思路:变化应该落在哪个对象上
ContentNode 只暴露所有节点都能合理回答的 articleCount。没有把 addChild 放进公共接口,因为文章叶子没有合理的“添加子节点”行为。这样避免了叶子实现一个永远抛异常的空壳操作。
ArticleNode 返回 1;Section 逐个调用 child.articleCount 并累计。递归依赖多态而不是 instanceof:到达叶子时自然结束,到达栏目时继续递归,空栏目自然得到零。
栏目构造时用 List.copyOf 保存子节点列表,并且之后没有修改结构的方法。使用本例内置不可变节点从下往上构造,可以避免自己把自己挂成子节点。Math.addExact 则防止计数溢出后悄悄出现负数。
关键实现与独立运行
配套仓库中的包名是 com.hanserwei.patterns.composite,源码目录为 src/main/java/com/hanserwei/patterns/composite/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。
ContentNode.java:
package com.hanserwei.patterns.composite;
/** 叶子和组合节点共享的统计契约. */
public interface ContentNode {
/** 返回该节点代表的文章数量. */
int articleCount();
}
Section.java:
package com.hanserwei.patterns.composite;
import java.util.List;
/** 通过子节点组合形成的不可变栏目. */
public final class Section implements ContentNode {
/** 构造时复制的子节点列表. */
private final List<ContentNode> children;
/** 以已有节点构建栏目,拒绝 null 列表与元素. */
public Section(List<ContentNode> children) {
this.children = List.copyOf(children);
}
/** 递归累计子节点贡献,空栏目返回零. */
@Override
public int articleCount() {
int count = 0;
for (ContentNode child : children) {
count = Math.addExact(count, child.articleCount());
}
return count;
}
}
ArticleNode.java:
package com.hanserwei.patterns.composite;
/** 内容树中代表单篇文章的不可变叶子. */
public final class ArticleNode implements ContentNode {
/** 一篇文章始终贡献一个计数. */
@Override
public int articleCount() {
return 1;
}
}
Demo.java 展示调用方如何装配这些对象:
package com.hanserwei.patterns.composite;
import java.util.List;
/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
/** 禁止实例化演示入口. */
private Demo() {}
/** 运行独立示例;args 为未使用的命令行参数. */
public static void main(String[] args) {
ContentNode root =
new Section(List.of(new ArticleNode(), new Section(List.of(new ArticleNode()))));
System.out.println(root.articleCount());
}
}
在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew:
./gradlew runComposite
./gradlew test --tests 'com.hanserwei.patterns.composite.PatternTest'
示例的业务输出如下,省略 Gradle 自身的任务提示:
2
用测试确认模式的行为
嵌套栏目得到正确总数,空栏目得到零;外部列表清空后既有栏目不变。测试区分了递归正确性和结构封装。
对应测试位于 src/test/java/com/hanserwei/patterns/composite/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。
常见用法
- 菜单、组织结构、文档章节和文件目录等树形模型。
- 图形组件需要统一执行绘制、布局或统计。
- 业务规则可以由局部结果递归组合成整体结果。
适用边界与容易踩的坑
这份结构按节点出现次数计数。如果同一个 ArticleNode 引用在两个栏目出现两次,就贡献两次计数;它不是“按文章业务标识去重”的统计。需要去重时应引入标识和独立查询语义,而不是偷偷修改局部计数含义。
接口是开放的,外部实现仍可能返回错误值或引入循环依赖。若改成可变树,必须明确禁止环、是否允许多父节点,以及移动子树的原子性。模式类图本身不会替你维护这些结构约束。
递归深度受到调用栈限制,特别深的树可以改用显式栈遍历。遍历成本通常随访问节点数增长,不能因为只调用了一次根节点方法就认为它是常数时间。
与相近模式比较
装饰器通常包装一个对象,重点叠加行为;组合节点通常管理多个子对象,重点表达局部与整体。两者都用递归委托,但结构含义和聚合规则不同。
动手练习
新增一个返回文章总字数的操作,并让 ArticleNode 保存字数。随后试着给同一个叶子挂两个父栏目,先写出预期计数,再决定你的模型允许共享节点还是必须保证严格的单父树。
系列导航:Java 25 设计模式学习指南