1
头图
This is the second part, the C++ implementation part of the finite state machine (FSM) is also equivalent to the state mode implementation

Prologue

The last article talks about one of the State modes in C++17, made an overview of the basic concepts involved in the state mode. So it is time to extract the parts we are interested in from these concepts and implement them.

C++ implementation (meta-programming implementation)

If we do not use the means promoted by DFA theory, but consider the realization of the state mode in the context of C++11/17, then we should reorganize the theory:

  • State machine FSM: The state machine is always finite (it is impossible for us to deal with an infinite set of states).
  • Start State S: Start State/Initial State
  • Current state C: Current State
  • Next State N: Next State
  • Terminated State (Optional)
  • Action when entering state: enter-action
  • The action when leaving the state: exit-action
  • Input action/input stream: input action, it can also be input condition, or event object, etc.
  • Transition: Transition
  • Context: Context
  • Load: Payload

Sometimes, Input Action is also called Transition Condition/Guard. Its connotation is consistent, which refers to the condition to determine whether the state change is permitted before entering the next state.

state machine

Core definition

Based on the above settings, we have determined the basic definition of fsm machine as follows:

namespace fsm_cxx {

  AWESOME_MAKE_ENUM(Reason,
                    Unknown,
                    FailureGuard,
                    StateNotFound)

  template<typename S,
           typename EventT = event_t,
           typename MutexT = void, // or std::mutex
           typename PayloadT = payload_t,
           typename StateT = state_t<S>,
           typename ContextT = context_t<StateT, EventT, MutexT, PayloadT>,
           typename ActionT = action_t<S, EventT, MutexT, PayloadT, StateT, ContextT>,
           typename CharT = char,
           typename InT = std::basic_istream<CharT>>
    class machine_t final {
      public:
      machine_t() {}
      ~machine_t() {}
      machine_t(machine_t const &) = default;
      machine_t &operator=(machine_t &) = delete;

      using Event = EventT;
      using State = StateT;
      using Context = ContextT;
      using Payload = PayloadT;
      using Action = ActionT;
      using Actions = detail::actions_t<S, EventT, MutexT, PayloadT, StateT, ContextT, ActionT>;
      using Transition = transition_t<S, EventT, MutexT, PayloadT, StateT, ContextT, ActionT>;
      using TransitionTable = std::unordered_map<StateT, Transition>;
      using OnAction = std::function<void(StateT const &, std::string const &, StateT const &, typename Transition::Second const &, Payload const &)>;
      using OnErrorAction = std::function<void(Reason reason, State const &, Context &, Event const &, Payload const &)>;
      using StateActions = std::unordered_map<StateT, Actions>;
      using lock_guard_t = util::cool::lock_guard<MutexT>;

      // ...
    };
}

This is the result of repeated iterations.

You must understand that most people, like me, are the kind of people with ordinary brains. When we make designs, we are all rudimentary at the beginning, and then we continue to modify the branches and improve the design to get what seems to be complete. The result is the same as the main machine definition given above.

Earlier version

As a confidence boost, here is the definition when an event is triggered and the state is advanced for the first time:

    template<typename StateT = state_t,
             typename ContextT = context_t<StateT>,
             typename ActionT = action_t<StateT, ContextT>,
             typename CharT = char,
             typename InT = std::basic_istream<CharT>>
    class machine_t final {
    public:
        machine_t() {}
        ~machine_t() {}

        using State = StateT;
        using Action = ActionT;
        using Context = ContextT;
        using Transition = transition_t<StateT, ActionT>;
        using TransitionTable = std::unordered_map<StateT, Transition>;
        using on_action = std::function<void(State const &, std::string const &, State const &, typename Transition::Second const &)>;
      
      // ...
    };

What you have to know is that the design of a state machine has a certain degree of complexity. This scale cannot be considered large-scale, and medium-scale is not considered small, but it is not small.

Not many people can design and code it all at once. Unless the human brain is very large, he is accustomed to first making a complete UML diagram and then converting to C++ codes..., but this function should be a more feasible step in the IBM Rational Rose era. Now It’s not possible to use any UML tools to design in this way. I don’t know the development of PlantUML today, but I haven’t drawn UML diagrams for a long time. It’s not as intuitive as I wrote classes by hand, at least for my brain. The way is like this.

Interpret

machine_t defines a bunch of template parameters. I don't think there is any need for additional explanation. Their intentions can be passed to you straightforwardly. If not, you may need to review the various backgrounds of the state machine. Well, the problem will definitely not be on me.

S and StateT

What needs to be mentioned is that S is the enumeration class of State. We require you to pass in an enumeration class here as the state table, and we recommend that your enumeration class use the AWESOME_MAKE_ENUM macro to help define it (not Required). Note that S will be state_t<S> later. In all places inside machine_t, we will only use this packaged class to access and manipulate the state.

This is a defensive programming technique.

If we want to introduce other mechanisms in the future, such as a state class system instead of enumerated type value representation, then we can provide a different state_t packaging scheme, so as to introduce the new mechanism into the existing machine_t system without damage . Even if we don't even need to destroy state_t, it is enough to just do a template specialization with enable_if on it.

StateT and State

You may notice that the template parameters StateT and using aliased to State :

using State = StateT;

The purpose of defining aliases is at least two:

  1. Inside and outside machine_t, using type alias State much more reliable than using machine_t template parameter name, and most of the time (especially outside machine_t) you can only use type alias
  2. The use of abstracted type aliases is conducive to adjusting and tuning the design

On State , we can use StateT directly or use more complex definitions. These changes will (almost) not affect the users of State E.g:

using State = std::optional<StateT>;

It also works. Of course, there is no need to do so in actual projects.

CharT and InT

They will be useful at some point in the future.

They may be useful for scenes where the character stream is eaten and driven by DFA.

But at the moment it just stays on the thoughts.

OnAction and OnErrorAction

OnAction actually means on_transition_made / on_state_changed. For the time being, we didn't have a rename to make it more prominent, because we only wanted to have a callback that can debug output, and we haven't thought about the necessity of on_state_changed Hook. It was not until the design of OnErrorAction that I realized the need to associate two callbacks.

Other definitions and how to use

State collection

There may be multiple ways to provide state collections, such as enumerations, integers, short strings, and even small structures.

But in fsm-cxx , we agree that you always define enumeration as the state set of fsm machine. Your enumeration type will be S machine, and the machine will perform a number of encapsulation based on this.

A set of enumerators in a specified state

So the code when used is similar to this:

AWESOME_MAKE_ENUM(my_state,
                  Empty,
                  Error,
                  Initial,
                  Terminated,
                  Opened,
                  Closed)

machine_t<my_state, event_base> m;

In cxx enumeration type , we have introduced that AWESOME_MAKE_ENUM can simplify the definition of enumeration type, here you only need to regard it as:

enum class my_state {
                      Empty,
                      Error,
                      Initial,
                      Terminated,
                      Opened,
                      Closed  
}

That's it.

Set states

Next, you can declare some basic states:

machine_t<my_state, event_base> m;
using M = decltype(m);

// states
        m.state().set(my_state::Initial).as_initial().build();
m.state().set(my_state::Terminated).as_terminated().build();
m.state().set(my_state::Error).as_error()
  .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cerr << "          .. <error> entering" << '\n'; })
  .build();
m.state().set(my_state::Opened)
  .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &) -> bool { return true; })
  .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &p) -> bool { return p._ok; })
  .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <opened> entering" << '\n'; })
  .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <opened> exiting" << '\n'; })
  .build();
m.state().set(my_state::Closed)
  .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed> entering" << '\n'; })
  .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed> exiting" << '\n'; })
  .build();

Initial is the required initial state. The state machine always stays here until there is a signal to push it. The initial state can be granted with as_initial().

The terminated, error state is optional. And for the time being, they have no significant effect-but you can detect these states in your actions and make corresponding changes. Similarly, there are as_terminated() / as_eeror() to complete the corresponding designation.

For each state enumeration, you can associate entry/exit_action for them as needed, as shown in entry_action()/exit_action()

guards

For a state to be transferred, you can also define guards for it. Like Transition Guards, a State guard is a function that can return a bool result. Moreover, the purpose of the guard is also similar: when it is about to be transferred to a certain state, a judgment is made according to the context to determine whether it should be transferred to the state. When false is returned, the forwarding action will not be executed.

The way to define state guards is like this:

// guards
m.state().set(my_state::Opened)
  .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &) -> bool { return true; })
  .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &p) -> bool { return p._ok; })

You can define multiple guards for a state. In the above example, the second guard will decide whether to switch to the Opened state based on the _ok boolean value carried in the payload.

If guard indicates that it cannot be transferred, the state machine stays in the original position, and the machine_t::on_error() callback function will get a reason == Reasion::FailureGuard . You can manipulate the context to switch to another state at this time, but note that this time will be an internal Operation: The context.current(new_state) will not trigger any conditional constraints and callback opportunities.

In the same way, in guard() , you can also manipulate the context to modify the new transition state without triggering further constraints and callback opportunities.

event

Events, or step signals, need to be based on a common base class event_t. Event_t is used as a template parameter to pass to the fsm machine, so you can use this default setting.

Of course, you can also pass a different custom base class as a template parameter. E.g:

struct event_base {};
struct begin : public event_base {};
struct end : public event_base {};
struct open : public event_base {};
struct close : public event_base {};

machine_t<my_state, event_base> m;

But such an event system may be too simple, and there is a risk of type loss (a class system without a virtual destructor declaration is dangerous).

Therefore, we suggest you use fsm-cxx preset event_t and event_type<E> to implement your event class system, which is this:

struct begin : public fsm_cxx::event_type<begin> {
  virtual ~begin() {}
  int val{9};
};
struct end : public fsm_cxx::event_type<end> {
  virtual ~end() {}
};
struct open : public fsm_cxx::event_type<open> {
  virtual ~open() {}
};
struct close : public fsm_cxx::event_type<close> {
  virtual ~close() {}
};

After this expansion, you can also avoid explicitly declaring event template parameters:

machine_t<my_state> m;
// Or
machine_t<my_state, fsm_cxx::event_t> m;

In addition to the above benefits, the biggest advantage is that you can use (begin{}).to_string() to get the class name. It relies on the support provided by the brief packaging of event_t and event_type<E>

namespace fsm_cxx {
  struct event_t {
    virtual ~event_t() {}
    virtual std::string to_string() const { return ""; }
  };
  template<typename T>
  struct event_type : public event_t {
    virtual ~event_type() {}
    std::string to_string() const { return detail::shorten(std::string(debug::type_name<T>())); }
  };
}

This leaves plenty of room for the future of Tenno.

If you feel that writing a virtual destructor for each event class is too weak, then use an auxiliary macro:

struct begin : public fsm_cxx::event_type<begin> {
  virtual ~begin() {}
  int val{9};
};
FSM_DEFINE_EVENT(end);
FSM_DEFINE_EVENT(open);
FSM_DEFINE_EVENT(closed);

Context

Maintain an internal context environment Context in machine_t, which is a very important core structure when state transitions occur.

Context provides the current position of the state and allows you to modify the position. But be aware that if you use this ability to modify the state, the conditional constraints and callback functions will be skipped by your operations.

If you look context_t the source code of 061565da375d5a, you will find that in this context, fsm-cxx also manages the entry/exit_action and its verification code related to states. This design was originally prepared for the future HFSM.

load

From the perspective of user coding, the payload is outside the context and events. But for the state machine theory, it is passed to the state machine along with the event.

Each time you push the state machine to step, you can carry some payload through m.step_by(). These loads can participate in guards decision-making, and can also participate in action execution in entry/exit_actions.

By default, machine_t uses payload_t as its PayloadT template parameter. So you only need to derive your class from payload_t to customize the payload you want to carry:

struct my_payload: public fsm_cxx::payload_t {};

You can also use payload_type template packaging:

struct my_payload: public fsm_cxx::payload_type<my_payload> {
  // ...
}

As for the template parameters of machine_t, no modification is required.

By using m.step_by(event, payload) directly transmitted to my_payload instance.

Conversion table

In our implementation, we are going to simply create a two-level hash_map, but in the second level we use a more clumsy construction method. It seems that there is no need for additional optimization in this area.

The specific definition is as follows:

using Transition = transition_t<S, EventT, MutexT, PayloadT, StateT, ContextT, ActionT>;
using TransitionTable = std::unordered_map<StateT, Transition>;

The conversion table uses from_state as the key of the first level, and is associated with a transition_t structure. In transition_t, there is actually a second level of hash_map that is associated with the class name of EventT, so an EventT instance signal will be indexed to the associated trans_item_t structure, but it should be noted here that the EventT instance itself is not important, the important thing is Its class name. You see that we previously agreed that event signals should be declared in the smallest struct, and the structure members of struct are ignored. Machine_t only needs its type name.

template<typename S,
         typename EventT = dummy_event,
         typename MutexT = void,
         typename PayloadT = payload_t,
         typename StateT = state_t<S, MutexT>,
         typename ContextT = context_t<StateT, EventT, MutexT, PayloadT>,
         typename ActionT = action_t<S, EventT, MutexT, PayloadT, StateT, ContextT>>
  struct transition_t {
    using Event = EventT;
    using State = StateT;
    using Context = ContextT;
    using Payload = PayloadT;
    using Action = ActionT;
    using First = std::string;
    using Second = detail::trans_item_t<S, EventT, MutexT, PayloadT, StateT, ContextT, ActionT>;
    using Maps = std::unordered_map<First, Second>;

    Maps m_;

    //...
  };

According to the above definition, you should define the conversion table as follows:

// transistions
m.transition(my_state::Initial, begin{}, my_state::Closed)
  .transition(
    my_state::Closed, open{}, my_state::Opened,
    [](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> entering" << '\n'; },
    [](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> exiting" << '\n'; })
  .transition(my_state::Opened, close{}, my_state::Closed)
  .transition(my_state::Closed, end{}, my_state::Terminated);

m.transition(my_state::Opened,
             M::Transition{end{}, my_state::Terminated,
                           [](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <T><END>" << '\n'; },
                           nullptr});

Similar to state(), when defining a conversion table rule, you can hook the exclusive entry/exit_action for the rule. You can choose whether to hook the event in the appropriate position of the state or the transition-rule and execute the action according to your actual needs.

You can choose to use the style of Builder Pattern to construct conversion table entries:

m.builder()
  .transition(my_state::Closed, open{}, my_state::Opened)
  .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &p) -> bool { return p._ok; })
  .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> entering" << '\n'; })
  .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> exiting" << '\n'; })
  .build();

It is equivalent to m.transition()

Guard for A Transition

When defining the conversion table, you can define a precondition for a transition. When the transition is about to occur, the state machine will check whether the conditions expected by the guard are met, and will only execute the transition when it is met.

We have mentioned that there can be multiple conversion paths to from_state through the event signal. How to choose among multiple paths in actual transfer? It is selected by guard conditions.

In the specific implementation, the principle of order selection is also implied: the path that first meets the guard condition is selected first, and the subsequent path is abandoned.
Unlimited

A conversion table entry represents the transition from from_state to to_state due to the excitation of the event ev. We neither limit the conversion path from from to (ev, to_state), but use guard conditions to select (but in fact it is a priority choice). For details, please refer to the transition_t::get() part of the source code.

Push the state machine to run

When the above-mentioned main definition is completed, the state machine is in a working state. At this point you need some mechanism to push the state machine to run. For example, when a mouse event is received, you can call m.step_by() to push the state machine. If the push is successful, the state machine will change to a new state.

For example, the following code makes a simple push:

m.step_by(begin{});   // goto Closed
if (!m.step_by(open{}, payload_t{false}))
  std::cout << "          E. cannot step to next with a false payload\n";
m.step_by(open{});    // goto Opened
m.step_by(close{});
m.step_by(open{});
m.step_by(end{});

The output is like this:

        [Closed] -- begin --> [Closed] (payload = a payload)
          .. <closed> entering
          Error: reason = Reason::FailureGuard
          E. cannot step to next with a false payload
          .. <closed -> opened> exiting
          .. <closed> exiting
        [Opened] -- open --> [Opened] (payload = a payload)
          .. <closed -> opened> entering
          .. <opened> entering
          .. <opened> exiting
        [Closed] -- close --> [Closed] (payload = a payload)
          .. <closed> entering
          .. <closed -> opened> exiting
          .. <closed> exiting
        [Opened] -- open --> [Opened] (payload = a payload)
          .. <closed -> opened> entering
          .. <opened> entering
          .. <opened> exiting
        [Closed] -- end --> [Closed] (payload = a payload)
          .. <closed> entering

Note that the second line of the push code will cause the push to be unsuccessful because of the guard, so there will be output information such as Error: reason = Reason::FailureGuard

Thread safe

If you need a thread-safe state machine, you can pass the third template parameter std::mutex to machine_t. Like this:

fsm_cxx::machine_t<my_state, fsm_cxx::event_t, std::mutex> m;
using M = decltype(m);

// Or:
fsm_cxx::safe_machine_t<my_state> m;

The race condition control is carried out inside m.step_by.

But in the definition function (for example, when defining state/guard/transition), there is no protection, so thread safety only applies after the machine_t starts running.

In addition, if you customize or extend your context class, race conditions must be protected in the internal operations of the context.

A complete overview of the sample code

The test code mentioned above:

namespace fsm_cxx { namespace test {

  // states

  AWESOME_MAKE_ENUM(my_state,
                    Empty,
                    Error,
                    Initial,
                    Terminated,
                    Opened,
                    Closed)

  // events

  struct begin : public fsm_cxx::event_type<begin> {
    virtual ~begin() {}
    int val{9};
  };
  struct end : public fsm_cxx::event_type<end> {
    virtual ~end() {}
  };
  struct open : public fsm_cxx::event_type<open> {
    virtual ~open() {}
  };
  struct close : public fsm_cxx::event_type<close> {
    virtual ~close() {}
  };

  void test_state_meta() {
    machine_t<my_state> m;
    using M = decltype(m);

    // @formatter:off
    // states
    m.state().set(my_state::Initial).as_initial().build();
    m.state().set(my_state::Terminated).as_terminated().build();
    m.state().set(my_state::Error).as_error()
      .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cerr << "          .. <error> entering" << '\n'; })
      .build();
    m.state().set(my_state::Opened)
      .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &) -> bool { return true; })
      .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &p) -> bool { return p._ok; })
      .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <opened> entering" << '\n'; })
      .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <opened> exiting" << '\n'; })
      .build();
    m.state().set(my_state::Closed)
      .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed> entering" << '\n'; })
      .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed> exiting" << '\n'; })
      .build();

    // transistions
    m.transition().set(my_state::Initial, begin{}, my_state::Closed).build();
    m.transition()
      .set(my_state::Closed, open{}, my_state::Opened)
      .guard([](M::Event const &, M::Context &, M::State const &, M::Payload const &p) -> bool { return p._ok; })
      .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> entering" << '\n'; })
      .exit_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <closed -> opened> exiting" << '\n'; })
      .build();
    m.transition().set(my_state::Opened, close{}, my_state::Closed).build()
      .transition().set(my_state::Closed, end{}, my_state::Terminated).build();
    m.transition().set(my_state::Opened, end{}, my_state::Terminated)
      .entry_action([](M::Event const &, M::Context &, M::State const &, M::Payload const &) { std::cout << "          .. <T><END>" << '\n'; })
      .build();
    // @formatter:on

    m.on_error([](Reason reason, M::State const &, M::Context &, M::Event const &, M::Payload const &) {
      std::cout << "          Error: reason = " << reason << '\n';
    });

    // debug log
    m.on_transition([&m](auto const &from, fsm_cxx::event_t const &ev, auto const &to, auto const &actions, auto const &payload) {
      std::printf("        [%s] -- %s --> [%s] (payload = %s)\n", m.state_to_sting(from).c_str(), ev.to_string().c_str(), m.state_to_sting(to).c_str(), to_string(payload).c_str());
      UNUSED(actions);
    });

    // processing

    m.step_by(begin{});
    if (!m.step_by(open{}, payload_t{false}))
      std::cout << "          E. cannot step to next with a false payload\n";
    m.step_by(open{});
    m.step_by(close{});
    m.step_by(open{});
    m.step_by(end{});

    std::printf("---- END OF test_state_meta()\n\n\n");
  }

}}

Epilogue

This time, the code has too many details, so we focus on explaining how to use fsm-cxx. And due to space reasons, there is not enough site to provide complete code, so please refer to repo: https://github.com/hedzr/fsm-cxx .

In general, I am not satisfied with what I wrote this time.

This kind of article will always be very boring, no matter how you write it, it feels messy.

:end:


hedzr
95 声望19 粉丝