這是第二部分,有關有限狀態機(FSM)的 C++ 實作部分,也等同於狀態模式實現
Prologue
上一篇 談 C++17 裡的 State 模式之一 對於狀態模式所牽扯到的基本概念做了一個綜述性的梳理。所以是時候從這些概念中抽取我們感興趣的部分予以實作了。
C++ 實現(超程式設計實現)
如果不採用 DFA 理論推動的手段,而是在 C++11/17 的語境裡考慮實現狀態模式,那麼我們應該重新梳理一下理論:
- 狀態機 FSM:狀態機總是有限的(我們不可能去處理無限的狀態集合)。
- 開始狀態 S:Start State/Initial State
- 當前狀態 C:Current State
- 下一狀態 N:Next State
- 終止狀態:Terminated State (Optional)
- 進入狀態時的動作:enter-action
- 離開狀態時的動作:exit-action
- 輸入動作/輸入流:input action,也可以是輸入條件、或者事件物件等
- 轉換:Transition
- 上下文:Context
- 負載:Payload
有的時候,Input Action 也被稱作 Transition Condition/Guard。它的內涵始終如一,是指在進入下一狀態前通過條件進行判定狀態變遷是否被許可。
狀態機
核心定義
根據以上的設定,我們決定了 fsm machine 的基礎定義如下:
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>;
// ...
};
}
這是反覆迭代之後的成果。
你一定要明白,多數人,和我一樣,都是那種腦容量普通的人,我們做設計時一開始都是簡陋的,然後不斷修正枝蔓、改善設計後才能得到看起來似乎還算完備的結果,如同上面給出的主機器定義那樣。
早期版本
作為一個信心增強,下面給出首次跑通一個事件觸發和狀態推進時的 machine_t 定義:
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 &)>;
// ...
};
你得知道的是,狀態機的設計有一定的複雜度,這個規模不能算大規模,中規模都算不上,但是也不算小。
不會有多少人能夠一次性將其設計並編碼到位。除非這個人腦容量特大,再不然就是他習慣於首先做完備的 UML 圖,然後 convert to C++ codes...,不過這種功能應該是在 IBM Rational Rose 時代才比較行得通的步驟了,現在已經不太可能借助什麼 UML 工具這樣來做設計了,我不清楚 PlantUML 今天的發展情況,但我自己是很久沒有畫過 UML 圖了,還不如我手寫出 classes 來得直觀呢,至少對我的腦路是這樣的。
闡釋
machine_t
的頭部定義了一堆模板引數。我覺得無需要什麼額外的解釋,它們的用意大約是能夠直白地傳遞給你的,如果不能,你可能需要回顧一下狀態機的各種背景,嗯,問題絕對不會在我身上。
S
和 StateT
需要特別提及的是,S
是 State 的列舉類傳入,我們要求你一定要在這裡傳入一個列舉類作為狀態表,並且我們建議你的列舉類採用 AWESOME_MAKE_ENUM 巨集來幫助定義(不是必需)。注意在稍後 S
會被 state_t<S>
所裝飾,在 machine_t 內部的一切場所,我們只會使用這個包裝過後的類來訪問和操作狀態。
這是一種防禦性的程式設計手法。
假如未來我們想要引入其他機制,例如一個狀態類體系而不是列舉型別的值表示,那麼我們可以提供一個不同的 state_t 包裝方案,從而將新的機制無破壞地引入到現有的 machine_t 體系中。甚至於我們連 state_t 也可以不必破壞,僅僅是對其做帶有 enable_if 的模板特化就足矣。
StateT
和 State
你可能注意到模板引數 StateT
和 using
別名 State
了:
using State = StateT;
定義別名的用意至少有兩個:
- 在 machine_t 的內部和外部,使用型別別名
State
比使用 machine_t 的模板引數名要可靠的多,並且多數時候(尤其是在 machine_t 的外部)你只能使用型別別名 - 採用抽象後的型別別名有利於調整調優設計
在 State
上,我們可以直接使用 StateT,也可以使用更復雜的定義,這些變更(幾乎)不會影響到 State
的使用者。例如:
using State = std::optional<StateT>;
也是行得通的。當然實際工程中這麼做沒有什麼必要性。
CharT
和 InT
它們會在未來某一時間點有用。
對於吃進字元流並作 DFA 推動的場景它們可能是有用的。
但目前只是停留在念頭上。
OnAction
以及 OnErrorAction
OnAction
實際上是 on_transition_made / on_state_changed 的意思。暫時來講我們沒有 rename 令其更顯著,因為當初只想著要有一個可以除錯輸出的 callback,還沒有想過 on_state_changed 的 Hook 的必要性。直到後來做了 OnErrorAction 的設計之後才察覺到有必要關聯兩個 callbacks。
其它定義以及如何使用
狀態集合
有可能有多種方式提供狀態集合,如:列舉量,整數,短字串,甚至是小型結構。
不過在 fsm-cxx
中,我們約定你總是定義列舉量作為 fsm machine 的狀態集合。你的列舉型別將作為 machine 的模板引數 S
而傳遞,machine 將以此為基礎進行若干的封裝。
指定狀態的列舉量集合
所以使用時的程式碼類似於這樣:
AWESOME_MAKE_ENUM(my_state,
Empty,
Error,
Initial,
Terminated,
Opened,
Closed)
machine_t<my_state, event_base> m;
在 cxx 列舉型別 中我們曾經介紹過 AWESOME_MAKE_ENUM
可以簡化列舉型別的定義,在這裡你只需要將其看成是:
enum class my_state {
Empty,
Error,
Initial,
Terminated,
Opened,
Closed
}
就可以了。
設定 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 是必需的初始狀態。狀態機總是呆在這裡,直到有訊號推動它。初始狀態可以用 as_initial() 來授予。
terminated,error 狀態是可選的。而且暫時來講它們沒有顯著的作用——但你可以在你的 action 中檢測這些狀態並做出相應的應變。類似的,有 as_terminated()
/as_eeror()
來完成相應的指定。
對於每一個狀態列舉量來說,你可以根據需要為它們關聯 entry/exit_action,如同上面的 entry_action()/exit_action()
呼叫所展示的那樣。
guards
對於一個將要轉進的 state,你也可以為其定義 guards。和 Transition Guards 相同地,一個 State guard 是一個可以返回 bool 結果的函式。而且,該 guard 的用途也相似:在將要轉進某個 state 時,根據上下文環境做出判斷,以決定是否應該轉進到該 state 中。返回 false 時轉進動作將不會執行。
定義 state guards 的方式如同這樣:
// 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; })
你可以為一個 state 定義多條 guards。在上面的例項中,第二條 guard 將會根據 payload 中攜帶的 _ok 布林型別值來決定要不要轉進到 Opened 狀態。
如果 guard 表示不可以轉進,則狀態機停留在原位置,machine_t::on_error()
回撥函式將會獲得一個 reason == Reasion::FailureGuard
的通知,你可以在此時操縱 context 轉進到另一狀態,但要注意這時候將會是一個內部操作:通過 context.current(new_state)
進行到內部轉進操作是不會觸發任何條件約束和回撥機會的。
同樣道理,在 guard()
所新增的 guard 函式中你也可以操作 context 去修改新的轉進狀態而不會觸發進一步的條件約束和回撥機會。
事件
事件,或者說步進訊號,需要以一個公共的基類 event_t 為基準,event_t 被用作模板引數傳遞給 fsm machine,所以你可以使用這個預設設定。
你當然也可以傳遞一個不同的自定義的基類作為模板引數。例如:
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;
但這樣的 event 體系有可能過於簡單了,並且存在著型別丟失的風險(沒有虛解構函式宣告的類體系是危險的)。
所以我們建議你採用 fsm-cxx
預置的 event_t
和 event_type<E>
來實現你的事件類體系,也就是這樣:
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() {}
};
這樣擴充之後,也可以免去顯式宣告 event 模板引數:
machine_t<my_state> m;
// Or
machine_t<my_state, fsm_cxx::event_t> m;
除了上面的好處之外,最大的好處是你可以使用 (begin{}).to_string()
來得到類名。它是依賴 event_t
和 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>())); }
};
}
這對於未來的騰挪留下了充分的餘地。
如果你覺得為每個事件類寫一個虛解構函式太過於弱爆了,那麼用一個輔助的巨集好了:
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);
上下文
在 machine_t 中維持一份內部的上下文環境 Context,這在發生狀態轉換時是非常重要的核心結構。
Context 提供了當前所處的狀態位置,並允許你修改該位置。但要注意如果你通過這個能力進行狀態修改的話,條件約束和回撥函式將會被你的操作所略過。
如果檢視 context_t
的原始碼你會發現在這個上下文環境中 fsm-cxx 還管理了和 states 相關的 entry/exit_action 及其校驗程式碼。這個設計本來是為未來的 HFSM 而準備的。
負載
負載 Payload 從使用者編碼的角度來看是遊離在上下文、事件之外的。但對於狀態機理論來說,它是隨著事件一起被傳遞給狀態機的。
在每一次推動狀態機步進時,你可以通過 m.step_by() 攜帶一些有效載荷。這些載荷可以參與 guards 決策,也可以在 entry/exit_actions 中參與動作執行。
預設時 machine_t 使用 payload_t 作為其 PayloadT 模板引數。所以你只需要從 payload_t 派生你的類就可以自定義想要攜帶的負載了:
struct my_payload: public fsm_cxx::payload_t {};
你也可以採用 payload_type 模板包裝的方式:
struct my_payload: public fsm_cxx::payload_type<my_payload> {
// ...
}
至於 machine_t 的模板引數無需做什麼修改。
使用時通過 m.step_by(event, payload)
直接傳遞 my_payload 例項即可。
轉換表
我們的實現中準備簡單地建立兩級 hash_map,但第二級中使用一種較笨拙的構造方式。目前看來還沒有必要應該在這個部位進行額外的優化。
具體的定義是這樣的:
using Transition = transition_t<S, EventT, MutexT, PayloadT, StateT, ContextT, ActionT>;
using TransitionTable = std::unordered_map<StateT, Transition>;
轉換表以 from_state 為第一層的 key,並關聯一個 transition_t 結構。在 transition_t 中,實際上又有第二級 hash_map 是關聯到 EventT 的類名上的,所以一個 EventT 例項訊號會索引到關聯的 trans_item_t 結構,但這裡需要注意的是 EventT 例項本身不重要,重要的是它的類名。你看到我們之前約定事件訊號應該分別以最小型 struct 的方式予以宣告,而 struct 的結構體成員是被忽視的,machine_t 只需要它的型別名稱。
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_;
//...
};
按照上述定義,你在使用時應該這麼定義轉換表:
// 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});
類似於 state(),定義一條轉換表規則時,可以為規則掛鉤專屬的 entry/exit_action,你可以根據你的實際需求來選擇是在 state 還是 transition-rule 的恰當位置 hook 事件並執行 action。
你可以選擇採用 Builder Pattern 的風格來構造轉換表條目:
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();
它和一次 m.transition()
呼叫是等價的。
Guard for A Transition
在轉換表定義時,你可以為一個變換(Transition)定義一個前提條件。在轉換將要發生時,狀態機將會校驗該 guard 期待的條件是否滿足,只有在滿足時才會執行轉換動作。
我們曾經提到過,通過 event 訊號面向 from_state 的轉換路徑可以有多條,實際轉進中在多條路徑中如何選擇呢?就是通過 guard 條件來挑選的。
在具體實現中,還隱含著順序挑選原則:最先滿足 guard 條件的路徑被優先擇出,後續的路徑則被放棄探查。
無限制
一條轉換表條目代表著從 from_state 因為事件 ev 的激勵而轉進到 to_state。我們既不限制 from 到 (ev, to_state) 的轉換路徑,而是利用 guard 條件進行選擇(但實際上是一個順序優先的選擇)。具體情況可以參考原始碼的 transition_t::get()
部分。
推動狀態機執行
當上述的主要定義完成之後,狀態機就處於可工作狀態。此時你需要某種機制來推動狀態機執行。例如當接收到一個滑鼠事件時,你可以呼叫 m.step_by() 去推動狀態機。如果推動成功,則狀態機將會變換到新的狀態。
例如下面的程式碼做了簡單的推動:
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{});
其輸出結果如同這樣:
[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
注意推動程式碼中的第二行會因為 guard 的緣故導致推動不成功,所以輸出行中會有 Error: reason = Reason::FailureGuard
這樣的輸出資訊。
執行緒安全
如果你需要一個執行緒安全的狀態機,那麼可以給 machine_t 傳入第三個模板引數為 std::mutex
。如同這樣:
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;
在 m.step_by 的內部進行了競態條件控制。
但是在定義功能中(例如定義 state/guard/transition 的時候)並沒有進行保護,所以執行緒安全僅適用於 machine_t 開始執行之後。
另外,如果你自定義、或者擴充套件了你的上下文類,在上下文的內部操作中必需進行競態條件保護。
示例程式碼完整一覽
上面提到的測試用的程式碼:
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
這一次,程式碼的細節太多,所以我們偏重於解釋如何使用 fsm-cxx。並且由於篇幅的原因,也沒有足夠的地盤提供完整的程式碼,所以請參考 repo: https://github.com/hedzr/fsm-cxx。
總的來說,這一次寫的自己都不滿意。
這種文章總是會非常無趣的吧,不管怎麼寫都覺得一片散亂。