当数据天然长成一棵树时,怎么让客户端不去关心"我面前这个节点到底是叶子还是子树"。本文讲清三个角色与那条自聚合的边、组合模式最核心的取舍(透明方式 vs 安全方式)。
一、问题的起点:树形结构,客户端却在做类型判断
组织结构是最典型的一棵树:公司下面有部门,部门下面有小组,小组下面有员工——每一层都可能是"容器",也可能已经是"叶子"。
没有组合模式时,这种结构会让调用方掉进类型判断里:
/** 没有组合时:调用方被迫按类型分支,而且每加一种节点就要改一次 */
void printAll(Object org, String indent) {
if (org instanceof Employee) {
System.out.println(indent + ((Employee) org).getName());
} else if (org instanceof Department) {
Department dept = (Department) org;
System.out.println(indent + dept.getName());
for (Object child : dept.getChildren()) {
printAll(child, indent + " "); // 递归分支也跟着类型走
}
} else {
throw new IllegalArgumentException("未知节点类型");
}
}
问题有三层,而且一层比一层严重:
- 调用方必须知道所有节点类型:每多一种(比如"外包组""虚拟团队"),
if/else就要加一支; - 递归逻辑和类型判断缠在一起:容器要递归、叶子不递归,这个差异被写死在调用方;
- "统一对待"根本无从谈起:你没法把"一个员工"和"整个部门"放进同一个方法里调用——它们没有共同的接口。

二、结构:三个角色,一条「自聚合」的边
组合只有三个角色,但类图里那条自己指向自己的边是关键:

- Component(抽象构件):
OrgUnit。叶子与容器共同对外暴露的接口——人数、薪资合计、打印。客户端只认它。 - Leaf(叶子构件):
Employee。没有子节点,headcount()永远返回 1,salaryFen()返回自己的月薪。 - Composite(容器构件):
Department。既是OrgUnit(继承),又持有一组OrgUnit(聚合)——和装饰者一样是"既实现又持有",但这里持有的是一组,而不是一个。 - 那条自聚合边:
Department o-- "0..*" OrgUnit。它让"部门里可以有子部门"成立,也让递归成为可能——树上任何一层都能当作整棵树来用。
两个容易漏掉的点:
print在抽象类里给了默认实现(只打印自己),容器再覆盖它做递归。这是组合模式里最常见的写法:叶子不需要知道"递归"这件事,容器负责往下走。- 聚合关系的重数写
0..*:空部门是合法的(刚建好还没加人),客户端不应该为"空容器"写特例。
三、手写一个组织架构
下面是可直接运行的单文件版本(Java 8+):
import java.util.ArrayList;
import java.util.List;
/** Component:叶子与容器统一对外暴露的接口 */
abstract class OrgUnit {
protected final String name;
protected OrgUnit(String name) {
this.name = name;
}
/** 人数 */
abstract int headcount();
/** 月薪合计(单位:分) */
abstract long salaryFen();
/** 默认只打印自己;容器覆盖它做递归 */
void print(String indent) {
System.out.println(indent + name);
}
}
/** Leaf:员工,没有子节点 */
class Employee extends OrgUnit {
private final long monthlySalaryFen;
Employee(String name, long monthlySalaryFen) {
super(name);
this.monthlySalaryFen = monthlySalaryFen;
}
@Override
int headcount() {
return 1; // 叶子的"规模"就是它自己
}
@Override
long salaryFen() {
return monthlySalaryFen;
}
@Override
void print(String indent) {
System.out.println(indent + "· " + name + "(" + monthlySalaryFen / 100 + " 元/月)");
}
}
/** Composite:部门——既是 OrgUnit,又持有一组 OrgUnit */
class Department extends OrgUnit {
private final List<OrgUnit> children = new ArrayList<>();
Department(String name) {
super(name);
}
void add(OrgUnit unit) {
children.add(unit);
}
void remove(OrgUnit unit) {
children.remove(unit);
}
@Override
int headcount() {
int total = 0;
for (OrgUnit child : children) {
total += child.headcount(); // 递归:叶子返回 1,子部门继续往下拆
}
return total;
}
@Override
long salaryFen() {
long total = 0L;
for (OrgUnit child : children) {
total += child.salaryFen();
}
return total;
}
@Override
void print(String indent) {
System.out.println(indent + name + "(" + headcount() + " 人)");
for (OrgUnit child : children) {
child.print(indent + " "); // 递归:叶子与容器都只认 print
}
}
}
public class CompositeDemo {
public static void main(String[] args) {
Employee zhang = new Employee("张三", 1200000L); // 12000 元
Employee li = new Employee("李四", 1500000L);
Employee wang = new Employee("王五", 900000L);
Department backend = new Department("后端组");
backend.add(zhang);
backend.add(li);
Department tech = new Department("技术部");
tech.add(backend);
tech.add(wang);
// 客户端统一对待:单个员工和整个部门走同一套接口
System.out.println("技术部人数:" + tech.headcount());
System.out.println("技术部月薪合计:" + tech.salaryFen() / 100 + " 元");
System.out.println();
tech.print("");
System.out.println();
System.out.println("单个员工人数:" + zhang.headcount());
}
}
输出:
技术部人数:3
技术部月薪合计:36000 元
技术部(3 人)
后端组(2 人)
· 张三(12000 元/月)
· 李四(15000 元/月)
· 王五(9000 元/月)
单个员工人数:1
三点值得注意:
- 客户端只写了一行
tech.print(""),却打印出整棵树——因为递归藏在容器里,不在调用方。 tech.headcount()和zhang.headcount()是同一个方法。这就是"统一对待":客户端不需要知道手上拿的是叶子还是整棵树。- 加一层部门不用改任何已有代码:
tech.add(backend)里的backend本身也是Department——容器可以装容器,这正是自聚合边的价值。
四、递归是组合的灵魂
组合模式的所有能力都来自一处:每个容器的操作,都是"对自己 + 对每个孩子"的同一操作的组合。

这段递归有三个性质,值得单独记住:
| 性质 | 含义 | 好处 |
|---|---|---|
| 对称 | 容器的实现对子节点调用的是同一套接口 | 树可以无限深,代码行数不变 |
| 自相似 | 每个子树的算法和整棵树完全一样 | 单元测试只需测一层容器 + 一个叶子 |
| 可折叠 | 深度优先即可得到汇总值 | 人数、金额、耗时这类"汇总型"需求天然适配 |
反过来,如果"汇总"的规则不是简单相加(比如部门人数要去重、跨部门共享的员工只算一次),组合就撑不住了——那不是树的问题,是数据本身不是树。
一个实践提醒:组合的递归是深度优先的,树的深度直接等于调用栈深度。业务上真有超深树(比如几万层的分类树),要么改成显式栈 + 迭代遍历,要么把汇总下推成一次 SQL 的递归查询(CTE)/ 物化结果,别让 headcount() 在深树上把栈撑爆。组合模式擅长的是"结构清楚、层次有限"的树,不是"无限深"的树。
五、透明方式 vs 安全方式
这是组合模式唯一真正需要做取舍的地方:add / remove 到底放在哪一层?
透明方式(放在 Component 里):客户端可以对任何节点无差别调用 add,但叶子必须"假装自己支持",运行时抛异常:
abstract class OrgUnit {
// 默认实现:叶子继承后调用就抛异常
void add(OrgUnit unit) {
throw new UnsupportedOperationException(name + " 是叶子节点,不支持 add");
}
void remove(OrgUnit unit) {
throw new UnsupportedOperationException(name + " 是叶子节点,不支持 remove");
}
}
安全方式(只放在 Composite 里):编译期就调不到,但客户端必须先判断类型:
class Department extends OrgUnit {
void add(OrgUnit unit) { children.add(unit); }
void remove(OrgUnit unit) { children.remove(unit); }
}
// 客户端:必须先知道它是 Department
if (node instanceof Department) {
((Department) node).add(new Employee("赵六", 800000L));
}
怎么选:
- 树是固定的、客户端几乎只做读取和汇总 → 透明方式,接口最干净(JDK 的
java.awt.Container就是这种); - 客户端会频繁增删节点、且叶子容器混用 → 安全方式,让编译期帮你把关;
- 折中做法:透明方式 + 容器提供
isComposite()之类的判断方法,让需要时能提前规避。
六、组合 vs 装饰者
这两个是结构型里结构最像的一对——都是"实现 Component + 持有 Component"。差别在持有一个还是一组,以及意图:
| 组合 Composite | 装饰者 Decorator | |
|---|---|---|
| 结构关系 | 实现 Component + 持有 一组 Component | 实现 Component + 持有 一个 Component |
| 树的形状 | 多叉树(自聚合,0..*) | 单链(一层套一层) |
| 意图 | 表示「部分-整体」层次 | 动态叠加职责 |
| 客户端看到 | 一棵树,但当做一个对象用 | 一个对象,但行为被增强了 |
| 典型场景 | 组织架构、菜单、文件系统、UI 容器 | IO 流、加缓存/日志/加密 |
一句话记忆:
- 组合:一个节点下面挂着很多节点,客户端把整棵树当一个对象用;
- 装饰者:一个对象外面套着一个对象,客户端拿到的还是同一个接口的对象。
七、什么时候不要用组合
- 结构本来就是平的:没有层级就别硬套树,只会多出一层无意义的抽象。
- 叶子与容器差异太大、无法统一接口:比如叶子需要"下单"、容器需要"批量审批"——硬塞进一个 Component 会让接口又大又空。
- 汇总规则不是"逐层相加":需要去重、跨节点约束、按不同维度重新分组的,组合模式会逼你在容器里写越来越多的特例。
- 需要严格的类型安全:如果业务上必须区分"这里只能是部门、那里只能是员工",组合模式追求的"统一对待"反而是负担。
八、结构型六个:一张表理清
| 模式 | 一句话意图 | 结构特征 | 典型场景 |
|---|---|---|---|
| 代理 Proxy | 控制对单个对象的访问 | 一对一,接口不变 | 权限、远程调用、延迟加载 |
| 适配器 Adapter | 让接口对不上的双方协作 | 一对一,接口转换 | 接第三方 SDK、老系统兼容 |
| 装饰者 Decorator | 不改源码叠加职责 | 一层套一个,接口不变 | IO 流、加缓存/日志/加密 |
| 桥接 Bridge | 分离两个独立变化的维度 | 两棵树 + 一座桥 | JDBC、跨平台 GUI |
| 外观 Facade | 简化一组子系统的调用 | 多对一,统一入口 | 下单编排、日志门面 |
| 组合 Composite | 表示部分-整体的层次结构 | 一对多自聚合,多叉树 | 组织架构、菜单、文件系统、UI 容器 |
评论