一、什么是有限状态机
在介绍状态模式之前,需要介绍“有限状态机”的概念。通俗的来说,如果一个对象拥有不同且有限个数的状态,且在特定的情况下,这些状态可以相互切换,那么我们就可以说这个对象是一个有限状态机。
在具体的业务中,有限状态机非常常见,例如 任务、单据和工作流等对象他们都有未处理、处理中、已完成等状态。
状态机有 3 个组成部分:状态、事件、动作。事件触发状态的转移,而状态的转移会引发动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
反过来,无限状态机就是状态个数无限,例如我们说一个商品的库存,它可以是100个,也可以是200个,也可以是321个,商品的库存数量是无限的,因此库存对象就是一个无限状态机。
举个有限状态机的具体例子:马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100 积分。
二、状态模式
状态模式是一种实现有限状态机对象的设计模式,用来描述一个状态机对象的事件、状态转化过程以及引发的动作。
它的使用场景就是实现一个对象的状态转变。
实际上,除了状态模式之外,还有分支逻辑法和查表法可以实现状态机。下面,我们分别用分支逻辑法、查表法和状态模式这三种方式来实现上述的马里奥状态变化。
无论是哪种方法,状态机的实现都是以事件作为驱动的,说人话就是对象的方法名应该是一个事件(即图中的线条),而方法内容应该是状态的转移和动作的执行。
因此无论使用哪种实现方法,状态机的实现框架都应该是如下所示的代码:
此外强烈推荐实现状态机之前,先像上面那样画出状态转移图,方便分析。
状态机实现方式一:分支逻辑法
状态机最简单直接的实现方式是,参照状态转移图,将每一个状态转移,直译成代码。这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑,甚至是嵌套的分支判断逻辑,因此称为分支逻辑法。
代码如下所示:
分支逻辑法可以处理简单的状态转移和动作变更逻辑。但是,对于复杂的状态机来说,这种实现方式极易漏写错写某个状态转移。
除此之外,代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移,我们要在冗长的分支逻辑中找到对应的代码进行修改,很容易出错,引入 bug。
状态机实现方式二:查表法
我们知道,状态机有3个维度:状态、动作 与 事件。
查表法本质是将状态、动作 与 事件这三个维度用一个二维表进行映射。在这个二维表中,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。
此时我们需要在代码中维护1个二维数组statusActionMap,存储状态的转移与动作的映射关系即可。
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 statusActionMap 这个二维数组即可。
具体的代码如下所示:
状态机实现方式三:状态模式
在查表法的代码实现中,事件触发的动作只是简单的积分加减,所以,我们用一个二维数组就能表示动作。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作,比如加减积分、写数据库,还有可能发送消息通知等等,我们就没法用如此简单的二维数组来表示了。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。
实际上,针对复杂动作变更,分支逻辑法存在的问题,我们可以使用状态模式来解决。状态模式通过将事件触发和动作执行,以不同状态的维度拆分到不同的状态类中,来避免分支判断逻辑。
我们还是结合代码来理解这句话。利用状态模式,我们来补全 MarioStateMachine 类,补全后的代码如下所示。其中,IMario 是状态的接口,定义了所有的事件。SmallMario、SuperMario、CapeMario、FireMario 是 IMario 接口的实现类,分别对应状态机中的 4 个状态。
原来所有的状态转移和动作执行的代码逻辑,都集中在 MarioStateMachine 类中,现在,这些代码逻辑被拆分到了这 4 个状态类中。
上面的代码,Mario对象需要重度依赖StateMachine对象,直接将Mario的状态以对象的形式保存。
实际上,我们完全可以将和状态相关的逻辑从Mario对象中抽离出来,Mario对象可以保留整型的state属性,并且有最基础的setState方法。但是和状态转移和落库的相关逻辑可以只交给状态机负责,也就是说状态机和Mario对象可以完全没有关系,不过状态机需要保存和状态相关的其他属性,例如score分数。Mario对象也有score和state,因为这是Mario对象自身的属性,Machine对象也有score和state,因为Machine做状态转移时需要用到。Mario的score、state与Machine的score、state可以不一致,Machine完成状态转移后再刷新Mario对象即可。
这个写法是真正的状态模式的写法和思维,前一种写法不算正规的状态模式,因为将复杂的状态转移行为放到了Mario对象中,与Mario对象耦合了起来,职责不够单一。
这种写法中,State和Machine也是双向依赖,不过Machine对State是强依赖,而State对Machine是一种弱依赖(因为State依赖Machine只是为了改变Machine内部的属性而已,而Machine对State的依赖是依赖State的行为,行为比操作属性更加的重),所以在obtainMushRoom这样的方法中通过传递Machine对象的方式让State对象可以使用到它,而非将Machine对象作为State对象的属性。
最后总结:
状态模式会引入非常多的状态类,会导致代码比较难维护,建议只有当三要素中的动作逻辑复杂的时候才使用状态模式。像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现;
对于状态比较多而动作逻辑简单的状态机,则优先推荐使用查表法,如果这种情况使用状态模式会产生很多状态类,而每个状态类中又没有什么实际内容。
如果状态和动作逻辑都很简单,则使用分支逻辑法即可。