组合 Composite

组合模式是一种结构型设计模式,你可以使用它将对象组合成树状结构,并且能像使用独立对象一样使用它们。

将对象组合成树形结构以表示整个部分的层次结构。组合模式可以让用户统一对待单个对象和对象的组合。


为什么要使用?

组合模式的对象职责:

将对象组合成树状结构,并且能像使用独立对象一样使用它们。

组合模式就是一种容器与内容组合在一起的模式。它能够使容器与内容具有一致性

例如,在文件系统中,有文件夹📁和文件📄两种对象,它们间的层级关系就是树状关系🌲。文件夹属于容器、文件属于内容,但是它们都属于目录条目。所以我们能像使用独立对象一样使用它们。

那么在树状结构中,树节点就是容器🫙,能够承载更多的树节点和叶子节点,叶子节点就是内容。它们都属于节点,可以组合成一棵树。两者不同的是树节点会记录它的子节点而已。

如果你有以下需求,可以考虑使用组合模式。

  1. 希望一组对象按照某种层级结构进行管理。
  2. 需要按照统一的行为来处理复杂结构中的对象。
  3. 能够快速拓展对象组合。

模式结构

  • 组件(Component)接口描述了树中简单项目和复杂项目所共有的操作。

  • 叶节点(Leaf)是树的基本结构,它不包含子项目。一般情况下,叶节点最终会完成大部分的实际工作,因为它们无法将工作指派给其他部分。

  • 容器(Container)——又名“组合(Composite)”——是包含叶节点或其他容器等子项目的单位。容器不知道其子项目所属的具体类,它只通过通用的组件接口与其子项目交互。容器接收到请求后会将工作分配给自己的子项目,处理中间结果,然后将最终结果返回给客户端。

  • 客户端(Client)通过组件接口与所有项目交互。因此,客户端能以相同方式与树状结构中的简单或复杂项目交互。

组合模式的类图:


模式实现

文件中只包含自己的信息,属于单个对象;文件夹中能够存储文件和文件夹,属于组合对象。该示例使用组合模式将文件和文件夹归为目录条目,使其具有一致性,方便管理。

示例程序的类图

代码实现

组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package example;

/** 目录条目抽象组件 */
public interface Entry {
/**
* 获取文件夹或文件名字
* @return 文件夹或文件名字
*/
String getName();

/**
* 获取文件夹的子文件夹和子文件总数或文件大小
* @return 文件夹的子文件夹和子文件总数或文件大小
*/
int getSize();

/**
* 打印文件夹或文件路径
* @param prefix 父文件夹路径
*/
void printList(String prefix);
}
叶节点
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
package example;

/** 文件叶节点 */
public class File implements Entry {
/** 文件名 */
private String name;
/** 文件大小 */
private int size;

public File(String name, int size) {
this.name = name;
this.size = size;
}

/**
* 获取文件夹或文件名字
* @return 文件夹或文件名字
*/
@Override
public String getName() {
return name;
}

/**
* 获取文件夹的子文件夹和子文件总数或文件大小
* @return 文件夹的子文件夹和子文件总数或文件大小
*/
@Override
public int getSize() {
return size;
}

/**
* 打印文件夹或文件路径
* @param prefix 父文件夹路径
*/
@Override
public void printList(String prefix) {
System.out.println(prefix + "/" + name);
}
}
容器
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
package example;

import java.util.ArrayList;

/** 文件夹容器 */
public class Directory implements Entry {
/** 文件夹名 */
private String name;
/** 子文件列表 */
private ArrayList<Entry> directory = new ArrayList<>();

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

/**
* 添加文件夹或文件
* @param entry 文件夹或文件
* @return 当前文件夹
*/
public Entry add(Entry entry) {
directory.add(entry);
return this;
}

/**
* 获取文件夹或文件名字
* @return 文件夹或文件名字
*/
@Override
public String getName() {
return name;
}

/**
* 获取文件夹的子文件夹和子文件总数或文件大小
* @return 文件夹的子文件夹和子文件总数或文件大小
*/
@Override
public int getSize() {
return directory.size();
}

/**
* 打印文件夹或文件路径
* @param prefix 父文件夹路径
*/
@Override
public void printList(String prefix) {
String path = prefix + "/" + name;
System.out.println(path);
for (Entry entry : directory) {
entry.printList(path);
}
}
}

代码测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import example.Directory;
import example.File;

/** 测试组合模式 */
public class Test {
public static void main(String[] args) {
File txt = new File("test.txt", 1);
File png = new File("test.png", 10);
Directory root = new Directory("root");
Directory child = new Directory("child");

System.out.println("root文件夹添加child文件夹:");
root.add(child).printList("~");
System.out.println("文件数量:" + root.getSize());

System.out.println("\nroot文件夹添加test.png文件:");
root.add(png).printList("~");
System.out.println("文件数量:" + root.getSize());

System.out.println("\nchild文件夹添加test.txt文件:");
child.add(txt).printList("~/root");
System.out.println("文件数量:" + child.getSize());
}
}

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root文件夹添加child文件夹:
~/root
~/root/child
文件数量:1

root文件夹添加test.png文件:
~/root
~/root/child
~/root/test.png
文件数量:2

child文件夹添加test.txt文件:
~/root/child
~/root/child/test.txt
文件数量:1

你也可以在 Entry 接口中加入获取父类文件路径的方法,就无需在创建 FileDirectory 时,直接输入字符串常量。


常用场景和解决方案

  • 处理一个树型结构。比如,公司人员组织架构、订单信息等。
  • 跨越多个层次结构聚合数据。比如,统计文件夹下文件总数。
  • 统一处理一个结构中的多个对象。比如,遍历文件夹下所有 XML 类型文件内容。
  • 如果你希望客户端代码以相同方式处理简单和复杂元素,可以使用该模式。组合模式中定义的所有元素共用同一个接口。在这一接口的帮助下,客户端不必在意其所使用的对象的具体类。

模式的优缺点

优点 缺点
你可以利用多态和递归机制更方便地使用复杂树结构。 对于功能差异较大的类,提供公共接口或许会有困难。在特定情况下,你需要过度一般化组件接口,使其变得令人难以理解。
开闭原则。无需更改现有代码,你就可以在应用中添加新元素,使其成为对象树的一部分。

使用组合模式的优势

  • 清晰定义分层结构。
  • 快速新增节点,提升组合灵活性。
  • 简化了使用者使用复杂结构数据的代码。组合模式为你提供了两种共享公共接口的基本元素类型:简单叶节点和复杂容器。容器中可以包含叶节点和其他容器。这使得你可以构建树状嵌套递归对象结构。

使用组合模式的劣势

  • 难以限制节点类型。
  • 需要增加很多运行时的检查,增加了代码复杂度。
  • 错误的遍历算法可能会影响系统性能。

拓展知识

  • 你可以在创建复杂组合树时使用生成器模式,因为这可使其构造步骤以递归的方式运行。
  • 责任链模式通常和组合模式结合使用。在这种情况下,叶组件接收到请求后,可以将请求沿包含全体父组件的链一直传递至对象树的底部。
  • 你可以使用迭代器模式来遍历组合树。
  • 你可以使用访问者模式对整个组合树执行操作。
  • 你可以使用享元模式实现组合树的共享叶节点以节省内存。


🔙 设计模式

📌最后:希望本文能够给您提供帮助,文章中有不懂或不正确的地方,请在下方评论区💬留言!

🔗参考文献:

🌐 设计模式 –refactoringguru

▶️ bilibili-趣学设计模式;黄靖锋. –拉勾教育

📖 图解设计模式 /(日)结城浩著;杨文轩译. –北京:人民邮电出版社,2017.1