设计模式
面向对象设计模式的演变历史参见《设计模式:可复用面向对象软件的基础》。(该书示例用 C++ 写的)
在该章节最后引人了一个关于设计演进过程的例子,模拟了垃圾分类场景,从最开始的设计开始,逐步改进逻辑和流程最终演进到更好的设计。可以将该演进过程看作某种演进原型——将满足某个特定问题的方案发展为可满足一类问题的灵活方案。
概念
最初,你可以将模式视为解决特定类问题的一种特别巧妙且有深刻见解的方法。模式看起来就像很多人已经解决了问题的所有细节部分,然后想出了最通用和灵活的方案。
模式似乎和传统的分析、设计和实现的思维方式不同。模式在程序中体现了完整的思想,因此它有时会出现在分析或高层设计的阶段。由于模式在代码中有着直接的实现,你可能并不希望它出现在低层设计或实现阶段,甚至是维护阶段之前。通常,在进人这些阶段之前,你不会意识到你需要一个特定的模式。
模式的基本概念也可以看作程序设计的基本概念:增加一层抽象。无论何时,如果你要抽象某个事物,实际上就是在隔离某个特定的细节,而这背后最重要的动机之一是:
将会变化的事物和不会变化的事物分开。
还有一种说法,一旦你发现程序的某个部分可能会由于某种原因而发生变化,抽象就可以防止这些变化引发整个代码中的其他变化。
通常,要实现一个优雅且易维护的设计,其中最困难的部分是如何发现我所说的变化的向量。(the vector of change,“向量”指的是最大的变化率,而不是某个集合类)这意味着找到系统中最重要的变化,换而言之,找到变化会导致最严重后果的地方。一旦找到了变化的向量,你就掌握了构建设计的关键点。
因此设计模式的目标就是隔离代码中的变化。如果能从这个角度看待设计模式,那就应该能意识到前面已经出现过很多设计模式了。举例来说,继承可以看成一种设计模式(尽管是由编译器实现的),它使你可以在具有相同接口的对象(保持不变的事物)中表现出行为的差异(变化的事物)。组合也可以被认为是一种模式,因为它使你可以动态或静态地改变实现类的对象,并由此改变对象的行为方式。
也看过了另一种模式:迭代器(Java 1.0和1.1随意地将其称为枚举; Java 2 集合才使用Iterator)。这会在遍历并逐个选择元素时隐藏集合的特定实现。迭代器使你可以在无须关心某个序列的构造方式的情况下,实现对该序列所有元素的某种操作。因此,你的通用代码可以与任何可以生成迭代器的集合一起使用。
虽然设计模式很有用,但也有人说:设计模式代表着语言的失败之处。
这个看法很重要,比如说,某模式只是在 C++ 中很合理,但在 Java 或其他语言中却可能并无必要存在。出于这个原因,不能只是因为一个模式出现在了《设计模式》中,就认为它在你的语言中很有用。
单例模式
最简单的设计模式可能就是单例(singleton)了,这是一种仅会提供唯一一个对象实例的方法。下面考虑一个参数化的 Resource:
假设想让每个不同的 Resource<T> 都仅能有一个实例,一种方法是为每个 T 都创建一个自定义的单例 Resource 类:
为了保证所控制的类仅有一个对象被创建,将 IntegerSingleton 的构造器设为私有的,因此就只能在类中使用。
因为 value 对象是静态的,所以它在首次调用静态方法 instance() 时被创建,此时类被加载,并执行 value 的静态初始化。main() 中 ir2 的创建不会再次引发构造器调用。
JVM 的工作方式使得静态初始化是线程安全的。为了达到完全的线程安全,IntegerSingleton 的 getter 和 setter 部是 synchronized 的。这很重要,因为多个线程可以分别持有指向同一个共享 IntegerSingleton 对象的引用。正如并发章节中所描述的,即使我们自己并没有在并发程序中使用该类,也必须考虑并发问题。
Java 还允许通过克隆来创建对象,这里将类修饰为 final,以防止克隆。IntegerSingleton 没有显式的基类,因此是直接继承自 Object,clone() 方法仍然是 protected,因此无法调用。但是,如果是从某个以 public 权限重写了 clone() 方法并实现了 Cloneable 的类层次结构中继承,那么阻止克隆就要重写 clone() 方法,并抛出 CloneNotSupportedException 异常。(也可以重写 clone() 方法并简单地返回 this,但这样实际上处理的仍然是原本的对象)
可以向上转型为 Resource<T>,并使用多态,如 show() 和 put() 函数。
也可以用这种方法创建一个有限的对象池,但这样做就必须要管理池中的对象。如果这成了问题,那么解决方案可以检查进出的共享对象。
这里的创建的 Resource<T> 看起来可能有点不必要,如果仅仅创建一个实现单例概念的纯泛型类呢?
这里我们实际想声明的是:
但是会导致编译器错误:“非静态类型变量 T 不能从静态上下文中引用”。无法在静态方法上使用类的泛型类型参数,因此需要将 Single 定义为 Object,然后在调用 get() 时对它进行转型。同样,不能将 get() 定义为静态方法(如果尝试这么做的话,则会得到很多关于静态泛型方法的教训)。
首次创建 Single 对象的时候,构造器将 static single 的值作为构造器参数。同一泛型类型 T 的第二次构造器调用会导致编译器错误消息。如下:
用 Single 来创建一个 Double 单例对象:
但是无法修改 Double 的内容,如果要求对象可修改,可以创建一个 set(T val) 方法,但是注意为了防止发生竞态条件,需要将 get() 和 set() 方法都设为 synchronized 的。
设计模式的分类
《设计模式》一书中讨论了23种不同的设计模式,并根据不同的目标将它们分为以下 3类,每一类都围绕着某个可能发生变化的方面展开。
创建类:即创建对象的方式。这通常涉及隔离对象创建的细节,以使代码不依赖于对象的类型,这样在增加新对象类型时就不必做任何修改。单例模式可归为创建类模式,稍后还会看到工厂模式的例子。
结构类:即如何设计满足特定项目约束的对象。这类设计主要围绕着这些对象和其他对象间的关联方式,以保证系统的变化不会导致这些关联方式的变化。
行为类:指处理程序中特定类型操作的对象。这些对象封装了要执行的流程,例如解释某种语言,满足某个请求,在序列中移动(比如通过迭代器),或者实现某种算法。本章列举了观察者模式(Observer)和访问者模式(Visitor)的相关示例。
模板方法
应用程序框架可以帮助我们,通过从提供的框架类中复用大部分代码,来创建新的应用程序,以及重写各种方法,以根据我们的需求自定义应用程序。
应用程序框架的重要概念之一是模板方法模式(Template Method),它通常是隐藏的,并通过调用各种基类方法来驱动该应用程序,我们可以重写这些基类方法来创建应用程序。
模板方法被定义在基类中,并且无法被改变。它有时是私有方法,但实际上通常是 final 的。它会调用其他的基类方法(重写的这些基类方法)来执行任务,但是通常只会作为初始化过程中的某个步骤被调用,调用方程序员不一定能够直接调用它。
基类的构造器负责执行必要的初始化、然后启动“引擎”(即模板方法),运行应用程序(在 GUI 程序中,“引擎”指主要事件的循环)。调用方程序员只需简单地提供 customize1() 和 customize2() 的定义,“应用程序”就做好了运行的准备。
封装实现
设计模式中有种常见的结构,即引入一个代理(surrogate)类,该代理类“封装”了实际执行工作的实现类。当在代理类中调用一个方法时,它会转而调用实现类中的某个方法。接下来会介绍 3 种使用了这种结构的模式:代理模式(Proxy)、状态模式(State)以及状态机(State Machine)。
代理类伴随着提供具体实现的一个或多个类,从基类中派生而来:
代理对象会连接到具体实现,它会在连接处转发所有的方法调用。
在结构上,代理模式只有一个实现,而状态模式有多个实现。一般认为这两个模式的应用应该是各不相同的:代理模式用来控制对自身实现的访问,而状态模式则可以让我们动态地改变实现。
代理模式
如果根据上图实现代理模式:
Implementation 并不需要和 Proxy 拥有相同的接口。只要 Proxy 能以某种方式“代言”实现类,就能符合基本的思路。不过,如果能有一个公共的接口,以强制 Implementation 必须实现 Proxy 需要调用的所有方法,会更方便。
状态模式
状态模式在代理模式的基础上增加了更多的实现,以及在代理类的生命周期内切换实现的方法。下面是一种状态模式的实现:
这里一开始用的是第一个实现(类),然后切换到了第二种。Implementation1 和 Implementation2 都继承自 State,但这并不是必需的。只要你实现了一个对象,它可以被转发调用给成员对象,并且该成员对象可以被动态地替换,就可以认为你在使用状态模式。下面是第二个例子,其中 State 控制器对象和调用转发的目标对象并不是相同的类型:
代理模式和状态模式的不同之处在于要解决哪种问题。代理模式常见的应用场景如下:
远程代理(remote proxy)。它用来代理处于不同地址空间的对象。例如,Java 远程方法调用(RMI)编译器 rmic 可以自动创建一个远程代理。
虚拟代理(virtual proxy)。提供了“延迟加载”的能力,可以按需创建开销较大的对象。如果永远不需要创建该对象,那么就无须花费相应的开销。可以等到需要的时候再初始化该对象,以减少启动耗时。
保护代理(protection proxy)。在你并不希望赋予调用方程序员访同被代理对象的完整权限时使用。
智能引用(smart reference)。在访问被代理的对象时执行额外的操作,例如
持续跟踪某个特定对象持有的引用数
实现写时复制(copy-on-write),以防止对象引用别名(减少系统开销和空间占用)
对指定方法的调用进行计数
可以将 Java 的引用看作某种保护性代理,因为其控制了在堆上对实际对象的访问(并且保证了你不会在 null 引用上调用方法)。
可以看到,状态模式可以使用独立的实现层。类似地,代理模式不需要为了自身的实现而使用相同的基类,只需要代理(proxy)对象是其他对象的代理(surrogate)。抛开细节不谈,在代理模式和状态模式中,都存在一个代理(surrogate)来将方法调用传递给某个实现对象。
状态机模式
状态模式使得调用方程序员可以改变具体的实现,而状态机模式则通过一种强加的结构来自动改变具体实现,具体的实现表示系统的当前状态。在不同的状态下,系统有着不同的行为,这是因为它的内部包含状态模式。
将系统从一种状态改变为另一种状态的代码通常使用了模板方法,如下例所示:
这里是由 StateMachine 控制着当前状态,并决定下一个状态是什么。不过状态对象自身可能也会决定下一个状态,一般是基于某种对系统的输人。这通常是一种更灵活的办法。
工厂模式:封装对象的创建
如果一个已有系统必须接受新的类型,那么通常第一步便是使用多态性为这些新类型创建一个公共接口。这样可以在系统中将代码和所增加的具体类型隔离开(不必知道具体是什么类型)。添加新的类型可以不影响已有代码一一至少看起来是这样。
可能会觉得代码中唯一要做的改动就是在继承出新类型的地方进行调整,但事实并非如此。你仍然要创建新类型的对象,并且在创建的时候必须指定具体要使用的构造器。如果这样的类型创建操作遍布于程序中,增加新类型便意味着需要找出所有要注意类型的地方。
工厂会强制通过某个公共节点来创建对象,以防止用于创建的代码在系统中四处扩散。系统中的所有代码都必须通过该工厂创建某个对象,这样在向系统增加新类的时候只需要修改工厂即可。
接下来会通过一系列 Shape 层次结构来演示工厂模式。如果某个 Shape 无法成功创建,则需要使用合适的异常:
然后是 Shape 的基类:
Shape 会自动为每个对象创建一个唯一的 id。toString() 方法则通过反射检测出具体的 Shape 子类名称。
然后创建一些 Shape 类:
工厂包含一个用于创建对象的方法:
create() 的参数用于指定要创建的 Shape 的具体类型,本例中的参数是 String,但也可以是任何一组数据。这个初始化数据可能来自系统外部。
下面是一个可复用的,对 FactoryMethod 实现的测试:
注意,只有在最后加上终结操作,Stream 才会真的执行。(然而实测在 JDK 21,使用 count() 终结操作,peek 副作用会被优化,导致没有输出)
创建工厂的一种方法是显式创建每种类型:
这样 create() 便成了在添加一种新的 Shape 类型时,系统中唯一需要修改的其他代码了。
动态工厂模式
由于 ShapeFactory1.java 封装了对象的创建过程,因此是一种合理的方案。不过,如果可以在增加新类时完全不需要修改任何代码,甚至是工厂本身,则会更好。ShapeFactory2.java 使用了反射,首次需要时,将某具体 Shape 的构造器动态加载到工厂映射中:
create() 方法基于传入的 String 参数生成各种新的 Shape,它会将该参数作为键,在 HashMap 中进行查询,返回值为 Constructor,然后通过调用其上的 newiInstancet() 创建新的 Shape 对象。
当你开始运行程序时,工厂的 map 为空。create() 使用 map 的 computeIfAbsent() 方法来查找构造器(如果该构造器已存在于 map 中)。如果不存在则使用 load() 计算出该构造器,并将其插入到 map 中。 从输出中可以看到,每种特定类型的 Shape 都是在第一次请求时才加载的,然后只需要从 map 中检索它。
多态工厂模式
工厂方法模式可以从基类工厂中子类化出不同类型的工厂:
RandomShape 是一个 Supplier<Shape>,因此可以通过 Stream.generate() 生成流。RandomShapes 的构造器接收 PolymorphicFactory 对象组成的可变参数列表。这里使用方法引用实现了函数式接口。可变参数列表是以数组的形式传入的,因此这也是该列表在内部的存储形式。get() 方法随机索引到该数组中,并对结果调用 create() 以生成随机 Shape。
RandomShapes 构造器调用(即[1]处)是唯一需要在增加新 Shape 类型时修改的地方。
鉴于 ShapeFactory2.java 可能会抛出异常,使用此方法则没有任何异常——它在编译时完全确定。
抽象工厂模式
抽象工厂模式看起来很像之前见过的工厂对象,只不过它带有多个工厂方法,每个工厂方法都会生成一种不同的对象。工厂类型也有多种,选定了一种工厂对象,即确定了该工厂所创建出的所有对象的具体类型。《设计模式》中给出的示例在多种图形用户界面(GUI)间实现了可移植性。只需创建适用于所用 GUI 的工厂对象即可。如果向该工厂对象请求菜单、按钮、滑块等组件,它便会生成适合该 GUI 版本的组件。这样便隔离了在不同 GUI 间切换的影响。
假设要创建一套通用的游戏环境,用来支持不同类型的游戏。用抽象工厂来实现便可能是下面这样:
每个游戏都有着不同类型的 Player 和 Obstacle,通过选择具体的 GameElementFactory 来确定具体的游戏。GameEnvironment 则会设置并运行游戏。在该例中,游戏的设置和运行都非常简单,但这些行为(初始条件和状态变化)可以很大程度上决定游戏的结果。这里的 GameEnvironment 并不是为继承而设计的,虽然继承也是一个可选项。
这里也用到了之后要介绍的双路分发(Double Dispatching)模式。
函数对象模式
一个函数对象(Function Object)封装一个方法,其目的是将函数的选择和调用函数的位置解耦。
命令模式
这种模式是函数对象最纯粹的形式:一个身为对象的方法。可以将一个函数对象作为参数传递,进而生成不同的行为。
下面示例中,show() 的代码保持不变,改变的是传给 show() 的 Command 对象:
下面示例中,创建了一个命令对象的列表,形成了一个“宏指令”。在 Java 8 之前的版本中,如果要实现独立函数的效果,就需要额外工作,显式地将方法包装到一个对象中。而 Java 8 的 lambda 表达式可以创建函数对象,因此命令模式就变得相当简单了:
命令模式最主要的一点在于,可以让我们将想要的行为传给一个方法或对象。macro 会将一组要集体执行的行为排成一队,这样就可以动态地创建行为了,而这本来一般都需要编写新的代码来实现。
该例子经过修改后,还可以用来解释脚本,参见解释器(Interpreter)模式。
《设计模式》中指出:“命令模式是回调的面向对象形式的替代品”。然而作者认为“回”字是回调概念的核心。也就是说,回调实际上会指回到回调的创建者,命令模式则一般只将命令对象创建出来,然后传给某个方法或对象,而不会随着时间的推移(往回)连接到命令对象上。
策略模式
策略模式(Strategy)很像命令模式,都是从同一个基类继承出来的一组函数对象。区别在于这些对象的使用方式。命令模式用于解决一类特定的问题,例如,通过传人一个描述所需文件的命令对象,从文件列表中选择一个文件。所调用的方法体即“保持不变的事物”,而变化的部分则被隔离在命令对象中。
策略模式包含一段可作为代理类的“上下文”,该代理类用于控制文件的选择以及对特定策略对象的使用。看起来就像这样:
MinimaSolver 中的 changeAlgorithm() 将一个不同的策略插人 stratey 字段,然后 minima() 就会使用不同的算法了。
可以通过将上下文合并到 FindMinima 来简化方案:
FindMinima2 封装了各种不同的算法,还同时包含了上下文,因此可以在单个类中控制算法的选择了。
职责链模式
职责链模式(Chain of Responsibility)大概可以被看作用策略对象实现的动态泛化版本的递归。先产生一个调用,然后一系列策略逐个尝试处理该调用。当某个策略处理成功,或者试过所有策略后(都不成功),整个过程结束。在递归过程中,一个方法不断重复调用自身,直到满足某个终结条件。而在职责链模式中,一个方法会调用相同基类的方法的不同实现,后者又会调用该基类方法的另一个实现,以此类推,直到满足终结条件。
职责链中多种方法都有机会处理请求,因此很适用于实现专家系统。职责链是高效的链表结构,可以动态地进行创建或修改。也可以将它看作一种更通用的、动态构建的 switch 语句。
职责链模式可以实现让 StrategyPattern.java 自动尝试不同算法,直到命中合适的。
Result 包含了 success 标签,用来告知收件人该算法是否成功,line 用来承载实际的数据。若某个算法失败,则返回 Result.fail。
由于失败是预期结果,而且要在某个策略失败后仍能继续运行,返回 Result.fail 比抛出异常更加合适。
每个 Algorithm 的实现针对 algorithm() 方法都有不同的方法。在 FindMinima 中,创建了一组 algorithm 的 List(这便是职贵链的那条“链”),而 minima() 方法会遍历该 List,并找出执行成功的算法。
改变接口
有时候我们需要解决的问题很简单,仅仅是“我没有需要的接口”而已。有两种设计模式可以解决这个问题:适配器模式(Adapter)接收一种类型为参数,并生成面向另一种类型的接口;外观模式(Facade)可以创建一个面向一组类的接口,这只是提供了一种更为简便的方式,来处理一个库或一批资源。
适配器模式
当我们已有某个类,而需要的却是另外一个类,就可以通过适配器模式 来解决问题。唯一需要做的就是产生出我们需要的那个类,有许多种方法可以完成这种适配。
《设计模式》一书的作者主张代理必须具有和其代理的对象完全相同的接口。将代理和适配器这两个词合并为代理适配器(ProxyAdapter)来使用也许更合理。
外观模式
作者在将需求转化为对象设计的开始阶段所遵循的原则:如果有一段逻辑很丑陋,那么就把它藏到对象里。
这基本上就是外观模式所完成的任务。如果你有一堆相当杂乱的类和交互逻辑,并且不需要让调用方程序员看到,就可以将这些藏到一个接口后面,以一种更易懂的形式只暴露出外部需要知道的信息。
外观模式通常被实现为单例抽象工厂。可以使用包含静态工厂方法的类来实现这个效果:
Java 包(package)可作为外观模式的变体。
外观模式给人一种“面向过程”(而非面向对象)的感觉:我们只是在调用一些生成对象的函数而已。而这实际上和抽象工厂又有多大区别呢?外观模式的重点在于将一组类(以及它们之间的交互)形成的库部分隐藏起来,不让调用方程序员看到,使得这一组类对外暴露出的接口更好用、更易懂。
而这正是 Java 的包特性所实现的功能。在库以外只能创建和使用 public 的类,而所有非 public 的类都只能在包范围内访问。这就好像外观模式是 Java 的内建特性。
大多时候,Java 的包就可以解决外观模式所要解决的问题了。
解释器模式
在一些应用程序中,用户需要在运行时拥有更高的灵活性,例如创建用来描述系统所需行为的脚本。解释器设计模式可以用来创建语言解释器,并将其嵌入这样的程序中。
在开发程序的过程中,设计自己的语言并为它构建一个解释器是一件让人分心且耗时的事。最好的解决方案就是复用代码:使用一个已经构建好并被调试过的解释器。Python 语言可以免费地嵌入营利性的应用中而不需要任何的协议许可、授权费或者是任何的声明。此外,有一个完全使用 Java 字节码实现的 Python 版本(叫做 Jython), 能够轻易地合并到 Java 程序中。
回调
回调可以将代码和行为解耦。这其中包含观察者模式(Observer),以及一类称为多路分发(Muliple Dispatching)的回调,其中包含《设计模式》中的访问者模式(Visitor)。
观察者模式
观察者模式用于解决一个很常见的问题:如果一组对象必须在某些其他对象改变状态时更新自身,该怎么做?这个场景可以在 SmallTalk 的“模型一视图一控制器”(model view-controller, MVC)设计中的“模型-视图”部分,或者在几乎完全相同的“文档-视图架构”(Document-View Architecture,可视为MVC的一种变体)中看到。假设你有一些数据(即“文档”)以及多个视图,比如一个绘图视图和一个文本视图。修改数据的时候,这两个视图都必须更新自身,而这时观察者模式就能派上用场。这是个比较常见的间题,因此相应的解决方案早就是标准 java.util 库的一部分了。
一般情况下,在该模式中,其中一个对象(称为“主题”或“被观察者”)维护一组依赖于它的对象(称为“观察者”),并在自身状态发生变化时通知这些观察者。观察者模式是一种回调机制,因为当被观察者的状态发生变化时,它会调用观察者的回调方法来通知它们。
观察者模式使我们可以将调用的来源和被调用的代码以完全动态的方式解耦。如同其他的回调形式,观察者模式包含一个钩子(hook)点,我们可以在此处改变代码,不同之处在于观察者模式是完全动态的。它常被用于处理“由其他对象的状态改变所触发的变更”的特殊场景,但它也是事件管理的基础。
观察者模式中实际上有两种“会改变的事物”:观察对象的数量和更新发生的方式。观察者模式使我们可以在不影响周围代码的情况下修改这两者。
Observer 是一个只有一个方法(update(Observable o, Object arg))的接口。被观察的对象(Observable)认为是时候更新所有的 Observer 时,便会调用该方法。第一个参数是导致更新发生的 Observable 对象,第二个参数提供了 Observer 更新自身时可能需要的任何额外信息。
一个 Observer 可以注册多个 Observable。
Observable 是决定何时及如何进行更新的“被观察的对象"。Observable 类会跟踪每个希望在变更发生时得到通知的 Observer。当发生任何应该让每个 Observer 更新自身的变更时,Observable 会调用自身的 notifyObservers() 方法,该方法会调用每个 Observer 上的 update() 方法。
Observable 中有个标志,用于表示它是否被改变。该标志允许我们一直等待,直到我们认为时机成熟,才会通知 Observer。标志状态的控制是 protected 的,因此只有继承者才能决定怎样才算是变更,而不是由 Observer 子类的终端用户决定。
大部分的工作是在 notifyObservers() 中完成的。如果 changed 标志没有设置,则调用 notifyObservers() 不会引发任何动作。否则该方法会首先清空 changed 标志,这样对 notifyObservers() 的重复调用就不会浪费时间。该操作会在通知观察者之前完成,以防对 update() 的调用引发对这个 Observable 对象的变更。然后它会调用每个 Observer 上的 update() 方法。
我们似乎可以用一个普通的 Observable 对象来管理更新。但是这样做不行,要达到效果,就必须继承 Observable,并在子类代码中的某处调用 setChanged()。该方法用于设置“changed”标志,意味着在调用 notifyobservers() 的时候,所有的观察着都会被通知到。应该在何处调用 setChanged() 取决于我们的程序逻辑。
注意,Observer 和 Observable 已在 Java 9 中被弃用,取而代之的是 Java 9 所引入的 Flow 类。
示例:观察花朵
一个观察者模式的示例:
Flower 可以 open() 或 close()。通过匿名内部类,这两个事件可被分别独立地观察。opening 和 closing 都继承自 Observable,因此它们可以访问 setChanged()。
Observer 是函数式接口(也称为 SAM 类型),因此 Bee 和 Hummingbird 中的 whenOpened 和 whenClosed 是使用 lambda 表达式定义的。whenOpened 和 whenClosed 各自独立观察着 Flower 的开花和闭拢。
从输出可以看到,通过 Observable 动态地注册或取消 Observer 来在运行时改变行为。
注意,可以创建完全不同的其他观察对象,Observer 唯一和 Flower 有关联的地方就是 Observer 接口。
一个可视化的观察者示例
下面示例中使用了 Swing 库来创建各种图形。屏幕上的网格中放置了一些方框,每个方框都被初始化为随机颜色。另外,每个方框都实现了 Observer 接口,并注册了一个 Observable 对象。当你单击一个方框时,所有其他的方框都会得到通知,这是因为 Observable 对象会自动调用每个 Observer 对象的 update() 方法。在该方法内部,方框会检查其是否和被单击的方框相邻,如果相邻,则会改变自身的颜色,以匹配被单击的方框。
java.axt.event 库中有一个带有多个方法的 MouseListener 类,但是我们只对 mouseClicked() 方法感兴趣。如果只是想实现 mouseClicked(),则无法通过编写 lambda 表达式实现,这是因为 MouseListener 有多个方法,所以它并不是函数式接口。Java 9允许我们通过 default 关键字简化代码,以创建一个辅助接口,只留下一个抽象方法:
现在可以成功地将 lambda 表达式转型为 MouseClick,并将其传给 addMouseListener()。
注意,如果想实现这种效果,就必须继承自 Observable,然后在子类代码中的某个地方调用 setChanged(),如你在 Boxes.notifier 中所见。setChanged() 设置了“changed”标志,这意味着在调用 notifyObservers() 的时候,所有的观察者都会得到通知。本例中的 setChanged() 是在 notifyObservers() 中调用的,但也可以使用任何标准来决定何时调用 setChanged()。
Boxes 有一个 Observable 对象 notifier,并且每创建出一个 Box,都会将 Box 绑定到 notifier 上。在 Box 中,不论何时单击鼠标,notifyObservers() 都会被调用,并将被单击的对象作为参数传递,因此所有(在自身的 update() 方法中)收到消息的方框都能知道谁被单击了,并决定是否改变自己的颜色。通过将 notifybservers() 和 update() 中的代码组合使用,就能实现某些相当复杂的方案。
多路分发
在处理多个相互作用的类型时,程序很容易变得格外混乱。例如,考虑一个解析并执行数学表达式的系统。我们想声明 Number + Number、Number * Number等运算,其中 Number 是一系列数值对象的基类。但如果我们想声明a + b,又不知道 a 和 b 的具体类型那怎样才能让它们正确地交互呢?
Java 只能实现单路分发,即在操作多个类型未知的对象时,Java 只能在其中一个类型上应用动态绑定机制。这并不能解决使用多个向上转型对象的问题,因此我们最终需要手动检测某些类型,并有效地生成自定义的动态绑定行为。
该问题的解决方案称为多路分发(通常只会有两个分发,这称为双路分发)。因为多态性只能通过方法调用来生效,所以如果想实现双路分发,就必须有两次方法调用:第一次用于确定第一个未知类型,第二次用于确定第二个未知类型。
通常需要创建一个生成多次动态方法调用的方法,从而确定流程中的多个类型。
这里将用之前的 RoShamBo1.java 实现一个该游戏的变体,但用的是独立的 ItemFactory 和 Compete.match() 函数。compete() 和 eval() 方法都是同一个 Item 类型的成员(如果有两个相互作用的不同层次结构,则每个层次结构上都需要一次多态方法调用)。
ltem 接口中包含双路分发的结构:compete() 实现了第一路分发,第二路分发则发生在 eval() 的调用过程中。
假设有两个 Item a 和 b,并且我们不知道这两者的类型,那么在调用 a.compete(b) 的时候过程如下。compete() 方法是自动分发的,因此会在 a 类型的 compete() 方法体内被唤醒。如果 a 是 Paper 类型,那就是在 Paper 的 compete() 方法体中被唤醒,这样就通过第一路分发确定了第一个未知对象的类型。但现在 compete() 转而调用了第二个未知类型 b 的 eval() 方法,同时将 a 作为参数传人,因此会调用类型 b 上的重载后的 eval() 方法,这便是第二路分发。此时程序正运行至 eval() 方法中,而该方法已经知道了两个对象的类型。
访问者模式是多路分发的一种类型。
访问者模式的使用场景是这样的:存在一个不可改变的主类层次结构,不过你希望为该层次结构加上某种多态操作,这通常意味着必须在基类接口中增加一个方法。其中的两难之处在于,必须为基类增加方法,但又无法直接修改基类,那么该如何解决这个问题呢?
访问者模式可以基于之前介绍的双路分发机制解决该问题。
访问者模式使你可以通过创建一套独立的 Visitor 类型的类层次结构来扩展主类型的接口,由此模拟在主类型上执行的操作。主类型的对象只需“接受”访问者,然后调用访问者的动态绑定方法。
注意本示例和之前示例的相似性:F1ower 接收 Visitor 参数,作为第一路分发,然后转而调用 visit(),井将自身作为参数,以最终进入与 Flower 的类型对应的重载方法作为第二路分发。
这里为 Flower 添加了一系列方法,生成了 String 类型的描述,以及执行了 Bee 的各种行为。这便是 Visitor 的意义所在:为不可改变的层次结构增加方法。
总结
设计模式告诉我们,面向对象并不仅仅是关于多态,而是“将会变化的事物和不会变化的事物分开”。多态是实现该目标的一种重要方法,如果编程语言直接支持多态,它就会很有帮助(这样就不必亲自实现多态了,否则往往会使实现成本过高)。但设计模式通常可以告诉我们能够实现基本目标的其他方法,一旦建立了这种意识,我们就会开始寻找更具创造性的设计。