本文是 Rust 学习笔记系列第七篇,参考 Rust 指南的第 9 章,主要讲错误处理,将涉及 panic!
和 Result<T, E>
。
Rust 对可靠性的保证也包括错误处理的部分。错误是在软件开发中无法避开的问题,Rust 提供了多种用于解决出错情况的特性。大部分情况下,Rust 会在编译时告知可能存在的问题,要求你在编译时就解决该问题。这些要求可以让你的程序变得更加健壮,因为它会确保你在发布代码之前可以发现问题并且采取合适的解决方法。
Rust 将错误分为两类:可恢复的错误与不可恢复的错误。可恢复的错误指可以合理的告知用户并且要求重试的错误,比如找不到文件;不可恢复的错误是缺陷的同义词,比如尝试数组下标越界等。
大部分语言不会区分这两者,通过异常(Exception)机制等方式提供统一的解决方法。但是 Rust 中没有实现异常,而是提供了 Result<T, E>
类型用于处理可恢复错误,用 panic!
宏来在遭遇不可恢复错误时,让程序中止。
不可恢复错误与 panic!
有些情况下,当代码出错时,开发者没法解决这些问题。Rust 提供 panic!
宏用于应对这种场景。当执行到 panic!
宏时,程序会打印错误信息,释放并清理所使用的内存,然后结束进程。通常发生在检测出某种缺陷,并且开发者并不明确如何去解决这个错误的时候。
默认情况下,程序在 panic 时,会释放栈(按顺序弹出栈空间,并清理所使用的数据),但是这个过程也是一个不小的开销。另一种方法是就是立即中止执行。那些被程序所占用的内存需要由操作系统来清理。如果需要让编译出的二进制文件尽量小,可以在
Cargo.toml
的[profile]
节中添加panic = 'abort'
配置,比如 :
1 | [profile.release] |
我们可以在程序中直接使用 panic!
:
1 | fn main() { |
执行代码会得到如下输出:
1 | $ cargo run |
查看 panic!
发生的调用栈
如果发生的 panic 并不是由我们自己编写的 panic!
触发的,我们就需要通过调用栈来查找到底是哪里导致的这个问题。
1 | # 通过设置变量 RUST_BACKTRACE=1 可以让程序打印出详细的调用栈 |
可恢复的错误与 Result
大部分错误并没有严重到需要程序中止。有时候函数执行失败时,其原因可以轻易地理解并做出应对。比如当找不到文件时,可能会尝试创建一个文件,而不是让程序中止。
Result 的定义如下:
1 | enum Result<T, E> { |
我们来试试文件读取的代码:
1 | use std::fs::File; |
匹配不同错误类型
File::open
返回的结果中,错误为标准库提供的 io::Error
类型。其中 kind
方法可以得到一个 io::ErrorKind
枚举类型的值,可以用于判断错误原因。
我们尝试更详细的例子:
1 | use std::fs::File; |
出错时中止的快捷方法:unwrap
与 expect
使用 match
,我们可以正常进行错误判断,并根据不同情况进行处理,不过这些 panic!
稍微有点繁琐。Result<T, E>
类型中有一些函数可以帮我们简化这个任务。
1 | use std::fs::File; |
传递错误
有时候你实现了一个函数,但是并不想在函数内部决定如何处理错误,而是交给调用者来处理,这时候我们需要将错误传递到函数外面。
1 | use std::fs::File; |
传递错误的快捷方法:?
下面的例子和刚刚那个一样,只是使用了 ?
来快捷传递错误。
1 | fn read_username_from_file() -> Result<String, io::Error> { |
利用 ?
,我们甚至可以将代码简化为:
1 | fn read_username_from_file() -> Result<String, io::Error> { |
?
只能用于返回 Result
的函数中(或者其他实现了 Try
特性的类型),在其他的函数中,还得写代码判断 Result
的结果。
1 | use std::fs::File; |
不过,main
函数是比较特殊的,它的返回值类型有一定的约束。其中 ()
和 Result<T, E>
都是允许的返回类型。
我们可以这样修改 main
,让它里面可以使用 ?
:
1 | use std::error::Error; |
这里 Box<dyn Error>
类型被称为是特性对象,这里我们只需要知道它可以指代任意类型的错误。
到底要不要 panic!
所以我们到底啥时候该调用 panic!
,啥时候返回 Result
呢?调用 panic!
会使一个可恢复的问题变成不可恢复的错误,所以通常情况下,我们会选择返回 Result
。但是在少数情况下,使用 panic!
更加合适。
示例代码、原型代码、测试代码
当为了说明一些概念而编写示例时,进行完善的错误处理会让例子更加复杂。这时候使用 unwrap
或者 except
来简单提示此处可能会产生错误会更好些。
与之类似,当你在原型代码中尚未决定如何处理一些错误时,可以先使用 unwrap
或者 except
来表示此处会产生错误,在后面再通过具体的处理代码来替换掉即可。
在测试中,panic!
是测试失败的标志。即使不是测试的目标,也可以用 unwrap
或者 except
来让错误不可恢复。
当你比编译器了解更多信息时
另一种适合使用 unwrap
的场景是,你有一些其他的逻辑可以保证 Result
只会是 Ok
的。但是编译器可能不知道这些逻辑,他仍然会要求你处理错误情况。
比如:
1 | use std::net::IpAddr; |
错误处理指南
建议在可能导致程序进入「错误的状态」时使用 panic!
,「错误的状态」是指某些假设、保证、约定、等式不成立的状态,比如无效的值、自相矛盾的值、缺失的值等等传入程序、并满足以下某些条件:
- 「错误的状态」并不是预期偶尔发生的情况
- 这之后的代码需要依赖正确的状态执行
- 没有好的方式将其转换为所使用的类型
如果某人使用了你的代码,并且传入某些无效的值,最好的选择就是使用 panic!
告诉他代码中存在缺陷,并让其修复该问题。类似地,在调用外部代码时失去控制导致无效状态产生时,也经常使用 panic!
。
然而,如果失败结果也是预期结果之一时,使用 Result
就更加合适了,比如,解析某种格式的数据或者 HTTP 请求达到频率限制。这些都是程序所预期的错误场景,需要在程序中有明确处理逻辑。
当代码对某些值执行操作时,应该首先验证这些值是否有效,否则抛出 panic!
。这主要是出于安全考虑:尝试在无效的数据上进行操作会使代码暴露出漏洞。这也是标准库会在你尝试越界访问内存时使用 panic!
的原因。函数通常存在一些约束:函数的行为仅在满足特定条件的情况下保证正常。当这些约束被打破时应该抛出 panic!
,因为这属于调用方的代码缺陷,调用方的开发者需要修复这个问题。函数的约束应该在 API 文档中描述清楚。
然而,在函数中加入大量的错误检查逻辑有些啰嗦且烦人。幸好,我们可以依赖 Rust 的类型系统(编译器的类型检查)来完成大多数检查。如果函数有特定的参数类型,那么编译器会为你保证传入的参数值有正确的类型。如果是某个确切类型而不是 Option
,那么程序的预期就是有正确类型的值,而不是没有值,就不需要再处理 Some
或者 None
的情况了。
创建自定义的类型用于验证
1 | // 创建猜数字的结构,用于保存数值 |
这样我们只要把相应的函数的参数约束为 Guess
类型,我们就一定能够得到 1 - 100 的数字。
总结
Rust 中错误处理相关的功能是设计来帮助你编写健壮的代码的。 panic!
宏将标志你的程序将进入无法处理的状态,会中止程序执行,而不是尝试继续在该状态下执行。Result
枚举使用 Rust 的类型系统来代表程序中可能执行失败,但是可以进行相应的恢复处理的情况。可以使用 Result
来告诉调用方存在一些错误的情况需要处理,使用 panic!
来告诉调用方他的调用不满足函数的约束/使用前提,可以活用 Rust 的类型系统来直接约束函数的参数。