Rust 错误处理

2024-05-08 15:25:29 浏览数 (1)

我知道只要活得足够久,这种事就一定会发生。 ——萧伯纳论死亡

Rust 的错误处理方法很不寻常,值得用单独的一章来讨论。这里没有什么深奥的编程思想,只是对你来说可能有点儿新而已。本章介绍了 Rust 中的两类错误处理:panic 和 Result

普通错误使用 Result 类型来处理。Result 通常用以表示由程序外部的事物引发的错误,比如错误的输入、网络中断或权限问题。这些问题并不是意料之中的,在没有任何 bug 的程序中也可能会不时出现。虽然本章大部分内容是关于 Result 的,但我们会先介绍 panic,因为它相对来说比较简单。

panic 针对的是另一种错误,即那种永远不应该发生的错误。1

1Rust 之所以会用一个新词(panic)而不是沿用“异常”来表达,是因为两者并不等价。——译者注

笔记 panic n. 恐慌

7.1 panic

当程序遇到下列问题的时候,就可以断定程序自身存在 bug,故而会引发 panic:

  • 数组越界访问;
  • 整数除以 0;
  • 在恰好为 ErrResult 上调用 .expect()
  • 断言失败。

panic!() 是一种宏,用于处理程序中出现错误的情况。当你的代码检测到出现错误并需要立即触发 panic 时,就可以使用这个宏。panic!() 可以接受类似于 println!() 的可选参数表,用于构建错误消息。)

以上情况的共同点是它们都由(不客气地说)程序员的错误所导致。而行之有效的一条经验法则是:“不要 panic”。

但每个人都会失误。如果这些不该发生的错误真的发生了,那么该怎么办呢?Rust 为你提供了一种选择。Rust 既可以在发生 panic 时展开调用栈,也可以中止进程。展开调用栈是默认方案。

7.1.1 展开调用栈

当海盗瓜分战利品时,船长会先分得一半,普通船员再对另一半进行平分。(众所周知,海盗讨厌小数,所以如果不能恰好平分,那么其结果将向下舍入,剩下的归船上的鹦鹉所有。)

笔记 海盗分金 - 经济学模型(baidu.com) 《海盗经济学》 - 知乎 (zhihu.com) 成为海盗 “所谓正当职业,带来的只有低下的地位、微薄的薪水与辛苦的劳作。” 海盗民主 “一个强大到可以约束自己的人,其实也强大到了可以在方便的时候打破所有约束。” 海盗宪章 “他们目无法纪,所以他们高度自律” 骷髅旗传递的信号 “挂上象征毁灭与死亡的骷髅旗,却保证了一场和平的掠抢。” 必要之恶 “快乐而短暂的人生是我的座右铭”

代码语言:javascript复制
fn pirate_share(total: u64, crew_size: usize) -> u64 {
    let half = total / 2;
    half / crew_size as u64
}

几个世纪以来,上述算法都能正常工作,直到有一天……船长成了抢劫后唯一的幸存者。如果将 0 作为 crew_size 传给此函数,那么它将除以 0。在 C 中,这将是未定义行为。而在 Rust 中,这会触发 panic,通常会按如下方式处理。

把一条错误消息打印到终端。

代码语言:javascript复制
thread 'main' panicked at 'attempt to divide by zero', pirates.rs:3780
note: Run with `RUST_BACKTRACE=1` for a backtrace.

如果设置了 RUST_BACKTRACE 环境变量,那么就像这条消息中建议的,Rust 也会在这里转储当前的调用栈。

展开调用栈。这很像 C 的异常处理。

当前函数使用的任何临时值、局部变量或参数都将按照与创建它们时相反的顺序被丢弃。丢弃一个值仅仅意味着随后会进行清理:程序正在使用的任何字符串或向量都将被释放,所有打开的文件都将被关闭,等等。还会调用由用户定义的 drop 方法,请参阅 13.1 节。就 pirate_share() 函数而言,没有需要清理的内容。

清理了当前函数调用后,我们将继续执行到其调用者中,以相同的方式丢弃其变量和参数。然后再“走到”那个调用者的调用者中,在调用栈中逐级向上,以此类推。

最后,线程退出。如果 panic 线程是主线程,则整个进程退出(使用非零退出码)。

也许把 panic 作为这个有序过程的名称有误导性。panic 不是崩溃,也不是未定义行为。它更像是 Java 中的 RuntimeException 或 C 中的 std::logic_error。其行为是明确定义的,只是本就不该发生罢了。

panic 是安全的,没有违反 Rust 的任何安全规则,即使你故意在标准库方法的中间引发 panic,它也永远不会在内存中留下悬空指针或半初始化的值。Rust 的设计理念是要在出现任何意外之前捕获诸如无效数组访问之类的错误。继续往下执行显然是不安全的,所以 Rust 会展开这个调用栈。但是进程的其余部分可以继续运行。

panic 是基于线程的。一个线程 panic 时,其他线程可以继续做自己的事。第 19 章会展示父线程如何发现子线程中的 panic 并优雅地处理错误。

还有一种方法可以捕获调用栈展开,让线程“存活”并继续运行。标准库函数 std::panic::catch_unwind() 可以做到这一点。本章不会介绍如何使用它,但这是 Rust 的测试工具用于在测试中断言失败时进行恢复的机制。(在编写可以从 C 或 C 调用的 Rust 代码时,这种机制是必需的,因为跨越非 Rust 代码展开调用栈是未定义行为,详情请参阅第 22 章。)

人无完人,没有 bug 且不会出现 panic 的代码只存在于理想之中。为了使程序更加健壮,可以使用线程和 catch_unwind() 来处理 panic。但有一个重要的限制,即这些工具只能捕获那些会展开调用栈的 panic。不过,并非所有 panic 都会展开调用栈。

7.1.2 中止

调用栈展开是默认的 panic 处理行为,但在两种情况下 Rust 不会试图展开调用栈。

如果 Rust 在试图清理第一个 panic 时,.drop() 方法触发了第二个 panic,那么这个 panic 就是致命的。Rust 会停止展开调用栈并中止整个进程。

此外,Rust 处理 panic 的行为是可定制的。如果使用 -C panic=abort 参数进行编译,那么程序中的第一个 panic 会立即中止进程。(如果使用这个选项,那么 Rust 就不需要知道如何展开调用栈,故此可以减小编译后的代码的大小。)

对 Rust 中 panic 机制的讨论到此结束。需要说的就这些,因为普通的 Rust 代码没有处理 panic 的义务。即使你确实使用了线程或 catch_unwind(),所有的 panic 处理代码也会集中在几个地方。期望程序中的每个函数都能预测并处理其自身代码中的 bug 是不合理的。但由其他因素引起的错误就是另一回事了。

7.2 Result

Rust 中没有异常。相反,函数执行失败时会有像下面这样的返回类型:

代码语言:javascript复制
fn get_weather(location: LatLng) -> Result<WeatherReport, io::Error>

Result 类型会指示出可能的失败。当我们调用 get_weather() 函数时,它要么返回一个成功结果 Ok(weather),其中的 weather 是一个新的 WeatherReport 值;要么返回一个错误结果 Err(error_value),其中的 error_value 是一个 io::Error,用来解释出了什么问题。

每当调用此函数时,Rust 都会要求我们编写某种错误处理代码。如果不对 Result 执行某些操作,就无法获取 WeatherReport;如果未使用 Result 值,就会收到编译器警告。

第 10 章将介绍标准库如何定义 Result 以及我们如何自定义出类似的类型。本章将采用类似“食谱”的方式并专注于使用 Result 来实现你期望的错误处理行为。你将了解如何捕获错误、传播错误和报告错误,以及关于组织和使用 Result 类型的常见模式。

7.2.1 捕获错误

第 2 章中已经展示过 Result 最彻底的处理方式:使用 match 表达式。

代码语言:javascript复制
match get_weather(hometown) {
    Ok(report) => {
        display_weather(hometown, &report);
    }
    Err(err) => {
        println!("error querying the weather: {}", err);
        schedule_weather_retry();
    }
}

这相当于其他语言中的 try/catch。如果想直接处理错误而不是将错误传给调用者,就可以使用这种方式。

match 有点儿冗长,因此 Result<T, E> 针对一些常见的特定场景提供了多个有用的方法,每个方法在其实现中都有一个 match 表达式。(有关 Result 方法的完整列表,请查阅在线文档。此处列出的是最常用的方法。)

result.is_ok()(已成功)和 result.is_err()(已出错)

返回一个 bool,告知此结果是成功了还是出错了。

result.ok()(成功值)

Option<T> 类型返回成功值(如果有的话)。如果 result 是成功的结果,就返回 Some(success_value);否则,返回 None,并丢弃错误值。

result.err()(错误值)

Option<E> 类型返回错误值(如果有的话)。

result.unwrap_or(fallback)(解包或回退值)

如果 result 为成功结果,就返回成功值;否则,返回 fallback,丢弃错误值。

代码语言:javascript复制
// 对南加州而言,这是一则十拿九稳的天气预报
const THE_USUAL: WeatherReport = WeatherReport::Sunny(72);

// 如果可能,就获取真实的天气预报;如果不行,就回退到常见状态
let report = get_weather(los_angeles).unwrap_or(THE_USUAL);
display_weather(los_angeles, &report);

这是 .ok() 的一个很好的替代方法,因为返回类型是 T,而不是 Option<T>。当然,只有存在合适的回退值时,才能用这个方法。

result.unwrap_or_else(fallback_fn)(解包,否则调用)

这个方法也一样,但不会直接传入回退值,而是传入一个函数或闭包。它针对的是大概率不会用到回退值且计算回退值会造成浪费的情况。只有在得到错误结果时才会调用 fallback_fn

代码语言:javascript复制
let report =
    get_weather(hometown)
    .unwrap_or_else(|_err| vague_prediction(hometown));

(第 14 章会详细介绍闭包。)

result.unwrap()(解包)

如果 result 是成功结果,那么此方法同样会返回成功值。但如果 result 是错误结果,则会引发 panic。此方法有其应用场景,后面会详细讨论。

result.expect(message)(期待)

.unwrap() 相同,但此方法允许你提供一条消息,在发生 panic 时会打印该消息。

最后是处理 Result 引用的两个方法。

result.as_ref()(转引用)

Result<T, E> 转换为 Result<&T, &E>

result.as_mut()(转可变引用)

与上一个方法一样,但它借入了一个可变引用,其返回类型是 Result<&mut T, &mut E>

最后这两个方法之所以有用,是因为前面列出的所有其他方法,除了 .is_ok().is_err(),都在消耗 result。也就是说,它们会按值接受 self 参数。有时在不破坏 result 的情况下访问 result 中的数据是非常方便的,这就是 .as_ref().as_mut() 的用武之地。假设你想调用 result.ok(),但要让 result 保持不可变状态,那么就可以写成 result.as_ref().ok(),它只会借用 result,返回 Option<&T> 而非 Option<T>

7.2.2 Result 类型别名

有时你会看到 Rust 文档中似乎忽略了 Result 中的错误类型:

代码语言:javascript复制
fn remove_file(path: &Path) -> Result<()>

这意味着正在使用 Result 的类型别名。

类型别名是类型名称的一种简写形式。模块通常会定义一个 Result 类型的别名,以免重复编写模块中几乎每个函数都要用到的 Error 类型。例如,标准库的 std::io 模块包括下面这行代码:

代码语言:javascript复制
pub type Result<T> = result::Result<T, Error>;

这定义了一个公共类型 std::io::Result<T>,它是 Result<T, E> 的别名,但将错误类型硬编码为 std::io::Error。实际上,这意味着如果你写下 use std::io;,那么 Rust 就会将 io::Result<String> 当作 Result<String, io::Error> 的简写形式。

当在线文档中出现类似 Result<()> 的内容时,可以单击标识符 Result 以查看正在使用的类型别名并了解其错误类型。实践中,错误类型在上下文中通常是显而易见的。

7.2.3 打印错误

有时处理错误的唯一方法是将其转储到终端并继续执行。前面已经展示过这样处理的一种方法:

代码语言:javascript复制
println!("error querying the weather: {}", err);

标准库定义了几种名称平平无奇的错误类型:std::io::Errorstd::fmt::Errorstd::str::Utf8Error 等。它们都实现了一个公共接口,即 std::error::Error 特型,这意味着它们都有以下特性和方法。

println!()(打印)

所有错误类型都可以通过 println!() 打印出来。使用格式说明符 {} 打印错误通常只会显示一条简短的错误消息。或者,也可以使用格式说明符 {:?},以获得该错误的 Debug 视图。虽然这对用户不太友好,但包含额外的技术信息。

代码语言:javascript复制
// `println!("error: {}", err);`的结果
error: failed to look up address information: No address associated with
hostname

// `println!("error: {:?}", err);`的结果
error: Error { repr: Custom(Custom { kind: Other, error: StringError(
"failed to look up address information: No address associated with
hostname") }) }

err.to_string()(转字符串)

String 形式返回错误消息。

err.source()(错误来源)

返回导致 err 的底层错误的 Option(如果有的话)。例如,网络错误可能导致银行交易失败,进而导致你的游艇被收回。如果 err.to_string()"boat was repossessed",那么 err.source() 可能会返回关于本次交易失败的错误。该错误的 .to_string() 可能是 "failed to transfer $300 to United Yacht Supply",而该错误的 .source() 可能是一个 io::Error(第二个错误),其中包含导致这一切乱象的特定网络中断的详细信息。第三个错误是根本原因,因此它的 .source() 方法应该返回 None。由于标准库仅包含相当底层的特性,因此从标准库返回的错误来源(.source())通常都是 None

打印一个错误值并不会打印出其来源。如果想确保打印所有可用信息,请使用下面这个函数:

代码语言:javascript复制
use std::error::Error;
use std::io::{Write, stderr};

/// 把错误消息转储到`stderr`
///
/// 如果在构建此错误消息或将其写入`stderr`期间发生了另一个错误,就忽略新的错误
fn print_error(mut err: &dyn Error) {
    let _ = writeln!(stderr(), "error: {}", err);
    while let Some(source) = err.source() {
        let _ = writeln!(stderr(), "caused by: {}", source);
        err = source;
    }
}

writeln! 宏类似于 println!,但它会将数据写入所选的流。在这里,我们将错误消息写入了标准错误流 std::io::stderr。可以使用 eprintln! 宏做同样的事情,但是如果 eprintln! 中发生了错误,就会 panic。在 print_error 中,要忽略在写入消息时出现的错误,稍后 7.2.7 节会解释原因。

标准库的这些错误类型不包括调用栈跟踪,但是,当与不稳定版本的 Rust 编译器一起使用时,可以使用广受欢迎的 anyhow crate 提供的一个现成的错误类型。(直到 Rust 1.50 为止,标准库中用于捕获回溯跟踪的函数尚未稳定。)

7.2.4 传播错误

大多数时候,当我们试图做某些可能失败的事情时,可能不想立即捕获并处理错误。如果在每个可能出错的地方都要使用十来行 match 语句,那代码就太多了。

因此,当发生某种错误时,我们通常希望让调用者去处理。也就是说,我们希望错误沿着调用栈向上传播

Rust 的 ? 运算符可以执行此操作。可以为任何生成 Result 的表达式加上一个 ?,比如将其加在函数调用的结果后面:

代码语言:javascript复制
let weather = get_weather(hometown)?;

? 的行为取决于此函数是返回了成功结果还是错误结果。

  • 如果是成功结果,那么它会解包 Result 以获取其中的成功值。这里的 weather 类型不是 Result<WeatherReport, io::Error>,而是简单的 WeatherReport
  • 如果是错误结果,那么它会立即从所在函数返回,将错误结果沿着调用链向上传播。为了确保此操作有效,? 只能在返回类型为 Result 的函数中的 Result 值上使用。

? 运算符并无任何神奇之处。可以使用 match 表达式来表达同样的意图,只是更冗长:

代码语言:javascript复制
let weather = match get_weather(hometown) {
    Ok(success_value) => success_value,
    Err(err) => return Err(err)
};

? 运算符与 match 表达式唯一的区别在于,它有一些涉及类型和转换的知识点。7.2.5 节会介绍这些细节。

笔记 语法糖,能够使代码更简洁,项目工程代码中会比较常见

在旧式代码中,你可能还会看到 try!() 宏,在 Rust 1.13 引入 ? 运算符之前,这是传播错误的常用方法:

代码语言:javascript复制
let weather = try!(get_weather(hometown));

此宏会扩展为一个 match 表达式,就像之前那段代码一样。

人们很难意识到在程序中出现错误的情况有多普遍,尤其是在与操作系统交互的代码中。? 运算符有时几乎会出现在函数的每一行中:

代码语言:javascript复制
use std::fs;
use std::io;
use std::path::Path;

fn move_all(src: &Path, dst: &Path) -> io::Result<()> {
    for entry_result in src.read_dir()? {  // 打开目录可能失败
        let entry = entry_result?;         // 读取目录可能失败
        let dst_file = dst.join(entry.file_name());
        fs::rename(entry.path(), dst_file)?;  // 重命名可能失败
    }
    Ok(())  // 哦……总算结束了!
}

? 的作用也与 Option 类型相似。在返回 Option 类型的函数中,也可以使用 ? 解包某个值,这样当遇到 None 时就会提前返回。

代码语言:javascript复制
let weather = get_weather(hometown).ok()?;

7.2.5 处理多种 Error 类型

通常,不止一个操作会出错。假设我们只想从文本文件中读取数值:

代码语言:javascript复制
use std::io::{self, BufRead};

/// 从文本文件中读取整数
/// 该文件中应该每行各有一个数值
fn read_numbers(file: &mut dyn BufRead) -> Result<Vec<i64>, io::Error> {
    let mut numbers = vec![];
    for line_result in file.lines() {
        let line = line_result?;         // 读取各行可能失败
        numbers.push(line.parse()?);     // 解析整数可能失败
    }
    Ok(numbers)
}

Rust 会报告一个编译器错误:

代码语言:javascript复制
error: `?` couldn't convert the error to `std::io::Error`

  numbers.push(line.parse()?);     // 解析整数可能失败
                           ^
            the trait `std::convert::From<std::num::ParseIntError>`
            is not implemented for `std::io::Error`

note: the question mark operation (`?`) implicitly performs a conversion
on the error value using the `From` trait

当我们读到第 11 章(介绍了相关特型)时,本错误消息中的这些术语会更有意义。现在,只需要注意 Rust 正在报错说 ? 运算符不能将 std::num::ParseIntError 值转换为 std::io::Error 类型就可以了。

这里的问题在于从文件中读取一行并解析一个整数时会生成两种潜在错误类型。line_result 的类型是 Result<String, std::io::Error>line.parse() 的类型是 Result<i64, std::num::ParseIntError>。而我们的 read_numbers() 函数的返回类型只能容纳 io::Error。Rust 试图将 ParseIntError 转换为 io::Error,但是无法进行这样的转换,所以我们得到了一个类型错误。

有几种方法可以解决这个问题。例如,第 2 章中用于创建曼德博集图像文件的 image crate 定义了自己的错误类型 ImageError,并实现了从 io::Error 和其他几种错误类型到 ImageError 的转换。如果你想采用这种方法,那么可以试试 thiserror crate,它旨在帮助你用几行代码就定义出良好的错误类型。

还有一种更简单的方法是使用 Rust 中内置的特性。所有标准库中的错误类型都可以转换为类型 Box<dyn std::error::Error Send Sync 'static>。这虽然有点儿啰唆,不过也不算难:dyn std::error::Error 表示“任何错误”,Send Sync 'static 表示可以安全地在线程之间传递,而这往往是我们的要求。2为便于使用,还可以定义类型别名:

2还应该考虑使用比较受欢迎的 anyhow crate,它提供的错误类型和结果类型与 GenericErrorGenericResult 非常相似,但有一些不错的附加特性。

代码语言:javascript复制
type GenericError = Box<dyn std::error::Error   Send   Sync   'static>;
type GenericResult<T> = Result<T, GenericError>;

然后,将 read_numbers() 的返回类型改为 GenericResult<Vec<i64>>。这样一来,函数就可以编译了。? 运算符会根据需要自动将任意类型的错误转换为 GenericError

顺便说一句,? 运算符使用了一种标准方法进行这种自动转换。你也可以使用这种方法将任何错误转换为 GenericError 类型,为此,可以调用 GenericError::from()

代码语言:javascript复制
let io_error = io::Error::new(         // 制作自己的io::Error
    io::ErrorKind::Other, "timed out");
return Err(GenericError::from(io_error));  // 手动转换成GenericError

第 13 章会全面介绍 From 特型及其 from() 方法。

GenericError 方法的缺点是返回类型不再准确地传达调用者可预期的错误类型。调用者必须做好应对任何情况的准备。

如果你正在调用一个返回 GenericResult 的函数,并且想要处理一种特定类型的错误,而让所有其他错误传播出去,那么可以使用泛型方法 error.downcast_ref::<ErrorType>()如果这个错误恰好是你要找的那种类型的错误,那么该方法就会借用对它的引用:

代码语言:javascript复制
loop {
    match compile_project() {
        Ok(()) => return Ok(()),
        Err(err) => {
            if let Some(mse) = err.downcast_ref::<MissingSemicolonError>() {
                insert_semicolon_in_source_code(mse.file(), mse.line())?;
                continue;  // 再试一次!
            }
            return Err(err);
        }
    }
}

许多语言提供了内置语法来执行此操作,但事实证明没什么必要。Rust 可以用 downcast_ref 方法代替它。

7.2.6 处理“不可能发生”的错误

有时我们明确知道某个错误不可能发生。假设我们正在编写代码来解析配置文件,并且确信文件中接下来的内容肯定是一串数字:

代码语言:javascript复制
if next_char.is_digit(10) {
    let start = current_index;
    current_index = skip_digits(&line, current_index);
    let digits = &line[start..current_index];
    ...

我们想将这个数字串转换为实际的数值。有一个标准方法可以做到这一点:

代码语言:javascript复制
let num = digits.parse::<u64>();

现在的问题是:str.parse::<u64>() 方法不返回 u64,而是返回了一个 Result。转换可能会失败,因为某些字符串不是数值:

代码语言:javascript复制
"bleen".parse::<u64>()  // ParseIntError: 无效的数字

但我们碰巧知道,在这种情况下,digits 一定完全由数字组成。那么应该怎么办呢?

如果我们正在编写的代码已经返回了 GenericResult,那么就可以添加一个 ?,并且忽略这个错误。否则,我们将不得不为处理不可能发生的错误而烦恼。最好的选择是使用 Result.unwrap() 方法。如果结果是 Err,就会 panic;但如果成功了,则会直接返回 Ok 中的成功值:

代码语言:javascript复制
let num = digits.parse::<u64>().unwrap();

这和 ? 的用法很相似,但如果我们对这个错误有没有可能发生的理解是错误的,也就是说如果它其实有可能发生,那么这种情况就会报 panic。

事实上,对于刚才这个例子,我们确实理解错了。如果输入中包含足够长的数字串,则这个数值会因为太大而无法放入 u64 中:

代码语言:javascript复制
"99999999999999999999".parse::<u64>()     // 溢出错误

因此,在这种特殊情况下使用 .unwrap() 存在 bug。这种有 bug 的输入本不应该引发 panic。

话又说回来,确实会出现 Result 值不可能是错误的情况。例如,在第 18 章中,你会看到 Write 特型为文本和二进制输出定义了一组泛型方法(.write() 等)。所有这些方法都会返回 io::Result,但如果你碰巧正在写入 Vec<u8>,那么它们就不可能失败。在这种情况下,可以使用 .unwrap().expect(message) 来简化 Result 的处理。

当错误表明情况相当严重或异乎寻常,理当用 panic 对它进行处理时,这些方法也很有用:

代码语言:javascript复制
fn print_file_age(filename: &Path, last_modified: SystemTime) {
    let age = last_modified.elapsed().expect("system clock drift");
    ...
}

在这里,仅当系统时间早于文件创建时间时,.elapsed() 方法才会失败。如果文件是最近创建的,并且在程序运行期间系统时钟往回调整过,就会发生这种情况。根据这段代码的使用方式,在这种情况下,调用 panic 是一个合理的选择,而不必处理该错误或将该错误传播给调用者。

7.2.7 忽略错误

有时我们只想完全忽略一个错误。例如,在 print_error() 函数中,我们必须处理打印一个错误时会触发另一个错误等罕见情况。如果将 stderr 通过管道传给另一个进程,而那个进程已终止,就可能发生这种情况。因为要报告的原始错误可能更值得传播,所以我们只想忽略 stderr 带来的这些小麻烦,但 Rust 编译器会警告你有未使用的 Result 值:

代码语言:javascript复制
writeln!(stderr(), "error: {}", err);  // 警告:未使用的结果

惯用法 let _ = ... 可用来消除这种警告。

代码语言:javascript复制
let _ = writeln!(stderr(), "error: {}", err);  // 正确,忽略结果

笔记 let _ = ... 消除警告,解决编译器报警问题

7.2.8 处理 main() 中的错误

在大多数生成 Result 的地方,让错误冒泡到调用者通常是正确的行为。这就是为什么 ? 在 Rust 中会设计成单字符语法。正如我们所见,在某些程序中,它曾连续用于多行代码。

但是,如果你传播错误的距离足够远,那么最终它就会抵达 main(),后者必须对其进行处理。通常来说,main() 不能使用 ?,因为它的返回类型不是 Result

代码语言:javascript复制
fn main() {
    calculate_tides()?;  // 错误:main()无法再推卸责任了
}

处理 main() 中错误的最简单方式是使用 .expect()

代码语言:javascript复制
fn main() {
    calculate_tides().expect("error");  // 责任止于此
}

如果 calculate_tides() 返回错误结果,那么 .expect() 方法就会 panic。主线程中的 panic 会打印出一条错误消息,然后以非零的退出码退出,大体上,这就是我们期望的行为。在一般的小型程序中我们都是这样做的。这是一个开始。

不过,错误消息有点儿吓人:

代码语言:javascript复制
$ tidecalc --planet mercury
thread 'main' panicked at 'error: "moon not found"', src/main.rs:2:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

错误消息淹没在噪声中。此外,在这种特殊情况下,RUST_BACKTRACE=1 是一个糟糕的建议。

但是,也可以更改 main() 的类型签名以返回 Result 类型,这样就可以使用 ? 了 :

代码语言:javascript复制
fn main() -> Result<(), TideCalcError> {
    let tides = calculate_tides()?;
    print_tides(tides);
    Ok(())
}

这适用于任何能用 {:?} 格式说明符打印的错误类型,也就是说,所有标准错误类型(如 std::io::Error)都适用。这种技巧易于使用,并能提供更好的错误消息,但它并不理想:

代码语言:javascript复制
$ tidecalc --planet mercury
Error: TideCalcError { error_type: NoMoon, message: "moon not found" }

如果你有更复杂的错误类型或想要在消息中包含更多信息,那么可以自己打印错误消息:

代码语言:javascript复制
fn main() {
    if let Err(err) = calculate_tides() {
        print_error(&err);
        std::process::exit(1);
    }
}

上述代码使用了 if let 表达式,以便仅在对 calculate_tides() 的调用返回错误结果时才打印错误消息。有关 if let 表达式的详细信息,请参阅第 10 章。print_error 函数在 7.2.3 节中介绍过。

现在的输出就相当整洁了。

代码语言:javascript复制
$ tidecalc --planet mercury
error: moon not found

7.2.9 声明自定义错误类型

假设你正在编写一个新的 JSON 解析器,并且希望它有自己的错误类型。(到目前为止,本书尚未介绍用户定义类型,后面我们将用几章的篇幅进行介绍。声明错误类型很简单,我们先在此处大概预览一下。)

笔记 Rust生态中流行的开源 JSON 解析器

  1. serde_json 是基于 Serde 框架的 JSON 序列化和反序列化库。Serde 是 Rust 中一个高效、通用的序列化框架,serde_json 利用 Serde 提供了对 JSON 数据的强大支持。它既可以处理简单的 JSON 数据,也可以处理复杂的嵌套结构,并且速度非常快。
  2. json-rust 是完全用 Rust 编写的 JSON 解析器,不依赖于 Serde。它的设计目标是简单、安全和高效。json-rust 直接操作 Rust 的数据结构,提供了一套简洁的 API 来处理 JSON 数据。
  3. simd-json 是一个利用 SIMD 指令集加速 JSON 处理的库。它兼容 serde_json 的 API,但在支持 SIMD 的硬件上可以提供更快的数据处理速度。适用于需要处理大量 JSON 数据的场景。

要编写的代码大概也就下面几行:

代码语言:javascript复制
// json/src/error.rs

#[derive(Debug, Clone)]
pub struct JsonError {
    pub message: String,
    pub line: usize,
    pub column: usize,
}

这个结构体叫作 json::error::JsonError。当你想引发这种类型的错误时,可以像下面这样写:

代码语言:javascript复制
return Err(JsonError {
    message: "expected ']' at end of array".to_string(),
    line: current_line,
    column: current_column
});

这没什么问题。但是,如果你希望达到你的库用户的预期,确保这个错误类型像标准错误类型一样工作,那么还有一点儿额外的工作要做:

代码语言:javascript复制
use std::fmt;

// 错误应该是可打印的
impl fmt::Display for JsonError {
    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        write!(f, "{} ({}:{})", self.message, self.line, self.column)
    }
}

// 错误应该实现std::error::Error特型,但使用Error各个方法的默认定义就够了
impl std::error::Error for JsonError { }

impl 关键字、self 和其他所有关键字的含义在后面的几章中会进行解释。

与 Rust 语言的许多方面一样,各种 crate 的存在是为了让错误处理更容易、更简洁。crate 种类繁多,但最常用的一个是 thiserror,它会帮你完成之前的所有工作,让你像下面这样编写错误定义:

代码语言:javascript复制
use thiserror::Error;

#[derive(Error, Debug)]
#[error("{message:} ({line:}, {column})")]
pub struct JsonError {
    message: String,
    line: usize,
    column: usize,
}

#[derive(Error)] 指令会让 thiserror 生成前面展示过的代码,这可以节省大量的时间和精力。

7.2.10 为什么是 Result

现在我们已经足够了解为何 Rust 会优先选择 Result 而非异常了。以下是此设计的几个要点。

  • Rust 要求程序员在每个可能发生错误的地方做出某种决策,并将其记录在代码中。这样做很好,否则容易因为疏忽而无法正确处理错误。
  • 最常见的决策是让错误继续传播,而这用单个字符 ? 就可以实现。因此,错误处理管道不会像在 C 和 Go 中那样让你的代码混乱不堪,而且它还具有可见性:在浏览一段代码时,你一眼就能看出错误是从哪里传出来的。
  • 是否可能出错是每个函数的返回类型的一部分,因此哪些函数会失败、哪些不会失败非常清晰。如果你将一个函数改为可能出错的,那么就要同时更改它的返回类型,而编译器会让你随之修改该函数的各个下游使用者。
  • Rust 会检查 Result 值是否被用过了,这样你就不会意外地让错误悄悄溜过去(C 中的常见失误)。
  • 由于 Result 是一种与任何其他数据类型没有本质区别的数据类型,因此很容易将成功结果和错误结果存储在同一个集合中,也很容易对“部分成功”的情况进行模拟。如果你正在编写一个从文本文件加载数百万条记录的程序,并且需要一种方法来处理大多数时候会成功但偶尔也会失败的可能结果,就可以用向量 Result 在内存中表达出现这种结果时的情形。

这样设计的代价是,你会发现自己在 Rust 中要比在其他语言中做更多的思考和工程化的错误处理。与许多其他领域一样,Rust 在错误处理方面比你所习惯的要严格一些。但对系统编程来说,这绝对是值得的。

笔记 相对严格,但更值得

欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗 ^_^

0 人点赞