ประเภทข้อผิดพลาดที่กำหนดเอง
ทำไมต้องมี Error ที่กำหนดเอง?
หัวข้อที่มีชื่อว่า “ทำไมต้องมี Error ที่กำหนดเอง?”การใช้ String หรือ Box<dyn std::error::Error> เป็น error type ของคุณนั้นใช้ได้ดีสำหรับสคริปต์เร็วๆ แต่ library และ application จริงจะได้ประโยชน์จาก error enum ที่เป็นรูปธรรม:
- ผู้เรียกสามารถ pattern-match กับ variant เฉพาะเจาะจงและจัดการแต่ละกรณีต่างกันได้
- error type บันทึกทุกโหมดความล้มเหลวของ API ของคุณไว้เป็นเอกสาร
- คุณสามารถแนบข้อมูลที่มีโครงสร้าง (เช่น ค่าที่ไม่ถูกต้อง) ไว้กับแต่ละ variant ได้
กำหนด Error Enum
หัวข้อที่มีชื่อว่า “กำหนด Error Enum”ประกาศ enum ที่มีหนึ่ง variant ต่อหนึ่งโหมดความล้มเหลว Derive Debug เพื่อให้ error พิมพ์ด้วย {:?} ได้:
#[derive(Debug)]enum AppError { ParseError(std::num::ParseIntError), NegativeNumber(i32), TooBig(i32),}Implement Display
หัวข้อที่มีชื่อว่า “Implement Display”trait std::fmt::Display ควบคุมข้อความ error ที่มนุษย์อ่านได้ — สิ่งที่คุณเห็นเมื่อพิมพ์ด้วย {}:
use std::fmt;
#[derive(Debug)]enum AppError { ParseError(std::num::ParseIntError), NegativeNumber(i32), TooBig(i32),}
impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::ParseError(e) => write!(f, "parse error: {}", e), AppError::NegativeNumber(n) => write!(f, "number is negative: {}", n), AppError::TooBig(n) => write!(f, "number is too big: {} (max 100)", n), } }}คอมไพเลอร์พูดว่าอะไร: Error ต้องการ Display
หัวข้อที่มีชื่อว่า “คอมไพเลอร์พูดว่าอะไร: Error ต้องการ Display”std::error::Error ถูกประกาศเป็น pub trait Error: Debug + Display หมายความว่า Display ไม่ใช่ตัวเลือก — มันคือ supertrait bound การ implement Error ก่อน Display จะล้มเหลวด้วยข้อความที่บอกชื่อ bound ที่ขาดหายไปเป๊ะๆ และที่มาของมัน:
// @expect-error E0277#[derive(Debug)]struct AppError;
impl std::error::Error for AppError {}
fn main() { let _e: Box<dyn std::error::Error> = Box::new(AppError);}error[E0277]: `AppError` doesn't implement `std::fmt::Display` --> error_needs_display.rs:4:28 | 4 | impl std::error::Error for AppError {} | ^^^^^^^^ unsatisfied trait bound |help: the trait `std::fmt::Display` is not implemented for `AppError` --> error_needs_display.rs:2:1 | 2 | struct AppError; | ^^^^^^^^^^^^^^^note: required by a bound in `std::error::Error` | | pub trait Error: Debug + Display { | ^^^^^^^ required by this bound in `Error`
error: aborting due to 1 previous errorนี่คือเหตุผลที่ custom error type ทุกตัวเดินตามลำดับเดียวกัน: derive Debug, implement Display, แล้วค่อย implement Error — คอมไพเลอร์ไม่ยอมให้คุณข้ามขั้นตอนไปได้
Implement std::error::Error
หัวข้อที่มีชื่อว่า “Implement std::error::Error”เพื่อให้ใช้ได้ทุกที่ที่ต้องการ dyn Error (เช่น ใน Box<dyn Error>) ประเภทของคุณต้อง implement std::error::Error นอกเหนือจาก bound Debug/Display แล้ว trait นี้ไม่มี method ที่บังคับ — default implementation เพียงพอสำหรับกรณีส่วนใหญ่ ถ้าต้องการให้ implement source() เพื่อเปิดเผยสาเหตุที่แท้จริง:
use std::fmt;
#[derive(Debug)]enum AppError { ParseError(std::num::ParseIntError), NegativeNumber(i32), TooBig(i32),}
impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::ParseError(e) => write!(f, "parse error: {}", e), AppError::NegativeNumber(n) => write!(f, "number is negative: {}", n), AppError::TooBig(n) => write!(f, "number is too big: {} (max 100)", n), } }}
impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { AppError::ParseError(e) => Some(e), _ => None, } }}เดินตาม Chain ของ source()
หัวข้อที่มีชื่อว่า “เดินตาม Chain ของ source()”source() เชื่อม error หนึ่งกับ error ระดับล่างที่เป็นสาเหตุของมัน ทีละชั้น ตัวพิมพ์ทั่วไปเดินตาม chain ทั้งหมดด้วย while let Some(...) จากข้อความระดับบนสุดลงไปถึงสาเหตุที่แท้จริง:
use std::error::Error;use std::fmt;
#[derive(Debug)]struct ConfigError { source: std::num::ParseIntError,}
impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "invalid config value") }}impl Error for ConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }}
#[derive(Debug)]struct StartupError { source: ConfigError,}
impl fmt::Display for StartupError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "server failed to start") }}impl Error for StartupError { fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }}
fn print_chain(err: &dyn Error) { println!("Error: {err}"); let mut source = err.source(); while let Some(s) = source { println!("Caused by: {s}"); source = s.source(); }}
fn main() { let parse_err = "abc".parse::<i32>().unwrap_err(); let config_err = ConfigError { source: parse_err }; let startup_err = StartupError { source: config_err }; print_chain(&startup_err);}การรันโค้ดนี้พิมพ์ chain ทั้งหมด หนึ่งข้อความ Display ต่อหนึ่งชั้น จากความล้มเหลวชั้นนอกสุดไปจนถึง ParseIntError ดั้งเดิม:
Error: server failed to startCaused by: invalid config valueCaused by: invalid digit found in stringถ้าไม่มี source() chain นั้นก็ไม่มีอยู่เลย — ผู้เรียก (และเครื่องมืออย่างการจัด format {:?} ของ anyhow ที่แสดงด้านล่าง) จะเห็นได้แค่ข้อความ Display ระดับบนสุดตัวเดียวเท่านั้น
การแปลงอัตโนมัติด้วย From
หัวข้อที่มีชื่อว่า “การแปลงอัตโนมัติด้วย From”การ implement From<SomeOtherError> for AppError ทำให้ตัวดำเนินการ ? แปลงให้อัตโนมัติเมื่อเรียกฟังก์ชันที่คืนค่า SomeOtherError:
#[derive(Debug)]enum AppError { ParseError(std::num::ParseIntError), NegativeNumber(i32), TooBig(i32),}
impl From<std::num::ParseIntError> for AppError { fn from(e: std::num::ParseIntError) -> Self { AppError::ParseError(e) }}ตอนนี้ s.parse::<i32>()? ในฟังก์ชันที่คืนค่า Result<_, AppError> จะห่อ ParseIntError เป็น AppError::ParseError ให้อัตโนมัติ ดูบทตัวดำเนินการ ? สำหรับสิ่งที่เกิดขึ้นเป๊ะๆ เมื่อ From impl นี้ขาดหายไป
use std::fmt;use std::num::ParseIntError;
#[derive(Debug)]enum AppError { ParseError(ParseIntError), NegativeNumber(i32), TooBig(i32),}
impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::ParseError(e) => write!(f, "parse error: {}", e), AppError::NegativeNumber(n) => write!(f, "number is negative: {}", n), AppError::TooBig(n) => write!(f, "number is too big: {} (max 100)", n), } }}
impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { AppError::ParseError(e) => Some(e), _ => None, } }}
impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::ParseError(e) }}
fn parse_bounded(s: &str) -> Result<i32, AppError> { let n: i32 = s.parse()?; // ParseIntError -> AppError via From if n < 0 { return Err(AppError::NegativeNumber(n)); } if n > 100 { return Err(AppError::TooBig(n)); } Ok(n)}
fn main() { let cases = ["42", "-5", "200", "abc"]; for s in &cases { match parse_bounded(s) { Ok(n) => println!("{} -> Ok({})", s, n), Err(e) => println!("{} -> Err: {}", s, e), } }}Compiling…
Box<dyn Error> ใน Binary เทียบกับ Enum ที่กำหนดเองใน Library
หัวข้อที่มีชื่อว่า “Box<dyn Error> ใน Binary เทียบกับ Enum ที่กำหนดเองใน Library”Box<dyn Error> ลบประเภท error ที่เป็นรูปธรรมออก — ประเภทใดก็ตามที่ implement Error ใส่เข้าไปในนั้นได้ นั่นเหมาะสมพอดีสำหรับ fn main ที่สิ่งเดียวที่สำคัญคือ “โปรแกรมล้มเหลวหรือเปล่า และควรพิมพ์อะไร” แต่มันผิดพอดีสำหรับ API สาธารณะ ของ library: ผู้เรียกเสียความสามารถในการ match ว่าความล้มเหลวไหนเกิดขึ้น และเหลือแค่ตัวเลือกที่งุ่มง่ามคือ downcast ตอนรันไทม์:
use std::error::Error;use std::fmt;
#[derive(Debug)]struct NotFoundError(String);
impl fmt::Display for NotFoundError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "not found: {}", self.0) }}impl Error for NotFoundError {}
fn lookup(key: &str) -> Result<i32, Box<dyn Error>> { if key == "answer" { Ok(42) } else { Err(Box::new(NotFoundError(key.to_string()))) }}
fn main() { match lookup("missing") { Ok(v) => println!("found: {v}"), Err(e) => { // Box<dyn Error> erases the concrete type, so a caller who wants to // react differently to NotFoundError specifically must downcast at runtime. if let Some(not_found) = e.downcast_ref::<NotFoundError>() { println!("caller can react specifically: {}", not_found.0); } else { println!("some other error: {e}"); } } }}downcast_ref::<T>() คืนค่า Some(&T) ก็ต่อเมื่อประเภทจริงตอนรันไทม์ของ trait object คือ T — มันคอมไพล์ผ่านได้ไม่ว่า T: Error + 'static จะถูกหรือไม่ก็ตาม ดังนั้นคอมไพเลอร์จับ typo ของประเภทตรงนี้ไม่ได้เหมือนที่มันจับ match arm ที่ผิดบน enum จริงได้ นี่คือต้นทุนที่เป็นรูปธรรมของการเลือก Box<dyn Error> สำหรับ API ที่ผู้เรียกต้อง branch: ผู้เรียกทุกคนที่สนใจต้อง implement การเช็ครันไทม์ที่เปราะบางแบบเดียวกันนี้ซ้ำ แทนที่จะได้ match ที่คอมไพเลอร์เช็คให้
flowchart TD
A[Choosing an error type] --> B{Who calls this function?}
B -->|main, or a script,
you just want to print it| C[Box<dyn Error>
is fine]
B -->|Other code that must
react differently per failure| D[Concrete error enum]
D --> E[Callers get a compiler-checked match]
C --> F[Callers who need specifics
must downcast_ref at runtime] #[non_exhaustive] บน Error Enum
หัวข้อที่มีชื่อว่า “#[non_exhaustive] บน Error Enum”การเพิ่ม variant ใหม่ให้ error enum สาธารณะเป็น breaking change สำหรับ crate downstream: match ใดๆ ที่ไม่มีสาขา wildcard จะหยุดคอมไพล์ทันทีที่คุณเพิ่ม variant เพราะมันไม่ exhaustive อีกต่อไป #[non_exhaustive] เปลี่ยนค่าเริ่มต้นนั้น — มันบังคับให้ทุก crate ที่ไม่ใช่ของคุณ ต้องมีสาขา wildcard เพื่อที่คุณจะเพิ่ม variant ในภายหลังได้โดยไม่ทำให้ build ของพวกเขาพัง:
#[derive(Debug)]#[non_exhaustive]pub enum ApiError { NotFound, Timeout,}ภายใน crate ที่กำหนดมันเอง #[non_exhaustive] ไม่มีผลอะไรเลย — attribute นี้จำกัดแค่ crate อื่น เท่านั้น diagnostic จริงด้านล่างมาจาก scratch workspace สองคร่าง: library crate ชื่อ apilib ที่กำหนด ApiError ด้วย #[non_exhaustive] และ consumer crate แยกต่างหากที่ match กับมันโดยไม่มี wildcard:
// @skip-verify cross-crate demo — needs a separate library crate to observe the effectuse apilib::ApiError;
fn describe(e: &ApiError) -> &'static str { match e { ApiError::NotFound => "not found", ApiError::Timeout => "timed out", }}$ cargo builderror[E0004]: non-exhaustive patterns: `&_` not covered --> src/main.rs:4:11 |4 | match e { | ^ pattern `&_` not covered | = note: the matched value is of type `&ApiError` = note: `ApiError` is marked as non-exhaustive, so a wildcard `_` is necessary to match exhaustivelyhelp: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown |6 ~ ApiError::Timeout => "timed out",7 ~ &_ => todo!(), |
error: could not compile `consumer` (bin "consumer") due to 1 previous errorสังเกตว่านี่คือพฤติกรรม E0004 ตัวเดียวกันเป๊ะๆ กับ “ข้อควรระวังสาขา _” ที่คุณเคยเห็นแล้วสำหรับ enum ธรรมดา — #[non_exhaustive] แค่ทำให้สาขา wildcard บังคับ สำหรับ crate ภายนอก แทนที่จะแค่ปิดช่องว่าง coverage ที่พวกเขาเลือกเข้าร่วมเอง
thiserror และ anyhow
หัวข้อที่มีชื่อว่า “thiserror และ anyhow”การเขียน Display, Error, และ From เองสำหรับ error enum ทุกตัวซ้ำซากพอที่ในโลกจริง crate สองตัวครองพื้นที่นี้: thiserror สำหรับ error enum ของ library, anyhow สำหรับการจัดการ error ระดับ application ไม่มีตัวไหนมากับ toolchain ของคอร์สนี้ ดังนั้น snippet ด้านล่างเป็น @skip-verify — output ถูก quote มาแบบคำต่อคำจาก cargo build && cargo run จริงใน scratch project ที่เพิ่ม thiserror = "2" และ anyhow = "1" ผ่าน cargo add
#[derive(Error)] ของ thiserror สร้าง Display และ Error impl จาก template ข้อความ #[error("...")] และ #[from] สร้าง From impl ให้ในบรรทัดเดียว:
// @skip-verify crate: thiserror — output quoted from a real scratch cargo build/run belowuse thiserror::Error;
#[derive(Error, Debug)]enum AppError { #[error("invalid number: {0}")] Parse(#[from] std::num::ParseIntError), #[error("value {0} is out of range (0..=100)")] OutOfRange(i32),}
fn parse_bounded(s: &str) -> Result<i32, AppError> { let n: i32 = s.parse()?; // ParseIntError -> AppError::Parse via #[from] if n > 100 { return Err(AppError::OutOfRange(n)); } Ok(n)}
fn main() { for s in ["42", "abc", "999"] { match parse_bounded(s) { Ok(n) => println!("{s} -> Ok({n})"), Err(e) => println!("{s} -> Err: {e}"), } }}$ cargo add thiserror && cargo build --quiet && cargo run --quiet42 -> Ok(42)abc -> Err: invalid number: invalid digit found in string999 -> Err: value 999 is out of range (0..=100)anyhow::Result<T> (alias ของ Result<T, anyhow::Error>) คือคู่หูฝั่ง application: มันคล้าย Box<dyn Error> ตรงที่ลบประเภทที่เป็นรูปธรรมออก แต่เพิ่ม .context(...)/.with_context(...) เพื่อแนบ breadcrumb ที่มนุษย์อ่านได้ในทุกชั้น และการจัด format {:?} (Debug) ของมันพิมพ์ chain source() ทั้งหมดให้คุณโดยอัตโนมัติ:
// @skip-verify crate: anyhow — output quoted from a real scratch cargo build/run belowuse anyhow::{Context, Result};
fn read_port(s: &str) -> Result<u16> { s.parse::<u16>() .with_context(|| format!("failed to parse '{s}' as a port number"))}
fn load_config() -> Result<u16> { read_port("not_a_port").context("loading server config")}
fn main() -> Result<()> { let port = load_config()?; println!("port = {port}"); Ok(())}$ cargo add anyhow && cargo build --quiet && cargo run --quiet; echo "exit=$?"Error: loading server config
Caused by: 0: failed to parse 'not_a_port' as a port number 1: invalid digit found in stringexit=1บล็อก Caused by: นั้นคือ chain source() จากก่อนหน้านี้เป๊ะๆ ที่พิมพ์ให้คุณ — anyhow โดยพื้นฐานคือ “ให้ตัวพิมพ์ chain source() และกลไกการแปลง From แบบฟรีๆ แลกกับการลบประเภท error ที่เป็นรูปธรรม” ซึ่งเป็นเหตุผลว่าทำไมมันเหมาะกับ application (ที่ไม่มีใคร downstream ต้อง match กับ error ของคุณ) ไม่ใช่ public API ของ library
panic = "abort" และ catch_unwind โดยย่อ
หัวข้อที่มีชื่อว่า “panic = "abort" และ catch_unwind โดยย่อ”Custom error type มีไว้สำหรับความล้มเหลวที่กู้คืนได้ — แต่ไม่ใช่ทุกความล้มเหลวจะกู้คืนได้ และบท Panics ครอบคลุมอีกครึ่งหนึ่งของเรื่องนี้อย่างลึกซึ้ง: การตั้ง panic = "abort" ใน Cargo.toml แลก cleanup แบบ Drop กับ binary ที่เล็กลง และ std::panic::catch_unwind สามารถแยก panic ที่ขอบเขต FFI หรือ worker thread ได้โดยไม่ทำให้ Rust กลายเป็นภาษาที่มี exception handling ทั่วไป ถ้า custom error enum ของคุณกำลังจะโต variant รูปร่าง Panic “เผื่อไว้กรณี callback panic” บทนั้นที่มีเรื่อง catch_unwind คือเครื่องมือสำหรับงานนั้น ไม่ใช่ Err variant อีกตัว
แบบจำลองความคิด
หัวข้อที่มีชื่อว่า “แบบจำลองความคิด”คิดถึงรูปร่างของ error type ของคุณว่าเป็นคำสัญญาต่อผู้ฟังสองกลุ่มพร้อมกัน Debug และ Display มีไว้สำหรับมนุษย์ — บรรทัด log, terminal, bug report Error (โดยเฉพาะ source()) มีไว้สำหรับเครื่องมือ — อะไรก็ตามที่ต้องการเดินตาม failure chain แบบเป็นโปรแกรม เหมือนที่ {:?} ของ anyhow ทำให้อัตโนมัติ From มีไว้สำหรับโค้ดของคุณเอง — กลไกที่ทำให้ ? ข้ามขอบเขตระหว่าง error type สองตัวได้โดยไม่ต้องเขียน match ด้วยมือที่ทุกจุดเรียก error type ที่ออกแบบดีตอบสนองผู้ฟังทั้งสามกลุ่มโดยไม่บังคับให้ใครต้องเดา
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”Enum ที่เป็นรูปธรรมให้ผู้เรียก match ที่คอมไพเลอร์เช็คให้และบันทึกทุกโหมดความล้มเหลวไว้ที่เดียว โดยแลกกับการเขียน (หรือ derive) Display/Error/From ให้มันและอัปเดต call site เมื่อคุณเพิ่ม variant — เว้นแต่คุณเพิ่ม #[non_exhaustive] ซึ่งจะบังคับให้ผู้เรียกภายนอกทุกคนต้องเขียนสาขา wildcard ตลอดไป Box<dyn Error> ไม่ต้องมี boilerplate นั้นเลยและเป็นค่าเริ่มต้นที่ถูกต้องสำหรับ main และที่อื่นๆ ที่ไม่มีอะไร downstream ต้อง branch แต่มันผลักผู้เรียกที่ต้อง branch จริงๆ ไปสู่การเรียก downcast_ref ตอนรันไทม์ที่คอมไพเลอร์เช็คไม่ได้ thiserror และ anyhow ลบ boilerplate ที่เหลือส่วนใหญ่ในแต่ละฝั่งของเส้นนั้น โดยแลกกับ dependency และสำหรับ anyhow คือข้อแลกเปลี่ยนการลบประเภทแบบเดียวกับ Box<dyn Error>
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
Box<dyn Error>ใช้ได้เสมอเพราะคุณdowncast_refได้ถ้าต้องการประเภทที่เป็นรูปธรรมจริงๆ” คุณทำได้ แต่คอมไพเลอร์เช็คไม่ได้ว่าdowncast_ref::<T>()ที่คุณเขียนคือประเภทที่ถูก return จริงๆ — typo หรือการ refactor เปลี่ยนมันจาก “สาขานี้รัน” เป็น “สาขานี้ไม่เคยรัน” แบบเงียบๆ โดยไม่มี error เลย - “
#[non_exhaustive]ทำให้ enum กันการ exhaustive ได้ทุกที่ รวมถึง crate ของฉันเอง” มันไม่มีผลอะไรเลยภายใน crate ที่กำหนดมัน — คุณยัง match โดยไม่มี wildcard ที่นั่นได้อยู่ มันเปลี่ยนแค่สิ่งที่ crate อื่น ถูกบังคับให้เขียน - “
anyhowและthiserrorทำงานเดียวกัน ฉันต้องการแค่ตัวเดียว” พวกมันแก้ปัญหาคนละครึ่ง:thiserrorสร้างประเภทที่เป็นรูปธรรม match ได้ (สำหรับ library);anyhowสร้างประเภทที่ถูกลบไปพร้อมคำอธิบายบริบทและการจัด format chain อัตโนมัติ (สำหรับ application) การใช้anyhow::Errorเป็น return type สาธารณะของ library นำปัญหา downcasting แบบเดียวกับBox<dyn Error>กลับมาให้ผู้เรียกของมัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”AI มักจะเลือกใช้ Box<dyn std::error::Error> เป็น return type ของฟังก์ชัน library สาธารณะ เพราะมันคอมไพล์ผ่านเสมอไม่ว่าโหมดความล้มเหลวจริงของฟังก์ชันจะเป็นอะไร — มันคือเส้นทางที่ต้านทานน้อยที่สุด ไม่ใช่การออกแบบ API ที่ถูกต้องเสมอไป
🐛 หา bug ในโค้ดนี้
pub fn connect(addr: &str) -> Result<Connection, Box<dyn std::error::Error>> { let socket = std::net::TcpStream::connect(addr)?; Ok(Connection::new(socket))}เฉลย
นี่คือฟังก์ชัน library สาธารณะ (สังเกต pub fn) ที่คืนค่า Box<dyn std::error::Error> ผู้เรียกของมันแยกแยะไม่ได้ว่า “DNS resolution ล้มเหลว” จาก “connection refused” จาก “timeout” โดยไม่ downcast แม้ว่า std::net::TcpStream::connect เองจะคืนค่า std::io::Error ที่มีประเภทชัดเจนซึ่ง .kind() ของมันแยกแยะสิ่งเหล่านี้ได้อยู่แล้ว API ของ library ควรเก็บข้อมูลนั้นไว้: Result<Connection, ConnectError> พร้อม enum ConnectError { Io(std::io::Error) } (หรือ variant ที่ละเอียดกว่าต่อ ErrorKind) และ From<std::io::Error> impl ทำให้ ? ยังทำงานได้เหมือนเดิมทุกประการ ในขณะที่ให้ผู้เรียก match จริงแทนการ downcast