
面向对象和设计模式(二十一) 状态模式——如何实现有限状态机
01 什么是有限状态机
在介绍状态模式之前,需要介绍“有限状态机”的概念。通俗的来说,如果一个对象拥有不同且有限个数的状态,且在特定的情况下,这些状态可以相互切换,那么我们就可以说这个对象是一个有限状态机。
在具体的业务中,有限状态机非常常见,例如 任务、单据和工作流等对象他们都有未处理、处理中、已完成等状态。
状态机有 3 个组成部分:状态、事件、动作。事件触发会导致状态的转移,而状态的转移会引发动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
反过来,无限状态机就是状态个数无限,例如我们说一个商品的库存,它可以是100个,也可以是200个,也可以是321个,商品的库存数量是无限的,因此库存对象就是一个无限状态机。
举个有限状态机的具体例子:马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire
Mario)、斗篷马里奥(Cape
Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100
积分。

02 状态模式
状态模式是一种实现有限状态机对象的设计模式,用来描述一个状态机对象的事件、状态转化过程以及引发的动作。
它的使用场景就是实现一个对象的状态转变。
实际上,除了状态模式之外,还有分支逻辑法和查表法可以实现状态机。下面,我们分别用分支逻辑法、查表法和状态模式这三种方式来实现上述的马里奥状态变化。
无论是哪种方法,状态机的实现都是以事件作为驱动的,说人话就是对象的方法名应该是一个事件(即图中的线条),而方法内容应该是状态的转移和动作的执行。
因此无论使用哪种实现方法,状态机的实现框架都应该是如下所示的代码:
class MarioStateMachine { // 马里奥状态机 // 省略其他方法和属性
// 获得蘑菇事件 public function obtainMushRoom() { //TODO }
// 获得斗篷事件 public function obtainCape() { //TODO }
// 获得火焰事件 public function obtainFireFlower() { //TODO }
// 遇到怪兽事件 public function meetMonster() { //TODO } }
此外强烈推荐实现状态机之前,先像上面那样画出状态转移图,方便分析。
状态机实现方式一:分支逻辑法
状态机最简单直接的实现方式是,参照状态转移图,将每一个状态转移,直译成代码。这样编写的代码会包含大量的 if-else 或 switch-case
分支判断逻辑,甚至是嵌套的分支判断逻辑,因此称为分支逻辑法。
代码如下所示:
<?php
class MarioStateMachine { const SMALL = 0; const SUPER = 1; const FIRE = 2; const CAPE = 3; private $score; private $currentState; public function __construct(){ $this->score = 0; $this->currentState = self::SMALL; } public function obtainMushRoom() { if ($this->currentState == self::SMALL) { $this->currentState = self::SUPER; $this->score += 100; } } public function obtainCape() { if ($this->currentState == self::SMALL || $this->currentState == self::SUPER ) { $this->currentState = self::CAPE; $this->score += 200; } } public function obtainFireFlower() { if ($this->currentState == self::SMALL || $this->currentState == self::SUPER ) { $this->currentState = self::FIRE; $this->score += 300; } } public function meetMonster() { if ($this->currentState == self::SUPER) { $this->currentState = self::SMALL; $this->score -= 100; return; } if ($this->currentState == self::CAPE) { $this->currentState = self::SMALL; $this->score -= 200; return; } if ($this->currentState == self::FIRE) { $this->currentState = self::SMALL; $this->score -= 300; return; } } public function getScore() { return $this->score; } public function getCurrentState() { return $this->currentState; } }
分支逻辑法可以处理简单的状态转移和动作变更逻辑。但是,对于复杂的状态机来说,这种实现方式极易漏写错写某个状态转移。
除此之外,代码中充斥着大量的 if-else 或者 switch-case
分支判断逻辑,可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移,我们要在冗长的分支逻辑中找到对应的代码进行修改,很容易出错,引入 bug。
状态机实现方式二:查表法
我们知道,状态机有3个维度:状态、动作 与 事件。
查表法本质是将状态、动作 与
事件这三个维度用一个二维表进行映射。在这个二维表中,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。

此时我们需要在代码中维护1个二维数组statusActionMap,存储状态的转移与动作的映射关系即可。
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 statusActionMap 这个二维数组即可。
具体的代码如下所示:
<?php
class MarioStateMachine { // 常量省略
protected static $statusActionMap = [ self::SMALL => [ self::CAPE => 200, self::FIRE => 300, self::SUPER => 100, ], self::SUPER => [ self::CAPE => 200, self::FIRE => 300, self::SMALL => -100, ], self::CAPE => [ self::SMALL => -100, ], self::FIRE => [ self::SMALL => -100, ], ];
private $score; private $currentState; public function __construct(){ $this->score = 0; $this->currentState = self::SMALL; }
public function doEvent($newStatus){ if( !isset(self::$statusActionMap[$this->currentState]) || !isset(self::$statusActionMap[$this->currentState][$newStatus]) ){ return ; }
$this->score += self::$statusActionMap[$this->currentState][$newStatus]; } public function obtainMushRoom() { $this->doEvent(self::SUPER); } public function obtainCape() { $this->doEvent(self::CAPE); } public function obtainFireFlower() { $this->doEvent(self::FIRE); } public function meetMonster() { $this->doEvent(self::SMALL); } }
状态机实现方式三:状态模式
在查表法的代码实现中,事件触发的动作只是简单的积分加减,所以,我们用一个二维数组就能表示动作。
但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作,比如加减积分、写数据库,还有可能发送消息通知等等,我们就没法用如此简单的二维数组来表示了。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。
实际上,针对复杂动作变更,分支逻辑法存在的问题,我们可以使用状态模式来解决。状态模式通过将事件触发和动作执行,以不同状态的维度拆分到不同的状态类中,来避免分支判断逻辑。
我们还是结合代码来理解这句话。利用状态模式,我们来补全 MarioStateMachine 类,补全后的代码如下所示。
其中,IMario 是状态的接口,定义了所有的事件。SmallMario、SuperMario、CapeMario、FireMario 是 IMario
接口的实现类,分别对应状态机中的 4 个状态。
原来所有的状态转移和动作执行的代码逻辑,都集中在 MarioStateMachine 类中,现在,这些代码逻辑被拆分到了这 4 个状态类中。
<?php
interface MarioEvent{ public function obtainMushRoom(); public function obtainCape(); public function obtainFireFlower(); public function meetMonster();}
/** * @property MarioStateMachine $stateMachine */ class Mario implements MarioEvent{ private $score = 0; protected $stateMachine;// 状态机对象来表示马里奥的状态 private function __construct($initState = MarioStateMachine::SMALL) { $this->stateMachine = MarioStateMachine::instance($this, $initState); } public function getState() { return $this->stateMachine->getState(); }
public function getScore() { return $this->score; }
public function addScore($update) { $this->score += $update; }
public function setStateMachine(MarioStateMachine $stateMachine) { $this->stateMachine = $stateMachine; }
public function obtainMushRoom(){ $this->stateMachine->obtainMushRoom(); }
public function obtainCape(){ $this->stateMachine->obtainCape(); }
public function obtainFireFlower(){ $this->stateMachine->obtainFireFlower(); }
public function meetMonster(){ $this->stateMachine->meetMonster(); }
// Mario对象不暴露 setState 这种直接改变状态的方法,因为状态往往和分数等其它属性联动变化 }
// 马里奥状态机 abstract class MarioStateMachine implements MarioEvent{ const SMALL = 0; const SUPER = 1; const FIRE = 2; const CAPE = 3; const STATE_MACHINE_MAP = [ self::SMALL => SmallMario::class, self::SUPER => SuperMario::class, self::FIRE => FireMario::class, self::CAPE => CapeMario::class, ];
protected $mario; // 马里奥对象 protected static $stateMap = []; // 多例模式,存储4种马里奥状态
public static function instance(Mario $mario, $currentState){ if(empty(self::$stateMap[$currentState])){ $stateClass = self::STATE_MACHINE_MAP[$currentState]; self::$stateMap[$currentState] = new $stateClass($mario); } return self::$stateMap[$currentState]; }
private function __construct(Mario $mario){ $this->mario = $mario; }
abstract public function getState(); } class SmallMario extends MarioStateMachine{ public function getState(){ return self::SMALL; } public function obtainMushRoom() { $this->mario->addScore(100); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::SUPER)); }
public function obtainCape() { $this->mario->addScore(200); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::CAPE)); }
public function obtainFireFlower() { $this->mario->addScore(300); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::FIRE)); }
public function meetMonster() { echo “Game over!”; } }
class SuperMario extends MarioStateMachine{ public function getState(){ return self::SUPER; } public function obtainMushRoom() { $this->mario->addScore(100); }
public function obtainCape() { $this->mario->addScore(200); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::CAPE)); }
public function obtainFireFlower() { $this->mario->addScore(300); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::FIRE)); }
public function meetMonster() { $this->mario->addScore(-100); $this->mario->setStateMachine(MarioStateMachine::instance($this->mario, self::SMALL)); } }
// 省略CapeMario、FireMario类…
class ApplicationDemo { public static function main() { $mario = new Mario(); $mario->obtainMushRoom(); $score = $mario->getScore(); $state = $mario->getState(); echo "mario score: " + $score + "; state: " + $state; } }
上面的代码,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对象即可。
<?php
interface MarioEvent{ public function getState(); public function obtainMushRoom(); public function obtainCape(); public function obtainFireFlower(); public function meetMonster();}
class Mario{ const SMALL = 0; const SUPER = 1; const FIRE = 2; const CAPE = 3;
// 省略Mario自身的属性和方法}
abstract class MarioState {
const STATE_MACHINE_MAP = [ self::SMALL => SmallMario::class, self::SUPER => SuperMario::class, self::FIRE => FireMario::class, self::CAPE => CapeMario::class, ]; protected static $stateMap = []; // 多例模式,存储4种马里奥状态
public static function instance(int $state){ if(empty(self::$stateMap[$state])){ $stateClass = self::STATE_MACHINE_MAP[$state]; self::$stateMap[$state] = new $stateClass(); } return self::$stateMap[$state]; }
abstract public function getState();}
class SmallState extends MarioState{ public function getState() { return Mario::SMALL; }
public function obtainMushRoom(MarioStateMachine $stateMachine){ $stateMachine->setState(Mario::SUPER); $stateMachine->setScore($stateMachine->getScore() + 100); }
public function obtainCape(MarioStateMachine $stateMachine){ $stateMachine->setState(Mario::CAPE); $stateMachine->setScore($stateMachine->getScore() + 200); }
public function obtainFireFlower(MarioStateMachine $stateMachine){ $stateMachine->setState(Mario::FIRE); $stateMachine->setScore($stateMachine->getScore() + 300); }
public function meetMonster(MarioStateMachine $stateMachine){ echo “Game Over”; }}
// 省略CapeState、FireState、SuperState类…
/** * 马里奥状态机 * @property MarioState $marioState */class MarioStateMachine implements MarioEvent{ protected $marioState; // 马里奥状态 private $score = 0;
public function __construct(int $initState){ $this->setState($initState); }
public function setState(int $state){ $this->marioState = MarioState::instance($state); }
public function getScore(){ return $this->score; }
public function setScore($score){ $this->score = $score; }
public function getState(){ return $this->marioState->getState(); }
public function obtainMushRoom(){ $this->marioState->obtainMushRoom($this); } public function obtainCape(){ $this->marioState->obtainCape($this); } public function obtainFireFlower(){ $this->marioState->obtainFireFlower($this); } public function meetMonster(){ $this->marioState->meetMonster($this); }}
class ApplicationDemo { public static function main() { $stateMachine = new MarioStateMachine(Mario::SMALL); $stateMachine->obtainMushRoom(); $score = $stateMachine->getScore(); $state = $stateMachine->getState(); echo "mario score: " + $score + "; state: " + $state; }}
这个写法是真正的状态模式的写法和思维,前一种写法不算正规的状态模式,因为将复杂的状态转移行为放到了Mario对象中,与Mario对象耦合了起来,职责不够单一。
这种写法中,State和Machine也是双向依赖,不过Machine对State是强依赖,而State对Machine是一种弱依赖(因为State依赖Machine只是为了改变Machine内部的属性而已,而Machine对State的依赖是依赖State的行为,行为比操作属性更加的重),所以在obtainMushRoom这样的方法中通过传递Machine对象的方式让State对象可以使用到它,而非将Machine对象作为State对象的属性。
最后总结:
1.状态模式会引入非常多的状态类,会导致代码比较难维护,建议只有当三要素中的动作逻辑复杂的时候才使用状态模式。像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现;
2.对于状态比较多而动作逻辑简单的状态机,则优先推荐使用查表法,因为如果在这种情况下使用状态模式会产生很多状态类,而每个状态类中又没有什么实际内容,用状态模式会显得杀鸡用牛刀,使设计模式流于表面。
3.如果状态和动作逻辑都很简单,则使用分支逻辑法即可。