一个数据和行为分离的下推自动机的开发笔记

本文是最近写的一个简单的 C++ 下推自动机 pdfsm.h 的开发笔记。

出发点

  1. 可以用在一个 tick 主循环之中。
  2. 数据和行为分离

关于第二点,主要考虑下面的场景:

  1. 如果我们存在大量的实体、每个实体有自身的状态。
  2. 同一类的实体有同一套行为。

    比如游戏中的士兵可能会有许多个实例,但是它们的状态和行为是同一套逻辑。

  3. 那么,实体的数据最好放在一起,比如说在一块连续内存上、以使得性能更优。

这样就面临一个问题:高度使用的状态机模式如何把行为和实体状态分离

下推自动机

下推自动机其实就是用一个栈来保存当前状态的有限状态机。

如果我们用数字标号来表示一个状态的话, 那么下推自动机就是只包含一个栈的一个结构体,其中栈顶表示的是当前激活中的状态:

struct StateMachine {
  int stack[N], top = -1;
};

下推自动机相比一般的有限状态机来说,保存了下推路径,也就是说,可以恢复到之前的状态,这样就可以方便的实现「暂停」和「恢复」功能:

Push(ctx, RobotState::Dancing); // 下推到新状态 RobotState::Dancing
Pop(ctx); // 恢复到上一个状态

这里采用了静态的、固定大小的数组来存储,原因在于:

  1. 方便把 StateMachine 直接嵌入实体中存储,大小是固定的,跟随实体本身的存储。

    实体在栈上,它的状态机就存栈上;实体在堆上,它的状态机就存堆上。

  2. 没有动态内存申请。

  3. 并不预期一个状态机的状态数目会很多,否则就不要用状态机了,所以这个 stack 的大小会很有限。

实现

状态就是一个枚举:

enum class RobotState { Idle, Moving, Dancing, N };

这里末尾的 N 是必要的,在 C++ 中,枚举值的数字这样会递增,这个末尾的 N 就自然表示了这个枚举值的个数。

然后,我们需要一套「跳转表」,如果跳转违背这个跳转表,会在运行时报错:

// 来源 => {可到达的目标状态列表...}
pdfsm::TransitionTable<RobotState> transitions{
   {RobotState::Idle, {RobotState::Moving, RobotState::Dancing}},
   {RobotState::Moving, {RobotState::Idle, RobotState::Dancing}},
   {RobotState::Dancing, {RobotState::Idle}},
};
可以把各个状态彻底解耦吗?

出于「分离关注点」的设计考虑,最最最好的情况是,一个状态只关心自身的实现、而不关心其他状态,然后用「跳转」来连接各个独立的状态。

就是说,每个状态的行为相当于一个模块,然后依赖跳转表把模块搭建成最终的状态机。

但是这个设计似乎太过美好,触发状态跳转的逻辑往往是复杂的、而且依赖于运行时的情况,这会让「跳转」关系成为新的逻辑点。

我还没有找到干净解耦的方式。

我们要实现一个状态的行为逻辑,只需要继承 pdfsm::B<State> 即可:

class RobotMovingBehavior : public pdfsm::B<RobotState::Moving> {
 public:
  void OnSetup() override {}
  void Update(const pdfsm::Context& ctx) override {}
  void OnEnter(const pdfsm::Context& ctx) override {}
  void OnTerminate(const pdfsm::Context& ctx) override { }
  void OnPause(const pdfsm::Context& ctx) override {}
  void OnResume(const pdfsm::Context& ctx) override {}
};

可以选择性地实现一些钩子函数,不必赘述。

需要注意的是,状态类里面要只包括行为逻辑、不应该存储任何和实体相关的状态和数据

如果要访问实体数据、或者环境数据,可以通过 ctx 来完成,此上下文结构可以持有一份外部世界的指针。

对于一个下推自动机,可以有如下几个接口,它们实现在一个叫做 StateMachineHandler 的结构内:

在操作进行的同时,会同步执行对应状态的钩子函数。

那么,最终「数据和行为的分离」体现在哪里呢?即 handler 来绑定和解绑一个实体的状态机,然后再操作:

pdfsm::StateMachineHandler<RobotState> handler(behaviors, transitions);
while (...) {
  for (auto& e : entities) {
    // 先绑定要操作的实体的状态机结构,再操作
    handler.SetHandlingFsm(e.fsm, ctx);
    handler.Update(ctx);
    handler.ClearHandlingFsm();
  }
}

在状态类实现中,也可以通过 GetHandler() 方法获取当前的 handler 的一个引用:

// 在一个状态的 Update 方法中
void Update(const pdfsm::Context& ctx) override {
  auto world = std::any_cast<std::shared_ptr<Blackboard>>(ctx.data);
  if (world.target) GetHandler().Jump(ctx, RobotState::Moving);
}

状态机善于对事件做出反应,这个库也可以很方便的和 blinker.h 集成。

最后,代码地址:https://github.com/hit9/pdfsm.h,欢迎给小星星。

(完)

本文原始链接地址: https://writings.sh/post/pushdown-fsm

王超 ·
喜欢这篇文章吗?
微信扫码赞赏
评论 首页 | 归档 | 算法 | 订阅