临近春节,正好赶上和团队小伙伴review设计,看到大家对状态机情有独钟,有感而发……
状态机本身是一个数字电路领域的词汇,因为嵌入式领域与数字电路设计在早期结合紧密,所以在嵌入式开发领域,尤其是监控领域,也把状态机的设计设计模式引入到软件领域,采用状态机的方式来设计程序,我们经常会看到整个应用在不同的状态之间进行跳转,在不同状态下系统(或子系统、模块)表现的行为不一样。采用状态机的方式解决问题自然而然的成为了嵌入式软件工程师的几乎必备技能,它能帮助设计者把一个复杂的系统在运行时分解为不同的状态模式,进而驱动整个系统循环反复的一直执行下去。
而随着面向对象设计思想的普及,从OO的角度来讲,用状态模式来替换部分情形的状态机实现,能更好的简化整个程序实现以及维护难度,本文试图谈谈个人的理解,与各位读者探讨。
状态机
状态机是一个行为模型,在建模过程中会分析出有限数目状态,因此,有些开发者也会称之为有限状态机(FSM,finite state machine)。
在软件设计领域,因为处理的是非实体输入,通常会有一个一个虚拟的运行环境,也会被称之为虚拟状态机(VFSM)。
对比来看,在硬件设计领域,状态机的输入输出通常为布尔型(二值输入),逻辑条件比较简单。但软件的输入会更加复杂的多,面临的是多值的输入以及复杂的逻辑条件。
状态机是有正式的数学定义的,它包含六个维度:
(S,s_0, I, O, F, G)(S,s0,I,O,F,G), 分别代表有限的状态集S,初始状态s_0s0, 有限的输入集I, 有限的输出集O,状态转移方程F,输出方程G。
从这个严格的数字定义上来看,如果我们采用状态机来设计我们的嵌入式应用的时候,缺少了部分维度,或者维度集合识别不全面,就会导致设计存在隐患。
这里面的s_0s0 非常重要,它告诉我们一个状态机必须有一个初始状态,之后才能根据输入在不同状态之间跳转。
举个例子,如果状态集合S没有识别完全,就会导致状态跳转到未知状态,应用无法完成后续状态转换,如果输入没有识别完全,就会导致我们的设计对未知输入考虑不充分,导致F无法按照实际期望完成我们的需求,如果状态转移方程设计的不正确,也会导致状态紊乱……
另外,可以理解为状态变换就像角色扮演一样:一个普通的不能再普通的彼得·本杰明·帕克,平时正常工作、上下班、谈恋爱,处理的日常输入和你我没什么不同。
但如果突然来了一个输入i_1i1,他突然变身(FF)成为蜘蛛侠,处理输入的方法GG就和我们完全不一样了,状态机其实就是在做这样的事情,让一个人切换不同的状态来表现出不一样的 行为模式。
作为一个机器(machine),状态机能够根据当前处于的状态完成不同的任务:
- 逻辑数据处理:处理输入,产生输出。
- 状态转换处理:根据输入,跳转到新的状态。
状态机的正式定义
状态机从简单到复杂可以分为三个阶段:
- 输出仅和状态有关系,一般称之为摩尔有限状态机(Moore FSM)
- 初始化为状态s_0s0
- 无论输入如何,仅根据当前的状态产生输出,函数形式为:G:S->OG:S−>O,
- 根据不同的输入和当前状态转移到下一个状态,函数形式为:F:(S, I)->SF:(S,I)−>S
- 输出仅和状态以及输入有关系,一般称之为米利有限状态机(Mealy FSM)
- 初始化为状态s_0s0
- 根据当前的状态以及输入产生输出,函数形式为:G:(S,I)->OG:(S,I)−>O,
- 根据不同的输入和当前状态转移到下一个状态,函数形式为:F:(S, I)->SF:(S,I)−>S
- 引入新的维度CC代表上下文,这个时候状态机就变成比较复杂的有限状态机:
- 初始化状态s_0s0,上下文c_0c0
- 根据当前的状态、状态下上文、输入产生输出、及上线文变化:G:(S,C,I)->(C,O)G:(S,C,I)−>(C,O)
- 根据当前的状态、状态上下文、输入转移到下一个状态及上下文,并传递上下文:F:(S,C,I)->(S,C)F:(S,C,I)−>(S,C)
简单示例
一般来讲做状态机分析可以采用一个表格方式或者状态图来实现。我们举个简单的控制灯光的案例来便于理解。
- 一个简单的例子:在我很小的时候,家里采用的灯光开关是拉线的,就是拉一下开灯,再拉一下关灯。这是一个非常简单的摩尔状态机,只要有输入就进行状态转换。
- 一个略微复杂的灯光系统,在酒店的时候经常碰到这样的开关,只要按一下开关按钮,灯就会切换开关状态,并且开关按钮没有状态显示。
- 当按下对应开关的时候,对应的灯会调整状态。
- 当按下总开关的时候,所有的灯会进入关闭状态。
- 接受手机app发送的简单指令:开灯或者关灯
表格分析
当前状态 | 输入 | 输出 | 下一状态 |
---|---|---|---|
ON | 按下信号 | 关灯 | OFF |
ON | APP命令ON | 无动作 | ON |
ON | APP命令OFF | 关灯 | OFF |
ON | 全关 | 关灯 | OFF |
OFF | 按下信号 | 开灯 | ON |
OFF | APP命令ON | 开灯 | ON |
OFF | APP命令OFF | 无动作 | OFF |
OFF | 全关 | 无动作 | OFF |
状态图分析
有时候采用状态图来描述状态转换会更为直观,分析步骤如下:
- 识别出所有的状态集合
- 识别出所有的输入集合
- 识别两个状态之间的转换函数
- 识别出每个状态下不同输入的输出函数
可以画出和下面的示意图,使得其更加直观:
经过这样的分析和设计,我们会吧整个状态转换的过程识别清楚,确保状态转化你的路径不会遗漏,
代码范例
这样我们尝试些个简单的状态机,根据输入的不同命令,来进行状态切换,示例代码如下,限于篇幅的原因,忽略掉一些具体的细节实现(这部分逻辑不复杂,去掉后更容易看清全貌,避免大脑栈溢出:-D),实际上很多代码几乎都是按照这个思路来操作的:
状态模式从上面的代码中我们可以看到,分别针对F,G进行了独立实现,这反映了我们分析和设计过程。但在实际嵌入式开发过程中,F,G关系紧密,为了性能等,需要进行F,G的融合,状态输入处理和状态转移合并为一个接口来实现。
在面向对象设计领域,我比较喜欢用状态模式来取代状态机进行设计。这样有两个好处:
- 简化状态处理过程:在状态机实现中,我们需要维护F,GF,G 两个状态,对输入输出集合也要进行维护,因为它是一个多维度的信息变化,在实际的项目中会非常复杂,耦合性比较高。
- 降低响应变化难度:在设计之初不可能识别出所有的状态,随着项目需求的不断变化,识别出来的状态越多,使得维护整个设计以及实现变得越来越困难,并且违背了开闭原则。这也是状态模式出现的原因。
状态设计模式(State Pattern)的定义
按照设计模式的分类, 状态模式属于一个行为型的设计模式。它支持一个对象随着内部状态的变化改变它的行为,使得看起来就像实例对象本身对应的class改变了一样。
从这个定义来看,虽然对象本身的行为发生了改变,但实际上对外提供的接口契约并没有改变。状态模式试图把对象上下文维护的属性与对应的操作分离开来,把不同状态情况下要做的操作委托(delegate)出去。
UML 图
为了便于理解,我画出一个简单的UML图来描述状态模式。
- 在Context的实例对象context中存储一个到不同状态的引用对象并且把所有具体的状态相关工作委托给它,context通过State接口与具体的状态对象,同时提供一个改变状态的接口用以切换到新的状态。
- State 接口声明了状态相关的方法,这些方法应该仔细分析设计,保证在不同状态下都有意义,避免接口臃肿和存在无意义的方法。
- context和concretestate都应该有能力通过替换状态对象改变系统状态。
模式解析
从状态模式的典型UML来看,调用者Client与上下文模型Context交互,上下文模型一般作为业务领域对外唯一的服务接口隔离了内部的状态变化。Context并未与具体的State类耦合,而是通过聚合识别出的State抽象接口来隔离了这种变化。这样使得Context类不再关心具体的State变化,State的变化通过当前的状态根据具体输入来决定跳转到新的状态,并通知Context来更改state指向的具体实例。
试着想一下,随着系统的变化,有一个新的状态被识别出来的时候,我们只需要实现一个ConcreteStateC,并更改现在State实现类的代码来相应如何跳转到新的状态接口,无需更改Context以及Client的任何代码,非常好的支持了OCP(开闭原则),同时,每个类都仅有一个原因来进行修改(单一职责)。
代码范例
我尝试把上文中我们用状态机来实现的功能重新用状态模式来实现一次。通过构造上下文类,状态接口以及具体的状态类来一步一步的而言是如何用状态类取代状态机的实现。
上下文类的声明
先来看一下上下文头文件的声明,提供了两个接口满足基本的业务需要,同时提供了更改当前状态的接口便于状态的切换。
状态接口以及状态类声明忽略掉构造函数等,我们看到这个类提供的基本的业务处理接口以及通过一个指针来保存当前状态的处理实例。
再来看一下State的相关定义
接下来抽取部分代码,来演示状态类的具体实现,为了减少不必要的篇幅,我仅展示一个状态的实现。状态类的部分实现
实现了两个基本的状态类,我们再放出上下文类Context的实现例子LightingController。可以看到这个类的实现非常简单,所有的状态输入处理都委托给了具体的状态类,上下文类的实现
从上面的整体来回顾,还有一个明显的特征是在采用状态机设计的时候,我们定义了所有的状态(枚举型),但通过状态模式来设计的时候,我们把这部分的内容转移到了具体的状态类中,使得上下文类以及调用者不再对这个内容有感知,把底层实现与上层应用完全的隔离开来,这也是大多数设计模式秉持的设计原则(SOLID)。通过这个实现,如果调用者发送指令到controller,都会转到当前状态的实现中。把可能预见的频发变化隔离开了,使得controller相对稳定,同时相应的跳转到不同状态的职责也从controller中分离出来并转移给具体的状态类。
本文代码采用TDD方式进行实现,附上部分gtest/gmock代码供参考。
Z3:通过这个简单的案例与大家分享了我对状态机以及状态模式的认识,希望与大家共同探讨。限于本文篇幅,我们这里介绍了简单的状态机以及状态模式的实现,在下一篇中,我会展示状态模式如何响应需求的变化。系列文章:设计原则101:从外门弟子到化神
在本文写作过程中参考了公开的相关文章和著作,在此不再一一列出。
感谢樊伟老师帮忙审阅本文。