返回值和错误处理
此笔记记录于Rust Course,大多数为其中的摘要,少数为笔者自己的理解
Rust 的错误哲学
错误对于软件来说是不可避免的,因此一门优秀的编程语言必须有其完整的错误处理哲学。在很多情况下,Rust 需要你承认自己的代码可能会出错,并提前采取行动,来处理这些错误。
Rust 中的错误主要分为两类:
- 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
- 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的
很多编程语言,并不会区分这些错误,而是直接采用异常的方式去处理。Rust 没有异常,但是 Rust 也有自己的卧龙凤雏:Result<T, E>
用于可恢复错误,panic!
用于不可恢复错误。
panic!与不可恢复错误
我们先做一个假设:文件读取操作发生在系统启动阶段。那么可以轻易得出一个结论,一旦文件读取失败,那么系统启动也将失败,这意味着该失败是不可恢复的错误,无论是因为文件不存在还是操作系统硬盘的问题,这些只是错误的原因不同,但是归根到底都是不可恢复的错误(梳理清楚当前场景的错误类型非常重要)。
对于这些严重到影响程序运行的错误,触发 panic
是很好的解决方式。在 Rust 中触发 panic
有两种方式:被动触发和主动调用,下面依次来看看。
被动触发
fn main() {
let v = vec![1, 2, 3];
v[99];
}
上述代码会发生严重的错误--数组越界访问:
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
总之,类似的 panic
还有很多,而被动触发的 panic
是我们日常开发中最常遇到的,这也是 Rust 给我们的一种保护,毕竟错误只有抛出来,才有可能被处理。
主动调用
在某些特殊场景中,开发者想要主动抛出一个异常,例如开头提到的在系统启动阶段读取文件失败。
对此,Rust 为我们提供了 panic!
宏,当调用执行该宏时,程序会打印出一个错误信息,展开报错点往前的函数调用堆栈,最后退出程序。
切记,一定是不可恢复的错误,才调用
panic!
处理,你总不想系统仅仅因为用户随便传入一个非法参数就崩溃吧?所以,只有当你不知道该如何处理时,再去调用 panic!.
fn main() {
panic!("crash and burn");
}
使用
RUST_BACKTRACE=1 cargo run
或$env:RUST_BACKTRACE=1 ; cargo run
进行栈展开(也称栈回溯),它包含了函数调用的顺序,当然按照逆序排列
panic 的两种终止方式
当出现 panic!
时,程序提供了两种方式来处理终止流程:栈展开和直接终止。
其中,默认的方式就是 栈展开
,这意味着 Rust 会回溯栈上数据和函数调用,因此也意味着更多的善后工作,好处是可以给出充分的报错信息和栈调用信息,便于事后的问题复盘。直接终止
,顾名思义,不清理数据就直接退出程序,善后工作交与操作系统来负责。
对于绝大多数用户,使用默认选择是最好的,但是当你关心最终编译出的二进制可执行文件大小时,那么可以尝试去使用直接终止的方式,例如下面的配置修改 Cargo.toml
文件,实现在 release
模式下遇到 panic
直接终止:
[profile.release]
panic = 'abort'
线程 panic 后,程序是否会终止
长话短说,如果是 main
线程,则程序会终止,如果是其它子线程,该线程会终止,但是不会影响 main
线程。因此,尽量不要在 main
线程中做太多任务,将这些任务交由子线程去做,就算子线程 panic
也不会导致整个程序的结束。
具体解析见 panic 原理剖析。
何时使用 panic!
先来一点背景知识,在前面章节我们粗略讲过 Result<T, E>
这个枚举类型,它是用来表示函数的返回结果:
enum Result<T, E> {
Ok(T),
Err(E),
}
当没有错误发生时,函数返回一个用 Result
类型包裹的值 Ok(T)
,当错误时,返回一个 Err(E)
。对于 Result
返回我们有很多处理方法,最简单粗暴的就是 unwrap
和 expect
,这两个函数非常类似,我们以 unwrap
举例:
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1".parse().unwrap();
上面的 parse
方法试图将字符串 "127.0.0.1"
解析为一个 IP 地址类型 IpAddr
,它返回一个 Result<IpAddr, E>
类型,如果解析成功,则把 Ok(IpAddr)
中的值赋给 home
,如果失败,则不处理 Err(E)
,而是直接 panic
。
因此 unwrap
简而言之:成功则返回值,失败则 panic
,总之不进行任何错误处理。
具体来说,有如下场景可以使用 panic!:
- 示例、原型、测试
- 你确切的知道你的程序是正确时
- 可能导致全局有害状态时
panic 原理剖析
当调用 panic!
宏时,它会
- 格式化
panic
信息,然后使用该信息作为参数,调用std::panic::panic_any()
函数 panic_any
会检查应用是否使用了panic hook
,如果使用了,该hook
函数就会被调用(hook
是一个钩子函数,是外部代码设置的,用于在panic
触发时,执行外部代码所需的功能)- 当
hook
函数返回后,当前的线程就开始进行栈展开:从panic_any
开始,如果寄存器或者栈因为某些原因信息错乱了,那很可能该展开会发生异常,最终线程会直接停止,展开也无法继续进行 - 展开的过程是一帧一帧的去回溯整个栈,每个帧的数据都会随之被丢弃,但是在展开过程中,你可能会遇到被用户标记为
catching
的帧(通过std::panic::catch_unwind()
函数标记),此时用户提供的catch
函数会被调用,展开也随之停止:当然,如果catch
选择在内部调用std::panic::resume_unwind()
函数,则展开还会继续。
还有一种情况,在展开过程中,如果展开本身 panic
了,那展开线程会终止,展开也随之停止。
一旦线程展开被终止或者完成,最终的输出结果是取决于哪个线程 panic
:对于 main
线程,操作系统提供的终止功能 core::intrinsics::abort()
会被调用,最终结束当前的 panic
进程;如果是其它子线程,那么子线程就会简单的终止,同时信息会在稍后通过 std::thread::join()
进行收集。
可恢复的错误 Result
Result<T, E>
是一个枚举类型,定义如下:
enum Result<T, E> {
Ok(T),
Err(E),
}
看下打开文件的例子:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
以上 File::open
就返回一个 Result
类型。
处理该类型:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => {
panic!("Problem opening the file: {:?}", error)
},
};
}
对返回的错误进行处理
直接 panic
还是过于粗暴,因为实际上 IO 的错误有很多种,我们需要对部分错误进行特殊处理,而不是所有错误都直接崩溃:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};
}
失败就 panic:unwarp 和 expect
unwrap
和 expect
的作用就是,如果返回成功,就将 Ok(T)
中的值取出来,如果失败,就直接 panic
,真的勇士绝不多 BB,直接崩溃。
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
expect
相比unwrap
能提供更精确的错误信息,在有些场景也会更加实用
传播错误
以下函数从文件中读取用户名,然后将结果进行返回:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
// 打开文件,f 是`Result<文件句柄,io::Error>`
let f = File::open("hello.txt");
let mut f = match f {
// 打开文件成功,将 file 句柄赋值给 f
Ok(file) => file,
// 打开文件失败,将错误返回(向上传播)
Err(e) => return Err(e),
};
// 创建动态字符串 s
let mut s = String::new();
// 从 f 文件句柄读取数据并写入 s 中
match f.read_to_string(&mut s) {
// 读取成功,返回 Ok 封装的字符串
Ok(_) => Ok(s),
// 将错误向上传播
Err(e) => Err(e),
}
}
有几点值得注意:
- 该函数返回一个
Result<String, io::Error>
类型,当读取用户名成功时,返回Ok(String)
,失败时,返回Err(io:Error)
File::open
和f.read_to_string
返回的Result<T, E>
中的E
就是io::Error
由此可见,该函数将 io::Error
的错误往上进行传播,该函数的调用者最终会对 Result<String,io::Error>
进行再处理,至于怎么处理就是调用者的事,如果是错误,它可以选择继续向上传播错误,也可以直接 panic
,亦或将具体的错误原因包装后写入 socket 中呈现给终端用户。
如何简化?
传播界的大明星?
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
其实 ?
就是一个宏,它的作用跟上面的 match
几乎一模一样:
let mut f = match f {
// 打开文件成功,将 file 句柄赋值给 f
Ok(file) => file,
// 打开文件失败,将错误返回(向上传播)
Err(e) => return Err(e),
};
如果结果是 Ok(T)
,则把 T
赋值给 f
,如果结果是 Err(E)
,则返回该错误,所以 ?
特别适合用来传播错误。
链式调用:
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}
?
用于 Option 的返回
?
不仅仅可以用于 Result
的传播,还能用于 Option
的传播:
fn first(arr: &[i32]) -> Option<&i32> {
let v = arr.get(0)?;
Some(v)
}
新手用?
常会犯的错误
fn first(arr: &[i32]) -> Option<&i32> {
arr.get(0)?
}
这段代码无法通过编译,切记:?
操作符需要一个变量来承载正确的值,这个函数只会返回 Some(&i32)
或者 None
,只有错误值能直接返回,正确的值不行,所以如果数组中存在 0 号元素,那么函数第二行使用 ?
后的返回类型为 &i32
而不是 Some(&i32)
。因此 ?
只能用于以下形式:
let v = xxx()?;
xxx()?.yyy()?;
带返回值的 main 函数
实际上 Rust 还支持另外一种形式的 main
函数:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let f = File::open("hello.txt")?;
Ok(())
}
这样就能使用 ?
提前返回了,同时我们又一次看到了Box<dyn Error>
特征对象,因为 std::error:Error
是 Rust 中抽象层次最高的错误,其它标准库中的错误都实现了该特征,因此我们可以用该特征对象代表一切错误,就算 main
函数中调用任何标准库函数发生错误,都可以通过 Box<dyn Error>
这个特征对象进行返回。
至于 main
函数可以有多种返回值,那是因为实现了 std::process::Termination 特征,目前为止该特征还没进入稳定版 Rust 中,也许未来你可以为自己的类型实现该特征!
try!
Rust 开发者还可以使用 try!
来处理错误,该宏的大致定义如下:
macro_rules! try {
($e:expr) => (match $e {
Ok(val) => val,
Err(err) => return Err(::std::convert::From::from(err)),
});
}
简单看一下与 ?
的对比:
// `?`
let x = function_with_error()?; // 若返回 Err, 则立刻返回;若返回 Ok(255),则将 x 的值设置为 255
// `try!()`
let x = try!(function_with_error());
可以看出 ?
的优势非常明显,何况 ?
还能做链式调用。
总之,try!
作为前浪已经死在了沙滩上,在当前版本中,我们要尽量避免使用 try!。