Introduce Assertion
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”code หลายชิ้นทำงานถูกต้องได้ก็ต่อเมื่อบางอย่างเป็นจริงอยู่ก่อน เช่น ส่วนลดอยู่ระหว่าง 0 ถึง 1 list ไม่ว่าง หรือบัญชีเปิดใช้งานแล้ว ข้อสมมติพวกนี้มักอยู่ในหัว dev ไม่ได้อยู่ใน code Introduce Assertion เขียนข้อสมมตินั้นออกมาเป็นการตรวจสอบ ที่เงียบสนิทเมื่อทุกอย่างเป็นไปตามคาด และหยุดโปรแกรมเสียงดังเมื่อไม่เป็นอย่างนั้น
assertion คือคำประกาศว่าอะไร ต้อง เป็นจริง ณ จุดนั้นใน code ไม่ใช่การจัดการ error สำหรับ input ที่คุณคาดว่าจะผิดอยู่แล้ว แต่เป็นสายสะดุดสำหรับสถานการณ์ที่ควรเป็นไปไม่ได้ ถ้า assertion ทำงานเมื่อไร แปลว่า caller มีบั๊ก
Code Smell
หัวข้อที่มีชื่อว่า “Code Smell”คุณอ่านการคำนวณชิ้นหนึ่งแล้วคิดในใจว่า “อันนี้จะสมเหตุสมผลก็ต่อเมื่อ rate เป็นบวก” แต่ไม่มีอะไรใน code เขียนไว้เลย ข้อสมมตินั้นจึงล่องหน วันที่ค่าศูนย์หรือค่าลบเล็ดลอดเข้ามา function ก็ผลิตขยะออกมาอย่างหน้าตาเฉย ความเสียหายไปโผล่ห่างออกไปสามชั้นในยอดรวมที่ผิดแบบเงียบ ๆ แล้วคุณก็เสียเวลาทั้งบ่ายไล่ย้อนกลับมาหาต้นตอ เงื่อนไขที่ code พึ่งพาไม่เคยถูกเขียนไว้ จึงไม่มีอะไรคอยเฝ้า
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”function ตัวนี้ใช้ส่วนลดที่ต้องเป็นเศษส่วนระหว่าง 0 ถึง 1 ตอนแรกข้อจำกัดนั้นไม่ได้เขียนไว้ที่ไหน หลัง refactor assertion พูดออกมาตรง ๆ ค่า rate ที่ผิดจึงพังตั้งแต่หน้าประตู
// Beforefunction applyDiscount(price: number, rate: number): number { // silently assumes 0 <= rate <= 1 return price - price * rate;}
// Afterfunction applyDiscount(price: number, rate: number): number { if (rate < 0 || rate > 1) { throw new Error(`discount rate must be in [0, 1], got ${rate}`); } return price - price * rate;}# Beforedef apply_discount(price, rate): # silently assumes 0 <= rate <= 1 return price - price * rate
# Afterdef apply_discount(price, rate): assert 0 <= rate <= 1, f"discount rate must be in [0, 1], got {rate}" return price - price * rate// Go has no assert keyword. State the invariant with an explicit// check and panic for a true "this can never happen" bug. If the// bad value can come from outside, return an error instead.//// Beforefunc ApplyDiscount(price, rate float64) float64 { // silently assumes 0 <= rate <= 1 return price - price*rate}
// Afterfunc ApplyDiscount(price, rate float64) float64 { if rate < 0 || rate > 1 { panic(fmt.Sprintf("discount rate must be in [0, 1], got %v", rate)) } return price - price*rate}// Use assert! for an invariant that must hold in every build, or// debug_assert! when the check is only worth paying for in dev builds.//// Beforefn apply_discount(price: f64, rate: f64) -> f64 { // silently assumes 0.0 <= rate <= 1.0 price - price * rate}
// Afterfn apply_discount(price: f64, rate: f64) -> f64 { assert!( (0.0..=1.0).contains(&rate), "discount rate must be in [0, 1], got {rate}" ); price - price * rate}flowchart LR
A["Caller passes bad value"] --> B{"Assertion present?"}
B -->|"No"| C["Wrong math runs<br/>NaN spreads<br/>fails far away"]
B -->|"Yes"| D["Stops here<br/>with a clear message"] กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- หาข้อสมมติให้เจอ อ่าน code ที่น่าสงสัยแล้วเติมประโยค “อันนี้ทำงานได้ก็ต่อเมื่อ…” ให้จบ อนุประโยคนั้นคือ assertion ของคุณ
- เขียนออกมาเป็นการตรวจสอบที่เป็นจริงเสมอในกรณีปกติ แล้ววางให้ใกล้ code ที่พึ่งพาเงื่อนไขนั้นที่สุด ก่อนที่งานจริงจะเริ่ม
- ทำให้ความล้มเหลวดังและเจาะจง ใส่ค่าที่ผิดลงไปในข้อความด้วย ผู้อ่านในอนาคตจะได้เห็นว่า อะไร ทำลายข้อสมมติ ไม่ใช่รู้แค่ว่าพัง
- รัน test เส้นทางที่ปกติดีต้องผ่านต่อไปโดยไม่ถูกแตะต้อง — assertion ที่ถูกต้องไม่เปลี่ยนแปลงอะไรสำหรับ input ที่ถูกต้อง
- เพิ่ม test ที่ป้อนค่าที่ผิดกฎเข้าไป แล้วยืนยันว่า assertion ถูกจุดชนวน นี่พิสูจน์ว่าสายสะดุดถูกต่อไว้แล้ว
- ทำซ้ำสำหรับข้อสมมติถัดไปที่ไม่ได้กล่าวไว้ ต้านทานการ assert สิ่งที่ชัดเจนว่าถูกรับประกันโดยชนิดข้อมูลอยู่แล้ว ให้ assert สิ่งที่ caller อาจทำผิดได้จริง
ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน”ใส่ assertion เมื่อ code ส่วนหนึ่งพึ่งพาเงื่อนไขอยู่เงียบ ๆ และถ้าเงื่อนไขนั้นพัง ข้อมูลจะเสียหายในจุดที่ไกลจากต้นเหตุมาก assertion ยังทำหน้าที่เป็นเอกสารไปในตัว เพราะบอกผู้อ่านคนถัดไปตรง ๆ ว่า code คาดหวังอะไร และต่างจาก comment ตรงที่เคลื่อนห่างจากความจริงไม่ได้ เพราะอันที่เป็นเท็จจะพังให้เห็นเอง
เส้นแบ่งที่ต้องระวังคือ assertion กับการจัดการ error assertion คอยจับความผิดพลาดของ dev คือ state ที่ควรเป็นไปไม่ได้ถ้า caller ทุกตัวทำถูก ส่วน input ที่ผิดรูปได้เป็นปกติ เช่น ผู้ใช้พิมพ์ผิด หรือ payload จาก network เสีย ไม่ใช่หน้าที่ของ assertion ให้ validate แล้ว return error จริงออกไป ใน Rust คือเลือกระหว่าง assert!/debug_assert! กับ Result ส่วนใน Go คือเลือกระหว่าง panic กับ error ที่ return ออกมา และอย่าใส่ side effect ไว้ใน
assertion เพราะ build ที่ถอด assertion ออก (Python’s -O, Rust’s debug_assert!) จะเปลี่ยน behavior อย่างเงียบ ๆ
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Replace Nested Conditional with Guard Clauses
- Replace Error Code with Exception
- Decompose Conditional
| ใช้ Introduce Assertion เมื่อ | หลีกเลี่ยงเมื่อ |
|---|---|
| code พึ่งพาเงื่อนไขโดยไม่กล่าวไว้ชัดเจน | input นั้นมาจาก user หรือ network — ใช้ error handling แทน |
| ต้องการให้ violation หยุดที่ต้นตอ ไม่ใช่ propagate | assertion ที่จะ trigger ใน production flow ปกติ |
| ข้อสมมติที่ละเมิดแล้วสร้างความเสียหายที่ห่างไกล | assertion มี side effect — จะหายไปใน optimized build |
⚠️ ไม่ควร Introduce Assertion เมื่อ:
- เงื่อนไขนั้น validate input จาก user หรือ external system — ควร return error แทน
- assertion ทำงานบ่อย ๆ ใน normal flow — แปลว่านั่นคือ expected condition
- ใส่ side effect ใน assertion — behavior จะเปลี่ยนเมื่อ assertion ถูกปิด