设计模式-行为型

DinS          Written on 2017/11/12

行为型(bahavioral)设计模式指在程序中处理具有特定操作类型的对象。

行为型是设计模式中成员最多的,从介绍那一句话可以知道非常的概括和模糊,除了创建型和结构型之外都算行为型了。这里只介绍最常用的几种:

一、状态模式(state pattern)

有时候我们希望同一个对象在不同的情况下具有不同的状态,相同的接口在不同状态下会带来不同的行为。
说的具体一点,比如设计一个RPG游戏的敌人,按照通常的套路敌人可以有狂暴、胆怯、中毒、缩小等各种状态。敌人的状态不同,攻击力、策略选择会有不同。如何来实现呢?
按照传统的FOP思路,我们可以设置状态位来标记对象,然后执行行为之前通过判断状态位来决定具体行为。让我们来实现这个思路:

整个代码平淡无奇。当然可以使用枚举让代码更容易阅读,但是本质不会有改变。

调用的时候是这个样子的:

运行结果:

确实达到了我们的目的,不过这里面有很大的隐患:
第一个问题是代码属于boilerplate。
第二个问题是如果增加状态位,这很有可能,那么所有行动里都需要增加对应的判断。现在只有攻击和使用道具两个行为,一旦行为更多后果不可想象。
第三个问题是目前展示的状态位都属于同一个级别的,如果增加不同级别的状态,比如根据所处地形进行不同行动,那么判断的条件就是乘法了,会急剧膨胀。

上面展示的这类情况在实际编程中经常遇到,如果if-else if不好,那么怎么解决呢?答案就是使用状态模式。
看代码,这里涉及了一些之前没有遇到的情况,将逐一解释:

一开头这句可能就颇为费解。
首先这是一个静态成员变量的声明,其次看到该变量的类型是一个枚举类型。其用意是使用枚举来代替数字,增加代码的可读性。
最后将枚举声明在类内部确实复杂一点,如果直接在外部声明枚举会容易些。但是这里的目的是表明这个枚举是跟类Enemy有关的,通过访问Enemy来访问枚举,可以避免让过多的内容散落在代码中,增强逻辑性。

之后又遇到了一个新内容:嵌套类(nested class)。如果在一个类内部再声明、定义类,就是嵌套类。其实也没有什么过于高深的东西。
不过要注意一点,外层类和嵌套类的对象相互独立,这意味着我们无法直接操作二者的成员,需要一些技巧。这里只涉及输出所以无所谓,后面还会看到更多例子。

往后看可以发现,所谓的状态是一个类体系,有基类和派生类。
状态模式的思路就是让编译器根据多态来自动选择合适的状态,避免状态位的判断,即使用类体系代替状态位的判断。
状态模式不一定非要把这个类体系做成嵌套类,不过因为外界调用者对这个类体系不感兴趣,他们感兴趣的只是同一个对象在不同状态下同一个接口有不同行为,因此一个合理的设计就是把整个状态类体系放到类内部,不让外界知道里面发生了什么事情,这样更加符合OOP设计。

State作为基类定义了接口,Normal作为派生类实现接口,其行为表现就是在Normal状态下应该有的行为。定义析构是为了展示智能指针可以正确释放内存。

Berkserk和之后的状态都是同理。都声明为public是方面后面调用。

私有成员就一个指向基类状态的指针。接口就很清晰了,因为动态绑定,直接调用指针的接口即可智能根据状态执行不同行为。

设定状态也不难。得益于枚举类型,代码可读性更强。使用智能指针可以自动释放内存。switch-case配合枚举量让代码更加清晰。

有两点需要特别说明。
第一点:
一开始你可能认为状态模式也不省事啊。你看,每个状态都要派生出来进行实现,跟之前那个每个行为判断状态位没差多少啊?

要这样来理解状态模式和状态位判断的区别。
使用状态位,我们把困难推到了接口里。使用状态模式,我们把困难推到了类体系的派生中。但是要注意到两种思路遇到的困难程度不一样。
状态模式的派生方式跟人类认知方式一致,都是在某种状态下对象表现出什么行为,而状态位更接近于机器方式,对编程人员不友好。
但是更重要的区别在于难度的增加方式不同。在状态位下,随着状态的增加各种状态会纠缠在一起,即状态越多维护难度越大。在状态模式下,状态的增加不会影响到之前的状态,即每一种状态是相互独立的,所以维护难度不会随着状态增加而变大,这就是状态模式的优越所在。

第二点:
如果不看嵌套类体系,只看最后的成员变量和接口,你会发现跟代理模式非常相似,都是一个基类指针,然后接口是调用指针的对应接口。
不过代理模式和状态模式是有根本性不同的,不同就在于目的不同。不能够因为代码类似就断定模式类似。一定要看目的,这也是OOP分析的重要原则,后面还会专门分析OOP原则。

类设计完了,接下来看看如何使用:

这一句大概是一脸懵逼,这是使用类内静态枚举需要付出的代价。
首先要说明,类内的静态变量都是声明,如果要使用必须在类外部定义并初始化,这句代码就是干这个用的。但还是看起来还是有些莫名其妙。我们可以写出int a;,其实这里跟这个形式一致。
ES是静态变量,所以需要用类名访问,即Enemy::ES。而ES的类型是枚举,也定义在类内,所以是Enemy::ENUM_State。合起来就跟int a;一样了。又因为这个是枚举,实际上也是一种类,所以直接使用了默认初始化来完成。

如果不在类内声明枚举,就不会有这些麻烦事。不过还是开头说明的,这样把枚举跟类关联起来逻辑性更强。

main里的代码没什么特别的。注意因为我们有了枚举,所以在SetState时代码的语义更清晰,调用时使用两层类名。

运行结果:

一方面是对象表现出了我们期待的状态不同行为不同,另一方面是智能指针及时释放了内存。

总结一下状态模式:
根据不同状态,相同接口表现出不同行为
更直观的理解是,如果发现代码中需要反复使用if-else if判断类似的东西,就可以考虑使用状态模式了。

二、命令模式(command pattern)

命令模式也是很常用的一种模式,它的用意是把行为信息传递给其他对象。
一般意义上参数都是静态的,这里静态指某个东西,比如整数、指针等,但是有时候我们希望把动作当作参数传入,得到该参数的对象可以执行这个动作。
这时候命令模式可以大发神威了。

让我们看看这个例子:

格斗游戏的经典:KOF97。
我们不断敲击键盘来发招,这个其实就是命令模式的一个典型应用:动态地根据输入执行不同动作。

让我们看看如何实现。
首先命令本身就是一个类体系,有基类和派生类,跟以往见过的类体系并无二致。
接收命令的也是一个类,内部有一个vector或者list,根据传入命令的顺序依次执行动作。

搞明白了设计思路,就可以实现了。
命令类体系:

玩家类作为接收命令参数的对象:

主程序:

执行结果:

整体还是很清晰的,而且似曾相识。
没错,水果商人的Basket类也是一样的设计理念。
区别就在于出发点不同,Basket装的是各种水果,水果类自带计算功能。
命令模式强调的是对象本身就是动作,压入动作然后依次执行。
这里再次出现了代码类似、目的不同的情况。

总结一下命令模式:
把动作视为对象,进行类体系设计
对象本身携带了一个动作,使用容器来接收动作对象并顺序执行

三、策略模式(strategy pattern)

所谓策略,就是使用不同的方法来解决某个问题。
使用策略模式,可以把方法与代码分离,这样在程序运行时可以动态选择方法来解决问题。

策略模式的主体思想是设计一个策略类体系,然后制作一个“语境”类,这个语境控制具体策略的选择和使用。这个跟人类的思考方式一致,我们都是在某个具体的情况下,根据情况提供的条件做出决策。

看看这个例子:

大名鼎鼎的文明5,回合制策略类游戏的顶峰。
游戏里有一个很好的元素:社会政策。
如何根据具体情况选择合适的社会政策是一个大问题。
对人类玩家这都是一个艰难的选择,对电脑来说更是如此。
这个问题基本上就是策略的同义转换,自然会想到策略模式。

根据策略模式的思路,我们先设计策略类体系。
对于文明5里具体的各种说明就免了,这里仅仅是举例,我们要达到的目的是在几类大情况下选择合适的社会政策。

先看看策略类体系:

有没有似曾相识的感觉?如果有就对了。之前的状态模式、命令模式等等从代码上看其实都差不多。类体系抛开各种功能,骨架都是差不多的。
看看语境类:

也没有什么过多可以解释的。
跟代理模式在代码上看也很类似,只不过这里没有用指针而已。

使用的时候如下:

先建好几个策略,然后放到语境下执行,运行结果:

看到这里你可能觉得策略模式没什么用嘛。
确实,仅就上面展示的代码而言没有太大作用,但是你需要扩展思维。我们之前制定的思路是根据不同语境选择不同策略,这里你应该想到状态模式和策略模式结合使用。
Context这个语境应该是有不同状态的,我们可以在Context类中使用状态模式,而具体选择的策略则又是一个策略类体系。通过设计模式的搭配使用,我们成功实现了状态和策略的解耦,二者可以独立变化又有机融合。

四、职责链模式(chain of responsibility)

但是我们还可以做得更好。
在现实生活中,针对一种情况我们通常不会只准备一个策略,这样风险太大。我们会对若干策略排出一个优先级,然后依次检测,如果优先级高的成功了那么后面的策略就不考虑。如果失败了,就继续检测后面的策略,这个过程一直持续到优先级最低的策略。

上面这种思考模式非常常见,所以专门有一种设计模式来描述:职责链模式。
职责链模式实质上是尝试一系列策略模式。为了让示例更贴近现实,下面我们将结合状态模式和职责链模式来完成文明5社会政策的选择问题。
这个例子会有些复杂,所以先来一张图表示:

从图中可以看出,状态和策略被分成了两部分,这样二者可以独立变化。
对于状态那部分,AI中体现了状态模式。
对于策略部分,ChainChoice中体现了职责链模式。
整个运转通过AI来交互。

先看看策略类体系:

跟上面的策略模式一致。
为什么先有策略类体系呢?因为不同状态需要确定不同策略的优先级,所以没有策略就无法写状态。再来看状态:

大图点这里

跟之前介绍的状态模式相比有两处不同:
第一个是没有使用嵌套类而是在外部构建类体系。
第二个是有返回参数。这个是状态模式经常需要的技巧,因为状态不可能直接访问成员变量,所以只能够依靠返回值来确定行动。
再来看职责链:

还是很简单的,依然是遍历容器,见多识广了。
这里为了简单并没有设计职责链中途退出的情况,这个可以依靠返回true/false来做。
最后是AI,把两套体系衔接起来:

在Think里就是状态模式的体现,只不过这里增加了一个将得到的策略加入职责链的动作。
在main中这样调用:

非常简单,也符合人类认知状态。指定一个状态,然后AI根据这个状态执行不同动作。
运行结果:

成功了!

至此应该回味一下,复盘点评。
我们将几种设计模式混合起来,实现了状态与策略的解耦。
以后我们可以随意增加状态和策略,同时不需要动职责链和AI的代码。


设计模式到这里三大部分已经介绍完毕,如果读者想继续探索OOP,那么可见《OOP若干原则》和《OO建模》。