继续学习后我发现 Rust 的错误处理同样很有意思。现代编程语言一直在演化。同样的老问题交给不同时代的设计者往往会得到完全不同的答案。这不是在评判谁好谁坏每一门语言在它诞生的那个年代都只是给出了那个时代认为最合适的回答。理解这些回答之间的差别其实也是在看一段软件工程慢慢演化的过程。废话少说进入正文。在 Kotlin 中读取一个文本文件我们一般会这样写import java.io.File import java.io.IOException fun readConfig(path: String): String { return File(path).readText() }代码很简单传入文件路径返回文件内容。但是如果文件不存在呢File.readText()会抛出异常。此时我们有两个选择不在当前层处理让异常继续向上传播。主动处理异常比如返回一个默认值。如果要处理异常在 Kotlin 中我们可以在调用的地方使用try-catchval content try { readConfig(server.conf) } catch (e: IOException) { }读取成功content就是文件内容读取失败我们返回一个空字符串。如果换成 Rust 呢你可能会下意识地寻找 Rust 的try-catch但是找不到。因为 Rust 没有传统的异常机制。Rust 读取文件时会返回一个Resultuse std::fs; fn main() { let content fs::read_to_string(server.conf); println!({content:?}); }如果文件存在输出大概是这样Ok(port8080)如果文件不存在输出会变成Err(Os { code: 2, kind: NotFound, message: No such file or directory })也就是说read_to_string()不会把错误从函数中“扔”出来。无论成功还是失败它都会老老实实返回一个值。这个值就是Result。Result 是什么Result本质上是一个枚举enum ResultT, E { Ok(T), Err(E), }它只有两种情况Ok(T)执行成功里面保存正常结果Err(E)执行失败里面保存错误。fs::read_to_string()的返回类型是ResultString, std::io::Error翻译一下就是读取成功返回String读取失败返回std::io::Error。所以在 Rust 中错误并没有进入另一条看不见的流程它就是返回值的一部分。我们再回头看一下 Kotlin 的函数fun readConfig(path: String): String只看函数签名它接收一个String返回一个String。至于读取过程中会不会抛出异常、会抛出什么异常签名中完全看不出来。Rust 的签名会写成这样fn read_config(path: str) - ResultString, std::io::Error如果开发者看到这个函数他马上就能知道这个函数可能失败。此时事情开始变得有意思了。使用 match 处理错误既然Result是一个枚举我们就可以使用match来处理use std::fs; fn main() { let result fs::read_to_string(server.conf); let content match result { Ok(content) content, Err(error) { println!(读取配置文件失败{error}); String::new() } }; println!(配置文件内容{content}); }代码很简单如果是Ok取出里面的字符串如果是Err打印错误然后返回一个空字符串。这和前面的 Kotlintry-catch差不多都是读取失败以后使用默认值。不过match有一个很重要的特点分支必须完整。如果我们只处理Oklet content match result { Ok(content) content, };代码无法通过编译因为Err还没有处理。当然我们也可以明确忽略错误let content fs::read_to_string(server.conf) .unwrap_or_default();这样写没有问题。Rust 并不要求所有错误都必须写一大段处理代码而是要求我们明确做出决定处理、转换、继续返回或者使用默认值。另外Result带有must_use标记。如果直接把它扔掉fs::read_to_string(server.conf);编译器通常会给出警告提醒我们刚刚忽略了一个可能失败的操作。这里可以看出一个明显区别Rust 会把可恢复错误显式放进返回类型。Kotlin 默认使用非受检异常。函数签名只承诺“我会返回String”至于运行时会抛出什么异常并没有自动写进类型里。如果当前函数没有捕获异常就会沿着调用栈继续传播最终交给线程的未捕获异常处理器。如果异常发生在主线程或者 Android UI 线程通常就会表现为应用崩溃。在默认的错误处理方式上Rust 选择了另一条路线。对于正常运行中可以预料的失败Rust 倾向于把它建模为Result。fs::read_to_string()返回的不是“String或抛异常”而是ResultString, io::Error——成功也好、失败也好都是一个普通值。前面的must_use警告就是这种设计的体现你调用了它却什么都不做编译器会提醒你刚刚忽略了一个可能失败的操作。错误在这里是“预期之内”的事情编译器会主动提醒你做出决定处理、转换还是继续向上传递。当然这种“显式”也有代价。当错误类型逐渐增多时返回类型和错误转换代码可能会变得复杂。难道每次都要写 match假设我们需要读取配置文件然后继续处理其中的内容use std::fs; fn load_config(path: str) - ResultString, std::io::Error { let text match fs::read_to_string(path) { Ok(text) text, Err(error) return Err(error), }; println!(配置内容{text}); Ok(text) }这里只处理了文件读取错误代码已经有些啰嗦了。如果一个函数中存在五六个可能失败的操作到处都会是match。Rust 为这种情况提供了?操作符use std::fs; fn load_config(path: str) - ResultString, std::io::Error { let text fs::read_to_string(path)?; Ok(text) }?做了什么呢如果结果是Ok取出里面的值继续执行如果结果是Err立即从当前函数返回。所以let text fs::read_to_string(path)?;大概相当于let text match fs::read_to_string(path) { Ok(text) text, Err(error) return Err(error.into()), };当前例子中的错误类型相同所以直接返回也能工作。不过更一般地说?还会通过From转换错误让它与当前函数声明的错误类型保持一致。Very good! 一行代码就完成了错误判断和向上传递。从效果上看?和 Kotlin 中不捕获异常有些相似当前函数不处理交给调用者。但这里还有一个区别。Kotlin 的异常向上传递以后函数签名依然可以是fun loadConfig(path: String): StringRust 使用?向上传递错误以后函数仍然需要返回Resultfn load_config(path: str) - ResultString, std::io::Error错误虽然传给了调用者但是没有从类型中消失。多种错误下面再看一个真正读取端口的例子。这个操作可能出现两种错误配置文件读取失败文件内容无法转换成u16。我们可以定义一个错误类型use std::fs; use std::io; use std::num::ParseIntError; #[derive(Debug)] enum LoadPortError { Io(io::Error), InvalidPort(ParseIntError), } fn load_port(path: str) - Resultu16, LoadPortError { let text fs::read_to_string(path) .map_err(LoadPortError::Io)?; let port text .trim() .parse::u16() .map_err(LoadPortError::InvalidPort)?; Ok(port) }此时load_port()的调用者能够非常清楚地处理两种失败match load_port(server.conf) { Ok(port) println!(服务端口{port}), Err(LoadPortError::Io(error)) { println!(配置文件读取失败{error}); } Err(LoadPortError::InvalidPort(error)) { println!(端口格式不正确{error}); } }以后如果LoadPortError又增加了一种错误match也会提醒我们补上对应的处理逻辑。和使用一段错误字符串相比这种写法更加可靠。调用者处理的是Io和InvalidPort两种类型而不是判断错误消息中是否包含某几个单词。当然实际项目不一定每个函数都要定义一个错误枚举。应用层经常会把多种错误统一起来底层库则更适合保留具体的错误类型。unwrap 是干什么的Rust 示例代码中经常能看到unwrap()let content fs::read_to_string(server.conf).unwrap();如果读取成功unwrap()会取出Ok中的字符串。如果读取失败它会触发 panic当前执行流程通常也就无法继续了。unwrap()并没有忽略错误。它表达的是一种非常明确的假设这里必须成功一旦得到Err就立即 panic。从最终表现来看它有时和 Kotlin 中未捕获的异常很像但两者并不是同一种机制。听起来有些暴力不过unwrap()并非完全不能使用。在测试代码中我们经常希望操作失败以后直接终止测试#[test] fn can_read_config() { let content fs::read_to_string(test.conf).unwrap(); assert!(content.contains(port)); }这里使用unwrap()很正常。文件都无法读取后面的断言也没有继续执行的意义。写小工具、示例和原型时也可以先使用unwrap()快速完成主要逻辑。但是如果路径来自用户输入文件不存在是一件完全可能发生的事情。这种情况就不应该直接unwrap()而应该把错误返回给调用者或者给用户一个明确的提示。expect()和unwrap()类似不过它可以添加一段说明let content fs::read_to_string(embedded.conf) .expect(安装包中必须包含 embedded.conf);这么写panic 信息中就会包含我们提供的上下文。除了原始错误我们还能知道程序为什么认为这个文件必须存在。panic! 是什么Rust 没有传统异常但是有panic!fn set_percentage(value: u8) { if value 100 { panic!(百分比不能超过 100); } }数组越界、断言失败、unwrap()取到Err也都可能触发 panic。那么panic 是不是 Rust 版本的异常呢并不完全是。文件不存在、网络连接失败、用户输入格式不正确这些情况在程序正常运行时完全可能发生。调用者可以重试、提示用户或者使用默认值因此适合返回Result。数组越界或者程序内部状态互相矛盾通常说明代码中的某个假设已经被破坏。程序可能已经无法可靠地继续运行这类问题才更接近 panic 的使用场景。Rust 的 panic 主要有两种处理策略unwind沿调用栈展开并对仍然存活、实现了Drop的值执行析构逻辑abort直接终止进程。大多数常见目标默认使用unwind也可以在编译时改成abort。标准库还提供了std::panic::catch_unwind()能够捕获一部分采用unwind策略的 panic。不过它并不适合拿来模拟日常的try-catch。程序正常运行时可能遇到的失败依然应该使用Result。简单来说操作失败了但是程序知道接下来怎么办返回Result某个本来不应该被破坏的条件真的被破坏了考虑 panic。Kotlin 也有 Result看到这里熟悉 Kotlin 的开发者可能会想到Kotlin 标准库不是也有ResultT吗当然有fun loadPort(path: String): ResultInt runCatching { File(path).readText().trim().toInt() }调用的时候可以这样写loadPort(server.conf) .onSuccess { port - println(服务端口$port) } .onFailure { error - println(配置读取失败$error) }这和 Rust 的Result已经很像了。不过runCatching会捕获代码块抛出的所有Throwable因此不适合不加区分地包住大段业务代码。在协程中尤其要注意CancellationException它代表正常的取消信号如果被捕获通常应该继续抛出。另外Kotlin 的ResultInt只声明了成功值是Int失败值统一保存为Throwable。仅看函数签名我们仍然不知道具体可能出现哪些错误。Rust 的ResultT, E同时包含成功类型和错误类型Resultu16, LoadPortErrorKotlin 当然也可以使用sealed interface自己构建带有具体错误类型的结果import java.io.IOException sealed interface LoadPortError { data class Io(val cause: IOException) : LoadPortError data class InvalidPort(val cause: NumberFormatException) : LoadPortError } sealed interface LoadPortResult { data class Success(val port: Int) : LoadPortResult data class Failure(val error: LoadPortError) : LoadPortResult }完全可以实现。区别在于两门语言的默认选择。Kotlin 和 JVM 中的大量 API 仍然通过异常报告失败Rust 标准库从一开始就广泛使用Result。Rust 的哲学现在让我们重新看一遍 Rust 错误处理的整套流程。Rust 没有使用一个机制处理所有错误而是把错误分成了两类可以预料、可以处理的失败使用ResultT, E程序进入了不应该出现的状态使用 panic。这种分类比具体语法更加重要。假设服务器端口配置错误我们可以提示用户重新填写也可以回退到默认端口。程序还有很多事情可以做所以应该返回Result。假设程序运行到某个位置时发现一个内部状态既是“已登录”又是“未登录”后面的逻辑已经不知道应该相信谁。这时候继续运行未必还有意义可以让程序尽快暴露问题。Rust 并不是不允许程序失败它只是把“正常运行中可能出现的失败”和“程序本身已经出问题”分开了。一开始使用Result时可能会觉得它比 Kotlin 的异常麻烦。函数返回类型变长了错误需要转换还会到处看到?。但是随着项目逐渐变大这些类型会告诉我们哪些函数可能失败、会因为什么失败配合must_use警告还能更早暴露被忽略的错误结果。原本隐藏在异常通道中的可恢复失败被 Rust 放到了函数签名中。这就是 Rust 处理异常的方式它没有提供传统异常而是使用Result处理可以恢复的错误使用 panic 暴露已经无法可靠继续的状态。通常情况下程序正常运行时可能遇到的失败更适合使用Result。