
前段时间,我写过一篇文章,主要讨论 Rust 是如何处理空指针的。
继续学习后,我发现 Rust 的错误处理同样很有意思。
现代编程语言一直在演化。同样的老问题交给不同时代的设计者,往往会得到完全不同的答案。
这不是在评判谁好谁坏,每一门语言在它诞生的那个年代,都只是给出了那个时代认为最合适的回答。理解这些回答之间的差别,其实也是在看一段软件工程慢慢演化的过程。
废话少说,进入正文。
在 Kotlin 中,读取一个文本文件,我们一般会这样写:
kotlin
import java.io.File
import java.io.IOException
fun readConfig(path: String): String {
return File(path).readText()
}
代码很简单,传入文件路径,返回文件内容。
但是,如果文件不存在呢?
File.readText() 会抛出异常。
此时,我们有两个选择:
- 不在当前层处理,让异常继续向上传播。
- 主动处理异常,比如返回一个默认值。
如果要处理异常,在 Kotlin 中,我们可以在调用的地方使用 try-catch:
kotlin
val content = try {
readConfig("server.conf")
} catch (e: IOException) {
""
}
读取成功,content 就是文件内容;读取失败,我们返回一个空字符串。
如果换成 Rust 呢?
你可能会下意识地寻找 Rust 的 try-catch,但是找不到。
因为 Rust 没有传统的异常机制。
Rust 读取文件时,会返回一个 Result:
rust
use std::fs;
fn main() {
let content = fs::read_to_string("server.conf");
println!("{content:?}");
}
如果文件存在,输出大概是这样:
text
Ok("port=8080")
如果文件不存在,输出会变成:
text
Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
也就是说,read_to_string() 不会把错误从函数中"扔"出来。无论成功还是失败,它都会老老实实返回一个值。
这个值就是 Result。
Result 是什么
Result 本质上是一个枚举:
rust
enum Result<T, E> {
Ok(T),
Err(E),
}
它只有两种情况:
Ok(T):执行成功,里面保存正常结果;Err(E):执行失败,里面保存错误。
fs::read_to_string() 的返回类型是:
rust
Result<String, std::io::Error>
翻译一下就是:读取成功返回 String,读取失败返回 std::io::Error。
所以,在 Rust 中,错误并没有进入另一条看不见的流程,它就是返回值的一部分。
我们再回头看一下 Kotlin 的函数:
kotlin
fun readConfig(path: String): String
只看函数签名,它接收一个 String,返回一个 String。至于读取过程中会不会抛出异常、会抛出什么异常,签名中完全看不出来。
Rust 的签名会写成这样:
rust
fn read_config(path: &str) -> Result<String, std::io::Error>
如果开发者看到这个函数,他马上就能知道这个函数可能失败。
此时,事情开始变得有意思了。
使用 match 处理错误

既然 Result 是一个枚举,我们就可以使用 match 来处理:
rust
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,打印错误,然后返回一个空字符串。
这和前面的 Kotlin try-catch 差不多,都是读取失败以后使用默认值。
不过,match 有一个很重要的特点:分支必须完整。
如果我们只处理 Ok:
rust
let content = match result {
Ok(content) => content,
};
代码无法通过编译,因为 Err 还没有处理。
当然,我们也可以明确忽略错误:
rust
let content = fs::read_to_string("server.conf")
.unwrap_or_default();
这样写没有问题。
Rust 并不要求所有错误都必须写一大段处理代码,而是要求我们明确做出决定:处理、转换、继续返回,或者使用默认值。
另外,Result 带有 must_use 标记。如果直接把它扔掉:
rust
fs::read_to_string("server.conf");
编译器通常会给出警告,提醒我们刚刚忽略了一个可能失败的操作。
这里可以看出一个明显区别:Rust 会把可恢复错误显式放进返回类型。
Kotlin 默认使用非受检异常。函数签名只承诺"我会返回 String",至于运行时会抛出什么异常,并没有自动写进类型里。
如果当前函数没有捕获,异常就会沿着调用栈继续传播,最终交给线程的未捕获异常处理器。如果异常发生在主线程或者 Android UI 线程,通常就会表现为应用崩溃。
在默认的错误处理方式上,Rust 选择了另一条路线。
对于正常运行中可以预料的失败,Rust 倾向于把它建模为 Result。fs::read_to_string() 返回的不是"String 或抛异常",而是 Result<String, io::Error>------成功也好、失败也好,都是一个普通值。
前面的 must_use 警告就是这种设计的体现:你调用了它却什么都不做,编译器会提醒你刚刚忽略了一个可能失败的操作。错误在这里是"预期之内"的事情,编译器会主动提醒你做出决定:处理、转换,还是继续向上传递。
当然,这种"显式"也有代价。当错误类型逐渐增多时,返回类型和错误转换代码可能会变得复杂。
难道每次都要写 match
假设我们需要读取配置文件,然后继续处理其中的内容:
rust
use std::fs;
fn load_config(path: &str) -> Result<String, 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 为这种情况提供了 ? 操作符:
rust
use std::fs;
fn load_config(path: &str) -> Result<String, std::io::Error> {
let text = fs::read_to_string(path)?;
Ok(text)
}
? 做了什么呢?
- 如果结果是
Ok,取出里面的值,继续执行; - 如果结果是
Err,立即从当前函数返回。
所以:
rust
let text = fs::read_to_string(path)?;
大概相当于:
rust
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) => return Err(error.into()),
};
当前例子中的错误类型相同,所以直接返回也能工作。不过更一般地说,? 还会通过 From 转换错误,让它与当前函数声明的错误类型保持一致。
Very good! 一行代码就完成了错误判断和向上传递。
从效果上看,? 和 Kotlin 中不捕获异常有些相似:当前函数不处理,交给调用者。
但这里还有一个区别。
Kotlin 的异常向上传递以后,函数签名依然可以是:
kotlin
fun loadConfig(path: String): String
Rust 使用 ? 向上传递错误以后,函数仍然需要返回 Result:
rust
fn load_config(path: &str) -> Result<String, std::io::Error>
错误虽然传给了调用者,但是没有从类型中消失。
多种错误
下面再看一个真正读取端口的例子。
这个操作可能出现两种错误:
- 配置文件读取失败;
- 文件内容无法转换成
u16。
我们可以定义一个错误类型:
rust
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum LoadPortError {
Io(io::Error),
InvalidPort(ParseIntError),
}
fn load_port(path: &str) -> Result<u16, 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() 的调用者能够非常清楚地处理两种失败:
rust
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():
rust
let content = fs::read_to_string("server.conf").unwrap();
如果读取成功,unwrap() 会取出 Ok 中的字符串。
如果读取失败,它会触发 panic,当前执行流程通常也就无法继续了。
unwrap() 并没有忽略错误。它表达的是一种非常明确的假设:这里必须成功;一旦得到 Err,就立即 panic。从最终表现来看,它有时和 Kotlin 中未捕获的异常很像,但两者并不是同一种机制。
听起来有些暴力,不过,unwrap() 并非完全不能使用。
在测试代码中,我们经常希望操作失败以后直接终止测试:
rust
#[test]
fn can_read_config() {
let content = fs::read_to_string("test.conf").unwrap();
assert!(content.contains("port"));
}
这里使用 unwrap() 很正常。文件都无法读取,后面的断言也没有继续执行的意义。
写小工具、示例和原型时,也可以先使用 unwrap() 快速完成主要逻辑。
但是,如果路径来自用户输入,文件不存在是一件完全可能发生的事情。
这种情况就不应该直接 unwrap(),而应该把错误返回给调用者,或者给用户一个明确的提示。
expect() 和 unwrap() 类似,不过它可以添加一段说明:
rust
let content = fs::read_to_string("embedded.conf")
.expect("安装包中必须包含 embedded.conf");
这么写,panic 信息中就会包含我们提供的上下文。除了原始错误,我们还能知道程序为什么认为这个文件必须存在。
panic! 是什么
Rust 没有传统异常,但是有 panic!:
rust
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 标准库不是也有 Result<T> 吗?
当然有:
kotlin
fun loadPort(path: String): Result<Int> = runCatching {
File(path).readText().trim().toInt()
}
调用的时候可以这样写:
kotlin
loadPort("server.conf")
.onSuccess { port ->
println("服务端口:$port")
}
.onFailure { error ->
println("配置读取失败:$error")
}
这和 Rust 的 Result 已经很像了。
不过,runCatching 会捕获代码块抛出的所有 Throwable,因此不适合不加区分地包住大段业务代码。在协程中尤其要注意 CancellationException:它代表正常的取消信号,如果被捕获,通常应该继续抛出。
另外,Kotlin 的 Result<Int> 只声明了成功值是 Int,失败值统一保存为 Throwable。仅看函数签名,我们仍然不知道具体可能出现哪些错误。
Rust 的 Result<T, E> 同时包含成功类型和错误类型:
rust
Result<u16, LoadPortError>
Kotlin 当然也可以使用 sealed interface 自己构建带有具体错误类型的结果:
kotlin
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 没有使用一个机制处理所有错误,而是把错误分成了两类:
- 可以预料、可以处理的失败,使用
Result<T, E>; - 程序进入了不应该出现的状态,使用 panic。
这种分类比具体语法更加重要。
假设服务器端口配置错误,我们可以提示用户重新填写,也可以回退到默认端口。程序还有很多事情可以做,所以应该返回 Result。
假设程序运行到某个位置时,发现一个内部状态既是"已登录"又是"未登录",后面的逻辑已经不知道应该相信谁。这时候继续运行未必还有意义,可以让程序尽快暴露问题。
Rust 并不是不允许程序失败,它只是把"正常运行中可能出现的失败"和"程序本身已经出问题"分开了。
一开始使用 Result 时,可能会觉得它比 Kotlin 的异常麻烦。函数返回类型变长了,错误需要转换,还会到处看到 ?。
但是,随着项目逐渐变大,这些类型会告诉我们:哪些函数可能失败、会因为什么失败;配合 must_use 警告,还能更早暴露被忽略的错误结果。
原本隐藏在异常通道中的可恢复失败,被 Rust 放到了函数签名中。
这就是 Rust 处理异常的方式:它没有提供传统异常,而是使用 Result 处理可以恢复的错误,使用 panic 暴露已经无法可靠继续的状态。
通常情况下,程序正常运行时可能遇到的失败,更适合使用 Result。
Very Rust, isn't it?
参考资料
- The Rust Programming Language:Error Handling
- The Rust Programming Language:Recoverable Errors with Result
- The Rust Programming Language:To panic! or Not to panic!
- The Rust Reference:Panic
- Kotlin Documentation:Exception and error handling
- Kotlin Standard Library:Result
- Kotlin Standard Library:runCatching
- Kotlin Coroutines:Cancellation and timeouts