[Design Patterns-Python]二.狀態模式

Daniel Chiang
Jan 28, 2021

--

情境

假設今天你是一名外包工程師,有一家廠商希望你幫她設計一台飲料販賣機,目前所知道主要的商品是咖啡飲品。在一大堆口語解釋操作方式之後,繪製出了這張圖,操作大致如下圖:

首先看到這張圖,這是一張狀態圖,每個圓圈表是一種狀態。很明顯在”沒有投入錢”時為起始狀態,在經過指定操作就會進入下一個狀態,像是當你在"沒有投入錢"時投錢就會變成"已投入錢"的狀態。每個狀態都要謹慎考慮使用者作出的動作是否合適,如果不合適又該如何回應,像是如果有人在"沒有投入錢"的狀態卻按了指定飲料按鈕,這時候我們當然不能送出飲料,而是要告訴使用者尚未投錢之類的訊息。

實做

在開始前想想你會怎麼做?
如果是我了話,我會進行以下步驟:

  1. 在vending_machine.py裡建立一個VendingMachine類別實做販賣機的操作。
  2. 定義四個不同狀態常數(NOMONEY, HASMONEY, SOLD, SOLDOUT),以及在建構式中定義state屬性紀錄現在的狀態。
  3. 定義四個方法(insert_money, eject_money, press_button, dispense)實現販賣機的操作。
  4. 在每一個方法中,需針對不同狀態作出不同動作。

上面的程式碼能滿足我們不同操作上的需求,也謹慎地對不同狀態遇到不合適的操作回傳一些警告訊息。
當你看到這麼多條件式,會不會對於這段程式碼有著莫名的懷疑。如果沒有也沒關係,我們只要稍作假設,問題就能更明顯。

情境-新需求

廠商對於你開發的飲料販賣機使用一陣子後覺得相當滿意,希望未來能長期合作並配合他的需求為飲料販賣機作出更新。
廠商現在希望能判斷投入錢正確,因為原本飲料都是10元一罐,但廠商之後可能會不時調整價格,所以希望有更完整判斷機制。

永遠躲不掉的需求改變讓你又在電腦前面大罵廠商一輪,但問題依舊還在,這也是設計模式之所以會存在,我們無法控制客戶、老闆的想法,但至少我們能控制我們的程式碼更加具有彈性。

我們回來看看要做什麼,現在我們多了一個正確金額的狀態,如果要把這個功能加進去會動到些什麼,我們要多定義一個新的狀態CorrectMoney,然後在每一個方法新增一筆判斷式,判斷如果狀態為CorrectMoney之後作出相應的動作。可不可怕? 你現在只是多加了一個功能結果類別裡的每個方法都要修改,相信你經過前一章策略模式就學過的設計守則知道,不要把容易改變部分跟不常更動部分放在一起。可怕的是你不知道廠商什麼時候又會有更可愛的需求提出來。

你遇到的問題,很多前輩都遇過了,他們將經驗歸納出了原則成了設計模式,現在我們來看看他們是怎麼解決這麼多繁瑣條件式,搞得我們程式難以擴充。

設計模式實作

其實我們要作的動作很簡單,在前一章策略模式你也已經學過了,封裝會變動的部分,變動的部分就是不同狀態之下的行為。像是當今天多了CorrectMoney狀態表示正確得金額,就意味著HasMoney表示使用者投入的金額不正確,因此在dispense這個行為下兩個狀態就有不同的結果,這就是會變動的部分。
因此我們可以將狀態封裝在各自的類別中,並且使用合成的概念,我們將每個狀態類別傳入VendingMachine中,因此當動作發生時,我們委由狀態文件去執行,因為使用合成,狀態物件能動態地改變。如果你看得有點霧煞煞,待會看到程式碼後會更清楚或是回頭複習[Design Patterns-Python]一.策略模式
我們將進行大幅度的更動:

State 實做

  1. 建立一個狀態介面,並且有飲料販賣機每一個動作的方法。
  2. 將每個狀態實踐這個狀態介面,這些類別負責在對應的狀態進行飲料販賣機的行為。
  3. 使用合成的概念,將動作轉介到狀態類別,淘汰了原本條件判斷式。
因為篇幅的關係,文章中就只放一個狀態類別實做,完整版可以參考我的github

每個狀態類別實做上基本差不多,在初始化會將VendingMachine物件當作參數傳進來,如果使用者作出正確的操作,VendingMachine就會改變狀態set_state(),如果錯誤的操作就會print 一些緊告訊息。像是如果在NomoneyState使用退錢(eject_money)了話,使用者就會看到You have not insert a money,但如果使用者投入錢了話VendingMachine就會改變狀態成有錢的(HasMoneyState)狀態。

vending_machine 實做

  1. 建構式:
    VendingMachine初始化參數傳入飲料(咖啡)的數量以及價錢。
    ● 我們不再使用靜態的常數(NOMONEY, HASMONEY, SOLD, SOLDOUT)來表示狀態,而是使用物件(SoldOutState(self), NoMoneyState(self), HasMoneyState(self), CorrectMoneyState(self))。VendingMachine自身當做參數傳入狀態類別。
    ● 我們一樣會定義一個state屬性來儲存當前的狀態,在初始時定義它為賣完的狀態(SoldOutState),但如果咖啡數量為正就設定為尚未投入錢狀態(NoMoneyState)。
  2. VendingMachine定義了原先三個操作方法(insert_money, eject_money, press_button),但現在這些動作不需要它自行實踐,而是轉介由現在的狀態(state)實做。而為什麼沒有定義dispense呢?我們從現實的角度切入就會覺得很合理,在正常人操作自動飲料販賣機時,使用者並不能自行叫機器吐出飲料,而是按按鈕時機器會自行判斷是否要送出飲料。回到程式邏輯上也是一樣,我們將狀態呼叫dispense放在press_button的方法中,由狀態自行決定是否這位使用者以達到能送出飲料得條件。
  3. 定義release方法,由SoldState狀態時調用。release方法實做將飲料送出,並減少飲料(咖啡)數量。
  4. 定義set_state方法改變狀態。並且你會看到狀態物件在改變狀態時調用封裝的方法get_has_money_state來拿到HasMoneyState物件,所以我們還會定義其他方法來取得狀態物件。
  5. 剩下的方法都是取得物件中私有屬性的方法。

在比較最初的程式碼,我們成功將容易產生問的判斷式移除,這有利於我們擴充及維護。這都歸功於我們將"行為"都區域化進各自的狀態類別中。我們現在"關閉"了修改狀態類別,但"開放"飲料販賣機的類別修改以便於擴充(像是加入新的狀態)。恭喜你又默默地學會一個守則:

開放關閉守則: 類別應該開放,以便擴充;應該關閉,禁止修改。

這個守則乍聽之下有些矛盾,如果我在最開頭就告訴你,可能難以理解,但當我們完成了狀態以及自動飲料機的類別,有著實際的例子你應該更能體會。
我們的終極目標就是類別容易擴充,並在不修改程式碼的情況下增加新的行為。
最後來玩玩我們的自動飲料販賣機吧。

定義

狀態模式(State Pattern): 允許物件隨著內在的狀態改變而改變行為,好像物件的類別改變了一樣。

我相信在上面範例的介紹,應該讓你對狀態模式的概念已經有更深刻的理解了,最後我想比較一下策略模式與狀態模式。
補: context(工作環境)可以擁有一些內部狀態的類別,以我們的例子是VendingMachine。

策略模式: 使用者通常會主動指定context要合成的策略為何者,它的彈性在於能在執行期間改變策略,但對某個context而言,通常只有一個最適當的策略物件。

狀態模式: 使用者通常對於狀態物件所知不多,隨著時間在走,context內部的行為隨著目前的狀態持續改變。

本篇範例程式

下一篇還是會講講跟策略模式有點神似的樣板方法模式

--

--