跳到主要內容

狀態模式 (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...

解譯器模式 (Interpreter Pattern)

        解譯器模式簡單來說就是 把一句有特殊規則的語句,透過解釋器將它真正的意思表現出來 。相信有學過 Context-free grammar (CFG),Backus–Naur form (BNF),或是 Compiler 相關程的人會比較了解這個模式 (不是我,我早就忘光了…)。不知道上面術語的人,還是可以透過接下來的介紹來稍微了解這個模式。先來看一下這個模式的正式定義及類別圖: 定義一個語言與其文法,使用一個解譯器來表示這個語言的敘述 Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language Context 通常是指待解譯的語句 AbstractExpression 是所有規則都要實作的介面 TerminalExpression 是指無法再展開的規則,算是最小單位的規則 NonterminalExpression 是指可以再展開的規則,可以展開成 NonterninalExpression 和 TerminalExpression 的組合         從類別圖可以看到,語法可能可以一直展開,這時就可以用語法樹來表示,而語法樹以程式來表達的話,就可以使用 合成模式 。而通常比較正式的語法會用 BNF 來表示,如下: expression ::= plus | minus | variable | number plus ::= expression expression '+' minus ::= expression expression '-' variable ::= 'a' | 'b' | 'c' | ... | 'z' digit = '0' | '1' | ... | '9' number ::= digit | digit number 而 每個語法都會定義一個類別 ,如 pl...

代理人模式 (Proxy Pattern)

        代理人模式如同字面上的意思,就是做事情時(如取得某些資料),是透過代理人,而不是直接跟提供資料的物件構通。先來看看此模式的正式定義及類別圖: 代理人模式讓某個物件具有一個替身,藉以控制外界對此物件的影響。 Provide a surrogate or placeholder for another object to control access to it.         從類別圖可以看到, Subject 是共用的介面,可以讓客戶 將 Proxy 物件視為 RealSubject 物件 來處理。RealSubject 是真正做事的物件,被 Proxy 代理, Proxy 可以控制 RealSubject 的存取 。Proxy 持有 RealSubject 的參考,客戶和 RealSubject 的互動都要透過 Proxy,在某些情形下這樣的限制是必要的,如 RealSubject 是遠端物件, RealSubject 建立成本高等等。         此模式有許多種變形,都是依據上面的原則而發展出來的。以下介紹  深入淺出設計模式(Head First Design Patterns) 裡面提到的一些應用方式:         1. 遠端代理人(Remote) :算是最常使用到的應用方式。在網路中或是跨 Process 的各種程式,不可能直接存取(assess) 不同程式間的物件,因此就需要遠端代理人。 Java RMI  和 Android AIDL  都算是遠端代理人的實踐。         2. 虛擬代理人(Virtual) :代理的對象是建立很花費資源的物件。當物件建立前和建立中,由虛擬代理人扮演代理的角色。物件建立完成後,代理人就會將讓求直接轉給物件。以下為簡單範例程式: // Proxy 實作跟 RealSubject 共同的介面 Icon public class ImageProxy implements Icon { // 這是我們的 RealSubje...