@[toc]
大多数 Rust 程序员在处理错误时表现得像被编译器吓坏了的孩子。
他们害怕 Result、害怕 ?、害怕 lifetimes,最后写出一堆看起来“类型安全”的垃圾。
问题不在语言,而在思维:错误处理是设计问题,不是语法问题。
一、错误的核心:调用者到底需要知道什么?
如果调用者需要知道错误的来源,就枚举(enumeration)。
如果调用者只需要知道“出错了”,就擦除(erasure)。
错误示例:不区分错误来源
1 2 3 4
| fn copy_data(mut reader: impl Read, mut writer: impl Write) -> Result<(), std::io::Error> { std::io::copy(&mut reader, &mut writer)?; Ok(()) }
|
看起来很简洁对吧?问题是:调用者根本不知道是读挂了还是写炸了。
在网络服务器里,这两个错误是完全不同的:
- 输入流失败可能意味着磁盘或 socket 损坏(致命)
- 输出流失败可能只是客户端断开(可以忽略)
但现在它们都被混在一个 std::io::Error 里,你的调用者只能瞎猜。
正确示例:枚举错误来源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| #[derive(Debug)] pub enum CopyError { In(std::io::Error), Out(std::io::Error), }
impl std::error::Error for CopyError {} impl std::fmt::Display for CopyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CopyError::In(e) => write!(f, "input error: {}", e), CopyError::Out(e) => write!(f, "output error: {}", e), } } }
fn copy_data(mut reader: impl Read, mut writer: impl Write) -> Result<(), CopyError> { let mut buf = [0; 4096]; loop { let n = reader.read(&mut buf).map_err(CopyError::In)?; if n == 0 { break; } writer.write_all(&buf[..n]).map_err(CopyError::Out)?; } Ok(()) }
|
现在调用者能区分错误来源,可以决定不同的策略。
这才叫语义清晰。类型系统不是目标,它是防止你撒谎的手段。
二、错误信息越多不代表更好
有的人以为“详细 = 专业”,于是他们搞出这种 monstrosity:
过度设计的灾难
1 2 3 4 5 6 7
| #[derive(Debug)] enum DecodeError { InvalidHeader(u32), UnsupportedCompression(String), MalformedChunk(usize), IoError(std::io::Error), }
|
然后每个错误都被上报、打印、打 tag。
问题是:上层调用者根本不在意。
他们只想知道“图片读不出来”,不在乎是哪个 bit 出问题。
实用设计:擦除无关信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #[derive(Debug)] struct ImageError(String);
impl std::fmt::Display for ImageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "image decoding failed: {}", self.0) } }
impl std::error::Error for ImageError {}
fn decode_image(data: &[u8]) -> Result<Image, ImageError> { let header = parse_header(data).map_err(|_| ImageError("invalid header".into()))?; decompress(header).map_err(|_| ImageError("decompression failed".into()))?; Ok(Image::new()) }
|
用户得到的是干净的 ImageError,无需知道底层细节。
擦除复杂度,不是隐藏错误,而是隔离不必要的信息。
三、特殊情况是设计的失败
很多人写函数时遇到“理论上不会失败”的情况,就乱来。
错误示例:用 Option 偷懒
1 2 3
| fn parse_number(s: &str) -> Option<i32> { s.parse().ok() }
|
看起来简洁,但 None 到底是什么意思?
是字符串不是数字?是空字符串?是 I/O 出错?
调用者完全没法判断——你直接剥夺了他们的恢复能力。
正确示例:用 Result 明确语义
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #[derive(Debug)] struct ParseNumberError;
impl std::fmt::Display for ParseNumberError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "invalid number format") } }
impl std::error::Error for ParseNumberError {}
fn parse_number(s: &str) -> Result<i32, ParseNumberError> { s.parse().map_err(|_| ParseNumberError) }
|
Result 表示“有错误发生”;Option 表示“没有值返回”。
混用这两个类型,是 API 设计的懒惰行为。
如果函数可能失败,就让类型系统告诉调用者这件事。
四、操作符不是魔法,是糖衣
有人以为 ? 很神秘,其实它就是:
1 2 3 4
| match expr { Ok(v) => v, Err(e) => return Err(e.into()), }
|
所以关键不在 ?,而在 From trait。
错误示例:只实现了 Into
1 2 3
| impl Into<MyError> for std::io::Error { fn into(self) -> MyError { MyError::Io(self) } }
|
然后惊讶地发现:? 报错,“trait bound not satisfied”。
因为 ? 调用的是 From::from,不是 Into::into。
正确示例:实现 From,一切通顺
1 2 3
| impl From<std::io::Error> for MyError { fn from(e: std::io::Error) -> Self { MyError::Io(e) } }
|
从此你可以愉快地写:
1 2 3 4 5
| fn read_file() -> Result<String, MyError> { let mut buf = String::new(); std::fs::File::open("config.txt")?.read_to_string(&mut buf)?; Ok(buf) }
|
五、try block:清理不该被跳过的东西
很多人喜欢:
1 2 3 4 5 6
| fn run() -> Result<(), Error> { let conn = connect()?; do_stuff(&conn)?; conn.close()?; Ok(()) }
|
一旦 do_stuff 出错,close() 永远不会跑到。
Rust 的 ? 让你早退,但不会帮你擦屁股。
正确做法:try block
1 2 3 4 5 6 7 8
| fn run() -> Result<(), Error> { let conn = connect()?; let r = try { do_stuff(&conn)?; }; conn.close()?; r }
|
这才是可靠的错误处理:不丢资源,不绕逻辑。
try {} 块让你能在出错时执行 cleanup,而不破坏 ? 的流畅性。
六、永远别为“优雅”破坏用户空间
有些库作者干了这种蠢事:
1 2 3 4 5
| pub fn foo() -> Result<(), Box<dyn Error>>;
pub fn foo() -> Result<(), Box<MyError>>;
|
然后他们说:“签名没变呀!编译器不会报错!”
但用户代码里:
1 2 3 4 5 6 7
| match foo() { Err(e) => { if let Some(ioe) = e.downcast_ref::<std::io::Error>() { } } }
|
现在全部崩了。
如果用户能 downcast,那类型擦除就是你的 API 一部分。
别假装不是。
这类破坏兼容性的更改,永远是破坏性更新(breaking change)。