导读:

Rust号称内存安全,也就是保证不存在以下问题:

  • 空指针\引用
  • 悬垂指针(dangling pointer):指向无效内存的指针\引用
  • 使用未初始化内存
  • 缓冲区溢出
  • double free

Rust并没有GC系统,那么它如何解决上述问题?通过两个特性:Ownership(所有权)和Lifetime(生命周期)。两者相互配合,形成了Rust独特的内存安全系统。Ownership和lifetime是rust的核心,需要花费时间去琢磨。

笔者在学习rust期间,一直处于半懂不懂的状态。因为笔者认为lifetime这个概念不是rute独有的,它广泛存在于几乎所有编程语言中,指的是变量有效的范围。但lifetime进入到Rust中,似乎跟什么东西混在一起变得似是而非,让人看得云里雾里。笔者阅读了大量资料,终于找到HashRust的一篇文章,它一针见血指出了问题所在:

Part of the reason why lifetimes are so confusing is because in much of Rust writing the word lifetime is loosely used to refer to three different things — the actual lifetimes of variables, lifetime constraints and lifetime annotations. Let’s talk about them one by one.

所以lifetime一词在rust中有三个含义:

  • 变量从声明到被回收的范围。
  • y=&x, y=&mut x 等借用表达式,对变量x施加了 lifetime constraints
  • 用于帮助编译器进行分析的lifetime annotation

这三者的关系如下:

  • 每个变量都有自己的生命周期,也就是变量从声明到被回收这一段范围。
  • 借用了对象x,就对变量x施加了生命周期约束,如果x的生命周期不满足这个约束,就会报错,一般是 'x' does not live enough 这类报错。
  • 简单情况编译器可以自动推导lifetime constraint,复杂情况下需要开发者手动添加lifetime annotation来表达life constraint。

Ownership

Ownership的具体规则如下:

  • 同一时间(范围), 每一个对象最多只能有一个owner, owner对自己拥有的对象有读写权力。
  • 当owner被回收,它所拥有的所有对象都被回收

这两点使得我们可以实现RAII来管理内存:

  • owner唯一:所以不会出现回收资源后,另一个owner无法使用资源的情况。
  • 自动回收拥有的资源,类似c++的析构函数。

为了配合ownership,rust采用了move语义作为默认语义。也就是如果b没有实现Copy trait的话,那么 a = b这个语句会把b的内容转移给a,b将不能再使用。

对象之间构成了一个树状的own关系。例如Vec,它可以容纳多个类型相同的元素:

  1. fn main(){
  2. let a = 10;
  3. let b = 20;
  4. // c owns a and b now.
  5. // a and b are moved into c,
  6. // without reinitialization, you can't use a or b.
  7. let c = vec![a, b];
  8. }

Borrow & lifetime constraints

只有ownership是不够的,并非所有场景我们都需要ownership。于是Rust增加了借用(borrow)机制。借用这一行为会产生被借用对象的可变引用或不可变引用( &T, &mut T ),允许在不转移ownership的情况下提供读写对象的能力。

大多数编程语言都提供了引用或指针。 当对象A被借用,那么Rust需要关注如下问题:

  • 当释放对象时,如何确保外部的引用不会变成dangling pointer?
  • 两个可变引用同时修改A
  • 一个不可变引用在读A,另一个可变引用在修改A

为了解决上述问题,Rust设计lifetime constraint,用于约束变量的真实生命周期。Rust规定lifetime constraints规则如下:

  • 每一个引用都有自己的lifetime。
  • 允许存在多个不可变引用,它们的lifetime可以重叠。
  • 最多一个可变引用。
  • 可变引用和不可变引用的lifetime不能重叠。
  • 被借用(可变或者不可变)期间,对象必须一直存活。
  • 返回值的lifetime必须与入参的lifetime相关联。

lifetime annotation

局部变量的lifetime是否满足constraint,这很好确定,但是如下情况就比较复杂:

  • 结构体内部包含引用
  • 函数入参与返回值都包含引用

因为缺乏足够上下文信息,compiler无法有效分析lifetime的合法性,所以需要在上面两种情况下,开发者要用lifetime annotation显式标记约束。在Rust中,生命周期注解是一种特殊的泛型参数。

  1. struct S<'a>{
  2. data: &'a i32,
  3. }
  4. // 'b:'a可以理解为'a是'b的子集
  5. fn proc<'a, 'b:'a>(a: &'a i32, b:&'b i32) -> &'a i32{
  6. if a > b{
  7. a
  8. }else{
  9. b
  10. }
  11. }

要明确的是:lifetime annotation不会改变引用的真实生命周期,它只是一个编译期的静态标记,用于帮助borrow checker检查变量的生命周期是否满足约束,从而拒绝不合理的代码。

我们可以这样理解 &'a obj它借用了obj一段范围,这段范围标记为 'a ,这段范围具体为多少是不定的,编译器会根据上下文进行推断和改变它的大小

下面举例说明lifetime annotation的作用。

例1

  1. #[derive(Debug)]
  2. struct Foo;
  3. impl Foo {
  4. fn mutate_and_share(&mut self) -> &Self { &*self }
  5. fn share(&self) {}
  6. }
  7. fn main() {
  8. let mut foo = Foo;
  9. let loan = foo.mutate_and_share(); // 等价于 Foo::mutate_and_share(&mut foo);
  10. foo.share();
  11. println!("{:?}", loan);
  12. }

从语义上看,这段代码没有安全问题。但是这段代码不能通过编译,原因如下:

  • 第5行的方法定义表明形参 &mut self (第11行的 实参&mut foo )这个借用的生命周期要足够长,足以覆盖返回值( loan )的生命周期。
  • loan 的生命周期为第11-13行。
  • 所以borrow checker推断, &mut foo 的借用至少要覆盖11-13行。
  • 而在第12行有一个不可变借用 &foo ,不能与 &mut foo 共存,因此报错。

如果注释掉第13行,则代码可以编译通过, 因为borrow checker认为loan这个变量没有被使用,创建后即刻回收也可以。因此 &mut foo 的借用只维持在第11行即可,那么就不会与 &foo 重叠了。

此外,将第12行移到 foo.mutate_and_share() 也可以消除重叠,这也是常用的方法。

例2

方法签名中不恰当的生命周期标记会导致编译器扩大借用范围,导致安全的代码无法通过编译。
样例代码如下:

  1. use std::fmt::Debug;
  2. struct Tes<'a, T>{
  3. data: &'a i32,
  4. data2: T,
  5. }
  6. impl<'a, T> Tes<'a, T>{
  7. // 作为对比, 一般写法是: fn mut_borrow(&mut self){}
  8. fn mut_borrow(&'a mut self){}
  9. }
  10. fn main(){
  11. let n = 10;
  12. let mut t1 = Tes{
  13. data: &n,
  14. data2: n,
  15. };
  16. {
  17. Tes::mut_borrow(&mut t1); //等价于 t1.mut_func()
  18. }
  19. Tes::mut_borrow(&mut t1); // 编译失败: 进行了二次可变借用
  20. }

这段代码没有安全问题的, 但这段代码会编译错误,原因在于第9行的方法签名:

  • 方法要求借用范围(lifetime constraint)是 'a , 也就是Tes结构体的成员data的借用范围。这就意味着 mut_borrow() 产生的借用要持续到Tes结构体被回收
  • 也就是说,第一个可变借用范围是从20行到23行到Tes被回收,在此范围内无法进行其他借用,因此编译失败。

那么如何修改?两种方法:

  1. fn mut_borrow<'b>(&'b mut self) where 'a:'b{
  2. // 'b是'a的子集
  3. }
  4. // 隐含生命周期
  5. fn mut_borrow>(&mut self){}

Lifetime annotation省略规则

编译器在下面场景可以自动标记lifetime annotation:

  • 每一个引用都有lifetime annotation,如果没有显式标记,则认为各自是不同的lifetime。比如 fn func(a: &A, b: &B) ,认为它有两个隐含的lifetime annotation '_a, '_b
  • 只有一个入参,那么它的生命周期约束被加到所有返回值上。
  • 如果函数带有 &self, &mut self ,那么将self的生命周期约束给返回值。

其他

unbounded lifetime

在unsafe代码中,可能产生unbounded lifetime,如:

  • 将raw pointer 解引用转换为引用,如 &*raw_ptr
  • std::mem::transmute() 以及类似方法,可以把一块内存视为另一种类型,如把某个i64 的内存解释为 一个&i32 引用类型。

以上两种情况产生的生命周期约束,导致原对象的生命周期可以被任意推断,可能产生Undefined behavior。

参考