跳到主要內容

狀態模式 (State Pattern)

        如果今天你要設計一台如下圖的糖果機,你會怎麼設計呢?



        有上過資訊相關課程的人,應該不難從上圖聯想到狀態圖,上圖中每個圓圈都是一個狀態,而每個箭頭就代表狀態的轉換。有了這個概念後,把它轉成程式就不難了:

public class CandyMachine {

    // 以下四個值表示糖果機會用到的狀態
    final static int SOLD_OUT = 0;
    final static int NO_COIN = 1;
    final static int HAS_COIN = 2;
    final static int SOLD = 3;

    // 需要有一個變數來記錄目前的狀態
    // 初始設為賣完, 因為一開始機器裡沒糖果
    int mState = SOLD_OUT;

    // 也要有另一個變數記錄目前剩多少顆糖果
    int mCount = 0;

    public CandyMachine(int count)
    {
        mCount = count;

        // 機器內有糖果的話就跳到沒投錢的狀態,
        // 表示糖果機在等人投錢
        if(mCount > 0)
        {
            mState = NO_COIN;
        }
    }

    // 當投錢時會執行這個方法
    public void insertCoin()
    {
        if(mState == HAS_COIN)
        {
            System.out.println("You can't insert another coin");
        }
        else if(mState == NO_COIN)
        {
            mState == HAS_COIN;
            System.out.println("You inserted a coin");
        }
        else if(mState == SOLD_OUT)
        {
            System.out.println("It's sold out")
        }
        else if(mState == SOLD)
        {
            System.out.println("Please wait for a candy.")
        }
    }

    // 當使用者要退錢時
    public void ejectCoin()
    {
        // 省略實作細節
    }

    // 當使用者轉動曲柄時
    public void turnCrank()
    {
        // 省略實作細節
    }

    // 機器要給使用者糖果時執行的方法
    public void dispense()
    {
        // 省略實作細節
    }
}
        這樣的寫法看起來好像不錯,但要加新功能時就完了。假如現在糖果機想加一個新功能,是有 10% 的機率會給使用者兩顆糖果,上述的程式就要全部改寫了。除了要加一個新的狀態 WINNER = 4,而且所有方法都還要判斷 if(mState == WINNER),這樣就不遵守「對修改關閉,對擴充開放」的設計原則。所以上述的程式勢必要重構。新的架構可以由下列想法開始:
  1. 定義一個狀態介面,在此介面內,糖果機的每個動作都有一個對應的方法。
  2. 為糖果機中的每個狀態實作狀態類別,這些類別會負責在對應的狀態下進行機器的行為。
  3. 改寫舊的程式碼,將要做的事情都轉到狀態類別裡。
       其實照著上面的步驟,不但能遵守設計守則,也實作了這次要說的狀態模式,先來看程式碼吧:
// State 是所有狀態類別都要實作的介面,
// State 也定義了所有次類別需要實作的方法
public class NoCoinState implements State {

    private CandyMachine mCandyMachine;

    public NoCoinState(CandyMachine machine)
    {
        // 保存糖果機的參考
        mCandyMachine = machine;
    }

    @Override
    public void insertCoin()
    {
        System.out.println("You inserted a coin");

        // 切換狀態到已投錢狀態
        mCandyMachine.setState(mCandyMachine.getHasCoinState());
    }

    @Override
    public void ejectCoin()
    {
        // 沒投錢就不能退錢
        System.out.pritnln("You haven't inserted a coin");
    }

    @Override
    public void turnCrank()
    {
        // 沒投錢就不能轉曲柄
        System.out.println("No coin no candy");
    }

    @Override
    public void dispanse()
    {
        // 沒投錢當然沒糖果
        System.out.println("Please insert coin first");
    }
}

public class CandyMachine {

    // 這是糖果機會用到的所有狀態
    private State mSoldOutState;
    private State mNoCoinState;
    private State mHasCoinState;
    private State mSoldState;

    private State mState = mSoldOutState;
    private int mCount = 0;

    public CandyMachine(int numOfCandy)
    {
        // 建立所有狀態實體
        mSoldOutState = new mSoldOutState(this);
        mNoCoinState = new mNoCoinState(this);
        mHasCoinState = new mHasCoinState(this);
        mSoldState = new mSoldState(this);
        mCount = numOfCandy;

        if(mCount > 0)
        {
            mState = mNoCoinState;
        }
    }

    // 以下的動作變得很容易實作
    // 我們只是將動作轉介到目前的狀態
    public void insertCoin()
    {
        mState.insertCoin();
    }

    public void ejectCoin();
    {
        mState.ejectCoin();
    }

    public void turnCrank()
    {
        mState.turnCrank();
        mState.dispense();
    }

    public void setState(State state)
    {
        mState = state;
    }

    public void releaseCandy()
    {
        System.out.println("A candy comes out!");
        if(mCount != 0)
        {
            mCount = mCount - 1;
        }
    }

    // 其他方法像是 getHasCoinState() 等
}

        其他狀態的實作也大同小異,都是在特定狀態做對應的事情。由上面的程式碼,我們可以知道:
  1. 將每個狀態的行為定義在自己的類別裡。
  2. 將容易產生問題的 if 敘述拿掉,以方便日後維護。
  3. 讓每個狀態「對修改關閉」。讓糖果機「對擴充開放」,因為容易加入新的狀態。
  4. 較舊程式碼更容易閱讀理解。
        修改後的程式碼已經是狀態模式的實作了,我們就來看看此模式的正式定義吧:

狀態模式允許物件隨著內在的狀態改變而改變行為,好像物件的類別改變了一樣。
Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

        定義的前半部可以從程式碼來理解。當糖果機在 NoCoinState 或是 HasCoinState 兩種不同狀態時,你投入硬幣,就會得到不同的行為 (機器接受硬幣,以及機器拒絕硬幣)。而後半部定義「好像物件改變了」是什麼意思?這要從客戶的觀點來看:如果說使用的物件能夠完全改變它的行為,客戶會覺得這個物件是從別的類別所實體化出來的。但實際上我們是用合成的方式,將動作導到其他的狀態物件,造成行為改變的假象。


        從類別圖來看,Context (工作環境) 是一個類別,它可以擁有一些內部的狀態。在我們的例子中,糖果機就是這個 Context。State定義了一個所有具象狀態共同的介面;任何狀態都要實作這個共同介面,這樣一來,狀態之間可以互相替換。state.handle() 就是 Context 有呼叫到 request() 時,就會轉介到真正的狀態進行處理。

        不知道看到這裡有沒有人發現,狀態模式的類別圖跟策略模式的類別圖竟然一模一樣!雖然類別圖一樣,但這兩個模式的差別,是在於它們的「意圖」。以狀態模式而言,我們將一群行為封裝在狀態物件中,Context 的行為隨時能委派到其中一個狀態物件中,而狀態改變,Context 的行為也跟著改變,但是 Context 的客戶對狀態物件所知不多,甚至根本不知道有狀態物件。

        而對策略模式而言,客戶通常主動指定 Context 所要合成的策略物件為何者。策略模式雖然有彈性,能在執行時期改變策略,但對於某個 Context 物件來說,經常只會有一個最適當的策略物件。比如有些鴨子被設定成一般的飛行行為,而有些鴨子(如橡皮鴨)卻要另外使用不同的飛行行為。

        一般來說,要把策略模式想成是除了繼承之外,更有彈性的替代方案,因為可以組合不同的物件改變行為。而把狀態模式想成是不用在 Context 中放置許多條件判斷的替代方案,因為可以在 Context 內簡單改變狀態物件,來改變 Context 的行為。

參考資料:

        深入淺出設計模式(Head First Design Patterns)

留言

  1. 依你的類別圖來看,State 應該不會知道 Context,但是你的程式實作卻違反了這個類別圖,State 去瞭解了 Context(CandyMachine)。我認為應該要去掉這層相依關係比較好吧

    回覆刪除
    回覆
    1. @洪彥彬 感謝你的留言。文中的類別圖是照著「深入淺出設計模式」這本書畫出來的。我個人認為書中所要強調的觀念是 Context 「知道」State (程式上實作就是 Context 類別有「有」一個 State 實體)。User 要透過 Context 做的事情,都會委派給 State 去處理。

      你提的問題跟如何「更新」State 比較有關係。文中的範例只是一種做法而已。你要用其他符合類別圖的方法來達成更新 Context 裡的 State,如 Callback 或是 State 的 method 都帶入 Context 當參數都是可以的,看你的程式如何設計。雖然程式碼跟類別圖有一點不同,但基本觀念是不變的。以上是個人的一點淺見

      刪除
  2. 這個 NoCoinState 類別本身就違反了 [對修改關閉,對擴充開放] 原則了吧,對於任何新的狀態都要修改這個類別才能有新功能。

    回覆刪除

張貼留言

這個網誌中的熱門文章

訪問者模式 (Visitor Pattern)

        假設你設計一個系統,其中會有一些相似類別,類別中都有某些方法內容相似,但還是需要判斷目前要做事的是哪個類別才能呼叫對應的適當類別。通常遇到這種情情,在 Java 中最直接的做法就是使用 instanceof 關鍵字來判斷,如以下的簡單範例: public interface CarComponent { public void printMessage(); } public class Wheel implements CarComponent { @Override public void printMessage() { System.out.println("This is a wheel"); } // 這是 Wheel 跟 Engine 不同的方法 public void doWheel() { System.out.println("Checking wheel..."); } } public class Engine implements CarComponent { @Override public void printMessage() { System.out.println("This is a engine"); } // 這是 Wheel 跟 Engine 不同的方法 public void doEngine() { System.out.println("Testing this engine..."); } } public class Car { private List mComponents; public Car() { mComponents = new ArrayList<carcomponent>(); } // 有些時候我們還是需要針對不同類別去做不同的事情 public void setComponent(CarCompon...

裝飾者模式 (Decorator Pattern)

        假如你有一間飲料店, 目前只有賣幾種咖啡。因為生意很好, 因此想更換菜單…         以下是目前菜單的類別圖:         簡單說明此類別圖, cost() 是抽象的, 子類別要實作自己的 cost() 來告知飲料的價格。         買咖啡時, 也可要求要加料, 例如牛奶(Milk)、摩卡(Mocha,就是巧克力口味)。這樣的新類別要如何設計呢 ? 看起來是不能直接新增所需的子類別, 例如 EspressoWithMilk, EspressoWithMilkAndMocha, DarkRoastWithMilk, DarkRoastWithMilkAndMocha… 這樣加下去, 日後飲料跟配料越來越多時, 類別也就越多, 這實在不是個好設計。         換個方式設計呢, 在 Beverage 裡面加入所有的配料如何 ? 這樣好像也不太好, 未來要是配料有更動, Beverage 程式碼就要重寫, 而未來要是有新口味的飲料時, 有些配料就不太合理 ( 薑茶加摩卡 ? ), 更麻煩的是, 無法應付機車的客人 (例如要加 3 份牛奶)。這時候裝飾者模式就能上場啦。在介紹裝飾者模式前, 先說明其設計守則: 類別應該開放, 以便擴充 ; 應該關閉, 禁止修改。         我們的目標是允許類別容易擴充, 在不修改現有程式碼的情形就能搭配新的行為。這樣的設計具有彈性, 可以接受新功能以達到改變需求的目的。這看起來好像有點矛盾, 但是的確有一些技術可以在不直接修改程式碼的情形下進行擴充, 如裝飾者模式。         這時候應該有人會問: 那是不是以後我的專案架構設計都遵循這個守則就是好設計了 ? 答案是不太可能, 也沒這必要, 就算做得到, 也可能是浪費, 容易導致程式碼複雜且難以理解。只需小心選擇哪些部分未來會擴充, 這些部份遵循這個設計守則即可。         接下來...