0%

备忘录模式

亦称:快照、Snapshot、Memento

意图

备忘录模式是一种行为设计模式,允许在不暴露对象实现细节的情况下保存和恢复对象之前的状态。

备忘录设计模式

问题

假如你正在开发一款文字编辑器应用程序。除了简单的文字编辑功能外,编辑器中还要有设置文本格式和插入内嵌图片等功能。

后来,你决定让用户能撤销施加在文本上的任何操作。这项功能在过去几年里变得十分普遍,因此用户期待任何程序都有这项功能。你选择采用直接的方式来实现该功能:程序在执行任何操作前会记录所有的对象状态,并将其保存下来。当用户此后需要撤销某个操作时,程序将从历史记录中获取最近的快照,然后使用它来恢复所有对象的状态。

在编辑器中撤销操作

程序在执行操作前保存所有对象的状态快照,稍后可通过快照将对象恢复到之前的状态。

让我们来思考一下这些状态快照。首先,到底该如何生成一个快照呢?很可能你会需要遍历对象的所有成员变量并将其数值复制保存。但只有当对象对其内容没有严格访问权限限制的情况下,你才能使用该方式。不过很遗憾,绝大部分对象会使用私有成员变量来存储重要数据,这样别人就无法轻易查看其中的内容。

现在我们暂时忽略这个问题,假设对象都像嬉皮士一样:喜欢开放式的关系并会公开其所有状态。尽管这种方式能够解决当前问题,让你可随时生成对象的状态快照,但这种方式仍存在一些严重问题。未来你可能会添加或删除一些成员变量。这听上去很简单,但需要对负责复制受影响对象状态的类进行更改。

如何复制对象的私有状态?

如何复制对象的私有状态?

还有更多问题。让我们来考虑编辑器(Editor)状态的实际“快照”,它需要包含哪些数据?至少必须包含实际的文本、光标坐标和当前滚动条位置等。你需要收集这些数据并将其放入特定容器中,才能生成快照。

你很可能会将大量的容器对象存储在历史记录列表中。这样一来,容器最终大概率会成为同一个类的对象。这个类中几乎没有任何方法,但有许多与编辑器状态一一对应的成员变量。为了让其他对象能保存或读取快照,你很可能需要将快照的成员变量设为公有。无论这些状态是否私有,其都将暴露一切编辑器状态。其他类会对快照类的每个小改动产生依赖,除非这些改动仅存在于私有成员变量或方法中,而不会影响外部类。

我们似乎走进了一条死胡同:要么会暴露类的所有内部细节而使其过于脆弱;要么会限制对其状态的访问权限而无法生成快照。那么,我们还有其他方式来实现“撤销”功能吗?

解决方案

我们刚才遇到的所有问题都是封装“破损”造成的。一些对象试图超出其职责范围的工作。由于在执行某些行为时需要获取数据,所以它们侵入了其他对象的私有空间,而不是让这些对象来完成实际的工作。

备忘录模式将创建状态快照(Snapshot)的工作委派给实际状态的拥有者原发器(Originator)对象。这样其他对象就不再需要从“外部”复制编辑器状态了,编辑器类拥有其状态的完全访问权,因此可以自行生成快照。

模式建议将对象状态的副本存储在一个名为备忘录(Memento)的特殊对象中。除了创建备忘录的对象外,任何对象都不能访问备忘录的内容。其他对象必须使用受限接口与备忘录进行交互,它们可以获取快照的元数据(创建时间和操作名称等),但不能获取快照中原始对象的状态。

原发器拥有对备忘录的完全权限,负责人则只能访问元数据

原发器拥有对备忘录的完全访问权限,负责人则只能访问元数据。

这种限制策略允许你将备忘录保存在通常被称为负责人(Caretakers)的对象中。由于负责人仅通过受限接口与备忘录互动,故其无法修改存储在备忘录内部的状态。同时,原发器拥有对备忘录所有成员的访问权限,从而能随时恢复其以前的状态。

在文字编辑器的示例中,我们可以创建一个独立的历史(History)类作为负责人。编辑器每次执行操作前,存储在负责人中的备忘录栈都会生长。你甚至可以在应用的 UI 中渲染该栈,为用户显示之前的操作历史。

当用户触发撤销操作时,历史类将从栈中取回最近的备忘录,并将其传递给编辑器以请求进行回滚。由于编辑器拥有对备忘录的完全访问权限,因此它可以使用从备忘录中获取的数值来替换自身的状态。

备忘录模式结构

基于嵌套类的实现

该模式的经典实现方式依赖于许多流行编程语言(例如 C++、C# 和 Java)所支持的嵌套类。

基于嵌套类的备忘录基于嵌套类的备忘录

  1. 原发器(Originator)类可以生成自身状态的快照,也可以在需要时通过快照恢复自身状态。

  2. 备忘录(Memento)是原发器状态快照的值对象(value object)。通常做法是将备忘录设为不可变的,并通过构造函数一次性传递数据。

  3. 负责人(Caretaker)仅知道“何时”和“为何”捕捉原发器的状态,以及何时恢复状态。

    负责人通过保存备忘录栈来记录原发器的历史状态。当原发器需要回溯历史状态时,负责人将从栈中获取最顶部的备忘录,并将其传递给原发器的恢复(restoration)方法。

  4. 在该实现方法中,备忘录类将被嵌套在原发器中。这样原发器就可访问备忘录的成员变量和方法,即使这些方法被声明为私有。另一方面,负责人对于备忘录的成员变量和方法的访问权限非常有限:它们只能在栈中保存备忘录,而不能修改其状态。

基于中间接口的实现

另外一种实现方法适用于不支持嵌套类的编程语言(没错,我说的就是 PHP)。

不使用嵌套类的备忘录不使用嵌套类的备忘录

  1. 在没有嵌套类的情况下,你可以规定负责人仅可通过明确声明的中间接口与备忘录互动,该接口仅声明与备忘录元数据相关的方法,限制其对备忘录成员变量的直接访问权限。

  2. 另一方面,原发器可以直接与备忘录对象进行交互,访问备忘录类中声明的成员变量和方法。这种方式的缺点在于你需要将备忘录的所有成员变量声明为公有。

封装更加严格的实现

如果你不想让其他类有任何机会通过备忘录来访问原发器的状态,那么还有另一种可用的实现方式。

封装更加严格的备忘录封装更加严格的备忘录

  1. 这种实现方式允许存在多种不同类型的原发器和备忘录。每种原发器都和其相应的备忘录类进行交互。原发器和备忘录都不会将其状态暴露给其他类。

  2. 负责人此时被明确禁止修改存储在备忘录中的状态。但负责人类将独立于原发器,因为此时恢复方法被定义在了备忘录类中。

  3. 每个备忘录将与创建了自身的原发器连接。原发器会将自己及状态传递给备忘录的构造函数。由于这些类之间的紧密联系,只要原发器定义了合适的设置器(setter),备忘录就能恢复其状态。

伪代码

本例结合使用了命令模式与备忘录模式,可保存复杂文字编辑器的状态快照,并能在需要时从快照中恢复之前的状态。

备忘录示例的结构

保存文字编辑器状态的快照。

命令(command)对象将作为负责人,它们会在执行与命令相关的操作前获取编辑器的备忘录。当用户试图撤销最近的命令时,编辑器可以使用保存在命令中的备忘录来将自身回滚到之前的状态。

备忘录类没有声明任何公有的成员变量、获取器(getter)和设置器,因此没有对象可以修改其内容。备忘录与创建自己的编辑器相连接,这使得备忘录能够通过编辑器对象的设置器传递数据,恢复与其相连接的编辑器的状态。由于备忘录与特定的编辑器对象相连接,程序可以使用中心化的撤销栈实现对多个独立编辑器窗口的支持。

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
// 原发器中包含了一些可能会随时间变化的重要数据。它还定义了在备忘录中保存
// 自身状态的方法,以及从备忘录中恢复状态的方法。
class Editor is
private field text, curX, curY, selectionWidth

method setText(text) is
this.text = text

method setCursor(x, y) is
this.curX = x
this.curY = y

method setSelectionWidth(width) is
this.selectionWidth = width

// 在备忘录中保存当前的状态。
method createSnapshot():Snapshot is
// 备忘录是不可变的对象;因此原发器会将自身状态作为参数传递给备忘
// 录的构造函数。
return new Snapshot(this, text, curX, curY, selectionWidth)

// 备忘录类保存有编辑器的过往状态。
class Snapshot is
private field editor: Editor
private field text, curX, curY, selectionWidth

constructor Snapshot(editor, text, curX, curY, selectionWidth) is
this.editor = editor
this.text = text
this.curX = x
this.curY = y
this.selectionWidth = selectionWidth

// 在某一时刻,编辑器之前的状态可以使用备忘录对象来恢复。
method restore() is
editor.setText(text)
editor.setCursor(curX, curY)
editor.setSelectionWidth(selectionWidth)

// 命令对象可作为负责人。在这种情况下,命令会在修改原发器状态之前获取一个
// 备忘录。当需要撤销时,它会从备忘录中恢复原发器的状态。
class Command is
private field backup: Snapshot

method makeBackup() is
backup = editor.createSnapshot()

method undo() is
if (backup != null)
backup.restore()
// ...

备忘录模式适合应用场景

当你需要创建对象状态快照来恢复其之前的状态时,可以使用备忘录模式。

备忘录模式允许你复制对象中的全部状态(包括私有成员变量),并将其独立于对象进行保存。尽管大部分人因为“撤销”这个用例才记得该模式,但其实它在处理事务(比如需要在出现错误时回滚一个操作)的过程中也必不可少。

当直接访问对象的成员变量、获取器或设置器将导致封装被突破时,可以使用该模式。

备忘录让对象自行负责创建其状态的快照。任何其他对象都不能读取快照,这有效地保障了数据的安全性。

实现方式

  1. 确定担任原发器角色的类。重要的是明确程序使用的一个原发器中心对象,还是多个较小的对象。

  2. 创建备忘录类。逐一声明对应每个原发器成员变量的备忘录成员变量。

  3. 将备忘录类设为不可变。备忘录只能通过构造函数一次性接收数据。该类中不能包含设置器。

  4. 如果你所使用的编程语言支持嵌套类,则可将备忘录嵌套在原发器中;如果不支持,那么你可从备忘录类中抽取一个空接口,然后让其他所有对象通过接口来引用备忘录。你可在该接口中添加一些元数据操作,但不能暴露原发器的状态。

  5. 在原发器中添加一个创建备忘录的方法。原发器必须通过备忘录构造函数的一个或多个实际参数来将自身状态传递给备忘录。

    该方法返回结果的类型必须是你在上一步中抽取的接口(如果你已经抽取了)。实际上,创建备忘录的方法必须直接与备忘录类进行交互。

  6. 在原发器类中添加一个用于恢复自身状态的方法。该方法接受备忘录对象作为参数。如果你在之前的步骤中抽取了接口,那么可将接口作为参数的类型。在这种情况下,你需要将输入对象强制转换为备忘录,因为原发器需要拥有对该对象的完全访问权限。

  7. 无论负责人是命令对象、历史记录或其他完全不同的东西,它都必须要知道何时向原发器请求新的备忘录、如何存储备忘录以及何时使用特定备忘录来对原发器进行恢复。

  8. 负责人与原发器之间的连接可以移动到备忘录类中。在本例中,每个备忘录都必须与创建自己的原发器相连接。恢复方法也可以移动到备忘录类中,但只有当备忘录类嵌套在原发器中,或者原发器类提供了足够多的设置器并可对其状态进行重写时,这种方式才能实现。

备忘录模式优缺点

  • 你可以在不破坏对象封装情况的前提下创建对象状态快照。

  • 你可以通过让负责人维护原发器状态历史记录来简化原发器代码。

  • 如果客户端过于频繁地创建备忘录,程序将消耗大量内存。

  • 负责人必须完整跟踪原发器的生命周期,这样才能销毁弃用的备忘录。

  • 绝大部分动态编程语言(例如 PHP、Python 和 JavaScript)不能确保备忘录中的状态不被修改。

与其他模式的关系

  • 你可以同时使用命令模式备忘录模式来实现“撤销”。在这种情况下,命令用于对目标对象执行各种不同的操作,备忘录用来保存一条命令执行前该对象的状态。

  • 你可以同时使用备忘录迭代器模式来获取当前迭代器的状态,并且在需要的时候进行回滚。

  • 有时候原型模式可以作为备忘录的一个简化版本,其条件是你需要在历史记录中存储的对象的状态比较简单,不需要链接其他外部资源,或者链接可以方便地重建。

状态模式

亦称:State

意图

状态模式是一种行为设计模式,让你能在一个对象的内部状态变化时改变其行为,使其看上去就像改变了自身所属的类一样。

状态设计模式

问题

状态模式与有限状态机的概念紧密相关。

有限状态机

有限状态机。

其主要思想是程序在任意时刻仅可处于几种有限状态中。在任何一个特定状态中,程序的行为都不相同,且可瞬间从一个状态切换到另一个状态。不过,根据当前状态,程序可能会切换到另外一种状态,也可能会保持当前状态不变。这些数量有限且预先定义的状态切换规则被称为转移

你还可将该方法应用在对象上。假如你有一个文档Document类。文档可能会处于草稿Draft、​审阅中Moderation和已发布Published三种状态中的一种。文档的publish发布方法在不同状态下的行为略有不同:

  • 处于草稿状态时,它会将文档转移到审阅中状态。
  • 处于审阅中状态时,如果当前用户是管理员,它会公开发布文档。
  • 处于已发布状态时,它不会进行任何操作。

文档对象的全部状态

文档对象的全部状态和转移。

状态机通常由众多条件运算符(ifswitch)实现,可根据对象的当前状态选择相应的行为。​“状态”通常只是对象中的一组成员变量值。即使你之前从未听说过有限状态机,你也很可能已经实现过状态模式。下面的代码应该能帮助你回忆起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Document is
field state: string
// ...
method publish() is
switch (state)
"draft":
state = "moderation"
break
"moderation":
if (currentUser.role == 'admin')
state = "published"
break
"published":
// 什么也不做。
break
// ...

当我们逐步在文档类中添加更多状态和依赖于状态的行为后,基于条件语句的状态机就会暴露其最大的弱点。为了能根据当前状态选择完成相应行为的方法,绝大部分方法中会包含复杂的条件语句。修改其转换逻辑可能会涉及到修改所有方法中的状态条件语句,导致代码的维护工作非常艰难。

这个问题会随着项目进行变得越发严重。我们很难在设计阶段预测到所有可能的状态和转换。随着时间推移,最初仅包含有限条件语句的简洁状态机可能会变成臃肿的一团乱麻。

解决方案

状态模式建议为对象的所有可能状态新建一个类,然后将所有状态的对应行为抽取到这些类中。

原始对象被称为上下文(context),它并不会自行实现所有行为,而是会保存一个指向表示当前状态的状态对象的引用,且将所有与状态相关的工作委派给该对象。

文档将工作委派给一个状态对象

文档将工作委派给一个状态对象。

如需将上下文转换为另外一种状态,则需将当前活动的状态对象替换为另外一个代表新状态的对象。采用这种方式是有前提的:所有状态类都必须遵循同样的接口,而且上下文必须仅通过接口与这些对象进行交互。

这个结构可能看上去与策略模式相似,但有一个关键性的不同——在状态模式中,特定状态知道其他所有状态的存在,且能触发从一个状态到另一个状态的转换;策略则几乎完全不知道其他策略的存在。

真实世界类比

智能手机的按键和开关会根据设备当前状态完成不同行为:

  • 当手机处于解锁状态时,按下按键将执行各种功能。
  • 当手机处于锁定状态时,按下任何按键都将解锁屏幕。
  • 当手机电量不足时,按下任何按键都将显示充电页面。

状态模式结构

状态设计模式的结构状态设计模式的结构

  1. 上下文(Context)保存了对于一个具体状态对象的引用,并会将所有与该状态相关的工作委派给它。上下文通过状态接口与状态对象交互,且会提供一个设置器用于传递新的状态对象。

  2. 状态(State)接口会声明特定于状态的方法。这些方法应能被其他所有具体状态所理解,因为你不希望某些状态所拥有的方法永远不会被调用。

  3. 具体状态(Concrete States)会自行实现特定于状态的方法。为了避免多个状态中包含相似代码,你可以提供一个封装有部分通用行为的中间抽象类。

    状态对象可存储对于上下文对象的反向引用。状态可以通过该引用从上下文处获取所需信息,并且能触发状态转移。

  4. 上下文和具体状态都可以设置上下文的下个状态,并可通过替换连接到上下文的状态对象来完成实际的状态转换。

伪代码

在本例中,状态模式将根据当前回放状态,让媒体播放器中的相同控件完成不同的行为。

状态模式示例的结构

使用状态对象更改对象行为的示例。

播放器的主要对象总是会连接到一个负责播放器绝大部分工作的状态对象中。部分操作会更换播放器当前的状态对象,以此改变播放器对于用户互动所作出的反应。

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
// 音频播放器(Audio­Player)类即为上下文。它还会维护指向状态类实例的引用,
// 该状态类则用于表示音频播放器当前的状态。
class AudioPlayer is
field state: State
field UI, volume, playlist, currentSong

constructor AudioPlayer() is
this.state = new ReadyState(this)

// 上下文会将处理用户输入的工作委派给状态对象。由于每个状态都以不
// 同的方式处理输入,其结果自然将依赖于当前所处的状态。
UI = new UserInterface()
UI.lockButton.onClick(this.clickLock)
UI.playButton.onClick(this.clickPlay)
UI.nextButton.onClick(this.clickNext)
UI.prevButton.onClick(this.clickPrevious)

// 其他对象必须能切换音频播放器当前所处的状态。
method changeState(state: State) is
this.state = state

// UI 方法会将执行工作委派给当前状态。
method clickLock() is
state.clickLock()
method clickPlay() is
state.clickPlay()
method clickNext() is
state.clickNext()
method clickPrevious() is
state.clickPrevious()

// 状态可调用上下文的一些服务方法。
method startPlayback() is
// ...
method stopPlayback() is
// ...
method nextSong() is
// ...
method previousSong() is
// ...
method fastForward(time) is
// ...
method rewind(time) is
// ...


// 所有具体状态类都必须实现状态基类声明的方法,并提供反向引用指向与状态相
// 关的上下文对象。状态可使用反向引用将上下文转换为另一个状态。
abstract class State is
protected field player: AudioPlayer

// 上下文将自身传递给状态构造函数。这可帮助状态在需要时获取一些有用的
// 上下文数据。
constructor State(player) is
this.player = player

abstract method clickLock()
abstract method clickPlay()
abstract method clickNext()
abstract method clickPrevious()


// 具体状态会实现与上下文状态相关的多种行为。
class LockedState extends State is

// 当你解锁一个锁定的播放器时,它可能处于两种状态之一。
method clickLock() is
if (player.playing)
player.changeState(new PlayingState(player))
else
player.changeState(new ReadyState(player))

method clickPlay() is
// 已锁定,什么也不做。

method clickNext() is
// 已锁定,什么也不做。

method clickPrevious() is
// 已锁定,什么也不做。


// 它们还可在上下文中触发状态转换。
class ReadyState extends State is
method clickLock() is
player.changeState(new LockedState(player))

method clickPlay() is
player.startPlayback()
player.changeState(new PlayingState(player))

method clickNext() is
player.nextSong()

method clickPrevious() is
player.previousSong()


class PlayingState extends State is
method clickLock() is
player.changeState(new LockedState(player))

method clickPlay() is
player.stopPlayback()
player.changeState(new ReadyState(player))

method clickNext() is
if (event.doubleclick)
player.nextSong()
else
player.fastForward(5)

method clickPrevious() is
if (event.doubleclick)
player.previous()
else
player.rewind(5)

状态模式适合应用场景

如果对象需要根据自身当前状态进行不同行为,同时状态的数量非常多且与状态相关的代码会频繁变更的话,可使用状态模式。

模式建议你将所有特定于状态的代码抽取到一组独立的类中。这样一来,你可以在独立于其他状态的情况下添加新状态或修改已有状态,从而减少维护成本。

如果某个类需要根据成员变量的当前值改变自身行为,从而需要使用大量的条件语句时,可使用该模式。

状态模式会将这些条件语句的分支抽取到相应状态类的方法中。同时,你还可以清除主要类中与特定状态相关的临时成员变量和帮手方法代码。

当相似状态和基于条件的状态机转换中存在许多重复代码时,可使用状态模式。

状态模式让你能够生成状态类层次结构,通过将公用代码抽取到抽象基类中来减少重复。

实现方式

  1. 确定哪些类是上下文。它可能是包含依赖于状态的代码的已有类;如果特定于状态的代码分散在多个类中,那么它可能是一个新的类。

  2. 声明状态接口。虽然你可能会需要完全复制上下文中声明的所有方法,但最好是仅把关注点放在那些可能包含特定于状态的行为的方法上。

  3. 为每个实际状态创建一个继承于状态接口的类。然后检查上下文中的方法并将与特定状态相关的所有代码抽取到新建的类中。

    在将代码移动到状态类的过程中,你可能会发现它依赖于上下文中的一些私有成员。你可以采用以下几种变通方式:

    • 将这些成员变量或方法设为公有。
    • 将需要抽取的上下文行为更改为上下文中的公有方法,然后在状态类中调用。这种方式简陋却便捷,你可以稍后再对其进行修补。
    • 将状态类嵌套在上下文类中。这种方式需要你所使用的编程语言支持嵌套类。
  4. 在上下文类中添加一个状态接口类型的引用成员变量,以及一个用于修改该成员变量值的公有设置器。

  5. 再次检查上下文中的方法,将空的条件语句替换为相应的状态对象方法。

  6. 为切换上下文状态,你需要创建某个状态类实例并将其传递给上下文。你可以在上下文、各种状态或客户端中完成这项工作。无论在何处完成这项工作,该类都将依赖于其所实例化的具体类。

状态模式优缺点

  • 单一职责原则。将与特定状态相关的代码放在单独的类中。

  • 开闭原则。无需修改已有状态类和上下文就能引入新状态。

  • 通过消除臃肿的状态机条件语句简化上下文代码。

  • 如果状态机只有很少的几个状态,或者很少发生改变,那么应用该模式可能会显得小题大作。

与其他模式的关系

  • 桥接模式状态模式策略模式(在某种程度上包括适配器模式)模式的接口非常相似。实际上,它们都基于组合模式——即将工作委派给其他对象,不过也各自解决了不同的问题。模式并不只是以特定方式组织代码的配方,你还可以使用它们来和其他开发者讨论模式所解决的问题。

  • 状态可被视为策略的扩展。两者都基于组合机制:它们都通过将部分工作委派给“帮手”对象来改变其在不同情景下的行为。策略使得这些对象相互之间完全独立,它们不知道其他对象的存在。但状态模式没有限制具体状态之间的依赖,且允许它们自行改变在不同情景下的状态。

迭代器模式

亦称:Iterator

意图

迭代器模式是一种行为设计模式,让你能在不暴露集合底层表现形式(列表、栈和树等)的情况下遍历集合中所有的元素。

迭代器设计模式

问题

集合是编程中最常使用的数据类型之一。尽管如此,集合只是一组对象的容器而已。

各种类型的集合

各种类型的集合。

大部分集合使用简单列表存储元素。但有些集合还会使用栈、树、图和其他复杂的数据结构。

无论集合的构成方式如何,它都必须提供某种访问元素的方式,便于其他代码使用其中的元素。集合应提供一种能够遍历元素的方式,且保证它不会周而复始地访问同一个元素。

如果你的集合基于列表,那么这项工作听上去仿佛很简单。但如何遍历复杂数据结构(例如树)中的元素呢?例如,今天你需要使用深度优先算法来遍历树结构,明天可能会需要广度优先算法;下周则可能会需要其他方式(比如随机存取树中的元素)。

各种遍历算法

可通过不同的方式遍历相同的集合。

不断向集合中添加遍历算法会模糊其“高效存储数据”的主要职责。此外,有些算法可能是根据特定应用订制的,将其加入泛型集合类中会显得非常奇怪。

另一方面,使用多种集合的客户端代码可能并不关心存储数据的方式。不过由于集合提供不同的元素访问方式,你的代码将不得不与特定集合类进行耦合。

解决方案

迭代器模式的主要思想是将集合的遍历行为抽取为单独的迭代器对象。

迭代器可以实现不同算法

迭代器可实现多种遍历算法。多个迭代器对象可同时遍历同一个集合。

除实现自身算法外,迭代器还封装了遍历操作的所有细节,例如当前位置和末尾剩余元素的数量。因此,多个迭代器可以在相互独立的情况下同时访问集合。

迭代器通常会提供一个获取集合元素的基本方法。客户端可不断调用该方法直至它不返回任何内容,这意味着迭代器已经遍历了所有元素。

所有迭代器必须实现相同的接口。这样一来,只要有合适的迭代器,客户端代码就能兼容任何类型的集合或遍历算法。如果你需要采用特殊方式来遍历集合,只需创建一个新的迭代器类即可,无需对集合或客户端进行修改。

真实世界类比

漫步罗马的不同方式

漫步罗马的不同方式。

你计划在罗马游览数天,参观所有主要的旅游景点。但在到达目的地后,你可能会浪费很多时间绕圈子,甚至找不到罗马斗兽场在哪里。

或者你可以购买一款智能手机上的虚拟导游程序。这款程序非常智能而且价格不贵,你想在景点待多久都可以。

第三种选择是用部分旅行预算雇佣一位对城市了如指掌的当地向导。向导能根据你的喜好来安排行程,为你介绍每个景点并讲述许多激动人心的故事。这样的旅行可能会更有趣,但所需费用也会更高。

所有这些选择(自由漫步、智能手机导航或真人向导)都是这个由众多罗马景点组成的集合的迭代器。

迭代器模式结构

迭代器设计模式的结构迭代器设计模式的结构

  1. 迭代器(Iterator)接口声明了遍历集合所需的操作:获取下一个元素、获取当前位置和重新开始迭代等。

  2. 具体迭代器(Concrete Iterators)实现遍历集合的一种特定算法。迭代器对象必须跟踪自身遍历的进度。这使得多个迭代器可以相互独立地遍历同一集合。

  3. 集合(Collection)接口声明一个或多个方法来获取与集合兼容的迭代器。请注意,返回方法的类型必须被声明为迭代器接口,因此具体集合可以返回各种不同种类的迭代器。

  4. 具体集合(Concrete Collections)会在客户端请求迭代器时返回一个特定的具体迭代器类实体。你可能会琢磨,剩下的集合代码在什么地方呢?不用担心,它也会在同一个类中。只是这些细节对于实际模式来说并不重要,所以我们将其省略了而已。

  5. 客户端(Client)通过集合和迭代器的接口与两者进行交互。这样一来客户端无需与具体类进行耦合,允许同一客户端代码使用各种不同的集合和迭代器。

    客户端通常不会自行创建迭代器,而是会从集合中获取。但在特定情况下,客户端可以直接创建一个迭代器(例如当客户端需要自定义特殊迭代器时)。

伪代码

在本例中,迭代器模式用于遍历一个封装了访问微信好友关系功能的特殊集合。该集合提供使用不同方式遍历档案资料的多个迭代器。

迭代器模式示例的结构

遍历社交档案的示例

“好友(friends)”迭代器可用于遍历指定档案的好友。​“同事(colleagues)”迭代器也提供同样的功能,但仅包括与目标用户在同一家公司工作的好友。这两个迭代器都实现了同一个通用接口,客户端能在不了解认证和发送 REST 请求等实现细节的情况下获取档案。

客户端仅通过接口与集合和迭代器交互,也就不会同具体类耦合。如果你决定将应用连接到全新的社交网络,只需提供新的集合和迭代器类即可,无需修改现有代码。

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
// 集合接口必须声明一个用于生成迭代器的工厂方法。如果程序中有不同类型的迭
// 代器,你也可以声明多个方法。
interface SocialNetwork is
method createFriendsIterator(profileId):ProfileIterator
method createCoworkersIterator(profileId):ProfileIterator


// 每个具体集合都与其返回的一组具体迭代器相耦合。但客户并不是这样的,因为
// 这些方法的签名将会返回迭代器接口。
class WeChat implements SocialNetwork is
// ...大量的集合代码应该放在这里...

// 迭代器创建代码。
method createFriendsIterator(profileId) is
return new WeChatIterator(this, profileId, "friends")
method createCoworkersIterator(profileId) is
return new WeChatIterator(this, profileId, "coworkers")


// 所有迭代器的通用接口。
interface ProfileIterator is
method getNext():Profile
method hasMore():bool


// 具体迭代器类。
class WeChatIterator implements ProfileIterator is
// 迭代器需要一个指向其遍历集合的引用。
private field weChat: WeChat
private field profileId, type: string

// 迭代器对象会独立于其他迭代器来对集合进行遍历。因此它必须保存迭代器
// 的状态。
private field currentPosition
private field cache: array of Profile

constructor WeChatIterator(weChat, profileId, type) is
this.weChat = weChat
this.profileId = profileId
this.type = type

private method lazyInit() is
if (cache == null)
cache = weChat.socialGraphRequest(profileId, type)

// 每个具体迭代器类都会自行实现通用迭代器接口。
method getNext() is
if (hasMore())
currentPosition++
return cache[currentPosition]

method hasMore() is
lazyInit()
return currentPosition < cache.length


// 这里还有一个有用的绝招:你可将迭代器传递给客户端类,无需让其拥有访问整
// 个集合的权限。这样一来,你就无需将集合暴露给客户端了。
//
// 还有另一个好处:你可在运行时将不同的迭代器传递给客户端,从而改变客户端
// 与集合互动的方式。这一方法可行的原因是客户端代码并没有和具体迭代器类相
// 耦合。
class SocialSpammer is
method send(iterator: ProfileIterator, message: string) is
while (iterator.hasMore())
profile = iterator.getNext()
System.sendEmail(profile.getEmail(), message)


// 应用程序(Application)类可对集合和迭代器进行配置,然后将其传递给客户
// 端代码。
class Application is
field network: SocialNetwork
field spammer: SocialSpammer

method config() is
if working with WeChat
this.network = new WeChat()
if working with LinkedIn
this.network = new LinkedIn()
this.spammer = new SocialSpammer()

method sendSpamToFriends(profile) is
iterator = network.createFriendsIterator(profile.getId())
spammer.send(iterator, "非常重要的消息")

method sendSpamToCoworkers(profile) is
iterator = network.createCoworkersIterator(profile.getId())
spammer.send(iterator, "非常重要的消息")

迭代器模式适合应用场景

当集合背后为复杂的数据结构,且你希望对客户端隐藏其复杂性时(出于使用便利性或安全性的考虑),可以使用迭代器模式。

迭代器封装了与复杂数据结构进行交互的细节,为客户端提供多个访问集合元素的简单方法。这种方式不仅对客户端来说非常方便,而且能避免客户端在直接与集合交互时执行错误或有害的操作,从而起到保护集合的作用。

使用该模式可以减少程序中重复的遍历代码。

重要迭代算法的代码往往体积非常庞大。当这些代码被放置在程序业务逻辑中时,它会让原始代码的职责模糊不清,降低其可维护性。因此,将遍历代码移到特定的迭代器中可使程序代码更加精炼和简洁。

如果你希望代码能够遍历不同的甚至是无法预知的数据结构,可以使用迭代器模式。

该模式为集合和迭代器提供了一些通用接口。如果你在代码中使用了这些接口,那么将其他实现了这些接口的集合和迭代器传递给它时,它仍将可以正常运行。

实现方式

  1. 声明迭代器接口。该接口必须提供至少一个方法来获取集合中的下个元素。但为了使用方便,你还可以添加一些其他方法,例如获取前一个元素、记录当前位置和判断迭代是否已结束。

  2. 声明集合接口并描述一个获取迭代器的方法。其返回值必须是迭代器接口。如果你计划拥有多组不同的迭代器,则可以声明多个类似的方法。

  3. 为希望使用迭代器进行遍历的集合实现具体迭代器类。迭代器对象必须与单个集合实体链接。链接关系通常通过迭代器的构造函数建立。

  4. 在你的集合类中实现集合接口。其主要思想是针对特定集合为客户端代码提供创建迭代器的快捷方式。集合对象必须将自身传递给迭代器的构造函数来创建两者之间的链接。

  5. 检查客户端代码,使用迭代器替代所有集合遍历代码。每当客户端需要遍历集合元素时都会获取一个新的迭代器。

迭代器模式优缺点

  • 单一职责原则。通过将体积庞大的遍历算法代码抽取为独立的类,你可对客户端代码和集合进行整理。

  • 开闭原则。你可实现新型的集合和迭代器并将其传递给现有代码,无需修改现有代码。

  • 你可以并行遍历同一集合,因为每个迭代器对象都包含其自身的遍历状态。

  • 相似的,你可以暂停遍历并在需要时继续。

  • 如果你的程序只与简单的集合进行交互,应用该模式可能会矫枉过正。

  • 对于某些特殊集合,使用迭代器可能比直接遍历的效率低。

与其他模式的关系

工厂方法模式

亦称:虚拟构造函数、Virtual Constructor、Factory Method

意图

工厂方法模式是一种创建型设计模式,其在父类中提供一个创建对象的方法,允许子类决定实例化对象的类型。

工厂方法模式

问题

假设你正在开发一款物流管理应用。最初版本只能处理卡车运输,因此大部分代码都在位于名为卡车的类中。

一段时间后,这款应用变得极受欢迎。你每天都能收到十几次来自海运公司的请求,希望应用能够支持海上物流功能。

在程序中新增一个运输类会遇到问题

如果代码其余部分与现有类已经存在耦合关系,那么向程序中添加新类其实并没有那么容易。

这可是个好消息。但是代码问题该如何处理呢?目前,大部分代码都与卡车类相关。在程序中添加轮船类需要修改全部代码。更糟糕的是,如果你以后需要在程序中支持另外一种运输方式,很可能需要再次对这些代码进行大幅修改。

最后,你将不得不编写繁复的代码,根据不同的运输对象类,在应用中进行不同的处理。

解决方案

工厂方法模式建议使用特殊的工厂方法代替对于对象构造函数的直接调用(即使用new运算符)。不用担心,对象仍将通过new运算符创建,只是该运算符改在工厂方法中调用罢了。工厂方法返回的对象通常被称作“产品”。

创建者类结构

子类可以修改工厂方法返回的对象类型。

乍看之下,这种更改可能毫无意义:我们只是改变了程序中调用构造函数的位置而已。但是,仔细想一下,现在你可以在子类中重写工厂方法,从而改变其创建产品的类型。

但有一点需要注意:仅当这些产品具有共同的基类或者接口时,子类才能返回不同类型的产品,同时基类中的工厂方法还应将其返回类型声明为这一共有接口。

产品对象层次结构

所有产品都必须使用同一接口。

举例来说,​卡车Truck和轮船Ship类都必须实现运输Transport接口,该接口声明了一个名为deliver交付的方法。每个类都将以不同的方式实现该方法:卡车走陆路交付货物,轮船走海路交付货物。​陆路运输Road­Logistics类中的工厂方法返回卡车对象,而海路运输Sea­Logistics类则返回轮船对象。

使用工厂方法模式后的代码结构

只要产品类实现一个共同的接口,你就可以将其对象传递给客户代码,而无需提供额外数据。

调用工厂方法的代码(通常被称为客户端代码)无需了解不同子类返回实际对象之间的差别。客户端将所有产品视为抽象的运输。客户端知道所有运输对象都提供交付方法,但是并不关心其具体实现方式。

工厂方法模式结构

工厂方法模式结构工厂方法模式结构

  1. 产品(Product)将会对接口进行声明。对于所有由创建者及其子类构建的对象,这些接口都是通用的。

  2. 具体产品(Concrete Products)是产品接口的不同实现。

  3. 创建者(Creator)类声明返回产品对象的工厂方法。该方法的返回对象类型必须与产品接口相匹配。

    你可以将工厂方法声明为抽象方法,强制要求每个子类以不同方式实现该方法。或者,你也可以在基础工厂方法中返回默认产品类型。

    注意,尽管它的名字是创建者,但它最主要的职责并不是创建产品。一般来说,创建者类包含一些与产品相关的核心业务逻辑。工厂方法将这些逻辑处理从具体产品类中分离出来。打个比方,大型软件开发公司拥有程序员培训部门。但是,这些公司的主要工作还是编写代码,而非生产程序员。

  4. 具体创建者(Concrete Creators)将会重写基础工厂方法,使其返回不同类型的产品。

    注意,并不一定每次调用工厂方法都会创建新的实例。工厂方法也可以返回缓存、对象池或其他来源的已有对象。

伪代码

以下示例演示了如何使用工厂方法开发跨平台 UI(用户界面)组件,并同时避免客户代码与具体 UI 类之间的耦合。

工厂方法模式示例结构

跨平台对话框示例。

基础对话框类使用不同的 UI 组件渲染窗口。在不同的操作系统下,这些组件外观或许略有不同,但其功能保持一致。Windows 系统中的按钮在 Linux 系统中仍然是按钮。

如果使用工厂方法,就不需要为每种操作系统重写对话框逻辑。如果我们声明了一个在基本对话框类中生成按钮的工厂方法,那么我们就可以创建一个对话框子类,并使其通过工厂方法返回 Windows 样式按钮。子类将继承对话框基础类的大部分代码,同时在屏幕上根据 Windows 样式渲染按钮。

如需该模式正常工作,基础对话框类必须使用抽象按钮(例如基类或接口),以便将其扩展为具体按钮。这样一来,无论对话框中使用何种类型的按钮,其代码都可以正常工作。

你可以使用此方法开发其他 UI 组件。不过,每向对话框中添加一个新的工厂方法,你就离抽象工厂模式更近一步。我们将在稍后谈到这个模式。

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
// 创建者类声明的工厂方法必须返回一个产品类的对象。创建者的子类通常会提供
// 该方法的实现。
class Dialog is
// 创建者还可提供一些工厂方法的默认实现。
abstract method createButton():Button

// 请注意,创建者的主要职责并非是创建产品。其中通常会包含一些核心业务
// 逻辑,这些逻辑依赖于由工厂方法返回的产品对象。子类可通过重写工厂方
// 法并使其返回不同类型的产品来间接修改业务逻辑。
method render() is
// 调用工厂方法创建一个产品对象。
Button okButton = createButton()
// 现在使用产品。
okButton.onClick(closeDialog)
okButton.render()


// 具体创建者将重写工厂方法以改变其所返回的产品类型。
class WindowsDialog extends Dialog is
method createButton():Button is
return new WindowsButton()

class WebDialog extends Dialog is
method createButton():Button is
return new HTMLButton()


// 产品接口中将声明所有具体产品都必须实现的操作。
interface Button is
method render()
method onClick(f)

// 具体产品需提供产品接口的各种实现。
class WindowsButton implements Button is
method render(a, b) is
// 根据 Windows 样式渲染按钮。
method onClick(f) is
// 绑定本地操作系统点击事件。

class HTMLButton implements Button is
method render(a, b) is
// 返回一个按钮的 HTML 表述。
method onClick(f) is
// 绑定网络浏览器的点击事件。


class Application is
field dialog: Dialog

// 程序根据当前配置或环境设定选择创建者的类型。
method initialize() is
config = readApplicationConfigFile()

if (config.OS == "Windows") then
dialog = new WindowsDialog()
else if (config.OS == "Web") then
dialog = new WebDialog()
else
throw new Exception("错误!未知的操作系统。")

// 当前客户端代码会与具体创建者的实例进行交互,但是必须通过其基本接口
// 进行。只要客户端通过基本接口与创建者进行交互,你就可将任何创建者子
// 类传递给客户端。
method main() is
this.initialize()
dialog.render()

工厂方法模式适合应用场景

当你在编写代码的过程中,如果无法预知对象确切类别及其依赖关系时,可使用工厂方法。

工厂方法将创建产品的代码与实际使用产品的代码分离,从而能在不影响其他代码的情况下扩展产品创建部分代码。

例如,如果需要向应用中添加一种新产品,你只需要开发新的创建者子类,然后重写其工厂方法即可。

如果你希望用户能扩展你软件库或框架的内部组件,可使用工厂方法。

继承可能是扩展软件库或框架默认行为的最简单方法。但是当你使用子类替代标准组件时,框架如何辨识出该子类?

解决方案是将各框架中构造组件的代码集中到单个工厂方法中,并在继承该组件之外允许任何人对该方法进行重写。

让我们看看具体是如何实现的。假设你使用开源 UI 框架编写自己的应用。你希望在应用中使用圆形按钮,但是原框架仅支持矩形按钮。你可以使用圆形按钮Round­Button子类来继承标准的按钮Button类。但是,你需要告诉UI框架UIFramework类使用新的子类按钮代替默认按钮。

为了实现这个功能,你可以根据基础框架类开发子类圆形按钮 UIUIWith­Round­Buttons,并且重写其create&shy;Button创建按钮方法。基类中的该方法返回按钮对象,而你开发的子类返回圆形按钮对象。现在,你就可以使用圆形按钮 UI类代替UI框架类。就是这么简单!

如果你希望复用现有对象来节省系统资源,而不是每次都重新创建对象,可使用工厂方法。

在处理大型资源密集型对象(比如数据库连接、文件系统和网络资源)时,你会经常碰到这种资源需求。

让我们思考复用现有对象的方法:

  1. 首先,你需要创建存储空间来存放所有已经创建的对象。
  2. 当他人请求一个对象时,程序将在对象池中搜索可用对象。
  3. …然后将其返回给客户端代码。
  4. 如果没有可用对象,程序则创建一个新对象(并将其添加到对象池中)。

这些代码可不少!而且它们必须位于同一处,这样才能确保重复代码不会污染程序。

可能最显而易见,也是最方便的方式,就是将这些代码放置在我们试图重用的对象类的构造函数中。但是从定义上来讲,构造函数始终返回的是新对象,其无法返回现有实例。

因此,你需要有一个既能够创建新对象,又可以重用现有对象的普通方法。这听上去和工厂方法非常相像。

实现方式

  1. 让所有产品都遵循同一接口。该接口必须声明对所有产品都有意义的方法。

  2. 在创建类中添加一个空的工厂方法。该方法的返回类型必须遵循通用的产品接口。

  3. 在创建者代码中找到对于产品构造函数的所有引用。将它们依次替换为对于工厂方法的调用,同时将创建产品的代码移入工厂方法。你可能需要在工厂方法中添加临时参数来控制返回的产品类型。

    工厂方法的代码看上去可能非常糟糕。其中可能会有复杂的switch分支运算符,用于选择各种需要实例化的产品类。但是不要担心,我们很快就会修复这个问题。

  4. 现在,为工厂方法中的每种产品编写一个创建者子类,然后在子类中重写工厂方法,并将基本方法中的相关创建代码移动到工厂方法中。

  5. 如果应用中的产品类型太多,那么为每个产品创建子类并无太大必要,这时你也可以在子类中复用基类中的控制参数。

    例如,设想你有以下一些层次结构的类。基类邮件及其子类航空邮件陆路邮件;​运输及其子类飞机,卡车火车。​航空邮件仅使用飞机对象,而陆路邮件则会同时使用卡车火车对象。你可以编写一个新的子类(例如火车邮件)来处理这两种情况,但是还有其他可选的方案。客户端代码可以给陆路邮件类传递一个参数,用于控制其希望获得的产品。

  6. 如果代码经过上述移动后,基础工厂方法中已经没有任何代码,你可以将其转变为抽象类。如果基础工厂方法中还有其他语句,你可以将其设置为该方法的默认行为。

工厂方法模式优缺点

  • 你可以避免创建者和具体产品之间的紧密耦合。

  • 单一职责原则。你可以将产品创建代码放在程序的单一位置,从而使得代码更容易维护。

  • 开闭原则。无需更改现有客户端代码,你就可以在程序中引入新的产品类型。

  • 应用工厂方法模式需要引入许多新的子类,代码可能会因此变得更复杂。最好的情况是将该模式引入创建者类的现有层次结构中。

与其他模式的关系

装饰模式

亦称:装饰者模式、装饰器模式、Wrapper、Decorator

意图

装饰模式是一种结构型设计模式,允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。

装饰设计模式

问题

假设你正在开发一个提供通知功能的库,其他程序可使用它向用户发送关于重要事件的通知。

库的最初版本基于通知器Notifier类,其中只有很少的几个成员变量,一个构造函数和一个send发送方法。该方法可以接收来自客户端的消息参数,并将该消息发送给一系列的邮箱,邮箱列表则是通过构造函数传递给通知器的。作为客户端的第三方程序仅会创建和配置通知器对象一次,然后在有重要事件发生时对其进行调用。

使用装饰模式前的库结构

程序可以使用通知器类向预定义的邮箱发送重要事件通知。

此后某个时刻,你会发现库的用户希望使用除邮件通知之外的功能。许多用户会希望接收关于紧急事件的手机短信,还有些用户希望在微信上接收消息,而公司用户则希望在 QQ 上接收消息。

实现其他类型通知后的库结构

每种通知类型都将作为通知器的一个子类得以实现。

这有什么难的呢?首先扩展通知器类,然后在新的子类中加入额外的通知方法。现在客户端要对所需通知形式的对应类进行初始化,然后使用该类发送后续所有的通知消息。

但是很快有人会问:​“为什么不同时使用多种通知形式呢?如果房子着火了,你大概会想在所有渠道中都收到相同的消息吧。”

你可以尝试创建一个特殊子类来将多种通知方法组合在一起以解决该问题。但这种方式会使得代码量迅速膨胀,不仅仅是程序库代码,客户端代码也会如此。

创建组合类后的程序库结构

子类组合数量爆炸。

你必须找到其他方法来规划通知类的结构,否则它们的数量会在不经意之间打破吉尼斯纪录。

解决方案

当你需要更改一个对象的行为时,第一个跳入脑海的想法就是扩展它所属的类。但是,你不能忽视继承可能引发的几个严重问题。

  • 继承是静态的。你无法在运行时更改已有对象的行为,只能使用由不同子类创建的对象来替代当前的整个对象。

  • 子类只能有一个父类。大部分编程语言不允许一个类同时继承多个类的行为。

其中一种方法是用聚合组合,而不是继承。两者的工作方式几乎一模一样:一个对象包含指向另一个对象的引用,并将部分工作委派给引用对象;继承中的对象则继承了父类的行为,它们自己能够完成这些工作。

你可以使用这个新方法来轻松替换各种连接的“小帮手”对象,从而能在运行时改变容器的行为。一个对象可以使用多个类的行为,包含多个指向其他对象的引用,并将各种工作委派给引用对象。

聚合(或组合)组合是许多设计模式背后的关键原则(包括装饰在内)。记住这一点后,让我们继续关于模式的讨论。

继承与聚合的对比

继承与聚合的对比

封装器是装饰模式的别称,这个称谓明确地表达了该模式的主要思想。​“封装器”是一个能与其他“目标”对象连接的对象。封装器包含与目标对象相同的一系列方法,它会将所有接收到的请求委派给目标对象。但是,封装器可以在将请求委派给目标前后对其进行处理,所以可能会改变最终结果。

那么什么时候一个简单的封装器可以被称为是真正的装饰呢?正如之前提到的,封装器实现了与其封装对象相同的接口。因此从客户端的角度来看,这些对象是完全一样的。封装器中的引用成员变量可以是遵循相同接口的任意对象。这使得你可以将一个对象放入多个封装器中,并在对象中添加所有这些封装器的组合行为。

比如在消息通知示例中,我们可以将简单邮件通知行为放在基类通知器中,但将所有其他通知方法放入装饰中。

装饰模式解决方案

将各种通知方法放入装饰。

客户端代码必须将基础通知器放入一系列自己所需的装饰中。因此最后的对象将形成一个栈结构。

程序可以配置由通知装饰构成的复杂栈

程序可以配置由通知装饰构成的复杂栈。

实际与客户端进行交互的对象将是最后一个进入栈中的装饰对象。由于所有的装饰都实现了与通知基类相同的接口,客户端的其他代码并不在意自己到底是与“纯粹”的通知器对象,还是与装饰后的通知器对象进行交互。

我们可以使用相同方法来完成其他行为(例如设置消息格式或者创建接收人列表)。只要所有装饰都遵循相同的接口,客户端就可以使用任意自定义的装饰来装饰对象。

真实世界类比

装饰模式示例

穿上多件衣服将获得组合性的效果。

穿衣服是使用装饰的一个例子。觉得冷时,你可以穿一件毛衣。如果穿毛衣还觉得冷,你可以再套上一件夹克。如果遇到下雨,你还可以再穿一件雨衣。所有这些衣物都“扩展”了你的基本行为,但它们并不是你的一部分,如果你不再需要某件衣物,可以方便地随时脱掉。

装饰模式结构

装饰设计模式的结构装饰设计模式的结构

  1. 部件(Component)声明封装器和被封装对象的公用接口。

  2. 具体部件(Concrete Component)类是被封装对象所属的类。它定义了基础行为,但装饰类可以改变这些行为。

  3. 基础装饰(Base Decorator)类拥有一个指向被封装对象的引用成员变量。该变量的类型应当被声明为通用部件接口,这样它就可以引用具体的部件和装饰。装饰基类会将所有操作委派给被封装的对象。

  4. 具体装饰类(Concrete Decorators)定义了可动态添加到部件的额外行为。具体装饰类会重写装饰基类的方法,并在调用父类方法之前或之后进行额外的行为。

  5. 客户端(Client)可以使用多层装饰来封装部件,只要它能使用通用接口与所有对象互动即可。

伪代码

在本例中,**装饰*模式能够对敏感数据进行压缩和加密,从而将数据从使用数据的代码中独立出来。

装饰模式示例的结构

加密和压缩装饰的示例。

程序使用一对装饰来封装数据源对象。这两个封装器都改变了从磁盘读写数据的方式:

  • 当数据即将被写入磁盘前,装饰对数据进行加密和压缩。在原始类对改变毫无察觉的情况下,将加密后的受保护数据写入文件。

  • 当数据刚从磁盘读出后,同样通过装饰对数据进行解压和解密。装饰和数据源类实现同一接口,从而能在客户端代码中相互替换。

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
// 装饰可以改变组件接口所定义的操作。
interface DataSource is
method writeData(data)
method readData():data

// 具体组件提供操作的默认实现。这些类在程序中可能会有几个变体。
class FileDataSource implements DataSource is
constructor FileDataSource(filename) { ... }

method writeData(data) is
// 将数据写入文件。

method readData():data is
// 从文件读取数据。

// 装饰基类和其他组件遵循相同的接口。该类的主要任务是定义所有具体装饰的封
// 装接口。封装的默认实现代码中可能会包含一个保存被封装组件的成员变量,并
// 且负责对其进行初始化。
class DataSourceDecorator implements DataSource is
protected field wrappee: DataSource

constructor DataSourceDecorator(source: DataSource) is
wrappee = source

// 装饰基类会直接将所有工作分派给被封装组件。具体装饰中则可以新增一些
// 额外的行为。
method writeData(data) is
wrappee.writeData(data)

// 具体装饰可调用其父类的操作实现,而不是直接调用被封装对象。这种方式
// 可简化装饰类的扩展工作。
method readData():data is
return wrappee.readData()

// 具体装饰必须在被封装对象上调用方法,不过也可以自行在结果中添加一些内容。
// 装饰必须在调用封装对象之前或之后执行额外的行为。
class EncryptionDecorator extends DataSourceDecorator is
method writeData(data) is
// 1. 对传递数据进行加密。
// 2. 将加密后数据传递给被封装对象 writeData(写入数据)方法。

method readData():data is
// 1. 通过被封装对象的 readData(读取数据)方法获取数据。
// 2. 如果数据被加密就尝试解密。
// 3. 返回结果。

// 你可以将对象封装在多层装饰中。
class CompressionDecorator extends DataSourceDecorator is
method writeData(data) is
// 1. 压缩传递数据。
// 2. 将压缩后数据传递给被封装对象 writeData(写入数据)方法。

method readData():data is
// 1. 通过被封装对象的 readData(读取数据)方法获取数据。
// 2. 如果数据被压缩就尝试解压。
// 3. 返回结果。


// 选项 1:装饰组件的简单示例
class Application is
method dumbUsageExample() is
source = new FileDataSource("somefile.dat")
source.writeData(salaryRecords)
// 已将明码数据写入目标文件。

source = new CompressionDecorator(source)
source.writeData(salaryRecords)
// 已将压缩数据写入目标文件。

source = new EncryptionDecorator(source)
// 源变量中现在包含:
// Encryption > Compression > FileDataSource
source.writeData(salaryRecords)
// 已将压缩且加密的数据写入目标文件。


// 选项 2:客户端使用外部数据源。SalaryManager(工资管理器)对象并不关心
// 数据如何存储。它们会与提前配置好的数据源进行交互,数据源则是通过程序配
// 置器获取的。
class SalaryManager is
field source: DataSource

constructor SalaryManager(source: DataSource) { ... }

method load() is
return source.readData()

method save() is
source.writeData(salaryRecords)
// ...其他有用的方法...


// 程序可在运行时根据配置或环境组装不同的装饰堆桟。
class ApplicationConfigurator is
method configurationExample() is
source = new FileDataSource("salary.dat")
if (enabledEncryption)
source = new EncryptionDecorator(source)
if (enabledCompression)
source = new CompressionDecorator(source)

logger = new SalaryManager(source)
salary = logger.load()
// ...

装饰模式适合应用场景

如果你希望在无需修改代码的情况下即可使用对象,且希望在运行时为对象新增额外的行为,可以使用装饰模式。

装饰能将业务逻辑组织为层次结构,你可为各层创建一个装饰,在运行时将各种不同逻辑组合成对象。由于这些对象都遵循通用接口,客户端代码能以相同的方式使用这些对象。

如果用继承来扩展对象行为的方案难以实现或者根本不可行,你可以使用该模式。

许多编程语言使用final最终关键字来限制对某个类的进一步扩展。复用最终类已有行为的唯一方法是使用装饰模式:用封装器对其进行封装。

实现方式

  1. 确保业务逻辑可用一个基本组件及多个额外可选层次表示。

  2. 找出基本组件和可选层次的通用方法。创建一个组件接口并在其中声明这些方法。

  3. 创建一个具体组件类,并定义其基础行为。

  4. 创建装饰基类,使用一个成员变量存储指向被封装对象的引用。该成员变量必须被声明为组件接口类型,从而能在运行时连接具体组件和装饰。装饰基类必须将所有工作委派给被封装的对象。

  5. 确保所有类实现组件接口。

  6. 将装饰基类扩展为具体装饰。具体装饰必须在调用父类方法(总是委派给被封装对象)之前或之后执行自身的行为。

  7. 客户端代码负责创建装饰并将其组合成客户端所需的形式。

装饰模式优缺点

  • 你无需创建新子类即可扩展对象的行为。

  • 你可以在运行时添加或删除对象的功能。

  • 你可以用多个装饰封装对象来组合几种行为。

  • 单一职责原则。你可以将实现了许多不同行为的一个大类拆分为多个较小的类。

  • 在封装器栈中删除特定封装器比较困难。

  • 实现行为不受装饰栈顺序影响的装饰比较困难。

  • 各层的初始化配置代码看上去可能会很糟糕。

与其他模式的关系

  • 适配器模式可以对已有对象的接口进行修改,装饰模式则能在不改变对象接口的前提下强化对象功能。此外,装饰还支持递归组合,适配器则无法实现。

  • 适配器能为被封装对象提供不同的接口,代理模式能为对象提供相同的接口,装饰则能为对象提供加强的接口。

  • 责任链模式装饰模式的类结构非常相似。两者都依赖递归组合将需要执行的操作传递给一系列对象。但是,两者有几点重要的不同之处。

    责任链的管理者可以相互独立地执行一切操作,还可以随时停止传递请求。另一方面,各种装饰可以在遵循基本接口的情况下扩展对象的行为。此外,装饰无法中断请求的传递。

  • 组合模式装饰的结构图很相似,因为两者都依赖递归组合来组织无限数量的对象。

    装饰类似于组合,但其只有一个子组件。此外还有一个明显不同:装饰为被封装对象添加了额外的职责,组合仅对其子节点的结果进行了“求和”。

    但是,模式也可以相互合作:你可以使用装饰来扩展组合树中特定对象的行为。

  • 大量使用组合装饰的设计通常可从对于原型模式的使用中获益。你可以通过该模式来复制复杂结构,而非从零开始重新构造。

  • 装饰可让你更改对象的外表,策略模式则让你能够改变其本质。

  • 装饰代理有着相似的结构,但是其意图却非常不同。这两个模式的构建都基于组合原则,也就是说一个对象应该将部分工作委派给另一个对象。两者之间的不同之处在于代理通常自行管理其服务对象的生命周期,而装饰的生成则总是由客户端进行控制。

策略模式

亦称:Strategy

意图

策略模式是一种行为设计模式,它能让你定义一系列算法,并将每种算法分别放入独立的类中,以使算法的对象能够相互替换。

策略设计模式

问题

一天,你打算为游客们创建一款导游程序。该程序的核心功能是提供美观的地图,以帮助用户在任何城市中快速定位。

用户期待的程序新功能是自动路线规划:他们希望输入地址后就能在地图上看到前往目的地的最快路线。

程序的首个版本只能规划公路路线。驾车旅行的人们对此非常满意。但很显然,并非所有人都会在度假时开车。因此你在下次更新时添加了规划步行路线的功能。此后,你又添加了规划公共交通路线的功能。

而这只是个开始。不久后,你又要为骑行者规划路线。又过了一段时间,你又要为游览城市中的所有景点规划路线。

导游代码将变得非常臃肿

导游代码将变得非常臃肿。

尽管从商业角度来看,这款应用非常成功,但其技术部分却让你非常头疼:每次添加新的路线规划算法后,导游应用中主要类的体积就会增加一倍。终于在某个时候,你觉得自己没法继续维护这堆代码了。

无论是修复简单缺陷还是微调街道权重,对某个算法进行任何修改都会影响整个类,从而增加在已有正常运行代码中引入错误的风险。

此外,团队合作将变得低效。如果你在应用成功发布后招募了团队成员,他们会抱怨在合并冲突的工作上花费了太多时间。在实现新功能的过程中,你的团队需要修改同一个巨大的类,这样他们所编写的代码相互之间就可能会出现冲突。

解决方案

策略模式建议找出负责用许多不同方式完成特定任务的类,然后将其中的算法抽取到一组被称为策略的独立类中。

名为上下文的原始类必须包含一个成员变量来存储对于每种策略的引用。上下文并不执行任务,而是将工作委派给已连接的策略对象。

上下文不负责选择符合任务需要的算法——客户端会将所需策略传递给上下文。实际上,上下文并不十分了解策略,它会通过同样的通用接口与所有策略进行交互,而该接口只需暴露一个方法来触发所选策略中封装的算法即可。

因此,上下文可独立于具体策略。这样你就可在不修改上下文代码或其他策略的情况下添加新算法或修改已有算法了。

路线规划策略

路线规划策略。

在导游应用中,每个路线规划算法都可被抽取到只有一个build&shy;Route生成路线方法的独立类中。该方法接收起点和终点作为参数,并返回路线中途点的集合。

即使传递给每个路径规划类的参数一模一样,其所创建的路线也可能完全不同。主要导游类的主要工作是在地图上渲染一系列中途点,不会在意如何选择算法。该类中还有一个用于切换当前路径规划策略的方法,因此客户端(例如用户界面中的按钮)可用其他策略替换当前选择的路径规划行为。

真实世界类比

各种出行策略

各种前往机场的出行策略

假如你需要前往机场。你可以选择乘坐公共汽车、预约出租车或骑自行车。这些就是你的出行策略。你可以根据预算或时间等因素来选择其中一种策略。

策略模式结构

策略设计模式的结构策略设计模式的结构

  1. 上下文(Context)维护指向具体策略的引用,且仅通过策略接口与该对象进行交流。

  2. 策略(Strategy)接口是所有具体策略的通用接口,它声明了一个上下文用于执行策略的方法。

  3. 具体策略(Concrete Strategies)实现了上下文所用算法的各种不同变体。

  4. 当上下文需要运行算法时,它会在其已连接的策略对象上调用执行方法。上下文不清楚其所涉及的策略类型与算法的执行方式。

  5. 客户端(Client)会创建一个特定策略对象并将其传递给上下文。上下文则会提供一个设置器以便客户端在运行时替换相关联的策略。

伪代码

在本例中,上下文使用了多个策略来执行不同的计算操作。

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
// 策略接口声明了某个算法各个不同版本间所共有的操作。上下文会使用该接口来
// 调用有具体策略定义的算法。
interface Strategy is
method execute(a, b)

// 具体策略会在遵循策略基础接口的情况下实现算法。该接口实现了它们在上下文
// 中的互换性。
class ConcreteStrategyAdd implements Strategy is
method execute(a, b) is
return a + b

class ConcreteStrategySubtract implements Strategy is
method execute(a, b) is
return a - b

class ConcreteStrategyMultiply implements Strategy is
method execute(a, b) is
return a * b

// 上下文定义了客户端关注的接口。
class Context is
// 上下文会维护指向某个策略对象的引用。上下文不知晓策略的具体类。上下
// 文必须通过策略接口来与所有策略进行交互。
private strategy: Strategy

// 上下文通常会通过构造函数来接收策略对象,同时还提供设置器以便在运行
// 时切换策略。
method setStrategy(Strategy strategy) is
this.strategy = strategy

// 上下文会将一些工作委派给策略对象,而不是自行实现不同版本的算法。
method executeStrategy(int a, int b) is
return strategy.execute(a, b)


// 客户端代码会选择具体策略并将其传递给上下文。客户端必须知晓策略之间的差
// 异,才能做出正确的选择。
class ExampleApplication is
method main() is

创建上下文对象。

读取第一个数。
读取最后一个数。
从用户输入中读取期望进行的行为。

if (action == addition) then
context.setStrategy(new ConcreteStrategyAdd())

if (action == subtraction) then
context.setStrategy(new ConcreteStrategySubtract())

if (action == multiplication) then
context.setStrategy(new ConcreteStrategyMultiply())

result = context.executeStrategy(First number, Second number)

打印结果。

策略模式适合应用场景

当你想使用对象中各种不同的算法变体,并希望能在运行时切换算法时,可使用策略模式。

策略模式让你能够将对象关联至可以不同方式执行特定子任务的不同子对象,从而以间接方式在运行时更改对象行为。

当你有许多仅在执行某些行为时略有不同的相似类时,可使用策略模式。

策略模式让你能将不同行为抽取到一个独立类层次结构中,并将原始类组合成同一个,从而减少重复代码。

如果算法在上下文的逻辑中不是特别重要,使用该模式能将类的业务逻辑与其算法实现细节隔离开来。

策略模式让你能将各种算法的代码、内部数据和依赖关系与其他代码隔离开来。不同客户端可通过一个简单接口执行算法,并能在运行时进行切换。

当类中使用了复杂条件运算符以在同一算法的不同变体中切换时,可使用该模式。

策略模式将所有继承自同样接口的算法抽取到独立类中,因此不再需要条件语句。原始对象并不实现所有算法的变体,而是将执行工作委派给其中的一个独立算法对象。

实现方式

  1. 从上下文类中找出修改频率较高的算法(也可能是用于在运行时选择某个算法变体的复杂条件运算符)。

  2. 声明该算法所有变体的通用策略接口。

  3. 将算法逐一抽取到各自的类中,它们都必须实现策略接口。

  4. 在上下文类中添加一个成员变量用于保存对于策略对象的引用。然后提供设置器以修改该成员变量。上下文仅可通过策略接口同策略对象进行交互,如有需要还可定义一个接口来让策略访问其数据。

  5. 客户端必须将上下文类与相应策略进行关联,使上下文可以预期的方式完成其主要工作。

策略模式优缺点

  • 你可以在运行时切换对象内的算法。

  • 你可以将算法的实现和使用算法的代码隔离开来。

  • 你可以使用组合来代替继承。

  • 开闭原则。你无需对上下文进行修改就能够引入新的策略。

  • 如果你的算法极少发生改变,那么没有任何理由引入新的类和接口。使用该模式只会让程序过于复杂。

  • 客户端必须知晓策略间的不同——它需要选择合适的策略。

  • 许多现代编程语言支持函数类型功能,允许你在一组匿名函数中实现不同版本的算法。这样,你使用这些函数的方式就和使用策略对象时完全相同,无需借助额外的类和接口来保持代码简洁。

与其他模式的关系

  • 桥接模式状态模式策略模式(在某种程度上包括适配器模式)模式的接口非常相似。实际上,它们都基于组合模式——即将工作委派给其他对象,不过也各自解决了不同的问题。模式并不只是以特定方式组织代码的配方,你还可以使用它们来和其他开发者讨论模式所解决的问题。

  • 命令模式策略看上去很像,因为两者都能通过某些行为来参数化对象。但是,它们的意图有非常大的不同。

    • 你可以使用命令来将任何操作转换为对象。操作的参数将成为对象的成员变量。你可以通过转换来延迟操作的执行、将操作放入队列、保存历史命令或者向远程服务发送命令等。

    • 另一方面,策略通常可用于描述完成某件事的不同方式,让你能够在同一个上下文类中切换算法。

  • 装饰模式可让你更改对象的外表,策略则让你能够改变其本质。

  • 模板方法模式基于继承机制:它允许你通过扩展子类中的部分内容来改变部分算法。策略基于组合机制:你可以通过对相应行为提供不同的策略来改变对象的部分行为。模板方法在类层次上运作,因此它是静态的。策略在对象层次上运作,因此允许在运行时切换行为。

  • 状态可被视为策略的扩展。两者都基于组合机制:它们都通过将部分工作委派给“帮手”对象来改变其在不同情景下的行为。策略使得这些对象相互之间完全独立,它们不知道其他对象的存在。但状态模式没有限制具体状态之间的依赖,且允许它们自行改变在不同情景下的状态。

桥接模式

亦称:Bridge

意图

桥接模式是一种结构型设计模式,可将一个大类或一系列紧密相关的类拆分为抽象和实现两个独立的层次结构,从而能在开发时分别使用。

桥接设计模式

问题

抽象? 实现?听上去挺吓人?让我们慢慢来,先考虑一个简单的例子。

假如你有一个几何形状Shape类,从它能扩展出两个子类:​圆形Circle和方形Square。你希望对这样的类层次结构进行扩展以使其包含颜色,所以你打算创建名为红色Red和蓝色Blue的形状子类。但是,由于你已有两个子类,所以总共需要创建四个类才能覆盖所有组合,例如蓝色圆形Blue­Circle和红色方形Red­Square。

桥接模式解决的问题

所有组合类的数量将以几何级数增长。

在层次结构中新增形状和颜色将导致代码复杂程度指数增长。例如添加三角形状,你需要新增两个子类,也就是每种颜色一个;此后新增一种新颜色需要新增三个子类,即每种形状一个。如此以往,情况会越来越糟糕。

解决方案

问题的根本原因是我们试图在两个独立的维度——形状与颜色——上扩展形状类。这在处理类继承时是很常见的问题。

桥接模式通过将继承改为组合的方式来解决这个问题。具体来说,就是抽取其中一个维度并使之成为独立的类层次,这样就可以在初始类中引用这个新层次的对象,从而使得一个类不必拥有所有的状态和行为。

桥接模式的解决方案

将一个类层次转化为多个相关的类层次,避免单个类层次的失控。

根据该方法,我们可以将颜色相关的代码抽取到拥有红色蓝色两个子类的颜色类中,然后在形状类中添加一个指向某一颜色对象的引用成员变量。现在,形状类可以将所有与颜色相关的工作委派给连入的颜色对象。这样的引用就成为了形状颜色之间的桥梁。此后,新增颜色将不再需要修改形状的类层次,反之亦然。

抽象部分和实现部分

设计模式四人组的著作 在桥接定义中提出了抽象部分实现部分两个术语。我觉得这些术语过于学术了,反而让模式看上去比实际情况更加复杂。在介绍过形状和颜色的简单例子后,我们来看看四人组著作中让人望而生畏的词语的含义。

抽象部分(也被称为接口)是一些实体的高阶控制层。该层自身不完成任何具体的工作,它需要将工作委派给实现部分层(也被称为平台)。

注意,这里提到的内容与编程语言中的接口抽象类无关。它们并不是一回事。

在实际的程序中,抽象部分是图形用户界面(GUI),而实现部分则是底层操作系统代码(API),GUI 层调用 API 层来对用户的各种操作做出响应。

一般来说,你可以在两个独立方向上扩展这种应用:

  • 开发多个不同的 GUI(例如面向普通用户和管理员进行分别配置)
  • 支持多个不同的 API(例如,能够在 Windows、Linux 和 macOS 上运行该程序)。

在最糟糕的情况下,程序可能会是一团乱麻,其中包含数百种条件语句,连接着代码各处不同种类的 GUI 和各种 API。

在模块化代码中驾驭变化要容易得多

在庞杂的代码中,即使是很小的改动都非常难以完成,因为你必须要在整体上对代码有充分的理解。而在较小且定义明确的模块中,进行修改则要容易得多。

你可以将特定接口-平台的组合代码抽取到独立的类中,以在混乱中建立一些秩序。但是,你很快会发现这种类的数量很多。类层次将以指数形式增长,因为每次添加一个新的 GUI 或支持一种新的 API 都需要创建更多的类。

让我们试着用桥接模式来解决这个问题。该模式建议将类拆分为两个类层次结构:

  • 抽象部分:程序的 GUI 层。
  • 实现部分:操作系统的 API。

跨平台结构

创建跨平台应用程序的一种方法

抽象对象控制程序的外观,并将真实工作委派给连入的实现对象。不同的实现只要遵循相同的接口就可以互换,使同一 GUI 可在 Windows 和 Linux 下运行。

最后的结果是:你无需改动与 API 相关的类就可以修改 GUI 类。此外如果想支持一个新的操作系统,只需在实现部分层次中创建一个子类即可。

桥接模式结构

桥接设计模式桥接设计模式

  1. 抽象部分(Abstraction)提供高层控制逻辑,依赖于完成底层实际工作的实现对象。

  2. 实现部分(Implementation)为所有具体实现声明通用接口。抽象部分仅能通过在这里声明的方法与实现对象交互。

    抽象部分可以列出和实现部分一样的方法,但是抽象部分通常声明一些复杂行为,这些行为依赖于多种由实现部分声明的原语操作。

  3. 具体实现(Concrete Implementations)中包括特定于平台的代码。

  4. 精确抽象(Refined Abstraction)提供控制逻辑的变体。与其父类一样,它们通过通用实现接口与不同的实现进行交互。

  5. 通常情况下,客户端(Client)仅关心如何与抽象部分合作。但是,客户端需要将抽象对象与一个实现对象连接起来。

伪代码

示例演示了桥接模式如何拆分程序中同时管理设备及其遥控器的庞杂代码。​设备Device类作为实现部分,而遥控器Remote类则作为抽象部分。

桥接模式示例的结构

最初类层次结构被拆分为两个部分:设备和遥控器。

遥控器基类声明了一个指向设备对象的引用成员变量。所有遥控器通过通用设备接口与设备进行交互,使得同一个遥控器可以支持不同类型的设备。

你可以开发独立于设备类的遥控器类,只需新建一个遥控器子类即可。例如,基础遥控器可能只有两个按钮,但你可在其基础上扩展新功能,比如额外的一节电池或一块触摸屏。

客户端代码通过遥控器构造函数将特定种类的遥控器与设备对象连接起来。

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
// “抽象部分”定义了两个类层次结构中“控制”部分的接口。它管理着一个指向“实
// 现部分”层次结构中对象的引用,并会将所有真实工作委派给该对象。
class RemoteControl is
protected field device: Device
constructor RemoteControl(device: Device) is
this.device = device
method togglePower() is
if (device.isEnabled()) then
device.disable()
else
device.enable()
method volumeDown() is
device.setVolume(device.getVolume() - 10)
method volumeUp() is
device.setVolume(device.getVolume() + 10)
method channelDown() is
device.setChannel(device.getChannel() - 1)
method channelUp() is
device.setChannel(device.getChannel() + 1)


// 你可以独立于设备类的方式从抽象层中扩展类。
class AdvancedRemoteControl extends RemoteControl is
method mute() is
device.setVolume(0)


// “实现部分”接口声明了在所有具体实现类中通用的方法。它不需要与抽象接口相
// 匹配。实际上,这两个接口可以完全不一样。通常实现接口只提供原语操作,而
// 抽象接口则会基于这些操作定义较高层次的操作。
interface Device is
method isEnabled()
method enable()
method disable()
method getVolume()
method setVolume(percent)
method getChannel()
method setChannel(channel)


// 所有设备都遵循相同的接口。
class Tv implements Device is
// ...

class Radio implements Device is
// ...


// 客户端代码中的某个位置。
tv = new Tv()
remote = new RemoteControl(tv)
remote.togglePower()

radio = new Radio()
remote = new AdvancedRemoteControl(radio)

桥接模式适合应用场景

如果你想要拆分或重组一个具有多重功能的庞杂类(例如能与多个数据库服务器进行交互的类),可以使用桥接模式。

类的代码行数越多,弄清其运作方式就越困难,对其进行修改所花费的时间就越长。一个功能上的变化可能需要在整个类范围内进行修改,而且常常会产生错误,甚至还会有一些严重的副作用。

桥接模式可以将庞杂类拆分为几个类层次结构。此后,你可以修改任意一个类层次结构而不会影响到其他类层次结构。这种方法可以简化代码的维护工作,并将修改已有代码的风险降到最低。

如果你希望在几个独立维度上扩展一个类,可使用该模式。

桥接建议将每个维度抽取为独立的类层次。初始类将相关工作委派给属于对应类层次的对象,无需自己完成所有工作。

如果你需要在运行时切换不同实现方法,可使用桥接模式。

当然并不是说一定要实现这一点,桥接模式可替换抽象部分中的实现对象,具体操作就和给成员变量赋新值一样简单。

顺便提一句,最后一点是很多人混淆桥接模式和策略模式的主要原因。记住,设计模式并不仅是一种对类进行组织的方式,它还能用于沟通意图和解决问题。

实现方式

  1. 明确类中独立的维度。独立的概念可能是:抽象/平台,域/基础设施,前端/后端或接口/实现。

  2. 了解客户端的业务需求,并在抽象基类中定义它们。

  3. 确定在所有平台上都可执行的业务。并在通用实现接口中声明抽象部分所需的业务。

  4. 为你域内的所有平台创建实现类,但需确保它们遵循实现部分的接口。

  5. 在抽象类中添加指向实现类型的引用成员变量。抽象部分会将大部分工作委派给该成员变量所指向的实现对象。

  6. 如果你的高层逻辑有多个变体,则可通过扩展抽象基类为每个变体创建一个精确抽象。

  7. 客户端代码必须将实现对象传递给抽象部分的构造函数才能使其能够相互关联。此后,客户端只需与抽象对象进行交互,无需和实现对象打交道。

桥接模式优缺点

  • 你可以创建与平台无关的类和程序。

  • 客户端代码仅与高层抽象部分进行互动,不会接触到平台的详细信息。

  • 开闭原则。你可以新增抽象部分和实现部分,且它们之间不会相互影响。

  • 单一职责原则。抽象部分专注于处理高层逻辑,实现部分处理平台细节。

  • 对高内聚的类使用该模式可能会让代码更加复杂。

与其他模式的关系

  • 桥接模式通常会于开发前期进行设计,使你能够将程序的各个部分独立开来以便开发。另一方面,适配器模式通常在已有程序中使用,让相互不兼容的类能很好地合作。

  • 桥接状态模式策略模式(在某种程度上包括适配器)模式的接口非常相似。实际上,它们都基于组合模式——即将工作委派给其他对象,不过也各自解决了不同的问题。模式并不只是以特定方式组织代码的配方,你还可以使用它们来和其他开发者讨论模式所解决的问题。

  • 你可以将抽象工厂模式桥接搭配使用。如果由桥接定义的抽象只能与特定实现合作,这一模式搭配就非常有用。在这种情况下,抽象工厂可以对这些关系进行封装,并且对客户端代码隐藏其复杂性。

  • 你可以结合使用生成器模式桥接模式主管类负责抽象工作,各种不同的生成器负责实现工作。

单例模式

亦称:单件模式、Singleton

意图

单例模式是一种创建型设计模式,让你能够保证一个类只有一个实例,并提供一个访问该实例的全局节点。

单例模式

问题

单例模式同时解决了两个问题,所以违反了_单一职责原则_:

  1. 保证一个类只有一个实例。为什么会有人想要控制一个类所拥有的实例数量?最常见的原因是控制某些共享资源(例如数据库或文件)的访问权限。

    它的运作方式是这样的:如果你创建了一个对象,同时过一会儿后你决定再创建一个新对象,此时你会获得之前已创建的对象,而不是一个新对象。

    注意,普通构造函数无法实现上述行为,因为构造函数的设计决定了它必须总是返回一个新对象。

一个对象的全局访问节点

客户端甚至可能没有意识到它们一直都在使用同一个对象。

  1. 为该实例提供一个全局访问节点。还记得你(好吧,其实是我自己)用过的那些存储重要对象的全局变量吗?它们在使用上十分方便,但同时也非常不安全,因为任何代码都有可能覆盖掉那些变量的内容,从而引发程序崩溃。

    和全局变量一样,单例模式也允许在程序的任何地方访问特定对象。但是它可以保护该实例不被其他代码覆盖。

    还有一点:你不会希望解决同一个问题的代码分散在程序各处的。因此更好的方式是将其放在同一个类中,特别是当其他代码已经依赖这个类时更应该如此。

如今,单例模式已经变得非常流行,以至于人们会将只解决上文描述中任意一个问题的东西称为单例

解决方案

所有单例的实现都包含以下两个相同的步骤:

  • 将默认构造函数设为私有,防止其他对象使用单例类的new运算符。
  • 新建一个静态构建方法作为构造函数。该函数会“偷偷”调用私有构造函数来创建对象,并将其保存在一个静态成员变量中。此后所有对于该函数的调用都将返回这一缓存对象。

如果你的代码能够访问单例类,那它就能调用单例类的静态方法。无论何时调用该方法,它总是会返回相同的对象。

真实世界类比

政府是单例模式的一个很好的示例。一个国家只有一个官方政府。不管组成政府的每个人的身份是什么,​“某政府”这一称谓总是鉴别那些掌权者的全局访问节点。

单例模式结构

单例模式结构单例模式结构

  1. 单例(Singleton)类声明了一个名为get&shy;Instance获取实例的静态方法来返回其所属类的一个相同实例。

    单例的构造函数必须对客户端(Client)代码隐藏。调用获取实例方法必须是获取单例对象的唯一方式。

伪代码

在本例中,数据库连接类即是一个单例

该类不提供公有构造函数,因此获取该对象的唯一方式是调用获取实例方法。该方法将缓存首次生成的对象,并为所有后续调用返回该对象。

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
// 数据库类会对`getInstance(获取实例)`方法进行定义以让客户端在程序各处
// 都能访问相同的数据库连接实例。
class Database is
// 保存单例实例的成员变量必须被声明为静态类型。
private static field instance: Database

// 单例的构造函数必须永远是私有类型,以防止使用`new`运算符直接调用构
// 造方法。
private constructor Database() is
// 部分初始化代码(例如到数据库服务器的实际连接)。
// ...

// 用于控制对单例实例的访问权限的静态方法。
public static method getInstance() is
if (Database.instance == null) then
acquireThreadLock() and then
// 确保在该线程等待解锁时,其他线程没有初始化该实例。
if (Database.instance == null) then
Database.instance = new Database()
return Database.instance

// 最后,任何单例都必须定义一些可在其实例上执行的业务逻辑。
public method query(sql) is
// 比如应用的所有数据库查询请求都需要通过该方法进行。因此,你可以
// 在这里添加限流或缓冲逻辑。
// ...

class Application is
method main() is
Database foo = Database.getInstance()
foo.query("SELECT ...")
// ...
Database bar = Database.getInstance()
bar.query("SELECT ...")
// 变量 `bar` 和 `foo` 中将包含同一个对象。

单例模式适合应用场景

如果程序中的某个类对于所有客户端只有一个可用的实例,可以使用单例模式。

单例模式禁止通过除特殊构建方法以外的任何方式来创建自身类的对象。该方法可以创建一个新对象,但如果该对象已经被创建,则返回已有的对象。

如果你需要更加严格地控制全局变量,可以使用单例模式。

单例模式与全局变量不同,它保证类只存在一个实例。除了单例类自己以外,无法通过任何方式替换缓存的实例。

请注意,你可以随时调整限制并设定生成单例实例的数量,只需修改获取实例方法,即 getInstance 中的代码即可实现。

实现方式

  1. 在类中添加一个私有静态成员变量用于保存单例实例。

  2. 声明一个公有静态构建方法用于获取单例实例。

  3. 在静态方法中实现”延迟初始化”。该方法会在首次被调用时创建一个新对象,并将其存储在静态成员变量中。此后该方法每次被调用时都返回该实例。

  4. 将类的构造函数设为私有。类的静态方法仍能调用构造函数,但是其他对象不能调用。

  5. 检查客户端代码,将对单例的构造函数的调用替换为对其静态构建方法的调用。

单例模式优缺点

  • 你可以保证一个类只有一个实例。

  • 你获得了一个指向该实例的全局访问节点。

  • 仅在首次请求单例对象时对其进行初始化。

  • 违反了_单一职责原则_。该模式同时解决了两个问题。

  • 单例模式可能掩盖不良设计,比如程序各组件之间相互了解过多等。

  • 该模式在多线程环境下需要进行特殊处理,避免多个线程多次创建单例对象。

  • 单例的客户端代码单元测试可能会比较困难,因为许多测试框架以基于继承的方式创建模拟对象。由于单例类的构造函数是私有的,而且绝大部分语言无法重写静态方法,所以你需要想出仔细考虑模拟单例的方法。要么干脆不编写测试代码,或者不使用单例模式。

与其他模式的关系

  • 外观模式类通常可以转换为单例模式类,因为在大部分情况下一个外观对象就足够了。

  • 如果你能将对象的所有共享状态简化为一个享元对象,那么享元模式就和单例类似了。但这两个模式有两个根本性的不同。

    1. 只会有一个单例实体,但是享元类可以有多个实体,各实体的内在状态也可以不同。
    2. 单例对象可以是可变的。享元对象是不可变的。
  • 抽象工厂模式生成器模式原型模式都可以用单例来实现。

外观模式

亦称:门面模式、Facade

意图

外观模式是一种结构型设计模式,能为程序库、框架或其他复杂类提供一个简单的接口。

外观设计模式

问题

假设你必须在代码中使用某个复杂的库或框架中的众多对象。正常情况下,你需要负责所有对象的初始化工作、管理其依赖关系并按正确的顺序执行方法等。

最终,程序中类的业务逻辑将与第三方类的实现细节紧密耦合,使得理解和维护代码的工作很难进行。

解决方案

外观类为包含许多活动部件的复杂子系统提供一个简单的接口。与直接调用子系统相比,外观提供的功能可能比较有限,但它却包含了客户端真正关心的功能。

如果你的程序需要与包含几十种功能的复杂库整合,但只需使用其中非常少的功能,那么使用外观模式会非常方便,

例如,上传猫咪搞笑短视频到社交媒体网站的应用可能会用到专业的视频转换库,但它只需使用一个包含encode&shy;(filename, format)方法(以文件名与文件格式为参数进行编码的方法)的类即可。在创建这个类并将其连接到视频转换库后,你就拥有了自己的第一个外观。

真实世界类比

电话购物的示例

电话购物。

当你通过电话给商店下达订单时,接线员就是该商店的所有服务和部门的外观。接线员为你提供了一个同购物系统、支付网关和各种送货服务进行互动的简单语音接口。

外观模式结构

外观设计模式的结构外观设计模式的结构

  1. 外观(Facade)提供了一种访问特定子系统功能的便捷方式,其了解如何重定向客户端请求,知晓如何操作一切活动部件。

  2. 创建附加外观(Additional Facade)类可以避免多种不相关的功能污染单一外观,使其变成又一个复杂结构。客户端和其他外观都可使用附加外观。

  3. 复杂子系统(Complex Subsystem)由数十个不同对象构成。如果要用这些对象完成有意义的工作,你必须深入了解子系统的实现细节,比如按照正确顺序初始化对象和为其提供正确格式的数据。

    子系统类不会意识到外观的存在,它们在系统内运作并且相互之间可直接进行交互。

  4. 客户端(Client)使用外观代替对子系统对象的直接调用。

伪代码

在本例中,外观模式简化了客户端与复杂视频转换框架之间的交互。

外观模式示例的结构

使用单个外观类隔离多重依赖的示例

你可以创建一个封装所需功能并隐藏其他代码的外观类,从而无需使全部代码直接与数十个框架类进行交互。该结构还能将未来框架升级或更换所造成的影响最小化,因为你只需修改程序中外观方法的实现即可。

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
// 这里有复杂第三方视频转换框架中的一些类。我们不知晓其中的代码,因此无法
// 对其进行简化。

class VideoFile
// ...

class OggCompressionCodec
// ...

class MPEG4CompressionCodec
// ...

class CodecFactory
// ...

class BitrateReader
// ...

class AudioMixer
// ...


// 为了将框架的复杂性隐藏在一个简单接口背后,我们创建了一个外观类。它是在
// 功能性和简洁性之间做出的权衡。
class VideoConverter is
method convert(filename, format):File is
file = new VideoFile(filename)
sourceCodec = new CodecFactory.extract(file)
if (format == "mp4")
destinationCodec = new MPEG4CompressionCodec()
else
destinationCodec = new OggCompressionCodec()
buffer = BitrateReader.read(filename, sourceCodec)
result = BitrateReader.convert(buffer, destinationCodec)
result = (new AudioMixer()).fix(result)
return new File(result)

// 应用程序的类并不依赖于复杂框架中成千上万的类。同样,如果你决定更换框架,
// 那只需重写外观类即可。
class Application is
method main() is
convertor = new VideoConverter()
mp4 = convertor.convert("funny-cats-video.ogg", "mp4")
mp4.save()

外观模式适合应用场景

如果你需要一个指向复杂子系统的直接接口,且该接口的功能有限,则可以使用外观模式。

子系统通常会随着时间的推进变得越来越复杂。即便是应用了设计模式,通常你也会创建更多的类。尽管在多种情形中子系统可能是更灵活或易于复用的,但其所需的配置和样板代码数量将会增长得更快。为了解决这个问题,外观将会提供指向子系统中最常用功能的快捷方式,能够满足客户端的大部分需求。

如果需要将子系统组织为多层结构,可以使用外观。

创建外观来定义子系统中各层次的入口。你可以要求子系统仅使用外观来进行交互,以减少子系统之间的耦合。

让我们回到视频转换框架的例子。该框架可以拆分为两个层次:音频相关和视频相关。你可以为每个层次创建一个外观,然后要求各层的类必须通过这些外观进行交互。这种方式看上去与中介者模式非常相似。

实现方式

  1. 考虑能否在现有子系统的基础上提供一个更简单的接口。如果该接口能让客户端代码独立于众多子系统类,那么你的方向就是正确的。

  2. 在一个新的外观类中声明并实现该接口。外观应将客户端代码的调用重定向到子系统中的相应对象处。如果客户端代码没有对子系统进行初始化,也没有对其后续生命周期进行管理,那么外观必须完成此类工作。

  3. 如果要充分发挥这一模式的优势,你必须确保所有客户端代码仅通过外观来与子系统进行交互。此后客户端代码将不会受到任何由子系统代码修改而造成的影响,比如子系统升级后,你只需修改外观中的代码即可。

  4. 如果外观变得过于臃肿,你可以考虑将其部分行为抽取为一个新的专用外观类。

外观模式优缺点

  • 你可以让自己的代码独立于复杂子系统。

  • 外观可能成为与程序中所有类都耦合的上帝对象

与其他模式的关系

  • 外观模式为现有对象定义了一个新接口,适配器模式则会试图运用已有的接口。适配器通常只封装一个对象,外观通常会作用于整个对象子系统上。

  • 当只需对客户端代码隐藏子系统创建对象的方式时,你可以使用抽象工厂模式来代替外观

  • 享元模式展示了如何生成大量的小型对象,外观则展示了如何用一个对象来代表整个子系统。

  • 外观中介者模式的职责类似:它们都尝试在大量紧密耦合的类中组织起合作。

    • 外观为子系统中的所有对象定义了一个简单接口,但是它不提供任何新功能。子系统本身不会意识到外观的存在。子系统中的对象可以直接进行交流。
    • 中介者将系统中组件的沟通行为中心化。各组件只知道中介者对象,无法直接相互交流。
  • 外观类通常可以转换为单例模式类,因为在大部分情况下一个外观对象就足够了。

  • 外观代理模式的相似之处在于它们都缓存了一个复杂实体并自行对其进行初始化。代理与其服务对象遵循同一接口,使得自己和服务对象可以互换,在这一点上它与外观不同。

组合模式

亦称:对象树、Object Tree、Composite

意图

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

组合设计模式

问题

如果应用的核心模型能用树状结构表示,在应用中使用组合模式才有价值。

例如,你有两类对象:​产品盒子。一个盒子中可以包含多个产品或者几个较小的盒子。这些小盒子中同样可以包含一些产品或更小的盒子,以此类推。

假设你希望在这些类的基础上开发一个定购系统。订单中可以包含无包装的简单产品,也可以包含装满产品的盒子……以及其他盒子。此时你会如何计算每张订单的总价格呢?

复杂订单的结构

订单中可能包括各种产品,这些产品放置在盒子中,然后又被放入一层又一层更大的盒子中。整个结构看上去像是一棵倒过来的树。

你可以尝试直接计算:打开所有盒子,找到每件产品,然后计算总价。这在真实世界中或许可行,但在程序中,你并不能简单地使用循环语句来完成该工作。你必须事先知道所有产品盒子的类别,所有盒子的嵌套层数以及其他繁杂的细节信息。因此,直接计算极不方便,甚至完全不可行。

解决方案

组合模式建议使用一个通用接口来与产品盒子进行交互,并且在该接口中声明一个计算总价的方法。

那么方法该如何设计呢?对于一个产品,该方法直接返回其价格;对于一个盒子,该方法遍历盒子中的所有项目,询问每个项目的价格,然后返回该盒子的总价格。如果其中某个项目是小一号的盒子,那么当前盒子也会遍历其中的所有项目,以此类推,直到计算出所有内部组成部分的价格。你甚至可以在盒子的最终价格中增加额外费用,作为该盒子的包装费用。

组合模式建议的解决方案

组合模式以递归方式处理对象树中的所有项目

该方式的最大优点在于你无需了解构成树状结构的对象的具体类。你也无需了解对象是简单的产品还是复杂的盒子。你只需调用通用接口以相同的方式对其进行处理即可。当你调用该方法后,对象会将请求沿着树结构传递下去。

真实世界类比

部队结构的例子

部队结构的例子。

大部分国家的军队都采用层次结构管理。每支部队包括几个师,师由旅构成,旅由团构成,团可以继续划分为排。最后,每个排由一小队实实在在的士兵组成。军事命令由最高层下达,通过每个层级传递,直到每位士兵都知道自己应该服从的命令。

组合模式结构

组合设计模式的结构组合设计模式的结构

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

  2. 叶节点(Leaf)是树的基本结构,它不包含子项目。

    一般情况下,叶节点最终会完成大部分的实际工作,因为它们无法将工作指派给其他部分。

  3. 容器(Container)——又名“组合(Composite)”——是包含叶节点或其他容器等子项目的单位。容器不知道其子项目所属的具体类,它只通过通用的组件接口与其子项目交互。

    容器接收到请求后会将工作分配给自己的子项目,处理中间结果,然后将最终结果返回给客户端。

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

伪代码

在本例中,我们将借助组合模式帮助你在图形编辑器中实现一系列的几何图形。

组合模式示例的结构

几何形状编辑器示例。

组合图形Compound­Graphic是一个容器,它可以由多个包括容器在内的子图形构成。组合图形与简单图形拥有相同的方法。但是,组合图形自身并不完成具体工作,而是将请求递归地传递给自己的子项目,然后“汇总”结果。

通过所有图形类所共有的接口,客户端代码可以与所有图形互动。因此,客户端不知道与其交互的是简单图形还是组合图形。客户端可以与非常复杂的对象结构进行交互,而无需与组成该结构的实体类紧密耦合。

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
// 组件接口会声明组合中简单和复杂对象的通用操作。
interface Graphic is
method move(x, y)
method draw()

// 叶节点类代表组合的终端对象。叶节点对象中不能包含任何子对象。叶节点对象
// 通常会完成实际的工作,组合对象则仅会将工作委派给自己的子部件。
class Dot implements Graphic is
field x, y

constructor Dot(x, y) { ... }

method move(x, y) is
this.x += x, this.y += y

method draw() is
// 在坐标位置(X,Y)处绘制一个点。

// 所有组件类都可以扩展其他组件。
class Circle extends Dot is
field radius

constructor Circle(x, y, radius) { ... }

method draw() is
// 在坐标位置(X,Y)处绘制一个半径为 R 的圆。

// 组合类表示可能包含子项目的复杂组件。组合对象通常会将实际工作委派给子项
// 目,然后“汇总”结果。
class CompoundGraphic implements Graphic is
field children: array of Graphic

// 组合对象可在其项目列表中添加或移除其他组件(简单的或复杂的皆可)。
method add(child: Graphic) is
// 在子项目数组中添加一个子项目。

method remove(child: Graphic) is
// 从子项目数组中移除一个子项目。

method move(x, y) is
foreach (child in children) do
child.move(x, y)

// 组合会以特定的方式执行其主要逻辑。它会递归遍历所有子项目,并收集和
// 汇总其结果。由于组合的子项目也会将调用传递给自己的子项目,以此类推,
// 最后组合将会完成整个对象树的遍历工作。
method draw() is
// 1. 对于每个子部件:
// - 绘制该部件。
// - 更新边框坐标。
// 2. 根据边框坐标绘制一个虚线长方形。


// 客户端代码会通过基础接口与所有组件进行交互。这样一来,客户端代码便可同
// 时支持简单叶节点组件和复杂组件。
class ImageEditor is
field all: CompoundGraphic

method load() is
all = new CompoundGraphic()
all.add(new Dot(1, 2))
all.add(new Circle(5, 3, 10))
// ...

// 将所需组件组合为复杂的组合组件。
method groupSelected(components: array of Graphic) is
group = new CompoundGraphic()
foreach (component in components) do
group.add(component)
all.remove(component)
all.add(group)
// 所有组件都将被绘制。
all.draw()

组合模式适合应用场景

如果你需要实现树状对象结构,可以使用组合模式。

组合模式为你提供了两种共享公共接口的基本元素类型:简单叶节点和复杂容器。容器中可以包含叶节点和其他容器。这使得你可以构建树状嵌套递归对象结构。

如果你希望客户端代码以相同方式处理简单和复杂元素,可以使用该模式。

组合模式中定义的所有元素共用同一个接口。在这一接口的帮助下,客户端不必在意其所使用的对象的具体类。

实现方式

  1. 确保应用的核心模型能够以树状结构表示。尝试将其分解为简单元素和容器。记住,容器必须能够同时包含简单元素和其他容器。

  2. 声明组件接口及其一系列方法,这些方法对简单和复杂元素都有意义。

  3. 创建一个叶节点类表示简单元素。程序中可以有多个不同的叶节点类。

  4. 创建一个容器类表示复杂元素。在该类中,创建一个数组成员变量来存储对于其子元素的引用。该数组必须能够同时保存叶节点和容器,因此请确保将其声明为组合接口类型。

    实现组件接口方法时,记住容器应该将大部分工作交给其子元素来完成。

  5. 最后,在容器中定义添加和删除子元素的方法。

    记住,这些操作可在组件接口中声明。这将会违反_接口隔离原则_,因为叶节点类中的这些方法为空。但是,这可以让客户端无差别地访问所有元素,即使是组成树状结构的元素。

组合模式优缺点

  • 你可以利用多态和递归机制更方便地使用复杂树结构。

  • 开闭原则。无需更改现有代码,你就可以在应用中添加新元素,使其成为对象树的一部分。

  • 对于功能差异较大的类,提供公共接口或许会有困难。在特定情况下,你需要过度一般化组件接口,使其变得令人难以理解。

与其他模式的关系

  • 桥接模式状态模式策略模式(在某种程度上包括适配器模式)模式的接口非常相似。实际上,它们都基于组合模式——即将工作委派给其他对象,不过也各自解决了不同的问题。模式并不只是以特定方式组织代码的配方,你还可以使用它们来和其他开发者讨论模式所解决的问题。

  • 你可以在创建复杂组合树时使用生成器模式,因为这可使其构造步骤以递归的方式运行。

  • 责任链模式通常和组合模式结合使用。在这种情况下,叶组件接收到请求后,可以将请求沿包含全体父组件的链一直传递至对象树的底部。

  • 你可以使用迭代器模式来遍历组合树。

  • 你可以使用访问者模式对整个组合树执行操作。

  • 你可以使用享元模式实现组合树的共享叶节点以节省内存。

  • 组合装饰模式的结构图很相似,因为两者都依赖递归组合来组织无限数量的对象。

    装饰类似于组合,但其只有一个子组件。此外还有一个明显不同:装饰为被封装对象添加了额外的职责,组合仅对其子节点的结果进行了“求和”。

    但是,模式也可以相互合作:你可以使用装饰来扩展组合树中特定对象的行为。

  • 大量使用组合装饰的设计通常可从对于原型模式的使用中获益。你可以通过该模式来复制复杂结构,而非从零开始重新构造。