配套代码: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 设计模式学习指南

上一篇:桥接 · 下一篇:装饰器