导语
云加社区祝大家新年快乐!新春假期结束的第一篇干货,为大家带来的是从C++转向Rust主题的内容。在日常的开发过程中,长期使用C++,在使用Rust的过程中可能会碰到一些问题。本文是FromC++ToRust的第二篇,在这一篇里,主要介绍错误处理和生命周期两个主题。
此前,我介绍了其中思维方式的转变(mindshift):《详细解答!从C++转向Rust需要注意哪些问题?》
一、错误处理
(一)C++任何生产级别的软件开发中,错误处理都需要被妥善考虑。C++通常会有两种错误处理的风格:从C继承下来的返回值风格。所有函数都返回整型,用错误码来表示各种错误情况。
C++的异常,在出错的位置抛出异常,然后在错误处理的位置捕捉异常。
这两种方案各有优劣,这里简单地说明一下。返回值风格的优点是清晰,错误发生的位置和处理方法都写得很直白;缺点即是拖沓,错误代码与业务代码交错在一起,使得主要逻辑不突出。同时占用了返回值位置,影响逻辑的表达。另外,没有强制错误检查,可能会遗漏错误检查而导致代码缺陷。如下:if(OK!=foo()){//errorhandle}SomeThingthing;if(OK!=getSomeThing(thing)){//errorhandle}thing.init();//可能已经失败了thing.action();//由于前面忘记检查是否成功初始化,这里可能会故障
异常恰恰相反,错误有独立的处理流,通常不与业务逻辑相交,使得业务逻辑看起来很清晰;但是由于异常的隐性,使得任何位置都可能抛出异常,函数的退出点也变得隐晦,带来异常安全问题,增加了代码编写的心智负担。如下:
voidfoo(){autothing=newThing();bar();//可能会抛出异常deletething;}如果上面代码中的bar抛出异常,程序的执行流程将从bar函数跳出进入异常处理流程,因此后面delete语句不能得到执行,导致thing泄漏。
解决此问题的方法是使用智能指针,它们使用了RAII机制确保了函数在各种情况下均能妥善地释放动态分配的对象。(二)RustResultT,ERust没有提供异常机制,与使用Option来解决可选的情况类似,它使用了Result来提供此功能。Result的定义如下:pubenumResultT,E{///ContainsthesuccessvalueOk(T),///ContainstheerrorvalueErr(E),}
可以看到,Result的定义几乎与Option一样。只是在异常的情况返回时多带一个错误类型。举一个具体的例子:
#[derive(Debug)]pubenumMyError{IoError(String),Inexist(String),}pubtypeResultT=std::result::ResultT,MyError;pubfnfetch_id()-Resultu64{Ok(0)}fnmain()-Result(){letid=fetch_id()?;println!("{:?}",id);Ok(())}
上面letid=fetch_id()?;一句,使用了?操作符,实际相当于执行如下语句:
letid=matchfetch_id(){Ok(id)=id,Err(err)={returnErr(err);}};相当于,如果被调函数(fetch_id)正常返回则unwrap其值;反之,则将被调函数的错误向上返回。
相对于C/C++,Rust在此处,实际上在尝试做到某种平衡:没有异常,没有引入新的执行模型。函数的执行流程可以采用简单的返回值方式分析,便于理解。
?操作符的引入,使用语法糖一方面减少错误处理代码,代码更清爽;另一方面也显式地注明了所有返回点。
Result中携带的返回值T必须unwrap之后才能使用,这在类型系统上保证了错误必须被处理,不能沉默地忽略。
错误处理是强类型的。通过Result中的E类型参数向上返回错误时,必须要求E类型不变。这里产生了一些Rust错误处理的独特要求,后面再展开。
由于SafeRust不能直接操作裸指针,所以不论函数从什么位置返回,均确保通过RAII机制释放了指针。
panic!在Rust中,错误被划成了两类:可恢复的(recoverable)和不可恢复的(unrecoverable)。所谓可恢复通常指的是可以上报给用户,修复之后,然后重试一下的错误,比如:文件未找到,配置错误等。而不可恢复一般是由于代码Bug导致的,程序已经进入未定义状态,继续执行可能产生未定义行为,比如:数组越界访问。对于可恢复的错误,使用ResultT,E返回错误,交由调用方决定该如何处理。而对于不可恢复的错误,使用panic!宏直接中止程序的执行。(三)Rust错误处理惯例如之前所说,Rust的错误处理是强类型的。因此,不能像C++的异常一样,错误可以穿透多层调用栈;相反,错误必须被逐层处理、翻译,不能一抛到底。这个工作其实是较为繁琐的。举个例子:#[derive(Debug)]pubenumMyError{IoError(String),Inexist(String),}pubtypeResultT=std::result::ResultT,MyError;pubfnfetch_id()-Resultu64{letcontent=std::fs::read_to_string("/tmp/tmp_id")?;letid=content.parse::u64()?;Ok(id)}
这段代码不能编译通过,因为std::fs::read_to_string和String::parse的返回值虽然都叫Result,但却不是相同的类型(因为E被定义为库局部的错误了)。因此,这里都不能直接使用?操作符。而是需要进行错误类型的翻译,转成我们自己定义的MyError:
pubfnfetch_id()-Resultu64{//写法1:letcontent=matchstd::fs::read_to_string("/tmp/tmp_id"){Ok(content)=content,Err(_)={returnErr(MyError::IoError("read/tmp/tmp_idfailed.".to_owned()));}};//写法2:使用标准库函数map_errletid=content.parse::u64().map_err(
_
MyError::ParseError("parseerror.".to_owned()))?;Ok(id)}显然,写法1过入繁冗,实在称不上优雅。而写法2直接使用标准库函数map_err来完成错误类型的映射,会干净很多。但是如果映射的代码比较复杂,或者同样的处理会多次重复,就会希望将错误映射集的代码中起来。因此,社区中也提供了库来简化这部分处理,如:thiserror,anyhow。这两个库分别对应了库级别与应用级别的错误处理。
所谓库级别指的是编写为可被其它库或者应用复用的代码。因此,并不清楚错误最终会被如何处理,所以最终会在库级别统一Error的类型,并最终将底层转译到该错误类型。如上例中的MyError。上例在使用thiserror改写之后:#[derive(thiserror::Error,Debug)]pubenumMyError{#[error("ioerror.")]IoError(#[from]std::io::Error),#[error("parseerror.")]ParseError(#[from]std::num::ParseIntError),}pubtypeResultT=std::result::ResultT,MyError;pubfnfetch_id()-Resultu64{letcontent=std::fs::read_to_string("/tmp/tmp_id")?;letid=content.parse::u64()?;Ok(id)}可以看使用thiserror后,代码清爽了很多。thiserror会为MyError自动实现底层Error的Fromtrait。所以在fetch_id中就可以直接用?操作符将底层Error映射到MyError。
而应用级别的Error不需要进一步上传给调用者,只需要有一个Result类型可以集中处理所有的底层Error即可。因此,此时不需要自定义MyError,使用anyhow改写之后如下:useanyhow::{Context,Result};pubfnfetch_id()-Resultu64{letcontent=std::fs::read_to_string("/tmp/tmp_id").context("open/tmp/tmp_idfailed.")?;letid=content.parse::u64().context("parseerror.")?;Ok(id)}fnmain()-Result(){letid=fetch_id()?;println!("{:?}",id);Ok(())}anyhow为泛型(generic)的ResultT,E实现了Contexttrait。而Context提供了context函数,将原来的ResultT,E转成了ResultT,anyhow::Error,最终在应用级别将错误类型统一到anyhow::Error上。
限于篇幅,这里不再对这两个库做更深入说明,更细致的说明可以参考以下详细文档:Rust:Structuringandhandlingerrorsin(