目录
前言
生命周期
生命周期的规则
总结
前言
上一节课讲述了所有权系统中的引用与借用,引用可以让我们对一块内存上的数据有读权限或者读写权限,但是不会有该内存的释放权限,因为释放权限只会属于所有者,这是所有权原则中的一块内存在某一个时刻只能有一个所有者规则约束的。这节课在引用的基础上继续讲解下一个重要概念:生命周期。我们想一想那个毕业生离开班级的例子,假设A同学是字典的所有者,B同学,C同学都借用该字典,拥有该字典的引用,那么如果同学A还没有离开班级时,同学B和同学C都可以正常访问该字典,假设同学A离开了教室,将字典带走了,那么同学B和同学C对于该字典的引用其实是很危险的,因为所有者离开作用域后,内存上的数据被清除,引用就会指向一个不存在的地方。那么在使用引用时,我们需要保证,引用指向的地方必须一直有效,最起码,引用离开作用域之前,引用指向的所有者也不能离开作用域,我们将这种存活时间称之为生命周期。它的作用是确保片在程序运行时,引用都是有效的。Rust编译器在编译代码时会帮助我们检查引用的生命周期的有效性,如果检查不通过,编译会报错,聊rust聊了这么多,其实可以很明显的感觉到rust在编译期做了很多事情,这也是很多人诟病的一个地方,觉得rust的编译太慢了,但是反过来说,编译耗时,运行时快且安全,收益其实是非常高的。
生命周期
生命周期在代码中的表现形式就是在引用前面增加一个‘a,比如有一个引用&p,那么加上生命周期后就是&‘a p,表示不可变引用p的生命周期是a。我们来看一下生命周期的例子,毕竟到现在为止,我们还没有看过生命周期。
fn main() {let r;{let i = 1;r = &i;}println!("r = {}", *r);
}
查看上面的代码,我们定义了变量r,在内部花括号中,定义了变量i,i绑定的值是1,将r等于i的不可变引用,当i走出内部花括号后,i因为是1那块内存的所有者,所以i会释放内存,在最后一行,我们在println!中通过r取值,这个操作是一个危险的操作,因为内存已经释放了,问题出在哪里呢?问题出在i的生命周期只在内部的花括号中,但是r的生命周期是整个main函数,r的生命周期大于i的生命周期,但是r是i的不可变引用,r引用了一个生命周期比自己小的生命周期的所有者。这就是生命周期的作用。实际上,上面这段代码在cargo build后会报错,这样危险的代码是不会过检查的,报错如下图,报错也非常明显,提示出i活得时间不够久,i死了之后,依然有人在读取i。
生命周期的规则
rust的编译器会帮我们推导生命周期和检查生命周期的有效性,但能力是有限的,我们看一个rust无法帮我们推导生命周期的例子。下面的代码是很典型,也是很常见的一个例子,下面的代码在编译后会报错,具体报错如下图
fn main() {}fn longest(x: &str, y: &str) -> &str {if x.len() > y.len() {x} else {y}
}
我们来还原一下rust编译器推导生命周期的过程,先提一下生命周期的三个规则:
1.每一个引用参数都有自己的生命周期,我们以上面的函数为例,longest函数有2个参数x和y,所以x和也都有自己的生命周期,就是了longest(x: &'a str, y: &'b str)
2.如果输入只有一个生命周期参数,那么输出的生命周期等于这个输入的生命周期参数,假设一个函数签名是my_method(x: &str) -> &str,根据第一条规则my_method(x: &‘a str) -> &str;根据第二套规则,my_method(x: &‘a str) -> &’a str。此时编译器只要保证输入的生命周期和返回值的生命周期保持一致就好了,如果不保持一致就会编译报错。
我们结合规则1和规则2来看个例子。我们定义了一个函数,我们按照规则1和规则2来加上生命周期,并且查看是否可以通过检查。可以看到对于函数my_method,输入引用的生命周期和返回引用的生命周期需要一样大,都是'a,那么实际上可以满足么,我们先看x的生命周期,是main函数的从x定义开始,到main函数结束,而返回引用&t的生命周期在函数结束就结束了,很明显&t的生命周期无法满足'a,所以下面这个代码编译会报错。
fn main() {let x = String::from("hi");my_method(&x);
}fn my_method<'a>(x: &'a str) -> &'a str {let t = String::from("hello");&t
}
趁热打铁,我们再分析一下longest函数的生命周期。根据规则1,输入参数x和y分别有自己的生命周期,假设为'a 和 'b,规则2不满足,所以输出引用没有生命周期,rust编译器无法做生命周期有效性的检查,于是会报错。我们补齐一下对于该函数,我们希望保证的生命周期的有效性
fn main() {}fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {if x.len() > y.len() {x} else {y}
}
我们来看一段无法通过生命周期检查的代码,如下图,这段代码无法通过的报错截图也贴在了下面,明显的,在函数定义上,希望参数x和参数y的生命周期保持一致,但是在main函数中str1 和 str2的生命周期并不一致,所以报错了,解决也很简单,就是将main中内部{}的代码放在外面,这样子生命周期一致,rust编译器检查通过,程序安全。
贴一下可以通过rust编译器的代码
fn main() {let str1 = String::from("hello");let x = &str1;let y;let str2 = String::from("hi");y = &str2;longest(x, y);
}fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {if x.len() > y.len() {x} else {y}
}
关于生命周期的3大规则,我们介绍了2个,这两个在我们分析编译器报错时,可以解决大部分问题了,第三个规则和面向对象有关,规则为,如果是对象的方法,方法的返回参数的生命周期都等于self的生命周期,这条规则我们放到介绍面向对象时再详细展开。
总结
这是关于rust所有权系统的最后一课,在引用的基础上,讲述了生命周期是如何保证引用的有效性的,关于rust的所有权系统,是非常精彩的设计思路的迭代与博弈,一开始针对内存设计了所有权,避免了额外的线程去做垃圾回收的操作,比如jvm类的语言,使得rust的内存消耗非常低,也正是因为所有权的转移会导致读写数据的不方便,设计了引用来提供读写数据,因为引用只能读写数据,不能清理内存上的数据,所以引入了生命周期的概念保证了使用引用的安全。环环相扣,构建了rust的整个所有权系统,除了这些好处,有坏处么?当然有,最明显的,对程序员的要求提高了,即使编译器的提示很详细,但是对于初学者来说,还是很不友好,随便写点代码就要调好久,体感很差。