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

Pattern matching — สถานะ​และ​การ​ตัดสิน​ใจ​โดย​ไม่​ต้อง if ซ้อน

สาม​บท​ที่​แล้ว​เรา​ปั้น​ก้อน​อิฐ​ที่ “อยู่​นิ่ง” ของ domain Order มา​แล้ว — Money, OrderId, Quantity ล้วน​เป็น Value ObjectValue Objectอ็อบเจ็กต์ที่​นิยาม​ด้วย 'ค่า' ไม่มี identity ของ​ตัวเอง สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เป็น​สิ่ง​เดียวกัน ควร immutable เปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง — ใน C# มัก​สร้าง​ด้วย record เช่น Money, OrderIdTactical Design ที่​ค่า​มัน​คงที่​ตั้งแต่​วินาที​ที่​สร้าง แต่​ตัว Order เอง​ไม่​ได้​อยู่​นิ่ง มัน​มี “ชีวิต”: ลูกค้า​กด​สั่ง ร้าน​รับ ครัว​เริ่ม​ทำ ไร​เด​อร์มา​รับ แล้ว​ส่ง​ถึง​บ้าน ทุก​ก้าว​คือ​การ​เปลี่ยน สถานะ และ​แทบ​ทุก​การ​ตัดสิน​ใจ​ใน app (“ยกเลิก​ได้​ไหม” “คืน​เงิน​เท่าไร”) ล้วน​ขึ้น​อยู่​กับ​ว่า ณ ตอน​นี้​ออเดอร์​อยู่​สถานะ​ไหน

พอ logic ผูก​กับ​สถานะ​แบบ​นี้ code ที่​เขียน​ไม่​ดี​จะ​กลาย​เป็น if ซ้อน if ซ้อน if เป็น​พีระมิด​ที่​อ่าน​ไม่​ออก บท​นี้​เรา​จะ​เรียน​เครื่อง​มือสอง​ชิ้น​ของ C# สมัย​ใหม่​ที่​ทำให้​เรื่อง​นี้​กลาย​เป็น “ตาราง” ที่​อ่าน​ง่าย — Pattern MatchingPattern Matchingไวยากรณ์​ที่​เช็ค 'รูปร่าง' ของ​ค่า​และ​ดึง​ส่วน​ย่อย​ออก​มา​ใช้​ใน​ขั้นตอน​เดียว เช่น `is OrderStatus.Placed` หรือ​จับ​คู่ type/ค่า​ใน switch — เครื่องมือ​หลัก​ใน​การ​เขียน​กฎ​เปลี่ยน​สถานะ​ของ Order ให้​ครบ​ทุก​กรณี​อย่าง​ปลอดภัยTactical Design (การ​เช็ก “รูปร่าง” ของ​ค่า) และ Switch ExpressionSwitch Expressionรูปแบบ `switch` ที่ 'คืน​ค่า' ได้​โดยตรง ต่าง​จาก switch statement แบบ​เดิม​ที่​สั่ง​งาน​เป็น​คำ​สั่ง กระชับ​กว่า​และ compiler ช่วย​เตือน​ถ้า​ลืม​บาง​เคส — ใช้​เขียน​การ​เปลี่ยน​สถานะ​ของ Order ให้​ครบ​ทุก branch เช่น `state switch { Placed => ..., Confirmed => ... }`Tactical Design (switch ที่​คืน​ค่า​ได้​โดยตรง)

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Domain/Orders/

ก่อน​อื่น​เรา​ต้อง​มี type ที่​แทน “สถานะ​ที่​เป็น​ไป​ได้” ของออเดอร์ ใน domain ของ​เรา​มี​อยู่ 7 สถานะ ไม่​มาก​ไป​กว่า​นี้ ไม่​น้อย​ไป​กว่า​นี้ เมื่อ​ค่าที่​เป็น​ไป​ได้​ถูก​จำกัด​เป็น​ชุด​คงที่​แบบ​นี้ เครื่องมือ​ที่​เหมาะ​ที่สุด​ของ C# คือ EnumEnumtype ที่​จำกัด​ค่า​ไว้​เป็น​ชุด​คงที่​ที่​ตั้ง​ชื่อ​ไว้​ล่วงหน้า เช่น `enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered }` — วิธี​พื้นฐาน​ที่สุด​ใน​การ​แทน​สถานะ​จำกัด​จำนวน​ของ state machine ใน domainTactical Design — type ที่​ระบุ​ค่าที่​ตั้ง​ชื่อ​ไว้​ล่วงหน้า​เป็น​ชุด:

namespace FoodOrdering.Domain.Orders;
public enum OrderStatus
{
Placed, // ลูกค้ากดสั่งแล้ว รอร้านตอบรับ
Confirmed, // ร้านรับออเดอร์
Preparing, // ครัวกำลังทำอาหาร
PickedUp, // ไรเดอร์รับของแล้ว
Delivered, // ส่งถึงลูกค้าเรียบร้อย
Rejected, // ร้านปฏิเสธออเดอร์
Cancelled, // ลูกค้ายกเลิก
}

แกะ​ที​ละ​ส่วน​สำหรับ​คน​ที่​เพิ่ง​เจอ C#:

  • enum คือ keyword ที่​บอกว่า “นี่​คือ type ที่​มี​ค่า​ได้​แค่​ใน​รายการ​นี้​เท่านั้น” ค่า​อื่น​นอก​รายการ​เอา​มา​ใส่​ไม่​ได้
  • ชื่อ​ใน​วงเล็บ​ปีกกา​แต่ละ​ตัว (Placed, Confirmed, …) คือ​ค่าที่​ถูกต้อง​หนึ่ง​ค่า เขียน​อ้าง​ถึง​ได้​ด้วย OrderStatus.Placed
  • ข้อดี​ทันที​คือ compiler กัน​เรา​จาก​การ​พิมพ์ "placd" ผิดๆ แบบ​ที่ string ทำ​ไม่​ได้ — สถานะ​ที่​ไม่มี​จริง​จะ compile ไม่​ผ่าน​ตั้งแต่​แรก

สถานะ​ทั้ง 7 ไม่​ได้​กระโดด​ถึง​กันมั่วๆ มัน​ไหล​ตาม​เส้นทาง​ที่​กำหนด เช่น ออเดอร์​ต้อง​ผ่าน Confirmed ก่อน​ถึง​จะ Preparing ได้ กระโดด​จาก Placed ไป Delivered ตรงๆ ไม่​ได้ ภาพ​นี้​คือ ตาราง​การ​เปลี่ยน​สถานะ ที่​เรา​จะ​เขียน​เป็น code ใน​บท​นี้:

stateDiagram-v2
    [*] --> Placed
    Placed --> Confirmed: ร้านรับออเดอร์
    Placed --> Rejected: ร้านปฏิเสธ
    Placed --> Cancelled: ลูกค้ายกเลิก
    Confirmed --> Preparing: เริ่มทำอาหาร
    Confirmed --> Cancelled: ลูกค้ายกเลิก
    Preparing --> PickedUp: ไรเดอร์รับของ
    PickedUp --> Delivered: ส่งถึงลูกค้า
    Rejected --> [*]
    Delivered --> [*]
    Cancelled --> [*]

คำ​บรรยาย​ภาพ: เส้นทาง​ชีวิต​ของออเดอร์​หนึ่ง​ใบ เริ่ม​ที่ Placed เสมอ จาก​นั้น​แตก​ได้​เป็น Confirmed (ร้าน​รับ), Rejected (ร้าน​ปฏิเสธ) หรือ Cancelled (ลูกค้า​ยกเลิก​ทัน) เมื่อ​เข้า​ครัว​แล้ว (Preparing) จะ​ยกเลิก​ไม่​ได้​อีก ต้อง​ไหล​ตรง​ไป PickedUp แล้ว Delivered สังเกต​ว่า​ลูกศร​มี​ทิศทาง​ชัดเจน — นี่​คือ​กฎ​ที่ code ของ​เรา​ต้อง​รักษา​ไว้ ไม่​ให้​ใคร​ข้าม​ขั้น

ลอง​ดู​งาน​จริง​งาน​หนึ่ง: “ถ้า​ยกเลิก​ออเดอร์​ตอน​นี้ ต้อง​คืน​เงิน​เท่าไร” กฎ​คือ ยกเลิก​ก่อน​เข้า​ครัว (Placed/Confirmed) คืน​เต็ม, ระหว่าง​ทำ​อาหาร (Preparing) คืน​ครึ่ง (หัก​ค่า​วัตถุดิบ​ที่​ลงมือ​ไป​แล้ว), ถ้า​ร้าน​ปฏิเสธ​เอง (Rejected) คืน​เต็ม, ส่วน​ที่​เลย​ไป​แล้ว (PickedUp/Delivered/Cancelled) ไม่​คืน

เขียน​ด้วย if/else ตรงๆ แบบ​ที่​มือใหม่​มัก​เขียน จะ​ออก​มา​เป็น ❌ version ดิบ หน้าตา​แบบ​นี้:

// ❌ version ดิบ: if/else ซ้อนกันเป็นพีระมิด อ่านยาก แก้ยาก
public static Money RefundFor(OrderStatus status, Money total)
{
if (status == OrderStatus.Placed)
{
return total;
}
else
{
if (status == OrderStatus.Confirmed)
{
return total;
}
else
{
if (status == OrderStatus.Preparing)
{
return Money.Thb(total.Amount * 0.5m);
}
else
{
if (status == OrderStatus.Rejected)
{
return total;
}
else
{
// PickedUp, Delivered, Cancelled → ไม่คืนเงิน
return Money.Thb(0);
}
}
}
}
}

ปัญหา​ไม่ใช่​แค่ “ยาว” แต่​มัน​ซ่อน​กับดัก​ไว้:

  • ยิ่ง​ลึก​ยิ่ง​อ่าน​ไม่​ออก — กว่า​จะ​รู้​ว่า Rejected คืน​เต็ม ต้อง​ไล่​สายตา​ผ่าน else สี่​ชั้น
  • ลืม​เคส​ได้​ง่าย — ถ้า​เรา​เพิ่ม​สถานะ​ใหม่​ใน enum วัน​หลัง ไม่มี​อะไร​เตือน​เลย​ว่า​ยัง​ไม่​ได้​มา​เติม​กฎ​ตรง​นี้ มัน​จะ​ตกลง​ไป​ที่ else ก้อน​สุดท้าย​เงียบๆ (คืน 0) ทั้ง​ที่​อาจ​ผิด
  • แผนที่​กระจาย — “กฎ​การ​คืน​เงิน​ทั้งหมด” ไม่​ได้​อยู่​รวม​กัน​เป็น​ตาราง​ให้​อ่าน​รวด​เดียว มัน​ถูก​ฝัง​อยู่​ใน​โครง​พีระมิด

เดี๋ยว​เรา​จะ​เขียน​ก้อน​นี้​ใหม่​ให้​กลาย​เป็น​ตาราง​บรรทัด​ต่อ​บรรทัด แต่​ก่อน​อื่น​ต้อง​รู้จัก​เครื่องมือ​ที่​ทำให้​มัน​เกิด​ขึ้น​ได้​ก่อน

pattern matching คือ​การ​ถาม​ค่า​หนึ่ง​ว่า “เข้า​กับ​รูปแบบ​นี้​ไหม” แล้ว​ดึง​ส่วน​ที่​ต้องการ​ออก​มา​ใช้​ใน​ขั้นตอน​เดียว หัวใจ​คือ keyword is มี​หลาย​แบบ ค่อยๆ ไล่​จาก​ง่าย​ไป​ยาก:

1. Constant pattern — เทียบ​กับ​ค่า​คง​ที่ตรงๆ เขียน​สั้น​กว่า ==:

if (status is OrderStatus.Delivered)
{
// ออเดอร์ส่งแล้ว
}

2. Type pattern — ถาม​ว่า “ค่า​นี้​เป็น type อะไร” แล้ว​ถ้า​ใช่ ก็​จับ​ใส่​ตัวแปร​ใหม่​ให้​พร้อม​ใช้​เลย:

object something = Money.Thb(120);
if (something is Money m)
{
// ถ้า something เป็น Money จริง → m คือ Money ก้อนนั้น ใช้ m.Amount ได้ทันที
var amount = m.Amount; // 120
}

ตรง is Money m ทำ​สอง​อย่าง​พร้อม​กัน: เช็กว่า something เป็น Money ไหม และ ถ้า​ใช่​ก็​ประกาศ​ตัวแปร m ที่​เป็น Money แล้ว​ให้​เลย ไม่​ต้อง cast เอง​อีกรอบ

3. Property pattern — ตรวจ ค่า​ของ property ข้าง​ใน​อ็อบเจ็กต์ โดย​เขียน​ใน​วงเล็บ​ปีกกา จำ​ได้​ไหม​ว่า Money มี property ชื่อ Amount เรา​ถาม​ค่า​ข้าง​ใน​มัน​ได้​เลย:

// total is { Amount: >= 500 } อ่านว่า "total เป็นอ็อบเจ็กต์ที่ Amount มากกว่าหรือเท่ากับ 500 ไหม"
public static bool QualifiesForFreeDelivery(Money total) =>
total is { Amount: >= 500 };

4. Relational pattern — คือ​ส่วน >= 500 ข้าง​บน​นั่นเอง มัน​เทียบ​ด้วย >, >=, <, <= ได้ตรงๆ ใน​ตัว pattern และ​เอา​มา​ต่อ​กัน​ได้​ด้วย and / or:

// ตัวเลขในช่วง 500 ถึง 2000
public static bool IsMidRange(Money total) =>
total is { Amount: >= 500 and <= 2000 };

ทั้ง​สี่​แบบ​นี้​ผสม​กัน​ได้​ใน​บรรทัด​เดียว เช่น ถาม​พร้อม​กัน​ว่า “ยัง​เป็น​แค่ Placed และ​ยอด​เกิน 2000 บาท​ไหม” (ออเดอร์​ก้อน​ใหญ่​ที่​ต้อง​ให้​ผู้จัดการ​อนุมัติ):

public static bool NeedsManagerApproval(OrderStatus status, Money total) =>
status is OrderStatus.Placed && total is { Amount: > 2000 };

pattern matching เก่ง​ขึ้น​อีก​มาก​เมื่อ​อยู่​ใน switch expression — switch แบบ​ใหม่​ที่ เป็น​นิพจน์ (expression) คือ​มัน “คืน​ค่า” ออก​มา​ได้​โดยตรง ต่าง​จาก switch statement แบบ​เดิม​ที่​เป็น “คำ​สั่ง” ต้อง​มี case, break และ​มัก​ต้อง​ตั้ง​ตัวแปร​ไว้​คอย​รับ​ค่า​เอง

เขียน​กฎ​การ​คืน​เงิน​ก้อน​เดิม (version ดิบ​ข้าง​บน) ใหม่​ด้วย switch expression จะ​กลาย​เป็น​ตาราง​ที่​อ่าน​รวด​เดียว​จบ:

public static Money RefundFor(OrderStatus status, Money total) => status switch
{
OrderStatus.Placed or OrderStatus.Confirmed => total, // ก่อนเข้าครัว → คืนเต็ม
OrderStatus.Preparing => Money.Thb(total.Amount * 0.5m), // กำลังทำ → คืนครึ่ง
OrderStatus.Rejected => total, // ร้านปฏิเสธ → คืนเต็ม
OrderStatus.PickedUp or OrderStatus.Delivered => Money.Thb(0), // ส่งแล้ว → ไม่คืน
OrderStatus.Cancelled => Money.Thb(0), // ยกเลิกไปแล้ว → ไม่คืน
_ => throw new ArgumentOutOfRangeException(nameof(status)),
};

แกะ​ไวยากรณ์​ใหม่:

  • status switch { ... } — เอา​ค่า status มา “แมตช์” กับ​แต่ละ​บรรทัด​ใน​วงเล็บ​ปีกกา เจอ​บรรทัด​ไหน​ตรง​ก่อน​ก็​คืน​ค่า​ฝั่ง​ขวา​ของ => บรรทัด​นั้น
  • แต่ละ​บรรทัด​คือ pattern => ค่าที่คืน เช่น OrderStatus.Preparing => Money.Thb(...)
  • or เชื่อม​สอง​สถานะ​ที่​ให้​ผล​เหมือน​กัน​ไว้​บรรทัด​เดียว (Placed or Confirmed) ไม่​ต้อง​เขียน​ซ้ำ
  • _ (ขีด​ล่าง) คือ “เคส​ที่​เหลือ​ทั้งหมด” กัน​ไว้​เผื่อ​มี​ค่า​แปลกปลอม​หลุด​เข้า​มา ตรง​นี้​เรา​เลือก​โยน exception เพราะ​มัน​ไม่​ควร​เกิด

สังเกต​ว่า code 30 กว่า​บรรทัด​ของ version ดิบ ยุบ​เหลือ “ตาราง” ที่​อ่าน​จาก​ซ้าย​ไป​ขวา​ได้​เลย — สถานะ​ไหน คืน​เท่าไร จบ​ใน​บรรทัด​เดียว

กลับ​ไป​ที่​ไดอะแกรม​สถานะ​ตอน​ต้นบท เรา​เขียน​กฎ “จาก A ไป B ได้​ไหม” เป็น switch expression บน คู่​สถานะ ได้ตรงๆ ตรง​นี้​ใช้ tuple pattern — จับ​คู่​ค่า​สอง​ตัว​ใน​วงเล็บ​พร้อม​กัน (from, to):

public static bool CanTransition(OrderStatus from, OrderStatus to) => (from, to) switch
{
(OrderStatus.Placed, OrderStatus.Confirmed) => true,
(OrderStatus.Placed, OrderStatus.Rejected) => true,
(OrderStatus.Placed or OrderStatus.Confirmed, OrderStatus.Cancelled) => true,
(OrderStatus.Confirmed, OrderStatus.Preparing) => true,
(OrderStatus.Preparing, OrderStatus.PickedUp) => true,
(OrderStatus.PickedUp, OrderStatus.Delivered) => true,
_ => false, // คู่ไหนไม่อยู่ในตาราง = เปลี่ยนไม่ได้
};

code ก้อน​นี้​คือ​ไดอะแกรม​เมื่อกี้​ที่​แปลง​เป็น C# แบบ​ตรง​ตัว — ลูกศร​แต่ละ​เส้น​บน​ภาพ คือ​หนึ่ง​บรรทัด​ที่​คืน true ส่วน​คู่​อื่น​ที่​ไม่มี​ลูกศร ตก​ไป​ที่ _ แล้ว​คืน false การ​เปลี่ยน​สถานะ​ข้าม​ขั้น (เช่น Placed → Delivered) ถูก​ปฏิเสธ​อัตโนมัติ​เพราะ​ไม่มี​บรรทัด​ไหน​รับ​มัน

พอ​เทียบ version ดิบ​กับ switch expression วาง​ข้าง​กัน จุด​ที่​ดี​กว่า​มี​สาม​ข้อชัดๆ:

  • อ่าน​เป็น​ตาราง input → output — ทุก​กฎ​อยู่​รวม​กัน​เป็น​ชุด อ่าน​จาก​ซ้าย (เงื่อนไข) ไป​ขวา (ผลลัพธ์) รวด​เดียว​จบ ไม่​ต้อง​ไล่ else ที​ละ​ชั้น​เพื่อ​หา​ว่า​เคส​หนึ่ง​ให้​ผล​อะไร
  • ลด​ความ​ซับซ้อน​ของ​เงื่อนไข — พีระมิด if ซ้อน​กัน​คือ​สิ่ง​ที่​นัก​ออกแบบ​เรียก​ว่า conditional complexity ยิ่ง​ซ้อน​ลึก​ยิ่ง​พลาด​ง่าย switch expression ทำให้​ทุก​กิ่ง​อยู่​ระดับ​เดียวกัน​หมด ไม่มี​ชั้น
  • compiler ช่วย​ตาม​งาน — เพราะ switch expression ต้อง​ครอบคลุม​ทุก​ความ​เป็น​ไป​ได้ ถ้า​เรา​ลืม​จับ​บาง​เคส compiler จะ​เตือน (ใน version ดิบ ลืม​เคส​ไป​ก็​เงียบ ไม่มี​ใคร​บอก) นี่​คือ​ความ​ต่าง​สำคัญ — มัน​เปลี่ยน “bug ตอน production” ให้​กลาย​เป็น “คำ​เตือน​ตอน compile”

หัวใจ​ของ​บท​นี้​คือ: เมื่อ logic ผูก​กับ​สถานะ อย่า​เขียน​เป็น if ซ้อน ให้​เขียน​เป็น​ตาราง pattern matching กับ switch expression คือ​ไวยากรณ์​ที่​ทำให้ “ตาราง” นั้น​เป็น code จริง​ที่​รัน​ได้​และ compiler ช่วย​ดูแล​ให้​ครบ

ตอน​นี้ domain Order ของ​เรา​มี​ทั้ง​ก้อน​อิฐ​ที่​อยู่​นิ่ง (Money, OrderId, Quantity) และ OrderStatus ที่​บอกว่า​ออเดอร์​กำลัง​อยู่​ช่วง​ไหน​ของ​ชีวิต พร้อม​กฎ​การ​เปลี่ยน​สถานะ​ที่​เขียน​เป็น​ตาราง​แล้ว บท​ต่อๆ ไป​จะ​ประกอบ​ร่าง​พวก​นี้​เข้า​ด้วย​กัน:

  • บท​ถัด​ไป — type system: enum จำกัด​ชุด​สถานะ​ให้​เป็น​ทางการ, การ​เลือก class/record/struct ให้​ถูก​งาน และ strongly-typed ID ที่​ห่อ Guid กัน​ไม่​ให้ OrderId กับ ProductId สลับ​กัน — เครื่องมือ​ที่​ทำให้ field ของ OrderLine/Order ที่​เรา​ปั้น​ไว้​แล้ว​ปลอดภัย​ขึ้น​อีก​ชั้น
  • บทหลังๆ — collection กับ LINQ ห่อ​กลุ่ม​ของ​บรรทัด​ใน Order, interface/generics/DI ทำให้ domain พึ่ง​แค่ port และ async/await กลาย​เป็น​รูปร่าง​ของ port ที่​คุย​กับ​โลก​ภายนอก

ส่วน​การ​ออกแบบ state machine ของ aggregate ใน​เชิง DDD ให้​ลึก​กว่า​นี้ (ใคร​มี​สิทธิ์​สั่ง​เปลี่ยน​สถานะ, domain event ที่​เกิด​ตาม​มา) เก็บ​ไว้​ที่​คอร์ส DDD in Code — บท​นี้​โฟกัส​ที่ “ไวยากรณ์ C# ที่​ทำให้​ตาราง​สถานะ​เป็น code จริง​ได้” ก่อน


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Conditional Complexity — code smell ของ if/else ที่​ซ้อน​กัน​จน​อ่าน​ยาก และ​ทำไม​มัน​เป็น​สัญญาณ​ว่า​ควร​ยุบ​เป็น​ตาราง​หรือ​แยก​ความ​รับผิดชอบ
  • State (design pattern) — pattern สำหรับ​จัดการ​อ็อบเจ็กต์ที่​พฤติกรรม​เปลี่ยน​ตาม​สถานะ เจาะ​ลึก​กว่า enum + switch เมื่อ state machine ของออเดอร์​ซับซ้อน​ขึ้น

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

switch expression ต่างจาก switch statement แบบเดิมอย่างไร?