状态模式
小的以前一直以为就是状态机😇.
实际上感觉像是把一个状态单独进行封装, 这样对于每个状态内部的行为可以比较方便的进行管理.
不使用该模式的朴素写法
(长二级标题,可恶)
假设一个简单的场景 —— PlayerController, 有 Hop, Step, Jump 三种状态(捏他怪捏他《守护甜心》)
那么可以朴素的写成
enum class State
{
Hop,
Step,
Jump
};
class Player
{
public:
void Player::Update(float dt)
{
switch (state_)
{
case State::Hop:
DoHop(dt);
if (input_ == Input::Step)
{
state_ = State::Step;
}
break;
case State::Step:
DoStep(dt);
if (input_ == Input::Jump)
{
state_ = State::Jump;
}
break;
case State::Jump:
UpdateAirMovement(dt);
if (IsGrounded())
{
state_ = State::Step;
}
break;
}
}
private:
State state_ = State::Hop;
};由于 Hop, Step, Jump 之间到底怎么切换比较神秘,于是随便敲点进去(
这样看上去有点💩,因为比如一个 State 中的行为比较复杂,然后我们把行为和转移条件写成面条. 并且更致命的是如果后面加上其他状态,比如 Draw, Stew 等,那么这个 switch 语句就会快速膨胀,让意大利面条愈发意大利面条.
当然,这边只是Update,如果其中还要加上动画切换等系统的参数更新(和当前State设计相关的系统),那么 switch ... 的结构会大量重复,导致代码又臭又长.(不过实际上这边也会分别定义不同的状态机实现就是,蛮说蛮看)
于是为了好看一点,简化一些,同时让每一个 State 比较好维护, 单独把每一个 State 抽象成一个类,就有点像是「状态模式」的写法了
简单例子
定义一个 State 抽象接口(一般会实现Enter, Update, Exit来进行生命周期的定义),让 HopState, JumpState 等都实现它即可,a,可恶,之前状态机一直是这么写的导致自己一直把他们混在一起,可恶.
然后使用一个 Context 来维护一个当前状态对象的实例, 并将与状态相关的操作委托给当前状态对象执行(说起来比较绕) —— 换言之,类似委托,Context(其实实际上可能并不需要专门写一个这样的类出来),记录当前的 State, 然后需要 Update 等之类的操作,委托给自己记录的 State 对象来进行具体实现(包括当前 State 的行为和 Transition)
那么到上面的例子 ——
class State
{
public:
virtual ~State() = default;
virtual void Enter(Player&) {}
virtual void Update(Player& player, float dt) = 0;
virtual void Exit(Player&) {}
};
class Player
{
public:
void Update(float dt)
{
_state->Update(*this, dt);
if (_next_state)
{
ChangeState(std::move(_next_state));
}
}
void RequestStateChange(std::unique_ptr<State> next)
{
_next_state = std::move(next);
}
// Context 提供的能力
void DoHop(float dt);
void DoStep(float dt);
void UpdateAirMovement(float dt);
bool WantStep() const;
bool WantJump() const;
bool IsGrounded() const;
private:
void ChangeState(std::unique_ptr<State> next)
{
if (_state)
{
_state->Exit(*this);
}
_state = std::move(next);
if (_state)
{
_state->Enter(*this);
}
}
private:
std::unique_ptr<State> _state;
std::unique_ptr<State> _next_state;
};然后在具体的 State 中进行接口的实现 ——
class HopState final : public State
{
public:
void Update(Player& player, float dt) override;
};
void HopState::Update(Player& player, float dt)
{
// 当前 State 的行为
player.DoHop(dt);
// 当前 State 的 Transition
if (player.WantStep())
{
player.RequestStateChange(
std::make_unique<StepState>()
);
return;
}
if (player.WantJump())
{
player.RequestStateChange(
std::make_unique<JumpState>()
);
}
}假设我们需要什么 IsOnGround 等“能力” —— 直接从传入的 player 身上调取即可,此时的 player 就相当于 Context
这里没有直接在 HopState::Update() 中替换当前 _state,而是先 Request,等当前 State 的 Update 返回之后再真正切换,避免在成员函数执行期间把当前 State 自己析构掉. 诶 生命周期是这样的
缺点
首先我们必须认识到,这种设计模式并没有减少什么复杂度,我们只是把同样的一个东西换一种组织形式(把意大利面分碗来装) —— 让每个 State 的可读性比较高.不过本身的 Transition 的 Guard/Trigger 等依旧没有改变,该写 If 还得写(
不过这样首先的一个问题就是类会不断变多 —— 随着 State 变多, 我们需要实现接口,导致类的数量膨胀,那么相比一个简单的 enum + switch,拆成一大堆 class 大抵会起反作用罢(
其次,由于 Transition 被分到每一个 State 进行管理了,导致我们查找 Transition 逻辑可能会比较复杂一些
似乎看起来如果我们要添加某个 State, 只需要非常简单的新增加一个 State 的具体实现即可,然后万事大吉 —— 十分的开闭原则优化口牙,但是如果需要对 State Interface 进行修改? 如果某个 Transition 涉及到很多个 State? 那就需要更改比较多的旧代码了(因为每个 Transition 都是由 State 自己管理),很坏
状态机
感觉 State Pattern 可以作为实现 FSM 的一种常见 OOP 组织方式, 而朴素面条代码也是一种实现方式.
可以看看 Boost.Statechart 的实现,不赖,它把 Event / Transition / Guard / Hierarchical State 等状态机概念作为 Framework 实现,确乎不错.
然后这叫做 —— “有限状态机”,诶是图诶?! —— 那么是否可以使用表格来进行组织? —— 答案是显然的,如果我们可以把 Transition 也当成数据,然后维护一张 Transition Table (状态转移表) 来描述状态机,也是一种很有意思的想法,继续挖坑💦
总结
状态模式的本质大抵以 State 作为核心而单独打包,使得业务代码写起来尽可能的简单,然后把一些麻烦事委托给 State 的具体实现罢