学Rust要有大局观(三) 最痛也就这么痛了

导语

读过上一篇(学Rust要有大局观(二) Rust的精髓)的同学直接给我反馈的问题主要是: 为什么Rust要有​move sematic这个神奇设定,你说的都懂,但是好处在哪里呢?(欢迎大家有问题直接在公众号"Rust工程实践"留言提问, 读者的反馈真的是写作的原始反馈和动力). 所以在开始今天的reference & lifetime主题之前,我们简单回顾一下转移语义到底的好坏之处有哪些:

move sematic的好坏

好处(rust的承诺)

  1. 方便编译器编译阶段跟踪内存值的使用情况,可以让编译器无比强大地分析堆栈状态.
  2. 编译阶段排除了非常多内存不安全的内存(代码写法), 不会存在悬空指针(dangling pointer)

坏处

  • 学习曲线陡峭的重要原因之一,会进一步引出引用,生命周期等概念,容易让初学者遭遇挫败感
    • 连一个赋值都TM编译不过!?
    • 为什么一个print打印之后,变量就没了!?

rust痛点排行

根据Rust Survey 2020 Results调查结果显示,rust最难学的部分以lifetime排第一位. 全局观很重要,今天我们就开始带大家看一看最难的部分到底有多难,最痛也就这么痛了. 可以看到生存周期,所有权,还有trait,是大家掌握起来比较棘手主题.

rating-of-topics-difficulties

rust reference

上一篇的末尾我们提到过一个简单的思考,基于move sematic编写代码的时候,一个简单的for循环print语句就会导致一个数组变量被使用后释放掉,这其中的核心原因就是for循环语句理论上应该是租借使用权, 而不应该取得所有权; 为了解决这个问题,rust提供了reference类型的可copy变量;

作为一个c/c++程序员的你, 请思考,把上面for循环和打印语句作为函数体的情况下(仅有外部变量的读取需求),如果在其他语言中,如此简单的函数有可能造成程序崩溃吗?或者会有什么陷阱?

你的答案可能是这样的; 函数的参数应该是const &, 常量引用,这样我即避免了外部变量的拷贝,节省了内存和调用开销,同时通过const保证了不改变使用的变量内容.

我想说的是,这都是没问题的,但是这样并不能保证你的完美print函数core掉,原因是,你通过const &对编译器承诺了自己不改变变量,仅仅是使用,也不做copy,然而gcc编译器并不给你任何承诺,所以:

  1. 变量其他地方被改掉了. 你读取到了奇怪的内容.
  2. 变量被销毁了,你读取到了不应该操作的内存,程序崩掉了.

为什么可以这样?因为程序员承诺我不修改我使用的内容,但c++语言本身,编译器并不承诺这个变量它自己不会变(这种承诺不是相互的);

在Rust中,程序员通过borrow得到一个reference来承诺仅读取,或者肯定会修改一个变量

borrow的承诺写法是在变量前增加一个&, 比如

struct Point {
	x: i32,
	y: i32 
}
let point = Point { x: 1000, y: 729 };

let r: &Point = &point; 	// r现在是point的`只读使用权`
let rr: &&Point = &r;		// rr现在是`只读使用权`的`只读使用权`
let rrr: &&&Point = &rr;	// rr现在是`只读使用权`的`只读使用权`的`只读使用权`

上面的代码的内存模型是这样的:
A-chain-of-references-to-references

so~ 很明显,如果使用r来访问point的话,并不会影响point的所有权关系,即使r被阅后即焚了也不影响point, 内存上它俩使用的栈资源没关系.

于此同时,编译器同时许诺你在你使用这个reference期间, 任何对原有变量所有权的变动的代码,及修改,我都拒绝编译!编译器通过如下图所示的规则来判断是否拒绝代码的编译, 详细来说,rustc通过两条规则来兑现它的承诺(下图的中间和最右则情况):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mJDRMNfV-1624847414050)(http://picinfo.pic.surhelix.com/img/20210627122052.png)]

详细来说就是,编译器做代码静态分析的时候,仅通过阅读文本符号就知道是否要编译此代码还是直接拒绝继续编译代码,因为borrow语意的语法是精确的,就是程序员给出的承诺. 以一个简单的结构体来说,内部还有一个其他结构体,那么它的内存模型大致就是上图最左边的样子(栈上是变量本身的空间,内容是指向堆内的资源的树形结构). 下面以程序员要操作结构体内部的结构体子元素为例:

  1. 当程序员承诺对一个子成员变量仅读取的时候(中图),编译器承诺的兑现动作就是
    • 对象树root所有权变动的代码编译不可以通过
    • 修改子成员内容的代码编译不可以通过
    • 值owner也最多做读取不能再多了`
  2. 当程序员承诺对一个子成员变量要修改的时候(右图),编译器承诺的兑现动作就是
    • 非承诺的引用之外的所有试图接触这个值的代码都不可以通过
    • 即使是通过值的owner也不行读取

基于以上两条规则,请自己编写rust的编译器,给出下面代码是否违反给程序员的承诺?(注意同样的代码写法,若是c++肯定是可以通过编译的)

fn main() {

	let mut v = 10;	// v是一个可以改变的变量
	let r = &v;	// `&v` 就是程序员的承诺: 用r(reference)来借用变量v的使用权(读取)
	let rr = &r;	// 程序员二次承诺还要用rr也读取v, 并且

	v += 10		// 要改变v了,编译过还是不过? 

	myprint(r);	// 打印函数, 使用r来访问 
	myprint(rr);	// 打印函数, 使用rr来访问
}

你是否疑惑上面的文字里,梁小孩反复地写程序员得到一个引用是承诺对一个变量做只读操作? 我就是读取一下,有什么要承诺的?

现在我要告诉你,rust的语法规则之精确,让你写任何一句话都是在给编译器诉说自己的承诺. 比如当你写一个任意函数的时候,函数的声明形式就是你给编译器的承诺, 承诺对变量是如何使用的, 当你承诺只读,但是函数体内部出现了写操作,编译器有权根据你先前的承诺拒绝编译你的代码.

再思考另外一种情况,多个变量的函数,一个只读承诺,一个写引用的承诺,还有一个是直接move语意的所有权取得;这就是你给编译器的承诺,变量会被函数形参拿去值的所有权,如果使用不当被销毁,是程序员自己一开始就不应该做出的此函数对所有权负责的承诺. 所以程序员一定要编码之前想好顶层架构,因为一旦有变动,可能很多函数需要重写.

生命周期

为了适应move sematic而引入的borrow & reference会带来的新的问题是,让所有权和使用权发生了切割分离之后,引用和原始值的内存空间独立,但是二者的逻辑关系要求: 原始值必须存在的前提下,引用才有存在的意义. 如果遇到返回值引用了的函数这样的代码是否要停止编译? 很明显函数返回了使用权,如果此时不发生move需要有其他维度帮忙判断代码的逻辑合理性. 答案就是需要考虑lifetime生命周期.

从最原始的疑问开始: 你编写了一个函数,并且返回了一个reference, 假如被引用的值是函数内部local变量,那么基于安全考虑我们要拒绝它,如果引用的传入参数的某个子元素,那么我们要知道传入参数的存活时间能否支撑这个引用是有效的. 所以编译器一定跟踪引用和对应变量是否存在冲突的读写情况,还要跟踪每个变量的有效范围,发现程序员读写违反承诺,或者引用的生命周期不是被引用值生命周期的子集的时候,合情合理地拒绝编译.

reference-with-a-lifetime

说了这么多,其实只有图中表示的一个核心原理,那就是rust编译代码要检查是否存在像这样的合理的嵌套(cover)关系.

如果事情到此为止的话一切完美,不过有很多中情况作用域的嵌套比上面的例子要更加隐晦, 比如一个结构体的成员borrow的外部资源, 比如一个函数调用其实就是变量进入新的作用域, 这种情况可能还会产生组合: 你得到一个内部包含了reference的结构体作为函数参数, 这时候资源跟踪情况就很复杂了。此时,rust编译器要求你给出明确的关于资源存活时间的承诺. 这种承诺的表现方式就是让很多同学看不懂的生命周期语法. 我们不关心语法,仅仅是回到原始问题上来,不管什么样的语法,我们应该通过这个语法给编译器传递什么信息呢? 程序员做的任何承诺,rust编译器都会仔细检查,针对这种情况,程序要要做出的承诺无非就是类似我绝对不会胡乱引用这样的信息,比如我不会引用比结构体本身存活时间还短的变量.

宣称使用范围

生命周期不是作用域, 是变量被使用的那段时间, 一个结构体有两个引用类型的成员, 其中一个引用失效时,只要可以保证它也永远不再被用到,那也是OK的. 所以我建议大家将生命周期理解成为程序员宣称的引用的合理使用范围;

struct S {
    r32: &i32;
    r64: &i64;
}

let s;
给s.r32承诺范围1
  1. s.r32存活时间(reference)一定要小于等于被引用值的存活时间(所有人都是必死的)
  2. s.r32存活时间和s一样(苏格拉底是人)
  3. 所以s的存活时间必须小于等于s.r32引用的值的存活时间(苏格拉底是必死的)
给s.r64承诺范围2
  1. s.r64存活时间(reference)一定要小于等于被引用值的存活时间(所有人都是必死的)
  2. s.r64存活时间和s一样(苏格拉底是人)
  3. 所以s的存活时间必须小于等于s.r64引用的值的存活时间(苏格拉底是必死的)

三段论里苏格拉底是人这个特殊陈述应该是问题的核心,因为当有多个成员变量的时候,被引用的具体值的生命周期可能一样, r32和r64原始值作用域会不同,但是无论如何s都是两者之中更小的那一个.

编译器处理上面的代码的时候需要程序员承诺: 到底s.r32s.64的范围一样还是不一样,你若是宣称一样,那么我检查s的存在多久就可以了,你若是宣称不一样, 那么我就得按照相对小的那个来判断s的使用范围是否合理.

OK, 是时候看一下实际代码了~

struct A<'a> {
    r32: &'a i32;
    r64: &'a i64;
}

struct S<'a, 'b> {
    r32: &'a i32;
    r64: &'b i64;
}

这就是添加了lifetime声明的结构体,其中A宣称A.r32A.r64预期使用范围一样, 此时rustc编译器按照A的实例存活时间判断就可以了跟踪实际引用的值是否满足要求, 但是S现在宣称S.r32S.r64是两个不同的使用范围,此时rustc将分别跟踪被引用的两个值的存活时间是否都比S要大.

重新回到返回引用的函数这个原始问题上, 怎么写才合适?

// 程序员宣称函数使用的时候, 返回值使用范围和入参肯定一样(或更小), rust会检查确认是否真的这样
fn smallest<'a>(v1: &'a [i32], v2:&'a [i32]) -> &'a i32 { ... }

到这里你应该明白了,lifetime真的是一个编译期概念,是程序员做出的承诺,rustc会根据你的承诺检查代码是否是你宣称的那样,被引用的值是不是一直比引用时间更久.

到此为止,我们应该可以更清楚地理解一下move sematicborrow & reference再到liftime的整个逻辑链条。他们到底都在解决什么问题, 这正学习的时候需要大量的例子加深细节把握. 我们仅关心概念和概念提出的场景,解决的问题.

以上是我自己对这些概念的理解和思考,难免会有重大错误,但是应该能帮到大家. 下一篇咱们看trait是个什么东西, 再会~~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值