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

时间:2022-10-09 18:09:08 | 浏览:425

导语 |云加社区祝大家新年快乐!新春假期结束的第一篇干货,为大家带来的是从C++转向Rust主题的内容。在日常的开发过程中,长期使用C++,在使用Rust的过程中可能会碰到一些问题。本文是From C++ To 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

作者简介

孟杰

腾讯后台开发工程师

本文由高可用架构翻译。技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。

高可用架构

改变互联网的构建方式

相关资讯

C++编程调试秘笈:c++的缺陷来自哪里?

C++语言是非常独特的。虽然实际上所有的编程语言都从其他语言中吸收了一些思路、语法元素和关键字C++却是吸收了另一种完整的语言,即C语言。事实上, C++语言的创建者Bjarne Stroustrup原先把他的新语言命名为"带类的C"。这意

C++编程自学宝典:初识C++语言

第1章初识C++为什么选择C++?从读者自身的实际情况来看,原因有很多。读者选择C++可能是因为必须为一个C++项目提供技术支持。在超过30年的生命周期中,该项目中已经包含了数百万行C++代码,并且大部分流行的应用程序和操作系统是使用C+编

36氪首发|「集群车宝」获1亿元C+轮融资,将布局新能源售后市场

36氪获悉,「集群车宝」获1亿元C+轮融资,由中科科创领投。去年9月,公司刚完成由彬复资本和沣源资本领投的1亿元C轮融资。「集群车宝」成立于2013年,总部位于广州,是一家汽车后市场产业互联网服务平台,。平台通过S2B2C模式,整合上游供应

火爆了,小白必读的优质C++开源项目

经过我前面介绍的C++学习书籍、博客网站、在线视频学习网站(没有看过的同学可以到我的主页翻看),相信大家已经有一些C++基础的,有了理论,需要通过项目来提升自身技术能力,下面推荐几个开源项目:那我就话不多说,直接开始上手了项目涉及后台开发组

C++对比其他语言到底难在哪里?除了性能优势,还有什么优点?

写在前面的话在大多数开发或者准开发人员的认识中,C/C++ 是一门非常难的编程语言,很多人知道它的强大,但因为认为“难”造成的恐惧让很多人放弃。笔者从学生时代开始接触 C/C++,工作以后先后担任过 C++客户端和服务器的开发经理并带队开发

C++20即将于年底发布,C++23提上日程

作者 | Herb Sutter译者 | 弯月,责编 | 屠敏出品 | CSDN(ID:CSDNnews)C++20已全票通过,有望于今年晚些时候发布9月4日,C++ 20的国际标准草案投票结束,而且获得了全票通过。这意味着C++ 20已完

C/C++编程笔记:帮你整理了"数组"的知识点!赶紧收藏

C或C ++中的数组是存储在连续内存位置的项目的集合,可以使用数组的索引随机访问元素。它们用于存储相似类型的元素,因为所有元素的数据类型必须相同。它们可用于存储原始数据类型的集合,例如任何特定类型的int,float,double,char

学c++不再难

众所周知,C++难。就好像博大精深的汉语比英语难,这是客观事实,我们并不否认。但,转念想想,英语也不简单吧?从小学开始学,到了大学,挂在四六级上的同学数不胜数。可貌似没有人说不好汉语吧,此时,你能说汉语比英语简单么?不过是环境使然罢了。

Linus:C++是很烂的语言

出品|开源中国文|局长科技外媒 ITWire 报道了对 Linus Torvalds 的采访。关于 Linux 内核对 Rust 的支持情况,Linus 回应称“Linux 内核尚未支持 Rust”,并补充说“目前相关工作正处于开发阶段,预

C++标准语言不断被开发,C++却走向了下坡路!

C 和 C++ 的没落,不仅是因为 CPU 时钟周期的关系,而且因为关系到了开发者的时间全球大约有400万C和C++程序员,很可能是最大的社区了,约占20%的市场份额,不亚于Java,甚至还要更多一些(C和C++一起)。它们也是当前主流语言

友情链接

SEO域名抢注宝宝起名网妈妈知道币圈釜山旅行网广东旅游网西班牙旅游网美食城资讯网凤凰古城旅游网云上黄石新闻网国学易经文化网vivo手机评测网衡阳新闻头条网保龄球初学网赛车比赛网联想电脑评测网贵阳交友相亲网广场舞健身网美术艺考培训网
c语言中文官网-零基础c++从入门到精通pdf、c语言编译器、C/C++开发工具、c语言入门自学零基础、c++入门自学、c/c++软件下载、c语言编程软件、c语言自学免费网站、c语言零基础自学视频教程、c++手机编程软件、c语言入门程序设计、初级编程视频教程、c语言在线编程平台、C语言线上作业网站。
c语言中文官网 chuxinxin.cn ©2022-2028版权所有