CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 第1章 系统程序员也能享受美好事物
  • 第2章 Rust概览
  • 第3章 基本类型
  • 第4章 所有权与移动
  • 第5章 引用
  • 第6章 表达式
  • 第7章 错误处理
    • panic
      • 展开栈
      • 终止
    • Result
      • 捕获错误
      • Result类型别名
      • 打印错误
      • 传播错误
      • 处理多种错误类型
      • 处理 “不可能发生” 的错误
      • 忽略错误
      • 在main()函数中处理错误
      • 声明自定义错误类型
      • 为什么选择Result?
  • 第8章 包和模块
  • 第9章 结构体
  • 第10章 枚举和模式
  • 第11章 特性与泛型
  • 第12章 运算符重载
  • 第13章 实用特性
  • 第14章 闭包
  • 第15章 迭代器
  • 第16章 集合
  • 第17章 字符串和文本
  • 第18章 输入与输出
  • 第19章 并发
  • 第20章 异步编程
  • Rust编程指南
zhangxf
2025-03-10
目录

第7章 错误处理

# 第7章 错误处理

我就知道,只要活得够久,总会遇到这种事。 ——乔治·伯纳德·肖(George Bernard Shaw)谈死亡

Rust处理错误的方式非常独特,值得用一小章的篇幅来专门讨论。这里面没有什么难懂的概念,只是有些对你来说可能比较新颖。本章将介绍Rust中两种不同的错误处理方式:panic和Result。

普通错误使用Result类型处理。Result通常表示由程序外部因素导致的问题,比如错误的输入、网络中断或权限问题。这些情况的发生不是我们能控制的;即使是一个没有漏洞的程序,也时不时会遇到它们。本章的大部分内容都围绕这类错误展开。不过,我们先介绍panic,因为它相对简单。

panic用于处理另一种错误,那种本不应发生的错误。

# panic

当程序遇到严重问题,以至于可以肯定是程序自身存在漏洞时,就会发生panic。比如:

  • 数组越界访问
  • 整数除以零
  • 对恰好是Err的Result调用.expect()
  • 断言失败

(还有panic!()宏,用于在你自己的代码发现出错,需要直接触发panic的情况。panic!()接受可选的类似println!()的参数,用于构建错误消息。)

这些情况的共同点是——直白地说——都是程序员的失误。一条不错的经验法则是:“别轻易panic”。

但我们都会犯错。当这些本不该发生的错误真的发生时,该怎么办呢?值得注意的是,Rust给了你选择。发生panic时,Rust可以选择展开(unwind)栈,或者终止进程。默认情况下是展开栈。

# 展开栈

当海盗们瓜分一次袭击所得的战利品时,船长会拿走一半,普通船员们则平分另一半(海盗们讨厌分数,所以如果任何一次分配不能整除,结果就向下取整,余数归船上的鹦鹉)。

fn pirate_share(total: u64, crew_size: usize) -> u64 {
    let half = total / 2;
    half / crew_size as u64
}
1
2
3
4

这个函数可能几个世纪都运行良好,直到有一天,某次袭击后船长成了唯一的幸存者。如果我们给这个函数传入crew_size为零,它就会执行除以零的操作。在C++中,这属于未定义行为。在Rust中,这会触发panic,通常会按如下步骤进行:

  • 一条错误消息会被打印到终端:
thread 'main' panicked at 'attempt to divide by zero', pirates.rs:3780
note: Run with `RUST_BACKTRACE=1` for a backtrace.
1
2

如果你按照消息提示设置RUST_BACKTRACE环境变量,Rust还会在此时输出栈信息。

  • 栈被展开。这和C++的异常处理很像。 当前函数使用的任何临时值、局部变量或参数都会被按创建顺序的相反顺序丢弃。 丢弃一个值仅仅意味着对其进行清理:程序使用的任何String或Vec会被释放,任何打开的File会被关闭等等。用户自定义的drop方法也会被调用;详见 “Drop”。就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章。)

理想情况下,我们的代码都没有漏洞,永远不会发生panic。但人无完人。你可以使用线程和catch_unwind()来处理panic,让你的程序更健壮。需要注意的是,这些工具只能捕获导致栈展开的panic,并非所有的panic都以这种方式进行。

# 终止

展开栈是panic的默认行为,但在两种情况下,Rust不会尝试展开栈。

如果在Rust还在清理第一次panic产生的影响时,某个.drop()方法又触发了第二次panic,这将被视为致命错误。Rust会停止展开栈,并终止整个进程。

此外,Rust的panic行为是可定制的。如果你使用-C panic=abort编译,程序中的第一次panic会立即终止进程。(使用这个选项时,Rust不需要知道如何展开栈,所以这可以减小编译后代码的大小。)

关于Rust中的panic,我们就讨论到这里。没什么太多可说的,因为普通的Rust代码没有义务处理panic。即使你使用了线程或catch_unwind(),所有的panic处理代码可能也只会集中在少数几个地方。期望程序中的每个函数都能预料并处理自身代码中的漏洞是不合理的。由其他因素导致的错误则是另一回事。

# Result

Rust没有异常机制。相反,可能失败的函数会在返回类型中体现这一点:

fn get_weather(location: LatLng) -> Result<WeatherReport, io::Error>
1

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

Rust要求我们在调用这个函数时,必须编写某种错误处理代码。如果不对Result做些处理,我们就无法获取WeatherReport,而且如果Result值未被使用,编译器会发出警告。

在第10章,我们会了解标准库是如何定义Result的,以及你如何定义自己类似的类型。目前,我们采用 “菜谱式” 的方法,重点关注如何使用Result来实现你想要的错误处理行为。

我们将探讨如何捕获、传播和报告错误,以及组织和使用Result类型的常见模式。

# 捕获错误

处理Result最全面的方式,就是我们在第2章展示的:使用match表达式。

match get_weather(hometown) {
    Ok(report) => {
        display_weather(hometown, &report);
    }
    Err(err) => {
        println!("error querying the weather: {}", err);
        schedule_weather_retry();
    }
}
1
2
3
4
5
6
7
8
9

这在Rust中相当于其他语言里的try/catch。当你想要直接处理错误,而不是把它们传递给调用者时,就可以使用这种方式。

match有点冗长,所以Result<T, E>提供了多种在特定常见场景下很有用的方法。这些方法的实现中都有一个match表达式。(完整的Result方法列表,请查阅在线文档。这里列出的是我们最常用的方法。)

  • result.is_ok(),result.is_err():返回一个bool值,表明result是成功结果还是错误结果。
  • result.ok():如果result是成功结果,将成功值作为Option<T>返回。如果result是成功结果,返回Some(success_value);否则返回None,并丢弃错误值。
  • result.err():如果result是错误结果,将错误值作为Option<E>返回。
  • result.unwrap_or(fallback):如果result是成功结果,返回成功值。否则,返回fallback,并丢弃错误值。
// 对南加州比较靠谱的天气预测
const THE_USUAL: WeatherReport = WeatherReport::Sunny(72);

// 尽可能获取真实的天气预报。如果获取失败,就使用通常的预测。
let report = get_weather(los_angeles).unwrap_or(THE_USUAL);
display_weather(los_angeles, &report);
1
2
3
4
5
6

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

  • result.unwrap_or_else(fallback_fn):和unwrap_or类似,但不是直接传递回退值,而是传递一个函数或闭包。这适用于如果不需要使用回退值,计算它会很浪费资源的情况。只有当result是错误结果时,才会调用fallback_fn。
let report =
    get_weather(hometown)
        .unwrap_or_else(|_err| vague_prediction(hometown));
1
2
3

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

  • result.unwrap():如果result是成功结果,也返回成功值。然而,如果result是错误结果,这个方法会触发panic。这个方法有它的用处,我们稍后会详细讨论。
  • result.expect(message):和.unwrap()一样,但你可以提供一条消息,在触发panic时打印。
  • 最后,还有用于处理Result中引用的方法:
    • result.as_ref():将Result<T, E>转换为Result<&T, &E>。
    • result.as_mut():和as_ref类似,但借用可变引用,返回类型是Result<&mut T, &mut E>。

最后这两个方法有用的一个原因是,除了.is_ok()和.is_err(),这里列出的其他方法都会消耗它们操作的result,也就是说,它们按值接受self参数。有时,在不销毁result的情况下访问其中的数据会非常方便,这就是.as_ref()和.as_mut()为我们做的事情。例如,假设你想调用result.ok(),但又需要保持result完好无损。你可以写result.as_ref().ok(),这只会借用result,返回Option<&T>而不是Option<T>。

# Result类型别名

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

fn remove_file(path: &Path) -> Result<>
1

这意味着使用了Result类型别名。

类型别名是类型名称的一种简写形式。模块通常会定义一个Result类型别名,以避免重复编写模块中几乎每个函数都一致使用的错误类型。例如,标准库的std::io模块包含这样一行代码:

pub type Result<T> = result::Result<T, Error>;
1

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

当类似Result<()>这样的内容出现在在线文档中时,你可以点击Result标识符,查看使用了哪个类型别名,并了解错误类型。实际上,通常从上下文就能明显看出。

# 打印错误

有时,处理错误的唯一方法就是将其输出到终端,然后继续执行。我们已经展示了一种方法:

println!("error querying the weather: {}", err);
1

标准库定义了几个名字普通的错误类型:std::io::Error、std::fmt::Error、std::str::Utf8Error等等。它们都实现了一个公共接口std::error::Error特性(trait),这意味着它们共享以下特性和方法:

  • println!():所有错误类型都可以用这个宏打印。使用{}格式说明符打印错误时,通常只会显示简短的错误消息。或者,你可以使用{:?}格式说明符,获取错误的调试视图。这样不太友好,但包含了额外的技术信息。
// result of `println!("error: {}", err);`
error: failed to lookup address information: No address associated with hostname
// result of `println!("error: {:?}", err);`
error: Error { repr: Custom(Custom { kind: Other, error: StringError(
"failed to lookup address information: No address associated with hostname") }) }
1
2
3
4
5
  • 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。

打印错误值时,不会同时打印出它的source。如果你想确保打印出所有可用信息,可以使用这个函数:

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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

writeln!宏的工作方式和println!类似,只是它将数据写入你选择的流。这里,我们将错误消息写入标准错误流std::io::stderr。我们也可以使用eprintln!宏来做同样的事情,但如果发生错误,eprintln!会触发panic。在print_error中,我们希望忽略写入消息时出现的错误;在本章后面的 “忽略错误” 部分,我们会解释原因。

标准库的错误类型不包含栈跟踪信息,但流行的anyhow库提供了一个现成的错误类型,在与不稳定版本的Rust编译器一起使用时,它包含栈跟踪信息。(截至Rust 1.50,标准库中用于捕获栈跟踪的函数还未稳定。)

# 传播错误

在大多数可能出现失败的地方,我们并不想立即捕获并处理错误。毕竟,在每个可能出错的地方都使用一个10行的match语句,代码量实在太大了。

相反,如果发生错误,我们通常希望让调用者来处理它。我们希望错误沿着调用栈向上传播。

Rust有一个?运算符可以实现这一点。你可以在任何产生Result的表达式(比如函数调用的结果)后面加上?:

let weather = get_weather(hometown)?;
1

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

  • 如果是成功结果,它会解包Result,获取里面的成功值。这里weather的类型不是Result<WeatherReport, io::Error>,而是简单的WeatherReport。
  • 如果是错误结果,它会立即从包含它的函数返回,将错误结果沿着调用链向上传递。为了确保这能正常工作,?只能用在返回类型为Result的函数中的Result值上。

?运算符并没有什么神奇之处。你可以用match表达式表达同样的意思,只是会更冗长:

let weather = match get_weather(hometown) {
    Ok(success_value) => success_value,
    Err(err) => return Err(err)
};
1
2
3
4

这段代码和?运算符之间的唯一区别在于一些涉及类型和转换的细节。我们将在下一节介绍这些细节。

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

let weather = try!(get_weather(hometown));
1

这个宏会展开成一个match表达式,和前面的类似。

很容易忽略程序中错误发生的可能性有多大,尤其是在与操作系统交互的代码中。?运算符有时几乎会出现在函数的每一行:

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(()) // 终于完成!
}
1
2
3
4
5
6
7
8
9
10
11
12

?对Option类型也有类似的作用。在返回Option的函数中,你可以使用?来解包值,如果是None则提前返回:

let weather = get_weather(hometown).ok()?;
1

# 处理多种错误类型

通常,可能出错的情况不止一种。假设我们只是从文本文件中读取数字:

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)
}
1
2
3
4
5
6
7
8
9
10
11
12

Rust会给出一个编译错误:

error: `?` couldn't convert the error to `std::io::Error`
  --> <source>:10:19
   |
10 |         numbers.push(line.parse()?);     // parsing integers can fail
   |                   ^^^^^^^^^^^^^^^^^^^^^^ 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
1
2
3
4
5
6
7

当我们学到第11章 “trait” 时,这个错误消息中的术语会更容易理解。目前,只需注意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库,定义了它自己的错误类型ImageError,并实现了从io::Error和其他几种错误类型到ImageError的转换。如果你想采用这种方式,可以试试thiserror库,它旨在帮助你用几行代码就定义出不错的错误类型。

一种更简单的方法是使用Rust内置的功能。所有标准库错误类型都可以转换为Box<dyn std::error::Error + Send + Sync + 'static>类型。这有点拗口,但dyn std::error::Error表示 “任何错误”,而Send + Sync + 'static确保它可以在不同线程之间安全传递,这通常是你需要的 。为了方便起见,你可以定义类型别名:

type GenericError = Box<dyn std::error::Error + Send + Sync + 'static>;
type GenericResult<T> = Result<T, GenericError>;
1
2

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

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

let io_error = io::Error::new(         // 创建我们自己的io::Error
    io::ErrorKind::Other, "timed out");
return Err(GenericError::from(io_error));  // 手动转换为GenericError
1
2
3

我们将在第13章全面介绍From trait及其from()方法。

使用GenericError这种方法的缺点是,返回类型不再能准确地告知调用者可能会遇到哪些类型的错误。调用者必须做好应对任何情况的准备。

如果你调用一个返回GenericResult的函数,并且想要处理某一种特定类型的错误,同时让其他错误继续传播,可以使用通用方法error.downcast_ref::<ErrorType>()。如果错误恰好是你要找的特定类型,它会借用一个指向错误的引用:

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);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

许多语言都有内置语法来实现这个功能,但事实证明它很少被用到。Rust则是通过一个方法来实现。

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

有时我们确定某个错误不可能发生。例如,假设我们正在编写解析配置文件的代码,在某个时刻发现文件中的下一个内容是一串数字:

if next_char.is_digit(10) {
    let start = current_index;
    current_index = skip_digits(&line, current_index);
    let digits = &line[start..current_index];
    ...
1
2
3
4
5

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

let num = digits.parse::<u64>();
1

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

"bleen".parse::<u64>() // ParseIntError: invalid digit
1

但我们恰好知道在这种情况下,digits完全由数字组成。我们该怎么办呢?

如果我们正在编写的代码已经返回GenericResult,可以直接加上?然后就不用管了。否则,我们就不得不为这个不可能发生的错误编写错误处理代码,这很烦人。在这种情况下,最好的选择是使用.unwrap(),这是Result的一个方法,如果结果是Err就会触发panic,如果是Ok则直接返回成功值:

let num = digits.parse::<u64>().unwrap();
1

这和?类似,只是如果我们对这个错误的判断有误,即它实际上可能发生,那么在这种情况下就会触发panic。

实际上,在这个特定的例子中我们的判断是错误的。如果输入包含足够长的数字字符串,这个数字会太大而无法用u64表示:

"99999999999999999999".parse::<u64>()    // overflow error
1

因此,在这种特定情况下使用.unwrap()会是一个漏洞。错误的输入不应该导致panic。

话虽如此,确实存在Result值不可能是错误的情况。例如,在第18章你会看到,Write trait为文本和二进制输出定义了一组常见的方法(.write()等)。所有这些方法都返回io::Result,但如果你恰好是向Vec<u8>写入数据,它们就不会失败。在这种情况下,使用.unwrap()或.expect(message)来处理Result是可以接受的。

当某个错误表明出现了非常严重或异常的情况,而panic恰好是你想要的处理方式时,这些方法也很有用:

fn print_file_age(filename: &Path, last_modified: SystemTime) {
    let age = last_modified.elapsed().expect("system clock drift");
    ...
}
1
2
3
4

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

# 忽略错误

偶尔我们只想完全忽略一个错误。例如,在我们的print_error()函数中,我们必须处理一种不太可能出现的情况,即打印错误时触发了另一个错误。比如,如果stderr被管道输出到另一个进程,而这个进程被终止,就可能发生这种情况。我们试图报告的原始错误可能更值得传播,所以我们只想忽略stderr的问题,但Rust编译器会对未使用的Result值发出警告:

writeln!(stderr(), "error: {}", err);  // warning: unused result
1

习惯用法let _ = ...可以消除这个警告:

let _ = writeln!(stderr(), "error: {}", err);  // ok, ignore result
1

# 在main()函数中处理错误

在大多数产生Result的地方,让错误向上冒泡到调用者是正确的做法。这就是为什么在Rust中?只是一个字符。正如我们所看到的,在一些程序中,它会连续出现在许多行代码中。

但是,如果你让错误传播足够长的时间,最终它会到达main()函数,这时就必须对它进行处理。

通常,main()函数不能使用?,因为它的返回类型不是Result:

fn main() {
    calculate_tides()?; // error: can't pass the buck any further
}
1
2
3

在main()函数中处理错误最简单的方法是使用.expect():

fn main() {
    calculate_tides().expect("error"); // 错误处理到此为止
}
1
2
3

如果calculate_tides()返回一个错误结果,.expect()方法会触发panic。主线程中的panic会打印一条错误消息,然后以非零退出码退出,这大致符合我们的预期。我们在小程序中一直使用这种方法。这是一个开始。

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

$ 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
1
2
3

错误消息淹没在一堆信息中。而且,在这种特定情况下,RUST_BACKTRACE=1并不是个好建议。

然而,你也可以更改main()函数的类型签名,使其返回一个Result类型,这样你就可以使用?:

fn main() -> Result<(), TideCalcError> {
    let tides = calculate_tides()?;
    print_tides(tides);
    Ok(())
}
1
2
3
4
5

对于任何可以用{:?}格式化器打印的错误类型,这种方法都有效,所有标准错误类型(比如std::io::Error)都可以。这种技术很容易使用,并且会给出一个稍微好一点的错误消息,但它并不完美:

$ tidecalc --planet mercury
Error: TideCalcError { error_type: NoMoon, message: "moon not found" }
1
2

如果你有更复杂的错误类型,或者想在消息中包含更多细节,最好自己打印错误消息:

fn main() {
    if let Err(err) = calculate_tides() {
        print_error(&err);
        std::process::exit(1);
    }
}
1
2
3
4
5
6

这段代码使用if let表达式,只有当calculate_tides()的调用返回错误结果时才打印错误消息。关于if let表达式的详细信息,见第10章。print_error函数在 “打印错误” 部分有列出。

现在输出就简洁明了了:

$ tidecalc --planet mercury
error: moon not found
1
2

# 声明自定义错误类型

假设你正在编写一个新的JSON解析器,并且希望它有自己的错误类型。(我们还没有介绍用户自定义类型,这会在接下来的几章讲到。但错误类型很实用,所以我们在这里提前简单介绍一下。)

你大致需要编写的最少代码如下:

// json/src/error.rs
#[derive(Debug, Clone)]
pub struct JsonError {
    pub message: String,
    pub line: usize,
    pub column: usize,
}
1
2
3
4
5
6
7

这个结构体名为json::error::JsonError,当你想抛出这种类型的错误时,可以这样写:

return Err(JsonError {
    message: "expected ']' at end of array".to_string(),
    line: current_line,
    column: current_column
});
1
2
3
4
5

这样做没问题。不过,如果你希望你的错误类型能像标准错误类型那样工作(这也是你库的用户所期望的),那你还得做更多工作:

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 trait,但Error方法的默认定义就足够了。
impl std::error::Error for JsonError {}
1
2
3
4
5
6
7
8
9
10
11

同样,impl关键字、self以及其他内容的含义将在接下来的几章解释。

和Rust语言的很多方面一样,有一些库(crates)可以让错误处理变得更简单、更简洁。这类库有很多,其中使用最广泛的一个是thiserror,它能为你完成前面提到的所有工作,让你可以这样写错误类型:

use thiserror::Error;

#[derive(Error, Debug)]
#[error("{message:} ({line:}, {column})")]
pub struct JsonError {
    message: String,
    line: usize,
    column: usize,
}
1
2
3
4
5
6
7
8
9

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

# 为什么选择Result?

现在我们已经有足够的知识来理解Rust选择Result而不是异常的原因。以下是这种设计的关键点:

  • Rust要求程序员在每个可能发生错误的地方做出某种决策,并在代码中记录下来。这很好,因为否则很容易因疏忽而导致错误处理不当。
  • 最常见的决策是允许错误传播,而这只需用一个字符?就能实现。因此,错误处理代码不会像在C和Go语言中那样让你的代码变得杂乱。而且它仍然很明显:你看一段代码,一眼就能看到所有传播错误的地方。
  • 由于错误的可能性是每个函数返回类型的一部分,所以很清楚哪些函数可能失败,哪些不会。如果你将一个函数改为可能失败的函数,就会改变它的返回类型,这样编译器会要求你更新该函数的下游调用者。
  • Rust会检查Result值是否被使用,所以你不会不小心让错误悄无声息地溜走(这是C语言中常见的错误)。
  • 由于Result和其他数据类型一样,所以很容易将成功和错误结果存储在同一个集合中。这使得对部分成功的情况进行建模变得很容易。例如,如果你正在编写一个从文本文件加载数百万条记录的程序,并且需要处理很可能出现的情况 —— 大多数记录加载成功,但有些会失败,你可以使用Result向量在内存中表示这种情况。

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

1 你还应该考虑使用流行的anyhow库,它提供的错误和结果类型与我们的GenericError和GenericResult非常相似,但有一些不错的额外功能。

编辑 (opens new window)
上次更新: 2025/03/20, 19:44:38
第6章 表达式
第8章 包和模块

← 第6章 表达式 第8章 包和模块→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式