iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 26

Day 26|State (狀態模式) 接單、製作到取餐,同一顆按鈕,如何依「當前狀態」做出不同反應

  • 分享至 

  • xImage
  •  

昨天用 Template Method 把飲料製作的 SOP 鎖進父類別,今天延續行為型 Pattern

State 狀態模式

原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

用中文理解可以是

允許一個物件在其內部狀態改變時改變其行為。該物件看起來就像是改變了所屬的類別一樣

State 主要要解決的是

一個物件的行為需要隨著內部狀態不同而不同,同一個請求在不同狀態下必須有不同的合法性判斷與反應
如果用 if/switch 條件判斷檢查目前狀態來決定該怎麼做,這些規則會分散在每一個呼叫的地方各自判斷一次,新增或修改狀態時很容易顧此失彼

核心角色

  1. Context(情境角色):持有目前狀態物件的參考,對外的請求都轉交給目前狀態處理,自己不做狀態判斷
  2. State(狀態介面):定義這個情境下所有可能發生的請求,通常一個方法對應一種事件
  3. ConcreteState(具體狀態):實作在「這個狀態」下,收到某個請求該怎麼反應,包含這個請求合不合法、合法的話要不要把 Context 換成另一個狀態

我們來看看 UML
https://ithelp.ithome.com.tw/upload/images/20260922/20183470ikoqPX0MIK.jpg

舉個生活化的例子,音樂播放器只有一顆播放鍵,但按下去的效果,看目前播放器在哪個狀態

  • 停止中按下 → 開始播放,狀態轉為播放中
  • 播放中按下 → 暫停,狀態轉為暫停中
  • 暫停中按下 → 繼續播放,狀態轉回播放中

同一顆按鍵、同一個方法呼叫,因為「目前狀態」不同而做不同的事、轉去不同的下一個狀態

public interface IPlayerState  // 定義共同介面
{
    void PressPlay(MusicPlayer player); // 定義方法 PressPlay 帶入實例 player
}

public class StoppedState : IPlayerState      // ⏹ 停止狀態 class
{
    public void PressPlay(MusicPlayer player)  // 實作 PressPlay 方法,傳入實體化物件 player
    {
        Console.WriteLine("[播放器] 開始播放");  // 停止狀態按下 Play 就開始撥放
        player.SetState(new PlayingState());   // 更改實體化物件方法.SetState( ▶ new 播放類別 )
    }
}

public class PlayingState : IPlayerState       // ▶ 播放狀態 class
{
    public void PressPlay(MusicPlayer player)
    {
        Console.WriteLine("[播放器] 暫停");
        player.SetState(new PausedState());    // 更改實體化物件方法.SetState( ⏸ new 播放類別 )
    }

}

public class PausedState : IPlayerState       // ⏸ 暫停狀態 class
{
    public void PressPlay(MusicPlayer player)
    {
        Console.WriteLine("[播放器] 繼續播放");
        player.SetState(new PlayingState());   // 更改實體化物件方法.SetState( ▶ new 播放類別 )
    }
}
public class MusicPlayer
{
    // Context 只認得「目前狀態」,不知道也不在乎裡面裝的是哪一種狀態
    // _state 在執行期間,參考到的永遠是某一個「具體類別」的實體
    private IPlayerState _state = new StoppedState();

    public void SetState(IPlayerState state) => _state = state;

    public void PressPlay() => _state.PressPlay(this);  // this -> MusicPlayer
}
var player = new MusicPlayer();

player.PressPlay(); // [播放器] 開始播放
player.PressPlay(); // [播放器] 暫停
player.PressPlay(); // [播放器] 繼續播放

還是不太熟悉嗎,朋友沒關係我也是,那我們再重新跑一次流程,確認每個階段狀態都是熟悉的,把流程轉換成文字

初始狀態:
var player = new MusicPlayer();

  1. player 裡面的 _state 就是 實體化 StoppedState

  2. playerthis 就是 MusicPlayer 實體本身

第一次按開始撥放 player.PressPlay():

  1. 呼叫 PressPlay() 執行 實體化 StoppedState.PressPlay 並傳入 MusicPlayer 實體

  2. Console.WriteLine("[播放器] 開始播放")

  3. 呼叫 player.SetState(new PlayingState()) 也就是 new 一個全新的 PlayingState 實體player.SetState(new PlayingState());

  4. 這時候 MusicPlayer 內部的 _state 欄位,就被換成新的 PlayingState 實體

第二次按下 player.PressPlay() (此時 _state 已經是 PlayingState 實體):

  1. 呼叫 PressPlay() 執行 PlayingState 實體.PressPlay(this),傳入的還是同一個 MusicPlayer 實體

  2. Console.WriteLine("[播放器] 暫停")

  3. 呼叫 player.SetState(new PausedState()), new 一個全新的 PausedState 實體

  4. MusicPlayer 內部的 _state,從 PlayingState 實體 換成這個新的 PausedState 實體

    MusicPlayer.PressPlay() 只有一行 _state.PressPlay(this)
    它不會知道現在是停止、播放還是暫停,也不用寫條件式去判斷,這件事交給目前的狀態物件自己決定

回到柴咖啡

柴咖啡連鎖化之後,一張訂單原本只有接單、製作中、完成三個階段,現在小黑要阿柴多加一個「取餐」:客人到店取走飲料,這張訂單才算真的結束

小黑同時也訂了取消規則:接單、製作中都能取消

But 一旦進入完成,代表飲料已經做好在等客人,不能再取消

如果訂單本身只是拿著一個 OrderStatus 這樣的列舉值,那「現在能不能取消」「按下一步該變成什麼狀態」這些規則,就得在每一個會呼叫取消、呼叫推進訂單的地方各自重新判斷一次現在是什麼狀態

規則以後只會越堆越多,像是逾時未取餐要怎麼處理,散落在下單、廚房出單、客服協助取消等各種呼叫端,新增一個階段時很容易漏改其中一處

阿柴這次決定把「狀態」本身拆成物件,讓每個狀態自己知道自己能做什麼、不能做什麼、做完之後要換成哪一個狀態

用 State 設計訂單狀態機

先定義這個情境下訂單可能收到的兩種請求:推進到下一階段、嘗試取消

public interface IOrderState
{
    string Name { get; }
    void Proceed(Order order); // 推進到下一階段
    void Cancel(Order order);  // 嘗試取消
}

接單、製作中、已完成、已取餐、已取消,一共五個狀態

各自決定「在我這個狀態,這兩件事該怎麼反應」

public class PendingState : IOrderState // 接單
{
    public string Name => "接單";

    public void Proceed(Order order)
    {
        Console.WriteLine($"[訂單 {order.OrderId}] 開始製作");
        order.SetState(new PreparingState());
    }

    public void Cancel(Order order)
    {
        Console.WriteLine($"[訂單 {order.OrderId}] 已取消");
        order.SetState(new CancelledState());
    }
}

public class PreparingState : IOrderState // 製作中
{
    public string Name => "製作中";

    public void Proceed(Order order)
    {
        Console.WriteLine($"[訂單 {order.OrderId}] 製作完成,通知取餐");
        order.SetState(new CompletedState());
        order.NotifyCompleted(); // 銜接 Day 23 的 IOrderObserver,通知客人與外送平台
    }

    public void Cancel(Order order)
    {
        Console.WriteLine($"[訂單 {order.OrderId}] 製作中取消,通知內場停止製作");
        order.SetState(new CancelledState());
    }
}

public class CompletedState : IOrderState // 已完成,等待取餐
{
    public string Name => "已完成";

    public void Proceed(Order order)
    {
        Console.WriteLine($"[訂單 {order.OrderId}] 客人取餐完畢");
        order.SetState(new PickedUpState());
    }

    public void Cancel(Order order)
        => Console.WriteLine($"[訂單 {order.OrderId}] 飲料已完成,無法取消");
}

public class PickedUpState : IOrderState // 已取餐,訂單結束
{
    public string Name => "已取餐";

    public void Proceed(Order order)
        => Console.WriteLine($"[訂單 {order.OrderId}] 訂單已結束,沒有下一步");

    public void Cancel(Order order)
        => Console.WriteLine($"[訂單 {order.OrderId}] 訂單已結束,無法取消");
}

public class CancelledState : IOrderState // 已取消,訂單結束
{
    public string Name => "已取消";

    public void Proceed(Order order)
        => Console.WriteLine($"[訂單 {order.OrderId}] 訂單已取消,沒有下一步");

    public void Cancel(Order order)
        => Console.WriteLine($"[訂單 {order.OrderId}] 訂單已經是取消狀態");
}

Order 改成持有目前狀態物件,Proceed()Cancel() 兩個方法本身不做判斷,單純轉交給目前的狀態

public class Order
{
    public string OrderId { get; set; }
    public int TotalPrice { get; set; }
    public List<OrderItem> Items { get; set; }

    private IOrderState _state = new PendingState(); // 訂單一建立就是接單狀態
    private readonly List<IOrderObserver> _observers = new();

    public string StatusName => _state.Name;

    public void SetState(IOrderState state) => _state = state;

    public void Proceed() => _state.Proceed(this);
    public void Cancel() => _state.Cancel(this);

    public void Subscribe(IOrderObserver observer) => _observers.Add(observer);
    public void NotifyCompleted() => _observers.ForEach(o => o.OnOrderCompleted(this));
}
var order = new Order { OrderId = "ORD1001", TotalPrice = 150 };
order.Subscribe(new CustomerNotifier());
order.Subscribe(new DeliveryPlatform());

order.Proceed(); // 接單 → 製作中
order.Proceed(); // 製作中 → 已完成,順便通知客人與外送平台
order.Cancel();  // 已完成,無法取消
order.Proceed(); // 已完成 → 已取餐

同一個 order.Proceed()、同一個 order.Cancel(),呼叫端不用去問「現在狀態是什麼、這個動作准不准」,這些判斷都留在各個狀態類別裡自己決定

之後如果小黑又想加規則,例如逾時未取餐要自動退款,也只需要新增一個 TimeoutState,其他既有的狀態類別不用更改

State 的取捨

如果一個物件的狀態只有兩三種、規則長期不太會變(像是一顆開關的開/關),拆成一個 IState 介面加上好幾個具體類別,會比一個列舉配一兩行 if 判斷更笨重,也多了不少要維護的檔案

State 划算的情境,是狀態數量多、狀態之間的轉移規則複雜,而且系統還會持續長出新的狀態或新的規則

柴咖啡的訂單接下來大概還會冒出更多這類需求,每次新增都只需要多寫一個狀態類別,不用回頭改動其他狀態的程式碼

今天學到的事

State 要解決的問題:

消除散落在系統各處的 if-else / switch 狀態檢查,讓物件根據「當前狀態」自動做出正確的反應與合法的轉移

優點

  • 行為獨立高內聚:每個狀態只專注管好自己的規則,Context 不用寫任何條件判斷

  • 擴充符合 OCP:加新狀態只需新增 class,不需回頭修改既有狀態的判斷邏輯

  • 除錯好定位:哪一個階段的行為出錯,直接找對應的狀態類別動刀,不再牽一髮動全身

缺點

  • 類別數量膨脹:多一個狀態就多一個 class,若狀態單純反而會造成過度設計

  • 看不清全局圖:轉移邏輯散在各個狀態內部,很難一眼看穿完整的狀態轉移流程圖

  • 狀態間具體耦合:狀態類別通常需要直接 new 下一個狀態,彼此之間存在依賴


明天,柴咖啡的客訴處理要從店員一個人扛,變成店員擋不住就往上交給店長,店長擋不住再往上交給客服中心

這種「請求沿著一條處理鏈往下傳,直到有人願意處理為止」的情境,換 Chain of Responsibility 責任鏈模式上場

參考資料

Refactoring Guru - State

C# State Design Pattern

[Design Pattern] State 狀態模式


上一篇
Day 25|Template Method(樣板方法模式) 用 Template Method 固化 SOP 咖啡品質
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言