如何实现一个状态机?

三味码屋 2022年03月28日 2,532次浏览

何为状态机?

从字面上简单粗暴地理解,状态机是一个跟状态有关的机器,但其实状态机并不是一种物理机器,而是一种模型,一种表达事物状态及状态变化过程的数学模型。
状态机全称是有限状态机(finite-state machine,缩写:FSM)或者有限状态自动机(finite-state automaton,缩写:FSA),是自动机理论的研究对象。状态机拥有有限数量的状态,每个状态可以迁移到零个或多个其它状态,状态机的状态及迁移过程可以用有向图来表示。

状态机用来干啥?

上面介绍了状态机的概念,很多同学可能会说:既然状态机是数学领域中的理论,而我是程序员,这跟我有什么关系呢?确实,状态机是属于数学理论,要深入研究需要掌握离散数学等专业知识,但这并不意味着计算机领域不会用到,毕竟计算机科学中太多东西都是以数学作为基石的。
在计算机科学中,或者干脆把范围直接缩小到我们程序员的日常开发中,我们或多或少都会接触到状态机,例如Android 的MediaPlayerMediaCodec,其实现框架里面就包含了大量的状态管理,iOS的GKState也使用了状态机来管理多种状态。
其实,在软件开发里面,我们更多地是结合自动机理论和软件设计思想来设计编程模式,以此构建出更加优秀的软件。GoF 23种软件设计模式中的状态模式就是一种基于状态的设计模式。

状态机的元素

状态机中包含哪些元素呢?一般来讲,一个状态机包含如下元素:

  • 状态
    即状态机中包含的有限个数的状态。
  • 行为
    即状态对应的一系列行为表现。
  • 事件
    即触发状态发生改变的事件。
  • 转换
    即状态改变的过程。

例如:我们每天经历的白天夜晚可以看做是一个状态机,早晨太阳从东边升起,我们迎来了美好的白天,白天我们会吃饭、上班、运动,等到傍晚太阳从西边落下的时候,我们便进入了静谧的夜晚,晚上我们会看电视、学习、睡觉,如果用一个有向图来表示这个过程,大概会是这样:

状态机_白天夜晚

在白天夜晚状态机里面,白天和黑夜属于状态,白天吃饭上班运动、夜晚看电视学习睡觉是状态对应的行为表现,日出和日落是触发状态转换的事件,夜晚经过日出转换为白天、白天经过日落转换为夜晚表示状态转换的过程。

状态模式

状态设计模式是GoF提出的23种设计模式之一,可以看做是一种基于状态机的设计模式,在状态设计模式里面,包含了与状态机对应的各项元素,即:状态、行为、事件、转换。设计模式和具体的编程语言无关,因此状态设计模式也可以用多种语言来实现。

状态模式的适用场景

我们在什么情况下需要使用状态模式呢?一般来讲,我们在编码的时候,如果发现对象在不同场景或不同阶段会表现出不同的行为,而且行为控制逻辑比较复杂、容易混乱的时候,我们就可以考虑使用状态模式。在状态模式里面,我们可以根据业务逻辑为对象划分出有限个数的状态,每个状态内部都封装好对应的行为,要改变对象的行为,只需要简单地改变对象的状态即可,我们可以“面向状态编程”了!这样原本复杂的糅杂在一起的逻辑,就一下变得清晰明了了。

通过状态模式实现状态机

接下来,我们将通过一个完整的示例,来演示如何通过状态设计模式来实现一个状态机。在示例里面,我们会实现上面的白天夜晚状态机,鉴于面向对象的思想能够清晰地表达状态机中的各种元素,因此我们选用当下比较流行的Kotlin作为编码语言。

定义状态及行为

首先,我们来定义状态。白天夜晚状态机包含白天和夜晚两个状态,两个状态都会表现出对应的行为,但是各自的行为是不一样的,因此,可以通过接口+实现类的方式来定义状态。这里我们抽象出了一个状态接口IState,并在IState中声明了表达状态行为的run()方法,然后实现了IState的3个子类IdleStateDayStateNightState,分别表示空闲状态、白天状态和夜晚状态,其中,IdleState仅作为状态机的起始状态,在示例里面没有体现太多实际意义,DayStateNightState在实现run()方法时,通过输出一段日志来表示状态执行的具体行为。
IState接口:

/**
 * 状态接口
 */
interface IState {

    /**
     * 状态要执行的行为
     */
    fun run()
}

DayState白天状态类:

/**
 * 白天状态
 */
class DayState : IState {

    init {
        run()
    }

    override fun run() {
        println("进入白天,吃饭、上班、运动!")
    }
}

NightState夜晚状态类:

/**
 * 夜晚状态
 */
class NightState : IState {

    init {
        run()
    }

    override fun run() {
        println("进入夜晚,看电视、学习、睡觉!")
    }
}
定义事件

然后我们来定义状态机的事件。在白天夜晚状态机中,白天状态经过日落转为夜晚状态,夜晚状态经过日出转为白天状态,因此,状态机中包含两个事件,即日出和日落。
事件:

/**
 * 事件-日出
 */
const val STATE_EVENT_SUNRISE = "sunrise"

/**
 * 事件-日落
 */
const val STATE_EVENT_SUNSET = "sunset"
状态转换

然后,我们来实现状态的转换。为了集中处理状态的转换,我们决定封装一个专门的类StateManager来进行管理。首先,我们抽象出StateManager的父接口IStateManager,用以声明StateManager中需要实现的各个属性及方法。
状态管理接口IStateManager

/**
 * 状态管理接口
 */
interface IStateManager {

    /**
     * 当前状态
     */
    val state: IState

    /**
     * 根据事件转换状态
     *
     * @param event 事件
     */
    fun transitionState(event: String)
}

IStateManager中声明了表示当前状态的变量state,同时声明了transitionState(event: String)方法用来状态转换。
状态管理类StateManager

/**
 * 状态管理类
 */
class StateManager : IStateManager {

    override var state: IState = IdleState()

    override fun transitionState(event: String) {
        state = when (event) {
            STATE_EVENT_SUNRISE -> DayState()
            STATE_EVENT_SUNSET -> NightState()
            else -> IdleState()
        }
    }
}

至此,白天夜晚状态机需要的状态、行为、事件、转换四个元素就已经备齐了,接下来我们可以运行状态机了。

运行状态机

我们通过模拟白天夜晚变化的情境,来运行状态机。我们通过定时任务模拟了一天当中从0点到次日0点之间24小时的变化,定时任务中1秒表示现实中的1个小时,6点日出时将状态机的当前状态转换为白天状态,18点日落时将状态机的当前状态转换为夜晚状态。
模拟情境StatePatternSceneSimulator

/**
 * 状态模式场景模拟器
 *
 * 通过定时任务模拟一天24小时变化,1秒表示1小时,6点日出,转换为白天状态,18点日落,转换为夜晚状态
 */
class StatePatternSceneSimulator : ISceneSimulator {

    /**
     * 状态管理接口实例
     */
    private val stateManager: IStateManager by lazy { StateManager() }

    /**
     * 当前时间,即几点
     */
    private var time: Int = 0

    override fun run() {
        val countDownLatch = CountDownLatch(240)
        val timer = Timer()
        timer.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                println("现在是 $time 点")
                if (time == 6) {
                    // 6点日出,转换为白天状态
                    stateManager.transitionState(STATE_EVENT_SUNRISE)
                } else if (time == 18) {
                    // 18点日落,转换为夜晚状态
                    stateManager.transitionState(STATE_EVENT_SUNSET)
                }
                if (time < 23) {
                    time++
                } else {
                    time = 0
                }
                countDownLatch.countDown()
            }
        }, 0, 1000)
        countDownLatch.await()
    }

    companion object {

        /**
         * 运行场景
         */
        fun run() {
            StatePatternSceneSimulator().run()
        }
    }
}

接下来,我们在测试代码中,调用StatePatternSceneSimulator来运行模拟情境。

/**
 * 状态模式示例
 */
class Main {

    /**
     * 演示状态模式
     */
    @Test
    fun main() {
        StatePatternSceneSimulator.run()
    }
}

执行main()函数之后,控制台将会输出如下日志:

现在是 0 点
现在是 1 点
现在是 2 点
现在是 3 点
现在是 4 点
现在是 5 点
现在是 6 点
进入白天,吃饭、上班、运动!
现在是 7 点
现在是 8 点
现在是 9 点
现在是 10 点
现在是 11 点
现在是 12 点
现在是 13 点
现在是 14 点
现在是 15 点
现在是 16 点
现在是 17 点
现在是 18 点
进入夜晚,看电视、学习、睡觉!
现在是 19 点
现在是 20 点
现在是 21 点
现在是 22 点
现在是 23 点
现在是 0 点

通过日志,我们可以看到随着时间的变化,状态机的状态在白天和夜晚两个状态中来回转换。至此,我们便通过状态设计模式实现了白天夜晚状态机!

状态机的实际应用

示例中的白天夜晚状态机,只是一个最简单的状态机,在实际开发中,我们遇到的业务场景会比这个复杂得多,如果要通过状态机来实现这些复杂业务,状态机的设计本身也会变得更加复杂,我们可以通过多种形式对简单的状态机进行拓展,来解决更加复杂的问题场景。

分层状态机

所谓分层状态机,是指状态可以像类的继承那样,自上而下包含多个层级。例如在白天夜晚状态机里面,白天状态包含吃饭、上班、运动等行为,起初这些行为可通过简单的代码进行描述,吃饭就是“吃饭”,上班就是“上班”,运动就是“运动”,但是随着业务的深入,逻辑会变得越来越复杂,吃饭不再是简单地描述为“吃饭”,而是需要描述清楚“吃的什么菜,吃了多少,和谁一起吃的”,上班不再是简单地描述为“上班”,而是要描述清楚“上班干了些什么,有没有会议,是正常上班还是加班”,运动也不再是简单地描述为“运动”,而是要描述清楚“做的那种类型的运动,运动时长是多少,消耗了多少热量”,试想一下,如果把这些逻辑继续放在白天状态里面,那么白天状态的逻辑会变得越来越复杂、越来越臃肿,甚至混乱出错,此时,我们可以考虑将白天状态进一步拆分,我们可以根据不同的行为,将白天状态拆分为吃饭状态、上班状态、运动状态等子状态,每一种子状态各自管理自己的业务,这样拆分之后,白天状态臃肿的逻辑被划分到了每个子状态中,一下子就变得清爽干净了!

并发状态机

所谓并发状态机,是指不止存在一种状态机,而是多种状态机并存。例如代码里面既有维护日夜交替的白天夜晚状态机,又有维护四季变迁的春夏秋冬状态机,两种状态机包含不同的状态以及状态转换逻辑,相互独立、互不干涉,但也不排除在某些情况下,状态机之间会进行交互,例如夏天的夜晚看星星、冬天的白天堆雪人等等。

下推自动机

所谓下推自动机,是指通过在状态机内部维护一个存储状态的栈来记录状态入栈和出栈的顺序,状态完成转换后,新的状态被压入栈中,位于栈顶,前一个状态并没有被新的状态直接覆盖,而是在栈中位于新状态的下面。在某些场景下,如果我们需要将当前状态恢复为之前的状态,那么我们就可以将栈顶的状态弹出,此时前一个状态又回到了栈顶的位置,我们拿到栈顶的状态也就是前一个状态后,将当前状态设置为前一个状态,便完成了状态的恢复。

以上便是几种常见的状态机拓展应用,当然,对状态机的拓展远不止于此,我们可以根据具体业务需求,结合面向对象封装、继承、多态的思想以及各种数据结构等,实现相应的拓展。

源码

GitHub项目源码

参考资料

1.https://zh.wikipedia.org/wiki/%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E6%9C%BA
2.https://zh.wikipedia.org/wiki/%E8%87%AA%E5%8A%A8%E6%9C%BA%E7%BC%96%E7%A8%8B
3.https://zh.wikipedia.org/wiki/%E8%87%AA%E5%8B%95%E6%A9%9F
4.https://baike.baidu.com/item/%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E8%87%AA%E5%8A%A8%E6%9C%BA/2850046?fromtitle=%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E6%9C%BA&fromid=2081914&fr=aladdin
5.https://zhuanlan.zhihu.com/p/74984237