Rust 是如何处理异常的

前段时间,我写过一篇文章,主要讨论 Rust 是如何处理空指针的。

继续学习后,我发现 Rust 的错误处理同样很有意思。

现代编程语言一直在演化。同样的老问题交给不同时代的设计者,往往会得到完全不同的答案。

这不是在评判谁好谁坏,每一门语言在它诞生的那个年代,都只是给出了那个时代认为最合适的回答。理解这些回答之间的差别,其实也是在看一段软件工程慢慢演化的过程。

废话少说,进入正文。

在 Kotlin 中,读取一个文本文件,我们一般会这样写:

kotlin 复制代码
import java.io.File
import java.io.IOException

fun readConfig(path: String): String {
    return File(path).readText()
}

代码很简单,传入文件路径,返回文件内容。

但是,如果文件不存在呢?

File.readText() 会抛出异常。

此时,我们有两个选择:

  1. 不在当前层处理,让异常继续向上传播。
  2. 主动处理异常,比如返回一个默认值。

如果要处理异常,在 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 倾向于把它建模为 Resultfs::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>

错误虽然传给了调用者,但是没有从类型中消失。

多种错误

下面再看一个真正读取端口的例子。

这个操作可能出现两种错误:

  1. 配置文件读取失败;
  2. 文件内容无法转换成 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 也会提醒我们补上对应的处理逻辑。

和使用一段错误字符串相比,这种写法更加可靠。

调用者处理的是 IoInvalidPort 两种类型,而不是判断错误消息中是否包含某几个单词。

当然,实际项目不一定每个函数都要定义一个错误枚举。应用层经常会把多种错误统一起来,底层库则更适合保留具体的错误类型。

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?

参考资料

相关推荐
冬日砥砺前行1 小时前
Kotlin 协程:几个曾经被我误解的点
kotlin
铅笔侠_小龙虾3 小时前
Rust 学习(8)-切片类型
学习·rust·c#
程序员爱钓鱼6 小时前
Rust 函数与返回值详解:参数、表达式与返回类型
后端·rust
花褪残红青杏小12 小时前
Rust图像处理第17节-Julia 朱利亚分形:拖动常数 c 看图实时变化
rust·webassembly·图形学
An_s17 小时前
rust开发wasm与浏览器(js)交互
javascript·rust·wasm
Mr -老鬼19 小时前
Zed WASM扩展开发全套踩坑记录(WASI适配、LSP插件开发避坑指南)
rust·wasm·lsp·zed·ai ide·语言服务器
糯米导航20 小时前
Rust + ONNX Runtime 构建生产级 AI 推理服务:从零到压测
开发语言·人工智能·rust
Arman37681 天前
rusty-cat 分片并发上传完全指南
rust·vibecoding
独孤留白1 天前
从C到Rust:mut 可变性,与恶魔战斗的利剑
rust