การจัดการ error — Result, ตัวดำเนินการ `?`, panic กับ thiserror
ในโลก C#/.NET ความผิดพลาดมีทางเดินเดียว: โยน Exception ขึ้นไปแล้วหวังว่าจะมีใคร catch ทัน — file อ่านไม่ได้, แปลงตัวเลขพลาด, หรือ NullReferenceException ที่ไม่มีใครประกาศไว้ในลายเซ็น ล้วนไหลผ่านช่องทางเดียวกันหมด และ compiler ไม่เคยบังคับให้คุณจัดการมันเลย Rust เดินคนละทาง: ความผิดพลาดที่ กู้ได้ ไม่ใช่การกระโดดออกจากกระแสโปรแกรม แต่เป็น ค่าธรรมดา ที่ function คืนกลับมา — ชนิด ResultResult`Ok(T)` หรือ `Err(E)` — error เป็นค่าปกติในลายเซ็น ไม่ใช่ exception — และ compiler จะไม่ยอมให้คุณเมินมันเงียบๆ
นี่คือหนึ่งในความต่างที่วิศวกร C# รู้สึกได้ชัดที่สุด: error กลายเป็นส่วนหนึ่งของ type signature ไม่ใช่ผลข้างเคียงที่ซ่อนอยู่ บทนี้เราจะเดินจาก Result<T,E> เปล่าๆ ไปจนถึง error type จริงของ taskcli ที่สร้างด้วย thiserrorthiserrorderive macro สร้าง `Error`+`Display` ให้ error enum ของ library (crate v2.x) และปิดท้ายด้วยเส้นแบ่ง thiserror (สำหรับ library) กับ anyhow (สำหรับ application)
code ลงมือของคอร์สนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — บทนี้เราออกแบบ error type ของ taskcli: enum ชื่อ ConfigError ที่รวมความผิดพลาดจากการอ่าน file และการ parse ค่าเข้าไว้ด้วยกันแบบ matchable code ? และ Box<dyn Error> ในบทนี้ compile ผ่านจริงบน rustc 1.97.1 (edition 2024) ส่วนสองสนิปเป็ต thiserror/anyhow เป็น derive-macro ที่เราคัดมาจาก version ที่ verify แล้ว — versions ที่ pin (thiserror 2.0.19, anyhow 1.0.104, as-of 2026-07-18) resolve ได้จริง
สองประเภทของความผิดพลาด: Result กับ panic!
หัวข้อที่มีชื่อว่า “สองประเภทของความผิดพลาด: Result กับ panic!”หนังสือ The Rust Programming Language (ch9) แบ่งความผิดพลาดออกเป็นสองกลุ่มใหญ่ชัดเจน และนี่คือแกนของทั้งบท:
- กู้ได้ (recoverable) — สิ่งที่ คาดว่าจะเกิดได้ ในการทำงานปกติ: file ไม่มีอยู่, input ผิดรูปแบบ, port ที่ผู้ใช้พิมพ์ไม่ใช่ตัวเลข Rust แทนด้วย
Result<T, E>=Ok(T)หรือErr(E)— เป็น ค่า ที่คืนกลับมาให้คุณตัดสินใจ - กู้ไม่ได้ (unrecoverable) — bug หรือ invariant ที่พังจนไม่ควรทำงานต่อ: index เกินขอบ array, logic ที่ “เป็นไปไม่ได้” ดันเกิด ตรงนี้ใช้ panic!panic!error ที่กู้ไม่ได้ (bug/invariant พัง) ทำให้ thread unwind; ไม่ใช่ทางไหลของ error ปกติ ซึ่งทำให้ thread unwind (คลาย stack แล้วเรียก drop ให้ทุกค่า) หรือ abort ทันที
ความต่างที่ต้องฝังหัว: panic! ไม่ใช่ ช่องทางไหลของ error ปกติ มันคือสัญญาณว่า “โปรแกรมนี้มี bug” ไม่ใช่ “ผู้ใช้พิมพ์ผิด” ใน C# คุณใช้ throw/catch กับทั้งสองกรณีปนกัน — Rust จงใจแยกออกจากกัน
และนี่คือจุดที่ compiler ทำหน้าที่ครู: Result ถูกทำเครื่องหมาย #[must_use] แปลว่าถ้าคุณเรียก function ที่คืน Result แล้ว ทิ้งค่าเฉยๆ compiler จะ warn ทันที — คุณ ทิ้ง error เงียบๆ ไม่ได้ ต่างจาก C# ที่ลืม try/catch แล้วโปรแกรมพังตอน runtime โดยไม่มีใครเตือนตอน compile
| C# / .NET | Rust | …ยกเว้น (สะพาน ไม่ใช่ของเดียวกัน) |
|---|---|---|
Exception ช่องทางเดียวสำหรับทุกความผิดพลาด | Result<T,E> (กู้ได้) แยกจาก panic! (bug) | error ที่กู้ได้เป็น ค่า ในลายเซ็น ไม่ใช่การกระโดดที่ซ่อนอยู่ — ไม่มี checked/unchecked เพราะ E อยู่ในชนิดเสมอ |
ลืม try/catch → พังตอน runtime เงียบๆ | Result เป็น #[must_use] → ทิ้งเฉยๆ ได้ warning | compiler รักษา ให้คุณต้องรับรู้ error ทุกตัวตั้งแต่ตอน compile |
throw แพงเพราะเก็บ stack trace เสมอ | คืน Err(e) ราคาเท่ากับ return ค่าธรรมดา | ไม่มี stack unwinding ในทางไหลปกติ — error เป็นแค่ค่าที่ส่งกลับ |
unwrap และ expect — panic ที่คุณสั่งเอง
หัวข้อที่มีชื่อว่า “unwrap และ expect — panic ที่คุณสั่งเอง”Result มีทางลัดสองตัวที่แกะค่า Ok ออกมาให้ แต่ panic ถ้าเป็น Err:
.unwrap()— คืนค่าในOkหรือ panic พร้อมข้อความปริยาย.expect("…")— เหมือนกัน แต่คุณเขียน ข้อความที่อธิบายสมมติฐาน เอง
fn parse_priority(s: &str) -> Result<u8, std::num::ParseIntError> { let n: u8 = s.trim().parse()?; Ok(n)}
fn main() { // unwrap/expect: panic ถ้าเป็น Err — ยอมรับได้เมื่อรู้แน่ว่าพลาดไม่ได้ let level: u8 = "3".parse().expect("literal นี้เป็นตัวเลขแน่นอน"); println!("level = {level}");
// ทางปกติ: จับ Result ด้วย match แล้วตัดสินใจเอง match parse_priority("not-a-number") { Ok(n) => println!("priority = {n}"), Err(e) => println!("อ่าน priority ไม่ได้: {e}"), }}unwrap/expect ยอมรับได้ ในสามที่: code test, prototype ที่รีบๆ และจุดที่คุณ พิสูจน์ได้ว่าเป็น Err ไม่ได้จริงๆ (เช่น parse literal "3" ข้างบน) .expect() ดีกว่า .unwrap() เสมอเพราะข้อความบอกคนอ่าน (และตัวคุณเองในอนาคต) ว่าคุณ อ้าง อะไรไว้ การโปรย unwrap ทั่ว code production เทียบได้กับการ catch แล้วกลืน exception เงียบๆ ใน C# — คุณเปลี่ยน error ที่จัดการได้ให้กลายเป็น crash
ตัวดำเนินการ ? — ยุบ boilerplate การส่งต่อ error
หัวข้อที่มีชื่อว่า “ตัวดำเนินการ ? — ยุบ boilerplate การส่งต่อ error”ทีนี้ลองส่งต่อ error ขึ้นไปให้ผู้เรียกจัดการ ❌ version ดิบ ที่เขียน match ด้วยมือทุกครั้งจะบวมเร็วมาก:
fn read_username_raw(path: &str) -> Result<String, std::io::Error> { let name = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) => return Err(e), // ส่งต่อ error เอง }; Ok(name.trim().to_string())}? operator? operatorตัวช่วยส่งต่อ error: คืนค่า Ok/Some หรือ early-return Err/None พร้อมแปลง error ด้วย `From` ยุบ pattern ทั้งก้อนนั้นให้เหลืออักขระเดียว — วางท้าย expression ที่คืน Result: ถ้าเป็น Ok(v) มัน แกะ v ออกมาแล้วทำงานต่อ; ถ้าเป็น Err(e) มัน early-return ออกจาก function ทันทีพร้อมค่านั้น:
use std::error::Error;use std::fs;
fn read_username(path: &str) -> Result<String, std::io::Error> { let name = fs::read_to_string(path)?; Ok(name.trim().to_string())}fn main() -> Result<(), Box<dyn Error>> { let user = read_username("user.txt")?; println!("user = {user}"); Ok(())}สองอย่างที่ต้องรู้เกี่ยวกับ ?:
- มันทำงาน เฉพาะ ใน function ที่ return type เข้ากันได้ —
Result,Option, หรือชนิดที่ implementFromResidualใส่?ใน function ที่คืน()เฉยๆ ไม่ได้ - ตอน early-return มัน แปลงชนิด error ให้อัตโนมัติด้วย FromFromtrait แปลงชนิด; impl `From` แล้วได้ `Into` ฟรี และเป็นกลไกที่ `?` ใช้แปลง error — ถ้า
Eตัวจริงต่างจากEในลายเซ็น?จะเรียกFrom::fromแปลงให้ก่อนคืน ตอนนี้ให้จำแค่ ผลลัพธ์เชิงปฏิบัติ ว่า “?แปลง error ให้”; ในบท6 เราจะเปิดเผยว่ากลไกนี้ คือ traitFromที่คุณ impl เองได้
ที่ใกล้ที่สุดคือ exception ที่ bubble up เองโดยไม่ต้องเขียน catch — แต่ ? ต่างตรงที่มัน มองเห็นได้ใน code (อักขระ ? คาตาอยู่) และ บังคับ ให้ function ประกาศ error type ในลายเซ็น ไม่มี error ตัวไหน “ลอย” ขึ้นไปโดยที่ผู้อ่านลายเซ็นไม่รู้ตัว การส่งต่อ error จึงชัดเจนและ type-checked ทุกก้าว
main ที่คืน Result<(), Box<dyn Error>>
หัวข้อที่มีชื่อว่า “main ที่คืน Result<(), Box<dyn Error>>”สังเกตลายเซ็น fn main() -> Result<(), Box<dyn Error>> ข้างบน — นี่คือสำนวนมาตรฐานของ binary/app ที่อยากใช้ ? ใน main ได้เลย Box<dyn Error> คือ “error ชนิดไหนก็ได้ที่ implement trait Error” ห่อไว้หลัง pointer — มันรับ io::Error, ParseIntError, หรืออะไรก็ตามที่เป็น Error ได้หมด ทำให้ ? ส่งต่อ error ต่างชนิดใน function เดียวได้
เมื่อ main คืน Err(...) runtime จะพิมพ์ error ในรูปแบบ Debug (ไม่ใช่ Display) แล้ว exit ด้วย code 1 — สะดวกสำหรับ CLI
Box<dyn Error> ลบชนิด (type erasure) ทิ้งไป — ผู้เรียกจับได้แค่ว่า “มี error บางอย่าง” แต่ match แยกกรณีไม่ได้อีกต่อไป มันเทียบได้กับการ catch (Exception e) ที่ฐานสุดใน C#: เก็บข้อความไว้ แต่เสียความสามารถในการแยกประเภท จึงเหมาะกับ main/app ที่แค่อยากรายงานแล้วจบ แต่ไม่เหมาะกับลายเซ็นสาธารณะของ library ที่ผู้เรียกควร match กรณีต่างๆ ได้ — นั่นคือเหตุผลที่หัวข้อถัดไปเราสร้าง error enum เฉพาะเจาะจงด้วย thiserror แทน
thiserror 2.x — error enum ของ library ที่ matchable
หัวข้อที่มีชื่อว่า “thiserror 2.x — error enum ของ library ที่ matchable”สำหรับ library (code ที่คนอื่นเรียกใช้) เราอยากได้ error type ที่ผู้เรียก match แยกกรณีได้ครบ วิธีเขียน impl Display และ impl Error ด้วยมือนั้นน่าเบื่อและซ้ำ thiserror เป็น derive macro ที่สร้างให้ทั้งหมด ต่อไปนี้คือ error type จริงของ taskcli:
use std::num::ParseIntError;use thiserror::Error;
#[derive(Error, Debug)]pub enum ConfigError { #[error("could not read config file")] Read(#[from] std::io::Error),
#[error("port `{0}` is not a valid number")] BadPort(#[from] ParseIntError),
#[error("missing required key `{key}`")] MissingKey { key: String },}
pub fn load_port(path: &str) -> Result<u16, ConfigError> { let text = std::fs::read_to_string(path)?; // io::Error -> ConfigError::Read if text.trim().is_empty() { return Err(ConfigError::MissingKey { key: "port".into() }); } let port: u16 = text.trim().parse()?; // ParseIntError -> ConfigError::BadPort Ok(port)}อ่านทีละชิ้น:
#[derive(Error, Debug)]— สร้างimpl Errorให้ และ ต้อง deriveDebugคู่เสมอ (traitErrorบังคับ ไม่งั้น compile ไม่ผ่าน)#[error("…")]— กำหนดข้อความDisplayของแต่ละ variant ใช้{0}อ้าง field แบบ tuple และ{key}อ้าง field แบบ named ได้ตรงๆ#[from]— สร้างimpl From<ต้นทาง>ให้อัตโนมัติ และ ทำ field นั้นเป็นsource()ของ error นี่แหละคือสิ่งที่ทำให้?ในบรรทัดread_to_string(path)?แปลงio::Errorเป็นConfigError::Readให้เอง โดยที่คุณไม่ต้องเขียน conversion เลย
สอง #[from] (io::Error กับ ParseIntError) อยู่ร่วมกันได้ เพราะชนิดต้นทางต่างกัน — macro สร้าง impl From<io::Error> และ impl From<ParseIntError> แยกกันคนละตัว
ถ้า2 variant ใช้ #[from] กับชนิดต้นทาง เดียวกัน macro จะพยายามสร้าง impl From<T> ซ้ำสองครั้ง → compiler ปฏิเสธด้วย E0119 (conflicting implementations) เพราะ Rust ห้ามมี impl From<T> ซ้ำสำหรับชนิดเดียวกัน วิธีแก้คือให้ variant หนึ่งใช้ #[from] และอีก variant แปลงเอง หรือแยกชนิดต้นทาง
อีกกับดัก: อย่า pin thiserror = "1" — major ปัจจุบันคือ 2.x code ที่เห็น (#[derive(Error)], #[error], #[from]) หน้าตาเหมือนเดิมเป๊ะ ต่างแค่บรรทัด version (ของเก่าใน 1.x บางสำนวนถูกถอดออกใน 2.0 แล้ว)
anyhow สำหรับ application — และเส้นแบ่งที่ต่อกันได้
หัวข้อที่มีชื่อว่า “anyhow สำหรับ application — และเส้นแบ่งที่ต่อกันได้”พอมาถึงชั้น application (code main ของ taskcli เอง) คุณมักไม่อยากประกาศ error enum ละเอียดสำหรับทุกความผิดพลาด — คุณอยากแค่ ส่งต่อ พร้อมแนบบริบทว่า “ล้มเหลวตอนกำลังทำอะไร” แล้วรายงานให้ผู้ใช้ anyhow เกิดมาเพื่อสิ่งนี้:
use anyhow::{bail, Context, Result};use std::fs;
fn load_config(path: &str) -> Result<String> { // Result<T> == Result<T, anyhow::Error> let contents = fs::read_to_string(path) .with_context(|| format!("failed to read config from {path}"))?; if contents.trim().is_empty() { bail!("config file {path} is empty"); } Ok(contents)}anyhow::Result<T>เป็น alias ของResult<T, anyhow::Error>—anyhow::Errorห่อ error ชนิดไหนก็ได้ (คล้ายBox<dyn Error>แต่ทรงพลังกว่า).with_context(|| …)แนบข้อความอธิบายชั้นบนเข้ากับ error เดิม — เทียบได้กับthrow new Exception($"…: {inner}")ใน C# ที่ยังเก็บ inner ไว้bail!("…")= สร้าง error จากข้อความแล้ว return ทันที (คู่กับensure!ที่เช็กเงื่อนไขก่อน)
หลักการปิดท้ายบท — จำให้ขึ้นใจ:
thiserror สำหรับ library, anyhow สำหรับ application (anyhow README, หัวข้อ “Comparison to thiserror”)
เหตุผล: library อยากให้ผู้เรียก match แยกกรณีได้ → ต้องการ enum เฉพาะเจาะจง (thiserror); application แค่อยากส่งต่อ + รายงาน → อยากได้ความสะดวก (anyhow) และสองตัวนี้ ต่อกันได้อย่างลงตัว: error ที่สร้างด้วย thiserror implement trait Error อยู่แล้ว ดังนั้นเมื่อ load_config เรียก function ที่คืน ConfigError แล้วใส่ ? ต่อท้าย ? จะแปลง ConfigError เป็น anyhow::Error ให้เองผ่าน From ตัวเดิม — library ผลิต error ที่ละเอียด, application กลืนมันขึ้นชั้นบนได้ทันที
anyhow เก็บ backtrace ให้ ก็ต่อเมื่อ ตั้ง environment variable RUST_BACKTRACE เท่านั้น ไม่ใช่จับอัตโนมัติทุกครั้ง อย่าเข้าใจผิดว่าได้ backtrace ฟรีเสมอเหมือน exception ของ .NET
Cargo.toml ของบทนี้เพิ่ม2 crate — จำหลัก caret req จากบท1: pin major แล้วปล่อยให้ Cargo.lock ล็อก patch เอง:
[package]name = "mycli"version = "0.1.0"edition = "2024"
[dependencies]thiserror = "2"anyhow = "1"flowchart TB
CALL["fs::read_to_string(path)?"]
CALL --> Q{"ผลลัพธ์เป็น Ok หรือ Err?"}
Q -->|"Ok(v)"| CONT["แกะค่า v ออกมา<br/>ทำงานต่อบรรทัดถัดไป"]
Q -->|"Err(e)"| CONV["From::from(e)<br/>แปลง io::Error เป็น ConfigError"]
CONV --> RET["return Err(ConfigError)<br/>early-return ออกจาก function ทันที"]
classDef ok fill:#fde68a,stroke:#92400e,color:#451a03;
classDef err fill:#fed7aa,stroke:#7c2d12,color:#431407;
class CALL,CONT ok;
class CONV,RET err;
คำบรรยายภาพ: เส้นทางของการเรียกหนึ่งครั้งที่ลงท้ายด้วย ? — ถ้าได้ Ok(v) จะแกะค่าแล้วเดินต่อ; ถ้าได้ Err(e) ? จะเรียก From::from แปลงชนิด error (เช่น io::Error → ConfigError) แล้ว early-return ออกจาก function ทันที การส่งต่อพร้อมแปลงชนิดนี้ยุบ boilerplate การเขียน match-แล้ว-return ด้วยมือให้เหลืออักขระเดียว
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้วางระบบ error ของ Rust ที่ต่างจาก exception ของ C# ทั้งกระบวน: ความผิดพลาดที่กู้ได้เป็น ค่า Result<T,E> ที่ compiler บังคับให้รับรู้ (#[must_use]) ส่วน panic! สงวนไว้ให้ bug ที่กู้ไม่ได้เท่านั้น; unwrap/expect แกะค่าแบบ panic-ถ้าพลาด ใช้ได้เฉพาะใน test/prototype/จุดที่พิสูจน์ว่าพลาดไม่ได้; ตัวดำเนินการ ? ยุบ match-แล้ว-return ให้เหลืออักขระเดียว พร้อมแปลงชนิด error ด้วย From โดยอัตโนมัติ (กลไกที่บท6 จะเปิดเผยว่าคือ trait From); fn main() -> Result<(), Box<dyn Error>> ให้ ? ส่งต่อ error ต่างชนิดใน binary ได้; thiserror 2.x สร้าง error enum ของ library แบบ matchable ด้วย #[derive(Error, Debug)] + #[error] + #[from]; และหลัก thiserror=library, anyhow=application ที่ต่อกันได้เพราะ error ของ thiserror เป็น Error ที่ ? แปลงเข้าไปใน anyhow::Error ได้
บทหน้าเราขยับไปที่ collections, strings, iterators และ closures — เราจะเห็น collect::<Result<Vec<_>, _>>() ที่ short-circuit หยุดทันทีที่เจอ Err ตัวแรก ซึ่งเป็นการนำ Result ของบทนี้ไปประกอบร่างกับ iterator ที่ขี้เกียจของบทหน้าอย่างสวยงาม
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch09 Error Handling (เข้าถึง 2026-07-24) — การแบ่ง recoverable (
Result) vs unrecoverable (panic!),unwrap/expect, และ?ที่ early-return พร้อมแปลง error ด้วยFrom - rustc Error Codes Index — E0119 (เข้าถึง 2026-07-24) — conflicting trait implementations (เหตุผลที่สอง
#[from]ชนิดเดียวกันไม่ compile) - thiserror v2.0.19 — docs.rs (as-of 2026-07-18) —
#[derive(Error)],#[error("…")]=Display,#[from]= สร้างFrom+ ทำ field เป็น source - thiserror 2.0 release notes (2024) — major ปัจจุบันคือ 2.x (อย่า pin
"1") - anyhow v1.0.104 — docs.rs (as-of 2026-07-18) —
Result<T>alias,.with_context(),bail!, และหัวข้อ “Comparison to thiserror”: thiserror สำหรับ library, anyhow สำหรับ application
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ตัวดำเนินการ `?` ท้าย expression ที่คืน `Result` ทำอะไร?