如何以面向物件的思想設計有限狀態機
阿新 • • 發佈:2020-05-03
# 狀態機的概念
有限狀態機又稱有限狀態自動機,簡稱狀態機,是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的數學計算模型,用英文縮寫也被簡稱為 FSM。
FSM 會響應“事件”而改變狀態,當事件發生時,就會呼叫一個函式,而且 FSM 會執行動作產生輸出,所執行的動作會因為當前系統的狀態和輸入的事件不同而不同。
# 問題背景
為了更好地描述狀態機的應用,這裡用一個地鐵站的閘機為背景,簡單敘述一下閘機的工作流程:
*通常閘機預設是關閉的,當閘機檢測到有效的卡片資訊後,開啟閘機,當乘客通過後,關閉閘機;如果有人非法通過,那麼閘機就會產生報警,如果閘機已經開啟,而乘客仍然在刷卡,那麼閘機將會顯示票價和餘額,並在螢幕輸出“請通過,謝謝”。*
在瞭解了閘機的工作流程之後,我們就可以畫出閘機的狀態圖,狀態圖如下:
![閘機狀態圖](https://img-blog.csdnimg.cn/20200502192919809.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjYxNjc5MQ==,size_16,color_FFFFFF,t_70)
在上圖中,線條上面的字表示的是:**閘機輸入事件**/**閘機執行動作**,方框內表示的是閘機的狀態。
除了使用狀態圖來表示系統的工作流程外,我們也可以採用狀態表的方式來表示系統的工作流程,狀態表如下所示:
起始狀態 | 事件|結束狀態|動作
-------- | -----|------|------|
Locked | card|Unlocked|unlock
Locked | pass|Locked|alarm
Unlocked | card| Unlocked|thankyou
Unlocked|pass|Locked|lock
通過上述我們已經知道閘機的工作流程了,接下來我們來看具體的實現。
# 程式碼實現
## 巢狀的 switch 語句
使用巢狀的 switch 語句是最為直接的辦法,也是最容易想的方法,第一層 switch 用於狀態管理,第二層 switch 用於管理各個狀態下的各個事件。程式碼實現可以用下述虛擬碼來實現:
```c
switch(當前狀態)
{
case LOCKED 狀態:
switch(事件):
{
case card 事件:
切換至 UNLOCKED 狀態;
執行 unlock 動作;
break;
case pass 事件:
執行 alarm 動作;
break;
}
break;
case UNLOCKED 狀態:
switch(事件):
{
case card 事件:
執行 thankyou 動作;
break;
case pass 事件:
切換至 LOCKED 狀態;
執行 lock 動作;
break;
}
break;
}
```
上述程式碼雖然很直觀,但是狀態和事件都出現在一個處理函式中,**對於一個大型的 FSM 中,可能存在大量的狀態和事件,那麼程式碼量將是非常冗長的**。為了解決這個問題,可以採用狀態轉移表的方法來處理。
## 狀態轉移表
為了減少程式碼的長度,可以使用查表法,將各個資訊存放於一個表中,根據事件和狀態查詢表項,找到需要執行的動作以及即將轉換的狀態。
```c
typedef struct _transition_t
{
狀態;
事件;
轉換為新的狀態;
執行的動作;
}transition_t;
transition_t transitions[] = {
{LOCKED 狀態,card 事件,狀態轉換為UNLOCKED,unlock動作},
{LOCKED 狀態,pass 事件,狀態保持為LOCKED,alarm 動作},
{UNLOCKED 狀態,card 事件,狀態轉換為 UNLOCKED,thankyou動作},
{UNLOCKED 狀態,pass 事件,狀態轉換為 LOCKED,lock 動作}
};
for (int i = 0;i < sizeof(transition)/sizeof(transition[0]);i++)
{
if (當前狀態 == transition[i].狀態 && 事件 == transition[i].事件)
{
切換狀態為:transition[i].轉換為新的狀態;
執行動作:transition[i].執行的動作;
break;
}
}
```
從上述我們可以看到如果要往狀態機中新增新的流程,那麼只需要往狀態表中新增東西就可以了,也就是說整個狀態機的維護及管理只需要把重心放到狀態轉移表的維護中就可以了,從程式碼量也可以看出來,採用狀態轉移表的方法相比於第一種方法也大大地縮減了程式碼量,而且也更容易維護。
但是對於狀態轉移表來說,缺點也是顯而易見的,**對於大型的 FSM 來說,遍歷狀態轉移表需要花費大量的時間**,從而影響程式碼的執行效率。
那要怎樣設計程式碼量少,又不需要以遍歷狀態轉移表的形式從而花費大量時間的狀態機呢?這個時候就需要以面向物件的思想來設計有限狀態機。
# 面向物件法設計狀態機
## 面向物件基本概念
> 以面向物件的思想實現的狀態機,大量涉及了對於函式指標的用法,必須對這個概念比較熟悉
上述所提到了兩個設計方法都是基於面向過程的一種設計思想,面向過程程式設計(POP)是一種以過程為中心的程式設計思想,以正在發生的事件為主要目標,指導開發者利用演算法作為基本構建塊構建複雜系統。
即將所要介紹的面向物件程式設計(OOP)是利用類和物件作為基本構建塊,因此分解系統時,可以從演算法開始,也可以從物件開始,然後利用所得到的結構作為框架構建系統。
提到面向物件程式設計,那自然繞不開面向物件的三個基本特徵:
- 封裝:隱藏物件的屬性和實現細節,僅僅對外公開介面
- 繼承:使用現有類的所有功能,並在無需重新編寫原來的類的情況下對這些功能進行擴充套件,C 語言使用 struct 的特性實現繼承
- 多型性:使用相同的方法,根據物件的型別呼叫不同的處理函式。
上述對於面向物件的三個基本特徵做了一個簡單的介紹,封裝和繼承的概念都都比較清晰,多型性這個特點可能會有所迷惑,在這裡筆者用在書中看到一個例子來解釋多型性,例子是這樣的:
要求畫一個形狀,這個形狀是可能是圓形,矩形,星形,無論是什麼圖形,其共性都是需要呼叫一個畫的方法來進行繪製,繪製的形狀可以通過函式指標呼叫各自的繪圖程式碼繪製,這就是多型的意義,根據物件的型別呼叫不同的處理函式。
在介紹了上述很基本的概念之後,我們來看狀態機的設計。
## 實現細節
我們由淺入深地來思考這個問題,首先我們可以想到把閘機當做一個物件,那麼這個這個物件的職責就是處理 card 事件(刷卡)和 pass 事件(通過閘機),閘機會根據當前的狀態執行不同的動作,也就有了如下的程式碼:
```c
enum {LOCKED,UNLOCKED};/*列舉各個狀態*/
/*定義閘機類*/
typedef struct _turnstile
{
int state;
void (*card)(struct _turnstile *p_this);
void (*pass)(struct _turnstile *p_this);
}turnstile_t;
/* 閘機 card 事件 */
void turnstile_card(turnstile_t *p_this)
{
if (p_this-> state == LOCKED)
{
/* 切換至解鎖狀態 */
/* 執行unlock動作,呼叫 unlock 函式 */
}
else
{
/* 執行 thank you 動作,呼叫 thank you 函式 */
}
}
/* 閘機 pass 事件*/
void turnstile_pass(turnstile_t *p_this)
{
if (p_this->state == LOCKED)
{
/* 執行 alarm 動作,呼叫 alarm 函式*/
}
else
{
/* 狀態切換至鎖閉狀態 */
/* 執行 lock 動作,呼叫 lock 函式 */
}
}
```
上述程式碼的思想實現的有限狀態機相比於前兩種不需要進行大量的遍歷,也不會導致程式碼量的冗長,看似已經比較完美了,但是我們再仔細想想,如果此時狀態更改了,那 turnstile_card 函式和 turnstile_pass 函式都要更改,也就是說事件和狀態存在著耦合,這與“高內聚,低耦合”的思想所違背,也就是說如果我們要繼續優化程式碼,那需要對事件和狀態進行解耦。
## 狀態和事件解耦
將事件與狀態相分離,從而使得各個狀態的事件處理函式非常的單一,因此在這裡需要定義一個狀態類:
```c
typedef struct _turnstile_state_t
{
void (*card)(void); /* card 事件處理函式 */
void (*pass)(void); /* pass 事件處理函式 */
}turnstile_state_t;
```
在定義了狀態類之後,我們就可以使用狀態類建立 lock 和 unlock 的例項並初始化。
```c
turnstile_state_t locked_state = {locked_card,locked_pass};
turnstile_state_t unlocked_state = {unlocked_card,unlocked_pass};
```
在這裡需要補充一下上述初始化項裡函式裡的具體實現。
```c
void locked_card(void)
{
/* 狀態切換至解鎖狀態 */
/* 執行 unlock 動作 ,呼叫 unlock 函式 */
}
void locked_pass(void)
{
/* 執行 alarm 動作,呼叫 alarm 函式 */
}
void unlocked_card(void)
{
/* 執行 thank you 動作,呼叫 thank you 函式 */
}
void unlocked_pass(void)
{
/* 狀態切換至鎖閉狀態 */
/* 執行 lock 動作,呼叫 lock 函式 */
}
```
這樣,也就實現了狀態與事件的解耦,閘機不再需要判斷當前的狀態,而是直接呼叫不同狀態提供的 card() 和 pass() 方法。定義了狀態類之後,由於閘機是整個系統的中心,我們還需要定義閘機類,由於 turnstile_state_t 中只存在方法,並不存在屬性,那麼我們可以這樣來定義閘機類:
```c
typedef struct _turnstile_t
{
turnstile_state_t *p_state;
}turnstile_t;
```
到這裡,我們已經定義了閘機類,閘機狀態類,以及閘機狀態類例項,他們之間的關係如下圖所示:
![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200502235908627.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjYxNjc5MQ==,size_16,color_FFFFFF,t_70)
通過圖中我們也可以看到閘機類是繼承於閘機狀態類的,locked_state 和 unlocked_state 例項是由閘機狀態類派生而來的,那最底下的那個箭頭是為什麼呢?這是在後面需要講到的對於閘機狀態轉換的處理,在獲取輸入事件呼叫具體的方法進行處理後,我們需要修改閘機類的p_state,所以也就有了這個箭頭。
相比於最開始定義的閘機類,這個顯得更加簡潔了,同時 p_state 可以指向相應的狀態物件,從而呼叫相應的事件處理函式。
在定義了一個閘機類之後,就可以通過閘機類定義一個閘機例項:
```c
turnstile_t turnstile;
```
然後通過函式進行初始化:
```c
void turnstile_init(turnstile_t *p_this)
{
p_this-> p_state = &locked_state;
}
```
整個系統閘機作為中心,進而需要定義閘機類的事件處理方法,定義方法如下:
```c
/* 閘機 card 事件*/
void turnstile_card(turnstile_t *p_this)
{
p_this->p_state->card();
}
/* 閘機 pass 事件 */
void turnstile_pass(turnstile_t *p_this)
{
p_this->p_state->pass();
}
```
到這裡,我們回顧前文所述,我們已經能夠對閘機進行初始化並使得閘機根據不同的狀態執行不同的處理函數了,再回顧整個閘機的工作流程,我們發現閘機在工作的時候會涉及到從 locked 狀態到 unlocked 狀態的相互變化,也就是狀態的轉移,因此狀態轉移函式可以這樣實現:
```c
void turnstile_state_set(turnstile_t *p_this,turnstile_state_t *p_new_state)
{
p_this-> p_state = p_new_state;
}
```
而狀態的轉移是在事件處理之後進行變化的。那麼我們可以這樣修改處理函式,這裡用輸出語句替代閘機動作執行函式:
```c
void locked_card(turnstile_t *p_turnstile)
{
turnstile_state_set(p_turnstile,&unlocked_state);
printf("unlock\n"); /* 執行 unlock 動作 */
}
void locked_pass(turnstile_t *p_turnstile)
{
printf("alarm\n"); /* 執行 alarm 動作*/
}
void unlocked_card(turnstile_t *p_turnstile)
{
printf("thankyou\n"); /* 執行 thank you 動作*/
}
void unlocked_pass(turnstile_t *p_turnstile)
{
turnstile_state_set(p_turnstile,&locked_state);
printf("lock\n"); /* 執行 lock 動作 */
}
```
既然處理函式都發生了變化,那麼閘機狀態類也應該發生更改,更改如下:
```c
typedef struct _turnstile_state_t
{
void (*card)(turnstile_t *p_turnstile);
void (*pass)(turnstile_t *p_turnstile);
}turnstile_state_t;
```
但是回顧之前我們給出的閘機類和閘機狀態類的關係,閘機類是繼承於閘機狀態類的,也就是說先有的閘機狀態類後有的閘機類,但是這裡卻在閘機狀態類的方法中使用了閘機類的引數,其實這樣也是可行的,需要提前對閘機類進行處理,總的閘機類狀態類定義如下:
```c
#ifndef __TURNSTILE_H__
#define __TURNSTILE_H__
struct _turnstile_t;
typedef struct _turnstile_t turnstile_t;
typedef struct _turnstile_state_t
{
void (*card)(turnstile_t *p_turnstile);
void (*pass)(turnstile_t *p_turnstile);
}turnstile_state_t;
typedef struct _turnstile_t
{
turnstile_state_t *p_state;
}turnstile_t;
void turnstile_init(turnstile_t *p_this); /* 閘機初始化 */
void turnstile_card(turnstile_t *p_this); /* 閘機 card 事件處理 */
void turnstile_pass(turnstile_t *p_this); /* 閘機 pass 事件處理 */
#endif
```
上述就是所有的關於狀態機的相關定義了,下面通過上述的定義實現狀態機的實現:
```c
#include
#include
int main(void)
{
int event;
turnstile_t turnstile; /* 閘機例項 */
turnstile_init(&turnstile); /* 初始化閘機為鎖閉狀態 */
while(1)
{
scanf("%d",&event);
switch(event)
{
case 0:
turnstile_card(&turnstile);
break;
case 1:
turnstile_pass(&turnstile);
break;
default:
exit(0);
}
}
```
上述程式碼執行結果如下:
![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200503001718255.png)
# 結論
以上便是筆者關於狀態機的全部總結,講述了面向過程和麵向物件兩種實現方法,雖然從篇幅上看面向物件的方法要更為複雜,但是程式碼的執行效率以及長度都要優於面向過程的方法,所以瞭解面向物件的程式設計方法是很有必要的。
> 這篇文章是在筆者學習了《程式設計與資料結構》周立功版後的自己的理解,該書的PDF版可以從立功科技官網的周立功專欄中獲取。
下面給出書籍和文章狀態機程式碼彙總的連結:
**程式設計與資料結構**:
連結:[https://pan.baidu.com/s/17ZH7Si1f_9My7BulLs8AVA](https://pan.baidu.com/s/17ZH7Si1f_9My7BulLs8AVA)
提取碼:x1im
**FSM**:
連結:[https://pan.baidu.com/s/1qO-Dy6bHukBRGxxQ1-KGJA](https://pan.baidu.com/s/1qO-Dy6bHukBRGxxQ1-KGJA)
提取碼:vyn2
最後,如果您覺得我的文章對您有所幫助,歡迎關注筆者的個人公眾號:**wenzi嵌入式軟體**
![在這裡插入圖片描述](https://img-blog.csdnimg.cn/202005031052237