原理
状态机有三个组成部分:状态、事件、动作。遇到不同的事件会触发状态的转移和动作的执行,不过动作不是必须的,可能只有状态的转移,没有动作的执行
状态模式的目的就是实现状态机
案例带入
比如"超级马里奥",在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下(遇到不同的事件),各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100 积分。
马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机中的“状态”,游戏情节(比如吃了蘑菇)就是状态机中的“事件”,加减积分就是状态机中的“动作”。比如,吃蘑菇这个事件,会触发状态的转移:从小马里奥转移到超级马里奥,以及触发动作的执行(增加 100 积分)。
为了便于讲解,对游戏背景做了简化,只保留了部分状态和事件。简化之后的状态转移如下图所示
如何实现这个状态转移图呢?如果把这个状态转移翻译成代码呢?我们有以下三种实现方式:
分支逻辑法
查表法
状态模式
分支逻辑法
最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。
这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑
const (
SmallState = iota
SuperState
CapeState
FireState
)
type MarioStateMachine struct {
state int
score int
}
func (m *MarioStateMachine) MeetMushRoom() {
if m.state == SmallState {
m.state = SuperState
m.score += 100
}
}
func (m *MarioStateMachine) MeetCape() {
if m.state == SmallState || m.state == SuperState {
m.state = CapeState
m.score += 200
}
}
func (m *MarioStateMachine) MeetFire() {
if m.state == SmallState || m.state == SuperState {
m.state = FireState
m.score += 300
}
}
func (m *MarioStateMachine) MeetMonster() {
if m.state == SuperState {
m.score -= 100
} else if m.state == CapeState {
m.score -= 200
} else if m.state == FireState {
m.score -= 300
}
m.state = SmallState
}
对于简单的状态机来说,分支逻辑这种实现方式是可以接受的。但是,对于复杂的状态机来说,这种实现方式极易漏写或者错写
某个状态转移
代码中充斥着大量的 if- else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移,我们要在冗长的分支逻辑中找到对应的代码进行修改,很容易改错,引入 bug。
查表法
我们可以维护一个二维表,一维表示当前状态,二维表示事件,值就是当前状态遇到事件后的状态以及执行的动作。
通过二维表表示整个状态转移,代码更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改stateTable 和 actionTable 两个二维数组即可。实际上,如果我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了。
const (
MeetMushRoomEvent = iota
MeetCapeEvent
MeetFireEvent
MeetMonsterEvent
)
type MarioStateMachineV2 struct {
state int
score int
stateTable [][]int //状态二维数组,值是转移后的状态
actionTable [][]int //动作二维数组,值是执行的动作
}
func NewMarioStateMachineV2() *MarioStateMachineV2 {
m := new(MarioStateMachineV2)
m.stateTable = [][]int{
{SuperState, CapeState, FireState, SmallState},
{SuperState, CapeState, FireState, SmallState},
{CapeState, CapeState, CapeState, SmallState},
{FireState, FireState, FireState, SmallState},
}
m.actionTable = [][]int{
{+100, +200, +300, 0},
{0, +200, +300, -100},
{0, 0, 0, -200},
{0, 0, 0, -300},
}
return m
}
func (m *MarioStateMachineV2) MeetMushRoom() {
m.meetEvent(MeetMushRoomEvent)
}
func (m *MarioStateMachineV2) MeetCape() {
m.meetEvent(MeetCapeEvent)
}
func (m *MarioStateMachineV2) MeetFire() {
m.meetEvent(MeetFireEvent)
}
func (m *MarioStateMachineV2) MeetMonster() {
m.meetEvent(MeetMonsterEvent)
}
func (m *MarioStateMachineV2) meetEvent(event int) {
m.state = m.stateTable[m.state][event]
m.score += m.actionTable[m.state][event]
}
状态模式
查表法的局限性在于只能表示简单的动作,例如积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等,那么既想要清晰的表达状态转移,又想支持复杂的操作,我们就可以采用状态模式
状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑
type MarioStateMachineV3 struct {
marioState IMarioState
state int
score int
}
type IMarioState interface {
MeetRushRoom()
MeetCape()
MeetFire()
MeetMonster()
}
type ParentMarioState struct {
stateChine *MarioStateMachineV3
}
func (s *ParentMarioState) MeetRushRoom() {}
func (s *ParentMarioState) MeetCape() {}
func (s *ParentMarioState) MeetFire() {}
func (s *ParentMarioState) MeetMonster() {}
type SmallMarioState struct {
ParentMarioState
}
func (s *SmallMarioState) MeetRushRoom() {
s.stateChine.state = SuperState
s.stateChine.score += 100
}
func (s *SmallMarioState) MeetCape() {
s.stateChine.state = CapeState
s.stateChine.score += 200
}
func (s *SmallMarioState) MeetFire() {
s.stateChine.state = FireState
s.stateChine.score += 300
}
type SuperMarioState struct {
ParentMarioState
}
func (s *SuperMarioState) MeetCape() {
s.stateChine.state = CapeState
s.stateChine.score += 200
}
func (s *SuperMarioState) MeetFire() {
s.stateChine.state = FireState
s.stateChine.score += 300
}
func (s *SuperMarioState) MeetMonster() {
s.stateChine.state = SmallState
s.stateChine.score -= 100
}
type CapeMarioState struct {
ParentMarioState
}
func (s *CapeMarioState) MeetMonster() {
s.stateChine.state = SmallState
s.stateChine.score -= 200
}
type FireMarioState struct {
ParentMarioState
}
func (s *FireMarioState) MeetMonster() {
s.stateChine.state = SmallState
s.stateChine.score -= 300
}
状态模式的缺点:需要维护多个状态类,类的个数会比较多,可读性会下降
总结
三种实现方式的选择:
- 如果状态转移不复杂,并且不存在扩展的情况,那么可以使用分值逻辑法
- 如果事件触发的动作很简单,那么可以使用查表法
- 如果状态转移复杂并且事件触发的动作也比较复杂,那么可以使用状态模式