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

การจัดการข้อผิดพลาด

Rust แบ่งข้อผิดพลาดออกเป็นสองประเภทที่แตกต่างกันโดยพื้นฐาน และจัดการแต่ละประเภทต่างกัน

ข้อผิดพลาดที่ไม่สามารถกู้คืนได้ — บั๊กในโปรแกรมของคุณที่คุณไม่ได้คาดการณ์ไว้และไม่สามารถดำเนินการต่อได้อย่างสมเหตุสมผล เช่น ดัชนีอาเรย์ที่เกินขอบเขต การยืนยันที่ล้มเหลว หรือค่าคงที่ทางตรรกะที่ไม่ควรถูกละเมิด Rust จัดการกับสิ่งเหล่านี้ด้วย panic! ซึ่งคลายสแต็กและยุติเธรด

ข้อผิดพลาดที่กู้คืนได้ — สถานการณ์ที่ความล้มเหลวเป็นสิ่งที่คาดเดาได้และผู้เรียกควรสามารถตอบสนองได้ เช่น ไฟล์อาจไม่มีอยู่ อินพุตของผู้ใช้อาจไม่สามารถแยกวิเคราะห์ได้ หรือคำขอเครือข่ายอาจหมดเวลา Rust จำลองสิ่งเหล่านี้ด้วย enum Result<T, E> ซึ่งบังคับให้ผู้เรียกทุกคนจัดการหรือส่งต่อข้อผิดพลาดอย่างชัดเจน

ความแตกต่างนี้ถูกบังคับใช้โดยระบบประเภท หากฟังก์ชันล้มเหลวได้แบบที่กู้คืนได้ ประเภทที่คืนค่าของฟังก์ชันนั้นคือ Result<T, E> แต่ถ้าฟังก์ชัน panic ได้ signature จะไม่บอกอะไรคุณเลย — panic เกิดขึ้นได้ทุกที่ แต่ควรสงวนไว้สำหรับบั๊กการเขียนโปรแกรมที่แท้จริง

บทเรียนหัวข้อ
Panicspanic!, .unwrap(), .expect(), เมื่อไหร่ที่การ panic เหมาะสม
ResultResult<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.");
}

ส่วนที่ยากที่สุดของการเขียนการจัดการข้อผิดพลาดแบบ Rust ที่ถูกต้องไม่ใช่ syntax แต่คือการตัดสินใจสำหรับความล้มเหลวแต่ละแบบว่ามันคือบั๊ก (panic) หรือผลลัพธ์ที่คาดไว้ (Result) ใช้ตารางนี้ทุกครั้งที่ไม่แน่ใจ:

คำถามถ้าใช่ถ้าไม่ใช่
ความล้มเหลวมาจากภายนอกโปรแกรมได้หรือไม่ (ไฟล์ เครือข่าย อินพุตผู้ใช้ กระบวนการอื่น)?Resultถามต่อ
การดำเนินการต่อหลังจากความล้มเหลวจะทำให้ข้อมูลเสียหายหรือละเมิดค่าคงที่ที่โค้ดรับประกันไว้หรือไม่?panic!ถามต่อ
นี่คือการตรวจสอบ debug_assert!/precondition ที่ไม่ควรเกิดขึ้นเลยในโค้ดที่ถูกต้องหรือไม่?panic!ถามต่อ
นี่คือโค้ดไลบรารีที่ผู้เรียก ไม่ใช่คุณ ควรเป็นคนตัดสินใจว่า “จัดการ” หมายถึงอะไรหรือไม่?ResultResult (ค่าเริ่มต้น)

สงวน 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]
การตัดสินใจระหว่าง panic! กับ 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 handled
note: 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 มักจะเลือกใช้ 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 ไว้ซึ่งคุณสร้างเองและรู้ว่าถูกต้อง)

โครงสร้างใดของ Rust ที่จำลองข้อผิดพลาดที่กู้คืนได้?
panic! ทำอะไรใน Rust?
ตามตารางตัดสินใจ ไฟล์คอนฟิกที่อาจไม่มีอยู่บนดิสก์ควรจัดการด้วย:
โค้ดนี้ print อะไร?
fn might_fail() -> Result<i32, String> {
    Err(String::from("nope"))
}
fn main() {
    might_fail();
    println!("done");
}
เหตุใดคอมไพเลอร์ Rust จึงบังคับให้คุณจัดการ Result?
เหตุใดการคืนค่า Result<T, E> สำหรับฟังก์ชันที่ไม่มีทางล้มเหลวจริงๆ (เช่น การแยกวิเคราะห์ literal ตัวเลขที่ hardcode ไว้) จึงเป็นค่าเริ่มต้นที่ไม่ดี?