有限状态机
有限状态机(Finite State Machine) 缩写为 FSM。以下简称为状态机。
状态机有 3 个组成部分:状态、事件、动作。
- 状态:所有可能存在的状态。包括当前状态和条件满足后要迁移的状态。
- 事件:也称为转移条件,当一个条件被满足,将会触发一个动作,或者执行一次状态的迁移。
- 动作:条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是* 必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
状态机的表示
状态转移图
我们举个例子,马里奥共有三种状态,可执行三个动作,如图中所示。
从图中可以看到,
- 在Small Mario状态下,A3,没有触发状态转移,也没有触发任何动作。
- 在Fire Mario状态下,A3,没有触发状态转移,触发了发射火球动作。
- 在Super Mario状态下,A2,触发状态转移到Small Mario,没有触发任何动作。
- 在Small Mario状态下,A1,触发状态转移到Super Mario,触发增加1000积分。
可见:动作的执行,和状态的转移,是相对独立的。并不是必须有状态转移,或必须触发动作。状态转移和动作之间也没有必然联系。
二维表
第一维表示当前状态,第二维表示事件,表格内容表示当前状态经过事件之后,转移到的新状态 或 要执行的动作,理论上状态转移和执行的动作可以是两个独立的表格。
合并后的:
吃蘑菇 | 碰到怪物 | 攻击 | |
---|---|---|---|
Small Mario | Super Mario/+1000 | Dead/- | -/- |
Super Mario | Fire Mario/+1000 | Small Mario/- | -/- |
Fire Mario | -/+1000 | Small Mario/- | -/发射火球 |
Dead Mario | -/- | -/- | -/- |
状态转移表:
吃蘑菇 | 碰到怪物 | 攻击 | |
---|---|---|---|
Small Mario | Super Mario | Dead | - |
Super Mario | Fire Mario | Small Mario | - |
Fire Mario | - | Small Mario | - |
Dead Mario | - | - | - |
动作表:
吃蘑菇 | 碰到怪物 | 攻击 | |
---|---|---|---|
Small Mario | +1000 | - | - |
Super Mario | +1000 | - | - |
Fire Mario | +1000 | - | 发射火球 |
Dead Mario | - | - | - |
实现
定义State枚举,定义四种状态。
定义三个函数,对应三个事件。我们首先给出简单的测试程序。三个函数有待实现。
typedef enum State{
DEAD = 0,
SMALL,
SUPER,
FIRE
}State;
class Mario{
private:
State state; //状态
int score; //积分
public:
Mario():state(SMALL), score(0){}
//吃蘑菇
void eatMushRooms(){
//todo something
}
//碰到怪物
void contactMonsters(){
//todo something
}
//攻击
void attack(){
//todo something
}
};
int main(int argc, char *argv[]){
Mario m;
m.eatMushRooms();//吃蘑菇
m.eatMushRooms();//吃蘑菇
m.eatMushRooms();//吃蘑菇
m.attack();//发射火球
m.contactMonsters();//变小
m.attack();//nothing
m.contactMonsters();//died
return 0;
}
穷举法
参照状态转移图,代码直译的方式,用if-else或switch-case,简单堆砌完成。穷举所有状态下发声任何事件的所有可能。
class Mario{
private:
State state; //状态
int score; //积分
void printState(){
switch(state){
case DEAD:
cout << "died!" << endl;
break;
case SMALL:
cout << "small!" << endl;
break;
case SUPER:
cout << "super!" << endl;
break;
case FIRE:
cout << "fire!" << endl;
break;
default:
break;
}
cout << "score is " << score << endl;
}
public:
Mario():state(SMALL), score(0){}
//吃蘑菇
void eatMushRooms(){
cout << "eatMushRooms begin:" << endl;
switch(state){
case DEAD:
break;
case SMALL:
state = SUPER;
score += 1000;
printState();
break;
case SUPER:
state = FIRE;
score += 1000;
printState();
break;
case FIRE:
score += 1000;
printState();
break;
default:
break;
}
cout << endl;
}
//碰到怪物
void contactMonsters(){
cout << "contactMonsters begin:" << endl;
switch(state){
case DEAD:
break;
case SMALL:
state = DEAD;
printState();
break;
case SUPER:
state = SMALL;
printState();
break;
case FIRE:
state = SMALL;
printState();
break;
default:
break;
}
cout << endl;
}
//攻击
void attack(){
cout << "attack begin:" << endl;
switch(state){
case DEAD:
cout << "do nothing!" << endl;
break;
case SMALL:
cout << "do nothing!" << endl;
break;
case SUPER:
cout << "do nothing!" << endl;
break;
case FIRE:
cout << "shoot firebool!" << endl;
break;
default:
break;
}
cout << endl;
}
};
eatMushRooms begin:
super!
score is 1000
eatMushRooms begin:
fire!
score is 2000
eatMushRooms begin:
fire!
score is 3000
attack begin:
shoot firebool!
contactMonsters begin:
small!
score is 3000
attack begin:
do nothing!
contactMonsters begin:
died!
score is 3000
对于简单的分支逻辑,这种方式是可以接受的。对于复杂的状态机来说,容易漏写或者错写状态转移。
目前四种状态,三种事件,代码中就已经充斥着大量的case语句。粗略估计要处理的分支数量大概是3*4。如果是10种状态,10种事件呢,就需要有100个分支需要处理,可读性和可维护性就会变得很差,修改也更容易出错。
查表法
结合二维表表示,查表法的代码更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 状态转移表 和 动作表 即可。
而且我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了。
需要注意的是,C++中,我们使用状态枚举编号和动作编号来定位数组对应的值。
State transitionTable[4][3] = {
/*A1 A2 A3*/
{DEAD, DEAD, DEAD}, //DEAD
{SUPER, DEAD, SMALL}, //SMALL
{FIRE, SMALL, SUPER}, //SUPER
{FIRE, SMALL, FIRE} //FIRE
};
int actionTable[4][3] = {
/*A1 A2 A3*/
{0, 0, 0}, //DEAD
{1000, 0, 0}, //SMALL
{1000, 0, 0}, //SUPER
{1000, 0, 0} //FIRE
};
bool actionFireboolTable[4][3] = {
/*A1 A2 A3*/
{false, false, false}, //DEAD
{false, false, false}, //SMALL
{false, false, false}, //SUPER
{false, false, true}, //FIRE
};
class Mario{
private:
State state; //状态
int score; //积分
void printState(){
switch(state){
case DEAD:
cout << "died!" << endl;
break;
case SMALL:
cout << "small!" << endl;
break;
case SUPER:
cout << "super!" << endl;
break;
case FIRE:
cout << "fire!" << endl;
break;
default:
break;
}
cout << "score is " << score << endl;
cout << endl;
}
void action(int index){
if(index == 2){
if(actionFireboolTable[state][index]){
cout << "shoot firebool!" << endl;
}else{
cout << "do nothing!" << endl;
}
}
score += actionTable[state][index];
}
public:
Mario():state(SMALL), score(0){
}
//吃蘑菇
void eatMushRooms(){
cout << "eatMushRooms begin:" << endl;
action(0);
state = transitionTable[state][0];
printState();
}
//碰到怪物
void contactMonsters(){
cout << "contactMonsters begin:" << endl;
action(1);
state = transitionTable[state][1];
printState();
}
//攻击
void attack(){
cout << "attack begin:" << endl;
action(2);
state = transitionTable[state][2];
printState();
}
};
eatMushRooms begin:
super!
score is 1000
eatMushRooms begin:
fire!
score is 2000
eatMushRooms begin:
fire!
score is 3000
attack begin:
shoot firebool!
fire!
score is 3000
contactMonsters begin:
small!
score is 3000
attack begin:
do nothing!
small!
score is 3000
contactMonsters begin:
died!
score is 3000
查表法明显在函数实现部分比较简洁。数组表格和状态、动作的对应顺序,需要对照二维表完成。如本例中,如果是简单的积分加减,不需要引入actionFireboolTable,代码就会更加简洁。对于更复杂的动作,我们可能要引入更多地表格和判断。这里就是查表法的局限,查表法仅能处理简单的 动作表。
当各个动作需要执行很多差异化很明显的代码(代码间难有共性,无法提炼)时。表格法的 动作表或者动作部分 就会变得复杂。
状态模式
状态模式通过将事件触发的状态转移和动作执行逻辑,分别放到不同的状态类中。转移到何种状态和执行何种动作,统一交个具体的状态类来处理。
定义状态类接口:
#include <string>
//所有状态类的接口
class IMarioState{
public:
IMarioState(){}
virtual ~IMarioState(){}
//输出状态名称
virtual std::string getName() = 0;
//吃蘑菇
virtual void eatMushRooms() = 0;
//碰到怪物
virtual void contactMonsters() = 0;
//攻击
virtual void attack() = 0;
};
状态机(Mario)依赖状态类接口(IMarioState)。
//mario.h
//Mario状态机,状态机将具体的事件响应交给IMarioState来完成
class Mario{
IMarioState *state; //状态
int score; //积分
public:
Mario();
~Mario();
void setCurrentState(IMarioState *st);
void addScore(int s);
void shootFirebool();
void printState();
//吃蘑菇
void eatMushRooms();
//碰到怪物
void contactMonsters();
//攻击
void attack();
};
//mario.cpp
Mario::Mario():state(NULL), score(0){
state = new SmallMario(this);
}
Mario::~Mario(){
if(this->state != NULL)
delete this->state;
}
void Mario::setCurrentState(IMarioState *st){
if(this->state != NULL)
delete this->state;
this->state = st;
}
void Mario::addScore(int s){
score += s;
}
void Mario::shootFirebool(){
cout << "shoot firebool!" << endl;
}
void Mario::printState(){
cout << state->getName() << endl;
cout << "score is " << score << endl;
cout << endl;
}
//吃蘑菇
void Mario::eatMushRooms(){
cout << "eatMushRooms begin:" << endl;
state->eatMushRooms();
printState();
}
//碰到怪物
void Mario::contactMonsters(){
cout << "contactMonsters begin:" << endl;
state->contactMonsters();
printState();
}
//攻击
void Mario::attack(){
cout << "attack begin:" << endl;
state->attack();
printState();
}
具体的状态类(SmallMario、SurperMario、FireMario、DeadMario)同样依赖状态机(Mario),具体状态类的某些行动,需要通过调用Mario来实现。以SmallMario为例。
//smallMario.h
class SmallMario: public IMarioState{
private:
Mario *mario;
public:
SmallMario(Mario* mario);
virtual std::string getName() override;
virtual void eatMushRooms() override;
virtual void contactMonsters() override;
virtual void attack() override;
};
//smallMario.cpp
#include "smallMario.h"
#include "superMario.h"
#include "deadMario.h"
SmallMario::SmallMario(Mario* mario):mario(mario){}
std::string SmallMario::getName() {
return "small!";
}
void SmallMario::eatMushRooms() {
mario->setCurrentState(new SuperMario(mario));
mario->addScore(1000);
}
void SmallMario::contactMonsters() {
mario->setCurrentState(new DeadMario(mario));
}
void SmallMario::attack() {
}
测试代码:
```c++
#include <iostream>
#include "mario.h"
int main(int argc, char *argv[]){
Mario m;
m.eatMushRooms();//吃蘑菇
m.eatMushRooms();//吃蘑菇
m.eatMushRooms();//吃蘑菇
m.attack();//发射火球
m.contactMonsters();//变小
m.attack();//nothing
m.contactMonsters();//died
return 0;
}
结果:
eatMushRooms begin:
super!
score is 1000
eatMushRooms begin:
fire!
score is 2000
eatMushRooms begin:
fire!
score is 3000
attack begin:
shoot firebool!
fire!
score is 3000
contactMonsters begin:
small!
score is 3000
attack begin:
small!
score is 3000
contactMonsters begin:
dead!
score is 3000
如果状态比较多,状态模式会引入非常多的状态类。
当然,状态模式有很多的变种,比如各个状态不需要每次new出来,可以采用单例模式等。这里就不深入讨论了。
总结
- 穷举法
优点:简单直接。
缺点:代码容易冗长,导致可读性和可维护性都很差。 - 查表法
优点:清晰,可读性和可维护性更好。
缺点:只适合执行的动作及其简单的情况。 - 状态模式:
优点:通过拆分,避免了大量if else或者switch case。
缺点:状态过多,状态类数量爆炸。阅读代码时需要打开各个状态类文件。
到这里,做了一个表格。如有错误,还请多指正。状态多暗含了状态转移复杂,动作多暗含了动作逻辑复杂。
动作少 | 动作多 | |
---|---|---|
状态多 | 查表法 | 状态模式 |
状态少 | 穷举法 | 状态模式 |