我們常說:狀態機是一種思維方式、一種工具,同時它也是一種擁有極高自由度的語言,作為一種翻譯思維的語言工具,不同人在使用狀態機時也有類似的表達能力的問題。
【功能單一原則】人類是視覺主導的「動物」,具體表現為:對於同樣的信息,一張優秀的圖片往往能讓人秒懂,而對應的優秀文字描述哪怕寫的簡單易懂,人們通常也需要花費數倍的時間來閱讀。這裡的原因其實很簡單——對於圖片,人類是並行處理的;而對於建立在閱讀之上才能理解的文字,人類採用的是一種「連蹦帶跳」的順序處理方式。並行和順序處理的在時間效率上的差異,可見一斑。
在普通的應用邏輯中,使用狀態圖描述邏輯也具有這種「讓人一目了然」的潛力;理論上,通過讀圖理解設計者意圖的速度應該遠高於直接閱讀翻譯後代碼的速度——遺憾的是,實踐中由於缺乏正確的設計原則指導,很多人繪製的狀態圖恐怕還不如代碼看起來好懂。空口白牙,抽象的很,我們不妨舉個例子:
一般來說,在全狀態機開發下,永遠都應該「先畫圖」,覺得邏輯沒有問題的情況下,再「根據狀態圖來無腦的翻譯代碼」——這對一張白紙的初學者來說往往很容易做到;遺憾的是,對大部分已經有幾年工作經驗,習慣了線性邏輯開發的人來說就有點困難了。比如,當我們說要設計一個輸出字符串的狀態機,對很多人來說,首先出現在大腦中的不是一張狀態圖,而是類似如下函數的一個參考代碼://! \brief 通過外設非阻塞的輸出一個字節
extern bool serial_out(uint8_t cbByte);
void print_str(const char *pchStr)
{
if (NULL == pchStr) {
return ;
}
//! C語言中,字符串一般以 '\0' 作為結尾
while (*pchStr != '\0') {
//! 由於外設比較慢,輸出不一定每次都成功
while(!serial_out(*pchStr));
pchStr++;
}
}作為參考代碼,顯然,所有人都知道阻塞是大問題,只要把這個代碼改為非阻塞的就行了,於是基於上述代碼在腦海中先入為主的影響,很容易「逆向」出如下的狀態圖:
這個圖從嚴格的語法意義上來說完全合格,只不過閱讀起來有點痛苦——雖然只有一個狀態,但猛然讓一個第三人閱讀,估計要花費不少的時間,可能的原因如下:
我們說這個圖雖然語法上正確,但是違反了一個很重要的狀態圖設計原則——功能單一原則。所謂功能單一原則是指:每個狀態的功能要儘可能單一,要避免將多個功能複合在同一個狀態上,從而產生所謂的「超級狀態」的情況。設計出超級狀態並不是什麼值得驕傲的本事,它單純:
增加了旁人(以及幾個月後的自己)閱讀和理解狀態邏輯的難度
回頭再看前面的例子,很容易發現,它違反了狀態功能單一原則:將字符輸出和判斷字符串尾部的功能集成進了同一個狀態,從而產生了一個擁有3條躍遷的超級狀態。基於狀態功能單一原則,一個更好的設計如下:
在這個圖中,雖然狀態數量變成了兩個,但它們分工明確功能單一,閱讀起來較為簡單,也非常容易發現上一張圖中不太容易發現的邏輯問題——比如前一個狀態圖如果字符串是空串「」,就會產生內存訪問越界的致命bug,而通過拆分成兩個狀態,很容易注意到「IS End Of String」狀態所處位置對空串的敏感度——這也是功能單一原則增強了白盒測試(肉眼看圖找bug)能力的一個強有力的證明。
有的小夥伴可能會說,前一個狀態圖其實也不是很複雜啦,我就能看懂。對此,我想說:前面的例子終究只是一個為了介紹問題而引入的例子,本身不會太複雜,講清楚問題就行了。而在實際應用中,在缺乏「功能單一」原則的限制下,鬼知道設計師會複合出怎樣的「蜘蛛精」;
超級狀態的成因之一是一部分人受到先前習慣的影響,雖然是設計狀態機,但總是無法自控的「先有代碼在腦海裡」然後「再逆向」繪製出對應的狀態圖。這個習慣說實話很難克服,也非常容易理直氣壯的產生「自以為是在優化代碼」的超級狀態。針對這種心理,我們不妨強調下狀態機設計的正確流程:
狀態機設計的第一步永遠都是邏輯設計,追求的是清晰,此時絕不需要考慮所謂的代碼翻譯時如何才能做到最優;
狀態圖才是真正的原始碼,而翻譯後的C代碼則是「彙編」;
任何針對狀態機的修改都必須從狀態圖開始,完成邏輯修改後,無腦的翻譯成代碼來查看運行效果;
若且唯若狀態機的邏輯已經經過驗證,確認是正確無誤的情況下,如果確實有用戶需求或者系統性能沒有達到要求,此時才進入狀態圖優化階段——這裡遵守的原則是:先優化狀態圖,最後萬不得已的情況下才去優化翻譯後的代碼。
說了這麼多,我們不妨再用一個例子實際操作一下:假設我們要設計一個狀態機,從用戶那裡識別單詞"OK"——其中一種最無腦的設計思路如下:
從邏輯的角度來說,很容易想到,對每一個字符來說,我們都有兩個階段:讀取字符輸入
因此很容易無腦的畫出如下的狀態機:
眼尖的小夥伴可能已經注意到,這個圖中引入了一個新的名為reset的小圓點,它的功能也很直接:當躍遷到reset小圓點時,狀態機復位,並返回on-going。一個系統中可以有多個reset小圓點。有的小夥伴可能要問,為啥不是「躍遷到狀態機的第一個狀態」而一定要使用reset小圓點呢?原因主要有二:
如果使用躍遷到第一個狀態的方法,則每一個躍遷都可能要重複去做類似初始化的工作——每多一條躍遷就多了一個重複的內容——這裡如果不是簡單複製粘貼的話,可能還會出現在漫長的代碼維護過程中出現「某些躍遷的動作與其它不一致」從而給自己挖坑的情況;使用reset可以確保狀態機復位,從而安全的從唯一的start點進入,完成統一的初始化動作。
儘管有的小夥伴會說:「這個狀態機看起來好蠢啊」,「這個狀態機看起來一點通用性都沒有」,但我要說,領會下精神啦,這只是我用來介紹方法論的例子,實際應用當然不會這麼設計,但不管怎麼說,這個狀態機很清晰有木有?就是非常簡單直接的「一二一二……」步驟的無腦疊加——而這種功能單一、邏輯清晰的無腦疊加正是狀態機設計思維的一種體現——先邏輯清晰,一切都對了再考慮要不要優化。
然而,沒有對比就沒有傷害,我們再來看看一個反例——當我們先有代碼在腦海裡「揮之不去」,再「逆向」出狀態圖時會發生什麼。
一個很容易想到的「最優」代碼如下:
fsm_rt_t check_ok(void)
{
uint8_t chByte;
...
switch (s_tState) {
...
case RCV_O:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'O') {
CHECK_OK_RESET_FSM();
break;
}
s_tState = RCV_K;
case RCV_K:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'K') {
CHECK_OK_RESET_FSM();
break;
}
CHECK_OK_RESET_FSM();
return fsm_rt_cpl;
}
return fsm_rt_on_going;
}受其影響,「逆向」出的狀態圖如下:圖片
看到這個狀態圖,很多小夥伴估計要不淡定了?「傻孩子你是不是打自己臉了?」「這個圖看起來明明看起來更直接啊?狀態更少,而且對應的代碼明顯更優化啊!」
別急別急,好漢先看完下面的內容再打不遲。
【如何在「結構清晰」和「性能優化」之間取得平衡】在前面的討論中,我們遇到了狀態機設計的一個非常實際的問題:在追求邏輯清晰的時候,似乎由於狀態的增多,代碼執行的性能受到了某種影響——它表現在當翻譯成switch狀態機時,增加了太多不必要的狀態切換,從而影響了當前狀態機的執行效率。比如:
這裡,每次成功閱讀到一個字符後,在翻譯成switch狀態機後,居然要到下一次才能對字符進行判斷,而判斷後居然又要退出狀態機,再下一次才能開始新的一輪字符讀取——這個狀態機也實在太「卑微」了。考慮到《實時性迷思(2)——「時間片輪轉」的沙子》中推導出的結論:非必要的頻繁任務切換會浪費大量的處理器時間,從而影響系統的實時性,這裡由狀態切換導致的頻繁CPU出讓(yield)實際上並非好事。
難道我們必須要在「邏輯清晰」和「性能優化」中做出取捨麼?
先別著急下結論,分析上面的原因容易發現:
遵循狀態功能單一原則會產生多個簡單狀態,邏輯清晰,閱讀簡單;
現有的狀態切換過程中根據翻譯方式的不同「有可能」出讓CPU時間給其它任務;
那麼,如果有一種方法能在狀態切換的過程中明確「標註」不要出讓CPU控制權(避免yield)是否就能解決問題了呢?比如,我們把此類切換從實線箭頭修改為虛線箭頭——表示此類切換不「主動」出讓CPU控制權,則修改後的圖如下所示:
那麼在switch狀態機中,這類「不讓出CPU」的切換,實際上就是「切換任務的同時確保不會退出狀態機函數」。要想做到這一點有兩種方式:
如果你對switch的fall-through特性感到一頭霧水,可以去找一本經典的C語言教程看一看,或者參考這裡的博文(https://c-for-dummies.com/blog/?p=3607)
實際上,這裡並不需要比較二者的優劣。一般來說,fall-through具有瀑布一般一瀉千裡不能回頭的特性;而goto則適用於那些需要「逆流而上」的場合。作為例子,我們不妨使用新的方法翻譯前面的狀態圖:
fsm_rt_t check_ok(void)
{
uint8_t chByte;
...
switch (s_tState) {
case START:
s_tState = READ_CHAR_0;
// break; //!< fall-through實現虛線切換
case READ_CHAR_0:
if (!serial_in(&chByte)) {
break;
}
s_tState = IS_O;
// break; //!< fall-through實現虛線切換
case IS_O:
if (chByte != 'O') {
CHECK_OK_RESET_FSM();
break;
}
s_tState = READ_CHAR_1:
// break; //!< fall-through實現虛線切換
case READ_CHAR_1:
if (!serial_in(&chByte)) {
break;
}
s_tState = IS_K;
// break; //!< fall-through實現虛線切換
case IS_K:
if (chByte != 'K') {
CHECK_OK_RESET_FSM();
break;
}
CHECK_OK_RESET_FSM();
return fsm_rt_cpl;
}
return fsm_rt_on_going;
}通過觀察可以發現,上述代碼藉助fall-through的特性取得了跟前一章節參考代碼幾乎無異的執行性能——實際上,聰明的你已經發現,在確有必要的情況下,可以在狀態機的優化階段對上述代碼進行進一步的優化,從而得到與此前參考代碼一模一樣的結果:
fsm_rt_t check_ok(void)
{
uint8_t chByte;
...
switch (s_tState) {
...
case RCV_O:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'O') {
CHECK_OK_RESET_FSM();
break;
}
s_tState = RCV_K;
case RCV_K:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'K') {
CHECK_OK_RESET_FSM();
break;
}
CHECK_OK_RESET_FSM();
return fsm_rt_cpl;
}
return fsm_rt_on_going;
}至此,我們實際上明確了狀態機的日常設計步驟:
按照狀態功能單一原則,以邏輯清晰為基本目標,再完全不考慮優化的情況下,完成狀態機的設計和調試;
在完成了狀態機邏輯正確性驗證的前提下,在必要的情況下,可以對狀態圖進行性能優化;
如果經過上述步驟,性能仍然達不到要求,可以對翻譯後的代碼進行進一步的等效優化。
注意,以上過程是單向不可逆的。一般會把步驟1和步驟2視作「一次迭代」,敏捷開發中可能會允許用戶進行多次迭代。
【條件太多怎麼辦】很多時候,雖然藉助「虛線切換」可以在性能和清晰度上獲得一定的平衡,但如果一個狀態機狀態數量太多,難免會讓人眼花繚亂。就前面的狀態圖check_ok來說,這只是識別兩個字符,如果字符一多,圖豈不是直接爆炸了?
也許不是一個很好的例子,但如果真的能省略掉圖中「IS_O」和「IS_K」兩個狀態,並且仍能保證清晰的邏輯,豈不美哉?為了應對這種情況,我們引入了新的圖例:公共條件(common condition)和子條件(sub condition)。那麼,如何理解這兩個新的概念呢?此前,我們的狀態模型上,每個躍遷都由兩部分組成:躍遷的條件(condition)和躍遷時執行的一次性動作(action):
這一模型可以應對大部分較為簡單的情況,但實際應用中,一個狀態的所涉及的具體行為可能會產生不止一個返回值,比如:
serial_in(&chByte);就產生了兩個有效的返回值:
serial_in() 函數的 boolean值,表示讀取成功還是失敗;
當讀取成功時,保存在 chByte 中的字符也就成了一個我們要判斷的返回值;
對於這種源自同一個狀態的動作而產生的多個返回值,我們可以藉助前面所說的「公共條件」和「子條件」的方式加以簡化:
根據這一方式,修改前面的狀態圖如下:
怎麼樣,是不是又清晰又簡單呢?它的switch狀態機代碼如下:
fsm_rt_t check_ok(void)
{
uint8_t chByte;
...
switch (s_tState) {
case START:
s_tState = READ_CHAR_0;
// break; //!< fall-through實現虛線切換
case READ_CHAR_0:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'O') {
CHECK_OK_RESET_FSM();
break;
}
s_tState = READ_CHAR_1:
// break; //!< fall-through實現虛線切換
case READ_CHAR_1:
if (!serial_in(&chByte)) {
break;
}
if (chByte != 'K') {
CHECK_OK_RESET_FSM();
break;
}
CHECK_OK_RESET_FSM();
return fsm_rt_cpl;
}
return fsm_rt_on_going;
}是不是似曾相識?這不就是之前的所謂最優代碼麼?
【八狀態準則】藉助前面介紹的方法,我們不僅能優雅的設計出邏輯清晰的狀態圖、兼顧翻譯後代碼的性能,還能在不影響邏輯清晰度的情況下減少狀態的數量。至此這裡「粗暴的」提出一個名為「八狀態」的經驗準則,即:
一個優秀的狀態機通常不應該擁有超過八個以上的狀態;
如果你的狀態機超過了八個狀態,那麼一定存在狀態圖層面的優化可能;
除了前面介紹過的能夠減少狀態的方法以外,將一部分高度相關(可能也重複出現)的狀態提取成為子狀態機,往往也能有效的減少狀態的數量。
【後記】使用狀態圖來設計狀態機,其本意就是利用人類的視覺優於閱讀能力的特性來降低設計難度。為了確保這一初衷能夠貫徹始終,「邏輯清晰」就成為狀態圖設計的核心原則。
本著清晰第一的原則,首先要確保狀態機邏輯正確,也就是常說的:先讓功能跑起來沒有問題;然後再考慮所謂的優化的問題。此外,對於有經驗的老工程師來說,要嘗試克服設計狀態圖時滿腦子都是具體代碼實現的弊端——至於這樣,才能真正擁抱使用狀態機進行開發的思維方式。