คุณเขียน Rust ใช่ไหม ดังนั้นต้องรู้จักกับ Creusot สักหน่อย อย่าพลาดของดี
ก่อนจะเข้าเนื้อหาหลัก ขอแอบสปอยนิดนึงว่า… สิ่งที่คุณกำลังจะได้จาก
Creusotคือความสามารถในการ “เขียนเงื่อนไขความถูกต้องของระบบ”
ลงไปในโค้ดของคุณโดยตรงแล้วให้ Creusot ทำหน้าที่เป็น “ผู้พิสูจน์”
ว่าฟังก์ชันนั้น
ทำงาน “ตรงตามที่คุณระบุไว้” จริงหรือไม่และถ้าวันหนึ่ง
สิ่งที่โค้ดทำ มันขัดแย้งกับสิ่งที่คุณบอกว่า “ควรจะเป็น”❌ โค้ดนั้นจะไม่มีวัน build ผ่าน
แต่ก่อนอื่น เพื่อให้เราคุยกันได้ลึกขึ้น ผมอยากปูความเข้าใจเรื่อง macro ใน Rust สักเล็กน้อย
ถ้าคุณเคยเห็นอะไรแบบนี้
#[some_attribute] //<--หมายถึงตรงนี้
fn do_something() { ... }
สิ่งที่อยู่ข้างบนฟังก์ชันพวกนี้ มันไม่ใช่ comment ไม่ใช่ decoration เฉย ๆ
มันคือ “macro”
บางคนจะเรียกมันว่า preprocessor ก็พอได้ (แม้ในเชิงเทคนิค Rust จะไม่ได้ใช้คำนี้ตรง ๆ แบบ C)
แต่ภาพที่ควรนึกคือ
🧠 มันคือ “โปรแกรมเล็ก ๆ” ที่มารันก่อนโค้ดคุณจริง ๆ
ตอนที่คุณสั่ง build
สิ่งที่เกิดขึ้นไม่ใช่ rustc เอาโค้ดคุณไป compile ตรง ๆ ทันที แต่จะมีช่วงหนึ่งที่ macro พวกนี้ถูก execute ก่อนเสมอ
โค้ดที่มี macro กำกับไว้ จะไม่ถูกมองว่าเป็นโค้ดธรรมดาอีกต่อไป
แต่จะถูก “โยนเข้าไปให้ macro จัดการ”
ภาพในหัวของคุณตอนนี้ควรเป็นแบบนี้
- เริ่มจาก โค้ดของคุณถูกสั่ง build
- rustc จะ compile ตัว macro ให้กลายเป็นโปรแกรมเล็กๆก่อน
- จากนั้น โค้ดที่มี macro กำกับ จะถูกส่งเข้าไปให้โปรแกรมเล็กๆพวกนั้น
- macro จะทำงานกับโค้ดตามที่มันถูกเขียนไว้
- มันอาจ rewrite โค้ดของคุณ หรือ generate โค้ดใหม่ขึ้นมาโดยที่คุณไม่เห็น (ซ่อนการทำงานไว้เบื้องหลัง)
- แล้วผลลัพธ์สุดท้าย ถึงจะถูกส่งต่อให้ compiler ทำงานจริง
ซึ่ง macro สามารถทำได้หลายอย่างมาก เช่น
-
แก้ไขโค้ดของคุณ (rewrite)
-
generate โค้ดเพิ่ม
-
ตรวจสอบเงื่อนไขบางอย่าง
-
❗ และมันสามารถ “หยุดการ build” ได้เลย ถ้ามันไม่พอใจ
ตรงนี้แหละคือจุดตั้งต้นที่สำคัญ
เพราะ
Creusotไม่ได้เป็นแค่ library ธรรมดา แต่มัน “ใช้พลังของ macro” เพื่อแทรก logic บางอย่างเข้าไปในโค้ดของคุณ และสิ่งที่มันตรวจสอบ มันลึกกว่าที่ compiler ปกติทำ
งั้นลองดู use case ง่าย ๆ ก่อน แบบที่ไม่ต้องคิดเยอะ แต่เห็นภาพทันที
สมมติว่าเรามีฟังก์ชันแบบนี้
fn plus_one(x: i32) -> i32 {
x + 1
}
ฟังก์ชั่นนี้ ทำงานง่ายๆ คือรับค่า x แล้วบวก 1 กลับไป
❓ คำถามคือ ฟังก์ชันนี้ “ถูกต้อง” ไหม?
ในมุมของ Rust
- ✅ มัน compile ผ่าน
- ✅ ไม่มี memory issue
- ✅ ไม่มี data race
ทุกอย่างดูโอเคหมด
แต่ลองคิดอีกมุมนึง
ถ้าในระบบของคุณ ค่าที่ส่งเข้ามา
“💥จำเป็นที่จะต้องคิดเผื่อว่า มันต้องไม่ overflow💥” ล่ะ?
เพราะสำหรับ i32
ถ้า
x = i32::MAX //ค่าสูงสุดของ i32 คือ 2147483647
//แล้วหากเอามาบวก 1 เข้าไป
x + 1
สิ่งที่ได้… ไม่ใช่ 2147483648
💥💥💥แต่มันคือ overflow💥💥💥
เพราะตัวแปรมันล้น เกินขอบเขตที่มันเก็บได้
ปกติเราอาจจะเขียนกันแบบนี้
fn plus_one(x: i32) -> i32 {
assert!(x < i32::MAX);
x + 1
}
ซึ่งก็ช่วยได้ แต่เหมือนเดิม มันตรวจตอน runtime
แต่กับ Creusot คุณสามารถบอกสิ่งที่ “ต้องเป็นจริงก่อนเรียกฟังก์ชัน” ได้ตรง ๆ
#[requires(x < i32::MAX)] // <-- ตรงนี้ ที่อยากให้ดู
fn plus_one(x: i32) -> i32 {
x + 1
}
✅ นี่ไม่ใช่การ check แต่มันคือการ “ประกาศกติกา” ให้กับ Creusot
และหน้าที่ของ Creusot คือพยายามพิสูจน์ให้ได้ว่า
ฟังก์ชันนี้ ถูกเขียนให้ทำงาน “ตรงตามกติกา” ที่เราระบุไว้จริงหรือไม่
ถ้ามันพิสูจน์แล้วพบว่า
โค้ดของคุณขัดแย้งกับกติกานี้
❌ โค้ดคุณจะ build ไม่ผ่าน
และไม่แค่นั้น Creusot ไม่ได้เช็คแค่ตัวฟังก์ชัน
แต่มันจะ “ไล่ตรวจทั้งโปรแกรม”
เพื่อมองหาว่า
“ทุกจุดที่เรียกฟังก์ชันนี้”
ต้องสามารถพิสูจน์ได้ว่า
เรียกใช้งานมันอย่างถูกต้อง
ถ้าจุดไหนพิสูจน์ไม่ได้
ทั้งโปรแกรมจะไม่ผ่านการ build
คล้าย ๆ Unit Test แต่ลึกกว่า
เพราะมันไม่ใช่แค่การทดสอบผลลัพธ์
แต่มันคือการพิสูจน์ว่า
“โค้ดของคุณ ถูกเขียนให้สอดคล้องกับเงื่อนไขที่คุณระบุไว้จริง”
ถัดมา เรามาบังคับรูปแบบของ Input และ Output กัน
จากตัวอย่างก่อนหน้า
เราบอกไปแล้วว่า
ฟังก์ชันนี้ “รับค่าแบบไหนได้” ด้วย #[requires(...)]
แต่นั่นยังไม่ครบ
เพราะในหลายกรณี
เราไม่ได้อยากคุมแค่ input
เรายังอยาก “บังคับผลลัพธ์” ด้วยว่า
ฟังก์ชันนี้ต้องคืนค่าอะไรออกมา
กลับมาที่ plus_one
ตอนนี้เรามีแบบนี้
#[requires(x < i32::MAX)]
fn plus_one(x: i32) -> i32 {
x + 1
}
สิ่งที่เราบอกคือ
ห้ามมีใครเรียกฟังก์ชันนี้ด้วยค่าที่จะทำให้ overflow
แต่เรายังไม่ได้บอกว่า
แล้วผลลัพธ์ต้องเป็นอะไร
เราสามารถระบุได้แบบนี้
#[requires(x < i32::MAX)]
#[ensures(result == x + 1)]
fn plus_one(x: i32) -> i32 {
x + 1
}
ตรง #[ensures(...)]
คือการบอกว่า
หลังจากฟังก์ชันทำงานเสร็จ ค่าที่ return ออกมา จะต้องเท่ากับ x + 1 เท่านั้น หากผิดไปจากนี้ เช่นกรณี overflow จะถูกปัดตกโดยปริยาย
เมื่อมันตรวจทุกแห่งที่เรียกฟังก์ชันนี้ แล้วพบว่ามีจุดใดที่ส่งค่าเข้ามาแล้ว overflow แน่ๆ
❌ มันจะไม่อนุญาตให้ build ผ่าน
สิ่งที่เปลี่ยนไป
ตอนนี้ฟังก์ชันนี้ไม่ได้มีแค่ implementation
แต่มันมี “สัญญา” ครบทั้งสองด้าน
- input ต้องเป็นแบบไหน (
requires) - output ต้องเป็นแบบไหน (
ensures)
และสิ่งสำคัญคือ
❗ นี่ไม่ใช่ comment
แต่เป็นสิ่งที่ Creusot จะพยายามพิสูจน์ให้ได้จริง
ลองทำให้มันพังดู
#[requires(x < i32::MAX)]
#[ensures(result == x + 1)]
fn plus_one(x: i32) -> i32 {
x + 2 // <- เขียนผิด
}
โค้ดนี้ยัง compile ใน Rust ปกติ
แต่สำหรับ Creusot
มันจะพยายามพิสูจน์ว่า
result == x + 1
ซึ่ง “ไม่จริง”
❌ build ไม่ผ่านทันที
นี่แหละคือจุดที่มันเริ่มต่าง
คุณไม่ได้รอให้มี test มาเจอ bug นี้ แต่คุณ “กันมันตั้งแต่ระดับ logic” ตั้งแต่ตอนเขียนโค้ด
ยังไม่จบ คุณกำลังเข้าสู่ความ Advanced ของ Creusot อย่าเพิ่งท้อ 🌸🍑 อ่านต่ออีกนิด
ย้อนกลับไปทำความเข้าใจการทำงานของ Creusot สักนิด ก่อนที่ผมจะท้าทายด้วยโจทย์รูปแบบถัดไป
สิ่งที่ผมอยากให้รู้ไว้คือ
การติดตั้ง Creusot ค่อนข้างซับซ้อน
เพราะมันไม่ได้มีแค่ตัวมันเอง
แต่ต้องติดตั้ง
- Why3
- และ SMT Solvers เพิ่มด้วย
ซึ่งทั้งหมดนี้
คือ “เครื่องมือจริง” ที่อยู่เบื้องหลังการพิสูจน์
โครงสร้างการทำงานของ Creusot
กระบวนการทั้งหมด มันจะไหลเป็นทอด ๆ แบบนี้
- เขียนโค้ด Rust พร้อมใส่ Logic ลงใน Attribute พิเศษ
Creusotจะอ่านโค้ดนั้น แล้วแปลงเป็นภาษา WhyMLWhy3(เครื่องมือพิสูจน์) จะรับช่วงต่อ- SMT Solvers (เช่น Z3, CVC4) จะพยายาม “หาทางหักล้าง” ตรรกะที่คุณเขียน
ถ้ามัน “หักล้างไม่ได้เลย”
✔️ แปลว่าโค้ดคุณ ถูกต้องตามตรรกะ
ทำไม plus_one ถึงพิสูจน์ได้?
เพราะมันมี “ความสัมพันธ์ที่ชัดเจน”
#[requires(x < i32::MAX)]
#[ensures(result == x + 1)]
fn plus_one(x: i32) -> i32 {
x + 1
}
สิ่งที่ solver เห็นคือ
- มี input x
- มีเงื่อนไข x < i32::MAX
- มี output = x + 1
ทั้งหมดนี้เชื่อมกันเป็น “สมการ”
มันเลยพิสูจน์ได้
และนี่คือจุดสำคัญมาก
มันไม่ได้ทำงานแบบ Unit Test
มันไม่ได้ “ลองรันโค้ด”
แต่มันกำลัง
วิเคราะห์ความสัมพันธ์ของตรรกะ
เลยทำให้
- Unit Test = เช็คด้วยตัวอย่างบางกรณี
- Creusot = พิสูจน์ทุกกรณี (ถ้าทำได้)
สองอย่างนี้ ไม่ได้แทนกัน แต่ “เสริมกัน”
แล้วคำถามสำคัญ
ถ้ามันเก่งเพราะ “ความสัมพันธ์แบบสมการ”
งั้นกรณีที่มันไม่ใช่สมการล่ะ?
เช่น
- ไม่มีสูตร
- ไม่มี x + y
- ไม่มีความต่อเนื่อง
แต่เป็น logic แบบ “แยกเคส”
ตัวอย่างนะ
เมื่อโจทย์ไม่ใช่แค่สมการ แต่เป็น “เงื่อนไขทางธุรกิจ” แบบแยกเคส
จากตัวอย่าง plus_one เราเห็นการใช้สูตรคณิตศาสตร์ง่าย ๆ อย่าง result == x + 1 แต่ในโลกความเป็นจริง งานของเรามักจะเจอกับ Business Logic ที่ไม่ได้เป็นเส้นตรงแบบนั้น
ลองดูโจทย์นี้ครับ:
สมมติว่าเรามีฟังก์ชันที่คำนวณราคาสินค้า แบบหักส่วนลด ตามระดับสมาชิก
โจทย์: ฟังก์ชันคำนวณราคาสินค้าตามระดับสมาชิก
- Bronze → ลด 5%
- Silver → ลด 12%
- Gold → ลด 20%
ข้อสังเกต: มันไม่มีสูตร x + y ที่ครอบคลุมทุกอย่าง แต่มันคือการ “จับคู่” (Mapping) ระหว่างสถานะกับตัวเลข ซึ่งความสัมพันธ์พวกนี้ไม่มีความต่อเนื่องเชิงคณิตศาสตร์เลย เป็นการกำหนดแบบแยกเคส (Discrete) โดยสิ้นเชิง
วิธีการ “ประกาศกติกา” ใน Creusot
แทนที่เราจะเขียนแค่โค้ด แล้วไปลุ้นเอาใน Unit Test ว่าเราพิมพ์ตัวเลขผิดไหม เราจะใช้ match เข้ามาช่วยร่าง “สัญญา” ใน ensures แบบนี้ครับ
#[requires(price >= 0 && price < 1_000_000)] // กันราคาติดลบและกัน overflow เบื้องต้น
#[ensures(match level {
MemberLevel::Bronze => result == price * 95 / 100,
MemberLevel::Silver => result == price * 88 / 100,
MemberLevel::Gold => result == price * 80 / 100,
})]
fn apply_discount(price: i32, level: MemberLevel) -> i32 {
match level {
MemberLevel::Bronze => price * 95 / 100,
MemberLevel::Silver => price * 88 / 100,
MemberLevel::Gold => price * 80 / 100,
}
}
นี่คือการ “ล็อคสเปค” ด้วยตรรกะ
การใช้ match ใน ensures คือการบังคับให้ “แผนผังความคิด” ของเรากับ “สิ่งที่คอมพิวเตอร์รัน” ต้องตรงกัน 100%
- ถ้าคุณเขียนโค้ดผิด -> Build ไม่ผ่าน
- ถ้าคุณเขียนสัญญาผิด -> Build ไม่ผ่าน
- ถ้ามีเคสที่หลุดรอดสายตา (Edge Case) -> Build ไม่ผ่าน
มันไม่ใช่แค่การเช็คว่า “รันได้ไหม” แต่มันคือการเช็คว่า “คุณทำตามที่พูดไว้หรือเปล่า”
อ่านมาตอนนี้ สรุปว่าเรารู้จักพวกนี้แล้ว
requires: คือการคัดกรองแขกที่จะเข้างาน (Input ต้องไม่เละ)ensures: คือการเซ็นสัญญาว่าจบงานแล้วลูกค้าต้องได้อะไร (Output ต้องเป๊ะ)match: คือการกางเงื่อนไขว่า “ถ้ามาแบบนี้ ต้องจบแบบนั้น”
ซึ่งทั้งหมดนี้ Creusot จะใช้พลังของ SMT Solvers ในการ “กางตรรกะ” ออกมาดูทุกซอกทุกมุม เพื่อยืนยันว่าไม่มีทางเลยที่ผลลัพธ์จะหลุดไปจากกติกานี้
ก่อนจบ ขอตัวอย่างสุดท้าย 👋
เมื่อเงื่อนไขมันยุบยับ เราสามารถมี requires และ ensures ได้หลายตัว เพื่อแยกความรับผิดชอบของแต่ละเงื่อนไขออกจากกันได้นะ
สมมติว่าเราอัปเกรดฟังก์ชันคำนวณส่วนลดเมื่อกี้ ให้มีความเป็น “มืออาชีพ” ขึ้น โดยเพิ่มกฎเหล็กเข้าไปอีก:
- ต้องมีราคาสินค้า: ราคาห้ามติดลบ และห้ามเกินงบประมาณระบบ (เช่น 1 ล้านบาท)
- ต้องระบุระดับสมาชิก: ห้ามส่งค่าว่างมา
- ผลลัพธ์ต้องสมเหตุสมผล: ราคาหลังลด ต้อง “น้อยกว่าหรือเท่ากับ” ราคาเดิมเสมอ (ป้องกันบั๊กที่ลดแล้วราคาดันเพิ่ม!)
- ต้องตรงตาม Tier: (อันนี้คือ Logic เดิมของเรา)
ลองดูหน้าตาของ “สัญญาหลายบรรทัด” กันครับ
// --- ด่านตรวจขาเข้า (Input Validation) ---
#[requires(price >= 0)] // 1. ราคาห้ามติดลบ
#[requires(price < 1_000_000)] // 2. ราคาต้องไม่เว่อร์เกินระบบรับไหว
#[requires(level != MemberLevel::None)] // 3. ต้องเป็นสมาชิกเท่านั้น
// --- สัญญาขาออก (Output Guarantees) ---
#[ensures(result <= price)] // 4. จบงานแล้ว ราคาต้องไม่แพงกว่าเดิมเด็ดขาด!
#[ensures(result >= 0)] // 5. จบงานแล้ว ห้ามติดลบ (ไม่มีการแจกเงินคืน)
#[ensures(match level { // 6. และต้องตรงตามเงื่อนไขเป๊ะๆ
MemberLevel::Bronze => result == price * 95 / 100,
MemberLevel::Silver => result == price * 88 / 100,
MemberLevel::Gold => result == price * 80 / 100,
_ => false, // เคสอื่นๆ ที่หลุดมาถือว่าผิดสัญญา
})]
fn apply_professional_discount(price: i32, level: MemberLevel) -> i32 {
// ... logic ของคุณ ...
}
ทำไมต้องแยกเขียนหลายบรรทัด? เขียนรวมกันไม่ได้เหรอ?
จริง ๆ เราสามารถใช้เครื่องหมาย && (and) เชื่อมยาว ๆ เป็นบรรทัดเดียวได้ครับ แต่การแยกบรรทัดมีข้อดีที่ “โคตรสำคัญ” อยู่ 2 อย่าง:
1. อ่านง่าย (Readability)
ลองนึกภาพคุณกลับมาอ่านโค้ดตัวเองในอีก 3 เดือนข้างหน้า การเห็นเงื่อนไขแยกเป็นข้อ ๆ เหมือน Check-list มันเข้าใจง่ายกว่าการนั่งแกะวงเล็บในสมการยาวเหยียดแน่นอน
2. บอกจุดตายได้แม่นยำ (Error Isolation)
เวลาที่ Creusot พิสูจน์แล้วพบว่าโค้ดคุณ “สอบตก” มันจะบอกได้เลยว่า ตกที่บรรทัดไหน
- ถ้ามันบอกว่าตกตรง
#[ensures(result <= price)]คุณจะรู้ทันทีว่า “เฮ้ย! โค้ดเราเขียนอีท่าไหนให้ราคามันเพิ่มขึ้นวะ?” - มันดีกว่าการที่มันบอกแค่ว่า “เงื่อนไขรวม ๆ ผิดนะ ไปหาเอาเอง”