การจัดการข้อผิดพลาด
ข้อผิดพลาดสองประเภท
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดสองประเภท”Rust แบ่งข้อผิดพลาดออกเป็นสองประเภทที่แตกต่างกันโดยพื้นฐาน และจัดการแต่ละประเภทต่างกัน
ข้อผิดพลาดที่ไม่สามารถกู้คืนได้ — บั๊กในโปรแกรมของคุณที่คุณไม่ได้คาดการณ์ไว้และไม่สามารถดำเนินการต่อได้อย่างสมเหตุสมผล เช่น ดัชนีอาเรย์ที่เกินขอบเขต การยืนยันที่ล้มเหลว หรือค่าคงที่ทางตรรกะที่ไม่ควรถูกละเมิด Rust จัดการกับสิ่งเหล่านี้ด้วย panic! ซึ่งคลายสแต็กและยุติเธรด
ข้อผิดพลาดที่กู้คืนได้ — สถานการณ์ที่ความล้มเหลวเป็นสิ่งที่คาดเดาได้และผู้เรียกควรสามารถตอบสนองได้ เช่น ไฟล์อาจไม่มีอยู่ อินพุตของผู้ใช้อาจไม่สามารถแยกวิเคราะห์ได้ หรือคำขอเครือข่ายอาจหมดเวลา Rust จำลองสิ่งเหล่านี้ด้วย enum Result<T, E> ซึ่งบังคับให้ผู้เรียกทุกคนจัดการหรือส่งต่อข้อผิดพลาดอย่างชัดเจน
ความแตกต่างนี้ถูกบังคับใช้โดยระบบประเภท หากฟังก์ชันล้มเหลวได้แบบที่กู้คืนได้ ประเภทที่คืนค่าของฟังก์ชันนั้นคือ Result<T, E> แต่ถ้าฟังก์ชัน panic ได้ signature จะไม่บอกอะไรคุณเลย — panic เกิดขึ้นได้ทุกที่ แต่ควรสงวนไว้สำหรับบั๊กการเขียนโปรแกรมที่แท้จริง
เนื้อหาของโมดูลนี้
หัวข้อที่มีชื่อว่า “เนื้อหาของโมดูลนี้”| บทเรียน | หัวข้อ |
|---|---|
| Panics | panic!, .unwrap(), .expect(), เมื่อไหร่ที่การ panic เหมาะสม |
| Result | Result<T, E>, Ok/Err, การ matching, .unwrap_or, .unwrap_or_else |
| ตัวดำเนินการ ? | การส่งต่อแบบ early-return, การเชื่อมโยง, main ที่คืน Result |
| ข้อผิดพลาดแบบกำหนดเอง | enum ข้อผิดพลาด, trait Display + Error, การแปลง From |
ลองดูตัวอย่างแรก
หัวข้อที่มีชื่อว่า “ลองดูตัวอย่างแรก”ตัวอย่างด้านล่างแสดงข้อผิดพลาดทั้งสองประเภทเคียงข้างกัน: panic! ที่คุณจะเรียกเฉพาะเมื่ออินพุตเป็นข้อผิดพลาดทางตรรกะ และฟังก์ชันที่คืนค่า Result ซึ่งผู้เรียกต้องจัดการ
fn divide(a: i32, b: i32) -> Result<i32, String> { if b == 0 { Err(String::from("division by zero")) } else { Ok(a / b) }}
fn main() { // Recoverable error: use Result match divide(10, 2) { Ok(result) => println!("10 / 2 = {}", result), Err(e) => println!("Error: {}", e), } match divide(5, 0) { Ok(result) => println!("5 / 0 = {}", result), Err(e) => println!("Error: {}", e), } println!("Program continues normally after handling errors.");}Compiling…
Result กับ Panic: ตารางตัดสินใจ
หัวข้อที่มีชื่อว่า “Result กับ Panic: ตารางตัดสินใจ”ส่วนที่ยากที่สุดของการเขียนการจัดการข้อผิดพลาดแบบ Rust ที่ถูกต้องไม่ใช่ syntax แต่คือการตัดสินใจสำหรับความล้มเหลวแต่ละแบบว่ามันคือบั๊ก (panic) หรือผลลัพธ์ที่คาดไว้ (Result) ใช้ตารางนี้ทุกครั้งที่ไม่แน่ใจ:
| คำถาม | ถ้าใช่ | ถ้าไม่ใช่ |
|---|---|---|
| ความล้มเหลวมาจากภายนอกโปรแกรมได้หรือไม่ (ไฟล์ เครือข่าย อินพุตผู้ใช้ กระบวนการอื่น)? | Result | ถามต่อ |
| การดำเนินการต่อหลังจากความล้มเหลวจะทำให้ข้อมูลเสียหายหรือละเมิดค่าคงที่ที่โค้ดรับประกันไว้หรือไม่? | panic! | ถามต่อ |
นี่คือการตรวจสอบ debug_assert!/precondition ที่ไม่ควรเกิดขึ้นเลยในโค้ดที่ถูกต้องหรือไม่? | panic! | ถามต่อ |
| นี่คือโค้ดไลบรารีที่ผู้เรียก ไม่ใช่คุณ ควรเป็นคนตัดสินใจว่า “จัดการ” หมายถึงอะไรหรือไม่? | Result | Result (ค่าเริ่มต้น) |
สงวน panic! ไว้สำหรับกรณีแคบๆ ที่การดำเนินการต่อไม่ปลอดภัยหรือไม่มีความหมาย: ดัชนีที่คุณคำนวณเองแล้วเกินขอบเขต .unwrap() บนค่าที่คุณเพิ่งสร้างเป็น Some หรือค่าคงที่ภายในที่ถูกละเมิด ทุกอย่างที่อาจล้มเหลวได้เพราะโลกภายนอก — ไฟล์ที่อาจไม่มีอยู่ สตริงที่อาจแยกวิเคราะห์ไม่ได้ ซ็อกเก็ตที่อาจหมดเวลา — คือ Result
flowchart TD
A[Failure can happen] --> B{Caused by external input,
environment, or another party?}
B -->|Yes| R[Return Result]
B -->|No| C{Continuing would corrupt
state or violate an invariant?}
C -->|Yes| P[panic!]
C -->|No| D{Are you writing
library code?}
D -->|Yes| R
D -->|No| R2[Result is still the safe default] คอมไพเลอร์พูดว่าอะไร: Result ที่ถูกละเลย
หัวข้อที่มีชื่อว่า “คอมไพเลอร์พูดว่าอะไร: Result ที่ถูกละเลย”Result ถูกทำเครื่องหมาย #[must_use] ในไลบรารีมาตรฐาน ดังนั้นการเรียกฟังก์ชันที่คืนค่า Result แล้วละเลยค่านั้นจะทำให้เกิดคำเตือนโดยค่าเริ่มต้น harness ของ snippet ในคอร์สนี้คอมไพล์ด้วย -A warnings เพื่อไม่ให้ตัวอย่างที่ตั้งใจพังทำให้ build ล้มเหลว แต่ #![deny(unused_must_use)] ที่ชัดเจนที่ต้นไฟล์จะเขียนทับ allow แบบครอบคลุมนั้น เหมือนกับ Cargo project ที่เข้มงวดกว่า (RUSTFLAGS="-D warnings") ที่จะทำ transcript ด้านล่างมาจากการรัน rustc --edition 2024 จริงของไฟล์นี้ โดยไม่มี -A warnings เพื่อให้คุณเห็นว่า cargo build ของคุณเองจะแสดงอะไร:
// @skip-verify demonstrates a lint-denied error with no E-code; harness -A warnings// would silence the plain warning form, so the real transcript is quoted below instead#![deny(unused_must_use)]
fn might_fail() -> Result<i32, String> { Err(String::from("nope"))}
fn main() { might_fail(); println!("done");}error: unused `Result` that must be used --> deny_must_use.rs:8:5 |8 | might_fail(); | ^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handlednote: the lint level is defined here --> deny_must_use.rs:1:9 |1 | #![deny(unused_must_use)] | ^^^^^^^^^^^^^^^help: use `let _ = ...` to ignore the resulting value |8 | let _ = might_fail(); | +++++++
error: aborting due to 1 previous errorถ้าไม่มี #![deny(...)] การรัน cargo build ปกติจะยังแสดงเป็น คำเตือน (ข้อความเดียวกัน แต่เป็น warning: แทน error: และ exit code 0) — พลาดดูง่ายมากใน build log ที่มีข้อความเยอะ บท question-mark และ result จะกลับมาพูดถึง #[must_use] แบบละเอียดกว่านี้
แบบจำลองความคิด
หัวข้อที่มีชื่อว่า “แบบจำลองความคิด”คิดว่าประเภทที่คืนค่าของฟังก์ชัน Rust คือสัญญากับผู้เรียก -> T บอกว่า “ฉันจะให้ T เสมอ” -> Result<T, E> บอกว่า “ฉันอาจล้มเหลวในแบบที่คุณควรวางแผนไว้ — นี่คือรูปแบบความล้มเหลวที่แน่นอน และคุณต้องรับทราบมัน” panic! ทำลายสัญญานั้นแทนที่จะขยายมัน: มันบอกว่า “ตัวสัญญาเองถูกละเมิดโดยบั๊ก และไม่มีค่าคืนใดจะแก้ไขมันได้ ดังนั้นฉันจะคลายสแต็กผ่านคุณไปทั้งหมด” การอ่าน signature ของฟังก์ชันควรบอกคุณได้ว่ากำลังเจอแบบไหนก่อนที่จะอ่านโค้ดในตัวฟังก์ชันสักบรรทัดเดียว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”panic! ไม่ประนีประนอมแต่ตรงไปตรงมา — ทันทีที่มีอะไรผิดพลาด โปรแกรมจะหยุดแทนที่จะดำเนินต่อบนสมมติฐานที่ผิด และคุณจะได้ตำแหน่งสแต็กฟรีๆ ต้นทุนของมันคือความพร้อมใช้งาน: อินพุตที่ไม่ดีหนึ่งตัวจากแหล่งที่ไม่น่าเชื่อถือสามารถทำให้ทั้งโปรเซสล้มได้ (หรือในเซิร์ฟเวอร์ ทำให้ request handler หนึ่งตัวล้มถ้าคุณ catch มัน ซึ่งไม่ค่อยคุ้มที่จะทำ) Result ประกอบกันได้และให้ผู้เรียกตัดสินใจเชิงนโยบาย — retry, fallback, log แล้วดำเนินต่อ — แต่มันผลักต้นทุนจริงไปให้ผู้เรียกทุกคน: พวกเขาต้องเขียน match, ?, หรือ .unwrap()/.expect() ที่ชัดเจนซึ่งเปลี่ยน Result กลับเป็น panic อยู่ดี การเลือก Result สำหรับทุกอย่าง “เผื่อไว้” ทำให้ signature รกและผลักการตัดสินใจไปให้ผู้เรียกที่ไม่มีข้อมูลมากไปกว่าคุณ
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Panic เหมือนกับการ throw exception ดังนั้นฉันควรใช้
Resultกับทุกอย่างเพื่อความปลอดภัย” การใช้Resultมากเกินไปกับสิ่งที่ไม่มีทางล้มเหลวจริงๆ (การแยกวิเคราะห์ literal ที่ hardcode ไว้ การ index อาเรย์ขนาดคงที่ที่คุณเพิ่งสร้าง) แค่ย้ายการตัดสินใจปลอมไปให้ผู้เรียกทุกคน ถ้าความล้มเหลวเป็นไปไม่ได้จริงๆ ให้unwrap()แล้วบอกเหตุผลไว้ในคอมเมนต์ - “
Resultที่เป็นOkเกือบตลอดเวลาแทบไม่มีต้นทุนอะไร”Result<T, E>ยังมีต้นทุนของการแยกเงื่อนไข (branch) และขึ้นอยู่กับขนาดของEอาจมีพื้นที่สแต็กเพิ่ม — ปกติเล็กน้อยมาก แต่ไม่ได้ “ฟรี” แบบที่บางครั้งคิดกันเรื่อง exception ที่ไม่เคย throw - “Rust ไม่มี exception ดังนั้น panic จะไม่เกิดขึ้นใน production” มันเกิดขึ้นได้ และเกิดขึ้นจริง — ดัชนีที่เกินขอบเขต การ overflow ของจำนวนเต็มใน debug build หรือ
unwrap()ของ crate บุคคลที่สามเอง ล้วน panic ในเซอร์วิสที่กำลังรันจริงเหมือนกับที่มันทำในเทสต์
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”AI มักจะเลือกใช้ panic! (หรือ .expect() ที่มีข้อความทั่วไป) ทันทีที่ความล้มเหลวมาจากภายนอกโปรแกรม — การอ่านไฟล์คอนฟิก การแยกวิเคราะห์ request body การเปิดซ็อกเก็ต — เพราะมันเป็นโค้ดที่พิมพ์สั้นที่สุด ไม่ใช่เพราะมันเป็นทางเลือกที่ถูกต้องสำหรับผู้เรียก
🐛 หา bug ในโค้ดนี้
fn load_config(path: &str) -> Config { let text = std::fs::read_to_string(path) .unwrap_or_else(|_| panic!("config file missing: {}", path)); parse_config(&text)}เฉลย
ไฟล์คอนฟิกที่หายไปเป็นความล้มเหลวประเภทที่ตารางตัดสินใจข้างบนส่งไปที่ Result ไม่ใช่ panic! — มันมาจากสภาพแวดล้อม (ดิสก์ของผู้ใช้ การ deploy ที่ลืม mount volume) และผู้เรียกอาจต้องการ fallback ไปใช้ค่าเริ่มต้นหรือพิมพ์ข้อความข้อผิดพลาดที่สะอาดแทนที่จะทำให้ทั้งโปรเซสล้ม การแก้ไขคือ fn load_config(path: &str) -> Result<Config, ConfigError> พร้อม ? หรือ match ที่ชัดเจนตรงจุดที่เรียกใช้ สงวน panic! ไว้สำหรับค่าคงที่ที่เป็นไปไม่ได้จริงๆ ภายใน parse_config เอง (เช่นค่าเริ่มต้นที่ hardcode ไว้ซึ่งคุณสร้างเองและรู้ว่าถูกต้อง)