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

Defensive Programming

คาด​การณ์​ปัญหา​ไว้​ล่วงหน้า และ​เขียน code ให้​ระบบ​อยู่​ใน​สถานะ​ที่​ดี​เสมอ

Defensive programming (หรือ defensive coding) คือ​แนว​ปฏิบัติ​ใน​การ คาด​การณ์​ปัญหา ที่​อาจ​เกิด​ขึ้น​ใน​ระบบ​ซอฟต์แวร์​ล่วงหน้า แล้ว​เขียน code เพื่อ​รักษา​ระบบ​ให้​อยู่​ใน​สถานะ​ที่​ดี​เมื่อ​ปัญหา​เหล่า​นั้น​เกิด​ขึ้น​จริง แนวคิด​นี้​ตั้ง​อยู่​บน​สมมติฐาน​แบบ Murphy’s Law — “อะไร​ที่​มี​โอกาส​ผิดพลาด​ได้ สัก​วัน​มัน​จะ​ผิดพลาด” ไม่​ว่า​จะ​เป็น input ที่​ผิด​รูปแบบ, dependency ที่​ล่ม, ผู้​ใช้​ที่​กรอก​ค่า​ประหลาด หรือ​แม้แต่ code ส่วน​อื่น​ใน​ทีม​เดียวกัน​ที่​เรียก​ใช้ function ผิด​วิธี

Steve McConnell อธิบาย​ไว้​ใน Code Complete ว่า defensive programming มี​สอง​มิติ​ที่​ต้อง​แยก​ให้​ออก​จาก​กัน คือ correctness (ความ​ถูกต้อง — ไม่​ยอม​ให้​ผลลัพธ์​ผิด​แม้แต่​นิดเดียว เหมาะ​กับ​ระบบ safety-critical) กับ robustness (ความ​ทนทาน — พยายาม​ให้​ระบบ​ทำงาน​ต่อ​ไป​ได้​แม้​เจอ​สถานการณ์​ที่​ไม่​คาด​คิด เหมาะ​กับ​ซอฟต์แวร์​ทั่วไป​ที่ downtime มี​ต้นทุน​สูง​กว่า​ความ​ผิดพลาด​เล็กน้อย) การ​เลือก​ว่า​จะ​เอียง​ไป​ทาง​ไหน​เป็นการ​ตัดสิน​ใจ​เชิง​สถาปัตยกรรม ไม่ใช่​กฎ​ตายตัว

Wikipedia แบ่ง​ขอบเขต​ของ defensive programming ออก​เป็น​สอง​แนว​ย่อย​ที่​สำคัญ:

  • Secure programming — มอง​ว่า​ข้อมูล​ทุก​ชิ้น​ที่มา​จาก​ภายนอก “แปดเปื้อน” (tainted) จนกว่า​จะ​พิสูจน์​ได้​ว่า​ปลอดภัย เน้น​ลด attack surface เช่น การ​ใช้ function ที่​ตรวจสอบ​ขอบเขต​หน่วย​ความ​จำ​แทน function ดิบ​ที่​ไม่​ตรวจสอบ หรือ​การ​ทำ canonicalization ข้อมูล (แปลง​ให้​อยู่​ใน​รูปแบบ​มาตรฐาน​เดียว) ก่อน​ตรวจสอบ เพื่อ​ไม่​ให้​ผู้​โจมตี​หลบ​เลี่ยง validation ด้วย​การ​เข้า​รหัส​ข้อมูล​ใน​รูปแบบ​อื่น
  • Offensive programming — แนวคิด​ตรง​ข้าม​ที่​มัก​ถูก​จัด​เป็น​สาขา​ย่อย​ของ defensive programming คือ​เชื่อ​ว่า code “ภายใน” ระบบ​ที่​ควบคุม​เอง​ได้​นั้น​ไว้ใจ​ได้ ส่วน​ที่​ต้อง​ระวัง​คือ input จาก​ภายนอก​เท่านั้น เมื่อ​เจอ​สถานะ​ที่ “เป็น​ไป​ไม่​ได้” ตาม​ทฤษฎี ให้​ใช้ assertion หยุด​โปรแกรม​ทันที​แทนที่​จะ​พยายาม fallback ให้​ทำงาน​ต่อ — นี่​คือ​หลักการ​เดียว​กับ Fail Fast ที่​ทำให้ bug เปิดเผย​ตัวเอง​เร็ว​ที่สุด​เท่า​ที่​จะ​ทำได้ ไม่​ถูก​กลบ​หรือ​ดำเนิน​ไป​ใน​สถานะ​ที่​เสียหาย​เงียบ ๆ

ประเด็น​ที่​ถกเถียง​กัน​บ่อย​คือ defensive programming ที่​ทำ​แบบ​ไม่​ระวัง​อาจ กลบ bug แทนที่​จะ​แก้​มัน เช่น การ catch exception ทุก​จุด​แล้ว​คืน​ค่า default ให้​เนียน ๆ โดย​ไม่ log หรือ​ไม่ throw ต่อ ทำให้​ปัญหา​จริง​ถูก​ซ่อน​ไว้​จนกว่า​จะ​ไป​โผล่​ใน​จุด​ที่​ไกล​จาก​ต้นตอ​มาก การ​ใช้ defensive programming ให้​ได้​ผล​จึง​ต้อง​รู้​ว่า “จุด​ไหน​ควร​ป้องกัน” กับ “จุด​ไหน​ควร​ปล่อย​ให้​พัง” — เขตแดน​สอง​ฝั่ง​นี้​เรียก​ว่า trust boundary หรือ barricade

1. ตรวจสอบ input ด้วย guard clause ที่​ขอบเขต​ของ​ระบบ (barricade) McConnell เสนอ​แนวคิด “สร้าง​บา​ริ​เคด” — แบ่ง code ออก​เป็น​โซน “ข้อมูล​สกปรก” (dirty, ยัง​ไม่​ผ่าน​การ​ตรวจสอบ) กับ “ข้อมูล​สะอาด” (clean, ผ่าน​การ​ตรวจสอบ​แล้ว) แล้ว​ตรวจสอบ​อย่าง​เข้มงวด​เฉพาะ​ตรง​จุด​ที่​ข้อมูล​ข้าม​พรมแดน​นี้​เท่านั้น ส่วน code ชั้น​ใน​ที่​อยู่​ใน​โซน​สะอาด​ไม่​ต้อง​เช็ค​ซ้ำซ้อน​ทุก​จุด ช่วย​ลด​ทั้ง​ความ​ซับซ้อน​และ​ภาระ runtime ลง​ได้​มาก รูปแบบ guard clause คือ​วิธี​ยอด​นิยม​ใน​การ​ตรวจสอบ​ที่​พรมแดน​นี้ เพราะ​ทำให้​ปัญหา​ถูก​จัดการ​ทันที​ก่อน​ตรรกะ​หลัก​จะ​เริ่ม​ทำงาน ตรง​ตาม​หลัก Fail Fast

2. แยก​ให้​ชัด​ว่า​จะ​ใช้ assertion หรือ error handling McConnell วาง​กฎ​ง่าย ๆ ไว้​ว่า “ใช้ error-handling code สำหรับ​เงื่อนไข​ที่​คาด​ว่า​จะ​เกิด​ขึ้น​ได้ และ​ใช้ assertion สำหรับ​เงื่อนไข​ที่​ไม่​ควร​เกิด​ขึ้น​เลย” — เช่น ข้อมูล​จาก​ผู้​ใช้​ที่​กรอก​ผิด​คือ​เงื่อนไข​ที่​ต้อง​มี error handling เพราะ​มัน​เกิด​ขึ้น​จริง​แน่นอน แต่​ตัวแปร private ภายใน class ที่​ค่า​ไม่​ควร​หลุด​ออก​นอก​ช่วง​ที่​กำหนด​ไว้​เลย​คือ​เงื่อนไข​ที่​เหมาะ​กับ assertion (จับ​ได้​เฉพาะ​ตอน dev/test ไม่​ทำงาน​ใน production build)

3. ออกแบบ​ตาม Design by Contract กำหนด precondition (สิ่ง​ที่​ต้อง​เป็น​จริง​ก่อน​เรียก method), postcondition (สิ่ง​ที่​รับประกัน​หลัง method ทำงาน​เสร็จ) และ invariant (สิ่ง​ที่​ต้อง​จริง​เสมอ​ตลอด​อายุ​ของ object) ไว้​อย่าง​ชัดเจน — แนวทาง​นี้​ทับซ้อน​กับ​หลัก Make Illegal States Unrepresentable เพราะ​ยิ่ง type system บังคับ contract ได้​มาก​เท่า​ไหร่ ก็​ยิ่ง​ต้อง​เขียน code defensive น้อย​ลง​เท่านั้น

4. เลือก​กลยุทธ์ error handling ให้​เหมาะ​กับ​บริบท เมื่อ​เจอ input ที่​ไม่​ถูกต้อง​ที่​พรมแดน มี​ทาง​เลือก​หลาย​แบบ ได้แก่ คืน​ค่า​กลาง (neutral value), แทนที่​ด้วย​ข้อมูล​ที่​ใกล้​เคียง​ถูกต้อง​ที่สุด, บันทึก log คำ​เตือน​แล้ว​ทำงาน​ต่อ, คืน error code, หรือ​เรียก routine จัดการ​ข้อ​ผิดพลาด/throw exception — ทาง​เลือก​ที่​เหมาะสม​ขึ้น​กับ​ว่า​ระบบ​ต้องการ correctness หรือ robustness ตาม​ที่​กล่าว​ไป​ข้าง​ต้น

แผนภาพ​ด้าน​ล่าง​สรุป​แนวคิด barricade: ข้อมูล​ที่​เข้า​มา​จาก​ภายนอก​ต้อง​ผ่าน guard clause ก่อน หาก​ไม่​ผ่าน​ให้ fail fast ทันที หาก​ผ่าน​แล้ว​จึง​เข้า​สู่​โซน​ที่​ไว้ใจ​ได้​ซึ่ง​ไม่​ต้อง​ตรวจ​ซ้ำ

flowchart LR
  Input[Untrusted Input from Caller] --> Guard{Guard Clause Check}
  Guard -->|Invalid| Reject[Fail Fast Throw Exception]
  Guard -->|Valid| Barricade[Trust Boundary Crossed]
  Barricade --> Clean[Clean Zone Core Logic Runs Safely]
  Clean --> Result[Return Result]

Guard clause: ตรวจ​เงื่อนไข​ที่​ผิด​ตั้งแต่​ต้น แล้ว​ออก​ทันที — นี่​คือ barricade ที่​พรมแดน​ของ method

public decimal ApplyDiscount(Order order, decimal percent)
{
// guard clauses — ล้มเร็ว ก่อนเข้าสู่ตรรกะหลัก (barricade ที่ขอบของ method)
if (order is null) throw new ArgumentNullException(nameof(order));
if (percent < 0 || percent > 100)
throw new ArgumentOutOfRangeException(nameof(percent));
// ถึงตรงนี้ input ถูกต้องแน่นอน — งานจริงเริ่มได้อย่างสบายใจ
return order.Total * (1 - percent / 100m);
}

ตัวอย่าง​ต่อ​ไป​นี้​แสดง​การ​แยก assertion (สำหรับ​สถานะ​ที่ “ไม่​ควร​เกิด​ขึ้น​เลย” ภายใน​ระบบ​ที่​ควบคุม​เอง) ออก​จาก error handling (สำหรับ input จาก​ภายนอก​ที่​คาด​ว่า​จะ​ผิด​ได้​จริง) ตาม​แนวทาง​ของ Code Complete:

public class ShoppingCart
{
private readonly List<CartLine> _lines = new();
// error handling — input จากผู้เรียกภายนอก คาดว่าอาจผิดพลาดได้จริง
public void AddLine(CartLine line)
{
if (line is null) throw new ArgumentNullException(nameof(line));
if (line.Quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(line));
_lines.Add(line);
}
// assertion — invariant ภายในที่ไม่ควรพังได้เลยถ้า code class นี้ถูกต้อง
public decimal GetTotal()
{
Debug.Assert(_lines.All(l => l.Quantity > 0),
"invariant violated: found a non positive quantity line");
return _lines.Sum(l => l.UnitPrice * l.Quantity);
}
}

ประโยชน์

  • ลด​จำนวน bug ที่​หลุด​ไป​ถึง production เพราะ​ปัญหา​ถูก​จับ​ที่​พรมแดน​ตั้งแต่​ต้นทาง
  • ทำให้ bug ที่​ยัง​หลง​เหลือ​อยู่ หา​เจอ​ง่าย​ขึ้น​และ​แก้​ง่าย​ขึ้น เพราะ​ข้อความ error ชี้​ตรง​จุด​ที่​เกิด​ปัญหา​แทนที่​จะ​ไป​พัง​ไกล​จาก​ต้นตอ
  • เพิ่ม​ความ​ปลอดภัย เพราะ​ข้อมูล​จาก​ภายนอก​ถูก​ปฏิบัติ​เสมือน “แปดเปื้อน” จนกว่า​จะ​พิสูจน์​ตรง​กัน​ข้าม ช่วย​ลด attack surface ต่อ​การ​โจมตี​อย่าง injection
  • แยก​ความ​รับผิดชอบ​ชัดเจน​ระหว่าง​โซน​ที่​ต้อง​ระวัง​กับ​โซน​ที่​ไว้ใจ​ได้ ทำให้ code ชั้น​ใน​อ่าน​ง่าย​ขึ้น​เพราะ​ไม่​ต้อง​เช็ค null หรือ range ซ้ำซ้อน​ทุก​จุด

ข้อ​ควร​ระวัง

  • Microsoft Learn เตือน​ไว้​ชัดเจน​ว่า defensive code ที่​มาก​เกิน​ไป​ทำให้ code อ่าน​ยาก​ขึ้น ทำงาน​ช้า​ลง ซับซ้อน​ขึ้น และ​กิน​เวลา​พัฒนา​โดย​ไม่​ได้​สัดส่วน​กับ​คุณค่า​ที่​ได้ ต้อง​ชั่ง​น้ำหนัก​กับ engineering practice อื่น​เสมอ ไม่ใช่​ยิ่ง​เช็ค​เยอะ​ยิ่ง​ดี
  • ตัว defensive programming เอง​ก็​ไม่​รอดพ้น​จาก bug — code validation ที่​เขียน​ผิดพลาด​กลาย​เป็น​จุด​เสี่ยง​ใหม่​ได้​เช่น​กัน
  • การ catch exception กว้าง ๆ แล้ว “กลบ” ปัญหา​ด้วย​การ​คืน​ค่า default เงียบ ๆ โดย​ไม่ log หรือ​ไม่​ปล่อย​ผ่าน​ต่อ ขัด​กับ​ปรัชญา offensive programming/Fail Fast และ​ทำให้การ debug ยาก​ขึ้น​มาก​ใน​ระยะ​ยาว ควร​สงวน​พฤติกรรม​แบบ​นี้​ไว้​เฉพาะ​จุด​ที่​ตั้งใจ​ออกแบบ​ให้ robust จริง ๆ เท่านั้น
  • ต้อง​แยก​ให้​ออกว่า​ใช้ assertion (จับ​ใน dev/test ไม่​ทำงาน​ใน production) หรือ error handling (ทำงาน​ตลอด​ใน production) — ใช้​ผิด​ที่​จะ​ทำให้ bug production ไม่​ถูก​จับ หรือ​ทำให้ production code ช้า​ลง​โดย​ไม่​จำเป็น