概述
本章学习状态模式。在实际的开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上看,它有点像我们之前讲到的组合模式。
状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有很多种,除了状态模式,比较常用的还有分支逻辑法和查表法。本章就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。
什么是有限状态机
有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称状态机。状态机有三个组成部分:状态(State)、事件(Event)、动作(Action)。
- 事件也称为转移条件(Transaction Condition)。
- 事件触发状态的转移及动作的执行。
- 不过,动作不是必须得,也可能只转移状态,不执行任何动作。
对于刚刚给出的状态机定义,结合一个具体的例子进行解释。
“超级马里奥” 游戏不知道你玩过没有?在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应地增减积分。比如,初始状态是小马里奥,吃了蘑菇后就变成超级马里奥,并且增加 100 积分。
实际上,马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机种的 “状态”,游戏情节(比如吃了蘑菇)就是状态机种的 “事件”,加减积分就是状态机种的 “动作”。比如,吃蘑菇这个事件,会触发状态的转移(从小马里奥转移到超级马里奥),以及触发动作的执行(增加 100 积分)。
为方便讲解,我对游戏背景做了简化,只保留了部分状态和事件,简化之后的状态转移如下所示:
如何编程来实现上面的状态机呢?
我写了个骨架代码,如下所示。其中,obtainMushRoom()
、obtainCape()
、obtainFileFlower()
、meetMonster()
这个几个函数,能够根据当前的状态和事件,更新状态和增减积分。不过,具体的代码实现暂时没给出。你可以先试着自己补全一下。
public enum State {SMALL(0),SUPER(1),FIRE(2),CAPE(3),;private int value;State(int value) {this.value = value;}public int getValue() {return value;}
}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 score;}public State getState() {return currentState;}
}public class ApplicationDemo {public static void main(String[] args) {MarioStateMachine mario = new MarioStateMachine();mario.obtainMushRoom();int score = mario.getScore();State state = mario.getState();System.out.println("mario score: " + score + "; state: " + state);}
}
状态机实现方式一:分支逻辑法
对于如何实现状态机,有三种方式。其中,最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-esle 或 switch-else 分支判断逻辑,甚至是嵌套的分支判断逻辑,所以,我把这种方法暂且命名为分支逻辑法。
按照这个思路,将上面的骨架代码补全一下。补全之后的代码如下所示:
public class MarioStateMachine {private int score;private State currentState;public MarioStateMachine() {this.score = 0;this.currentState = State.SMALL;}public void obtainMushRoom() {if (currentState == State.SMALL) {this.currentState = State.SUPER;this.score += 100;}}public void obtainCape() {if (currentState == State.SMALL || currentState == State.SUPER) {this.currentState = State.CAPE;this.score += 200;}}public void obtainFireFlower() {if (currentState == State.SMALL || currentState == State.SUPER) {this.currentState = State.FIRE;this.score += 300;}}public void meetMonster() {if (currentState == State.SUPER) {this.currentState = State.SMALL;this.score -= 100;}if (currentState == State.CAPE) {this.currentState = State.SMALL;this.score -= 200;}if (currentState == State.FIRE) {this.currentState = State.SMALL;this.score -= 300;}}public int getScore() {return score;}public State getState() {return currentState;}
}
对于简单的状态机来说,分支逻辑这种实现方式是可以接收的。但是,对于复杂的状态机来说,这种实现方式及其容易漏写或错写某个状态转移。此外,代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。如果哪些修改了状态机种的某个转移,我们要在冗长的分支逻辑中找到对应地代码进行修改,很容易改错,引入 BUG。
状态机实现方式二:查表法
实际上,上面的实现方式有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,看下如何利用查表法来补全骨架代码。
实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示,如下所示。这个二维表中,第一维表示当前的状态,第二维表示当前状态经过事件之后,转移到的新状态及其执行的动作。
E1(Got MushRoom) | E2(Got Cape) | E3(Got Fire Flower) | E4(Meet Monster) | |
---|---|---|---|---|
Small | Super/+100 | Cape/+200 | Fire/+300 | / |
Super | / | Cape/+200 | Fire/+300 | Small/-100 |
Cape | / | / | / | Small/-200 |
Fire | / | / | / | Small/-300 |
注:表中的斜杠表示不存在这种状态转移
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 transactionTable
和 actionTable
两个二维数组即可。实际上,如果我们把二维数组存储在配置文件中,当需要修改状态机时,甚至可以不修改代码,只需要修改配置文件就可以了。具体代码如下所示:
public enum Event {GOT_MUSHROOM(0),GOT_CAPE(1),GOT_FIRE(2),MEET_MONSTER(3),;private int value;Event(int value) {this.value = value;}public int getValue() {return value;}
}public class MarioStateMachine {private int score;private State currentState;private static final State[][] transitionTable = {{SUPER, CAPE, FIRE, SMALL},{SUPER, CAPE, FIRE, SMALL},{CAPE, CAPE, CAPE, SMALL},{FIRE, FIRE, FIRE, SMALL}};private static final 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.MEET_MONSTER);}private void executeEvent(Event event) {int stateValue = currentState.getValue();int eventValue = event.getValue();this.currentState = transitionTable[stateValue][eventValue];this.score += actionTable[stateValue][eventValue];}public int getScore() {return score;}public State getState() {return currentState;}
}
状态机实现方式三:状态模式
在查表法的代码实现中,事件触发的动作只能是简单的积分加减,所以,我们用一个 int 类型的二维数组 actionTable
就能表示,二维数组中的值表示积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。也就是说,查表法的实现方式有一定的局限性。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码的可读性和可维护性不好等。实际上,对于分支逻辑法存在的问题,可以使用状态模式来解决。
状态模式通过将事件触发的状态转移和动作执行,拆分到不同的类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。
其中,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 implements IMario {private MarioStateMachine stateMachine;public SmallMario(MarioStateMachine stateMachine) {this.stateMachine = stateMachine;}@Overridepublic State getName() {return State.SMALL;}@Overridepublic void obtainMushRoom() {stateMachine.setCurrentState(new SuperMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 100);}@Overridepublic void obtainCape() {stateMachine.setCurrentState(new CapeMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 200);}@Overridepublic void obtainFireFlower() {stateMachine.setCurrentState(new FireMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster() {// do nothing...}
}public class SuperMario implements IMario {private MarioStateMachine stateMachine;public SuperMario(MarioStateMachine stateMachine) {this.stateMachine = stateMachine;}@Overridepublic State getName() {return State.SUPER;}@Overridepublic void obtainMushRoom() {// do nothing...}@Overridepublic void obtainCape() {stateMachine.setCurrentState(new CapeMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 200);}@Overridepublic void obtainFireFlower() {stateMachine.setCurrentState(new FireMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster() {stateMachine.setCurrentState(new SmallMario(stateMachine));stateMachine.setScore(stateMachine.getScore() - 100);}
}public class CapeMario implements IMario {private MarioStateMachine stateMachine;public CapeMario(MarioStateMachine stateMachine) {this.stateMachine = stateMachine;}@Overridepublic State getName() {return State.CAPE;}@Overridepublic void obtainMushRoom() {// do nothing...}@Overridepublic void obtainCape() {// do nothing...}@Overridepublic void obtainFireFlower() {stateMachine.setCurrentState(new FireMario(stateMachine));stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster() {stateMachine.setCurrentState(new SmallMario(stateMachine));stateMachine.setScore(stateMachine.getScore() - 200);}
}public class FireMario implements IMario {private MarioStateMachine stateMachine;public FireMario(MarioStateMachine stateMachine) {this.stateMachine = stateMachine;}@Overridepublic State getName() {return State.FIRE;}@Overridepublic void obtainMushRoom() {// do nothing...}@Overridepublic void obtainCape() {// do nothing...}@Overridepublic void obtainFireFlower() {// do nothing...}@Overridepublic void meetMonster() {stateMachine.setCurrentState(new SmallMario(stateMachine));stateMachine.setScore(stateMachine.getScore() - 300);}
}public class MarioStateMachine {private int score;private IMario currentState; // 不在使用枚举表示状态public MarioStateMachine() {this.score = 0;this.currentState = new SmallMario(this);}public void obtainMushRoom() {currentState.obtainMushRoom();}public void obtainCape() {currentState.obtainCape();}public void obtainFireFlower() {currentState.obtainFireFlower();}public void meetMonster() {currentState.meetMonster();}public int getScore() {return score;}public void setScore(int score) {this.score = score;}public void setCurrentState(IMario currentState) {this.currentState = currentState;}public IMario getState() {return currentState;}
}
上面的代码实现不难看懂,只需要注意一点,即 MarioStateMachine
和各个状态类之间是双向依赖关系。 MarioStateMachine
依赖各个类是理所当然的,但是反过来,各个状态类为什么要依赖 MarioStateMachine
呢? 这是因为,各个状态类需要更新 MarioStateMachine
中的属性, score
和 currentState
。
实际上,上面的代码还可以继续优化,可以将状态类设置成单例,比较状态类中不包含任何成员变量。但是,当状态类设计成单例之后,就无法通过构造函数来传递 MarioStateMachine
了,而状态类又要依赖 MarioStateMachine
,那该如何解决呢?
实际上,在《创建型:2.单例模式(中):为什么不推荐使用单例模式?又有何替代方案?》中,提到过集中解决方法,你可以回过头去查看下。在这里,可以通过函数参数将 MarioStateMachine
传递进状态类。根据这个设计思路,对上面的代码进行重构。重构之后的代码如下所示:
public interface IMario {State getName();// 以下是定义的事件void obtainMushRoom(MarioStateMachine stateMachine);void obtainCape(MarioStateMachine stateMachine);void obtainFireFlower(MarioStateMachine stateMachine);void meetMonster(MarioStateMachine stateMachine);
}public class SmallMario implements IMario {private static final SmallMario instance = new SmallMario();private SmallMario() {}public static SmallMario getInstance() {return instance;}@Overridepublic State getName() {return State.SMALL;}@Overridepublic void obtainMushRoom(MarioStateMachine stateMachine) {stateMachine.setCurrentState(SuperMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 100);}@Overridepublic void obtainCape(MarioStateMachine stateMachine) {stateMachine.setCurrentState(CapeMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 200);}@Overridepublic void obtainFireFlower(MarioStateMachine stateMachine) {stateMachine.setCurrentState(FireMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster(MarioStateMachine stateMachine) {// do nothing...}
}public class SuperMario implements IMario {private static final SuperMario instance = new SuperMario();private SuperMario() {}public static SuperMario getInstance() {return instance;}@Overridepublic State getName() {return State.SUPER;}@Overridepublic void obtainMushRoom(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void obtainCape(MarioStateMachine stateMachine) {stateMachine.setCurrentState(CapeMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 200);}@Overridepublic void obtainFireFlower(MarioStateMachine stateMachine) {stateMachine.setCurrentState(FireMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster(MarioStateMachine stateMachine) {stateMachine.setCurrentState(SmallMario.getInstance());stateMachine.setScore(stateMachine.getScore() - 100);}
}public class CapeMario implements IMario {private static final CapeMario instance = new CapeMario();private CapeMario() {}public static CapeMario getInstance() {return instance;}@Overridepublic State getName() {return State.CAPE;}@Overridepublic void obtainMushRoom(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void obtainCape(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void obtainFireFlower(MarioStateMachine stateMachine) {stateMachine.setCurrentState(FireMario.getInstance());stateMachine.setScore(stateMachine.getScore() + 300);}@Overridepublic void meetMonster(MarioStateMachine stateMachine) {stateMachine.setCurrentState(SmallMario.getInstance());stateMachine.setScore(stateMachine.getScore() - 200);}
}public class FireMario implements IMario {private static final FireMario instance = new FireMario();private FireMario() {}public static FireMario getInstance() {return instance;}@Overridepublic State getName() {return State.FIRE;}@Overridepublic void obtainMushRoom(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void obtainCape(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void obtainFireFlower(MarioStateMachine stateMachine) {// do nothing...}@Overridepublic void meetMonster(MarioStateMachine stateMachine) {stateMachine.setCurrentState(SmallMario.getInstance());stateMachine.setScore(stateMachine.getScore() - 300);}
}public class MarioStateMachine {private int score;private IMario currentState; // 不在使用枚举表示状态public MarioStateMachine() {this.score = 0;this.currentState = SmallMario.getInstance();}public void obtainMushRoom() {currentState.obtainMushRoom(this);}public void obtainCape() {currentState.obtainCape(this);}public void obtainFireFlower() {currentState.obtainFireFlower(this);}public void meetMonster() {currentState.meetMonster(this);}public int getScore() {return score;}public void setScore(int score) {this.score = score;}public void setCurrentState(IMario currentState) {this.currentState = currentState;}public IMario getState() {return currentState;}
}
实际上,像游戏这种比较复杂的状态机,包含的状态比较多,优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。
相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以更加推荐使用状态模式来实现。
总结
本章讲解了状态模式。虽然网上有各种各样的状态模式,但是你只要记住状态模式是状态机的一种实现方式即可。
状态机又叫有限状态机,它由3部分组成:状态、事件、动作。
- 其中事件也称为转移条件。
- 事件触发状态的转移及动作的执行。
- 不过动作不是必须的,也可能只转移状态,不执行任何动作。
针对状态机,本章总结了三种实现方式。
- 第一种实现方式叫分支逻辑法。利用 if-else 或 switch-case 分支逻辑,参照状态转移图,将每个状态转移原模原样的直译成代码。对于简单的状态机来说,这种实现方式最简单、最直接,是首选。
- 第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维素组来表示状态转移图,能极大地提高代码的可读性和可维护性。
- 第三张实现方式叫状态模式。对于状态不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,首选这种实现方式。