摘要
二、既定挑战
许多作者已经确定了当模式在特定的软件系统中具体化时会出现的挑战。其中,三个最重要的挑战与实现、文档和组合有关。
设计模式的实现通常有许多不良的相关影响。因为模式影响系统结构,并且它们的实现也受到系统结构的影响,所以模式实现通常是根据使用情况定制的。这可能导致它们“消失在代码中”,并失去模块化。这使得很难区分模式、具体实例和涉及的对象模型。在系统中添加或移除模式通常是一种侵入性的、难以逆转的改变。因此,虽然设计模式是可重用的,但它的实现通常不是。
模式代码的侵入性,以及它与其他代码的分散和纠缠造成了文档问题。如果在一个系统中使用了多个模式,跟踪一个设计模式的特定实例会变得很困难,尤其是当类涉及到多个模式时(即,如果有模式覆盖/组合)。
模式组合导致的不仅仅是文档问题。对包含相同类的多个模式的系统进行推理本来就很困难,因为这种组合会产生大量相互依赖的类。这是一个重要的主题,因为一些设计模式在他们的解决方案中显式地使用其他模式。
三、研究格式
本文中的发现是基于对GoF设计模式的Java和AspectJ实现的比较分析。
对于23种GoF模式中的每一种,我们都创建了一个利用该模式的小示例,并在Java和AspectJ中实现了该示例(代码下载[已失效])。Java实现对应于GoF书中的示例C++实现,并做了一些小的调整,以解决C++和Java之间的差异(缺乏多重继承等)。大多数模式都有许多实现变体和替代方案。如果一个模式提供了不止一个可能的实现,我们选择一个看起来最普遍适用的。
AspectJ实现是迭代开发的。AspectJ结构允许许多不同的实现,通常有不同的权衡。我们的目标是充分研究每个模式的明确定义的实现的设计空间。我们最终总共创建了57个不同的实现,每个模式从1到7个不等。第4节讨论了一些权衡和设计决策。
四、结果
本节介绍了GoF设计模式的具体实例的AspectJ和Java实现的比较。第4.1节详细讨论了观察者模式。我们使用这个讨论来呈现大多数AspectJ解决方案共有的属性。其余的模式是基于第4.1节中的概念提出的。
4.1 例子: 观察者模式
观察者模式的意图是“在对象之间定义一对多的依赖关系,这样当一个对象改变状态时,它的所有从属对象都会被自动通知和更新”。观察者模式的面向对象实现,比如GoF书(第300-303页)中的示例代码,通常向所有潜在的主题添加一个字段,存储对该特定主题感兴趣的观察者列表。当一个主体想要向它的观察者报告状态改变时,它调用它自己的通知方法,该方法又调用列表中所有观察者的更新方法。
考虑一个简单图形包中观察者模式的具体例子,如图1所示。在这样的系统中,观察者模式将被用来使图形元素的突变操作更新屏幕。如图所示,实现这种模式的代码分布在各个类中。
所有参与者(即点和线)必须知道他们在模式中的角色,并因此拥有模式代码。从类中添加或删除角色需要更改该类。改变通知机制(例如在推和拉模型之间切换[9])需要改变所有参与的类。
4.1.1 抽象的观察者模式
在观察者模式的结构中,一些部分是模式的所有潜在实例化所共有的,而其他部分是每个实例化所特有的。所有实例共有的部分是:
- 主体和观察者角色的存在(即有些类作为观察者,有些作为主体)。
- 维护从受试者到观察者的映射。
- 一般更新逻辑:主题更改触发观察者更新。
模式的每个实例化的特定部分是:
- 哪些课可以是主题,哪些可以是观察者。
- 引发观察者更新的一组感兴趣的主题变化
- 当更新逻辑需要时,更新每种观察者的具体方法。
我们开发了AspectJ代码,它反映了可重用部分和实例特定部分的分离。抽象方面封装了可概括的部分(1-3),而模式的每个实例的方面的一个具体扩展填充了特定的部分(4-6)。图2显示了可重用的ObserverProtocol方面。
4.1.1.1 主体和观察者的角色
角色被实现为名为主题和观察者的受保护的内部接口(图2,第3-4行)。它们的主要目的是允许在模式实现的上下文中正确地输入主题和观察者,比如在像addObserver这样的方法中。ObserverProtocol方面的具体扩展将角色分配给特定的类(见下文)。
这些接口是受保护的,因为它们将只由ObserverProtocol及其具体扩展使用。方面和扩展之外的任何代码都不需要根据这些角色来处理对象。
这些接口是空的,因为模式没有定义主题或观察者角色的方法。通常在主体和观察者上定义的方法是在方面本身上定义的(见下文)。
对于可抽象的模式,我们必须决定将角色接口放在哪里。两个位置是可能的:要么作为抽象方面内部的私有接口,要么作为单独的公共接口。我们是根据角色接口是否引入了客户端访问的功能来做出这个决定的,即向客户端公开功能(如策略、迭代器等)或者不公开(如观察者的情况)。如果角色没有客户端可访问的功能,它将只在模式方面被引用。因为这个原因,我们把它放在抽象方面。在另一种情况下,我们将接口移动到一个单独的文件中,以便于引用。
4.1.1.2 主体-观察者映射
AspectJ代码中映射的实现被本地化为ObserverProtocol方面。它是通过使用链表的弱散列映射来存储每个主题的观察者来实现的(第6行)。由于每个模式实例都由ObserverProtocol的一个具体子方面表示,因此每个实例都有自己的映射。
对主题-观察者映射的改变可以通过具体子方面继承的公共的添加观察者和移除观察者方法(第21-26行)来实现。为了让屏幕对象成为点对象的观察者,客户在适当的子方面调用这些方法(例如颜色观察者):
ColorObserving.aspectOf().addObserver(P, S);
私有的getObservers方法只在内部使用。它按需创建适当的二级数据结构(链表)(第8-19行)。请注意,在这个实现中,主题-观察者映射数据结构集中在每个具体的扩展中。对抽象模式方面进行子类化的所有具体方面都将自动拥有一个单独的字段副本。这遵循[9]中给出的结构。这在某些情况下会造成瓶颈。通过用一种使用更分散的数据结构的方法覆盖getObservers,可以在每个模式实例的基础上修复这些问题。
一般来说,只要模式解决方案需要参与者之间的映射(即责任链中处理程序的后继字段),并且模式实现是可抽象的,我们就可以在参与者上定义一个字段,或者将映射保留在抽象方面的中心数据结构中(如本例所示)。无论选择哪种方法,对数据结构的访问点都是特定于实例的方面,这样涉及相同参与者的模式的不同实例都是可能的,并且不会混淆。
4.1.1.3 更新逻辑
在可重用方面,更新逻辑实现了一个一般概念,即主题可以以需要更新所有观察者的方式进行更改。这个实现并没有精确地定义什么构成了一个改变,或者观察者应该如何被更新。一般更新逻辑由三部分组成:
感兴趣的变化描述了概念操作,即程序执行中的一组点,在这些点上,主体应该更新其观察者(以通知他们其状态的变化)。在AspectJ中,这类点的集合用切入点构造来标识。在可重用方面,我们只知道有感兴趣的修改,但我们不知道它们是什么。因此,我们定义了一个名为subjectChange的抽象切入点,它将被实例特定的子方面具体化(第28-29行)。
在可重用部分,我们只知道观察者必须在模式的上下文中更新,但是不能预测如何最好地实现。我们定义了一个抽象的更新方法updateObserver,它将为每个模式实例具体化(第31-32行)。这样,观察者模式的每个实例都可以选择自己的更新机制。
最后,可重用方面根据上面提到的可推广的实现部分来实现更新逻辑。这个逻辑包含在后一条建议中(第34-39行)。这个建议是这样的:每当执行到达一个与主题匹配的连接点时,改变切入点,然后更新适当主题的所有观察者。
4.1.2 特定于模式实例的具体方面
ObserverProtocol的每个具体子方面定义了一种特定的观察关系,换句话说就是一个模式实例。在这种关系中,可以有任意数量的主体,每个主体有任意数量的观察者。子方面定义了三件事:
- 扮演受试者和观察者角色的分类。这是通过使用declare parents构造来完成的,该构造将超类或超接口添加到类中,以分配抽象方面中定义的角色。
- 需要更新观察者的关于该主题的概念性操作。这是通过具体化subjectChange切入点来实现的。
- 如何更新观察者?这是通过具体化updateObserver来实现的。更新的推或拉模式之间的选择不再是必要的,因为此时我们可以访问主题和观察者,并且可以定制更新。
declare parents构造是AspectJ开放类机制的一部分,它允许方面修改现有的类而不改变它们的代码。这种开放的类机制可以将字段、方法或者——在这种情况下——接口附加到现有的类上。
图3显示了观察者模式的两个不同实例,包括点、线和屏幕类。在这两种情况下,点和线扮演主体的角色,屏幕扮演观察者的角色。第一个观察颜色变化,第二个观察坐标变化。
请注意,第13行和第31行中的类型转换预计会随着AspectJ对泛型的计划支持而消失。这样就有可能创建包含角色分配并且类型安全的参数化子方面。
特定的类可以在同一个模式实例或不同的模式实例中扮演一个或两个主体和观察者的角色。图4显示了第三个模式实例,其中屏幕同时充当主体和观察者。
在AspectJ版本中,与观察者和主题之间的关系相关的所有代码都被移动到一个方面中,这改变了模块之间的依赖关系。图5显示了这种情况的结构。
4.1.3 此实现的属性
观察者模式的这种实现具有以下密切相关的模块化属性:
- 局部性——实现观察者模式的所有代码都在抽象和具体的观察者方面,没有一个在参与者类中。参与者类完全不受模式上下文的约束,因此参与者之间没有耦合。每个观察者模式实例的潜在变化被限制在一个地方。
- 可重用性——核心模式代码是抽象的和可重用的。ObserverProtocol的实现概括了整个模式行为。抽象方面可以在多个观察者模式实例中重用和共享。对于每个模式实例,我们只需要定义一个具体的方面。
- 合成透明性——因为模式参与者的实现与模式无关,如果一个主体或观察者参与多个观察关系,他们的代码不会变得更复杂,模式实例也不会混淆。该模式的每个实例都可以独立推理。
- (不)可插入性——因为主体和观察者不需要知道他们在任何模式实例中的角色,所以可以在系统中使用模式和不使用模式之间切换。
4.2 其他模式
在下文中,我们将描述剩余的22种GoF模式,以及AspectJ实现与纯Java版本的不同之处。模式按共同特征分组,无论是模式结构还是它们的AspectJ实现。
4.2.1 复合、命令、中介、责任链:角色仅用于模式方面
类似于观察者模式,这些模式引入了不需要客户端可访问接口的角色,并且只在模式中使用。在AspectJ中,这样的角色是用空的(受保护的)接口实现的。它们引入的类型在模式协议中使用。每个模式的一个抽象方面定义了角色,并在可能的情况下附加了默认实现(参见图6的部分抽象组合方面)。
对于涉及特定概念操作的模式,抽象模式方面引入了一个抽象切入点(为模式的每个实例具体化),它捕获应该触发重要事件的连接点(例如命令模式中命令的执行)。就像在观察者的例子中一样,建议(在之后、之前或周围)负责调用适当的方法。
在复合情况下,为了允许遍历模式固有的树结构,我们定义了让访问者遍历和/或改变结构的工具。这些来访者是在具体方面定义的。有关如何从组合结构中收集统计数据的示例,请参见图7。在这个例子中,我们展示了一个为文件系统建模的复合模式的实例。目录是复合的,文件是叶。该示例显示了如何计算文件系统所需的磁盘空间,假设文件对象具有大小字段。同样,客户端使用方面的公共方法来访问新功能。参与者的适当方法是私下介绍的,只能通过方面看到。
4.2.2 单例模式、原型模式、备忘录模式、迭代器模式、享元模式:作为对象工厂的方面
这些模式管理对特定对象实例的访问。它们都向客户提供工厂方法,并共享按需创建策略。模式在AspectJ中是抽象的(可重用的),在Aspect中有工厂代码。
在AspectJ实现中,工厂方法要么是抽象Aspect的参数化方法,要么附加到参与者的方法中。如果使用前一种方法,模式的多个实例透明地组成,即使所有的工厂方法都有相同的名称。单例的情况很特殊,因为我们可以使用“环绕”建议将原始构造函数转换为工厂方法,并在所有构造函数调用中返回唯一的对象。
参数化的工厂方法也可以根据诺德伯格的工厂示例[18]来实现:工厂方法为空(返回null或默认对象)。其他返回值由该方法的Advice提供。如果参数合适,Advice创建一个新的匹配对象;否则,它将继续正常执行。这使得我们可以在不改变代码的情况下扩展工厂(就新产品而言)。参与者不再需要有模式代码;原始对象和它的表示或访问器(纪念品,迭代器)之间的紧密耦合将从参与者中移除。
4.2.3 适配器、装饰器、策略、访问者、代理:语言结构
使用AspectJ,一些模式的实现完全消失了,因为AspectJ语言构造直接实现了它们。这在不同程度上适用于这些模式。
适配器和访问者模式可以通过扩展适配器的接口来实现(通过AspectJ的开放类机制)。装饰器、策略和代理有基于附加建议的替代实现(在[18]中为装饰器提到)。
虽然更简单、更模块化,但这些方法有其固有的局限性。Decorator基于建议的实现失去了它的动态操作属性(Decorator的动态重新排序),因此不太灵活。当我们想用另一个具有相同名称和参数但返回类型不同的方法替换现有方法时,适配器的接口扩展不能以这种方式实现。
使用上述方法,保护或委托代理可以实现为可重用的,但是代理模式的一些应用程序要求代理和主体是两个不同的对象(例如远程代理和虚拟代理)。在这些情况下,Java和AspectJ实现是相同的。
4.2.4 抽象工厂模式,工厂方法模式,模板模式,建造者模式,桥接模式:多重继承
这些模式在结构上是相似的:继承用于区分不同但相关的实现。因为这已经在面向对象中很好地实现了,所以这些模式不能被给予更多可重用的实现。然而,使用AspectJ,可以用接口替换GoF解决方案中提到的抽象类,而不会失去将(默认)实现附加到它们的方法的能力。对于Java,如果我们想为模式代码中的方法定义一个默认实现,我们就不能使用接口。在这方面,AspectJ的开放类机制有效地提供了一种有限形式的多重继承。
除此之外,建造者模式和桥接模式还有以下额外的实现注意事项。 对于建造者模式,一个Aspect可以拦截对创建方法的调用,并使用Advice用替代实现来替换它们(参见上面的策略)。 对于桥接模式,抽象和实现者的解耦可以通过使用诺德伯格建议的多态Advice来实现[24]。虽然这种方法减少了参与者之间的耦合,但是当涉及到动态改变实现者时,它就不那么灵活了。
4.2.6 外观模式:没有从AspectJ中获益
实现
对于这种模式,AspectJ方法在结构上与Java实现没有什么不同。Façade为子系统的一组接口提供了统一的接口,使子系统更易于使用。这个例子主要需要名字空间管理和良好的编码风格。
五、分析
在这一节中,我们分析了之前观察到的用AspectJ实现模式的好处。分析分为三个部分:
- 在许多模式重新实现中观察到的一般改进。
- 与特定模式相关的具体改进。
- 模式中横切结构的起源,以及观察到的改进与模式中横切结构的存在相关的演示。
5.1 总体改进
对于许多模式来说,AspectJ实现体现了几个密切相关的模块化好处:局部性、可重用性、依赖性反转、透明的可组合性和(不)可插入性。试图说哪一个是主要的是困难的,相反,我们简单地描述它们并讨论它们的一些相互关系。
23个GoF模式中的17个的AspectJ实现是本地化的。对于其中的12个,局部性使得实现的核心部分能够被抽象成可重用的代码。在17个中的14个中,我们观察到了模式实例的透明可组合性,因此多个模式可以有共享的参与者(见表1)。
AspectJ实现的改进主要是由于颠倒了依赖性,因此模式代码依赖于参与者,而不是相反。这与局部性直接相关——模式和参与者之间的所有依赖都被本地化在模式代码中。
5.2 具体改进
5.2.1 单例模式
模式实现的AspectJ版本打开了两个在Java中不可能的设计选项: 第一,Singleton是继承属性,还是说,我们存在错误的继承? 第二,我们是希望一个专门的工厂方法来提供单例模式的实例,还是希望构造函数在被调用时返回它?
我们决定实现继承的Singleton属性,但是如果需要的话,我们提供了从Singleton保护中排除特定子类的工具。
第二,我们认为使用构造函数而不是专用的工厂方法是有益的。如果需要,工厂可以直接在类中实现,或者作为一个透明组合的Aspect来实现。
5.2.2 多重继承与Java
正如最初提出的,一些GoF模式在其实现中利用了多重继承,例如适配器模式的类版本。对于许多模式,参与者在模式中扮演的角色在Java中被实现为抽象类。参与者类从这些抽象类继承接口和默认实现。但是如果参与者类具有模式上下文之外的功能(比如作为观察者模式中的主体或观察者的图形用户界面小部件),它们通常已经是继承层次的一部分。由于Java缺乏多重继承,在这些情况下的实现可能有些笨拙:在Java中,如果参与者必须继承其角色和其他功能,那么其中一个超类型必须实现为接口。不幸的是,Java中的接口不能包含代码,因此无法附加方法的默认实现。
AspectJ中的开放类机制为我们提供了一种更灵活的方式来实现这些模式,因为它允许将接口和实现(代码)都附加到现有的类上。
5.2.3 打破循环依赖
一些设计模式管理对象集合之间复杂的交互。在面向对象的实现中,这些类紧密耦合并且相互依赖。引入循环依赖的设计模式的一个例子是中介器,它是在用户界面编程中经常使用的观察者模式的变体。在这里,对同事(如小部件)的更改会触发中介对象(如控制器)的更新。另一方面,作为对此的反应,调解人可能会更新一些或所有同事。
5.3 设计模式的横切结构
本节介绍了模式中横切结构的起源,并展示了在模式实现中使用AspectJ的观察到的好处与模式中的横切相关。角色定义了模式中参与者的行为和功能。这种角色的例子有:复合模式的组件、叶和复合,观察者模式的主题和观察者,或者抽象工厂模式的抽象和具体工厂。模式结构中的横切是由不同类型的角色及其与参与者类的交互引起的。
在一些模式中,角色是定义的:参与者在模式之外没有功能。也就是角色完整的定义了参与者。例如,扮演门面角色的对象为子系统提供统一的接口,并且(通常)没有自己的其他行为。定义角色通常包括客户端可访问的界面。
在其他模式中,角色是重叠的:它们被分配给在模式之外有功能和责任的类。例如,在观察者模式中,扮演主体和观察者的类不仅仅满足模式要求。例如,在图形用户界面环境中,主题可以是小部件。换句话说,在观察者模式上下文之外有行为的类。因此,主体角色只是现有类的扩充。叠加角色通常没有客户端可访问的界面。
在面向对象编程中,定义角色往往是通过子类化一个抽象超类来实现不同但相关的行为;叠加的角色通常是定义行为和责任的接口。
Java中有一个不一致的地方,叠加角色上的方法可能只供模式使用,但是它们必须在接口上定义,这要求它们是公共的。
5.3.1 角色和横切
叠加的角色导致模式和参与者之间三种不同的横切:
- 角色可以横切参与者类。即对于1个角色,可以有n个类,1个类可以有n个角色;即如图5所示的主题角色。
- 感兴趣的概念操作可以横切一个或多个类中的方法。即一个概念操作可以有n个方法,n个概念操作中可以有1个方法;即触发观察者更新的主题变更操作,如图5所示。
- 来自多个模式的角色可以在类和/或方法方面相互横切。也就是说,模式A认为是一个角色的一部分的两个类,模式B可能认为是一个以上的角色,反之亦然。概念操作也是如此;即主体角色和主体变更操作,如图9所示。
表1显示了模式引入的角色类型和AspectJ实现的观察到的好处之间的关系。设计模式可以分为三类:只有定义角色的,有两种角色的和只有叠加角色的。该表显示,虽然第一组中模式的AspectJ实现没有显示出任何改进,但最后一组中的模式显示出我们确定的所有模块化优势类别的改进。对于具有两种角色的模式,结果取决于特定的模式。
鉴于AspectJ旨在模块化横切结构,这一结果并不奇怪。主要涉及横切结构的模式在AspectJ实现中被很好地模块化了。 (请注意,AspectJ没有移除模式的横切,而是提供了模块化该结构的机制。)
5.3.2 预测模型?
模式角色之间的紧密联系,模式引入的横切,以及观察到的AspectJ实现的好处,暗示了给定设计模式的AspectJ实现的好处的预测模型。
通过定义角色,每个抽象单元(类)代表一个单一的概念,即一个类的功能对应于它在模式中的角色。继承用于区分相关但不同的实现。在这种情况下,透明性和可插入性并不是有用的属性,因为每个参与者本质上都只在一个特定的模式实例中有用。
有叠加行为,情况就不一样了。参与者在模式环境之外有自己的责任和理由。如果我们强迫一个这样的类进入模式上下文,我们至少有两个由一个抽象模块(类)表示的关注点:原始功能和模式特定的行为。由于模块化受到损害,由此产生的混乱和代码重复经常会导致问题。对于这些模式和它们的实现,模式功能和参与者的原始功能的干净模块化是合乎需要的。在AspectJ实现中,通常可以模块化抽象的模式行为,并让每个模式实例有一个方面来分配角色、概念操作和填充实例特定的代码。因为参与者在模式上下文之外确实有一个意义,所以他们并不局限于一个角色或者一个模式实例。
这个模型对于那些只有定义或只有叠加角色的GoF模式来说似乎是准确的。对其他人来说,预期的好处似乎取决于实现特定角色的参与者的数量。映射到多个参与者的叠加角色(例如,访问者中的元素、组合或组合中的叶)表明了模块化的潜力,即使模式也包括定义角色。
七、总结
在模式实现中使用AspectJ的改进与模式中横切结构的存在直接相关。这种横切结构出现在将行为叠加到参与者身上的模式中。在这种模式中,角色可以横切参与者类,概念操作可以横切方法(和构造函数)。就共享参与者而言,多个这样的模式也可以相互横切。
这些改进表现为一组与模块化相关的属性。模式实现更加本地化,并且在很多情况下是可重用的。因为AspectJ解决方案更好地将代码中的依赖与解决方案结构中的依赖联系起来,所以模式的AspectJ实现有时也是可组合的。
本地化模式实现提供了内在的代码可理解性优势——模式代码的单个命名单元的存在使得模式的存在和结构更加明确。此外,它还为改进代码文档提供了一个锚。
我们的结果为进一步的实验提出了几个方向,包括将AspectJ应用于更多的模式,试图系统地使用我们的可重用模式实现,以及试图在已知受设计模式思维影响的遗留代码库中使用AspectJ。未来工作的另一个途径是将这些结果与其他定向技术的使用进行比较。