设计模式修炼之路 ⑨ 组合模式——叶子和容器一视同仁
部分-整体、叶子和容器一视同仁、在 DOM/MyBatis 里找它
目录
上一篇过滤器模式的结尾,And 嵌着 Or 拼出的那棵"条件树"已经提前剧透了一切:条件是树、文件系统是树、组织架构是树、前端组件还是树。今天正主登场:组合模式(Composite Pattern),GoF 23 人名单正牌选手、结构型第四位,有的书也叫它"部分-整体模式(Part-Whole)"。它专门伺候一种结构——树,而它给出的答案简单到让人怀疑:让叶子和整棵树,用同一个接口。
一、什么是组合模式?
先看定义:
将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使客户端对单个对象和组合对象的使用保持一致性。
翻译成人话:叶子和容器实现同一个接口,容器里装的还是这个接口——树就这么长出来了。客户端拿到一个节点,不用关心它是单片叶还是一整棵树,调就完了。
生活类比是文件资源管理器:想知道一个文件夹多大,右键属性,系统自己一层层加到底——你不需要(也不可能)区分"文件的算法"和"文件夹的算法",因为系统早就替你统一了。或者想想公司算人数:CEO 管总监、总监管经理、经理管员工,但"全公司多少人"从来不分"领导怎么算、员工怎么算",就是一路加到底。
它有三个角色:
- Component(组件接口):叶子和容器的公共抽象,本篇里是 getName、getSizeKB、print;
- Leaf(叶子):树的最末端,没有孩子,干活的,本篇里是 File;
- Composite(容器):持有
List<Component>,本篇里是 Folder,它的方法基本都是"把孩子挨个问一遍再汇总"。
精髓在 Composite 的字段类型:容器持有的不是叶子,是组件自己这个类型。孩子可以是叶子,也可以是另一个容器,容器套容器,树形结构是这个类型的自然结果。上一篇里 AndCriteria 装着 Criteria、还能再被别的组合器装走,是同一个把戏——组合模式就是它的"有户口"版本。递归也不用谁特意发明:容器的方法是"把孩子的事问一遍",孩子若是容器,它会继续往下问,一路问到叶子为止。
二、组合模式的优缺点
优点
- 客户端一视同仁:拿到 Component 就调用,没有 instanceof、没有分支——定义里那句"单个对象和组合对象的使用保持一致性",整个模式就为这一句话活着。
- 新增节点类型零成本:哪天要加一种"快捷方式"节点?实现 FileComponent 就能进树,全树所有遍历、统计、打印代码一行不改——开闭原则。
- 树形操作天然递归:算大小、打印结构、查找、序列化……一套递归通吃,而且每种逻辑全树只写一份——对照反面教材的"文件一遍、文件夹一遍",下面看事故现场。
缺点
- 接口不好设计:Component 的方法必须对叶子和容器都有意义。getSize 好说,"打开文件内容"这种叶子独有的行为就尴尬了——硬塞进接口,容器只能抛异常;不塞,客户端又得下转型。接口粒度是这个模式里最见功力的地方。
- 时机敏感:它属于"动工前就该想清楚"的模式。项目半路想把两个既有类硬统一成树,牵动面往往大到不值得。
- 类型安全总有让步:透明式叶子被迫实现无意义方法(错误拖到运行时才炸),安全式客户端想管理孩子得拿到具体容器类型——两种姿势各有代价,第五节专门掰扯。
三、组合模式的应用场景
- AWT / Swing(JDK):教科书级实现。Component 是组件抽象,Button、Label 是叶子,Container 继承 Component 并持有组件列表——Panel 里塞得进 Button,Button 里什么都塞不进,类型系统如实相告。
- DOM(org.w3c.dom):XML/HTML 的文档对象模型。Node 是组件,Element 是容器(可 appendChild),Text、Comment 是叶子。前端世界的前二十年,就活在这棵树里。
- MyBatis 动态 SQL:SqlNode 接口只有一个 apply() 方法;TextSqlNode 是叶子,MixedSqlNode、IfSqlNode、ForEachSqlNode 全是容器——ForEachSqlNode 里套着的还是 SqlNode,动态标签互相嵌套出任意结构,靠的就是这棵树。本系列已经第三次在 MyBatis 里撞见老朋友了。
- Jackson 的 JsonNode:JSON 天生是树。ObjectNode、ArrayNode 是容器,TextNode、IntNode、NullNode 是叶子,readTree 之后一套递归走天下。
- 前端组件树:React/Vue 里组件嵌组件、递归 render,本质就是运行时在组装组合模式——所以前端工程师其实天天在写"JSX 版组合模式"。
- 后台系统里的各种树:菜单树、部门树、权限树、评论盖楼、商品类目——前端那个 Tree 组件渲染的每一份数据,后端十有八九在给组合模式供货。
什么时候该用:需求里出现"层级""树""容器套容器"这样的字眼,或者你发现自己在写"递归 + instanceof"——就该想起它。反过来,对象之间没有层级关系时,别为了套模式硬造一棵树。
四、组合模式的实现方式
示例代码在 design-pattern 项目的 composite 包里:
design-pattern/src/main/java
└── composite // 组合模式
├── BadFile.java // 反面教材:文件类(与 BadFolder 无公共抽象)
├── BadFolder.java // 反面教材:文件、子文件夹两个列表
├── FileComponent.java // 组件接口:叶子和容器的公共抽象
├── File.java // 叶子:文件
├── Folder.java // 容器:文件夹
└── CompositeTest.java // 测试入口
场景:文件系统。文件有大小,文件夹由内容组成,要打印目录树、算总大小。先看文件、文件夹"两套体系"的下场,再看组合模式怎么救。
1. 反面教材:两套体系
最直觉的建模:文件一个类、文件夹一个类,各管各的。文件夹为了同时装下文件和子文件夹,开了两个列表:
// 反面教材:文件一个列表、子文件夹另一个列表,每个操作都得遍历两遍
public class BadFolder {
private final String name;
private final List<BadFile> files = new ArrayList<>();
private final List<BadFolder> subFolders = new ArrayList<>();
// 算总大小:先遍历文件再遍历子文件夹,递归还得自己写
public long totalSizeKB() {
long total = 0;
for (BadFile file : files) {
total += file.getSizeKB();
}
for (BadFolder folder : subFolders) {
total += folder.totalSizeKB();
}
return total;
}
// 打印目录树:又是两遍遍历,结构和上面几乎重复
public void print(String indent) {
System.out.println(indent + name + "/");
for (BadFile file : files) {
System.out.println(indent + " " + file.getName() + "(" + file.getSizeKB() + "KB)");
}
for (BadFolder folder : subFolders) {
folder.print(indent + " ");
}
}
}
(构造器、getter、addFile/addFolder 三个简单方法略,完整代码见项目。)
测试代码,末尾专门演示"想统一处理"时的下场:
BadFolder docs = new BadFolder("docs");
docs.addFile(new BadFile("简历.pdf", 512));
BadFolder projects = new BadFolder("projects");
projects.addFile(new BadFile("pom.xml", 8));
projects.addFile(new BadFile("App.java", 16));
docs.addFolder(projects);
docs.print("");
System.out.println("docs 总大小:" + docs.totalSizeKB() + "KB");
// 想把文件和文件夹放进同一个列表统一处理?没有公共父类型,只能 instanceof 硬判
List<Object> mixed = new ArrayList<>();
mixed.add(new BadFile("临时文件.txt", 1));
mixed.add(new BadFolder("新建文件夹"));
for (Object o : mixed) {
if (o instanceof BadFile f) {
System.out.println("这是个文件:" + f.getName());
} else if (o instanceof BadFolder f) {
System.out.println("这是个文件夹:" + f.getName());
}
}
运行结果:
===== 反面教材:文件、文件夹两套体系 =====
docs/
简历.pdf(512KB)
projects/
pom.xml(8KB)
App.java(16KB)
docs 总大小:536KB
这是个文件:临时文件.txt
这是个文件夹:新建文件夹
能跑,但三个雷已经埋下:
- 每个操作都得"两遍遍历":算总大小,文件列表一遍、子文件夹一遍再递归;打印目录树,又是同样的两遍——两个方法结构几乎是复制粘贴。再要加个"统计文件个数"?还是两遍。
- 没法统一处理:BadFile 和 BadFolder 之间没有任何公共抽象,想把它们放进同一个列表统一处理,只能 List 加 instanceof 硬判——每来一种新成员,所有硬判处全部加分支。
- 加新成员全链路遭殃:哪天要支持"快捷方式":BadFolder 再加一个
List<Shortcut>,totalSizeKB、print 以及一切遍历过这个结构的方法,挨个改一遍。 - 反面教材"两遍遍历"的 totalSizeKB 和 print,正解里只剩一份"遍历孩子"——孩子是文件还是文件夹,一个 for 通吃,两种代码全树各只有一份;
- 文件的 getSizeKB 是"自己",文件夹的是"孩子的和"——同一个方法名,"部分"和"整体"各自给出自己的答案,这就是定义里"部分-整体"的字面意思;
List<FileComponent>里文件和文件夹混装,一个循环统一处理,instanceof 退场,add 的时候也没有任何转型;- root.print("") 一行,整棵树递归打印出来,缩进都是各节点自己传下去的;
- 哪天加"快捷方式":写一个 Shortcut 实现 FileComponent 就能进树,BadFolder 那种"新列表 + 全链路改"的事故不再发生——开闭原则。
病根只有一个:文件和文件夹被建成了两个没有血缘关系的类型。
2. 组合模式版:一个接口,一棵树
先立公共抽象,这是全模式的定海神针:
// 组合模式的核心:叶子和容器的公共接口,客户端只认它
public interface FileComponent {
String getName();
long getSizeKB();
// indent 参数只是为了打印出树形缩进,不是模式的一部分
void print(String indent);
}
叶子——文件,大小就是自己:
// 叶子节点:文件,大小就是自己
public class File implements FileComponent {
private final String name;
private final long sizeKB;
public File(String name, long sizeKB) {
this.name = name;
this.sizeKB = sizeKB;
}
@Override
public long getSizeKB() {
return sizeKB;
}
@Override
public void print(String indent) {
System.out.println(indent + name + "(" + sizeKB + "KB)");
}
}
(getName 就是返回 name,略。)
重头戏是容器——文件夹。注意它的孩子列表是什么类型:
// 容器节点:文件夹,把孩子的大小加起来就是自己——递归由此而来
public class Folder implements FileComponent {
private final String name;
private final List<FileComponent> children = new ArrayList<>();
// 安全式:管理孩子的方法只放在容器里,叶子压根没这方法;为什么放这里,第五节专门说
public Folder add(FileComponent component) {
children.add(component);
return this;
}
@Override
public long getSizeKB() {
long total = 0;
for (FileComponent child : children) {
// 孩子可能是文件也可能是文件夹,一律递归
total += child.getSizeKB();
}
return total;
}
@Override
public void print(String indent) {
System.out.println(indent + name + "/(共 " + getSizeKB() + "KB)");
for (FileComponent child : children) {
// 不用关心孩子是什么类型,转发就行
child.print(indent + " ");
}
}
}
List<FileComponent>——容器装的是组件自己这个类型,文件、文件夹、文件夹套文件夹,全都合法。建一棵三层目录树,然后玩本篇的核心戏法:把叶子和整棵树扔进同一个列表:
private static FileComponent buildTree() {
Folder projects = new Folder("projects");
projects.add(new File("pom.xml", 8));
Folder src = new Folder("src");
src.add(new File("App.java", 16));
src.add(new File("Config.java", 4));
Folder test = new Folder("test");
test.add(new File("AppTest.java", 2));
src.add(test);
projects.add(src);
projects.add(new File("README.md", 2));
return projects;
}
private static void uniform() {
List<FileComponent> mixed = new ArrayList<>();
mixed.add(new File("临时文件.txt", 1));
mixed.add(buildTree());
for (FileComponent component : mixed) {
System.out.println(component.getName() + " → " + component.getSizeKB() + "KB");
}
}
运行结果:
===== 正解:组合模式 =====
projects/(共 32KB)
pom.xml(8KB)
src/(共 22KB)
App.java(16KB)
Config.java(4KB)
test/(共 2KB)
AppTest.java(2KB)
README.md(2KB)
整棵树总大小:32KB
===== 统一处理:叶子和容器一视同仁 =====
临时文件.txt → 1KB
projects → 32KB
对照反面教材逐条验收:
五、透明式 vs 安全式:GoF 留下的经典取舍
有个细节不知道你注意到没有:add/remove 为什么放在 Folder 里,而不上提到 FileComponent 接口?这不是随手一放,是 GoF 原书里就摆上台面的抉择。
透明式(GoF 原版):管理孩子的方法直接进接口,叶子和容器都实现,客户端彻底不用区分手里是什么:
// 透明式:管理孩子的方法上提到接口,叶子被迫"假装"能生孩子
public interface FileComponent {
void add(FileComponent component);
void remove(FileComponent component);
long getSizeKB();
}
public class File implements FileComponent {
@Override
public void add(FileComponent component) {
throw new UnsupportedOperationException("文件没有孩子");
}
// remove 同理抛异常,其余方法照旧
}
安全式(本篇示例):管理孩子的方法只放在 Composite 身上,叶子压根没有这方法。
| 透明式(GoF 原版) | 安全式(本篇示例) | |
|---|---|---|
| add/remove 放哪 | Component 接口 | 只在 Composite |
| 叶子怎么交差 | 抛 UnsupportedOperationException | 压根没这方法 |
| 客户端加孩子 | 拿到组件就能加,零转型 | 得拿到具体容器类型 |
| 代价 | 违背单一职责与里氏替换,错误拖到运行时才炸 | "完全透明"打了折扣 |
JDK 两种都选过:AWT 走安全式(add 在 Container 上,Button 上没有这个方法),DOM 走透明式(Node 接口就有 appendChild,往 Text 节点里塞孩子会抛 DOMException)。两家都活得好好的,说明这不是对错题,是口味题。
给个倾向性建议:业务代码默认安全式——让类型系统如实反映"叶子生不了孩子",编译期诚实好过运行期惊吓;而且只要客户端的操作以"读"为主(遍历、统计、渲染),两种方式的体验完全一致,本篇示例的 uniform() 就证明了这点——一个循环处理混合列表,谁也没下转型,也没谁抛异常。
写在最后
回顾这一篇:组合模式把"部分-整体"组织成树——叶子与容器实现同一接口,容器持有组件列表,树形结构是类型的自然结果,递归不需要发明;反面教材"两套体系"两遍遍历、instanceof 硬判、加成员全链路遭殃;AWT、DOM、MyBatis 的 SqlNode、Jackson 的 JsonNode,以及后台系统里的每一棵菜单树部门树,都是它的地盘;安全式与透明式是 GoF 留下的经典取舍,业务代码建议默认安全式。
下一篇 ⑩ 装饰器模式,它是组合模式的近亲:UML 画出来,几乎是"只准养一个孩子的组合模式"。用途上两家分了工——组合管"一对多长成树",装饰器管"一对一层层加"。不改原类动态加功能,Java IO 流里 new BufferedInputStream(new FileInputStream(file)) 那套套娃,正是它的封神之作。