设计模式-17章(状态模式)


第十七章 状态模式

状态模式(State Pattern)是一种行为型设计模式,它允许对象在其内部状态改变时改变其行为。这个模式的核心思想是将对象的状态抽象为独立的类,并且将状态的转换和行为的实现分离。这有助于减少条件语句的数量,使代码更具可维护性和可扩展性。

状态模式通常包括以下几个要素:

  1. Context(上下文):这是包含状态的对象,它会根据当前的状态来执行不同的行为。上下文类通常具有一个状态引用,可以指向当前的状态对象。

  2. State(状态):状态是一个接口或抽象类,定义了一系列具体状态类必须实现的方法。每个具体状态类都代表了上下文的一个特定状态,并实现了这些方法以定义状态下的行为。

  3. Concrete State(具体状态):具体状态类是状态接口的实现,每个具体状态类实现了在特定状态下上下文可以执行的行为。这些类通常会包含一个引用指向上下文对象,以便在需要时切换到其他状态。

  4. Client(客户端):客户端是使用状态模式的代码,它与上下文对象进行交互,但无需知道具体状态类的细节。

下面是一个状态模式的示例,假设我们有一个简单的文档编辑器,它可以处于三种状态:编辑文本、选择文本和插入图片。

// State 接口
interface State {
    void handle();
}

// Concrete States 具体状态类
class EditState implements State {
    @Override
    public void handle() {
        System.out.println("Editor is in Edit mode.");
        // 编辑文本的具体行为
    }
}

class SelectState implements State {
    @Override
    public void handle() {
        System.out.println("Editor is in Select mode.");
        // 选择文本的具体行为
    }
}

class InsertImageState implements State {
    @Override
    public void handle() {
        System.out.println("Editor is in Insert Image mode.");
        // 插入图片的具体行为
    }
}

// Context 上下文类
class TextEditor {
    private State currentState;

    public TextEditor() {
        // 默认状态为编辑文本
        currentState = new EditState();
    }

    public void setState(State state) {
        currentState = state;
    }

    public void performAction() {
        currentState.handle();
    }
}

// 客户端代码
public class Client {
    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        editor.performAction();  // 输出: Editor is in Edit mode.

        editor.setState(new SelectState());
        editor.performAction();  // 输出: Editor is in Select mode.

        editor.setState(new InsertImageState());
        editor.performAction();  // 输出: Editor is in Insert Image mode.
    }
}

在这个示例中,TextEditor 是上下文类,它持有一个状态对象 currentState,根据 currentState 的不同,会执行不同的操作。具体的状态类实现了 State 接口,定义了每个状态下的具体行为。客户端代码通过改变状态来控制编辑器的行为,而无需直接处理条件分支语句,这使得代码更加清晰和易于扩展。


在实际的软件开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上来看,它有点像我们之前讲到的组合模式。

状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有多种,除了状态模式,比较常用的还有分支逻辑法和查表法。今天,我们就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。

1. 什么是有限状态机?

有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称为状态机。状态机有3 个组成部分:状态(State)、事件(Event)、动作(Action)。其中,事件也称为转移条件(Transition Condition)。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。

对于刚刚给出的状态机的定义,我结合一个具体的例子,来进一步解释一下。“超级马里奥”游戏不知道你玩过没有?在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100 积分。

实际上,马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机中的“状态”,游戏情节(比如吃了蘑菇)就是状态机中的“事件”,加减积分就是状态机中的“动作”。比如,吃蘑菇这个事件,会触发状态的转移:从小马里奥转移到超级马里奥,以及触发动作的执行(增加 100 积分)。

为了方便接下来的讲解,我对游戏背景做了简化,只保留了部分状态和事件。简化之后的状态转移如下图所示:

E1:吃了蘑菇

E2:获得斗篷

E3:获得火焰

E4:遇到怪物

我们如何编程来实现上面的状态机呢?换句话说,如何将上面的状态转移图翻译成代码呢?

我写了一个骨架代码,如下所示。其中,obtainMushRoom()obtainCape()obtainFireFlower()meetMonster() 这几个函数,能够根据当前的状态和事件,更新状态和增减积分。不过,具体的代码实现我暂时并没有给出。你可以把它当做面试题,试着补全一下,然后再来看我下面的讲解,这样你的收获会更大。

public enum State {
    SMALL(0),
    SUPER(1),
    FIRE(2),
    CAPE(3);
    private int value;
    
    private State(int value) {
        this.value = value;
    }
    
    public int getValue() {
        return this.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 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();
        System.out.println("mario score: " + score + "; state: " + state);
    }
}

2. 状态机实现方式一:分支逻辑法

对于如何实现状态机,我总结了三种方式。其中,最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-elseswitch-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

3. 状态机实现方式二:查表法

实际上,上面这种实现方法有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,我们就一块儿来看下,如何利用查表法来补全骨架代码。

实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示,如下所示。在这个二维表中,第一维表示当前状态第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。

相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 transitionTable actionTable 两个二维数组即可。实际上,如果我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了。具体的代码如下所示:

public enum Event {
    GOT_MUSHROOM(0),
    GOT_CAPE(1),
    GOT_FIRE(2),
    MET_MONSTER(3);
    private int value;
    
    private Event(int value) {
        this.value = value;
    }
    public int getValue() {
        return this.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.MET_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 this.score;
    }
    public State getCurrentState() {
        return this.currentState;
    }
}

4. 状态机实现方式三:状态模式

在查表法的代码实现中,事件触发的动作只是简单的积分加减,所以,我们用一个 int 类型的二维数组 actionTable 就能表示,二维数组中的值表示积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。这也就是说,查表法的实现方式有一定局限性

虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。实际上,针对分支逻辑法存在的问题,我们可以使用状态模式来解决。

状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。

利用状态模式,我们来补全 MarioStateMachine 类,补全后的代码如下所示。

其中,IMario 是状态的接口,定义了所有的事件。SmallMarioSuperMarioCapeMarioFireMario 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;
    }
    
    @Override
    public State getName() {
        return State.SMALL;
    }
    
    @Override
    public void obtainMushRoom() {
        stateMachine.setCurrentState(new SuperMario(stateMachine));
        stateMachine.setScore(stateMachine.getScore() + 100);
    }
    
    @Override
    public void obtainCape() {
        stateMachine.setCurrentState(new CapeMario(stateMachine));
        stateMachine.setScore(stateMachine.getScore() + 200);
    }
    
    @Override
    public void obtainFireFlower() {
        stateMachine.setCurrentState(new FireMario(stateMachine));
        stateMachine.setScore(stateMachine.getScore() + 300);
    }
    
    @Override
    public void meetMonster() {
        // do nothing...
    }
}
public class SuperMario implements IMario {
    private MarioStateMachine stateMachine;
    
    public SuperMario(MarioStateMachine stateMachine) {
        this.stateMachine = stateMachine;
    }
    
    @Override
    public State getName() {
        return State.SUPER;
    }
    
    @Override
    public void obtainMushRoom() {
        // do nothing...
    }
    
    @Override
    public void obtainCape() {
        stateMachine.setCurrentState(new CapeMario(stateMachine));
        stateMachine.setScore(stateMachine.getScore() + 200);
    }
    
    @Override
    public void obtainFireFlower() {
        stateMachine.setCurrentState(new FireMario(stateMachine));
        stateMachine.setScore(stateMachine.getScore() + 300);
    }
    
    @Override
    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 State getCurrentState() {
        return this.currentState.getName();
    }
    
    public void setScore(int score) {
        this.score = score;
    }
    
    public void setCurrentState(IMario currentState) {
        this.currentState = currentState;
    }
}

上面的代码实现不难看懂,我只强调其中的一点,即 MarioStateMachine 和各个状态类之间是双向依赖关系。MarioStateMachine 依赖各个状态类是理所当然的,但是,反过来,各个状态类为什么要依赖 MarioStateMachine 呢?这是因为,各个状态类需要更新MarioStateMachine 中的两个变量,score currentState


文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录