0%

模板方法模式

亦称:Template Method

意图

模板方法模式是一种行为设计模式,它在超类中定义了一个算法的框架,允许子类在不修改结构的情况下重写算法的特定步骤。

模板方法设计模式

问题

假如你正在开发一款分析公司文档的数据挖掘程序。用户需要向程序输入各种格式(PDF、DOC 或 CSV)的文档,程序则会试图从这些文件中抽取有意义的数据,并以统一的格式将其返回给用户。

该程序的首个版本仅支持 DOC 文件。在接下来的一个版本中,程序能够支持 CSV 文件。一个月后,你“教会”了程序从 PDF 文件中抽取数据。

数据挖掘类包含许多重复代码

数据挖掘类中包含许多重复代码。

一段时间后,你发现这三个类中包含许多相似代码。尽管这些类处理不同数据格式的代码完全不同,但数据处理和分析的代码却几乎完全一样。如果能在保持算法结构完整的情况下去除重复代码,这难道不是一件很棒的事情吗?

还有另一个与使用这些类的客户端代码相关的问题:客户端代码中包含许多条件语句,以根据不同的处理对象类型选择合适的处理过程。如果所有处理数据的类都拥有相同的接口或基类,那么你就可以去除客户端代码中的条件语句,转而使用多态机制来在处理对象上调用函数。

解决方案

模板方法模式建议将算法分解为一系列步骤,然后将这些步骤改写为方法,最后在“模板方法”中依次调用这些方法。步骤可以是抽象的,也可以有一些默认的实现。为了能够使用算法,客户端需要自行提供子类并实现所有的抽象步骤。如有必要还需重写一些步骤(但这一步中不包括模板方法自身)。

让我们考虑如何在数据挖掘应用中实现上述方案。我们可为图中的三个解析算法创建一个基类,该类将定义调用了一系列不同文档处理步骤的模板方法。

模板方法定义了算法的框架

模板方法将算法分解为步骤,并允许子类重写这些步骤,而非重写实际的模板方法。

首先,我们将所有步骤声明为抽象类型,强制要求子类自行实现这些方法。在我们的例子中,子类中已有所有必要的实现,因此我们只需调整这些方法的签名,使之与超类的方法匹配即可。

现在,让我们看看如何去除重复代码。对于不同的数据格式,打开和关闭文件以及抽取和解析数据的代码都不同,因此无需修改这些方法。但分析原始数据和生成报告等其他步骤的实现方式非常相似,因此可将其提取到基类中,以让子类共享这些代码。

正如你所看到的那样,我们有两种类型的步骤:

  • 抽象步骤必须由各个子类来实现
  • 可选步骤已有一些默认实现,但仍可在需要时进行重写

还有另一种名为钩子的步骤。钩子是内容为空的可选步骤。即使不重写钩子,模板方法也能工作。钩子通常放置在算法重要步骤的前后,为子类提供额外的算法扩展点。

真实世界类比

建造大型房屋

可对典型的建筑方案进行微调以更好地满足客户需求。

模板方法可用于建造大量房屋。标准房屋建造方案中可提供几个扩展点,允许潜在房屋业主调整成品房屋的部分细节。

每个建造步骤(例如打地基、建造框架、建造墙壁和安装水电管线等)都能进行微调,这使得成品房屋会略有不同。

模板方法模式结构

模板方法设计模式的结构模板方法设计模式的结构

  1. 抽象类(Abstract­Class)会声明作为算法步骤的方法,以及依次调用它们的实际模板方法。算法步骤可以被声明为抽象类型,也可以提供一些默认实现。

  2. 具体类(Concrete­Class)可以重写所有步骤,但不能重写模板方法自身。

伪代码

本例中的模板方法模式为一款简单策略游戏中人工智能的不同分支提供“框架”。

模板方法模式示例的结构

一款简单游戏的 AI 类。

游戏中所有的种族都有几乎同类的单位和建筑。因此你可以在不同的种族上复用相同的 AI 结构,同时还需要具备重写一些细节的能力。通过这种方式,你可以重写半兽人的 AI 使其更富攻击性,也可以让人类侧重防守,还可以禁止怪物建造建筑。在游戏中新增种族需要创建新的 AI 子类,还需要重写 AI 基类中所声明的默认方法。

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
// 抽象类定义了一个模板方法,其中通常会包含某个由抽象原语操作调用组成的算
// 法框架。具体子类会实现这些操作,但是不会对模板方法做出修改。
class GameAI is
// 模板方法定义了某个算法的框架。
method turn() is
collectResources()
buildStructures()
buildUnits()
attack()

// 某些步骤可在基类中直接实现。
method collectResources() is
foreach (s in this.builtStructures) do
s.collect()

// 某些可定义为抽象类型。
abstract method buildStructures()
abstract method buildUnits()

// 一个类可包含多个模板方法。
method attack() is
enemy = closestEnemy()
if (enemy == null)
sendScouts(map.center)
else
sendWarriors(enemy.position)

abstract method sendScouts(position)
abstract method sendWarriors(position)

// 具体类必须实现基类中的所有抽象操作,但是它们不能重写模板方法自身。
class OrcsAI extends GameAI is
method buildStructures() is
if (there are some resources) then
// 建造农场,接着是谷仓,然后是要塞。

method buildUnits() is
if (there are plenty of resources) then
if (there are no scouts)
// 建造苦工,将其加入侦查编组。
else
// 建造兽族步兵,将其加入战士编组。

// ...

method sendScouts(position) is
if (scouts.length > 0) then
// 将侦查编组送到指定位置。

method sendWarriors(position) is
if (warriors.length > 5) then
// 将战斗编组送到指定位置。

// 子类可以重写部分默认的操作。
class MonstersAI extends GameAI is
method collectResources() is
// 怪物不会采集资源。

method buildStructures() is
// 怪物不会建造建筑。

method buildUnits() is
// 怪物不会建造单位。

模板方法模式适合应用场景

当你只希望客户端扩展某个特定算法步骤,而不是整个算法或其结构时,可使用模板方法模式。

模板方法将整个算法转换为一系列独立的步骤,以便子类能对其进行扩展,同时还可让超类中所定义的结构保持完整。

当多个类的算法除一些细微不同之外几乎完全一样时,你可使用该模式。但其后果就是,只要算法发生变化,你就可能需要修改所有的类。

在将算法转换为模板方法时,你可将相似的实现步骤提取到超类中以去除重复代码。子类间各不同的代码可继续保留在子类中。

实现方式

  1. 分析目标算法,确定能否将其分解为多个步骤。从所有子类的角度出发,考虑哪些步骤能够通用,哪些步骤各不相同。

  2. 创建抽象基类并声明一个模板方法和代表算法步骤的一系列抽象方法。在模板方法中根据算法结构依次调用相应步骤。可用final最终修饰模板方法以防止子类对其进行重写。

  3. 虽然可将所有步骤全都设为抽象类型,但默认实现可能会给部分步骤带来好处,因为子类无需实现那些方法。

  4. 可考虑在算法的关键步骤之间添加钩子。

  5. 为每个算法变体新建一个具体子类,它必须实现所有的抽象步骤,也可以重写部分可选步骤。

模板方法模式优缺点

  • 你可仅允许客户端重写一个大型算法中的特定部分,使得算法其他部分修改对其所造成的影响减小。

  • 你可将重复代码提取到一个超类中。

  • 部分客户端可能会受到算法框架的限制。

  • 通过子类抑制默认步骤实现可能会导致违反_里氏替换原则_。

  • 模板方法中的步骤越多,其维护工作就可能会越困难。

与其他模式的关系

  • 工厂方法模式模板方法模式的一种特殊形式。同时,工厂方法可以作为一个大型模板方法中的一个步骤。

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

享元模式

亦称:缓存、Cache、Flyweight

意图

享元模式是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

享元设计模式

问题

假如你希望在长时间工作后放松一下,所以开发了一款简单的游戏:玩家们在地图上移动并相互射击。你决定实现一个真实的粒子系统,并将其作为游戏的特色。大量的子弹、导弹和爆炸弹片会在整个地图上穿行,为玩家提供紧张刺激的游戏体验。

开发完成后,你推送提交了最新版本的程序,并在编译游戏后将其发送给了一个朋友进行测试。尽管该游戏在你的电脑上完美运行,但是你的朋友却无法长时间进行游戏:游戏总是会在他的电脑上运行几分钟后崩溃。在研究了几个小时的调试消息记录后,你发现导致游戏崩溃的原因是内存容量不足。朋友的设备性能远比不上你的电脑,因此游戏运行在他的电脑上时很快就会出现问题。

真正的问题与粒子系统有关。每个粒子(一颗子弹、一枚导弹或一块弹片)都由包含完整数据的独立对象来表示。当玩家在游戏中鏖战进入高潮后的某一时刻,游戏将无法在剩余内存中载入新建粒子,于是程序就崩溃了。

享元模式问题

解决方案

仔细观察粒子Particle类,你可能会注意到颜色(color)和精灵图(sprite)这两个成员变量所消耗的内存要比其他变量多得多。更糟糕的是,对于所有的粒子来说,这两个成员变量所存储的数据几乎完全一样(比如所有子弹的颜色和精灵图都一样)。

享元模式的解决方案

每个粒子的另一些状态(坐标、移动矢量和速度)则是不同的。因为这些成员变量的数值会不断变化。这些数据代表粒子在存续期间不断变化的情景,但每个粒子的颜色和精灵图则会保持不变。

对象的常量数据通常被称为内在状态,其位于对象中,其他对象只能读取但不能修改其数值。而对象的其他状态常常能被其他对象“从外部”改变,因此被称为外在状态

享元模式建议不在对象中存储外在状态,而是将其传递给依赖于它的一个特殊方法。程序只在对象中保存内在状态,以方便在不同情景下重用。这些对象的区别仅在于其内在状态(与外在状态相比,内在状态的变体要少很多),因此你所需的对象数量会大大削减。

享元模式的解决方案

让我们回到游戏中。假如能从粒子类中抽出外在状态,那么我们只需三个不同的对象(子弹、导弹和弹片)就能表示游戏中的所有粒子。你现在很可能已经猜到了,我们将这样一个仅存储内在状态的对象称为享元

外在状态存储

那么外在状态会被移动到什么地方呢?总得有类来存储它们,对不对?在大部分情况中,它们会被移动到容器对象中,也就是我们应用享元模式前的聚合对象中。

在我们的例子中,容器对象就是主要的游戏Game对象,其会将所有粒子存储在名为粒子particles的成员变量中。为了能将外在状态移动到这个类中,你需要创建多个数组成员变量来存储每个粒子的坐标、方向矢量和速度。除此之外,你还需要另一个数组来存储指向代表粒子的特定享元的引用。这些数组必须保持同步,这样你才能够使用同一索引来获取关于某个粒子的所有数据。

享元模式的解决方案

更优雅的解决方案是创建独立的情景类来存储外在状态和对享元对象的引用。在该方法中,容器类只需包含一个数组。

稍等!这样的话情景对象数量不是会和不采用该模式时的对象数量一样多吗?的确如此,但这些对象要比之前小很多。消耗内存最多的成员变量已经被移动到很少的几个享元对象中了。现在,一个享元大对象会被上千个情境小对象复用,因此无需再重复存储数千个大对象的数据。

享元与不可变性

由于享元对象可在不同的情景中使用,你必须确保其状态不能被修改。享元类的状态只能由构造函数的参数进行一次性初始化,它不能对其他对象公开其设置器或公有成员变量。

享元工厂

为了能更方便地访问各种享元,你可以创建一个工厂方法来管理已有享元对象的缓存池。工厂方法从客户端处接收目标享元对象的内在状态作为参数,如果它能在缓存池中找到所需享元,则将其返回给客户端;如果没有找到,它就会新建一个享元,并将其添加到缓存池中。

你可以选择在程序的不同地方放入该函数。最简单的选择就是将其放置在享元容器中。除此之外,你还可以新建一个工厂类,或者创建一个静态的工厂方法并将其放入实际的享元类中。

享元模式结构

享元设计模式的结构享元设计模式的结构

  1. 享元模式只是一种优化。在应用该模式之前,你要确定程序中存在与大量类似对象同时占用内存相关的内存消耗问题,并且确保该问题无法使用其他更好的方式来解决。

  2. 享元(Flyweight)类包含原始对象中部分能在多个对象中共享的状态。同一享元对象可在许多不同情景中使用。享元中存储的状态被称为“内在状态”。传递给享元方法的状态被称为“外在状态”。

  3. 情景(Context)类包含原始对象中各不相同的外在状态。情景与享元对象组合在一起就能表示原始对象的全部状态。

  4. 通常情况下,原始对象的行为会保留在享元类中。因此调用享元方法必须提供部分外在状态作为参数。但你也可将行为移动到情景类中,然后将连入的享元作为单纯的数据对象。

  5. 客户端(Client)负责计算或存储享元的外在状态。在客户端看来,享元是一种可在运行时进行配置的模板对象,具体的配置方式为向其方法中传入一些情景数据参数。

  6. 享元工厂(Flyweight Factory)会对已有享元的缓存池进行管理。有了工厂后,客户端就无需直接创建享元,它们只需调用工厂并向其传递目标享元的一些内在状态即可。工厂会根据参数在之前已创建的享元中进行查找,如果找到满足条件的享元就将其返回;如果没有找到就根据参数新建享元。

伪代码

在本例中,享元模式能有效减少在画布上渲染数百万个树状对象时所需的内存。

享元模式的示例

该模式从主要的Tree类中抽取内在状态,并将其移动到享元类树种类Tree­Type之中。

最初程序需要在多个对象中存储相同数据,而现在仅需在几个享元对象中保存数据,然后在作为情景的对象中连入享元即可。客户端代码使用享元工厂创建树对象并封装搜索指定对象的复杂行为,并能在需要时复用对象。

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
// 享元类包含一个树的部分状态。这些成员变量保存的数值对于特定树而言是唯一
// 的。例如,你在这里找不到树的坐标。但这里有很多树木之间所共有的纹理和颜
// 色。由于这些数据的体积通常非常大,所以如果让每棵树都其进行保存的话将耗
// 费大量内存。因此,我们可将纹理、颜色和其他重复数据导出到一个单独的对象
// 中,然后让众多的单个树对象去引用它。
class TreeType is
field name
field color
field texture
constructor TreeType(name, color, texture) { ... }
method draw(canvas, x, y) is
// 1. 创建特定类型、颜色和纹理的位图。
// 2. 在画布坐标 (X,Y) 处绘制位图。

// 享元工厂决定是否复用已有享元或者创建一个新的对象。
class TreeFactory is
static field treeTypes: collection of tree types
static method getTreeType(name, color, texture) is
type = treeTypes.find(name, color, texture)
if (type == null)
type = new TreeType(name, color, texture)
treeTypes.add(type)
return type

// 情景对象包含树状态的外在部分。程序中可以创建数十亿个此类对象,因为它们
// 体积很小:仅有两个整型坐标和一个引用成员变量。
class Tree is
field x,y
field type: TreeType
constructor Tree(x, y, type) { ... }
method draw(canvas) is
type.draw(canvas, this.x, this.y)

// 树(Tree)和森林(Forest)类是享元的客户端。如果不打算继续对树类进行开
// 发,你可以将它们合并。
class Forest is
field trees: collection of Trees

method plantTree(x, y, name, color, texture) is
type = TreeFactory.getTreeType(name, color, texture)
tree = new Tree(x, y, type)
trees.add(tree)

method draw(canvas) is
foreach (tree in trees) do
tree.draw(canvas)

享元模式适合应用场景

仅在程序必须支持大量对象且没有足够的内存容量时使用享元模式。

应用该模式所获的收益大小取决于使用它的方式和情景。它在下列情况中最有效:

  • 程序需要生成数量巨大的相似对象
  • 这将耗尽目标设备的所有内存
  • 对象中包含可抽取且能在多个对象间共享的重复状态。

实现方式

  1. 将需要改写为享元的类成员变量拆分为两个部分:

    • 内在状态:包含不变的、可在许多对象中重复使用的数据的成员变量。
    • 外在状态:包含每个对象各自不同的情景数据的成员变量
  2. 保留类中表示内在状态的成员变量,并将其属性设置为不可修改。这些变量仅可在构造函数中获得初始数值。

  3. 找到所有使用外在状态成员变量的方法,为在方法中所用的每个成员变量新建一个参数,并使用该参数代替成员变量。

  4. 你可以有选择地创建工厂类来管理享元缓存池,它负责在新建享元时检查已有的享元。如果选择使用工厂,客户端就只能通过工厂来请求享元,它们需要将享元的内在状态作为参数传递给工厂。

  5. 客户端必须存储和计算外在状态(情景)的数值,因为只有这样才能调用享元对象的方法。为了使用方便,外在状态和引用享元的成员变量可以移动到单独的情景类中。

享元模式优缺点

  • 如果程序中有很多相似对象,那么你将可以节省大量内存。

  • 你可能需要牺牲执行速度来换取内存,因为他人每次调用享元方法时都需要重新计算部分情景数据。

  • 代码会变得更加复杂。团队中的新成员总是会问:​“为什么要像这样拆分一个实体的状态?”。

与其他模式的关系

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

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

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

    1. 只会有一个单例实体,但是享元类可以有多个实体,各实体的内在状态也可以不同。
    2. 单例对象可以是可变的。享元对象是不可变的。

适配器模式

亦称:封装器模式、Wrapper、Adapter

意图

适配器模式是一种结构型设计模式,它能使接口不兼容的对象能够相互合作。

适配器设计模式

问题

假如你正在开发一款股票市场监测程序,它会从不同来源下载 XML 格式的股票数据,然后向用户呈现出美观的图表。

在开发过程中,你决定在程序中整合一个第三方智能分析函数库。但是遇到了一个问题,那就是分析函数库只兼容 JSON 格式的数据。

整合分析函数库之前的程序结构

你无法“直接”使用分析函数库,因为它所需的输入数据格式与你的程序不兼容。

你可以修改程序库来支持 XML。但是,这可能需要修改部分依赖该程序库的现有代码。甚至还有更糟糕的情况,你可能根本没有程序库的源代码,从而无法对其进行修改。

解决方案

你可以创建一个适配器。这是一个特殊的对象,能够转换对象接口,使其能与其他对象进行交互。

适配器模式通过封装对象将复杂的转换过程隐藏于幕后。被封装的对象甚至察觉不到适配器的存在。例如,你可以使用一个将所有数据转换为英制单位(如英尺和英里)的适配器封装运行于米和千米单位制中的对象。

适配器不仅可以转换不同格式的数据,其还有助于采用不同接口的对象之间的合作。它的运作方式如下:

  1. 适配器实现与其中一个现有对象兼容的接口。
  2. 现有对象可以使用该接口安全地调用适配器方法。
  3. 适配器方法被调用后将以另一个对象兼容的格式和顺序将请求传递给该对象。

有时你甚至可以创建一个双向适配器来实现双向转换调用。

适配器解决方案

让我们回到股票市场程序。为了解决数据格式不兼容的问题,你可以为分析函数库中的每个类创建将 XML 转换为 JSON 格式的适配器,然后让客户端仅通过这些适配器来与函数库进行交流。当某个适配器被调用时,它会将传入的 XML 数据转换为 JSON 结构,并将其传递给被封装分析对象的相应方法。

真实世界类比

适配器模式的示例

出国旅行前后的旅行箱。

如果你是第一次从美国到欧洲旅行,那么在给笔记本充电时可能会大吃一惊。不同国家的电源插头和插座标准不同。美国插头和德国插座不匹配。同时提供美国标准插座和欧洲标准插头的电源适配器可以解决你的难题。

适配器模式结构

对象适配器

实现时使用了构成原则:适配器实现了其中一个对象的接口,并对另一个对象进行封装。所有流行的编程语言都可以实现适配器。

适配器设计模式的结构(对象适配器)适配器设计模式的结构(对象适配器)

  1. 客户端(Client)是包含当前程序业务逻辑的类。

  2. 客户端接口(Client Interface)描述了其他类与客户端代码合作时必须遵循的协议。

  3. 服务(Service)中有一些功能类(通常来自第三方或遗留系统)。客户端与其接口不兼容,因此无法直接调用其功能。

  4. 适配器(Adapter)是一个可以同时与客户端和服务交互的类:它在实现客户端接口的同时封装了服务对象。适配器接受客户端通过适配器接口发起的调用,并将其转换为适用于被封装服务对象的调用。

  5. 客户端代码只需通过接口与适配器交互即可,无需与具体的适配器类耦合。因此,你可以向程序中添加新类型的适配器而无需修改已有代码。这在服务类的接口被更改或替换时很有用:你无需修改客户端代码就可以创建新的适配器类。

类适配器

这一实现使用了继承机制:适配器同时继承两个对象的接口。请注意,这种方式仅能在支持多重继承的编程语言中实现,例如 C++。

适配器设计模式(类适配器)适配器设计模式(类适配器)

  1. 类适配器不需要封装任何对象,因为它同时继承了客户端和服务的行为。适配功能在重写的方法中完成。最后生成的适配器可替代已有的客户端类进行使用。

伪代码

下列适配器模式演示基于经典的“方钉和圆孔”问题。

适配器模式结构的示例

让方钉适配圆孔。

适配器假扮成一个圆钉(Round­Peg),其半径等于方钉(Square­Peg)横截面对角线的一半(即能够容纳方钉的最小外接圆的半径)。

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
// 假设你有两个接口相互兼容的类:圆孔(Round­Hole)和圆钉(Round­Peg)。
class RoundHole is
constructor RoundHole(radius) { ... }

method getRadius() is
// 返回孔的半径。

method fits(peg: RoundPeg) is
return this.getRadius() >= peg.getRadius()

class RoundPeg is
constructor RoundPeg(radius) { ... }

method getRadius() is
// 返回钉子的半径。


// 但还有一个不兼容的类:方钉(Square­Peg)。
class SquarePeg is
constructor SquarePeg(width) { ... }

method getWidth() is
// 返回方钉的宽度。


// 适配器类让你能够将方钉放入圆孔中。它会对 RoundPeg 类进行扩展,以接收适
// 配器对象作为圆钉。
class SquarePegAdapter extends RoundPeg is
// 在实际情况中,适配器中会包含一个 SquarePeg 类的实例。
private field peg: SquarePeg

constructor SquarePegAdapter(peg: SquarePeg) is
this.peg = peg

method getRadius() is
// 适配器会假扮为一个圆钉,
// 其半径刚好能与适配器实际封装的方钉搭配起来。
return peg.getWidth() * Math.sqrt(2) / 2


// 客户端代码中的某个位置。
hole = new RoundHole(5)
rpeg = new RoundPeg(5)
hole.fits(rpeg) // true

small_sqpeg = new SquarePeg(5)
large_sqpeg = new SquarePeg(10)
hole.fits(small_sqpeg) // 此处无法编译(类型不一致)。

small_sqpeg_adapter = new SquarePegAdapter(small_sqpeg)
large_sqpeg_adapter = new SquarePegAdapter(large_sqpeg)
hole.fits(small_sqpeg_adapter) // true
hole.fits(large_sqpeg_adapter) // false

适配器模式适合应用场景

当你希望使用某个类,但是其接口与其他代码不兼容时,可以使用适配器类。

适配器模式允许你创建一个中间层类,其可作为代码与遗留类、第三方类或提供怪异接口的类之间的转换器。

如果您需要复用这样一些类,他们处于同一个继承体系,并且他们又有了额外的一些共同的方法,但是这些共同的方法不是所有在这一继承体系中的子类所具有的共性。

你可以扩展每个子类,将缺少的功能添加到新的子类中。但是,你必须在所有新子类中重复添加这些代码,这样会使得代码有坏味道

将缺失功能添加到一个适配器类中是一种优雅得多的解决方案。然后你可以将缺少功能的对象封装在适配器中,从而动态地获取所需功能。如要这一点正常运作,目标类必须要有通用接口,适配器的成员变量应当遵循该通用接口。这种方式同装饰模式非常相似。

实现方式

  1. 确保至少有两个类的接口不兼容:

    • 一个无法修改(通常是第三方、遗留系统或者存在众多已有依赖的类)的功能性服务类。
    • 一个或多个将受益于使用服务类的客户端类。
  2. 声明客户端接口,描述客户端如何与服务交互。

  3. 创建遵循客户端接口的适配器类。所有方法暂时都为空。

  4. 在适配器类中添加一个成员变量用于保存对于服务对象的引用。通常情况下会通过构造函数对该成员变量进行初始化,但有时在调用其方法时将该变量传递给适配器会更方便。

  5. 依次实现适配器类客户端接口的所有方法。适配器会将实际工作委派给服务对象,自身只负责接口或数据格式的转换。

  6. 客户端必须通过客户端接口使用适配器。这样一来,你就可以在不影响客户端代码的情况下修改或扩展适配器。

适配器模式优缺点

  • _单一职责原则_你可以将接口或数据转换代码从程序主要业务逻辑中分离。

  • 开闭原则。只要客户端代码通过客户端接口与适配器进行交互,你就能在不修改现有客户端代码的情况下在程序中添加新类型的适配器。

  • 代码整体复杂度增加,因为你需要新增一系列接口和类。有时直接更改服务类使其与其他代码兼容会更简单。

与其他模式的关系

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

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

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

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

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

命令模式

亦称:动作、事务、Action、Transaction、Command

意图

命令模式是一种行为设计模式,它可将请求转换为一个包含与请求相关的所有信息的独立对象。该转换让你能根据不同的请求将方法参数化、延迟请求执行或将其放入队列中,且能实现可撤销操作。

命令设计模式

问题

假如你正在开发一款新的文字编辑器,当前的任务是创建一个包含多个按钮的工具栏,并让每个按钮对应编辑器的不同操作。你创建了一个非常简洁的按钮类,它不仅可用于生成工具栏上的按钮,还可用于生成各种对话框的通用按钮。

命令模式解决的问题

应用中的所有按钮都可以继承相同的类

尽管所有按钮看上去都很相似,但它们可以完成不同的操作(打开、保存、打印和应用等)。你会在哪里放置这些按钮的点击处理代码呢?最简单的解决方案是在使用按钮的每个地方都创建大量的子类。这些子类中包含按钮点击后必须执行的代码。

大量的按钮子类

大量的按钮子类。没关系的。

你很快就意识到这种方式有严重缺陷。首先,你创建了大量的子类,当每次修改基类按钮时,你都有可能需要修改所有子类的代码。简单来说,GUI 代码以一种拙劣的方式依赖于业务逻辑中的不稳定代码。

多个类实现同一功能

多个类实现同一功能。

还有一个部分最难办。复制/粘贴文字等操作可能会在多个地方被调用。例如用户可以点击工具栏上小小的“复制”按钮,或者通过上下文菜单复制一些内容,又或者直接使用键盘上的Ctrl+C

我们的程序最初只有工具栏,因此可以使用按钮子类来实现各种不同操作。换句话来说,​复制按钮Copy­Button子类包含复制文字的代码是可行的。在实现了上下文菜单、快捷方式和其他功能后,你要么需要将操作代码复制进许多个类中,要么需要让菜单依赖于按钮,而后者是更糟糕的选择。

解决方案

优秀的软件设计通常会将关注点进行分离,而这往往会导致软件的分层。最常见的例子:一层负责用户图像界面;另一层负责业务逻辑。GUI 层负责在屏幕上渲染美观的图形,捕获所有输入并显示用户和程序工作的结果。当需要完成一些重要内容时(比如计算月球轨道或撰写年度报告),GUI 层则会将工作委派给业务逻辑底层。

这在代码中看上去就像这样:一个 GUI 对象传递一些参数来调用一个业务逻辑对象。这个过程通常被描述为一个对象发送请求给另一个对象。

GUI 层可以直接访问业务逻辑层

GUI 层可以直接访问业务逻辑层。

命令模式建议 GUI 对象不直接提交这些请求。你应该将请求的所有细节(例如调用的对象、方法名称和参数列表)抽取出来组成命令类,该类中仅包含一个用于触发请求的方法。

命令对象负责连接不同的 GUI 和业务逻辑对象。此后,GUI 对象无需了解业务逻辑对象是否获得了请求,也无需了解其对请求进行处理的方式。GUI 对象触发命令即可,命令对象会自行处理所有细节工作。

通过命令访问业务逻辑层。

通过命令访问业务逻辑层。

下一步是让所有命令实现相同的接口。该接口通常只有一个没有任何参数的执行方法,让你能在不和具体命令类耦合的情况下使用同一请求发送者执行不同命令。此外还有额外的好处,现在你能在运行时切换连接至发送者的命令对象,以此改变发送者的行为。

你可能会注意到遗漏的一块拼图——请求的参数。GUI 对象可以给业务层对象提供一些参数。但执行命令方法没有任何参数,所以我们如何将请求的详情发送给接收者呢?答案是:使用数据对命令进行预先配置,或者让其能够自行获取数据。

GUI 对象将命令委派给命令对象

GUI 对象将命令委派给命令对象。

让我们回到文本编辑器。应用命令模式后,我们不再需要任何按钮子类来实现点击行为。我们只需在按钮Button基类中添加一个成员变量来存储对于命令对象的引用,并在点击后执行该命令即可。

你需要为每个可能的操作实现一系列命令类,并且根据按钮所需行为将命令和按钮连接起来。

其他菜单、快捷方式或整个对话框等 GUI 元素都可以通过相同方式来实现。当用户与 GUI 元素交互时,与其连接的命令将会被执行。现在你很可能已经猜到了,与相同操作相关的元素将会被连接到相同的命令,从而避免了重复代码。

最后,命令成为了减少 GUI 和业务逻辑层之间耦合的中间层。而这仅仅是命令模式所提供的一小部分好处!

真实世界类比

在餐厅里点餐

在餐厅里点餐。

在市中心逛了很久的街后,你找到了一家不错的餐厅,坐在了临窗的座位上。一名友善的服务员走近你,迅速记下你点的食物,写在一张纸上。服务员来到厨房,把订单贴在墙上。过了一段时间,厨师拿到了订单,他根据订单来准备食物。厨师将做好的食物和订单一起放在托盘上。服务员看到托盘后对订单进行检查,确保所有食物都是你要的,然后将食物放到了你的桌上。

那张纸就是一个命令,它在厨师开始烹饪前一直位于队列中。命令中包含与烹饪这些食物相关的所有信息。厨师能够根据它马上开始烹饪,而无需跑来直接和你确认订单详情。

命令模式结构

命令设计模式的结构命令设计模式的结构

  1. 发送者(Sender)——亦称“触发者(Invoker)”——类负责对请求进行初始化,其中必须包含一个成员变量来存储对于命令对象的引用。发送者触发命令,而不向接收者直接发送请求。注意,发送者并不负责创建命令对象:它通常会通过构造函数从客户端处获得预先生成的命令。

  2. 命令(Command)接口通常仅声明一个执行命令的方法。

  3. 具体命令(Concrete Commands)会实现各种类型的请求。具体命令自身并不完成工作,而是会将调用委派给一个业务逻辑对象。但为了简化代码,这些类可以进行合并。

    接收对象执行方法所需的参数可以声明为具体命令的成员变量。你可以将命令对象设为不可变,仅允许通过构造函数对这些成员变量进行初始化。

  4. 接收者(Receiver)类包含部分业务逻辑。几乎任何对象都可以作为接收者。绝大部分命令只处理如何将请求传递到接收者的细节,接收者自己会完成实际的工作。

  5. 客户端(Client)会创建并配置具体命令对象。客户端必须将包括接收者实体在内的所有请求参数传递给命令的构造函数。此后,生成的命令就可以与一个或多个发送者相关联了。

伪代码

在本例中,命令模式会记录已执行操作的历史记录,以在需要时撤销操作。

命令模式示例的结构

文本编辑器中的可撤销操作。

有些命令会改变编辑器的状态(例如剪切和粘贴),它们可在执行相关操作前对编辑器的状态进行备份。命令执行后会和当前点备份的编辑器状态一起被放入命令历史(命令对象栈)。此后,如果用户需要进行回滚操作,程序可从历史记录中取出最近的命令,读取相应的编辑器状态备份,然后进行恢复。

客户端代码(GUI 元素和命令历史等)没有和具体命令类相耦合,因为它通过命令接口来使用命令。这使得你能在无需修改已有代码的情况下在程序中增加新的命令。

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
// 命令基类会为所有具体命令定义通用接口。
abstract class Command is
protected field app: Application
protected field editor: Editor
protected field backup: text

constructor Command(app: Application, editor: Editor) is
this.app = app
this.editor = editor

// 备份编辑器状态。
method saveBackup() is
backup = editor.text

// 恢复编辑器状态。
method undo() is
editor.text = backup

// 执行方法被声明为抽象以强制所有具体命令提供自己的实现。该方法必须根
// 据命令是否更改编辑器的状态返回 true 或 false。
abstract method execute()


// 这里是具体命令。
class CopyCommand extends Command is
// 复制命令不会被保存到历史记录中,因为它没有改变编辑器的状态。
method execute() is
app.clipboard = editor.getSelection()
return false

class CutCommand extends Command is
// 剪切命令改变了编辑器的状态,因此它必须被保存到历史记录中。只要方法
// 返回 true,它就会被保存。
method execute() is
saveBackup()
app.clipboard = editor.getSelection()
editor.deleteSelection()
return true

class PasteCommand extends Command is
method execute() is
saveBackup()
editor.replaceSelection(app.clipboard)
return true

// 撤销操作也是一个命令。
class UndoCommand extends Command is
method execute() is
app.undo()
return false


// 全局命令历史记录就是一个堆桟。
class CommandHistory is
private field history: array of Command

// 后进...
method push(c: Command) is
// 将命令压入历史记录数组的末尾。

// ...先出
method pop():Command is
// 从历史记录中取出最近的命令。


// 编辑器类包含实际的文本编辑操作。它会担任接收者的角色:最后所有命令都会
// 将执行工作委派给编辑器的方法。
class Editor is
field text: string

method getSelection() is
// 返回选中的文字。

method deleteSelection() is
// 删除选中的文字。

method replaceSelection(text) is
// 在当前位置插入剪贴板中的内容。

// 应用程序类会设置对象之间的关系。它会担任发送者的角色:当需要完成某些工
// 作时,它会创建并执行一个命令对象。
class Application is
field clipboard: string
field editors: array of Editors
field activeEditor: Editor
field history: CommandHistory

// 将命令分派给 UI 对象的代码可能会是这样的。
method createUI() is
// ...
copy = function() { executeCommand(
new CopyCommand(this, activeEditor)) }
copyButton.setCommand(copy)
shortcuts.onKeyPress("Ctrl+C", copy)

cut = function() { executeCommand(
new CutCommand(this, activeEditor)) }
cutButton.setCommand(cut)
shortcuts.onKeyPress("Ctrl+X", cut)

paste = function() { executeCommand(
new PasteCommand(this, activeEditor)) }
pasteButton.setCommand(paste)
shortcuts.onKeyPress("Ctrl+V", paste)

undo = function() { executeCommand(
new UndoCommand(this, activeEditor)) }
undoButton.setCommand(undo)
shortcuts.onKeyPress("Ctrl+Z", undo)

// 执行一个命令并检查它是否需要被添加到历史记录中。
method executeCommand(command) is
if (command.execute)
history.push(command)

// 从历史记录中取出最近的命令并运行其 undo(撤销)方法。请注意,你并
// 不知晓该命令所属的类。但是我们不需要知晓,因为命令自己知道如何撤销
// 其动作。
method undo() is
command = history.pop()
if (command != null)
command.undo()

命令模式适合应用场景

如果你需要通过操作来参数化对象,可使用命令模式。

命令模式可将特定的方法调用转化为独立对象。这一改变也带来了许多有趣的应用:你可以将命令作为方法的参数进行传递、将命令保存在其他对象中,或者在运行时切换已连接的命令等。

举个例子:你正在开发一个 GUI 组件(例如上下文菜单),你希望用户能够配置菜单项,并在点击菜单项时触发操作。

如果你想要将操作放入队列中、操作的执行或者远程执行操作,可使用命令模式。

同其他对象一样,命令也可以实现序列化(序列化的意思是转化为字符串),从而能方便地写入文件或数据库中。一段时间后,该字符串可被恢复成为最初的命令对象。因此,你可以延迟或计划命令的执行。但其功能远不止如此!使用同样的方式,你还可以将命令放入队列、记录命令或者通过网络发送命令。

如果你想要实现操作回滚功能,可使用命令模式。

尽管有很多方法可以实现撤销和恢复功能,但命令模式可能是其中最常用的一种。

为了能够回滚操作,你需要实现已执行操作的历史记录功能。命令历史记录是一种包含所有已执行命令对象及其相关程序状态备份的栈结构。

这种方法有两个缺点。首先,程序状态的保存功能并不容易实现,因为部分状态可能是私有的。你可以使用备忘录模式来在一定程度上解决这个问题。

其次,备份状态可能会占用大量内存。因此,有时你需要借助另一种实现方式:命令无需恢复原始状态,而是执行反向操作。反向操作也有代价:它可能会很难甚至是无法实现。

实现方式

  1. 声明仅有一个执行方法的命令接口。

  2. 抽取请求并使之成为实现命令接口的具体命令类。每个类都必须有一组成员变量来保存请求参数和对于实际接收者对象的引用。所有这些变量的数值都必须通过命令构造函数进行初始化。

  3. 找到担任发送者职责的类。在这些类中添加保存命令的成员变量。发送者只能通过命令接口与其命令进行交互。发送者自身通常并不创建命令对象,而是通过客户端代码获取。

  4. 修改发送者使其执行命令,而非直接将请求发送给接收者。

  5. 客户端必须按照以下顺序来初始化对象:

    • 创建接收者。
    • 创建命令,如有需要可将其关联至接收者。
    • 创建发送者并将其与特定命令关联。

命令模式优缺点

  • 单一职责原则。你可以解耦触发和执行操作的类。

  • 开闭原则。你可以在不修改已有客户端代码的情况下在程序中创建新的命令。

  • 你可以实现撤销和恢复功能。

  • 你可以实现操作的延迟执行。

  • 你可以将一组简单命令组合成一个复杂命令。

  • 代码可能会变得更加复杂,因为你在发送者和接收者之间增加了一个全新的层次。

与其他模式的关系

  • 责任链模式命令模式中介者模式观察者模式用于处理请求发送者和接收者之间的不同连接方式:

    • 责任链按照顺序将请求动态传递给一系列的潜在接收者,直至其中一名接收者对请求进行处理。
    • 命令在发送者和请求者之间建立单向连接。
    • 中介者清除了发送者和请求者之间的直接连接,强制它们通过一个中介对象进行间接沟通。
    • 观察者允许接收者动态地订阅或取消接收请求。
  • 责任链的管理者可使用命令模式实现。在这种情况下,你可以对由请求代表的同一个上下文对象执行许多不同的操作。

    还有另外一种实现方式,那就是请求自身就是一个命令对象。在这种情况下,你可以对由一系列不同上下文连接而成的链执行相同的操作。

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

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

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

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

  • 原型模式可用于保存命令的历史记录。

  • 你可以将访问者模式视为命令模式的加强版本,其对象可对不同类的多种对象执行操作。

访问者模式

亦称:Visitor

意图

访问者模式是一种行为设计模式,它能将算法与其所作用的对象隔离开来。

访问者设计模式

问题

假如你的团队开发了一款能够使用巨型图像中地理信息的应用程序。图像中的每个节点既能代表复杂实体(例如一座城市),也能代表更精细的对象(例如工业区和旅游景点等)。如果节点代表的真实对象之间存在公路,那么这些节点就会相互连接。在程序内部,每个节点的类型都由其所属的类来表示,每个特定的节点则是一个对象。

将图像导出为 XML

将图像导出为 XML。

一段时间后,你接到了实现将图像导出到 XML 文件中的任务。这些工作最初看上去非常简单。你计划为每个节点类添加导出函数,然后递归执行图像中每个节点的导出函数。解决方案简单且优雅:使用多态机制可以让导出方法的调用代码不会和具体的节点类相耦合。

但你不太走运,系统架构师拒绝批准对已有节点类进行修改。他认为这些代码已经是产品了,不想冒险对其进行修改,因为修改可能会引入潜在的缺陷。

所有节点的类中都必须添加导出至 XML 文件的方法

所有节点的类中都必须添加导出至 XML 文件的方法,但如果在修改代码的过程中引入了任何缺陷,那么整个程序都会面临风险。

此外,他还质疑在节点类中包含导出 XML 文件的代码是否有意义。这些类的主要工作是处理地理数据。导出 XML 文件的代码放在这里并不合适。

还有另一个原因,那就是在此项任务完成后,营销部门很有可能会要求程序提供导出其他类型文件的功能,或者提出其他奇怪的要求。这样你很可能会被迫再次修改这些重要但脆弱的类。

解决方案

访问者模式建议将新行为放入一个名为访问者的独立类中,而不是试图将其整合到已有类中。现在,需要执行操作的原始对象将作为参数被传递给访问者中的方法,让方法能访问对象所包含的一切必要数据。

如果现在该操作能在不同类的对象上执行会怎么样呢?比如在我们的示例中,各节点类导出 XML 文件的实际实现很可能会稍有不同。因此,访问者类可以定义一组(而不是一个)方法,且每个方法可接收不同类型的参数,如下所示:

1
2
3
4
5
class ExportVisitor implements Visitor is
method doForCity(City c) { ... }
method doForIndustry(Industry f) { ... }
method doForSightSeeing(SightSeeing ss) { ... }
// ...

但我们究竟应该如何调用这些方法(尤其是在处理整个图像方面)呢?这些方法的签名各不相同,因此我们不能使用多态机制。为了可以挑选出能够处理特定对象的访问者方法,我们需要对它的类进行检查。这是不是听上去像个噩梦呢?

1
2
3
4
5
6
7
foreach (Node node in graph)
if (node instanceof City)
exportVisitor.doForCity((City) node)
if (node instanceof Industry)
exportVisitor.doForIndustry((Industry) node)
// ...
}

你可能会问,我们为什么不使用方法重载呢?就是使用相同的方法名称,但它们的参数不同。不幸的是,即使我们的编程语言(例如 Java 和 C#)支持重载也不行。由于我们无法提前知晓节点对象所属的类,所以重载机制无法执行正确的方法。方法会将节点基类作为输入参数的默认类型。

但是,访问者模式可以解决这个问题。它使用了一种名为双分派的技巧,不使用累赘的条件语句也可下执行正确的方法。与其让客户端来选择调用正确版本的方法,不如将选择权委派给作为参数传递给访问者的对象。由于该对象知晓其自身的类,因此能更自然地在访问者中选出正确的方法。它们会“接收”一个访问者并告诉其应执行的访问者方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 客户端代码
foreach (Node node in graph)
node.accept(exportVisitor)

// 城市
class City is
method accept(Visitor v) is
v.doForCity(this)
// ...

// 工业区
class Industry is
method accept(Visitor v) is
v.doForIndustry(this)
// ...

我承认最终还是修改了节点类,但毕竟改动很小,且使得我们能够在后续进一步添加行为时无需再次修改代码。

现在,如果我们抽取出所有访问者的通用接口,所有已有的节点都能与我们在程序中引入的任何访问者交互。如果需要引入与节点相关的某个行为,你只需要实现一个新的访问者类即可。

真实世界类比

保险代理

优秀的保险代理人总能为不同类型的团体提供不同的保单。

假如有这样一位非常希望赢得新客户的资深保险代理人。他可以拜访街区中的每栋楼,尝试向每个路人推销保险。所以,根据大楼内组织类型的不同,他可以提供专门的保单:

  • 如果建筑是居民楼,他会推销医疗保险。
  • 如果建筑是银行,他会推销失窃保险。
  • 如果建筑是咖啡厅,他会推销火灾和洪水保险。

访问者模式结构

访问者设计模式的结构访问者设计模式的结构

  1. 访问者(Visitor)接口声明了一系列以对象结构的具体元素为参数的访问者方法。如果编程语言支持重载,这些方法的名称可以是相同的,但是其参数一定是不同的。

  2. 具体访问者(Concrete Visitor)会为不同的具体元素类实现相同行为的几个不同版本。

  3. 元素(Element)接口声明了一个方法来“接收”访问者。该方法必须有一个参数被声明为访问者接口类型。

  4. 具体元素(Concrete Element)必须实现接收方法。该方法的目的是根据当前元素类将其调用重定向到相应访问者的方法。请注意,即使元素基类实现了该方法,所有子类都必须对其进行重写并调用访问者对象中的合适方法。

  5. 客户端(Client)通常会作为集合或其他复杂对象(例如一个组合树)的代表。客户端通常不知晓所有的具体元素类,因为它们会通过抽象接口与集合中的对象进行交互。

伪代码

在本例中,访问者模式为几何图像层次结构添加了对于 XML 文件导出功能的支持。

访问者模式示例的结构

通过访问者对象将各种类型的对象导出为 XML 格式文件。

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
// 元素接口声明了一个`accept(接收)`方法,它会将访问者基础接口作为一个参
// 数。
interface Shape is
method move(x, y)
method draw()
method accept(v: Visitor)

// 每个具体元素类都必须以特定方式实现`accept`方法,使其能调用相应元素类的
// 访问者方法。
class Dot implements Shape is
// ...

// 注意我们正在调用的`visitDot(访问点)`方法与当前类的名称相匹配。
// 这样我们能让访问者知晓与其交互的元素类。
method accept(v: Visitor) is
v.visitDot(this)

class Circle implements Shape is
// ...
method accept(v: Visitor) is
v.visitCircle(this)

class Rectangle implements Shape is
// ...
method accept(v: Visitor) is
v.visitRectangle(this)

class CompoundShape implements Shape is
// ...
method accept(v: Visitor) is
v.visitCompoundShape(this)


// 访问者接口声明了一组与元素类对应的访问方法。访问方法的签名能让访问者准
// 确辨别出与其交互的元素所属的类。
interface Visitor is
method visitDot(d: Dot)
method visitCircle(c: Circle)
method visitRectangle(r: Rectangle)
method visitCompoundShape(cs: CompoundShape)

// 具体访问者实现了同一算法的多个版本,而且该算法能与所有具体类进行交互。
//
// 访问者模式在复杂对象结构(例如组合树)上使用时能发挥最大作用。在这种情
// 况下,它可以存储算法的一些中间状态,并同时在结构中的不同对象上执行访问
// 者方法。这可能会非常有帮助。
class XMLExportVisitor implements Visitor is
method visitDot(d: Dot) is
// 导出点(dot)的 ID 和中心坐标。

method visitCircle(c: Circle) is
// 导出圆(circle)的 ID 、中心坐标和半径。

method visitRectangle(r: Rectangle) is
// 导出长方形(rectangle)的 ID 、左上角坐标、宽和长。

method visitCompoundShape(cs: CompoundShape) is
// 导出图形(shape)的 ID 和其子项目的 ID 列表。


// 客户端代码可在不知晓具体类的情况下在一组元素上运行访问者操作。“接收”操
// 作会将调用定位到访问者对象的相应操作上。
class Application is
field allShapes: array of Shapes

method export() is
exportVisitor = new XMLExportVisitor()

foreach (shape in allShapes) do
shape.accept(exportVisitor)

如果你并不十分理解为何本例中需要使用accept接收方法,我的一篇文章访问者和双分派详细解释了这个问题。

访问者模式适合应用场景

如果你需要对一个复杂对象结构(例如对象树)中的所有元素执行某些操作,可使用访问者模式。

访问者模式通过在访问者对象中为多个目标类提供相同操作的变体,让你能在属于不同类的一组对象上执行同一操作。

可使用访问者模式来清理辅助行为的业务逻辑。

该模式会将所有非主要的行为抽取到一组访问者类中,使得程序的主要类能更专注于主要的工作。

当某个行为仅在类层次结构中的一些类中有意义,而在其他类中没有意义时,可使用该模式。

你可将该行为抽取到单独的访问者类中,只需实现接收相关类的对象作为参数的访问者方法并将其他方法留空即可。

实现方式

  1. 在访问者接口中声明一组“访问”方法,分别对应程序中的每个具体元素类。

  2. 声明元素接口。如果程序中已有元素类层次接口,可在层次结构基类中添加抽象的“接收”方法。该方法必须接受访问者对象作为参数。

  3. 在所有具体元素类中实现接收方法。这些方法必须将调用重定向到当前元素对应的访问者对象中的访问者方法上。

  4. 元素类只能通过访问者接口与访问者进行交互。不过访问者必须知晓所有的具体元素类,因为这些类在访问者方法中都被作为参数类型引用。

  5. 为每个无法在元素层次结构中实现的行为创建一个具体访问者类并实现所有的访问者方法。

    你可能会遇到访问者需要访问元素类的部分私有成员变量的情况。在这种情况下,你要么将这些变量或方法设为公有,这将破坏元素的封装;要么将访问者类嵌入到元素类中。后一种方式只有在支持嵌套类的编程语言中才可能实现。

  6. 客户端必须创建访问者对象并通过“接收”方法将其传递给元素。

访问者模式优缺点

  • 开闭原则。你可以引入在不同类对象上执行的新行为,且无需对这些类做出修改。

  • 单一职责原则。可将同一行为的不同版本移到同一个类中。

  • 访问者对象可以在与各种对象交互时收集一些有用的信息。当你想要遍历一些复杂的对象结构(例如对象树),并在结构中的每个对象上应用访问者时,这些信息可能会有所帮助。

  • 每次在元素层次结构中添加或移除一个类时,你都要更新所有的访问者。

  • 在访问者同某个元素进行交互时,它们可能没有访问元素私有成员变量和方法的必要权限。

与其他模式的关系

生成器模式

亦称:建造者模式、Builder

意图

生成器模式是一种创建型设计模式,使你能够分步骤创建复杂对象。该模式允许你使用相同的创建代码生成不同类型和形式的对象。

生成器设计模式

问题

假设有这样一个复杂对象,在对其进行构造时需要对诸多成员变量和嵌套对象进行繁复的初始化工作。这些初始化代码通常深藏于一个包含众多参数且让人基本看不懂的构造函数中;甚至还有更糟糕的情况,那就是这些代码散落在客户端代码的多个位置。

大量子类会带来新的问题

如果为每种可能的对象都创建一个子类,这可能会导致程序变得过于复杂。

例如,我们来思考如何创建一个房屋House对象。建造一栋简单的房屋,首先你需要建造四面墙和地板,安装房门和一套窗户,然后再建造一个屋顶。但是如果你想要一栋更宽敞更明亮的房屋,还要有院子和其他设施(例如暖气、排水和供电设备),那又该怎么办呢?

最简单的方法是扩展房屋基类,然后创建一系列涵盖所有参数组合的子类。但最终你将面对相当数量的子类。任何新增的参数(例如门廊类型)都会让这个层次结构更加复杂。

另一种方法则无需生成子类。你可以在房屋基类中创建一个包括所有可能参数的超级构造函数,并用它来控制房屋对象。这种方法确实可以避免生成子类,但它却会造成另外一个问题。

可伸缩的构造函数

拥有大量输入参数的构造函数也有缺陷:这些参数也不是每次都要全部用上的。

通常情况下,绝大部分的参数都没有使用,这使得对于构造函数的调用十分不简洁。例如,只有很少的房子有游泳池,因此与游泳池相关的参数十之八九是毫无用处的。

解决方案

生成器模式建议将对象构造代码从产品类中抽取出来,并将其放在一个名为生成器的独立对象中。

应用生成器模式

生成器模式让你能够分步骤创建复杂对象。生成器不允许其他对象访问正在创建中的产品。

该模式会将对象构造过程划分为一组步骤,比如build­Walls创建墙壁和build­Door创建房门创建房门等。每次创建对象时,你都需要通过生成器对象执行一系列步骤。重点在于你无需调用所有步骤,而只需调用创建特定对象配置所需的那些步骤即可。

当你需要创建不同形式的产品时,其中的一些构造步骤可能需要不同的实现。例如,木屋的房门可能需要使用木头制造,而城堡的房门则必须使用石头制造。

在这种情况下,你可以创建多个不同的生成器,用不同方式实现一组相同的创建步骤。然后你就可以在创建过程中使用这些生成器(例如按顺序调用多个构造步骤)来生成不同类型的对象。

不同生成器以不同方式执行相同的任务。

例如,假设第一个建造者使用木头和玻璃制造房屋,第二个建造者使用石头和钢铁,而第三个建造者使用黄金和钻石。在调用同一组步骤后,第一个建造者会给你一栋普通房屋,第二个会给你一座小城堡,而第三个则会给你一座宫殿。但是,只有在调用构造步骤的客户端代码可以通过通用接口与建造者进行交互时,这样的调用才能返回需要的房屋。

主管

你可以进一步将用于创建产品的一系列生成器步骤调用抽取成为单独的主管类。主管类可定义创建步骤的执行顺序,而生成器则提供这些步骤的实现。

主管知道需要哪些创建步骤才能获得可正常使用的产品。

严格来说,你的程序中并不一定需要主管类。客户端代码可直接以特定顺序调用创建步骤。不过,主管类中非常适合放入各种例行构造流程,以便在程序中反复使用。

此外,对于客户端代码来说,主管类完全隐藏了产品构造细节。客户端只需要将一个生成器与主管类关联,然后使用主管类来构造产品,就能从生成器处获得构造结果了。

生成器模式结构

生成器设计模式结构生成器设计模式结构

  1. 生成器(Builder)接口声明在所有类型生成器中通用的产品构造步骤。

  2. 具体生成器(Concrete Builders)提供构造过程的不同实现。具体生成器也可以构造不遵循通用接口的产品。

  3. 产品(Products)是最终生成的对象。由不同生成器构造的产品无需属于同一类层次结构或接口。

  4. 主管(Director)类定义调用构造步骤的顺序,这样你就可以创建和复用特定的产品配置。

  5. 客户端(Client)必须将某个生成器对象与主管类关联。一般情况下,你只需通过主管类构造函数的参数进行一次性关联即可。此后主管类就能使用生成器对象完成后续所有的构造任务。但在客户端将生成器对象传递给主管类制造方法时还有另一种方式。在这种情况下,你在使用主管类生产产品时每次都可以使用不同的生成器。

伪代码

下面关于生成器模式的例子演示了你可以如何复用相同的对象构造代码来生成不同类型的产品——例如汽车(Car)——及其相应的使用手册(Manual)。

生成器模式结构示例

分步骤制造汽车并制作对应型号用户使用手册的示例

汽车是一个复杂对象,有数百种不同的制造方法。我们没有在汽车类中塞入一个巨型构造函数,而是将汽车组装代码抽取到单独的汽车生成器类中。该类中有一组方法可用来配置汽车的各种部件。

如果客户端代码需要组装一辆与众不同、精心调教的汽车,它可以直接调用生成器。或者,客户端可以将组装工作委托给主管类,因为主管类知道如何使用生成器制造最受欢迎的几种型号汽车。

你或许会感到吃惊,但确实每辆汽车都需要一本使用手册(说真的,谁会去读它们呢?)。使用手册会介绍汽车的每一项功能,因此不同型号的汽车,其使用手册内容也不一样。因此,你可以复用现有流程来制造实际的汽车及其对应的手册。当然,编写手册和制造汽车不是一回事,所以我们需要另外一个生成器对象来专门编写使用手册。该类与其制造汽车的兄弟类都实现了相同的制造方法,但是其功能不是制造汽车部件,而是描述每个部件。将这些生成器传递给相同的主管对象,我们就能够生成一辆汽车或是一本使用手册了。

最后一个部分是获取结果对象。尽管金属汽车和纸质手册存在关联,但它们却是完全不同的东西。我们无法在主管类和具体产品类不发生耦合的情况下,在主管类中提供获取结果对象的方法。因此,我们只能通过负责制造过程的生成器来获取结果对象。

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
// 只有当产品较为复杂且需要详细配置时,使用生成器模式才有意义。下面的两个
// 产品尽管没有同样的接口,但却相互关联。
class Car is
// 一辆汽车可能配备有 GPS 设备、行车电脑和几个座位。不同型号的汽车(
// 运动型轿车、SUV 和敞篷车)可能会安装或启用不同的功能。

class Manual is
// 用户使用手册应该根据汽车配置进行编制,并介绍汽车的所有功能。


// 生成器接口声明了创建产品对象不同部件的方法。
interface Builder is
method reset()
method setSeats(...)
method setEngine(...)
method setTripComputer(...)
method setGPS(...)

// 具体生成器类将遵循生成器接口并提供生成步骤的具体实现。你的程序中可能会
// 有多个以不同方式实现的生成器变体。
class CarBuilder implements Builder is
private field car:Car

// 一个新的生成器实例必须包含一个在后续组装过程中使用的空产品对象。
constructor CarBuilder() is
this.reset()

// reset(重置)方法可清除正在生成的对象。
method reset() is
this.car = new Car()

// 所有生成步骤都会与同一个产品实例进行交互。
method setSeats(...) is
// 设置汽车座位的数量。

method setEngine(...) is
// 安装指定的引擎。

method setTripComputer(...) is
// 安装行车电脑。

method setGPS(...) is
// 安装全球定位系统。

// 具体生成器需要自行提供获取结果的方法。这是因为不同类型的生成器可能
// 会创建不遵循相同接口的、完全不同的产品。所以也就无法在生成器接口中
// 声明这些方法(至少在静态类型的编程语言中是这样的)。
//
// 通常在生成器实例将结果返回给客户端后,它们应该做好生成另一个产品的
// 准备。因此生成器实例通常会在 `getProduct(获取产品)`方法主体末尾
// 调用重置方法。但是该行为并不是必需的,你也可让生成器等待客户端明确
// 调用重置方法后再去处理之前的结果。
method getProduct():Car is
product = this.car
this.reset()
return product

// 生成器与其他创建型模式的不同之处在于:它让你能创建不遵循相同接口的产品。
class CarManualBuilder implements Builder is
private field manual:Manual

constructor CarManualBuilder() is
this.reset()

method reset() is
this.manual = new Manual()

method setSeats(...) is
// 添加关于汽车座椅功能的文档。

method setEngine(...) is
// 添加关于引擎的介绍。

method setTripComputer(...) is
// 添加关于行车电脑的介绍。

method setGPS(...) is
// 添加关于 GPS 的介绍。

method getProduct():Manual is
// 返回使用手册并重置生成器。


// 主管只负责按照特定顺序执行生成步骤。其在根据特定步骤或配置来生成产品时
// 会很有帮助。由于客户端可以直接控制生成器,所以严格意义上来说,主管类并
// 不是必需的。
class Director is
private field builder:Builder

// 主管可同由客户端代码传递给自身的任何生成器实例进行交互。客户端可通
// 过这种方式改变最新组装完毕的产品的最终类型。
method setBuilder(builder:Builder)
this.builder = builder

// 主管可使用同样的生成步骤创建多个产品变体。
method constructSportsCar(builder: Builder) is
builder.reset()
builder.setSeats(2)
builder.setEngine(new SportEngine())
builder.setTripComputer(true)
builder.setGPS(true)

method constructSUV(builder: Builder) is
// ...


// 客户端代码会创建生成器对象并将其传递给主管,然后执行构造过程。最终结果
// 将需要从生成器对象中获取。
class Application is

method makeCar() is
director = new Director()

CarBuilder builder = new CarBuilder()
director.constructSportsCar(builder)
Car car = builder.getProduct()

CarManualBuilder builder = new CarManualBuilder()
director.constructSportsCar(builder)

// 最终产品通常需要从生成器对象中获取,因为主管不知晓具体生成器和
// 产品的存在,也不会对其产生依赖。
Manual manual = builder.getProduct()

生成器模式适合应用场景

使用生成器模式可避免“重叠构造函数(telescopic constructor)”的出现。

假设你的构造函数中有十个可选参数,那么调用该函数会非常不方便;因此,你需要重载这个构造函数,新建几个只有较少参数的简化版。但这些构造函数仍需调用主构造函数,传递一些默认数值来替代省略掉的参数。

1
2
3
4
5
class Pizza {
Pizza(int size) { ... }
Pizza(int size, boolean cheese) { ... }
Pizza(int size, boolean cheese, boolean pepperoni) { ... }
// ...

只有在 C# 或 Java 等支持方法重载的编程语言中才能写出如此复杂的构造函数。

生成器模式让你可以分步骤生成对象,而且允许你仅使用必须的步骤。应用该模式后,你再也不需要将几十个参数塞进构造函数里了。

当你希望使用代码创建不同形式的产品(例如石头或木头房屋)时,可使用生成器模式。

如果你需要创建的各种形式的产品,它们的制造过程相似且仅有细节上的差异,此时可使用生成器模式。

基本生成器接口中定义了所有可能的制造步骤,具体生成器将实现这些步骤来制造特定形式的产品。同时,主管类将负责管理制造步骤的顺序。

使用生成器构造组合树或其他复杂对象。

生成器模式让你能分步骤构造产品。你可以延迟执行某些步骤而不会影响最终产品。你甚至可以递归调用这些步骤,这在创建对象树时非常方便。

生成器在执行制造步骤时,不能对外发布未完成的产品。这可以避免客户端代码获取到不完整结果对象的情况。

实现方法

  1. 清晰地定义通用步骤,确保它们可以制造所有形式的产品。否则你将无法进一步实施该模式。

  2. 在基本生成器接口中声明这些步骤。

  3. 为每个形式的产品创建具体生成器类,并实现其构造步骤。

    不要忘记实现获取构造结果对象的方法。你不能在生成器接口中声明该方法,因为不同生成器构造的产品可能没有公共接口,因此你就不知道该方法返回的对象类型。但是,如果所有产品都位于单一类层次中,你就可以安全地在基本接口中添加获取生成对象的方法。

  4. 考虑创建主管类。它可以使用同一生成器对象来封装多种构造产品的方式。

  5. 客户端代码会同时创建生成器和主管对象。构造开始前,客户端必须将生成器对象传递给主管对象。通常情况下,客户端只需调用主管类构造函数一次即可。主管类使用生成器对象完成后续所有制造任务。还有另一种方式,那就是客户端可以将生成器对象直接传递给主管类的制造方法。

  6. 只有在所有产品都遵循相同接口的情况下,构造结果可以直接通过主管类获取。否则,客户端应当通过生成器获取构造结果。

生成器模式优缺点

  • 你可以分步创建对象,暂缓创建步骤或递归运行创建步骤。

  • 生成不同形式的产品时,你可以复用相同的制造代码。

  • 单一职责原则。你可以将复杂构造代码从产品的业务逻辑中分离出来。

  • 由于该模式需要新增多个类,因此代码整体复杂程度会有所增加。

与其他模式的关系

代理模式

亦称:Proxy

意图

代理模式是一种结构型设计模式,让你能够提供对象的替代品或其占位符。代理控制着对于原对象的访问,并允许在将请求提交给对象前后进行一些处理。

代理设计模式

问题

为什么要控制对于某个对象的访问呢?举个例子:有这样一个消耗大量系统资源的巨型对象,你只是偶尔需要使用它,并非总是需要。

代理模式解决的问题

数据库查询有可能会非常缓慢。

你可以实现延迟初始化:在实际有需要时再创建该对象。对象的所有客户端都要执行延迟初始代码。不幸的是,这很可能会带来很多重复代码。

在理想情况下,我们希望将代码直接放入对象的类中,但这并非总是能实现:比如类可能是第三方封闭库的一部分。

解决方案

代理模式建议新建一个与原服务对象接口相同的代理类,然后更新应用以将代理对象传递给所有原始对象客户端。代理类接收到客户端请求后会创建实际的服务对象,并将所有工作委派给它。

代理模式的解决方案

代理将自己伪装成数据库对象,可在客户端或实际数据库对象不知情的情况下处理延迟初始化和缓存查询结果的工作。

这有什么好处呢?如果需要在类的主要业务逻辑前后执行一些工作,你无需修改类就能完成这项工作。由于代理实现的接口与原类相同,因此你可将其传递给任何一个使用实际服务对象的客户端。

真实世界类比

信用卡是一大捆现金的代理

信用卡和现金在支付过程中的用处相同。

信用卡是银行账户的代理,银行账户则是一大捆现金的代理。它们都实现了同样的接口,均可用于进行支付。消费者会非常满意,因为不必随身携带大量现金;商店老板同样会十分高兴,因为交易收入能以电子化的方式进入商店的银行账户中,无需担心存款时出现现金丢失或被抢劫的情况。

代理模式结构

代理设计模式的结构代理设计模式的结构

  1. 服务接口(Service Interface)声明了服务接口。代理必须遵循该接口才能伪装成服务对象。

  2. 服务(Service)类提供了一些实用的业务逻辑。

  3. 代理(Proxy)类包含一个指向服务对象的引用成员变量。代理完成其任务(例如延迟初始化、记录日志、访问控制和缓存等)后会将请求传递给服务对象。通常情况下,代理会对其服务对象的整个生命周期进行管理。

  4. 客户端(Client)能通过同一接口与服务或代理进行交互,所以你可在一切需要服务对象的代码中使用代理。

伪代码

本例演示如何使用代理模式在第三方腾讯视频(TencentVideo,代码示例中记为 TV)程序库中添加延迟初始化和缓存。

代理模式示例的结构

使用代理缓冲服务结果。

程序库提供了视频下载类。但是该类的效率非常低。如果客户端程序多次请求同一视频,程序库会反复下载该视频,而不会将首次下载的文件缓存下来复用。

代理类实现和原下载器相同的接口,并将所有工作委派给原下载器。不过,代理类会保存所有的文件下载记录,如果程序多次请求同一文件,它会返回缓存的文件。

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 ThirdPartyTVLib is
method listVideos()
method getVideoInfo(id)
method downloadVideo(id)

// 服务连接器的具体实现。该类的方法可以向腾讯视频请求信息。请求速度取决于
// 用户和腾讯视频的互联网连接情况。如果同时发送大量请求,即使所请求的信息
// 一模一样,程序的速度依然会减慢。
class ThirdPartyTVClass implements ThirdPartyTVLib is
method listVideos() is
// 向腾讯视频发送一个 API 请求。

method getVideoInfo(id) is
// 获取某个视频的元数据。

method downloadVideo(id) is
// 从腾讯视频下载一个视频文件。

// 为了节省网络带宽,我们可以将请求结果缓存下来并保存一段时间。但你可能无
// 法直接将这些代码放入服务类中。比如该类可能是第三方程序库的一部分或其签
// 名是`final(最终)`。因此我们会在一个实现了服务类接口的新代理类中放入
// 缓存代码。当代理类接收到真实请求后,才会将其委派给服务对象。
class CachedTVClass implements ThirdPartyTVLib is
private field service: ThirdPartyTVLib
private field listCache, videoCache
field needReset

constructor CachedTVClass(service: ThirdPartyTVLib) is
this.service = service

method listVideos() is
if (listCache == null || needReset)
listCache = service.listVideos()
return listCache

method getVideoInfo(id) is
if (videoCache == null || needReset)
videoCache = service.getVideoInfo(id)
return videoCache

method downloadVideo(id) is
if (!downloadExists(id) || needReset)
service.downloadVideo(id)

// 之前直接与服务对象交互的 GUI 类不需要改变,前提是它仅通过接口与服务对
// 象交互。我们可以安全地传递一个代理对象来代替真实服务对象,因为它们都实
// 现了相同的接口。
class TVManager is
protected field service: ThirdPartyTVLib

constructor TVManager(service: ThirdPartyTVLib) is
this.service = service

method renderVideoPage(id) is
info = service.getVideoInfo(id)
// 渲染视频页面。

method renderListPanel() is
list = service.listVideos()
// 渲染视频缩略图列表。

method reactOnUserInput() is
renderVideoPage()
renderListPanel()

// 程序可在运行时对代理进行配置。
class Application is
method init() is
aTVService = new ThirdPartyTVClass()
aTVProxy = new CachedTVClass(aTVService)
manager = new TVManager(aTVProxy)
manager.reactOnUserInput()

代理模式适合应用场景

使用代理模式的方式多种多样,我们来看看最常见的几种。

延迟初始化(虚拟代理)。如果你有一个偶尔使用的重量级服务对象,一直保持该对象运行会消耗系统资源时,可使用代理模式。

你无需在程序启动时就创建该对象,可将对象的初始化延迟到真正有需要的时候。

访问控制(保护代理)。如果你只希望特定客户端使用服务对象,这里的对象可以是操作系统中非常重要的部分,而客户端则是各种已启动的程序(包括恶意程序),此时可使用代理模式。

代理可仅在客户端凭据满足要求时将请求传递给服务对象。

本地执行远程服务(远程代理)。适用于服务对象位于远程服务器上的情形。

在这种情形中,代理通过网络传递客户端请求,负责处理所有与网络相关的复杂细节。

记录日志请求(日志记录代理)。适用于当你需要保存对于服务对象的请求历史记录时。代理可以在向服务传递请求前进行记录。

缓存请求结果(缓存代理)。适用于需要缓存客户请求结果并对缓存生命周期进行管理时,特别是当返回结果的体积非常大时。

  • 代理可对重复请求所需的相同结果进行缓存,还可使用请求参数作为索引缓存的键值。

智能引用。可在没有客户端使用某个重量级对象时立即销毁该对象。

代理会将所有获取了指向服务对象或其结果的客户端记录在案。代理会时不时地遍历各个客户端,检查它们是否仍在运行。如果相应的客户端列表为空,代理就会销毁该服务对象,释放底层系统资源。

代理还可以记录客户端是否修改了服务对象。其他客户端还可以复用未修改的对象。

实现方式

  1. 如果没有现成的服务接口,你就需要创建一个接口来实现代理和服务对象的可交换性。从服务类中抽取接口并非总是可行的,因为你需要对服务的所有客户端进行修改,让它们使用接口。备选计划是将代理作为服务类的子类,这样代理就能继承服务的所有接口了。

  2. 创建代理类,其中必须包含一个存储指向服务的引用的成员变量。通常情况下,代理负责创建服务并对其整个生命周期进行管理。在一些特殊情况下,客户端会通过构造函数将服务传递给代理。

  3. 根据需求实现代理方法。在大部分情况下,代理在完成一些任务后应将工作委派给服务对象。

  4. 可以考虑新建一个构建方法来判断客户端可获取的是代理还是实际服务。你可以在代理类中创建一个简单的静态方法,也可以创建一个完整的工厂方法。

  5. 可以考虑为服务对象实现延迟初始化。

代理模式优缺点

  • 你可以在客户端毫无察觉的情况下控制服务对象。

  • 如果客户端对服务对象的生命周期没有特殊要求,你可以对生命周期进行管理。

  • 即使服务对象还未准备好或不存在,代理也可以正常工作。

  • 开闭原则。你可以在不对服务或客户端做出修改的情况下创建新代理。

  • 代码可能会变得复杂,因为需要新建许多类。

  • 服务响应可能会延迟。

与其他模式的关系

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

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

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

中介者模式

亦称:调解人、控制器、Intermediary、Controller、Mediator

意图

中介者模式是一种行为设计模式,能让你减少对象之间混乱无序的依赖关系。该模式会限制对象之间的直接交互,迫使它们通过一个中介者对象进行合作。

中介者设计模式

问题

假如你有一个创建和修改客户资料的对话框,它由各种控件组成,例如文本框(Text­Field)、复选框(Checkbox)和按钮(Button)等。

用户界面中各元素间的混乱关系

用户界面中各元素间的关系会随程序发展而变得混乱。

某些表单元素可能会直接进行互动。例如,选中“我有一只狗”复选框后可能会显示一个隐藏文本框用于输入狗狗的名字。另一个例子是提交按钮必须在保存数据前校验所有输入内容。

UI 中各元素相互依赖

元素间存在许多关联。因此,对某些元素进行修改可能会影响其他元素。

如果直接在表单元素代码中实现业务逻辑,你将很难在程序其他表单中复用这些元素类。例如,由于复选框类与狗狗的文本框相耦合,所以将无法在其他表单中使用它。你要么使用渲染资料表单时用到的所有类,要么一个都不用。

解决方案

中介者模式建议你停止组件之间的直接交流并使其相互独立。这些组件必须调用特殊的中介者对象,通过中介者对象重定向调用行为,以间接的方式进行合作。最终,组件仅依赖于一个中介者类,无需与多个其他组件相耦合。

在资料编辑表单的例子中,对话框(Dialog)类本身将作为中介者,其很可能已知自己所有的子元素,因此你甚至无需在该类中引入新的依赖关系。

UI 元素必须通过中介者进行沟通。

UI 元素必须通过中介者对象进行间接沟通。

绝大部分重要的修改都在实际表单元素中进行。让我们想想提交按钮。之前,当用户点击按钮后,它必须对所有表单元素数值进行校验。而现在它的唯一工作是将点击事件通知给对话框。收到通知后,对话框可以自行校验数值或将任务委派给各元素。这样一来,按钮不再与多个表单元素相关联,而仅依赖于对话框类。

你还可以为所有类型的对话框抽取通用接口,进一步削弱其依赖性。接口中将声明一个所有表单元素都能使用的通知方法,可用于将元素中发生的事件通知给对话框。这样一来,所有实现了该接口的对话框都能使用这个提交按钮了。

采用这种方式,中介者模式让你能在单个中介者对象中封装多个对象间的复杂关系网。类所拥有的依赖关系越少,就越易于修改、扩展或复用。

真实世界类比

空中交通管制塔台

飞行器驾驶员之间不会通过相互沟通来决定下一架降落的飞机。所有沟通都通过控制塔台进行。

飞行器驾驶员们在靠近或离开空中管制区域时不会直接相互交流。但他们会与飞机跑道附近,塔台中的空管员通话。如果没有空管员,驾驶员就需要留意机场附近的所有飞机,并与数十位飞行员组成的委员会讨论降落顺序。那恐怕会让飞机坠毁的统计数据一飞冲天吧。

塔台无需管制飞行全程,只需在航站区加强管控即可,因为该区域的决策参与者数量对于飞行员来说实在太多了。

中介者模式结构

中介者设计模式的结构中介者设计模式的结构

  1. 组件(Component)是各种包含业务逻辑的类。每个组件都有一个指向中介者的引用,该引用被声明为中介者接口类型。组件不知道中介者实际所属的类,因此你可通过将其连接到不同的中介者以使其能在其他程序中复用。

  2. 中介者(Mediator)接口声明了与组件交流的方法,但通常仅包括一个通知方法。组件可将任意上下文(包括自己的对象)作为该方法的参数,只有这样接收组件和发送者类之间才不会耦合。

  3. 具体中介者(Concrete Mediator)封装了多种组件间的关系。具体中介者通常会保存所有组件的引用并对其进行管理,甚至有时会对其生命周期进行管理。

  4. 组件并不知道其他组件的情况。如果组件内发生了重要事件,它只能通知中介者。中介者收到通知后能轻易地确定发送者,这或许已足以判断接下来需要触发的组件了。

    对于组件来说,中介者看上去完全就是一个黑箱。发送者不知道最终会由谁来处理自己的请求,接收者也不知道最初是谁发出了请求。

伪代码

在本例中,中介者模式可帮助你减少各种 UI 类(按钮、复选框和文本标签)之间的相互依赖关系。

中介者模式示例的结构

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
// 中介者接口声明了一个能让组件将各种事件通知给中介者的方法。中介者可对这
// 些事件做出响应并将执行工作传递给其他组件。
interface Mediator is
method notify(sender: Component, event: string)


// 具体中介者类可解开各组件之间相互交叉的连接关系并将其转移到中介者中。
class AuthenticationDialog implements Mediator is
private field title: string
private field loginOrRegisterChkBx: Checkbox
private field loginUsername, loginPassword: Textbox
private field registrationUsername, registrationPassword,
registrationEmail: Textbox
private field okBtn, cancelBtn: Button

constructor AuthenticationDialog() is
// 创建所有组件对象并将当前中介者传递给其构造函数以建立连接。

// 当组件中有事件发生时,它会通知中介者。中介者接收到通知后可自行处理,
// 也可将请求传递给另一个组件。
method notify(sender, event) is
if (sender == loginOrRegisterChkBx and event == "check")
if (loginOrRegisterChkBx.checked)
title = "登录"
// 1. 显示登录表单组件。
// 2. 隐藏注册表单组件。
else
title = "注册"
// 1. 显示注册表单组件。
// 2. 隐藏登录表单组件。

if (sender == okBtn && event == "click")
if (loginOrRegister.checked)
// 尝试找到使用登录信息的用户。
if (!found)
// 在登录字段上方显示错误信息。
else
// 1. 使用注册字段中的数据创建用户账号。
// 2. 完成用户登录工作。 …


// 组件会使用中介者接口与中介者进行交互。因此只需将它们与不同的中介者连接
// 起来,你就能在其他情境中使用这些组件了。
class Component is
field dialog: Mediator

constructor Component(dialog) is
this.dialog = dialog

method click() is
dialog.notify(this, "click")

method keypress() is
dialog.notify(this, "keypress")

// 具体组件之间无法进行交流。它们只有一个交流渠道,那就是向中介者发送通知。
class Button extends Component is
// ...

class Textbox extends Component is
// ...

class Checkbox extends Component is
method check() is
dialog.notify(this, "check")
// ...

中介者模式适合应用场景

当一些对象和其他对象紧密耦合以致难以对其进行修改时,可使用中介者模式。

该模式让你将对象间的所有关系抽取成为一个单独的类,以使对于特定组件的修改工作独立于其他组件。

当组件因过于依赖其他组件而无法在不同应用中复用时,可使用中介者模式。

应用中介者模式后,每个组件不再知晓其他组件的情况。尽管这些组件无法直接交流,但它们仍可通过中介者对象进行间接交流。如果你希望在不同应用中复用一个组件,则需要为其提供一个新的中介者类。

如果为了能在不同情景下复用一些基本行为,导致你需要被迫创建大量组件子类时,可使用中介者模式。

由于所有组件间关系都被包含在中介者中,因此你无需修改组件就能方便地新建中介者类以定义新的组件合作方式。

实现方式

  1. 找到一组当前紧密耦合,且提供其独立性能带来更大好处的类(例如更易于维护或更方便复用)。

  2. 声明中介者接口并描述中介者和各种组件之间所需的交流接口。在绝大多数情况下,一个接收组件通知的方法就足够了。如果你希望在不同情景下复用组件类,那么该接口将非常重要。只要组件使用通用接口与其中介者合作,你就能将该组件与不同实现中的中介者进行连接。

  3. 实现具体中介者类。该类可从自行保存其下所有组件的引用中受益。

  4. 你可以更进一步,让中介者负责组件对象的创建和销毁。此后,中介者可能会与工厂外观类似。

  5. 组件必须保存对于中介者对象的引用。该连接通常在组件的构造函数中建立,该函数会将中介者对象作为参数传递。

  6. 修改组件代码,使其可调用中介者的通知方法,而非其他组件的方法。然后将调用其他组件的代码抽取到中介者类中,并在中介者接收到该组件通知时执行这些代码。

中介者模式优缺点

  • 单一职责原则。你可以将多个组件间的交流抽取到同一位置,使其更易于理解和维护。

  • 开闭原则。你无需修改实际组件就能增加新的中介者。

  • 你可以减轻应用中多个组件间的耦合情况。

  • 你可以更方便地复用各个组件。

  • 一段时间后,中介者可能会演化成为上帝对象

与其他模式的关系

  • 责任链模式命令模式中介者模式观察者模式用于处理请求发送者和接收者之间的不同连接方式:

    • 责任链按照顺序将请求动态传递给一系列的潜在接收者,直至其中一名接收者对请求进行处理。
    • 命令在发送者和请求者之间建立单向连接。
    • 中介者清除了发送者和请求者之间的直接连接,强制它们通过一个中介对象进行间接沟通。
    • 观察者允许接收者动态地订阅或取消接收请求。
  • 外观模式中介者的职责类似:它们都尝试在大量紧密耦合的类中组织起合作。

    • 外观为子系统中的所有对象定义了一个简单接口,但是它不提供任何新功能。子系统本身不会意识到外观的存在。子系统中的对象可以直接进行交流。
    • 中介者将系统中组件的沟通行为中心化。各组件只知道中介者对象,无法直接相互交流。
  • 中介者观察者之间的区别往往很难记住。在大部分情况下,你可以使用其中一种模式,而有时可以同时使用。让我们来看看如何做到这一点。

    中介者的主要目标是消除一系列系统组件之间的相互依赖。这些组件将依赖于同一个中介者对象。观察者的目标是在对象之间建立动态的单向连接,使得部分对象可作为其他对象的附属发挥作用。

    有一种流行的中介者模式实现方式依赖于观察者。中介者对象担当发布者的角色,其他组件则作为订阅者,可以订阅中介者的事件或取消订阅。当中介者以这种方式实现时,它可能看上去与观察者非常相似。

    当你感到疑惑时,记住可以采用其他方式来实现中介者。例如,你可永久性地将所有组件链接到同一个中介者对象。这种实现方式和观察者并不相同,但这仍是一种中介者模式。

    假设有一个程序,其所有的组件都变成了发布者,它们之间可以相互建立动态连接。这样程序中就没有中心化的中介者对象,而只有一些分布式的观察者。

抽象工厂模式

亦称:Abstract Factory

意图

抽象工厂模式是一种创建型设计模式,它能创建一系列相关的对象,而无需指定其具体类。

抽象工厂模式

问题

假设你正在开发一款家具商店模拟器。你的代码中包括一些类,用于表示:

  1. 一系列相关产品,例如椅子Chair、​沙发Sofa和咖啡桌Coffee­Table。

  2. 系列产品的不同变体。例如,你可以使用现代Modern、​维多利亚Victorian、​装饰风艺术Art­Deco等风格生成椅子、​沙发咖啡桌

生成不同风格的系列家具。

系列产品及其不同变体。

你需要设法单独生成每件家具对象,这样才能确保其风格一致。如果顾客收到的家具风格不一样,他们可不会开心。

现代风格的沙发和维多利亚风格的椅子不搭。

此外,你也不希望在添加新产品或新风格时修改已有代码。家具供应商对于产品目录的更新非常频繁,你不会想在每次更新时都去修改核心代码的。

解决方案

首先,抽象工厂模式建议为系列中的每件产品明确声明接口(例如椅子、沙发或咖啡桌)。然后,确保所有产品变体都继承这些接口。例如,所有风格的椅子都实现椅子接口;所有风格的咖啡桌都实现咖啡桌接口,以此类推。

椅子类的层次结构

同一对象的所有变体都必须放置在同一个类层次结构之中。

接下来,我们需要声明抽象工厂——包含系列中所有产品构造方法的接口。例如create­Chair创建椅子、​create­Sofa创建沙发和create­Coffee­Table创建咖啡桌。这些方法必须返回抽象产品类型,即我们之前抽取的那些接口:​椅子,​沙发咖啡桌等等。

工厂类的层次结构

每个具体工厂类都对应一个特定的产品变体。

那么该如何处理产品变体呢?对于系列产品的每个变体,我们都将基于抽象工厂接口创建不同的工厂类。每个工厂类都只能返回特定类别的产品,例如,​现代家具工厂Modern­Furniture­Factory只能创建现代椅子Modern­Chair、​现代沙发Modern­Sofa和现代咖啡桌Modern­Coffee­Table对象。

客户端代码可以通过相应的抽象接口调用工厂和产品类。你无需修改实际客户端代码,就能更改传递给客户端的工厂类,也能更改客户端代码接收的产品变体。

客户端无需了解其所调用工厂的具体类信息。

假设客户端想要工厂创建一把椅子。客户端无需了解工厂类,也不用管工厂类创建出的椅子类型。无论是现代风格,还是维多利亚风格的椅子,对于客户端来说没有分别,它只需调用抽象椅子接口就可以了。这样一来,客户端只需知道椅子以某种方式实现了sit­On坐下方法就足够了。此外,无论工厂返回的是何种椅子变体,它都会和由同一工厂对象创建的沙发或咖啡桌风格一致。

最后一点说明:如果客户端仅接触抽象接口,那么谁来创建实际的工厂对象呢?一般情况下,应用程序会在初始化阶段创建具体工厂对象。而在此之前,应用程序必须根据配置文件或环境设定选择工厂类别。

抽象工厂模式结构

抽象工厂设计模式抽象工厂设计模式

  1. 抽象产品(Abstract Product)为构成系列产品的一组不同但相关的产品声明接口。

  2. 具体产品(Concrete Product)是抽象产品的多种不同类型实现。所有变体(维多利亚/现代)都必须实现相应的抽象产品(椅子/沙发)。

  3. 抽象工厂(Abstract Factory)接口声明了一组创建各种抽象产品的方法。

  4. 具体工厂(Concrete Factory)实现抽象工厂的构建方法。每个具体工厂都对应特定产品变体,且仅创建此种产品变体。

  5. 尽管具体工厂会对具体产品进行初始化,其构建方法签名必须返回相应的抽象产品。这样,使用工厂类的客户端代码就不会与工厂创建的特定产品变体耦合。客户端(Client)只需通过抽象接口调用工厂和产品对象,就能与任何具体工厂/产品变体交互。

伪代码

下面例子通过应用抽象工厂模式,使得客户端代码无需与具体 UI 类耦合,就能创建跨平台的 UI 元素,同时确保所创建的元素与指定的操作系统匹配。

抽象工厂模式示例的类图

跨平台 UI 类示例。

跨平台应用中的相同 UI 元素功能类似,但是在不同操作系统下的外观有一定差异。此外,你需要确保 UI 元素与当前操作系统风格一致。你一定不希望在 Windows 系统下运行的应用程序中显示 macOS 的控件。

抽象工厂接口声明一系列构建方法,客户端代码可调用它们生成不同风格的 UI 元素。每个具体工厂对应特定操作系统,并负责生成符合该操作系统风格的 UI 元素。

其运作方式如下:应用程序启动后检测当前操作系统。根据该信息,应用程序通过与该操作系统对应的类创建工厂对象。其余代码使用该工厂对象创建 UI 元素。这样可以避免生成错误类型的元素。

使用这种方法,客户端代码只需调用抽象接口,而无需了解具体工厂类和 UI 元素。此外,客户端代码还支持未来添加新的工厂或 UI 元素。

这样一来,每次在应用程序中添加新的 UI 元素变体时,你都无需修改客户端代码。你只需创建一个能够生成这些 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
67
68
69
70
71
72
73
74
75
76
77
78
// 抽象工厂接口声明了一组能返回不同抽象产品的方法。这些产品属于同一个系列
// 且在高层主题或概念上具有相关性。同系列的产品通常能相互搭配使用。系列产
// 品可有多个变体,但不同变体的产品不能搭配使用。
interface GUIFactory is
method createButton():Button
method createCheckbox():Checkbox


// 具体工厂可生成属于同一变体的系列产品。工厂会确保其创建的产品能相互搭配
// 使用。具体工厂方法签名会返回一个抽象产品,但在方法内部则会对具体产品进
// 行实例化。
class WinFactory implements GUIFactory is
method createButton():Button is
return new WinButton()
method createCheckbox():Checkbox is
return new WinCheckbox()

// 每个具体工厂中都会包含一个相应的产品变体。
class MacFactory implements GUIFactory is
method createButton():Button is
return new MacButton()
method createCheckbox():Checkbox is
return new MacCheckbox()


// 系列产品中的特定产品必须有一个基础接口。所有产品变体都必须实现这个接口。
interface Button is
method paint()

// 具体产品由相应的具体工厂创建。
class WinButton implements Button is
method paint() is
// 根据 Windows 样式渲染按钮。

class MacButton implements Button is
method paint() is
// 根据 macOS 样式渲染按钮

// 这是另一个产品的基础接口。所有产品都可以互动,但是只有相同具体变体的产
// 品之间才能够正确地进行交互。
interface Checkbox is
method paint()

class WinCheckbox implements Checkbox is
method paint() is
// 根据 Windows 样式渲染复选框。

class MacCheckbox implements Checkbox is
method paint() is
// 根据 macOS 样式渲染复选框。

// 客户端代码仅通过抽象类型(GUIFactory、Button 和 Checkbox)使用工厂
// 和产品。这让你无需修改任何工厂或产品子类就能将其传递给客户端代码。
class Application is
private field factory: GUIFactory
private field button: Button
constructor Application(factory: GUIFactory) is
this.factory = factory
method createUI() is
this.button = factory.createButton()
method paint() is
button.paint()


// 程序会根据当前配置或环境设定选择工厂类型,并在运行时创建工厂(通常在初
// 始化阶段)。
class ApplicationConfigurator is
method main() is
config = readApplicationConfigFile()

if (config.OS == "Windows") then
factory = new WinFactory()
else if (config.OS == "Mac") then
factory = new MacFactory()
else
throw new Exception("错误!未知的操作系统。")

Application app = new Application(factory)

抽象工厂模式适合应用场景

如果代码需要与多个不同系列的相关产品交互,但是由于无法提前获取相关信息,或者出于对未来扩展性的考虑,你不希望代码基于产品的具体类进行构建,在这种情况下,你可以使用抽象工厂。

抽象工厂为你提供了一个接口,可用于创建每个系列产品的对象。只要代码通过该接口创建对象,那么你就不会生成与应用程序已生成的产品类型不一致的产品。

如果你有一个基于一组抽象方法的类,且其主要功能因此变得不明确,那么在这种情况下可以考虑使用抽象工厂模式。

在设计良好的程序中,每个类仅负责一件事。如果一个类与多种类型产品交互,就可以考虑将工厂方法抽取到独立的工厂类或具备完整功能的抽象工厂类中。

实现方式

  1. 以不同的产品类型与产品变体为维度绘制矩阵。

  2. 为所有产品声明抽象产品接口。然后让所有具体产品类实现这些接口。

  3. 声明抽象工厂接口,并且在接口中为所有抽象产品提供一组构建方法。

  4. 为每种产品变体实现一个具体工厂类。

  5. 在应用程序中开发初始化代码。该代码根据应用程序配置或当前环境,对特定具体工厂类进行初始化。然后将该工厂对象传递给所有需要创建产品的类。

  6. 找出代码中所有对产品构造函数的直接调用,将其替换为对工厂对象中相应构建方法的调用。

抽象工厂模式优缺点

  • 你可以确保同一工厂生成的产品相互匹配。

  • 你可以避免客户端和具体产品代码的耦合。

  • 单一职责原则。你可以将产品生成代码抽取到同一位置,使得代码易于维护。

  • 开闭原则。向应用程序中引入新产品变体时,你无需修改客户端代码。

  • 由于采用该模式需要向应用中引入众多接口和类,代码可能会比之前更加复杂。

与其他模式的关系

观察者模式

亦称:事件订阅者、监听者、Event-Subscriber、Listener、Observer

意图

观察者模式是一种行为设计模式,允许你定义一种订阅机制,可在对象事件发生时通知多个“观察”该对象的其他对象。

观察者设计模式

问题

假如你有两种类型的对象:​顾客商店。顾客对某个特定品牌的产品非常感兴趣(例如最新型号的 iPhone 手机),而该产品很快将会在商店里出售。

顾客可以每天来商店看看产品是否到货。但如果商品尚未到货时,绝大多数来到商店的顾客都会空手而归。

访问商店或发送垃圾邮件

前往商店和发送垃圾邮件

另一方面,每次新产品到货时,商店可以向所有顾客发送邮件(可能会被视为垃圾邮件)。这样,部分顾客就无需反复前往商店了,但也可能会惹恼对新产品没有兴趣的其他顾客。

我们似乎遇到了一个矛盾:要么让顾客浪费时间检查产品是否到货,要么让商店浪费资源去通知没有需求的顾客。

解决方案

拥有一些值得关注的状态的对象通常被称为目标,由于它要将自身的状态改变通知给其他对象,我们也将其称为发布者(publisher)。所有希望关注发布者状态变化的其他对象被称为订阅者(subscribers)。

观察者模式建议你为发布者类添加订阅机制,让每个对象都能订阅或取消订阅发布者事件流。不要害怕!这并不像听上去那么复杂。实际上,该机制包括 1)一个用于存储订阅者对象引用的列表成员变量;2)几个用于添加或删除该列表中订阅者的公有方法。

订阅机制

订阅机制允许对象订阅事件通知。

现在,无论何时发生了重要的发布者事件,它都要遍历订阅者并调用其对象的特定通知方法。

实际应用中可能会有十几个不同的订阅者类跟踪着同一个发布者类的事件,你不会希望发布者与所有这些类相耦合的。此外如果他人会使用发布者类,那么你甚至可能会对其中的一些类一无所知。

因此,所有订阅者都必须实现同样的接口,发布者仅通过该接口与订阅者交互。接口中必须声明通知方法及其参数,这样发布者在发出通知时还能传递一些上下文数据。

通知方法

发布者调用订阅者对象中的特定通知方法来通知订阅者。

如果你的应用中有多个不同类型的发布者,且希望订阅者可兼容所有发布者,那么你甚至可以进一步让所有订阅者遵循同样的接口。该接口仅需描述几个订阅方法即可。这样订阅者就能在不与具体发布者类耦合的情况下通过接口观察发布者的状态。

真实世界类比

杂志和报纸订阅

杂志和报纸订阅。

如果你订阅了一份杂志或报纸,那就不需要再去报摊查询新出版的刊物了。出版社(即应用中的“发布者”)会在刊物出版后(甚至提前)直接将最新一期寄送至你的邮箱中。

出版社负责维护订阅者列表,了解订阅者对哪些刊物感兴趣。当订阅者希望出版社停止寄送新一期的杂志时,他们可随时从该列表中退出。

观察者模式结构

观察者设计模式的结构观察者设计模式的结构

  1. 发布者(Publisher)会向其他对象发送值得关注的事件。事件会在发布者自身状态改变或执行特定行为后发生。发布者中包含一个允许新订阅者加入和当前订阅者离开列表的订阅构架。

  2. 当新事件发生时,发送者会遍历订阅列表并调用每个订阅者对象的通知方法。该方法是在订阅者接口中声明的。

  3. 订阅者(Subscriber)接口声明了通知接口。在绝大多数情况下,该接口仅包含一个update更新方法。该方法可以拥有多个参数,使发布者能在更新时传递事件的详细信息。

  4. 具体订阅者(Concrete Subscribers)可以执行一些操作来回应发布者的通知。所有具体订阅者类都实现了同样的接口,因此发布者不需要与具体类相耦合。

  5. 订阅者通常需要一些上下文信息来正确地处理更新。因此,发布者通常会将一些上下文数据作为通知方法的参数进行传递。发布者也可将自身作为参数进行传递,使订阅者直接获取所需的数据。

  6. 客户端(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
// 发布者基类包含订阅管理代码和通知方法。
class EventManager is
private field listeners: hash map of event types and listeners

method subscribe(eventType, listener) is
listeners.add(eventType, listener)

method unsubscribe(eventType, listener) is
listeners.remove(eventType, listener)

method notify(eventType, data) is
foreach (listener in listeners.of(eventType)) do
listener.update(data)

// 具体发布者包含一些订阅者感兴趣的实际业务逻辑。我们可以从发布者基类中扩
// 展出该类,但在实际情况下并不总能做到,因为具体发布者可能已经是子类了。
// 在这种情况下,你可用组合来修补订阅逻辑,就像我们在这里做的一样。
class Editor is
public field events: EventManager
private field file: File

constructor Editor() is
events = new EventManager()

// 业务逻辑的方法可将变化通知给订阅者。
method openFile(path) is
this.file = new File(path)
events.notify("open", file.name)

method saveFile() is
file.write()
events.notify("save", file.name)

// ...


// 这里是订阅者接口。如果你的编程语言支持函数类型,则可用一组函数来代替整
// 个订阅者的层次结构。
interface EventListener is
method update(filename)

// 具体订阅者会对其注册的发布者所发出的更新消息做出响应。
class LoggingListener implements EventListener is
private field log: File
private field message

constructor LoggingListener(log_filename, message) is
this.log = new File(log_filename)
this.message = message

method update(filename) is
log.write(replace('%s',filename,message))

class EmailAlertsListener implements EventListener is
private field email: string

constructor EmailAlertsListener(email, message) is
this.email = email
this.message = message

method update(filename) is
system.email(email, replace('%s',filename,message))


// 应用程序可在运行时配置发布者和订阅者。
class Application is
method config() is
editor = new Editor()

logger = new LoggingListener(
"/path/to/log.txt",
"有人打开了文件:%s");
editor.events.subscribe("open", logger)

emailAlerts = new EmailAlertsListener(
"admin@example.com",
"有人更改了文件:%s")
editor.events.subscribe("save", emailAlerts)

观察者模式适合应用场景

当一个对象状态的改变需要改变其他对象,或实际对象是事先未知的或动态变化的时,可使用观察者模式。

当你使用图形用户界面类时通常会遇到一个问题。比如,你创建了自定义按钮类并允许客户端在按钮中注入自定义代码,这样当用户按下按钮时就会触发这些代码。

观察者模式允许任何实现了订阅者接口的对象订阅发布者对象的事件通知。你可在按钮中添加订阅机制,允许客户端通过自定义订阅类注入自定义代码。

当应用中的一些对象必须观察其他对象时,可使用该模式。但仅能在有限时间内或特定情况下使用。

订阅列表是动态的,因此订阅者可随时加入或离开该列表。

实现方式

  1. 仔细检查你的业务逻辑,试着将其拆分为两个部分:独立于其他代码的核心功能将作为发布者;其他代码则将转化为一组订阅类。

  2. 声明订阅者接口。该接口至少应声明一个update方法。

  3. 声明发布者接口并定义一些接口来在列表中添加和删除订阅对象。记住发布者必须仅通过订阅者接口与它们进行交互。

  4. 确定存放实际订阅列表的位置并实现订阅方法。通常所有类型的发布者代码看上去都一样,因此将列表放置在直接扩展自发布者接口的抽象类中是显而易见的。具体发布者会扩展该类从而继承所有的订阅行为。

    但是,如果你需要在现有的类层次结构中应用该模式,则可以考虑使用组合的方式:将订阅逻辑放入一个独立的对象,然后让所有实际订阅者使用该对象。

  5. 创建具体发布者类。每次发布者发生了重要事件时都必须通知所有的订阅者。

  6. 在具体订阅者类中实现通知更新的方法。绝大部分订阅者需要一些与事件相关的上下文数据。这些数据可作为通知方法的参数来传递。

    但还有另一种选择。订阅者接收到通知后直接从通知中获取所有数据。在这种情况下,发布者必须通过更新方法将自身传递出去。另一种不太灵活的方式是通过构造函数将发布者与订阅者永久性地连接起来。

  7. 客户端必须生成所需的全部订阅者,并在相应的发布者处完成注册工作。

观察者模式优缺点

  • 开闭原则。你无需修改发布者代码就能引入新的订阅者类(如果是发布者接口则可轻松引入发布者类)。

  • 你可以在运行时建立对象之间的联系。

  • 订阅者的通知顺序是随机的。

与其他模式的关系

  • 责任链模式命令模式中介者模式观察者模式用于处理请求发送者和接收者之间的不同连接方式:

    • 责任链按照顺序将请求动态传递给一系列的潜在接收者,直至其中一名接收者对请求进行处理。
    • 命令在发送者和请求者之间建立单向连接。
    • 中介者清除了发送者和请求者之间的直接连接,强制它们通过一个中介对象进行间接沟通。
    • 观察者允许接收者动态地订阅或取消接收请求。
  • 中介者观察者之间的区别往往很难记住。在大部分情况下,你可以使用其中一种模式,而有时可以同时使用。让我们来看看如何做到这一点。

    中介者的主要目标是消除一系列系统组件之间的相互依赖。这些组件将依赖于同一个中介者对象。观察者的目标是在对象之间建立动态的单向连接,使得部分对象可作为其他对象的附属发挥作用。

    有一种流行的中介者模式实现方式依赖于观察者。中介者对象担当发布者的角色,其他组件则作为订阅者,可以订阅中介者的事件或取消订阅。当中介者以这种方式实现时,它可能看上去与观察者非常相似。

    当你感到疑惑时,记住可以采用其他方式来实现中介者。例如,你可永久性地将所有组件链接到同一个中介者对象。这种实现方式和观察者并不相同,但这仍是一种中介者模式。

    假设有一个程序,其所有的组件都变成了发布者,它们之间可以相互建立动态连接。这样程序中就没有中心化的中介者对象,而只有一些分布式的观察者。