第十章 组合模式

组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的孙对象构成的。

10.1 回顾宏命令

在第九章宏命令中,宏命令对象包含了一组具体的子命令对象,不管是宏命令对象,还是子命令对象,都有一个 execute 方法执行命令。

js
1class closeDoorCommand {
2  execute() {
3    console.log('开门')
4  }
5}
6
7class OpenPcCommand {
8  execute() {
9    console.log('打开电脑')
10  }
11}
12
13class openQQCommand {
14  execute() {
15    console.log('登录QQ')
16  }
17}
18class MacroCommand {
19  macroList = []
20  add(commander) {
21    this.macroList.push(commander)
22  }
23  execute() {
24    for (let commander of this.macroList) {
25      commander.execute()
26    }
27  }
28}
29const macroCommand = new MacroCommand()
30macroCommand.add(new closeDoorCommand())
31macroCommand.add(new OpenPcCommand())
32macroCommand.add(new openQQCommand())
33
34macroCommand.execute() // 执行宏命令

我们很容易发现,在宏命令中包含了一组子命令,他们组成一个树形结构,这里是一颗结构非常简单的树。

img

其中,macroCommand 被称为组合对象,closeDoorCommand、OpenPcCommand、OpenQQCommand 都是叶对象。在 macroCommand 的 execute 方法中,并不执行真正的操作,而是遍历它的叶对象,把真正的 execute 委托给它的叶对象。

macroCommand 表现得像一个命令,但它实际上只是这些命令的“代理”。并非真正的代理,因为 macroCommand 只负责将请求传递给叶对象,它的目的不在于控制对叶对象的访问。

10.2 组合模式的用途

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。下面分别说明:

  • 表现树形结构

    组合模式的一个优点是提供了一种遍历树形结构的方案,通过调用组合对象的 execute 方法,程序会递归调用组合对象下面的叶对象的 execute 方法。这样我们就可以只操作一次,便能够依次做多个命令。组合模式可以非常方便地描述对象部分-整体的层次结构

  • 利用对象的多态性统一对待组合对象和单个对象。利用对象的多态性表现,可以使客户端忽略组合对象和单个对象的不同。在组合模式中,客户将统一地使用组合结构中的所有对象,而不需要关心它们究竟是组合对象还是单个对象。

这种模式在实际开发中,会带来非常大的便利性。当我们往宏命令中添加命令时,我们甚至不需要关心这个命令是另一个宏命令还是普通子命令。我们只需要确定它是一个命令,并且这个命令拥有 execute 方法,那么这个命令就可以被添加进去。

当宏命令和普通子命令接收到执行 execute 方法的请求时,宏命令和普通子命令都会做它认为正确的事情。这些差异是隐藏起来,这种透明性可以让我们非常自由地扩展命令。

10.3 请求在树中传递的过程

在组合模式中,请求在树中传递的过程总是遵循一种逻辑。

以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象(宏命令),组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。

如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。叶对象下面不会再有其他子节点,一个叶对象就是树的这条枝叶的尽头,组合对象下面可能还会有子节点。

img

请求从上到下沿着树进行传递,直到树的尽头。作为客户,只需要关心树最顶层的组合对象,客户只需要请求这个组合对象,请求就会沿着树往下传递,依次到达所有的叶对象。

10.4 更强大的宏命令

假设我们现在需要一个超级万能遥控器,可以控制家里所有的电器,这个遥控器拥有一下功能:

  • 打开空调
  • 打开电视和音响
  • 关门、开电脑、登录 QQ

先设置一下超级万能遥控器的按钮

js
1<button id="button">按我</button>

下面是命令集

js
1class MacroCommand {
2  constructor() {
3    this.commandList = []
4  }
5  add(command) {
6    this.commandList.push(command)
7  }
8  execute() {
9    for (let command of this.commandList) {
10      command.execute()
11    }
12  }
13}
14
15class OpenAcCommand {
16  execute() {
17    console.log('开空调')
18  }
19}
20
21class OpenTvCommand {
22  execute() {
23    console.log('开电视')
24  }
25}
26
27class openSoundCommand {
28  execute() {
29    console.log('打开音响')
30  }
31}
32//超级遥控器
33const macroCommand1 = new MacroCommand()
34macroCommand1.add(new OpenAcCommand())
35macroCommand1.add(new OpenTvCommand())
36macroCommand1.add(new openSoundCommand())
37
38class closeDoorCommand {
39  execute() {
40    console.log('关门')
41  }
42}
43
44class openPcCommand {
45  execute() {
46    console.log('开电脑')
47  }
48}
49
50class openQQCommand {
51  execute() {
52    console.log('开QQ')
53  }
54}
55// 遥控器2号
56var macroCommand2 = new MacroCommand()
57macroCommand2.add(new closeDoorCommand())
58macroCommand2.add(new openPcCommand())
59macroCommand2.add(new openQQCommand())
60// 遥控器1号将2号也组合起来
61macroCommand1.add(macroCommand2)
62
63function setCommand(command) {
64  button.onclick = command.execute.bind(command) //这里记得绑定一下this指向
65}
66
67setCommand(macroCommand1)

点击按钮后,就会看到执行以下结果:

bash
1"开空调"
2"开电视"
3"打开音响"
4"关门"
5"开电脑"
6"开QQ"

从这个例子可以看出,基本对象可以被组合成更复杂的组合对象,组合对象又可以被组合,这样不断递归下去,这棵树的结构可以支持任意多的复杂度。在树最终被构造完成之后,让整颗树最终运转起来的步骤非常简单,只需要调用最上层对象的 execute 方法。每当对最上层的对象进行一次请求时,实际上是在对整个树进行深度优先的搜索,而创建组合对象的程序员并不关心这些内在的细节,往这棵树里面添加一些新的节点对象是非常容易的事情。

10.5 抽象类在组合模式的作用

组合模式最大的优点在于可以一致地对待组合对象和基本对象。用户不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有 execute 方法,这个命令就可以被添加到树中。

在 JavaScript 这种语言中,对象的多态性是与生俱来的,我们通常不需要去模拟一个抽象类,JavaScript 中实现组合模式的难点在于要保证组合对象和叶对象都拥有同样的方法。

在 JavaScript 中实现组合模式,看起来只缺乏一些严谨性,我们的代码算不上安全,但能够更快速自由地开发,这既是 JavaScript 的优点,又是 JavaScript 的缺点。

10.6 透明性带来的安全问题

组合模式的透明性使得发起请求的客户不用顾忌树中组合对象和叶对象的区别,但他们本质上是有区别的。

组合对象可以拥有子节点,叶对象下面就没有子节点,所以我们也许会有一些误操作,比如试图往叶对象中添加子节点。解决方案通常是给叶对象也增加一个 add 方法,并且调用这个方法时,跑出一个异常来做提醒。

js
1class openPcCommand {
2  execute() {
3    console.log('开电脑')
4  }
5  add() {
6    throw new Error('叶对象不能添加子节点')
7  }
8}

10.7 组合模式的例子——扫描文件夹

文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以包含其他文件夹,最终组成一棵树。

组合模式在文件夹的应用有以下两层好处:

  • 复制文件类型时,不再需要考虑文件的类型。
  • 当扫描文件夹时,不需要关心里面有多少文件和子文件夹,组合模式可以让我们只操作最外层的文件夹

下面是扫描文件夹的例子:

js
1function Folder(name) {
2  this.name = name
3  this.files = []
4}
5
6Folder.prototype.add = function (file) {
7  this.files.push(file)
8}
9
10Folder.prototype.scan = function () {
11  console.log('开始扫描文件夹:' + this.name)
12  for (let file of this.files) {
13    file.scan.call(file)
14  }
15}
16
17function File(name) {
18  this.name = name
19}
20
21File.prototype.scan = function () {
22  console.log('开始扫描文件:' + this.name)
23}
24
25File.prototype.add = function () {
26  throw new Error('文件下不能添加文件')
27}
28
29const file1 = new File('文件1')
30const file2 = new File('文件2')
31const folder1 = new Folder('文件夹1')
32
33folder1.add(file1)
34folder1.add(file2)
35
36const file3 = new File('文件3')
37const file4 = new File('文件4')
38const folder2 = new Folder('文件夹2')
39folder2.add(file3)
40folder2.add(file4)
41folder1.add(folder2)
42
43folder1.scan()

最终的打印结果为:

bash
1"开始扫描文件夹:文件夹1"
2"开始扫描文件:文件1"
3"开始扫描文件:文件2"
4"开始扫描文件夹:文件夹2"
5"开始扫描文件:文件3"
6"开始扫描文件:文件4"

现在假设我需要将文件夹 2 内的文件 3 复制到文件夹 1 来,可以直接添加

js
1folder1.add(file3)

在添加一批文件的操作过程中,客户不用分辨它们到底是文件还是文件夹。新增加的文件和文件夹能够很容易地添加到原来的树结构中,在树里已有的对象一起工作。

运行了组合模式后,扫描整个文件夹的操作也是轻而易举的,我们只需要操作树的顶端对象

js
1folder1.scan()

10.8 一些值得注意的地方

  1. 组合模式不是父子关系

    组合模式的树形结构很容易让人误会组合对象和叶对象是父子关系,这是不正确的。

    组合模式是一种聚合关系,而不是父子关系。组合对象包含一组叶对象,但 Leaf 并不是 Composite 的子类。组合对象把请求委托给它包含的叶对象,它们能够合作的关键是拥有相同接口。

  2. 对叶对象操作的一致性

    组合模式除了要求组合对象和叶对象拥有相同的接口外,还有一个必要条件,就是一组叶对象的操作必须拥有一致性。

  3. 双向映射关系

    发放过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本身是一个组合模式的好例子,但要考虑的一种情况是,也许某些员工属于多个组织架构。比如某位架构师既隶属于开发组,又隶属于架构组,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该架构师很可能会收到两份过节费。

    这种复合情况下我们必须给父节点和子节点建立双向映射关系,一个简单的方法是给小组和员工对象都增加集合来保存对方的引用。但是这种相互间的引用相当复杂,而且对象之间产生了过多的耦合性,修改或者删除一个对象都变得困难,此时我们可以引入中介者模式来管理这些对象。

  4. 用职责链模式提高组合模式性能

    在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许表现得不够理想

    有时候我们确实可以借助一些技巧,在实际操作中避免遍历整棵树,有一种现成的方案是借助职责链模式。职责链模式一般需要我们手动去设置链条,但在组合模式中,父对象和子对象之间实际上形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反过来从子对象往父对象传递,直到遇到可以处理该请求的对象为止,这也是职责链模式的经典运用场景之一。

10.9 引用父对象

组合对象保存了它下面的子节点的引用,这是组合模式的特点,此时树结构是自上而下的。但有时候我们需要在子节点上保存父节点的引用,比如组合模式中使用职责链时,有可能需要让请求从子节点往父节点上冒泡传递。还有当我们删除某个文件时,实际上是从这个文件的上层文件夹中删除该文件的。

现在我们修改一下扫描文件夹的代码,使得扫描整个文件夹之前,我们可以先移除某一个具体的文件。

首先我们需要调用 add 方法时,设置一个父对象

js
1/* ******Folder对象 ****** */
2function Folder(name) {
3  this.name = name
4  this.parent = null // 增加parent属性
5  this.files = []
6}
7
8Folder.prototype.add = function (file) {
9  file.parent = this // 设置父对象
10  this.files.push(file)
11}
12
13Folder.prototype.scan = function () {
14  console.log('开始扫描文件夹:' + this.name)
15  for (let file of this.files) {
16    file.scan.call(file)
17  }
18}

接着完成删除逻辑,当子对象调用 remove 方法时,让父对象遍历 files 属性,删除这个子对象。

js
1Folder.prototype.remove = function () {
2  if (!this.parent) {
3    return //如果没有父节点,说明要么是根文件夹,要么只创建还没被添加
4  }
5  const index = this.parent.files.findIndex((f) => f === this)
6  this.parent.files.splice(index, 1)
7}

对于 File 对象来说也是同样的逻辑

js
1/* ******File对象 ****** */
2function File(name) {
3  this.parent = null
4  this.name = name
5}
6
7File.prototype.scan = function () {
8  console.log('开始扫描文件:' + this.name)
9}
10
11File.prototype.add = function () {
12  throw new Error('文件下不能添加文件')
13}
14
15File.prototype.remove = function () {
16  if (!this.parent) {
17    return //如果没有父节点,,说明只创建还没被添加
18  }
19  const index = this.parent.files.findIndex((f) => f === this)
20  this.parent.files.splice(index, 1)
21}

下面测试一下:

js
1const file1 = new File('文件1')
2const file2 = new File('文件2')
3const file3 = new File('文件3')
4const file4 = new File('文件4')
5const folder1 = new Folder('文件夹1')
6const folder2 = new Folder('文件夹2')
7
8folder1.add(file1)
9folder1.add(file2)
10folder1.add(folder2)
11folder2.add(file3)
12folder2.add(file4)
13folder1.scan()

此时的目录结构时这样的

bash
1"开始扫描文件夹:文件夹1"
2"开始扫描文件:文件1"
3"开始扫描文件:文件2"
4"开始扫描文件夹:文件夹2"
5"开始扫描文件:文件3"
6"开始扫描文件:文件4"

文件夹 1 中包含文件 1、文件 2、文件夹 2。

文件夹 2 中包含文件 3、文件 4。

此时删除文件夹 2 和文件 2

js
1file2.remove()
2folder2.remove()

再扫描看看

js
1folder1.scan()
2;('开始扫描文件夹:文件夹1')
3;('开始扫描文件:文件1')

文件删除成功了。

10.10 何时使用组合模式

组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下两种情况:

  • 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构,特别是我们在开发期间不知道这棵树有多少层次的时候。在树的构造最终完成之前,我们都可以通过请求树的顶层对象来对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放-封闭原则
  • 客户希望统一对待树中的所有对象。组合模式可以使用户忽略叶对象和组合对象之间的区别,在面对这棵树时,用户不用关系当前正在处理的是组合对象还是叶对象,也不需要写大量的判断语句来处理。组合对象和叶对象会各自做自己认为正确的事情。

10.11 小结

组合模式可以让我们使用树形结构的方式创建对象的结构。我们可以把相同的操作应用在组合对象和单个对象上。大多数情况下,我们都可以忽略组合对象和叶对象之间的差别。用一致的方式来对待它们。

组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都和其他对象差不多。它们的区别只有在运行时候才会显现出来,这会让代码难以理解。此外,如果通过组合模式创建太多对象,那么这些对象可能会让系统负担不起。