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

Posted QcloudCommunity

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从C++转向Rust:两大主题值得关注!相关的知识,希望对你有一定的参考价值。

导语 | 云加社区祝大家新年快乐!新春假期结束的第一篇干货,为大家带来的是从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!


以上是关于从C++转向Rust:两大主题值得关注!的主要内容,如果未能解决你的问题,请参考以下文章

详细解答!从C++转向Rust需要注意哪些问题?

从C++转向最受欢迎的Rust语言

从C++转向最受欢迎的Rust语言

Go 语言的下一个大版本:Go 2.0 被安排上了(全面兼容1.X,改进错误处理和泛型这两大主题)

性能提升 25 倍:Rust 有望取代 C 和 C++,成为机器学习首选 Python 后端

2022世界5G大会值得关注 中兴通讯主题演讲令我印象深刻