设计模式之行为模式
本文是设计模式系列的最后一篇。主要讲解设计模式中最后一种模式类型——行为模式。本文抽选了几个主要的设计模式,忽略了少部分次要的,明显意图的模式。
在设计模式中,所谓的行为模式指的是对象之间的交互行为。而从程序的组织结构来看,基本的交互行为仅有一种,即一个对象调用另一个对象的方法,或者说一个对象请求另一个对象的方法,或者说一个对象向另一个对象发起了请求。总得来说,这三种说法大致上是等价的。
class A{
private B b = new B();
public void methodA(){
b.methodB();
}
}
在上面的例子中,我们说A对象在methodA方法中调用了b的methodB方法,或者说请求了methodB方法,或者说向b的methodB方法发起了请求。
此外,在行为模式中,除了最基本的交互行为之外,实际上同样还涉及类结构模式的组织,只不过这些结构组织在行为模式中处于次要地位。
一、基本的行为模式
有一些基本行为模式,无论是从组织结构还是行为模式上看,它们都异常简单。但从意图上看,却难以区分。
1. 策略模式(Strategy)
策略模式定义了一族算法(algorithms)。客户端持有一个算法接口,并有权选择使用哪一个具体的算法子类。
这个模式非常简单。
interface Strategy{
void caculate();
}
class StrategyA implements Strategy{
public void caculate(){
// ...
}
}
class StrategyB implements Strategy{
public void caculate(){
// ...
}
}
class Context{
private Strategy strategy;
public Context(Strategy strategy){
this.strategy = strategy;
}
public doSomething(){
//....
strategy.caculate();
//....
}
}
我们看到,上下文对象持有了一个Strategy
接口的对象,它允许向其中内注入一个具体的strategy
子类。从组织结构的角度上来看,它与之前文章《从设计模式看面向对象的程序结构组织》中有提到的桥接模式(Strategy)非常相象(以及与本文接下来将要提到的状态模式)。在桥接模式中,我们主动提供一个编程SPI,让服务商提供他们的实现,然后我们的应用程序框架基于这个实现来提供更多的功能。
特别是,设计模式的作者认为,策略模式与其它模式的区别在于,策略模式用来抽象算法(algorithms),而其它模式各有自己适应的目标。然而,算法这个词的外延十分夸张,尤其是在编程领域,也许凡是你能想到的东西都可以归为算法的范畴。因此,在这里我们不得不考虑在设计模式里,算法仅仅指那些常见通用的、与可计算理论和复杂度理论相关的所谓算法,而不是说通用的算法,否则策略模式将囊括其它诸多模式。
2. 状态模式(State)
interface State{
void caculate();
}
class StateA implements State{
public void caculate(){
// ...
}
}
class StateB implements State{
public void caculate(){
// ...
}
}
class Context{
private State state;
public Context(State state){
this.state = state;
}
private changeState(){
// ...
}
public doSomething(){
//....
state.caculate();
//....
changeState();
state.caculate();
}
}
上面的代码从策略模式的说明代码拷贝而来,仅修改了名字,增加了一个改变状态的方法,在doSomething()
中添加了一行改变状态的方法。同相,一个上下文对象持有了一个状态对象,这个状态对象代表了上下文对象的状态,它有A和B两个实现状态。
状态模式封装了对象对于状态的依赖。当状态改变时,附着在状态上相应的行为也会改变。也就是说,当状态从A变到B时,调用B状态的计算,从B变到A时,调用A状态的计算。
注意,我们在这里很难说服自己这所谓附着在状态上的行为不能是算法。当它是算法是,很显然它同样是策略模式。然而,另一个不同点在于,由于可能存在需要显式改变状态的场景,状态模式允许上下文了解它的状态子类分别是什么,对于纯粹的策略模式来说则无太所谓。
3. 命令模式(Command)
命令模式即所谓的回调模式,或者说Action模式。它用一个命令对象表示一个行为。这种表示在java编程语言中是必须的,因为java由于语法的限制,不能直接传递函数。而在javascript等脚本语言中,函数本身就是对象,可以直接传递,因而命令模式在这种语言中会被极大地简化。
// 回调接口
interface Command{
void callback()
}
class Button{
private Command command;
public onClick(){
command.callback();
}
}
Button
类持有了一个Command
接口,当点击发生的时候,它调用callback
回调。
同样,你也很难说服自己callback
函数中不能实现一个算法。并且,当它实现一个算法时,它与策略模式几乎毫无二致。但对于命令模式,我们唯一区分它跟策略模式的方式就是,把它看作一个普通的请求抽象。
4. 模板方法(Template)
也许你会很奇怪模板方法为什么要放在全文的这样一个位置。但在我看来,如果说状态模式和命令模式非常像的话,那么模板方法本质上就是一个与策略模式没有任何区别的模式,尽管它从表面上看上去似乎比前两者与策略模式相差得更远。
我们来看一个简单的分治算法框架, 以及基于这个框架的快排和归并排序。
abstract class DivideAndConquer<T>{
private List<T> list;
public DivideAndConquer<T>(List<T> list){
this.list = list;
}
protected List<T> getData(){
return list;
}
// int 指明分开的界限索引
abstract protected DivideAndConquer<T> subProblem(List<T> sublist);
// int 指明分开的界限索引
abstract protected int doDivide();
// 两个list合并的结果
abstract protected List<T> doConquer(List<T> list1, List<T> list2);
public List<T> caculate(){
// 计算分界限索引
int index = doDivide();
// 按索引分别获得左右列表
List<T> leftList = list.sublist(0, index);
List<T> rightList = list.sublist(index + 1, list.length())
DivideAndConquer<T> leftSubProblem = subProblem(leftList);
DivideAndConquer<T> rightSubProblem = subProblem(rightList);
List<T> leftResult = leftSubProblem.caculate();
List<T> rightResult = rightSubProblem.caculate();
return doConquer(leftResult, rightResult);
}
}
class QuikSort<T>{
public QuikSort<T>(List<T> list){
super(list);
}
protected DivideAndConquer<T> subProblem(List<T> sublist){
return new QuikSort<>(sublist);
}
protected int doDivide(){
// 将列表分开,并证返回索引的元素处于正确位置
// 且位置小于这个索引的元素小于它,大于这个索引的元素位置大于它
}
protected List<T> doConquer(List<T> list1, List<T> list2){
// 简单地合并两个列表
}
}
class MergeSort<T>{
public MergeSort<T>(List<T> list){
super(list);
}
protected DivideAndConquer<T> subProblem(List<T> sublist){
return new MergeSort<>(sublist);
}
protected int doDivide(){
// 取最中间元素
}
protected List<T> doConquer(List<T> list1, List<T> list2){
// list1和list2中是排好序的元素
// 将list1和list2中元素排到一个列表中
}
}
可以看到分治算法模板类定义了三个模板方法,分别是subProblem
,doDivide
和doConquer
。我们继承这个抽象类,然后在子类中实现不同的子算法,即可获得不同的具体算法,如上,我们通过一个分治抽象类继承出了两个不同的排序算法。理论上讲,只要某个具体算法使用的是分治理论,就可以通过这个类继承出来。
注意,在这里subProblem
模板方法实际上还是一个工厂方法。
然而,通过对比策略方法我们发现,它不过是就是把策略接口定义在自己身上,然后再把自己定义成抽象类而已。实现一个策略接口变成了继承抽象的父类,所不同的仅仅是应用了面向对象编程的不同机制而已。
5. 访问者模式(Visitor)
Visitor 用来实现一个类的定制策略。
class Bean{
private Set<Property> properties;
private Set<Method> methods;
BeanVisitor visitor;
public void doVisit(){
visitor.visitProperties(properties);
visitor.visitMethods(methods);
}
}
interface BeanVisitor{
void visitProperties(Set<Property> properties);
void visitMethods(Set<Method> methods);
}
我们可以看到,Bean
类型使用BeanVisitor
来定制它的属性,这些由Bean
和BeanVisitor
协商出来的可访问的属性称为"可定制的属性"。从结构上看,BeanVisitor
对Bean
的结构是非熟悉的,也正因为如此,BeanVisitor
有能力定制Bean
的属性。
Bean
可以在方法上依赖一个类,也可以将一个BeanVisitor
作为它的持有依赖,这都无关紧要。但对比之下却很关键,因为策略模式的策略接口也是如此——策略接口当然也可以作为方法参数传递。但习惯上,我们却经常把BeanVisitor
作为接口参数传递而Strategy
作为持有对象。为了保持说明上的一致,我们这里把BeanVisitor
声明为Bean
属性。但读者应该了解,这只是依赖的不同方式,并不会导致设计模式本质发生变化。
对于BeanVisitor
来说,它的策略发生了一些改变。一是从数量上来看,BeanVisitor
通常会有多个接口方法(Strategy
也可以有多个策略接口方法,但不是常见情况);二是从目标上看,这些策略方法通常与一个目标对象相关,其策略类型通常就只是针对目标对象的可定制属性。
二、多依赖管理
所谓多依赖指得是一个对象直接或间接持有了许多个对象。我们将看到,多依赖管理解决的是如何对这些依赖进行访问,以及将一个操作请求分发到各个依赖的对象中去。
当然,我们首先回忆一下,前文对于单个对象的依赖是如何控制访问以及分发访问请求的。显然,没有任何的控制。前面所描述的模式中,大抵可以这么总结,一个类持有一个特殊用途的接口,无论它是私有的还是公有的,在这个类中我们都可以直接访问它,并且调用相关的请求。
1. 迭代器模式(Iterator)
相比之下,我更喜欢把这个模式称为访问器模式。
多个依赖在对象中一般是存储为一个集合类,然而集合类与集合类之间可以大不相同,对它们的访问方式也各有不同。例如最基本的分类,可以把一个集合类分为Collection
和Map
,即单个的集合和对的集合,它们的访问方式显然具有很大差异。然而,对于Collection
来说,Queue
和Stack
这种拥有特殊访问控制方法的类显然也居有一些顺序上的差异。
这个时候,就可以把这些不同的访问过程抽象到Iterator
中。客户端只要持有一个Iterator
对象,并按照Iterator
对象上的协议访问(通常是next
和hasNext
方法)就可以避免知道底层是什么具体的集合类。
因为Iterator
实际上做的事情就是抽象出各个不同类型集合的访问过程,因此把它称作访问器实在是再合适不过了。用迭代器来称呼的原因更有可能是,Iterator
在使用中通常用在迭代的场合。由于通常交流中,我们还是比较习惯称为迭代器,因此本文并不打算更换这个称呼。
迭代器模式常用例子可以在JDK的集合框架中找到,这也许是少有的几个在编程语言中无歧义实现的设计模式之一。
2. 接受分发请求的对象
分发模式,包括接下来将要提到的责任链模式和观察者模式,都有一些共同的特点。第一,它们都包含一组依赖对象,第二,它们都需要向这组依赖对象分发请求。其次,在设计上,它们也有一些共同的要素,即抽象出一个接受请求的对象,而不用管具体是谁在处理这个请求。
interface RequestHanlder{
void handle(Request request);
}
在上面的代码中,我们给出了一个RequestHanlder
接口。
当一个请求发生时,我们将看到,RequestHanlder
将接受这个请求,并把请求分发到它管理的对象中。对于客户端对象来说,RequestHanlder
其实就是一个集合类,它包含了一系列的处理对象,客户端不关心请求怎么处理,这由RequestHanlder
的实现来决定。
3. 责任链模式(Chain of Responsibility)
责任链模式将RequestHanlder
进一步实现为一个请求处理链。
abstract class ResponsibilityChainRequestHanlder implements RequestHanlder{
private ResponsibilityChainRequestHanlder successor;
public ResponsibilityChainRequestHanlder(ResponsibilityChainRequestHanlder successor){
this.successor = successor;
}
}
在实现具体的handle处理的时候,如果某一个RequestHanlder
不接受当前的请求,则需要将它传递给下一个(successor)处理器。
注意到,在这种实现方式里,需要有一个管理责任链本身的逻辑。例如,如何将责任链组装起来。在更通用的实现里,我们提供一个ResponsibilityChain
类型。
class ResponsibilityChain{
private ResponsibilityChainRequestHanlder requestHandlers;
public ResponsibilityChain(ResponsibilityChainRequestHanlder requestHandlers){
this.requestHandlers = requestHandlers;
}
public void addRequestHanlder(ResponsibilityChainRequestHanlder requestHandler){
//....
}
public ResponsibilityChainRequestHanlder next(){
//...
}
}
可以看到,ResponsibilityChain
提供了添加RequestHanlder
和获取下一个RequestHanlder
的方法。然而要记住的是,这些对于责任链模式来说仅仅是辅助性的。
4. 观察者模式(Observer)
观察者模式在责任链模式的基础上放宽了许多限制。我们甚至可以用上面的迭代器模式来描述。
interface Observer{
void doUpdate(Subject subject);
}
class Subject implements RequestHanlder{
private String titile;
Iterator<Observer> listeners;
public void handle(){
while(listeners.hasNext()){
Observer observer = listeners.next();
observer.doUpdate(this);
}
}
public void setTitle(String title){
this.titile = title;
handle();
}
}
当主题对象的title改变时,就会通知所有已经注册的Observer
对象。
我们使用迭代器的目的在于表达Observer
如何在主题对象中存储,以什么样的顺序获得与观察者模式是无关的。事实上,观察者模式对主题对象是否作为业务对象,listeners是否作为Subject的持有对象都不作规定。
与责任链同样作为分发请求的模式,观察者模式通常会把请求平均分发到每一个对象,或者在主题对象的层次上进行访问控制。但在责任链中,请求是否传到下一个处理者是由当前的处理者来决定的。与此同时我们会发现Observer
接口实际上是一个命令模式的回调接口。
结语
全文完。
了解更多关于结构性设计模式的内容: