使用coroutine實現狀態機
很久以前給GacUI的Workflow腳本加入了coroutine的功能,現在馬上就要有狀態機了。狀態機是實現GUI或者他大量的東西,使用很頻繁的一種抽象手段。但是每一次做的時候都沒有辦法在代碼寫得很好看的前提下把狀態機抽象成庫。其原因很容易理解,因為狀態機也是一個控制流的問題,而不改語法是沒有辦法修改控制流的。
一直以來都在嘗試把狀態及翻譯為coroutine,直到最近終於找到了一個比較簡單的辦法。《凌波微步》曾經講到一個類似的問題,如何用狀態機來實現計算器。現在我也以這個作為例子,來描述一下我的這個想法。
首先我們給出一個簡化後的計算器的ViewModel應有的樣子:可以輸入數字、小數點、還有加號、乘號、等號、清除。寫成代碼大概就是這個樣子:
class Calculatorn{n prop Value : string = "0" {const}nn func Digit(i: int): void;n func Dot(): void;n func Add(): void;n func Mul(): void;n func Equal(): void;n func Clear(): void;n}n
這個類給了你一個只讀的、帶有ValueChanged事件的Value屬性,還有象徵按鈕的幾個函數。一個簡單的ViewModel就這樣做出來了。在GUI上面,只需要把Value屬性綁定到為本框裡面,然後把按鈕綁定到幾個函數上,這樣程序就能用了。因此關鍵就在於實現這幾個函數。
剛開始學編程的時候,如果試圖做一個這樣的東西,都會有比較大的挑戰。因為這並不是那種兩個輸入框+一個輸出框的計算器,而是只有一個顯示屏幕,上面顯示的內容,有時候等於計算結果,有時候等於編輯了一半的數字,很容易搞錯。不過如果我們用狀態機的思維來思考他,雖然不能降低複雜度,但是至少在思路上就可以理清楚,寫代碼的時候也就有底氣了。
現在讓我們先不要考慮狀態本身應該由什麼變數組成。狀態機的結構無非就是狀態加上一些象徵狀態轉換的邊,邊通常意味著GUI上的輸入,那麼很明顯,下面的6個函數自然就是邊了。同一個函數在不同的狀態下面會執行不同的內容,並且跳轉到其他狀態下。因此如果直接來實現這個東西的話,就相當於把整個邏輯都打散了。
因為我們所期望的代碼組織方法是,把邏輯按照當前狀態分類,把一個狀態下面按不同按鈕的行為寫在一起。而不是像現在這樣按輸入來分類。
那到底這種代碼要怎麼表達呢?目前我提供了一個思路,雖然還沒有最終定稿,實現出來可能會有一些細微的差別。既然要按照狀態分類,那我們就把狀態看成一個聲明語句:
class Calculatorn{n var valueFirst : string = "";n var op : string = "";n prop Value : string = "0";n n func Update(value : string) : voidn {n SetValue(value);n valueFirst = value;n }n n func Calculate() : voidn {n if (valueFirst == "")n {n valueFirst = value;n }n else if (op == "+")n {n Update((cast double valueFirst) + (cast double Value));n }n else if (op == "*")n {n Update((cast double valueFirst) * (cast double Value));n }n elsen {n raise $"Unrecognized operator: $(op)";n }n }n n $state_machinen {n $state_input Digit(i : int);n $state_input Dot();n $state_input Add();n $state_input Mul();n $state_input Equal();n $state_input Clear();n n $state Digits()n {n $switch(pass)n {n case Digit(i)n {n Value = Value & i;n $goto_state Digits();n }n }n }nn $state Integer(newNumber: bool)n {n $switch(pass)n {n case Digit(i)n {n if (newNumber)n {n Value = i;n }n elsen {n Value = Value & i;n }n $goto_state Digits();n }n }n }nn $state Number()n {n $push_state Integer(true);n $switch(pass_and_return)n {n case Dot()n {n Value = Value & ".";n }n }n $push_state Integer(false);n }nn $state Calculate()n {n $push_state Number();n $switchn {n case Add(): {Calculate(); op = "+";}n case Mul(): {Calculate(); op = "-";}n case Equal(): {Calculate(); op = "=";}n case Clear():n {n valueFirst = "";n op = "";n Value = "0";n }n }n $goto_state Calculate();n }n n $staten {n $goto_state Calculate();n }n }n}n
就像這樣。一個類的一旦以$state_machine結尾,那麼他就會給你用$state_input的留個函數之外,還給你生成一個RunStateMachine函數用來初始化。RunStateMachine就等於從$state{...}開始執行。現在我們來人肉執行一下。
假設我們輸入的東西是1.2+30*2,那麼結果自然是(1.2+30)*2=62.4,這也是普通的計算器會給出的結果。在一般情況下,valueFirst、op和Value分別代表左操作數、操作符和右操作數。當你按下1+2之後,如果在某個時間點觸發了Calculate函數,那麼valueFirst將等於3,Value也等於3(為了顯示在GUI上,ViewModel當然是直接跟GUI掛鉤的),op等於"+"。這個時候按下了數字鍵,數字鍵在不同的情況下,要麼把數字增加到Value後面,要麼直接替換掉Value,根據當前處於不同的狀態有不同的行為。
$state什麼都不幹先跳轉到了Calculate(雖然名字跟Calculate函數一樣但實際上是不同的東西)狀態,$Calculate也什麼都不幹先在對棧上壓入一個Number狀態。主義$goto_state和$push_state的區別。$push_state會把指定的狀態當成一個新的狀態機的初始狀態來運行,等退出以後回到$push_state的下一行語句開始運行,他們的關係就像goto和函數調用一樣。Number也什麼都不管先壓入了Integer,Integer則開始執行$switch。
$switch是一個等待輸入的語句,這個時候代碼就停在這裡了,直到我們調用了Digit函數,也就是按下數字鍵的時候才開始執行。注意這裡的$switch(pass),pass代表了如果輸入不滿足要求的話,就直接跳過$switch運行下面的東西。自己推演的話會發現,(如果我沒有寫錯)直接按下"+"會導致左操作數當成0處理。這也就是為什麼Value屬性的初始值是0。
好了現在我們按下了"1",那麼case就進去了。Integer進來的時候newNumber是true,所以Value就被賦值成了"1"。現在我們跳轉到了Digits狀態,在這個狀態裡面,又開始執行$switch。
現在我們按下了"."。$switch(pass)匹配失敗,所以Digit狀態就結束了,整個狀態機也就結束了。這個狀態及是從Number狀態的第一行壓進去的,所以這個時候就退到了$switch(pass_and_return)這裡。$switch裡面一共可以寫
- 不寫:匹配錯誤就拋異常。
- pass:匹配錯誤就接下去執行,讓下一個$switch匹配。
- pass_and_return:匹配錯誤就直接結束當前狀態機,讓下一個$switch匹配。
- ignore和ignore_and_return也差不多,區別在於他不會讓下一個$switch去匹配,而是直接把輸入忽略了。下一個$switch會進入等待狀態。
現在我們命中了,所以Value變成了"1."。然後有進入了Integer(false)狀態。
現在我們按下了2,Value變成了"1.2",Number開始的狀態及也結束了。這個狀態機是Calculate的第一行壓進去的,於是又進入了下一個$switch。
現在我們按下了"+",因此執行Calculate函數,結果很明顯,現在valueFirst是"1.2",op是"+",Value還是"1.2"沒有變。然後開始了新一輪的循環。剩下的你們自己模擬一下。
我們可以發現,當我們按下數字鍵的時候,取決於我們是在Integer(true)還是Integer(false)裡面,Value會被不同的方式修改。因此我們已經完成了把邏輯按照狀態來組織的目標了。唯一的問題就是實現它。
實現其實並不難,畢竟coroutine已經早就有了,我們把它編譯成coroutine,最後coroutine又會被編譯成普通代碼,幾輪codegen下來,控制流就被分解為正常編程語言的東西了。
在這裡貼一下我手寫的生成出來的代碼:vczh-libraries/Release 。這幾天我就把它實現了,然後就可以出一個新的計算器Demo。XML腳本裡面就是這個簡單易懂的狀態機,而生成的C++代碼,當然只能是以輸入來組織代碼。大家就能看到我是如何把這個東西拆開成普通地控制流語句的了。
推薦閱讀: