易栈 · 一盏

塞外秋来,衡阳雁去

设计模式:可复用面向对象软件基础 下

三、行为模式

行为模式涉及到算法对象职责的分配

1、Chain Of Responsibility(职责链)

描述:使多个对象有机会处理请求,从而避免请求的发送者接收者之间的耦合关系。实现的方法是将接收者对象连成一条链,并沿着这条链传递该请求直到有一个对象处理它为止。 

实例:一个图形界面的上下文相关的帮助机制。用户在界面上任何一部分点击就可以得到帮助信息。用户点击之后,请求会沿着图形界面的组件逐步往上冒泡(按钮->对话框->窗口),直到有一个组件提供了帮助信息。这些组件就形成了对象链。 

实现:定义一个请求处理者的基类(Handler),对象链中的具体类都从这个基类派生。Handler中定义一个成员指针,指向对象链中下一个对象;并且定义一个成员函数用来处理请求,这个函数的缺省实现是对请求进行无条件转发

2、Command(命令)

描述:将一个请求封装成一个对象,从而可以用不同的请求对客户进行配置。Command对象封装了要执行的操作请求的接收者(Receiver,操作的实际执行者或操作实际作用于的对象),可以用不同的Command对象配置客户端。客户无需关心Command的具体类型,只需要在请求时调用Command对象的Execute接口。Command对象还可以实现更多操作如:构造请求序列(宏/脚本)、记录请求日志(用于应用崩溃时恢复数据)、支持可撤销的操作(定义一个UnExecute接口)。

实例:一个应用的菜单栏,每一个菜单项(MenuItem)对应一个请求。可以为每一个菜单项配置一个Command子类的实例,当用户点击一个菜单项时,MenuItem对象调用Command对象的Execute方法。不同Command对象的请求接收者可能不同,例如本例中请求接收者有可能是当前正在编辑的文档(Document)或者是整个应用(Application)。 

实现:定义Command抽象类,作为所有请求对象的基类。Command定义了执行请求的接口(Execute)。Command的子类中可以定义一个成员指针指向请求的接收者(Receiver),然后在Execute接口中操作Receiver。

3、Interpreter(解释器)

描述:当一个语言需要解释执行,并且它的语句可以表示为一个抽象语法树时,可以使用解释器模式(Interpreter)。例如数学表达式就是一种语言,它的语句就是具体的表达式,表达式中的每个运算元可视为语法树中的一个节点,这个运算元本身可能是个数字(叶节点),也可能是一个表达式(子节点)。可以用解释器模式来表示和求解数学表达式。如果想要用其他元素代替数字进行运算,这时候解释器模式就很有用。解释器模式描述了如何为简单语言定义一个文法规则,如何表示一个语句,以及如何解释这些语句。例如定义加减乘除表达式的文法:表达式 = 加法表达式|减法表达式|乘法表达式|除法表达式,然后用这几种表达式来“表示&解释”语句。

实例:用解释器来表示正则表达式。

实现:定义一个Expression抽象类作为所有表达式类的基类。Expression类定义一个解释操作Interpret,每个子类中具体实现自己的解释操作。Expression的每个子类表示一条文法规则,例如用AddExpression类来表示加法表达式,AddExpression类的左右运算元都是Expression对象。同样减法、乘法表达式也分别对应一个Expression子类。利用这些类的实例,可以把语句表示成语法树,对这个语句求解,就是递归地对语法树中的每个节点求解(就是调用节点的Interpret操作)。如果子类表示一个非终结符表达式(也就是它有子节点),那么它的解释操作一般是通过递归调用子节点的解释操作来实现。

总结:Interpreter模式易于改变和实现文法,但是不易于维护复杂文法(解析器模式为每一条文法规则至少定义了一个类,规则很多时难以维护)。

4、Iterator(迭代器)

描述:提供一种方法顺序访问一个聚合对象(例如列表、数组)中各个元素,而又不暴露该对象的内部表示。这一模式的关键在于将对聚合对象的访问和遍历聚合对象分离出来并放入一个迭代器(iterator)对象中。这样易于在相同的聚合上使用不同的迭代算法(而无需在聚合中为每种迭代算法添加接口),同时也易于在不同的聚合上重用相同的算法。但这样也有一个缺点:遍历算法可能需要访问聚合的私有变量,如果允许访问,那就会破坏聚合的封装性

实例:遍历一个列表(list)。

实现:定义一个迭代器抽象类(Iterator),其中定义了迭代器基本接口例如First、Next等。另外有时候为了允许在不同的聚合上使用相同的算法,会为聚合定义一个抽象类,这样迭代器就可以通过该抽象类的接口来访问聚合,不关心聚合的具体类型。在C++中,当迭代器要访问聚合的私有变量时,通常的方法是将迭代器设为聚合的友元。但是这样的话每增加一个迭代器,就要在聚合中增加一个友元。一个解决方法是将迭代器的基类设为聚合的友元,并把迭代器中访问聚合私有变量的接口定义为protect,这样保证有且只有迭代器的子类才能够调用这些接口。

5、Mediator(中介者)

描述:用一个中介对象来封装一系列的对象交互,使得各个对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互(通过改变中介者)。面向对象设计鼓励将行为分布到各个对象中。这种分布可能会导致对象间有很多连接。大量的相互连接使一个对象不太可能在没有其他对象的支持下工作。对系统的行为做大的改动也十分困难,因为牵扯到很多对象。可以将集体行为封装在一个单独的中介者(mediator)对象中来解决这个问题。中介者负责控制和协调一组对象间的交互,使得组中的对象不再相互显式引用。这些对象只知道中介者,从而减少了相互连接的数目。

实例:一个图形界面对话框,对话框中的窗口组件相互间存在依赖关系,例如输入框为空时,发送按键要处于禁用状态;在多选框里面勾选时要同步改变输入框的内容。可以设计一个中介者来协调窗口组件间的交互。组件参数发生改变或者有事件触发会去通知中介者,中介者再对其他组件做相应处理。

实现:中介者(Meditator)协调的对象称为同事(Colleague)对象。Colleague类中有一个指针指向Mediator对象。当Colleague对象状态发生改变时,会回调Mediator对象的接口,Mediator对象再对相关的Colleague对象做操作。在回调Mediator对象接口时,可以用指向当前Colleague对象的指针作为参数,以方便Mediator对象操作当前Colleague对象。

总结:Mediator模式实际上把Colleague之间的多对多关系变成了Mediator和Colleague间的一对多关系

6、Memento(备忘录)

描述:不破坏封装的前提下,获取一个对象的内部状态快照,并在对象之外保存这个快照。这样以后可以将对象恢复到抓取快照时的状态,Memento模式可以用来实现还原点取消机制。问题是怎么在“不破坏封装的前提下”获取对象的内部状态呢?对象通常封装了其状态信息,使得不能被其他对象访问。而暴露其内部状态有违反封装的原则,可能有损应用的可靠性和可扩展性。Memento模式的解决方法是使用备忘录(memento)对象。备忘录存储了一个对象(称为备忘录的原发器,originator)在某个瞬间的状态。原发器负责用自己的状态信息来初始化备忘录。只有原发器能够向备忘录中存储or获取信息,备忘录的内容对其他对象“不可见”,从而不会破坏封装。当要设置一个还原点或者实现取消机制的时候,管理器会向原发器请求一个备忘录,管理器负责保存这个备忘录,当需要还原时,再将备忘录发回给原发器。

实例:图形编辑器中要保存正在编辑的图形的状态,以便用户做取消操作。

实现:定义原发器的抽象类Originator和备忘录的抽象类Memento,将Originator作为Memento的一个友元,并把Memento的数据和相关接口定义为private。可以将Memento模式和前面介绍的Command模式结合,在Command对象的执行操作Execute中向原发器请求备忘录,保存在Command对象中,然后在取消操作Unexecute中再把备忘录发回给原发器。

总结:有一个问题是如果生成备忘录需要拷贝大量的信息,使用备忘录可能代价很高。这时候可以考虑用增量式改变的方法,也就是只在备忘录中存储一个命令产生的增量改变,而不是受影响的对象的完整状态。

7、Observer(观察者)

描述:定义对象中一种一对多的依赖关系,当一个对象的状态发生改变时,多个依赖于它的对象都可以得到更新。Observer模式中有两个关键对象:目标(subject)观察者(observer)一个目标可以有多个依赖于它的观察者。一旦目标状态发生改变,所有观察者都得到通知。观察者可以反过来设置目标的状态,设置后的状态会被目标同步更新到其他所有观察者。表面看似是观察者之间的联动,其实是由目标充当中介来实现的,观察者相互并不知道对方

实例:一个电子表格工具(例如excel)可以同时用表格饼状图柱状图显示同一份数据。这份数据就是目标,表格、饼状图和柱状图就是观察者。当用户在表格中修改数据的时候,饼状图和柱状图应该同步得到更新。

实现:目标和观察者之间的通知机制有很多种实现方法。可以由观察者轮询,也可以由目标主动推送;可以总是发送所有状态,也可以由观察者根据需要订阅;可以由目标在每次状态修改后自动触发更新,也可以由观察者调用了一系列接口改变目标状态后,自行决定调用更新操作的时机(避免了频繁触发更新,但容易忘记调用更新操作)。书中的示例是定义目标的抽象类Subject和观察者的抽象类Observer。Subject类中定义一个数据成员,用来存储观察者列表。Subject提供一个通知接口Notify,该接口遍历所有观察者,回调观察者的Update函数来更新它们。

总结:有一种复杂的情况是多个目标对应多个观察者,这时候还要在目标和观察者间增加一个管理器对象来维护这种复杂关系。

8、State(状态)

描述:如果对象内部是按照一个状态机在运作,在不同状态下,该对象的同一个接口要做不同的操作。这时候可以考虑把对象的行为抽取出来,为不同的状态分别实现一个行为类。那么在对象的状态改变时,只要替换这个行为类的实例,就可以相应改变对象的行为。

实例:一个表示网络连接的类TCPConnection。一个TCPConnection对象可能处于以下任一状态:Established、Listening、Closed。当接收到其他对象的请求时,TCPConnection对象根据自身的当前状态做出不同反应,例如Open请求的结果依赖于连接是处于Closed还是Established状态。这里可以引入一个TCPState抽象类来表示网络的连接状态。TCPState的子类(例如TCPEstablished、TCPListen、TCPClosed)实现与特定状态相关的行为。TCPConnection类维护一个表示TCP连接当前状态的状态对象(一个TCPState子类实例),并将所有与状态相关的请求委托给这个状态对象。一旦连接状态改变,TCPConnection对象会改变它所使用的状态对象,从而改变他的行为。 

实现:定义一个上下文(例如TCPConnection)的抽象类Context和状态的抽象类State。Context中定义一个成员指针指向State对象,Context将与状态有关的操作委托给这个State对象。State的每一个子类实现与一个状态相关的行为。当Context对象的状态改变时,会把它的State对象指针指向相应的State子类对象。这里有一个问题是谁负责切换状态对象。有两种方法:1、由Context切换;2、Context提供接口给State,让State子类自身来指定它的后继状态。后者更灵活,缺点是使State子类之间产生了依赖。 

9、Strategy(策略)

描述:Strategy模式主张把算法部分提取出来,进行独立封装。这样就可以定义一系列的算法,并且使它们可以相互替换。一个类可以在保持对外的行为不变的情况下,通过使用不同的算法动态切换不同的内部实现。当一个算法有不同的变体时,使用者可以根据时间、空间和效果的权衡来选择。以这种方式封装的算法又称为策略(strategy)

实例:一个编辑器要使用不同的换行算法。如果把这些算法硬编码到编辑器的类中,会导致1、编码器的代码庞大复杂,难以维护;2、难以配置当前要支持哪些算法;3、增加或者修改算法困难。使用策略模式来实现换行算法可以避免这些问题。

实现:定义一个上下文抽象类Context和策略抽象类Strategy。通过派生Strategy可以实现不同的算法子类。Context中定义一个成员指针指向Strategy对象。客户可以用一个具体Strategy子类对象来配置Context,配置完之后,客户只需要操作Context。Context将客户请求转发给它的Strategy,同时将该算法所需要的数据都传给Strategy。

总结:有两种方法可以给Strategy传递实现算法所需要的数据:1、Context将数据放在参数中传递给Strategy,Context和Strategy解耦,但是有可能发送一些不需要的数据(简单的Strategy和复杂的Strategy需要的数据不同),造成额外开销;2、Context将自身作为一个参数传递给Strategy,Strategy通过访问Context对象来获取数据,Context和Strategy耦合紧密,可能会破坏封装

10、Template Method(模板方法)

描述:在一个方法中实现一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中,这个方法称为模板方法(template method)。模板方法由父类实现,可以用于在调用子类的接口前后,添加一些准备或收尾操作。这些操作属于各个子类的公共操作,提取出来集中到父类中,一来可以避免代码重复,二来保证这些操作会被执行(客户使用的是模板方法,而不直接使用子类的接口)。

实例:有一个阅读器框架(抽象类为Application),通过派生这个框架,可以实现不同类型文档的阅读器。Application类的OpenDocument操作定义了打开和读取一个文档的算法
  1. 检查文档是否可以打开(CanOpenDocument,文件是否存在、类型是否支持)
  2. 创建文档对象(DoCreateDocument,不同的Application子类可能创建不同类型的文档)
  3. 打开文档
  4. 读取文档
由于在Application类中无法知道文档的具体类型,所以无法实现CanOpenDocument和DoCreateDocument这些操作,Application的子类将重定义这些操作以提供具体的行为。这里OpenDocument就是一个模板方法

实现:定义抽象类AbstractClass和它的子类ConcreteClass。AbstractClass中定义了一些抽象的原语操作,这些原语操作将由ConcreteClass实现。AbstractClass中实现一个模板方法,其中使用了原语操作。

11、Visitor(访问者)

描述:表示一个作用于某对象结构中的各元素的操作。Visitor模式可以使你在不改变各元素的类的前提下定义作用于这些元素的新操作。假如用一个对象结构来表示主机,对象结构中的元素即是主机的组件,例如主板、CPU、内存等。如果要实现一个检查主机硬件工作状态的操作,一种方法是每个组件都实现一个自检操作, 通过遍历组件,调用每个组件的自检操作,就可以得出主机的整体状态。这种方法的缺点是每添加一种操作(例如要打印所有硬件的型号、获取硬件温度等),就要改动所有组件,而且可能会导致组件类中充斥着很多与它自身关系不密切的操作。Visitor模式的解决方法是把对不同元素的操作封装到同一个访问者(visitor)类中,并在遍历元素时将visitor对象传递给当前访问的元素被访问的元素只需要回调visitor对象相应的接口(例如VisitCPU、VisitRam)来完成操作,而无需关心这个接口是如何实现的。

实例:一个编译器将源程序表示为一个抽象语法树。编译器要在语法树上实施一些操作,例如类型检查、代码优化、流程分析、优美格式打印等等。如果将这些操作分散到各个节点类,将会导致整个系统难以理解、维护和修改,把它们实现成visitor可以避免这个问题。

实现:定义访问者的抽象类Visitor和被访问的元素的抽象类Element。Visitor中定义了一系列接口,分别用来访问不同Element子类的对象(例如VisitElementA、VisitElementB)。Element中定义一个Accept接口,接收一个Visitor对象作为参数。Element子类实现Accept操作,在Accept操作中调用对应的Visitor接口(例如ElementA的Accept操作中调用Visitor的VisitElementA接口)。

总结:Visitor模式使得易于增加新的操作,但是要增加新的元素很困难,因为要在Visitor抽象类中添加一个新的抽象操作,并且在每个Visitor子类中实现相应操作。

本文链接:易栈 - 设计模式:可复用面向对象软件基础 下