狀態機設計原則:清晰!清晰!還是清晰!

2021-12-24 技術讓夢想更偉大
【說在前面的話】

我們常說:狀態機是一種思維方式、一種工具,同時它也是一種擁有極高自由度的語言,作為一種翻譯思維的語言工具,不同人在使用狀態機時也有類似的表達能力的問題。

【功能單一原則】

人類是視覺主導的「動物」,具體表現為:對於同樣的信息,一張優秀的圖片往往能讓人秒懂,而對應的優秀文字描述哪怕寫的簡單易懂,人們通常也需要花費數倍的時間來閱讀。這裡的原因其實很簡單——對於圖片,人類是並行處理的;而對於建立在閱讀之上才能理解的文字,人類採用的是一種「連蹦帶跳」的順序處理方式。並行和順序處理的在時間效率上的差異,可見一斑。

在普通的應用邏輯中,使用狀態圖描述邏輯也具有這種「讓人一目了然」的潛力;理論上,通過讀圖理解設計者意圖的速度應該遠高於直接閱讀翻譯後代碼的速度——遺憾的是,實踐中由於缺乏正確的設計原則指導,很多人繪製的狀態圖恐怕還不如代碼看起來好懂。空口白牙,抽象的很,我們不妨舉個例子:

一般來說,在全狀態機開發下,永遠都應該「先畫圖」,覺得邏輯沒有問題的情況下,再「根據狀態圖來無腦的翻譯代碼」——這對一張白紙的初學者來說往往很容易做到;遺憾的是,對大部分已經有幾年工作經驗,習慣了線性邏輯開發的人來說就有點困難了。比如,當我們說要設計一個輸出字符串的狀態機,對很多人來說,首先出現在大腦中的不是一張狀態圖,而是類似如下函數的一個參考代碼://! \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;
}

是不是似曾相識?這不就是之前的所謂最優代碼麼?

【八狀態準則】

藉助前面介紹的方法,我們不僅能優雅的設計出邏輯清晰的狀態圖、兼顧翻譯後代碼的性能,還能在不影響邏輯清晰度的情況下減少狀態的數量。至此這裡「粗暴的」提出一個名為「八狀態」的經驗準則,即:

一個優秀的狀態機通常不應該擁有超過八個以上的狀態;

如果你的狀態機超過了八個狀態,那麼一定存在狀態圖層面的優化可能;

除了前面介紹過的能夠減少狀態的方法以外,將一部分高度相關(可能也重複出現)的狀態提取成為子狀態機,往往也能有效的減少狀態的數量。

【後記】

使用狀態圖來設計狀態機,其本意就是利用人類的視覺優於閱讀能力的特性來降低設計難度。為了確保這一初衷能夠貫徹始終,「邏輯清晰」就成為狀態圖設計的核心原則。

本著清晰第一的原則,首先要確保狀態機邏輯正確,也就是常說的:先讓功能跑起來沒有問題;然後再考慮所謂的優化的問題。此外,對於有經驗的老工程師來說,要嘗試克服設計狀態圖時滿腦子都是具體代碼實現的弊端——至於這樣,才能真正擁抱使用狀態機進行開發的思維方式。

相關焦點

  • 超清晰放大鏡
    超清晰放大鏡 生活工具 大小: 992KB
  • 常春藤入學必備讀物:20條基礎寫作原則,助你高效、清晰寫作
    你是學生還是商務人士?最近在準備考試嗎?我在寫3萬字的碩士畢業論文時,讀到了哈佛大學畢業生布蘭登·羅伊爾的寫作入門書《一本小小的紅色寫作書》,發現裡面的20條原則任何寫作者都不能夠忽視,每一條原則都是寫作基礎中的基礎。相信就算你是寫作老手,常翻看還是能夠受益匪淺。
  • 感情邏輯最清晰與感情邏輯最不清晰的星座
    一個人想要處理好自己的感情,那首先自己就得有相當清晰的感情邏輯,並且一定能夠把自己放在一個客觀的位置上去觀察、去思考,而不是僅憑自我意識,如果你做不到這兩點,那你的感情邏輯就無法清晰。而如果一個人的感情邏輯不夠清晰,那在他表達感情與處理矛盾問題的時候就會呈現出一種單向化的狀態特徵出來,而這就會導致被對方誤解,今天咱們就來說一下這兩個感情邏輯最清晰與感情邏輯最不清晰的星座吧!
  • 抖音拍視頻如何更清晰 抖音拍視頻為什麼那麼清晰
    抖音拍視頻如何更清晰?抖音拍視頻為什麼那麼清晰?有的人上傳的抖音視頻非常清晰,這是怎麼做到的呢?下面一起來看看吧。
  • 投影儀不清晰怎麼調
    投影儀不清晰,排除硬體問題的前提下多半是沒對好焦,或者鏡頭髒了。投影儀投影出來的圖像模糊不清的原因有以下幾點:  一、投影儀聚焦未調好; 解決方法:手動旋轉投影儀的鏡頭,讓投影聚焦。小技巧:先按投影儀菜單鍵,打開投影儀的菜單,然後轉動鏡頭聚焦調試畫面到最佳,直到看到菜單上的字幕清晰為止。
  • 如何做出一份邏輯清晰且設計美觀的 PPT?
    有一個原則和一個方法可以幫到我們。「一頁只講一件事」原則很簡單,即每頁PPT都只設一個焦點。我們對比一下兩張PPT,體會一下這個原則的必要性。而第二張PPT只介紹變化情況,清晰明了。學完「一個原則」,再來看看「一個方法」:結果倒推法。
  • 三大部分的寫作原則,教你寫出條理清晰的文章
    文|夢寒若我們總是羨慕別人能夠寫出重點突出、條理清晰的好文章,其實他們是掌握了基本的寫作原則。那這些寫作原則是什麼呢?答案就在這本《一本小小的紅色寫作書》裡面。《一本小小的紅色寫作書》的作者布蘭登·羅伊爾是加拿大人,就讀於哈佛大學,後在考試培訓機構Kaplan擔任主管。
  • 鴻晨光學——讓視界更清晰
    鴻晨防藍光360環焦防控鏡片運用獨特的光學理念設計——「360環焦周邊視力控制技術」,在為近視患者提供清晰舒適的中心視力的同時,也能改善周邊視網膜成像品質,先進的自由曲面設計技術,精密計算出鏡片自由曲面率,打磨出更加適合眼球生理特徵的中心和周邊度數,並釋放配鏡者眼位運動,緩解眼部疲勞,又使可見清晰視野最大。獨特的樹脂材質鏡片讓眼鏡更輕,戴在鼻梁上舒適。
  • 清晰蛻變徵文014|和少奇:用清晰視力擁抱大學生活
    同時用全新的自己來迎接大學生這個身份,也帶著重新獲得的清晰視力,來感受擁抱全新的大學生活。今天徵文的主人公就是我們新晉大學生中的一員,她在高考愛眼公益活動的幫助下,成功摘鏡獲得清晰視力。那麼她的摘鏡感受如何,讓我們一起來看看吧。我叫和少奇,今年18歲,也是今年的大一新生。
  • 清晰攝錄人聲的降噪算法錄音筆榜
    今天小編就梳理了清晰攝錄人聲的降噪算法錄音筆榜。排名前三的分別為索尼寬廣立體聲錄音筆、派克金屬機身錄音筆、索愛性能晶片錄音筆。亮點描述:在各類雜音環繞的會議環境,可使用這款支持寬廣立體聲收音的錄音筆,能清晰記錄不同位置發出的聲音。
  • PS讓模糊照片變清晰
    問題不大,還有救,我教你三種方法,你就可以將它們變清晰。    局部閃亮 銳化工具來幫忙    銳化工具是最簡單的工具,我們首先學習如何用銳化塗抹修復法局部修復模糊圖像,它常常被用於修復人物臉部。    菜菜:這個工具我用過,很不好用,總是出現花花綠綠的雜斑。
  • 從「垃圾食品」到概念清晰
    這樣垃圾這個詞解釋就清晰了,實際上是資源錯配。比如我們不要的衣服、物品是垃圾,但是對於有需要的人那是好東西。我們經常走極端,垃圾食品是不好,人們壓根不吃,即便要吃也帶著負罪感。相反那綠色食品就肯定是好的。什麼蔬菜、水果,天天吃這些,久而久之到了一定年紀缺鈣,骨質疏鬆等等症狀就來了。即便再好的東西,你多了就是過尤而不及。
  • 清晰視力 護航成長
    擁有清晰視力,護航學生成長,相信,在精心的呵護下,在好習慣的指引下,學生們都會擁有一個「光明、清晰」的未來!
  • dvi比vga清晰嗎
    打開APP dvi比vga清晰嗎 網絡整理 發表於 2020-12-22 16:22:50   dvi比vga清晰嗎   DVI接口:DVI(Digital Visual Interface),即數字視頻接口。
  • 這個時候,孤獨感格外的清晰……
    這個時候,孤獨感格外的清晰。休息日,到了晚上才發現,自己的電話一整天都沒有響過。你這一整天就只吃一頓飯,竟然還是那種不太健康的沒養營的快餐。沒人知道,這個時候,孤獨感格外的清晰。當你每天都獨自吃晚餐,下雨了沒人送傘,開心的事沒人可以分享,難過了沒人可以傾訴,走在熙熙攘攘的人群中,看著來往的人群,沒人在意人是喜是悲。
  • 派頓:邏輯與清晰思考有什麼用
    清晰思考這個觀念雖然罕見但卻很重要,它能幫你辨別專家、偽專家與騙子說法的不同,也能讓你免於陷入荒謬的困境或上當受騙。當你開始進行清晰思考時,你將發現它是個令人愉快的活動。進行清晰而客觀的思考,能讓你明白髮現真理的方法。幫助你熟練使用最具力量的人類活動——理性思考。理性思考是人類獨有的特徵,它能讓你作出睿智的選擇,讓你獲得真正的個人自由,最終,它也有助於實現一個自由且開放的社會。
  • 「換擋邏輯清晰」,到底是什麼意思?
    經常愛看汽車測評節目的朋友們肯定對以下這幾個詞或短語非常熟悉,「幾拳幾指」、「動力夠用」、「換擋邏輯清晰」……在這些車評人的行話聽多了之後,大家是否有考慮過箇中道理呢。從研究學術角度上講,變速箱的換擋過程可細分為兩大研究領域:其一是變速箱的換擋規律(或稱換擋策略),其二是變速箱的換擋品質。
  • PS如何使圖片變得清晰
    如何選擇SCI期刊,也是一門技巧。
  • 成熟的人都擁有清晰心理邊界
    4.僵硬型心理越位為了做到心裡不越位,我們是否就要死守邊界,無比有原則呢? 其實,過於嚴格的邊界也是件麻煩事兒,叫做邊界僵硬。邊界僵硬,就是和別人分得足夠清,以至於到了固執的地步。這種人聽不進去他人的意見,過分堅持不必要的原則,拒絕一切人情世故。設立好自己的底線和原則,靈活調整與他人重合的模糊具有彈性的地帶。
  • 學術論文怎樣才能做到條理清晰?
    總的來說,讀一篇條理清晰的文章就像跟一個頭腦特別清晰的人對話,他知道他要去哪,腦中圖景不亂,步伐清晰,目標明確,一步一個腳印地帶著你順著他的思路走,直達目的地。二、尋找自己最主要的主論點我們大部分人在動筆寫論文的時候都不是思路百分之一百清晰的時候,但是有一個原則要明確,就是在寫的時候我們要去尋找自己最主要的主論點,而每個主論點都應該由分論點去支撐,如果必要,分論點又應該有分分論點來支撐。