模式开发之旅(23):状态模式
2010-09-13 17:08:46**状态模式:**当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。
在实际的软件开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上来看,它有点组合模式。
状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有多种,除了状态模式,比较常用的还有分支逻辑法和查表法。今天,我们就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。
什么是有限状态机?
有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称为状态机。状态机有 3 个组成部分:状态(State)、事件(Event)、动作(Action)。其中,事件也称为转移条件(Transition Condition)。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
“超级马里奥”游戏不知道你玩过没有?在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。 在不同的游戏情节下,各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100 积分。
实际上,马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机中的“状态”,游戏情节(比如吃了蘑菇)就是状态机中的“事件”,加减积分就是状态机中的“动作”。比如,吃蘑菇这个事件,会触发状态的转移:从小马里奥转移到超级马里奥,以及触发动作的执行(增加 100 积分)。
接下来写一个骨架代码,如下所示。其中,ObtainMushRoom()、ObtainCape()、ObtainFireFlower()、MeetMonster() 这几个方法,能够根据当前的状态和事件,更新状态和增减积分。
public enum State {
SMALL,
SUPER,
FIRE,
CAPE
}
public class MarioStateMachine
{
private int score;
private State currentState;
public MarioStateMachine()
{
this.score = 0;
this.currentState = State.SMALL;
}
public void ObtainMushRoom()
{
//TODO
}
public void ObtainCape()
{
//TODO
}
public void ObtainFireFlower()
{
//TODO
}
public void MeetMonster()
{
//TODO
}
public int GetScore()
{
return this.score;
}
public State GetCurrentState()
{
return this.currentState;
}
}
public class ApplicationDemo
{
public static void main(string[] args)
{
MarioStateMachine mario = new MarioStateMachine();
mario.ObtainMushRoom();
int score = mario.GetScore();
State state = mario.GetCurrentState();
Console.WriteLine("mario score: " + score + "; state: " + state);
}
}
状态机实现方式一:分支逻辑法
对于如何实现状态机,我总结了三种方式。其中,最简单直接的实现方式是,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑,甚至是嵌套的分支判断逻辑,所以,我把这种方法暂且命名为分支逻辑法。
按照这个实现思路,将上面的骨架代码补全一下。补全之后的代码如下所示:
public class MarioStateMachine
{
private int score;
private State currentState;
public MarioStateMachine()
{
this.score = 0;
this.currentState = State.SMALL;
}
public void ObtainMushRoom()
{
if (currentState.Equals(State.SMALL))
{
this.currentState = State.SUPER;
this.score += 100;
}
}
public void ObtainCape()
{
if (currentState.Equals(State.SMALL) || currentState.Equals(State.SUPER))
{
this.currentState = State.CAPE;
this.score += 200;
}
}
public void ObtainFireFlower()
{
if (currentState.Equals(State.SMALL) || currentState.Equals(State.SUPER))
{
this.currentState = State.FIRE;
this.score += 300;
}
}
public void MeetMonster()
{
if (currentState.Equals(State.SUPER))
{
this.currentState = State.SMALL; this.score -= 100; return;
}
if (currentState.Equals(State.CAPE))
{
this.currentState = State.SMALL; this.score -= 200; return;
}
if (currentState.Equals(State.FIRE))
{
this.currentState = State.SMALL; this.score -= 300; return;
}
}
public int GetScore()
{
return this.score;
}
public State GetCurrentState()
{
return this.currentState;
}
}
对于简单的状态机来说,分支逻辑这种实现方式是可以接受的。但是,对于复杂的状态机来说,这种实现方式极易漏写或者错写某个状态转移。除此之外,代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移,我们要在冗长的分支逻辑中找到对应的代码进行修改,很容易改错,引入 bug。
状态机实现方式二:查表法
实际上,上面这种实现方法有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,我们就一块儿来看下,如何利用查表法来补全骨架代码。
状态机还可以用二维表来表示,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 transitionTable 和 actionTable 两个二维数组即可。实际上,如果我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了。具体的代码如下所示:
public enum Event
{
GOT_MUSHROOM,
GOT_CAPE,
GOT_FIRE,
MET_MONSTER
}
public class MarioStateMachine
{
private int score;
private State currentState;
private State[,] transitionTable =
{
{ State.SUPER, State.CAPE, State.FIRE, State.SMALL },
{ State.SUPER, State.CAPE, State.FIRE, State.SMALL },
{ State.CAPE, State.CAPE, State.CAPE, State.SMALL },
{ State.FIRE, State.FIRE, State.FIRE, State.SMALL }
};
private int[,] actionTable =
{
{ +100, +200, +300, +0 },
{ +0, +200, +300, -100 },
{ +0, +0, +0, -200 },
{ +0, +0, +0, -300 }
};
public MarioStateMachine()
{
this.score = 0;
this.currentState = State.SMALL;
}
public void ObtainMushRoom()
{
ExecuteEvent(Event.GOT_MUSHROOM);
}
public void ObtainCape()
{
ExecuteEvent(Event.GOT_CAPE);
}
public void ObtainFireFlower()
{
ExecuteEvent(Event.GOT_FIRE);
}
public void MeetMonster()
{
ExecuteEvent(Event.MET_MONSTER);
}
private void ExecuteEvent(Event @event)
{
int stateValue = (int)currentState;
int eventValue = (int)@event;
this.currentState = transitionTable[stateValue,eventValue];
this.score += actionTable[stateValue,eventValue];
}
public int GetScore()
{
return this.score;
}
public State GetCurrentState()
{
return this.currentState;
}
}
状态机实现方式三:状态模式
在查表法的代码实现中,事件触发的动作只是简单的积分加减,所以,我们用一个 int 类型的二维数组 actionTable 就能表示,二维数组中的值表示积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。这也就是说,查表法的实现方式有一定局限性。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。实际上,针对分支逻辑法存在的问题,我们可以使用状态模式来解决。
状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。
利用状态模式,我们来补全 MarioStateMachine 类,补全后的代码如下所示。
其中,IMario 是状态的接口,定义了所有的事件。SmallMario、SuperMario、CapeMario、FireMario 是 IMario 接口的实现类,分别对应状态机中的 4 个状态。原来所有的状态转移和动作执行的代码逻辑,都集中在 MarioStateMachine 类中,现在,这些代码逻辑被分散到了这 4 个状态类中。
public interface IMario
{
//所有状态类的接口
State GetName();
//以下是定义的事件
void ObtainMushRoom();
void ObtainCape();
void ObtainFireFlower();
void MeetMonster();
}
public class SmallMario : IMario
{
private MarioStateMachine stateMachine;
public SmallMario(MarioStateMachine stateMachine)
{
this.stateMachine = stateMachine;
}
public State GetName()
{
return State.SMALL;
}
public void ObtainMushRoom()
{
stateMachine.SetCurrentState(new SuperMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() + 100);
}
public void ObtainCape()
{
stateMachine.SetCurrentState(new CapeMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() + 200);
}
public void ObtainFireFlower()
{
stateMachine.SetCurrentState(new FireMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() + 300);
}
public void MeetMonster()
{
// do nothing...
}
}
public class SuperMario : IMario
{
private MarioStateMachine stateMachine;
public SuperMario(MarioStateMachine stateMachine)
{
this.stateMachine = stateMachine;
}
public State GetName()
{
return State.SMALL;
}
public void ObtainMushRoom()
{
// do nothing...
}
public void ObtainCape()
{
stateMachine.SetCurrentState(new CapeMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() + 200);
}
public void ObtainFireFlower()
{
stateMachine.SetCurrentState(new FireMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() + 300);
}
public void MeetMonster()
{
stateMachine.SetCurrentState(new SmallMario(stateMachine));
stateMachine.SetScore(stateMachine.GetScore() - 100);
}
}
// 省略CapeMario、FireMario类...
public class MarioStateMachine
{
private int score;
private IMario currentState; // 不再使用枚举来表示状态
public MarioStateMachine()
{
this.score = 0;
this.currentState = new SmallMario(this);
}
public void ObtainMushRoom()
{
this.currentState.ObtainMushRoom();
}
public void ObtainCape()
{
this.currentState.ObtainCape();
}
public void ObtainFireFlower()
{
this.currentState.ObtainFireFlower();
}
public void MeetMonster()
{
this.currentState.MeetMonster();
}
public int GetScore()
{
return this.score;
}
public IMario GetCurrentState()
{
return this.currentState;
}
public void SetScore(int score)
{
this.score = score;
}
public void SetCurrentState(IMario currentState)
{
this.currentState = currentState;
}
}
上面的代码实现不难看懂,只强调其中的一点,即 MarioStateMachine 和各个状态类之间是双向依赖关系。MarioStateMachine 依赖各个状态类是理所当然的,但是,反过来,各个状态类为什么要依赖 MarioStateMachine 呢?这是因为,各个状态类需要更新 MarioStateMachine 中的两个变量,score 和 currentState。
实际上,像游戏这种比较复杂的状态机,包含的状态比较多,我优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现。