组合模式

设计模式笔记:组合模式 (Composite Pattern)

一、一句话概括

将对象组合成树形结构以表示“部分-整体”的层次结构,并使得客户端能够统一地对待单个对象(叶子)和对象组合(容器)

二、为什么需要它 (Why - The Pain Point)

在很多应用场景中,我们需要处理具有层级结构的数据。最经典的例子就是文件系统:一个目录下既可以包含文件(单个对象),也可以包含其他目录(对象组合),而这些子目录又可以继续包含文件和目录,形成一个无限嵌套的树形结构。

痛点:

如果你不使用组合模式,客户端代码在处理这种结构时会变得非常复杂和混乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 痛点:客户端代码需要区分文件和目录,并分别处理
public void display(Object node) {
if (node instanceof File) {
// 处理文件的逻辑
File file = (File) node;
System.out.println("File: " + file.getName());
} else if (node instanceof Directory) {
// 处理目录的逻辑
Directory dir = (Directory) node;
System.out.println("Directory: " + dir.getName());
// 必须遍历子节点,并对每个子节点再次进行类型判断
for (Object child : dir.getChildren()) {
display(child); // 递归调用,但递归的每一步都充满了if-else
}
}
}

这种代码的问题显而易见:

  1. 客户端逻辑复杂:代码中充斥着 instanceof 判断和类型转换,既不美观也容易出错。
  2. 违反开闭原则:如果未来我们想增加一种新的节点类型,比如“符号链接(Symbolic Link)”,那么就必须修改所有包含 if-else 判断的客户端代码。

核心问题:如何让客户端代码忽略“简单对象”和“复杂容器对象”之间的差异,用一种统一的方式来操作整个树形结构?

三、它是什么 (What - The Solution)

组合模式通过定义一个共同的抽象,完美地解决了这个问题。它让“叶子节点”和“容器节点”实现同一个接口,从而在客户端眼中,它们都变成了同一种东西。

核心思想和角色:

  1. **组件 (Component)**:
    • 这是一个接口或抽象类,为组合中的所有对象(包括叶子和容器)定义了一个统一的接口。
    • 它声明了所有节点共有的操作,例如 display(), getSize() 等。
  2. **叶子 (Leaf)**:
    • 表示树中的基本对象,它没有子节点
    • 它实现了 Component 接口,并提供了这些操作的具体实现。例如,一个 File 对象。
  3. **组合 (Composite)**:
    • 表示树中的容器节点,它可以包含子节点(这些子节点本身也是 Component)。
    • 它也实现了 Component 接口。对于接口中的操作,它通常会将请求委托给它的子节点去处理,并可能对子节点返回的结果进行汇总。例如,一个 Directory 对象的 getSize() 就是其所有子节点大小的总和。
    • 它还提供了管理子节点的方法,如 add(), remove(), getChild()

关键设计抉择:透明性 vs. 安全性

在设计 Component 接口时,有一个经典的两难选择:管理子节点的方法(add, remove)应该放在哪里?

  • **透明模式 (Transparency)**:将 add/remove 等方法声明在 Component 接口中。
    • 优点:客户端完全无需区分叶子和容器,可以对任何 Component 对象调用 add 方法,非常“透明”。
    • 缺点:不“安全”。叶子节点本身不能有子节点,所以它必须为空实现或抛出 UnsupportedOperationException。这在某种程度上违反了里氏替换原则。
  • **安全模式 (Safety)**:只在 Composite 类中声明 add/remove 方法。
    • 优点:类型安全。你永远不会对一个叶子对象调用 add 方法。
    • 缺点:不“透明”。客户端必须先判断一个对象是不是 Composite 类型,然后才能调用 add 方法,这又回到了 instanceof 的老路。

在实际应用中,透明模式更为常见,因为它最大化地简化了客户端代码,这也是组合模式的主要目标。

四、怎么做 (How - The Blueprint)

基本步骤(以文件系统为例):

  1. 创建 Component 抽象类/接口 FileSystemComponent,声明通用方法如 display()getSize()。为了透明性,也在这里声明 add/remove 方法(可以提供默认的空实现或抛出异常)。
  2. 创建 LeafFileNode,继承 FileSystemComponent。实现 displaygetSizeadd/remove 方法可以沿用父类的异常实现。
  3. 创建 CompositeDirectoryNode,继承 FileSystemComponent
    • 内部持有一个 List<FileSystemComponent> 来存储子节点。
    • 实现 display 方法,通过遍历子节点并调用它们的 display 方法来完成。
    • 实现 add/remove 方法来管理子节点列表。
  4. 客户端通过组合 FileNodeDirectoryNode 对象来构建一棵树,然后只需对根节点调用 display() 即可展示整个文件系统的结构。

Java 示例代码 (采用透明模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import java.util.ArrayList;
import java.util.List;

// 1. 组件 (Component)
abstract class FileSystemComponent {
protected String name;

public FileSystemComponent(String name) {
this.name = name;
}

public String getName() {
return name;
}

// 通用操作
public abstract void display(String indent);
public abstract long getSize();

// 子节点管理方法(透明模式)
// 默认实现为抛出异常,因为叶子节点不支持这些操作
public void add(FileSystemComponent component) {
throw new UnsupportedOperationException("Cannot add to a file.");
}

public void remove(FileSystemComponent component) {
throw new UnsupportedOperationException("Cannot remove from a file.");
}
}

// 2. 叶子 (Leaf)
class FileNode extends FileSystemComponent {
private long size;

public FileNode(String name, long size) {
super(name);
this.size = size;
}

@Override
public void display(String indent) {
System.out.println(indent + "- " + getName() + " (" + getSize() + " bytes)");
}

@Override
public long getSize() {
return this.size;
}
}

// 3. 组合 (Composite)
class DirectoryNode extends FileSystemComponent {
// 内部持有子节点的集合
private List<FileSystemComponent> children = new ArrayList<>();

public DirectoryNode(String name) {
super(name);
}

@Override
public void add(FileSystemComponent component) {
children.add(component);
}

@Override
public void remove(FileSystemComponent component) {
children.remove(component);
}

@Override
public void display(String indent) {
System.out.println(indent + "+ " + getName());
// 递归地显示子节点
for (FileSystemComponent component : children) {
component.display(indent + " ");
}
}

@Override
public long getSize() {
// 目录的大小是所有子节点大小的总和
long totalSize = 0;
for (FileSystemComponent component : children) {
totalSize += component.getSize();
}
return totalSize;
}
}

// 4. 客户端
public class CompositePatternDemo {
public static void main(String[] args) {
// 创建树形结构
DirectoryNode root = new DirectoryNode("root");
DirectoryNode documents = new DirectoryNode("documents");
DirectoryNode pictures = new DirectoryNode("pictures");

FileNode resume = new FileNode("resume.docx", 1024);
FileNode photo1 = new FileNode("photo1.jpg", 2048);
FileNode photo2 = new FileNode("photo2.png", 4096);
DirectoryNode project = new DirectoryNode("project");
FileNode code = new FileNode("main.java", 512);

// 构建层级关系
root.add(documents);
root.add(pictures);

documents.add(resume);
documents.add(project);
project.add(code);

pictures.add(photo1);
pictures.add(photo2);

// 客户端统一操作,只需调用根节点的 display
System.out.println("--- Displaying File System Structure ---");
root.display("");

System.out.println("\n--- Calculating Sizes ---");
System.out.println("Size of 'documents' directory: " + documents.getSize() + " bytes");
System.out.println("Total size of 'root' directory: " + root.getSize() + " bytes");
}
}

五、UML 类图

下面是上述例子的 PlantUML 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@startuml
skinparam classAttributeIconSize 0
hide empty members

abstract class FileSystemComponent {
# String name
+ {abstract} void display(String indent)
+ {abstract} long getSize()
+ void add(FileSystemComponent c)
+ void remove(FileSystemComponent c)
}

class FileNode extends FileSystemComponent {
- long size
+ void display(String indent)
+ long getSize()
}

class DirectoryNode extends FileSystemComponent {
- List<FileSystemComponent> children
+ void display(String indent)
+ long getSize()
+ void add(FileSystemComponent c)
+ void remove(FileSystemComponent c)
}

' 组合关系:DirectoryNode 包含多个 FileSystemComponent
DirectoryNode *-- "0..*" FileSystemComponent

class CompositePatternDemo {
+ {static} void main(String[] args)
}
CompositePatternDemo ..> FileSystemComponent : uses

@enduml

图示解读:

  • FileNode (叶子) 和 DirectoryNode (组合) 都继承自 FileSystemComponent (组件)。
  • DirectoryNode 内部包含一个 FileSystemComponent 的集合,表示它可以拥有子节点。这个关系由一个带星号和菱形的线(组合关系 *--)表示。
  • 客户端 CompositePatternDemo 只与抽象的 FileSystemComponent 交互。

六、优缺点分析

优点 (Pros):

  1. 简化客户端代码:客户端可以统一地处理所有对象,无需关心它是叶子还是容器,大大降低了代码的复杂性。
  2. 易于增加新组件:增加新的叶子或容器类非常容易,只要它们实现了 Component 接口即可,符合开闭原则。
  3. 结构清晰:定义了清晰的“部分-整体”层次结构,代码易于理解。

缺点 (Cons):

  1. 设计变得过于通用(透明模式的代价)Component 接口中包含了它所有子类(叶子和容器)可能用到的方法。这使得叶子类会继承到它本不该有的方法(如add/remove),可能需要在运行时抛出异常来处理,这在编译时是无法检查的。
  2. 难以限制组合中的组件类型:有时候我们希望一个容器里只能放特定类型的叶子,组合模式本身不提供这样的约束机制,需要额外添加代码来实现。

七、在 Java 中的应用

  • Java AWT/Swing GUI 框架: 这是组合模式最经典的教科书级应用。
    • java.awt.Component 是抽象组件。
    • JButton, JTextField 等是叶子节点。
    • java.awt.Container 是组合节点(JPanel, JFrame 都继承自它)。一个 Container 可以包含 Component(包括其他的 Container),形成复杂的 GUI 布局。你可以对一个 JPanel 调用 setVisible(true),它会自动让其内部所有组件都可见。
  • **JavaServer Faces (JSF)**:JSF 的 UI 组件树也是一个典型的组合模式应用。