ข้าม​ไป​ยัง​เนื้อหา

State

จำลอง​การ​เปลี่ยน​สถานะ​ด้วย object แยก​ต่อ​แต่ละ​สถานะ (FSM)

หลาย object มี​พฤติกรรม​ที่​เปลี่ยน​ไป​ตาม “สถานะ​ภายใน” ของ​มัน​เอง เช่น คำ​สั่ง​ซื้อ (Order) ที่​ทำงาน​ต่าง​กัน​ตอน​เป็น Draft, Pending, Approved หรือ Rejected วิธี​เขียน​ที่​ไร้​เดียงสา​ที่สุด​คือ​เก็บ field แบบ enum ไว้​ใน class เดียว แล้ว​ใช้ if/switch เช็ค​ค่า​นั้น​ใน​แทบ​ทุก method — ยิ่ง​จำนวน​สถานะ​และ transition เพิ่ม​ขึ้น conditional ก็​ยิ่ง​พัน​กัน​จน​แก้​ยาก และ​เสี่ยง​ลืม​อัปเดต​บาง​จุด​เวลา​สถานะ​ใหม่​ถูก​เพิ่ม​เข้า​มา

State pattern แก้​ปัญหา​นี้​ด้วย​การ​ดึง​พฤติกรรม​เฉพาะ​ของ​แต่ละ​สถานะ​ออก​มา​เป็น class ของ​ตัวเอง แล้ว​ให้ object หลัก (เรียก​ว่า Context) ถือ reference ไป​ยัง state object ปัจจุบัน และ ส่ง​ต่อ (delegate) ทุก​การ​เรียก​ที่​ขึ้น​กับ​สถานะ​ไป​ให้ state object นั้น​จัดการ​แทน เวลา​สถานะ​เปลี่ยน Context แค่​สลับ reference ไป​ชี้ state object ใหม่ — จาก​มุมมอง​ภายนอก​มัน​ดูเหมือน object เปลี่ยน class ของ​ตัวเอง​ไป​เลย

มอง​อีก​มุม1 pattern นี้​คือ​การนำ Finite State Machine (FSM) มา​เขียน​ด้วย OOP: แต่ละ​สถานะ (node) เป็น subtype หนึ่ง​ของ state ฐาน ที่​นิยาม method สำหรับ​ทุก transition (edge) ที่​เป็น​ไป​ได้​ใน​ระบบ subclass แต่ละ​ตัว implement เฉพาะ transition ที่​ตัวเอง​อนุญาต​ให้​เกิด​ขึ้น ส่วน​การ​ดำเนิน​การ​ที่​ไม่​ถูกต้อง​ใน​สถานะ​นั้น​จะ​โยน exception หรือ​ไม่มี​ผล​ใด ๆ — ใช้ polymorphism แทน conditional และ​เชื่อม​โยง​โดยตรง​กับ​หลักการ Make Illegal States Unrepresentable

classDiagram
    class Context {
      -IOrderState state
      +Submit()
      +Approve()
      +Reject()
      +TransitionTo(IOrderState s)
    }
    class IOrderState {
      +Submit(Context c)
      +Approve(Context c)
      +Reject(Context c)
    }
    class DraftState {
      +Submit(Context c)
    }
    class PendingState {
      +Approve(Context c)
      +Reject(Context c)
    }
    class ApprovedState
    class RejectedState
    Context o-- IOrderState
    IOrderState <|.. DraftState
    IOrderState <|.. PendingState
    IOrderState <|.. ApprovedState
    IOrderState <|.. RejectedState
  • Context — class ที่ client เห็น​และ​เรียก​ใช้งาน มัน​ถือ reference ไป​ยัง state object ปัจจุบัน แต่ ไม่รู้​รายละเอียด ว่า​แต่ละ​สถานะ​ทำงาน​อย่างไร มัน​แค่ delegate ให้ state object จัดการ
  • State (interface/abstract class) — นิยาม contract ของ​ทุก operation ที่​ขึ้น​กับ​สถานะ
  • Concrete State — implement เฉพาะ transition ที่​ตัวเอง​อนุญาต และ​มัก​เป็น​ผู้​สั่ง​ให้ Context เปลี่ยน​ไป​ยัง state object ถัด​ไป (state จึง​มัก “รู้จัก” กันเอง​ว่า​จะ​ไป​สถานะ​ไหน​ต่อ)
  1. Client เรียก method บน Context เช่น context.Approve()
  2. Context ไม่​ตัดสิน​ใจ​เอง แต่ delegate การ​เรียก​ไป​ยัง state object ปัจจุบัน (state.Approve(this))
  3. Concrete state ตรวจสอบ​ว่า transition นี้​ถูก​กฎ​หรือ​ไม่ ถ้า​ถูก​กฎ มัน​จะ​สร้าง/เลือก state object ถัด​ไป แล้ว​สั่ง​ให้ Context เปลี่ยน reference ไป​ชี้ state ใหม่ (context.TransitionTo(new ApprovedState()))
  4. ถ้า transition ไม่​ถูก​กฎ​ใน​สถานะ​ปัจจุบัน (เช่น​เรียก Approve() ตอน​ยัง​เป็น Draft) จะ​โยน exception หรือ​ถูก no-op ไป​เลย — ไม่​ต้อง​มี if (state == "Pending") กระจาย​อยู่​ทั่ว code
sequenceDiagram
    participant Client
    participant Context
    participant Pending as PendingState
    participant Approved as ApprovedState

    Client->>Context: Approve()
    Context->>Pending: Approve(context)
    Pending->>Context: TransitionTo(ApprovedState)
    Context-->>Client: OK

จุด​ที่​มัก​เป็น​ประเด็น​ใน​การ​ออกแบบ​คือ ใคร​เป็น​คน​สร้าง/ถือ state object ถัด​ไป — จะ​ให้ concrete state แต่ละ​ตัว​รู้จัก​กันเอง (state ต่อ state ตัดสิน​ใจ) หรือ​ให้ Context เป็น​ศูนย์กลาง​ตัดสิน​ใจ transition ทั้งหมด (มัก implement ด้วย dictionary ของ (currentState, event) -> nextState) ทั้ง​สอง​แบบ​ถูกต้อง​ตาม pattern แต่ trade-off เรื่อง coupling ต่าง​กัน

// สถานะฐาน — นิยาม operation ทั้งหมดที่ Context รองรับ
// ค่า default คือ "ไม่อนุญาต" เพื่อบังคับให้แต่ละสถานะเปิดเฉพาะ transition ที่ถูกต้อง
public abstract class OrderState
{
public abstract string Name { get; }
public virtual void Submit(OrderContext order) =>
throw new InvalidOperationException($"ส่ง (Submit) จากสถานะ {Name} ไม่ได้");
public virtual void Approve(OrderContext order) =>
throw new InvalidOperationException($"อนุมัติ (Approve) จากสถานะ {Name} ไม่ได้");
public virtual void Reject(OrderContext order) =>
throw new InvalidOperationException($"ปฏิเสธ (Reject) จากสถานะ {Name} ไม่ได้");
}
public class DraftState : OrderState
{
public override string Name => "Draft";
public override void Submit(OrderContext order) =>
order.TransitionTo(new PendingState());
}
public class PendingState : OrderState
{
public override string Name => "Pending";
public override void Approve(OrderContext order) =>
order.TransitionTo(new ApprovedState());
public override void Reject(OrderContext order) =>
order.TransitionTo(new RejectedState());
}
public class ApprovedState : OrderState
{
public override string Name => "Approved";
// ไม่ override method ใดเพิ่ม — ทุก transition จาก Approved ถือเป็นสถานะสุดท้าย
}
public class RejectedState : OrderState
{
public override string Name => "Rejected";
}
// Context — ถือสถานะปัจจุบัน และ delegate ทุกการเรียกให้มันจัดการ
public class OrderContext
{
public OrderState State { get; private set; } = new DraftState();
public void Submit() => State.Submit(this);
public void Approve() => State.Approve(this);
public void Reject() => State.Reject(this);
// เปิดให้เฉพาะ state object เท่านั้นเป็นผู้สั่งเปลี่ยนสถานะ
internal void TransitionTo(OrderState next)
{
Console.WriteLine($"{State.Name} -> {next.Name}");
State = next;
}
}
// การใช้งาน
var order = new OrderContext();
order.Submit(); // Draft -> Pending
order.Approve(); // Pending -> Approved
order.Reject(); // โยน InvalidOperationException — Approved ปฏิเสธไม่ได้แล้ว
  • object มี​พฤติกรรม​ที่​ต่าง​กัน​ชัดเจน​ตาม​สถานะ และ​จำนวน​สถานะ/transition มี​พอสมควร (ตั้งแต่ 3-4 สถานะ​ขึ้น​ไป)
  • code ปัจจุบัน​เต็ม​ไป​ด้วย if/switch ที่​เช็ค field แบบ enum ซ้ำ ๆ ใน​หลาย method (ตรง​กับ smell Conditional Complexity)
  • ต้องการ​เพิ่ม​สถานะ​ใหม่​บ่อย ๆ โดย​ไม่​อยาก​แก้ code เดิม (สอดคล้อง​กับ Open-Closed Principle)
  • ต้องการ​บังคับ transition ที่​ผิด​กฎ​ให้ fail ทันที​ที่ compile-time หรือ runtime แรก​สุด แทนที่​จะ​ปล่อย​ให้ object อยู่​ใน​สถานะ​ที่​ไม่​ควร​มี​อยู่​จริง
  • สถานะ​มี​แค่ 2 ค่า (on/off) หรือ transition ไม่​ซับซ้อน — bool หรือ enum ธรรมดา​ก็​เพียงพอ และ​อ่าน​ง่าย​กว่า
  • ทีม​ต้องการ​เห็น​ตาราง transition ทั้งหมด​ใน​ที่​เดียว​เพื่อ audit/debug ง่าย — การกระจาย logic ไป​หลาย class อาจ​ทำให้​ตาม​รอย transition ยาก​ขึ้น (กรณี​นี้ table-driven state machine หรือ library อย่าง state machine ภายนอก​อาจ​เหมาะ​กว่า)
  • สถานะ​แทบ​ไม่​เพิ่ม​และ​ไม่​เปลี่ยน​พฤติกรรม​อีก​แล้ว — ต้นทุน​สร้าง class เพิ่ม​ไม่​คุ้ม​กับ​ความ​ยืดหยุ่น​ที่​ได้
ด้านรายละเอียด
ข้อดีกำจัด conditional ก้อน​ใหญ่​ออก​จาก Context ทำให้​แต่ละ method สั้น​และ​อ่าน​ง่าย​ขึ้น
ข้อดีเพิ่ม​สถานะ​ใหม่​ได้​โดย​ไม่​แก้ code เดิม (Open-Closed) — เขียน class ใหม่​แล้ว implement interface
ข้อดีทำให้ transition ที่​ไม่​ถูกต้อง​เป็น “impossible by construction” หรือ fail ชัดเจน​ทันที แทนที่​จะ​ปล่อย​ผ่าน​เงียบ ๆ
ข้อ​เสียจำนวน class เพิ่ม​ขึ้น​ตาม​จำนวน​สถานะ — สำหรับ FSM เล็ก ๆ อาจ​ดู over-engineered
ข้อ​เสียtransition logic กระจาย​อยู่​หลาย class ทำให้​มอง​ภาพ​รวม FSM ทั้ง​ระบบ​ได้​ยาก​กว่า​ตาราง transition แบบ​รวม​ศูนย์
ข้อ​เสียถ้า concrete state ต้อง “รู้จัก” กันเอง​เพื่อ​สร้าง state ถัด​ไป จะ​เกิด coupling ระหว่าง concrete state ด้วย​กัน
  • Make Illegal States Unrepresentable — เป้าหมาย​ปลายทาง​ที่ State pattern ช่วย​ให้​เข้า​ใกล้​ได้
  • Strategy — โครงสร้าง​คล้าย​กัน​มาก (Context ถือ interface แล้ว delegate) แต่ Strategy ไม่มี​แนวคิด​เรื่อง transition ระหว่าง strategy ด้วย​กันเอง ส่วน State ออกแบบ​มา​ให้ concrete state เปลี่ยน​สถานะ​ของ Context ได้​เอง
  • Open-Closed Principle — เหตุผล​หลัก​ที่​ทำให้​เพิ่ม​สถานะ​ใหม่​ได้​โดย​ไม่​แก้ code เดิม
  • Conditional Complexity — code smell ที่ State pattern มัก​ถูก​ใช้​แก้ไข
  • Observer — มัก​ใช้​ร่วม​กัน​เพื่อ​แจ้ง​ผู้​สนใจ​เมื่อ Context เปลี่ยน​สถานะ
  • Memento — ใช้​เก็บ snapshot ของ​สถานะ​เพื่อ undo/redo ซึ่ง​เป็น​แนวคิด​ที่​ต่าง​จาก State แต่​มัก​ถูก​พูด​ถึง​คู่​กัน​เพราะ​ทำงาน​กับ “สถานะ” เหมือน​กัน