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

Guard Clause

ตรวจ​เงื่อนไข​ที่​ผิด​ตั้งแต่​ต้น method แล้ว​ออก​ทันที ลด​ความ​ซับซ้อน

ความ​ซับซ้อน​ทำให้​เข้าใจ code ยาก​ขึ้น หนึ่ง​ใน​ตัวการ​หลัก​คือ conditional complexity จาก if/switch ที่​ซ้อน​กัน​หลาย​ชั้น เมื่อ logic หลัก​ถูก​ห่อ​ไว้​ใน​เงื่อนไข​ที่​ลึก​ลง​เรื่อย ๆ โครงสร้าง​ของ code จะ​ค่อย ๆ เยื้อง​ไป​ทาง​ขวา​จน​ดูเหมือน​ลูกศร​ชี้ — ผู้​อ่าน​ต้อง​ไล่​ตาม​เงื่อนไข​ซ้อน​กัน​หลาย​ชั้น​ใน​หัว ก่อน​จะ​รู้​ว่า “ทาง​เดิน​ปกติ” (happy path) ของ method คือ​อะไร

Guard clause คือ​การ​ตรวจ​เงื่อนไข​พิเศษ​หรือ​กรณี​ขอบ (edge case) แล้ว ออก​จาก method ทันที ด้วย return หรือ throw เมื่อ input หรือ​สถานะ​ไม่​ถูกต้อง โดย​วาง​เงื่อนไข​เหล่า​นี้​ไว้​ที่​ต้น method แยก​ออก​จาก logic หลัก แนวคิด​คือ “กรณี​นี้​ผิด​ปกติ ถ้า​เกิด​ขึ้น​ให้​จัดการ​แล้ว​ออก​ไป​เลย” — ตรง​ข้าม​กับ​การ​ห่อ logic หลัก​ไว้​ใน if ที่​ซ้อน​ลึก

ผลลัพธ์​คือ code ที่​แบน​ราบ (flat) ขึ้น อ่าน​จาก​บน​ลง​ล่าง​ได้​เป็น​เส้นตรง เหลือ​แต่​ทาง​ที่​ถูกต้อง​เป็น​เนื้อ​หลัก​ที่​ไม่​ต้อง​เยื้อง และ​เป็น​ไป​ตาม​หลัก Fail Fast — พบ​ปัญหา​ให้​แจ้ง​ทันที ไม่​ปล่อย​ให้​ลุกลาม​ลึก​เข้าไป​ใน logic

เทียบ — เงื่อนไข​ซ้อน​ลึก vs. guard clause ที่​ทำให้​ตรรกะ​หลัก​ไม่​ต้อง​เยื้อง

ซ้อนลึก: guard clause:
if (valid) { if (!valid) return; ← ออกก่อน
if (ready) { if (!ready) return;
...ตรรกะหลัก... ...ตรรกะหลัก... (ไม่ซ้อน)
}
}

Guard clause ไม่ใช่ pattern เชิง​โครงสร้าง (ไม่มี class หรือ interface พิเศษ) แต่​เป็น​รูปแบบ​การ​จัด​เรียง control flow ภายใน method เดียว ไดอะแกรม​ด้าน​ล่าง​แสดง​ว่า method หนึ่ง​ประกอบ​ด้วย guard clause หลาย​ตัว​เรียง​ต่อ​กัน ก่อน​ถึง core logic

classDiagram
    class Method {
      +Execute(input) Result
    }
    class GuardNullCheck {
      +Check(input) bool
      +ExitEarly() void
    }
    class GuardEmptyCheck {
      +Check(input) bool
      +ExitEarly() void
    }
    class GuardStateCheck {
      +Check(input) bool
      +ExitEarly() void
    }
    class CoreLogic {
      +Run(input) Result
    }
    Method --> GuardNullCheck
    Method --> GuardEmptyCheck
    Method --> GuardStateCheck
    Method --> CoreLogic
    GuardNullCheck --> GuardEmptyCheck
    GuardEmptyCheck --> GuardStateCheck
    GuardStateCheck --> CoreLogic
  • Method — method ที่​มี guard clause อยู่​ภายใน
  • GuardXxxCheck — แต่ละ​เงื่อนไข​ที่​ตรวจ​ก่อน ถ้า​เงื่อนไข​เป็น​จริง (ผิด​ปกติ) จะ ExitEarly() ด้วย return หรือ throw
  • CoreLogic — logic หลัก​ที่​ทำงาน​เมื่อ​ผ่าน​ทุก guard แล้ว​เท่านั้น ไม่​ต้อง​เยื้อง​อยู่​ใน if อีก

การ​ทำงาน​ของ guard clause คือ​ลำดับ​การ​ตรวจ​แบบ fail fast: ตรวจ​ที​ละ​เงื่อนไข เรียง​จาก​กรณี​ผิด​ปกติ​ที่สุด​หรือ​ถูก​ตรวจ​ได้​ง่าย​ที่สุด​ไป​ก่อน แล้ว​ออก​ทันที​เมื่อ​พบ ไม่มี​เงื่อนไข​ไหน​ต้อง “รอ” เงื่อนไข​อื่น​ซ้อน​อยู่​ข้าง​ใน

flowchart TD
    A[เริ่ม method] --> B{input เป็น null?}
    B -- ใช่ --> B1[throw ArgumentNullException]
    B -- ไม่ --> C{input ว่างเปล่า?}
    C -- ใช่ --> C1[return ค่าเริ่มต้น]
    C -- ไม่ --> D{สถานะพร้อมหรือไม่?}
    D -- ไม่ --> D1[return หรือ throw InvalidOperationException]
    D -- ใช่ --> E[ทำ core logic]
    E --> F[return ผลลัพธ์]

ข้อสังเกต​สำคัญ​คือ แต่ละ guard ตรวจ​แล้ว​ออก​ทันที ไม่มี​การ else ต่อ​กัน​เป็น​สาย ทำให้​เมื่อ​อ่าน​ถึง​บรรทัด​สุดท้าย​ของ method ผู้​อ่าน​มั่นใจ​ได้​ว่า​เงื่อนไข​ที่​ผิด​ทั้งหมด​ถูก​กรอง​ออก​ไป​แล้ว เหลือ​แต่​กรณี​ที่​ถูกต้อง​เท่านั้น​ที่​จะ​ไป​ถึง core logic — สอดคล้อง​กับ​หลักการ Fail Fast ที่​ว่า​ให้​ตรวจ​พบ​และ​แจ้ง​ข้อ​ผิดพลาด​เร็ว​ที่สุด​เท่า​ที่​จะ​ทำได้ แทนที่​จะ​ปล่อย​ให้​ข้อมูล​ที่​ไม่​ถูกต้อง​ไหล​ลึก​เข้าไป​ใน​ระบบ

ตัวอย่าง​การ​ประมวล​ผล​คำ​สั่ง​ซื้อ (order) ที่​ต้อง​ตรวจ​หลาย​เงื่อนไข​ก่อน​จะ​ดำเนิน​การ​จริง เทียบ​ให้​เห็น​ทั้ง​สอง​แบบ

// ----- แบบเงื่อนไขซ้อนลึก (หลีกเลี่ยง) -----
public void Process(Order order)
{
if (order != null)
{
if (order.Items.Count > 0)
{
if (order.Status == OrderStatus.Pending)
{
if (order.Customer.IsActive)
{
// ตรรกะหลักถูกฝังอยู่ลึกถึง 4 ชั้น อ่านยาก ทดสอบยาก
Fulfil(order);
}
}
}
}
}
// ----- แบบ guard clause (แนะนำ) -----
public void Process(Order order)
{
if (order is null)
throw new ArgumentNullException(nameof(order)); // guard: null check
if (order.Items.Count == 0)
return; // guard: ไม่มีรายการให้ทำ ออกเงียบ ๆ
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException(
$"ไม่สามารถประมวลผลคำสั่งซื้อที่มีสถานะ {order.Status}"); // guard: สถานะไม่ถูกต้อง
if (!order.Customer.IsActive)
throw new InvalidOperationException("ลูกค้าถูกระงับการใช้งาน"); // guard: ลูกค้าไม่ active
// ถึงตรงนี้ input ถูกต้องแน่นอน — โฟกัสที่ตรรกะหลักได้เต็มที่ ไม่ต้องเยื้อง
Fulfil(order);
}

จะ​เห็น​ว่า version guard clause แต่ละ​เงื่อนไข​ตรวจ​สิ่ง​เดียว​และ​ออก​ทันที ทำให้​สามารถ​เขียน unit test แยก​ที​ละ guard ได้​ตรง​ไป​ตรง​มา เช่น ทดสอบ​ว่า order เป็น null แล้ว​ต้อง throw ArgumentNullException, ทดสอบ​ว่า​รายการ​ว่าง​แล้ว​ต้อง return โดย​ไม่​เรียก Fulfil เป็นต้น

  • method มี “ทาง​เดิน​ปกติ” (happy path) ที่​ชัดเจน​หนึ่ง​ทาง และ​มี​กรณี​พิเศษ/ผิด​ปกติ​หลาย​กรณี​ที่​ต้อง​กัน​ออก​ก่อน
  • ต้องการ​ตรวจ precondition ของ input (null, ค่า​ว่าง, ค่า​นอก​ช่วง) ก่อน​เริ่ม​ทำงาน​จริง ตาม​หลัก Defensive Programming
  • code เดิม​มี​เงื่อนไข​ซ้อน​กัน​หลาย​ชั้น​จน conditional complexity สูง และ​ต้องการ​ลด cyclomatic complexity แบบ​ไม่​เปลี่ยน​พฤติกรรม (pure refactoring)
  • ต้องการ​ให้ error/edge case ปรากฏ​ชัดเจน​อยู่​ด้าน​บน​ของ method แทนที่​จะ​ฝัง​ลึก​อยู่​กลาง logic ช่วย​ให้​ผู้​อ่าน​และ Code Readability ดี​ขึ้น
  • แต่ละ​เงื่อนไข​เป็น​อิสระ​ต่อ​กัน ไม่​ได้​พึ่งพา​ผลลัพธ์​ของ​เงื่อนไข​อื่น​แบบ​ซับซ้อน
  • เงื่อนไข​ต่าง ๆ พึ่งพา​กัน​แบบ​ซับซ้อน (interdependent) การ​แยก​เป็น guard เดี่ยว ๆ อาจ​ทำให้​เข้าใจ​ยาก​กว่า​เดิม — กรณี​นี้​อาจ​เหมาะ​กับ Replace Conditional with Polymorphism มากกว่า
  • method มี​หลาย​ทางออก​ที่ “เท่า​กัน” ไม่​ได้​มี​ทาง​ใด​เป็น happy path ชัดเจน (เช่น state machine ที่​ทุก branch สำคัญ​เท่า​กัน) — การ​ใช้ guard clause พร่ำเพรื่อ​อาจ​ทำให้​สับสน​ว่า​ทาง​ไหน​คือ​กรณี​ปกติ
  • ทีม​มี​มาตรฐาน code ที่​ห้าม multiple return points อย่าง​เคร่งครัด (บาง coding standard เก่า​ยัง​ยึด​หลัก single-entry single-exit)
  • guard ถูก​ใช้​จน​ล้น​เกิน (เช่น 10+ guard ต่อ​เนื่อง​กัน) — อาจ​เป็น​สัญญาณ​ว่า method ทำ​หน้าที่​มาก​เกิน​ไป​และ​ควร​แยก​ความ​รับผิดชอบ (Single Responsibility Principle)
  • code มี side effect ที่​ต้อง​เกิด​ขึ้น​เสมอ​ไม่​ว่า​เงื่อนไข​จะ​เป็น​อย่างไร (เช่น ต้อง cleanup resource) — ต้อง​ระวัง​ไม่​ให้ early return ข้าม​การ cleanup นั้น​ไป (ใช้ finally หรือ using ช่วย)
ด้านรายละเอียด
ข้อดีลด nesting และ cyclomatic complexity ทำให้​ทาง​เดิน​ปกติ​อ่าน​ง่าย​เป็น​เส้นตรง
ข้อดีแต่ละ​เงื่อนไข​เป็น​อิสระ ทดสอบ​แยก​ที​ละ​กรณี​ได้​ง่าย (unit test ต่อ guard)
ข้อดีสอดคล้อง​กับ Fail Fast — ตรวจ​พบ​ปัญหา​เร็ว ไม่​ปล่อย​ให้ state ผิดพลาด​ไหล​ลึก​เข้าไป​ใน​ระบบ
ข้อดีลด​การ​ซ้ำ code จาก else ที่​ไม่​จำเป็น
ข้อ​เสียมี return point หลาย​จุด​ใน method เดียว ซึ่ง​บาง​มาตรฐาน code (เช่น single-entry single-exit แบบ​เก่า) มอง​ว่า​ไม่​พึง​ประสงค์
ข้อ​เสียถ้า​ใช้​พร่ำเพรื่อ​กับ​เงื่อนไข​ที่​พึ่งพา​กัน​ซับซ้อน อาจ​ทำให้​แยก​ตาม​ไม่ทัน​ว่า guard ไหน​ตัด​กรณี​อะไร​ออก​ไป​แล้ว
ข้อ​เสียต้อง​ระวัง side effect หรือ resource cleanup ที่​ต้อง​เกิด​ขึ้น​ก่อน early return เสมอ