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 ช้าลงโดยไม่จำเป็น
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Fail Fast
- Make Illegal States Unrepresentable
- Explicit Dependencies
- Murphy’s Law
- Postel’s Law
- Guard Clause