設計模式-狀態模式

煮詩君發表於2021-05-01

說到狀態模式,顧名思義,應該就是跟狀態相關的設計模式了,不過,我們還是跟前面一樣,先不管狀態模式是個什麼東西,先從一個小小的例子出發,看看狀態模式能為我們解決什麼問題。

示例

現在需要實現一個交通燈排程程式,交通燈的顏色需要在紅燈->綠燈->黃燈->紅燈之間迴圈轉換,但是不允許綠燈->紅燈或黃燈->綠燈等情況。這屬於交通規則的常識,現在我們用程式實現它,先看看我們最傳統的思考和實現方式。

首先,我們會很容易想到需要定義一個交通燈顏色的列舉:

public enum LightColor
{
    Green,
    Red,
    Yellow
}

然後,定義一個交通燈的類,在交通燈類中處理顏色轉換及相應的業務邏輯,程式碼如下:

public class TrafficLight
{
    private LightColor _lightColor;
    public TrafficLight()
    {
        _lightColor = LightColor.Red;
    }

    public void Turn()
    {
        if (_lightColor == LightColor.Red)
        {
            Console.WriteLine("紅燈停");
            _lightColor = LightColor.Green;
        }
        else if (_lightColor == LightColor.Green)
        {
            Console.WriteLine("綠燈行");
            _lightColor = LightColor.Yellow;
        }
        else if (_lightColor == LightColor.Yellow)
        {
            Console.WriteLine("黃燈亮了等一等");
            _lightColor = LightColor.Red;
        }
    }
}

最後,再不妨呼叫執行一下:

static void Main(string[] args)
{
    TrafficLight light = new TrafficLight();
    light.Turn();
    light.Turn();
    light.Turn();
    light.Turn();
}

顯而易見,這段程式碼是完全滿足需求的,並且邏輯嚴謹,呼叫方式也極其簡單,如果需求不變,這或許就是最好的實現方式了。但是經過前面設計原則的薰陶,我們知道,需求不變是不可能的。因此,我們很容易就會發現這段程式碼存在的問題,充斥著if-else的條件分支,這就意味著擴充套件困難。這裡例子簡單,可能並不明顯,但是真實專案中必然會有更多的條件分支和更多類似Turn()的方法,這會導致整個專案擴充套件維護起來極其困難,因為,它嚴重違背了開閉原則

其實,對於解決if-elseswitch-case帶來的問題,我們已經相當有經驗了,在簡單工廠模式中,我們採用工廠方法模式抽象出生產具體類的工廠類解決了switch-case的問題,在上一篇的策略模式中,我們通過將方法抽象成策略類的方式,同樣解決了switch-case的問題。這裡也不例外,我們也一定需要抽象點什麼才行。但是具體抽象什麼呢?燈的顏色?Turn()方法?還是別的什麼?思路好像並不是那麼清晰。不過呢,我們發現其實這段程式碼結構跟策略模式改造前的例子極其相似,我們不妨用策略模式改造一下,看看能否滿足需求,如果不滿足,看看還缺點什麼,然後再進一步改造,因為我們知道,策略模式至少能解決if-elseswitch-case的問題。

我們看看策略模式改造後的程式碼,先將Turn()方法抽象成策略類:

public interface ITurnStrategy
{
    void Turn();
}

public class GreenLightTurnStrategy : ITurnStrategy
{
    public void Turn()
    {
        Console.WriteLine("綠燈行");
    }
}

public class RedLightTurnStrategy : ITurnStrategy
{
    public void Turn()
    {
        Console.WriteLine("紅燈停");
    }
}

public class YellowLightTurnStrategy : ITurnStrategy
{
    public void Turn()
    {
        Console.WriteLine("黃燈亮了等一等");
    }
}

再看看改造後的TrafficLight類:

public class TrafficLight
{
    private ITurnStrategy _turnStrategy;

    public TrafficLight(ITurnStrategy turnStrategy)
    {
        _turnStrategy = turnStrategy;
    }

    public void Turn()
    {
        if (_turnStrategy != null)
        {
            _turnStrategy.Turn();
        }
    }

    public void ChangeTurnStrategy(ITurnStrategy turnStrategy)
    {
        _turnStrategy = turnStrategy;
    }
}

一切看起來似乎都很完美,天衣無縫。再來看看如何使用:

static void Main(string[] args)
{ 
    TrafficLight light = new TrafficLight(new RedLightTurnStrategy());
    light.Turn();
    light.ChangeTurnStrategy(new GreenLightTurnStrategy());
    light.Turn();
    light.ChangeTurnStrategy(new YellowLightTurnStrategy());
    light.Turn();
    light.Turn();
}

一用就發現了問題,呼叫變複雜了。其實,為了能讓系統更容易擴充套件,呼叫時複雜一點也沒什麼,但是,另一個致命的問題卻不能忽視,我們希望燈顏色切換是由內部一套固定機制控制,而不是呼叫方來決定,如果使用者想換什麼顏色就換什麼顏色,交通規則豈不亂套了?顯然,策略模式是不滿足需求的,我們其實希望light.ChangeTurnStrategy()這個動作,由系統自己內部完成。

既然不滿足需求,那麼問題到底出在哪呢?回過頭來再梳理一下,我們發現或許我們的思路一開始就出現了偏差,交通燈能換顏色嗎?顯然是不能的,因為每個燈的顏色是固定的,我們所謂的換顏色,實際上換的是燈,難道要用工廠方法模式來創造不同顏色的燈?顯然也不合適,三個燈一開始就在那裡,只是迴圈切換而已,不存在建立的過程。實際上,我們或許應該換一種思路,這裡明顯體現的是交通燈的三種狀態,每一種狀態下對應一種需要處理的行為動作,同時,也只有狀態才有切換的過程。

換一種思路後,我們看問題的角度就不一樣了,看看改變思路後的程式碼:

public abstract class TrafficLightState
{
    public abstract void Handle(TrafficLight light);
}

public class GreenState : TrafficLightState
{
    public override void Handle(TrafficLight light)
    {
        Console.WriteLine("綠燈行");
        light.SetState(new YellowState());
    }
}

public class RedState : TrafficLightState
{
    public override void Handle(TrafficLight light)
    {
        Console.WriteLine("紅燈停");
        light.SetState(new GreenState());
    }
}

public class YellowState : TrafficLightState
{
    public override void Handle(TrafficLight light)
    {
        Console.WriteLine("黃燈亮了等一等");
        light.SetState(new RedState());
    }
}

public class TrafficLight
{
    private TrafficLightState _currentState;

    public TrafficLight()
    {
        _currentState = new RedState();
    }

    public void Turn()
    {
        if (_currentState != null)
        {
            _currentState.Handle(this);
        }
    }

    public void SetState(TrafficLightState state)
    {
        _currentState = state;
    }
}

其實,可以發現,除了類名和方法名變了,程式碼跟策略模式幾乎一模一樣(具體演化過程,文字難以表達清楚,可以看一下我在B站或者公眾號上的視訊),但含義卻是天差地遠,這裡不是直接將方法抽象成策略物件,而是抽象不同的狀態,因此用了抽象類,而非介面(也可以用介面,但是我們通常會將方法抽象成介面,而將物件或屬性抽象成類);併為每個狀態提供處理該狀態下對應行為的介面方法,而不是直接提供具體行為的介面方法。

另外,引數也有所不同,TrafficLightState中需要持有對TrafficLight的引用,因為需要在具體的狀態類中處理TrafficLight的狀態轉移。改造後的程式碼再次完美滿足需求,呼叫方又變得簡單了,狀態的轉移再次迴歸了主權:

static void Main(string[] args)
{
    TrafficLight light = new TrafficLight();
    light.Turn();
    light.Turn();
    light.Turn();
    light.Turn();
}

這就狀態模式了,下面是交通燈示例最終的類圖:

有限狀態機

從上面的例子中,我們可能會很容易聯想到狀態機,我們也經常聽到或看到有限狀態機或無限狀態機這樣的字眼,那麼有限狀態機跟狀態模式有什麼關係呢?我們先看看有限狀態機的工作原理。有限狀態機的工作原理是,發生 事件(event) 後,根據 當前狀態(cur_state) ,決定執行的 動作(action) ,並設定 下一個狀態(nxt_state)。從交通燈例子可以看到,事件(event) 就是TrafficLight中的Turn()方法,由客戶端觸發,觸發後,系統會判斷當前處於哪種燈的狀態,然後執行相應的動作,完成之後再設定下一種燈狀態,和有限狀態機的工作原理完美對應上了。那麼,二者是否等價呢?其實不然,狀態模式只是實現有限狀態機的一種手段而已,因為if-else版本的實現,也是有限狀態機。

這裡算是一個小插曲,下面我們迴歸到狀態模式。

定義

狀態模式允許一個物件在其內部狀態改變時改變它的行為,從而使物件看起來似乎修改了它的類。

UML類圖

我們將交通燈示例的類圖抽象一下,就可以得到如下狀態模式的類圖:

  • Context:上下文環境,定義客戶程式需要的介面,並維護一個具體狀態角色的例項,將與狀態相關的操作委託給當前的 ConcreteState物件來處理;
  • State:抽象狀態,定義特定狀態對應行為的介面;
  • ConcreteState:具體狀態,實現抽象狀態定義的介面。

優缺點

優點

  • 解決switch-caseif-else帶來的難以維護的問題,這個很明顯,沒什麼好說的;
  • 結構清晰,提高了擴充套件性,不難發現,Context類簡潔清晰了,擴充套件時,幾乎不用改變,而且每個狀態子類也簡潔清晰了,擴充套件時也只需要極少的改變。
  • 通過單例或享元可使狀態在多個上下文間共享。

這個問題需要單獨說,我們不難發現,狀態模式雖然解決了很多問題,但是每次狀態的切換都需要建立一個新的狀態類,而原本它僅僅是一個小小的列舉值而已,這樣一對比,物件重複的建立資源開銷是否過於巨大?其實,要解決物件重複建立的問題,我們知道,單例模式和享元模式都是不錯的選擇,具體選用哪一個,就要看狀態類的數量和個人的喜好了。

下面是採用享元模式改進的程式碼,首先是熟悉的享元工廠,程式碼很簡單:

public class LightStateFactory
{
    private static readonly IDictionary<Type, TrafficLightState> _lightStates
           = new Dictionary<Type, TrafficLightState>();

    private static readonly object _locker = new object();
    public static TrafficLightState GetLightState<TLightState>() where TLightState : TrafficLightState
    {
        Type type = typeof(TLightState);
        if (!_lightStates.ContainsKey(type))
        {
            lock (_locker)
            {
                if (!_lightStates.ContainsKey(type))
                {
                    TrafficLightState typeface = Activator.CreateInstance(typeof(TLightState)) as TrafficLightState;
                    _lightStates.Add(type, typeface);
                }
            }
        }

        return _lightStates[type];
    }
}

使用就更簡單了,將建立狀態物件的地方換成享元工廠建立就可以了,程式碼片段如下:

public override void Handle(TrafficLight light)
{
    Console.WriteLine("紅燈停");
    light.SetState(LightStateFactory.GetLightState<GreenState>());
}

這裡需要特別提一下,由於狀態是單例的,可以在多個上下文間共享,而任何時候,涉及到全域性共享就不得不考慮併發的問題。因此,除非明確需要共享,否則狀態類中不應持有其它的資源,不然可能產生併發問題。同樣的原因,狀態類也不要通過屬性或欄位的方式持有對Context的引用,這也是我採用區域性變數對TrafficLight進行傳參的原因。

缺點

  • 隨著狀態的擴充套件,狀態類數量會增多,這個老生常談了,幾乎所有解決類似問題的設計模式都存在這個缺點;
  • 增加了系統複雜度,使用不當將會導致邏輯的混亂,因為,狀態類畢竟增多了嘛,而且還涉及到狀態的轉移,思維可能就更亂了;
  • 不完全滿足開閉原則,因為擴充套件時,除了新增或刪除對應的狀態子類外,還需要修改涉及到的相應狀態轉移的其它狀態類,不過相對於原來的實現,這裡已經改善很多了。

與策略模式區別

策略模式

  • 強調可以互換的演算法;
  • 使用者直接與具體演算法互動,決定演算法的替換,需要了解演算法本身;
  • 策略類不需要持有Context的引用。

狀態模式

  • 強調改變物件內部的狀態來幫助控制自己的行為;
  • 狀態是物件內部流轉,使用者不會直接跟狀態互動,不需要了解狀態本身;
  • 狀態類需要持有Context的引用,用來實現狀態轉移。

總結

其實,從類圖和實現方式上可以看出,狀態模式和策略模式真的很像,但是由於策略模式更具有一般性,因此更容易想到。而且,我們也知道狀態模式和策略模式都能解決if-else帶來的問題,關鍵就在於策略和狀態的識別,就如上述交通燈例子,剛開始識別成策略也很難發現有什麼不對。再舉一個更通俗的例子,老師會根據同學的考試成績對同學給出不同的獎懲方案,如成績低於60分的同學罰款,成績高於90分的同學獎錢,但是怎麼獎怎麼罰,都是老師決定的(不然全考90分以上,老師得哭)。這裡是普通的條件分支,沒有列舉,但是我們依然可以看出,這裡體現的是根據不同的分數段採取不同的策略,可以採用策略模式。再例如,同樣是考試成績,父母對你設定一個指標,考了60以下,罰錢,考了90分以上,獎錢。這時是策略還是狀態呢?感覺好像都可以,但實際上,仔細思考會發現,或許視為狀態會更好,即在不同的狀態會有一個對應的動作,但狀態的有哪些呢?分數段?獎罰?狀態又是怎麼轉移的呢?還得仔細斟酌,這裡例子簡單,或許能想清楚(其實不一定),但實際專案中,估計就沒這麼容易了。

不過呢,一旦我們識別出了狀態,然後識別出了會根據一定的觸發條件發生狀態轉移,那麼十有八九就可以使用狀態模式了。

原始碼連結

相關文章