查看原文
其他

从C++转向Rust:两大主题值得关注!

孟杰 云加社区 2022-06-14


导语 | 云加社区祝大家新年快乐!新春假期结束的第一篇干货,为大家带来的是从C++转向Rust主题的内容。在日常的开发过程中,长期使用C++,在使用Rust的过程中可能会碰到一些问题。文是From C++ To Rust的第二篇,在这一篇里,主要介绍错误处理和生命周期两个主题。


此前,我介绍了其中思维方式的转变(mind shift):详细解答!从C++转向Rust需要注意哪些问题?


一、错误处理


(一)C++


任何生产级别的软件开发中,错误处理都需要被妥善考虑。C++通常会有两种错误处理的风格:


  • 从C继承下来的返回值风格。所有函数都返回整型,用错误码来表示各种错误情况。


  • C++的异常,在出错的位置抛出异常,然后在错误处理的位置捕捉异常。


这两种方案各有优劣,这里简单地说明一下。


返回值风格的优点是清晰,错误发生的位置和处理方法都写得很直白;缺点即是拖沓,错误代码与业务代码交错在一起,使得主要逻辑不突出。同时占用了返回值位置,影响逻辑的表达。另外,没有强制错误检查,可能会遗漏错误检查而导致代码缺陷。如下:


if (OK != foo()) { // error handle}
SomeThing thing;if (OK != getSomeThing(&thing)) { // error handle}
thing.init(); // 可能已经失败了thing.action(); // 由于前面忘记检查是否成功初始化,这里可能会故障


异常恰恰相反,错误有独立的处理流,通常不与业务逻辑相交,使得业务逻辑看起 来很清晰;但是由于异常的隐性,使得任何位置都可能抛出异常,函数的退出点也变得隐晦,带来异常安全问题,增加了代码编写的心智负担。如下:


void foo() { auto thing = new Thing(); bar(); // 可能会抛出异常 delete thing;}


如果上面代码中的bar抛出异常,程序的执行流程将从bar函数跳出进入异常处理流程因此后面delete语句不能得到执行,导致thing泄漏。


解决此问题的方法是使用智能指针,它们使用了RAII机制确保了函数在各种情况下均能妥善地释放动态分配的对象。



(二)Rust


  • Result<T,E>


Rust没有提供异常机制,与使用Option来解决可选的情况类似,它使用了Result来提供此功能。Result的定义如下:


pub enum Result<T, E> { /// Contains the success value Ok(T), /// Contains the error value Err(E),}


可以看到,Result的定义几乎与Option一样。只是在异常的情况返回时多带一个错误类型。举一个具体的例子:


#[derive(Debug)]pub enum MyError { IoError(String), Inexist(String),}
pub type Result<T> = std::result::Result<T, MyError>;
pub fn fetch_id() -> Result<u64> { Ok(0)}
fn main() -> Result<()> { let id = fetch_id()?; println!("{:?}", id); Ok(())}


上面let id=fetch_id()?;一句,使用了?操作符,实际相当于执行如下语句:


let id = match fetch_id() { Ok(id) => id, Err(err) => { return Err(err); }};


相当于,如果被调函数(fetch_id)正常返回则unwrap其值;反之,则将被调函数的错误向上返回。


相对于C/C++,Rust在此处,实际上在尝试做到某种平衡


  • 没有异常,没有引入新的执行模型。函数的执行流程可以采用简单的返回值方式分析,便于理解。


  • ?操作符的引入,使用语法糖一方面减少错误处理代码,代码更清爽;另一方面也显式地注明了所有返回点


  • Result中携带的返回值T必须unwrap之后才能使用,这在类型系统上保证了错误必须被处理,不能沉默地忽略


  • 错误处理是强类型的。通过Result中的E类型参数向上返回错误时,必须要求E类型不变。这里产生了一些Rust错误处理的独特要求,后面再展开。


  • 由于Safe Rust不能直接操作裸指针,所以不论函数从什么位置返回,均确保通过RAII机制释放了指针。


  • panic!


在Rust中,错误被划成了两类:可恢复的(recoverable) 和不可恢复的(unrecoverable)。所谓可恢复通常指的是可以上报给用户,修复之后,然后重试一下的错误,比如:文件未找到,配置错误等。而不可恢复一般是由于代码Bug导致的,程序已经进入未定义状态,继续执行可能产生未定义行为,比如:数组越界访问。


对于可恢复的错误,使用Result<T,E>返回错误,交由调用方决定该如何处理。而对于不可恢复的错误,使用panic!宏直接中止程序的执行。



(三)Rust错误处理惯例


如之前所说,Rust的错误处理是强类型的。因此,不能像C++的异常一样,错误可以穿透多层调用栈;相反,错误必须被逐层处理、翻译,不能一抛到底。这个工作其实是较为繁琐的。举个例子:


#[derive(Debug)]pub enum MyError { IoError(String), Inexist(String),}
pub type Result<T> = std::result::Result<T, MyError>;
pub fn fetch_id() -> Result<u64> { let content = std::fs::read_to_string("/tmp/tmp_id")?; let id = content.parse::<u64>()?; Ok(id)}


这段代码不能编译通过,因为std::fs::read_to_string和String::parse的 返回值虽然都叫Result,但却不是相同的类型(因为E被定义为库局部的错误了)。因此,这里都不能直接使用?操作符。而是需要进行错误类型的翻译,转成我们自己定义的MyError:


pub fn fetch_id() -> Result<u64> { // 写法1: let content = match std::fs::read_to_string("/tmp/tmp_id") { Ok(content) => content, Err(_) => { return Err(MyError::IoError("read /tmp/tmp_id failed.".to_owned())); } }; // 写法2:使用标准库函数 map_err let id = content .parse::<u64>() .map_err(|_| MyError::ParseError("parse error.".to_owned()))?; Ok(id)}


显然,写法1过入繁冗,实在称不上优雅。而写法2直接使用标准库函数map_err来完成错误类型的映射,会干净很多。但是如果映射的代码比较复杂,或者同样的处理会多次重复,就会希望将错误映射集的代码中起来。因此,社区中也提供了库来简化这部分处理,如:thiserror,anyhow。这两个库分别对应了库级别应用级别的错误处理。


所谓库级别指的是编写为可被其它库或者应用复用的代码。因此,并不清楚错误最终会被如何处理,所以最终会在库级别统一Error的类型,并最终将底层转译到该错误类型。如上例中的MyError。上例在使用thiserror改写之后:


#[derive(thiserror::Error, Debug)]pub enum MyError { #[error("io error.")] IoError(#[from] std::io::Error), #[error("parse error.")] ParseError(#[from] std::num::ParseIntError),}
pub type Result<T> = std::result::Result<T, MyError>;
pub fn fetch_id() -> Result<u64> { let content = std::fs::read_to_string("/tmp/tmp_id")?; let id = content.parse::<u64>()?; Ok(id)}


可以看使用thiserror后,代码清爽了很多。thiserror会为MyError自动实现底层Error的From trait。所以在fetch_id中就可以直接用?操作符将底层 Error映射到MyError。


应用级别的Error不需要进一步上传给调用者,只需要有一个Result类型 可以集中处理所有的底层Error即可。因此,此时不需要自定义MyError, 使用anyhow改写之后如下:


use anyhow::{Context, Result};
pub fn fetch_id() -> Result<u64> { let content = std::fs::read_to_string("/tmp/tmp_id") .context("open /tmp/tmp_id failed.")?; let id = content.parse::<u64>().context("parse error.")?; Ok(id)}
fn main() -> Result<()> { let id = fetch_id()?; println!("{:?}", id); Ok(())}


anyhow为泛型(generic)的Result<T,E>实现了Context trait。而Context提供了context函数,将原来的Result<T,E>转成了Result<T,anyhow::Error>,最终在应用级别将错误类型统一到anyhow::Error上。


限于篇幅,这里不再对这两个库做更深入说明,更细致的说明可以参考以下详细文档: 


  • Rust:Structuring and handling errors in 2020(https://nick.groenen.me/posts/rust-error-handling/)

  • thiserror(https://docs.rs/thiserror)

  • anyhow(https://docs.rs/anyhow)



二、生命周期


终于到这个主题了!显然生命周期是Rust最独特的特性,没有之一。虽然在各种语言都会定义对象的生命周期,但将其在语言中静态表达出来的只有Rust。因此,虽然早有接触,但是在Rust碰到还是会觉得陌生,甚至晦涩。在这里笔者尝试记录下自己学习这个概念的关键点,想到什么说什么,不会是一个系统的教程,只是记录C++的熟悉者容易忽略的一些点。


(一)谈谈生命周期


简单地说,生命周期就是一个对象的存续时间。对于支持引用的语言来说, 引用目标在使用时必须存在是程序正确运行的基础;同时因为计算机内有限的资源,所以在对象使用完毕后,必须尽早释放。生命周期可以手动管理,但是因为程序的复杂性,手动管理是一件成本很高并且易错的工作。所以也就诞生了各种自动管理生命周期的算法,当前典型的算法有两类,引用计数(RC)垃圾收集(GC)


而Rust实际是探索了第三种自动管理的方案:编译期的静态检查-BorrowChecker,它通过分析变量的定义域(Scope)与移动(Move)规则,来保证通过引用使用目标对象的安全性。(注:在NLL BorrowChecker引入后,定义域不再是严格的代码块)。


初次接触Rust,最奇怪的就是生命周期的记法了:'a。很陌生,很费解。为什么需要它?解决什么问题?说一下我的理解:


  • 'a 是一种标记,BorrowChecker通过比较生命周期来保证引用的安全性;


  • 一般地,所有引用都含有生命周期标记。只是因为避免语言过于繁冗,Rust允许开发在一些情况下省略该标记(Lifetime Elision);


  • 因为BorrowChecker工作在编译期,所以生命周期标记合并在泛型系统,具体实现为泛型参数中的一项。



(二)生命周期省略-Lifetime Elision


Rust为了代码的清爽,允许开发在很多情况下都可以不使用生命周期标记。


这使得生命周期标记的出现场景比较微妙,比如:


fn print(s: &str);


实际上应该理解为:


fn print<'a>(s: &'a str);


该函数接受一个字符串的引用,显然,这个引用目标的生命周期一定可以覆盖 print的执行期,print并不需要对引用的生命周期做更特别的静态检查。因此,Rust允许省略这个生命周期标记。


具体的省略规则可以参考文档:Lifetime Elision

(https://doc.rust-lang.org/nomicon/lifetime-elision.html)


说一下我的理解:


首先定义了输入引用输出引用,文档中为了严谨,描述得比较长。简单地说,除了函数返回的引用外,其它都是输入引用。然后依据以下规则省略引用:


  • 规则1:每个输入引用都给予的生命周期;


  • 规则2:如果只有一个输入引用,那么该引用的生命周期给到全部省略的输出引用


  • 规则3:如果是多个输入引用,并且其中有&self或者&mut self,那么self的生命周期给到全部省略的输出引用


  • 其它情况都是错误的,必须显式地进行生命周期标注。


虽然Rust允许开发省略标注,但是需要注意的是:Rust根据上面规则自动恢复的标注,有可能并不是你想要表达的目的。如文档中的例子:


fn substr(s: &str, until: usize) -> &str;


应用规则2,取消省略之后:


fn substr<'a>(s: &'a str, until: usize) -> &'a str;


在取子串的情形中,返回的子串生命周期与输入参数一致,因此,默认恢复的标注是合理的。但是如果是下面函数:


fn country_abbr(c: &str) -> &str { match c { "China" => "CN", "America" => "US", _ => "Unknown", }}


应用规则2,取消省略后的签名是:


fn country_abbr<'a>(c: &'a str) -> &'a str;


可以知道,返回的“CN”,“US”,“Unknown”的生命周期是'static,由于'static的长度比所有其它生命周期都长,因此,将其以&'a str的类型返回不会有编译错误。但是这个结果会缩小country_abbr的使用范围,这可能并不是我们想要的结果。如下代码会无法编译通过:


fn check_lifetime(abbr: &'static str) {}
check_lifetime(country_abbr("China"));


所以,在了解了生命周期的省略规则后,country_abbr的签名应该写作:


fn country_abbr(c: &str) -> &'static str;


另一个需要注意的地方是:对于接受多个引用参数的函数,每个引用的生命周期都是独立的。如下:


fn foo(bar: &str, baz: &str);


应该应用规则1和规则2展开为:


fn foo<'a, 'b>(bar: &'a str, baz: &'b str);


而不是:


fn foo<'a>(bar: &'a str, baz: &'a str);


因为后期实际上要求了两个参数的生命周期必须是一样的, 因此施加了比前者更强的约束。



(三)子类化(Subtyping)与变型(Variance)


写下这个标题时,我心里也是没有什么底的:因为相对来说这是一些抽象及陌生的概念,使用简单且易于理解的语言将其说明白,对我来说是也很大的挑战。下面的说明都是使用自己理解的语言来表达的,不追求特别严谨精确,但希望易于理解。


  • 子类化Subtyping


为了加快思考,人脑会将一些常用的推导变成直觉,不自觉地忽略底层的逻辑细节,子类化(Subtyping)就是其中一个例子。因为在C++中,子类关系通常在继承关系中体现,所以从C++转过来的话,很容易下意识地认为子类就是继承。而事实上,子类关系是比继承关系更一般的(generic)关系。或者换句话说,继承关系是子类关系的一种实现。


所以,在Rust中不能简单地将子类化理解为继承,需要重新整理一下。笔者从几个点来理解:


  • 子类关系符合里氏替换原则。即是说,如果S是T的子类,那么类型为T的形参可以填入类型为S的实参。说人话:在需要使用某个类型的场合,也可以使用该类型的子类来代替。白话:子类比超类更有用


  • 在逻辑学中,内涵指概念所拥有的属性;而外延指的具备概念属性的事物。对应到类型系统,内涵指是某个类型的属性或方法;而外延指的是该类型的所有实例。所以,子类比超类有更多内涵更少外延;而超类反之


说了这么多,终于可以回到生命周期主题了。笔者在学习生命周期的过程中, 碰到第一个反直觉的结论是:'static是所有其它生命周期的子类,可以写作'static<: 'a ('a是任意任命周期)。你看:明明'static是最长最大最多的生命周期,为什么是子类?是小的那端?理解起来很不自然。


后来一句话解了我的疑惑:生命周期的长度体现的是内涵。这句话想想还有点哲学意味。


因此,<: 描述的是外延的大小,所以,任何大于'a的生命周期都是'a的实例,而'static的实例只有一个,就是'static本身。显然,'a的外延大于'static,所以'static是子类。从有用性的角度理解,'static可以在任何需要生命周期的地方使用,是最有用的,所以根据前面说到的,子类比超类更有用,'static显然是子类。


  • 变型Variance


在介绍变型之前,需要先引入另外一个概念类型构造子(Type Constructor)。首先这个概念要与C++中的构造函数(Constructor)区别开来:构造函数是用于创建类型的新实例;而类型构造子则是用于创建新类型


  • 可以是和类型或者积类型的构造。在Rust中可以认为是enum或者struct的定义式;


  • 可以是泛型类型的实例化。如:Vec<u8>。


在考虑变型时,主要是第二种情形,即:泛型类型的实例化。我们可以将泛型类型理解为类型的函数,因为其接收类型参数,返回新的类型。这样,我们就可以引出变型的三种情况了:


假设有类型构造子:F<T>, 并且有两个具体的类型:Super和Sub满足Sub<:Super,这两个具体类型通过F<T>可以分别构建新类型F<Sub>和F<Super>


  • 协变-covariance: 如果新类型和类型参数的关系一致即满足 F<Sub> <: F<Super>,则称之为F<T>对T协变。


  • 逆变-contravariance: 如果新类型和类型参数的关系相反,即满足 F<Super> <: F<Sub>,则称之为F<T>对T逆变。


  • 不变-invariance: 如果新类型和类型参数的关系无关,即不满足任何约束,则称之为F<T>对T不变。


终于介绍完了两个抽象概念,可以回来谈Rust了。


Rust当前没有定义类型间的子类关系。trait虽然可以继承,但并不是符合定义的子类关系(无法将&dyn Derive直接传给&dyn Base)。因此,在当前版本的Rust中,子类关系只在生命周期中存在


在Rust的文档中,有一个表描述了各种类型的变型关系,这里针对不太容易理解的两种情况进一步说明:


  • &'a mut T为什么对T是不变(invariant)?


根据《锈灵书》在介绍变型的相关章节中提供的例子:


fn evil_feeder(pet: &mut Animal) { let spike: Dog = ...;
// `pet` is an Animal, and Dog is a subtype of Animal, // so this should be fine, right..? *pet = spike;}
fn main() { let mut mr_snuggles: Cat = ...; evil_feeder(&mut mr_snuggles); // Replaces mr_snuggles with a Dog mr_snuggles.meow(); // OH NO, MEOWING DOG!}


&mut T对T的不变性(invariant) 是为了阻止通过修改超类的引用&mut Animal将Dog的实例复制到Cat的内存上(*pet=spike)。但是这个例子还是有一些不清晰的地方:


如前面所述,类型间的子类关系在Rust并未定义,所以这里上面提到“Dog is a subtype of Animal”并不准确。


另外,由于trait object是一个动态尺寸类型(dynamic sized type),所以必须Dog,Cat必须位于某种指针之后,因此,let spike: Dog=...不是合法的代码。


从逻辑上说,拿到某个类的指针,并不能用子类(当然也不能用超类)实例去覆盖该类的实例,因此,&mut T应该是不变的(invariant)。笔者推测是否也是Rust为了保留以后类型子类化的能力。


  • fn(T)-> ()为什么对T是逆变(contravariant)?


这是文档中唯一的逆变的例子,所以多说明一下。fn(T) -> ()是函数类型,用该类型描述某个作用场景(即,参数位置)时,其实是回调的场景。因此,回调函数的参数类型T,实际是对调用方的要求。这个要求越少(即,更加泛化,约束少,更偏向超类), 回调函数反而使用场景更大(即,更有用)。前面已经说到,更有用的是子类。


举个例子:


struct A;
fn foo(cb: fn(a: &A)) { let a = A; cb(&a);}
fn cb0(_a: &A) { println!("cb0 called.");}
fn cb1(_a: &'static A) { println!("cb1 called.");}
fn main() { foo(cb1);}


这里cb1的参数类型&'statc A是cb0的参数类型&'a A的子类,但是cb1却不能被foo接受。



(四)关于生命周期容易产生的误解


在Rust中,生命周期是全新的概念,因此也容易理解错误,对于常见的情形,Common Rust Lifetime Misconceptions一文介绍得非常清楚。

文档:(https://github.com/pretzelhammer/rust-blog/blob/master/posts/common-rust-lifetime-misconceptions.md#3-a-t-and-t-a-are-the-same-thing


如果刚开始学习Rust特别需要注意:


  • T既是&T也是&mut T的超类;


  • &T和&mut T是不相交的集合。


对于熟悉C++重载规则的开发来说,这两点是需要注意的。在Rust中,因为T包含&T,所以,不能同时为T,&T实现一个trait. 如下:


trait Trait {}
impl<T> Trait for T {}
impl<T> Trait for &T {} // ❌


&mut T和T也同理。因此,要为引用实现trait应该写作:


trait Trait {}
impl<T> Trait for &T {} // ✅
impl<T> Trait for &mut T {} // ✅


另外,即是要区分好,T: 'static和&'static T,主要规则如下:


  • T: 'static 应该读做:类型T被生命周期'static定界;


  • 如果T: 'static, 那么,T可以是生命周期为'static的借用类型,或者是拥有所有权的类型


因为T: 'static包括拥有所有权类型,所以T:


  • 可以在运行时动态分配;


  • 不必在程序运行的整个生命周期有效;


  • 可以安全地被修改;


  • 可以在运行时动态释放;


  • 可以具备不同的存续期。


更加一般地,T: 'a和&'a T的规则如下:


  • T: 'a比 &'a T更具弹性且通用;


  • T: 'a可以接受拥有所有权的类型,包含引用的拥有所有权类型或者引用


  • &'a T只接受引用;


  • 如果T: 'static那么T: 'a因为对于所有'a有'static >='a。



三、后记


这两个主题比我想像花了更多的篇幅,所以这一篇先到这里吧。后面计划继续聊聊可修改性Mutablility异步Async



 作者简介


孟杰

腾讯后台开发工程师

腾讯后台开发工程师,毕业于中南大学。目前负责腾讯安全流量分析平台的后台开发工作。开发经验丰富,对程序语言,类型系统,编译等方向很感兴趣。



 推荐阅读


技术人专属年味尽在这里!云加社区祝您虎年大吉

关于Go并发编程,你不得不知的“左膀右臂”——并发与通道!

一文入魂:妈妈再也不用担心我不懂C++移动语义了!

图解史上最晦涩的分布式共识算法——Paxos!




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存