组合模式 (Composite) —— 统一对待整体与部分
前言
在深入探讨组合模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。
1. 模式背景:树形结构的痛点
在软件开发中,我们经常需要处理树形结构的数据,例如:
- 文件系统:文件夹里既可以有文件,也可以有子文件夹。
- 组织架构:总公司下有部门,部门下有员工,也有子部门。
- GUI 容器:Panel 里可以放 Button,也可以放另一个 Panel。
1.1 场景引入:杀毒软件
假设我们要开发一个杀毒软件,它可以扫描:
- 单个文件:直接杀毒。
- 文件夹:先扫描里面的文件,再递归扫描里面的子文件夹。
如果不使用设计模式,客户端代码可能会写成这样:
public void scan(Object node) {
if (node instanceof File) {
((File)node).killVirus();
} else if (node instanceof Folder) {
Folder folder = (Folder)node;
for (Object child : folder.getChildren()) {
scan(child); // 递归
}
}
}问题分析:
- 缺乏一致性:客户端必须区分“文件”和“文件夹”,代码逻辑复杂。
- 扩展性差:如果增加一种新类型(比如“压缩包”),客户端的
if-else逻辑需要修改。
1.2 组合模式的解决方案
组合模式通过定义一个抽象的 Component 接口,让“叶子节点(Leaf)”和“容器节点(Composite)”都去实现它。这样,客户端就可以像对待单个对象一样对待组合对象,彻底消除了区别。
2. 组合模式定义
组合模式 (Composite Pattern):将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
2.1 核心角色
- Component (抽象构件):
- 为组合中的对象声明接口。
- 在适当情况下,实现所有类共有接口的缺省行为。
- 声明管理子构件的接口(
add,remove,getChild)。
- Leaf (叶子构件):
- 在组合中表示叶子节点对象,叶子节点没有子节点。
- 实现 Component 接口中定义的行为。
- Composite (容器构件):
- 表示容器节点对象,容器节点包含子节点。
- 实现 Component 接口中定义的行为(通常是遍历子节点并调用它们的对应方法)。
2.2 两种实现形式:透明 vs 安全
组合模式有两种主要流派,区别在于管理子节点的方法放在哪里。
A. 透明方式 (Transparent)
- 做法:在
Component接口中声明add/remove方法。 - 优点:客户端无需区分 Leaf 和 Composite,完全一致。
- 缺点:Leaf 节点必须实现
add/remove,但它实际上没有子节点,只能抛出异常(Unsafe)。 - 适用:大多数情况下的首选,符合“一致性”原则。
B. 安全方式 (Safe)
- 做法:
Component接口不声明管理子节点的方法,只在Composite类中声明。 - 优点:Leaf 节点不需要实现无意义的方法,编译期安全。
- 缺点:客户端必须区分 Leaf 和 Composite,如果要把 Component 转为 Composite 需要强制类型转换,失去了透明性。
2.3 UML 类图结构 (透明方式)
classDiagram
class Client
class Component {
<<interface>>
+ operation()
+ add(Component c)
+ remove(Component c)
+ getChild(int i)
}
class Leaf {
+ operation()
+ add(Component c)
+ remove(Component c)
+ getChild(int i)
}
class Composite {
- children: List<Component>
+ operation()
+ add(Component c)
+ remove(Component c)
+ getChild(int i)
}
Client --> Component
Composite --|> Component
Leaf --|> Component
Composite o--> Component3. 实战案例:公司组织架构
我们模拟一个公司组织架构,包括总公司、分公司、部门(行政部、IT部),它们都有统一的职责:checkWork (考勤)。
3.1 Step 1: 定义抽象构件 (透明方式)
// Component
public abstract class OrgComponent {
protected String name; // 节点名称
protected String desc; // 描述
public OrgComponent(String name, String desc) {
this.name = name;
this.desc = desc;
}
// 核心业务方法
public abstract void checkWork();
// 管理方法(默认抛异常,Leaf 不需要重写)
public void add(OrgComponent c) {
throw new UnsupportedOperationException("叶子节点不支持添加操作");
}
public void remove(OrgComponent c) {
throw new UnsupportedOperationException("叶子节点不支持删除操作");
}
public OrgComponent getChild(int i) {
throw new UnsupportedOperationException("叶子节点无子节点");
}
}3.2 Step 2: 定义叶子节点 (具体部门)
// Leaf: 员工/具体部门
public class Department extends OrgComponent {
public Department(String name, String desc) {
super(name, desc);
}
@Override
public void checkWork() {
System.out.println(" " + name + " (" + desc + "):考勤完成,全员到岗");
}
}3.3 Step 3: 定义容器节点 (公司)
import java.util.ArrayList;
import java.util.List;
// Composite: 公司(可以包含部门或其他分公司)
public class Company extends OrgComponent {
// 关键点:持有一个 Component 列表
private List<OrgComponent> children = new ArrayList<>();
public Company(String name, String desc) {
super(name, desc);
}
@Override
public void add(OrgComponent c) {
children.add(c);
}
@Override
public void remove(OrgComponent c) {
children.remove(c);
}
@Override
public OrgComponent getChild(int i) {
return children.get(i);
}
@Override
public void checkWork() {
System.out.println(name + " (" + desc + ") 开始考勤汇总:");
// 递归调用子节点的业务方法
for (OrgComponent c : children) {
c.checkWork();
}
}
}3.4 Step 4: 客户端调用
public class Client {
public static void main(String[] args) {
// 1. 组装树形结构
Company headOffice = new Company("北京总公司", "Headquarters");
Department headAdmin = new Department("总公司行政部", "负责后勤");
Department headIT = new Department("总公司IT部", "负责技术");
Company shBranch = new Company("上海分公司", "Branch Office");
Department shSales = new Department("上海销售部", "负责华东区销售");
Department shSupport = new Department("上海客服部", "负责客户服务");
// 组装
shBranch.add(shSales);
shBranch.add(shSupport);
headOffice.add(headAdmin);
headOffice.add(headIT);
headOffice.add(shBranch); // 分公司也是一个节点
// 2. 统一操作
System.out.println("--- 考勤系统启动 ---");
headOffice.checkWork();
}
}输出结果:
--- 考勤系统启动 ---
北京总公司 (Headquarters) 开始考勤汇总:
总公司行政部 (负责后勤):考勤完成,全员到岗
总公司IT部 (负责技术):考勤完成,全员到岗
上海分公司 (Branch Office) 开始考勤汇总:
上海销售部 (负责华东区销售):考勤完成,全员到岗
上海客服部 (负责客户服务):考勤完成,全员到岗4. 源码中的组合模式
4.1 MyBatis 的 SqlNode
MyBatis 强大的动态 SQL 功能(<if>, <where>, <foreach>)就是基于组合模式实现的。
- Component:
org.apache.ibatis.scripting.xmltags.SqlNode接口。javapublic interface SqlNode { boolean apply(DynamicContext context); } - Leaf:
StaticTextSqlNode(静态文本),TextSqlNode(带参数文本)。 - Composite:
MixedSqlNode(持有List<SqlNode>)。 - 其他 Composite:
IfSqlNode,WhereSqlNode,TrimSqlNode等,它们既是 Composite(内部包含子节点),又有特定的业务逻辑。
当 MyBatis 解析 XML 时,会将 SQL 片段组装成一个 SqlNode 树。执行时,只需要调用根节点的 apply 方法,整个 SQL 就会被递归拼装出来。
4.2 Java AWT/Swing
Java 的 GUI 编程是组合模式的典型应用。
- Component:
java.awt.Component(Button, TextField, Label)。 - Composite:
java.awt.Container(Frame, Panel, Dialog)。
Container 继承自 Component,同时又有一个 add(Component c) 方法。当我们调用 container.paint() 时,它会负责绘制自己,并递归调用所有子组件的 paint()。
4.3 JDK 集合
java.util.HashMap 的 putAll(Map m) 方法,虽然不是严格的组合模式结构,但体现了“将一组对象视为一个整体”的思想。更典型的例子是 ArrayList.addAll(Collection c),它允许将一个集合(一组对象)一次性添加,虽然这更多是集合操作而非设计模式,但逻辑上类似。
5. 优缺点与总结
优点
- 高层模块调用简单:客户端不需要关心对象是单个还是组合,调用逻辑统一。
- 节点自由增加:很容易在树中增加新的节点类型(如新增一个“临时工”类型的 Leaf,或“项目组”类型的 Composite),符合开闭原则。
- 简化客户端代码:不需要写复杂的递归逻辑和类型判断。
缺点
- 设计复杂:如果业务逻辑不具有树形结构,强行使用组合模式会增加复杂度。
- 类型限制困难:在透明模式下,无法在编译期限制 Composite 只能添加特定类型的 Leaf(必须在运行时检查)。
- 叶子节点的安全性:透明模式下,叶子节点暴露了
add/remove接口,调用会抛出异常,可能给调用者带来困惑。
适用场景
- 需要表示对象的部分-整体层次结构(树形结构)。
- 希望用户忽略组合对象与单个对象的不同,用户将统一地使用组合结构中的所有对象。
6. 最佳实践 Tips
- 透明 vs 安全:
- 如果你希望客户端完全忽略差异,选透明方式(Leaf 抛异常)。这是最常用的。
- 如果你非常看重类型安全,或者 Leaf 和 Composite 差异巨大,选安全方式。
- 递归性能:组合模式通常涉及递归操作。如果树结构非常深,或者操作非常复杂(如计算整个公司的复杂的奖金系数),要注意性能问题,必要时可以使用缓存(在 Composite 中缓存子节点的计算结果)。
- 父指针:在实际应用中,子节点通常需要访问父节点(例如删除自己时,或者向上冒泡事件)。可以在 Component 中维护一个
parent引用。
7. 实验实操
在 design-patterns-web 项目中,通过文件系统来演示组合模式。 界面展示了一个树状的文件夹结构。你可以点击文件夹展开或折叠内容。当你点击某个文件夹时,系统会利用组合模式的递归特性,自动累加该文件夹下所有子文件和子文件夹的大小,并显示出来。无论是处理单个文件还是包含文件的文件夹,客户端代码都以统一的方式(getSize())进行交互。 截图建议:截取展开了多级目录的文件树,并显示某个文件夹总大小的界面。