Rust错误处理最佳实践
错误处理是Rust编码过程中重要的一环; )
序言
现实世界处处凶险,充满了未知和异常,错误处理是保持代码健壮性必不可少的环节,处理错误的方式各有千秋,本文是对笔者在学习与实践过程中摸索得来的错误处理之道的梳理和总结.
为什么要进行错误处理
以如下Demo为例:
fn main(){
let path="abc.txt";
println!("{}",try_read_file(path));
}
fn try_read_file(path: &str)-> String {
std::fs::read_to_string(path).unwrap()
}
try_read_file
函数尝试对path指示的文件进行读取,但 unwrap()
在该文件不存在时将导致程序崩溃,这显然不够健壮,在快速实现思路的阶段或者在出于演示目的的demo代码中可以使用 unwrap()
快速打开 Option
和 Result
的包装,但是这个操作很危险,实际开发中应尽量避免这种暴力的方式.
如何在Rust中进行正确且优雅的错误处理
前导知识
1:实现"自定义错误类型"
阅读标准库中 std::error::Error
的代码,剔除无关代码之后基本框架如下:
pub trait Error: Debug+Display{
// snip
fn source(&self)-> Option<&(dynError+'static)>{
None
}
}
如果采用自定义错误类型的方式来完成这项任务,我们需要为我们的自定义错误类型完成以下任务:
impl std::fmt::Display
Trait,并实现fmt
方法- 一般情况下可通过
#[derive(Debug)]
实现std::fmt::Debug
Trait - 实现
std::error::Error
的Trait,并根据error的级别决定是否覆盖source()
方法.如果当前错误类型是低级别错误,没有子错误,则返回None
,所以此时可以不覆盖soucre()
; 如果当前错误有子错误,则需要覆盖该方法
动手实现一个Demo如下:
usestd::error::Error;
// 自定义Error,通过#[derive(Debug)]注解实现std::fmt::Debug的trait
#[derive(Debug)]
struct CustomError{
err: ChildError,
}
// 实现Display的trait
impl std::fmt::Display for CustomError{
// 一般情况下是固定写法
fn fmt(&self,f: &mutstd::fmt::Formatter<'_>)-> std::fmt::Result{
write!(f,"父类型错误~")
}
}
// 实现std::error::Error Trait,因为有子Error:ChildError,覆盖source()方法,返回Some(err)
impl std::error::Error for CustomError{
fn source(&self)-> Option<&(dynstd::error::Error+'static)>{
Some(&self.err)
}
}
// 子Error
#[derive(Debug)]
struct ChildError;
// 实现Display
impl std::fmt::Display for ChildError{
fn fmt(&self,f: &mutstd::fmt::Formatter<'_>)-> std::fmt::Result{
write!(f,"子类型错误~")
}
}
// 实现Error的trait,因为没有子Error,不需要覆盖source()方法
impl std::error::Error for ChildError{}
// 构建一个Result的结果,返回自定义的error:CustomError
fn get_super_error()-> Result<(),CustomError>{
Err(CustomError{err: ChildError})
}
fn main(){
match get_super_error(){
Err(e)=>{
println!("{}",e);
println!("Caused by: {}",e.source().unwrap());
}
_=>println!("No error"),
}
执行结果:
父类型错误~
Caused by:子类型错误~
2:如何避免复杂的类型定义
复杂的类型定义其实可以视作代码噪音,例如类型 std::result::Result<someType, CustomError>
,简化它分为两个步骤个,首先使用 use std::result::Result
,然后再使用 pub type Result<I> = std::result::Result<I, CustomError>
设置别名,最终Rust在编译 Result<u32>
的时候就会替换成 std::result::Result<u32,CustomError>
3:如何避免大量使用 match
匹配Option和Result
对于函数返回的 Option
类型和 Result
类型,Rust提供 match
对其进行匹配,但是如果嵌套层次比较深,可能会出现非常丑陋的层叠 match
,虽然能够覆盖所有情况,但显然这样的代码不太美观,此时应考虑使用Rust提供的 ?
操作符进行简化.
分析sled的错误处理哲学
强烈推荐首先阅读sled作者写的文章:
Error Handling in a Correctness-Critical Rust Projectsled.rs/errors.html
接下来的代码来自sled项目,将错误处理模块从项目中提取出来,重要部分添加注释.
Error
是一个全局的致命错误枚举类型,将其列成 enum
的原因是,在代码运行过程中出现的致命错误都将被向上传递给调用者,所以需要一个外部的错误类型.这里其实和Rust社区中"将任何错误都包裹到一个全局的错误enum"的风气恰好相反.,因为这种方式无法区分致命错误和非致命错误,有些时候向上传播了本应该在函数内处理的错误.
result.rs
// 不要把逻辑上在函数内就能够处理的小错误传递到上层去,即不要把这种类型的错误包裹到全局Error enum中
// 而需要包裹到全局Error enum中的是那些可能会引起系统瘫痪的致命错误,这些错误也必须要人工干预
// 因此必须要传递到上层
pub enum Error{
/// The underlying collection no longer exists.
CollectionNotFound(IVec),
/// The system has been used in an unsupported way.
Unsupported(String),
/// An unexpected bug has happened. Please open an issue on github!
ReportableBug(String),// 比如这个错误就很严重,严重到要通知作者,所以就被包裹进全局的Error enum中了
/// A read or write error has happened when interacting with the file
/// system.
Io(io::Error),
/// Corruption has been detected in the storage file.
Corruption{
/// The file location that corrupted data was found at.
at: DiskPtr,
},
// a failpoint has been triggered for testing purposes
#[doc(hidden)]
#[cfg(feature = "failpoints")]
FailPoint,
}
而在设计之初就知道很有可能甚至是一定会发生的错误 应该直接在发生错误的函数内处理掉 ,无需向上传播给调用者. 这种类型的错误需要有完全单独的错误类型,比如 CompareAndSwap
错误,可能会引起这个错误的操作是: 猜测Map某个键的值,如果猜对了就将其更新为新值,如果猜错了,就引发 CompareAndSwap
错误,这个错误实际上出现非常频繁,它不是一个致命错误,而是一个在设计的时候就知道会发生的错误,它的类型定义为:
pub struct CompareAndSwapError{
/// The current value which caused your CAS to fail.
pub current: Option<IVec>,
/// Returned value that was proposed unsuccessfully.
pub proposed: Option<IVec>,
}
能够引起该错误的 compare_and_swap
函数如下(剔除无关代码):
pub fn compare_and_swap<K,OV,NV>(
&self,
key: K,
old: Option<OV>,
new: Option<NV>,
)-> CompareAndSwapResult {
/*snip*/
if self.context.read_only{
// 如果满足错误路径,则在这里返回一个致命错误
return Err(Error::Unsupported(
"can not perform a cas on a read-only Tree".into(),
));
}
/*snip*/
return Ok(Err(CompareAndSwapError{
current: current_value,
proposed: new,
}));
/*snip*/
returnOk(Ok(()));// 在完全无错的状态下返回Ok(Ok())
}
CompareAndSwapResult
类型的完整定义是:Result<Result<(), CompareAndSwapError>, sled::Error>
,看起来很笨重,一点都不美,但它实际上非常精妙:
return Err(Error::Unsupported(
返回的是CompareAndSwapResult
的外层Result
,此时函数内出现了致命错误,Err内包裹的是自定义的Error::Unsupported
类型
2和3这两个是嵌套进外层 Result
里面的 Ok
,说明即使有错但问题也不大,不用传播至最开始的调用者(main函数)那里,直接在函数内处理即可.
return Ok(Err(CompareAndSwapError{
// 包裹了我们定义的CompareAndSwapError
错误return Ok(Ok(()))
// 没有任何错误发生
这个看似笨重实则精妙的类型是如何起作用的呢?来观察一下 compare_and_swap
的调用者 basic
函数:
// basic的函数体里面有对compare_and_swap返回值很清晰的使用方式
fn basic()-> Result<(),sled::Error>{
/*Snap*/
match db.compare_and_swap(k.clone(),Some(&v1.clone()),Some(v2.clone()))?{
Ok(())=>println!("it worked!"),
Err(sled::CompareAndSwapError{current: cur,proposed: _})=>{
println!("the actual current value is {:?}",cur)
}
}
/*Snap*/
let(k1,v1)=iter.next().unwrap().unwrap();// 是不是意味着还是允许有崩溃情况发生的?
/*Snap*/
Ok(())
}
注意 match
处有一个通过 ?
进行的解包操作,如果 compare_and_swap()
的返回值是不是 Err(sled::Error)
,则它一定是 Ok(())
或者 Ok(sled::CompareAndSwapError)
,这说明产生的都是小问题,?
解包操作就直接把返回值给拿出来了,然后通过 match
进行匹配,但是如果 compare_and_swap()
的返回值是 sled::Error
,这问题比较大,因为按照这种错误处理的思路,这个枚举的项们都是比较严重的错误,就必须传播给 basic()
的 caller
了,此处使用 ?
直接提前返回是正确的操作.经过 basic
函数这么一处理,设计时就意料到的错误就被正确"吞"掉了,而致命错误接着向上层转发.
basic
函数的调用者是 main
函数(Rust1.26支持了 main
返回 Result
):
fn main()-> Result<(),sled::Error>{
// 如果basic返回的是Ok(()),这说明basic内部一切正常,没有任何致命错误,皆大欢喜,继续执行
// 如果basic返回的是sled::Error,这说明basic遇到了致命错误,main负责通知程序员,所以用?直接提前返回了.
basic()?;
// snip
// 这里的代码们依然有可能返回Err(sled::Error)
// snip
Ok(())// 只要执行到这里就说明完全没有错误了
}
总结
这就是使用嵌套 Result
的哲学,它看上去不优雅,不美,但是好用,它把错误分成两种,能够处理的 ( 包括无错误的( ),以及设计中预料到的轻微错误),将它们包在外层 Result
的 Result::Ok
里面,而必须人工干预的严重错误包裹在外层Result的Err里面. 这构成了 compare_and_swap
的返回类型,然后在调用 compare_and_swap
的 basic
函数中使用 match + ?
来解析这个返回值,?
使致命错误继续上传,其他情况就在basic中消化掉了.
实战Demo
use std::collections::HashMap;
use std::result::Result;
use std::fmt::Display;
use std::io::Read;
fn main()->Result<(),UnExpectedError>{
let mut m = HashMap::new();
for i in 0..4{
m.insert(i,i);
}
use_wrapper(&mutm,1,1,100,"abc.txt")?;
Ok(())
}
#[derive(Debug)]
enum UnExpectedError{
Io(std::io::Error)
}
struct DesignError{
ov: i32,
nv: i32,
}
// 实现Error trait表明UnExpectedError是个错误类型
// 而错误类型通过Debug和Display来描述它们自己,所以UnExpectedError又实现了这两个trait。
impl std::error::Error for UnExpectedError{}
impl Display for UnExpectedError{
fn fmt(&self,f:&mutstd::fmt::Formatter<'_>)->std::fmt::Result{// ???
use self::UnExpectedError::*;
match &self{
Io(refe)=>write!(f,"IO error: {}",e)// ???
}
}
}
// sled作者不是说不让进行错误类型转换吗,为什么这里还要提供转换能力呢
// 这是因为sled作者不提倡的行为是:
// 把处理手段不同的错误类型们(比如意料内的轻微错误和需要人工干预的严重错误)转换到一种类型的错误上
// 作者在文中把这种单一类型的错误称之为big-ball-of-mud single error enum
// 但是我们这里的转换属于只把那些致命的错误(比如std::io::Error)转换到外部的,全局的UnExpectedError类型上,
// 这不属于上述不推荐的行为
// 根据业务逻辑,如有必要,那些致命错误类型当然可以不止std::io::Error一种,那么我们就
// 可以反复为这个全局的UnExpectedError实现From trait. impl From<FatalErrorType> for UnExpectedError.
impl From<std::io::Error> for UnExpectedError{
fn from(io_error:std::io::Error)->Self{
UnExpectedError::Io(io_error)
}
}
// 这个From trait是?操作符要求的。比如在use_wrapper的代码中我们有 maybe_err(m,k,ov,nv,path)? 的操作
// 而为了让严重错误类型继续向上传播,且传播这些严重错误类型时使用单一类型进行传播,
// use_wrapper的返回值是Result<(),UnExpectedError>。此处这个单一类型错误就是UnExpectedError。
// 当?执行时,得到的是一个std::io::Error, From trait就把它转成一个UnExpectedError。
// 如果k的v等于用户猜测的ov,那么将其替换成nv.如果不是,则这是DesignError; 无论上述哪种情况发生,都打印相关信息
// 本函数内还会由路径读取一个文件,如果有则打印读取到的内容,如果没有这是严重的io错误
fn maybe_err(m: &mutHashMap<i32,i32>,k: i32,ov: i32,nv: i32,path: &str)-> Result<Result<(),DesignError>,UnExpectedError>
{
use std::fs::File;
// 先整致命错误
let mut s = String::new();
File::open(path)?.read_to_string(&muts)?;
println!("{}",s);
// 再整DesignError
if *m.get(&k).unwrap()==ov{
m.insert(k,nv);
}else{
returnOk(Err(DesignError{ov,nv}))
};
Ok(Ok(()))
}
fn use_wrapper(m: &mut HashMap<i32,i32>,k: i32,ov: i32,nv: i32,path: &str)
-> Result <(),UnExpectedError>{
match maybe_err(m,k,ov,nv,path)?{
Ok(()) => println!("{}","It worked!"),
// 如果写成ov:ref cur, 那么cur绑定的就是传进来的那个DesignError的ov值的引用
// 当然也可以写成ov:ref ov, Rust会提示直接写成 ref ov更好
// 这里只能用ref,而不能用&,因为ref就是用来干这个事的:进行模式匹配时,无论是let还是match,ref关键字可以用来创建 结构体/元组 的字段的引用
Err(DesignError{ ref ov,nv:_ }) => {
println!("The actual current value is {}",ov)
}
}
Ok(())
}
还有一段更简单清晰的代码:
use rand::Rng;
use std::result::Result;
use std::fmt::{Display, Formatter};
use std::io::{Error, Read};
type CompareAndSwapResult=Result<Result<(),DesignedError>,UnExpectedError>;
fn main() {
match use_wapper(){
Ok(())=>{
println!("Ok,everythings done!")
},
Err(e)=>{
println!("We got something wrong! {}",e)
}
}
}
#[derive(Debug)]
enum UnExpectedError{
Io(std::io::Error)
}
struct DesignedError;
impl std::error::Error for UnExpectedError{}
impl Display for UnExpectedError{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self{
UnExpectedError::Io(e)=>{
write!(f,"IO error: {}",e)
}
}
}
}
impl From<std::io::Error> for UnExpectedError{
fn from(e: Error) -> Self {
UnExpectedError::Io(e)
}
}
fn maybe_err<P:AsRef<std::path::Path>>(path:P)->CompareAndSwapResult{
use std::fs::File;
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
println!("File contents is:{}", s);
let mut rng = rand::thread_rng();
let num = rng.gen_range(0..=1);
if num==0 {
return Ok(Err(DesignedError))
}
Ok(Ok(()))
}
fn use_wapper() -> Result<(), UnExpectedError> {
let result = maybe_err(std::path::Path::new("abc.txt"))?;
if let Err(DesignedError)=result{
println!("DesignedError occurs!")
}
Ok(())
}