侧边栏壁纸
  • 累计撰写 10 篇文章
  • 累计创建 5 个标签
  • 累计收到 2 条评论
标签搜索

目 录CONTENT

文章目录

以代码示例了解 rust 中的生命周期

生命周期是 rust 语言的一大特点,同时也是非常难的一部分,初学时比较难以理解
那么 rust 为什么需要它呢?

我们需要从指针开始说起

传统的指针

c、c++ 语言中,可以定义指针,例如下面的 c 代码

int* getDanglingPointer() {  
    int localVar = 42;    // 局部变量在栈上分配  
    return &localVar;     // 返回局部变量的地址 - 危险!  
}   

上面代码的问题显而易见,指针(引用)被返回,但是指针指向的变量是函数内部的局部变量,函数调用结束之后已经被释放了,该变量这块内存已经不知道是被那个程序使用了,此时再使用指针,取到的值就不再是预期中的值了,轻则 bug,重则直接影响到整个程序,导致崩溃,非常危险。

当然绝大多数情况下不会有人写出上面的代码,它只是为了说明,在语言层面 c、c++ 不会对这种代码做处理,因为语法是正确的,只有在真正运行时才会发生错误。

那避免空指针问题就只能取决于开发者的水平了,可是复杂项目中可能会发生空指针的情况会比较隐蔽,即便有代码审查,也会存在空指针的风险。

而 rust 使用借用检查器配合生命周期解决了这个问题

什么是生命周期

生命周期 = 引用的有效范围

存在引用就必然涉及引用的有效范围,因为任何一个引用的有效范围都不能超过其引用的值

rust 中生命周期解决的一个核心问题就是空指针,它要保证所有引用是合法的,不会存在空指针的情况。

上面的 c 代码在 rust 中的写法无法通过编译,编译器会直接提示错误
道理很简单:引用的有效范围超过了其引用的值

fn get_dangling_pointer() -> &i32 {
    let local_var = 1;
    return &local_var;
}

image

红色波浪线就是编译器对于开发者的警示:rust 编译器很智能,发现这是一个必然会发生空指针的函数,所以会提示错误及修改意见

生命周期参数

编译器也并非完全智能,以下代码编译器就不能直接判断是否会存在问题

fn longer(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
       x
     } else {
       y
     }
}

上述函数传入两个字符串切片(实际也是引用),返回长度较长的一个
此时问题来了:返回的是一个引用,来自于 x 或者 y,不确定是哪一个。甚至函数调用之后 x、y 是否还有效都无从知晓,编译器无法确定返回的引用和输出之间的生命周期关系,可能会存在悬垂引用

我们现在可以先单独来分析函数:
假设参数 x 的作用域为 a,参数 y 的作用域为 b
由于返回值是 x 或者 y,那么返回值的作用域要 小于等于 x y 中较小的那个作用域

满足上面的条件才能保证函数返回的引用指向是有效的

上面的函数在编译时 rust 就会提示,缺少生命周期标识

image-1740995815616

此种情况需要为引用类型添加生命周期约束,如下

fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
       x
     } else {
       y
     }
}

要注意的是,其中生命周期参数 'a 并不直接表示 x 和 y 的生命周期就是 'a
'a 表示一种约束:x 和 y 的真实生命周期必须大于等于'a 生命周期
同时返回值的生命周期必须小于等于 'a

的确有一点绕,还有一种写法较为复杂,但是理解上其实更为直观,如下

// 'x: 'y 的写法表示 生命周期x >= 生命周期y
fn longer_full<'a: 'c, 'b: 'c, 'c>(x: &'a str, y: &'b str) -> &'c str {
   if x.len() > y.len() {
       x
   } else {
       y
   }
}

以上代码实际上对三个引用都约束了生命周期(也就是作用域)x 和 y 以及返回的引用都有自己的生命周期约束,同时两个输入的生命周期都要 大于等于 最终的输出生命周期,我们并没有必要这么写,只是便于理解

生命周期消除规则

根据前面的代码示例和逻辑我们现在可以知道:对于函数,只有同时存在输入引用和输出引用时才需要标注生命周期

但有的时候即便同时存在输入引用和输出引用也无需手动标注生命周期,例如

fn an_example(s: &str) -> &str {
  // ...
}

上面的函数虽然同时存在输入和输出引用,但是我们甚至不需要关注具体内容就知道无需标注生命周期,原因在于:函数返回了一个引用,那么这个引用只可能来自两个地方

  1. 引用了函数内部的数据
  2. 引用来自于函数参数

对于第一种情况,必定会返回悬垂指针,编译器直接会提示错误
现在只剩下了第二种:那么就意味着输出的引用与输入引用强相关

此时我们就不需要再标注生命周期了,编译器会自动推断,这就是生命周期消除

关于消除规则,有如下几条

  1. 每一个引用参数都会获得独自的生命周期
  2. 若只有一个输入生命周期,那么该生命周期会被赋予给所有的输出生命周期
  3. 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期

以上都是编译器给予的便利,不需要在这些情况下标注生命周期,除此之外,就需要我们手动标注了

代码示例1

#[derive(Debug)]
struct Foo;

impl Foo {
    fn mutate_and_share(&mut self) -> &Self {
        &*self
    }
    fn share(&self) {}
}

fn main() {
    let mut foo = Foo;
    let loan = foo.mutate_and_share();
    foo.share();
    println!("{:?}", loan);
}

上面代码编译时会提示

cannot borrow `foo` as immutable because it is also borrowed as mutable

同时获取了 foo 的可变借用和不可变借用

原因如图

image-1741006399464

方法 mutate_and_share 满足生命周期消除规则,输入的生命周期被赋予给输出生命周期,也就是当 loan 有效时,对于 foo 的可变引用也同样有效,但是可变引用有效期间又同时尝试获取不可变引用,借用检查器就会告知编译错误

把最后一行注释掉,就可以解决此冲突

#[derive(Debug)]
struct Foo;

impl Foo {
    fn mutate_and_share(&mut self) -> &Self {
        &*self
    }
    fn share(&self) {}
}

fn main() {
    let mut foo = Foo;
    let loan = foo.mutate_and_share();
    // loan 生命周期到此结束,同时可变引用也结束,此时再获取不可变引用,将不会发生冲突
    foo.share();
    // println!("{:?}", loan);
}

代码示例2

#![allow(unused)]
fn main() {
    use std::collections::HashMap;
    use std::hash::Hash;
    fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
    where
        K: Clone + Eq + Hash,
        V: Default,
    {
        match map.get_mut(&key) {
            Some(value) => value,
            None => {
                map.insert(key.clone(), V::default());
                map.get_mut(&key).unwrap()
            }
        }
    }
}

以上代码同样会提示错误

 cannot borrow `*map` as mutable more than once at a time

不能同一时间有多个可变借用

主要原因在 match 表达式中,分析如下

image-1741007524953

这段代码出错的原因来自于生命周期消除规则和 match 表达式规则的结合

当一个引用作为 match 表达式的 “判断条件” 时:

  1. 如果这个引用不需要在 match 分支中继续使用或返回,那么它的生命周期就仅限于用于模式匹配的那一刻
  2. 如果这个引用需要在某个分支中返回或继续使用,那么它的生命周期就会延续到整个 match 表达式结束

修改后可通过编译的代码

#![allow(unused)]
fn main() {
    use std::collections::HashMap;
    use std::hash::Hash;
    fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
    where
        K: Clone + Eq + Hash,
        V: Default,
    {
    	// 获取的不可变引用
        match map.get(&key) {
            Some(value) => {
            	// value 在后续并未使用,所以不可变引用的生命周期已经结束
                map.get_mut(&key).unwrap()
            },
            None => {
                map.insert(key.clone(), V::default());
                map.get_mut(&key).unwrap()
            }
        }
    }
}

综合示例

一个综合的生命周期错误示例

struct Interface<'a> {
    manager: &'a mut Manager<'a>
}

impl<'a> Interface<'a> {
    pub fn noop(self) {
        println!("interface consumed");
    }
}

struct Manager<'a> {
    text: &'a str
}

struct List<'a> {
    manager: Manager<'a>,
}

impl<'a> List<'a> {
      // 此处生命周期参数 'a 是编译错误的核心原因
      pub fn get_interface(&'a mut self) -> Interface {
          Interface {
              manager: &mut self.manager
          }
      }
}

fn main() {
    let mut list = List {
        manager: Manager {
            text: "hello"
        }
    };

    list.get_interface().noop();

    println!("Interface should be dropped here and the borrow released");

    // 下面的调用会失败,编译器提示同时有不可变/可变借用
    use_list(&list);
}

fn use_list(list: &List) {
    println!("{}", list.manager.text);
}

先分析代码,这是 Manager 定义

struct Manager<'a> {
    text: &'a str
}

此处可以分析出 Manager 的生命周期参数 'a 实际上就是 text 的生命周期
同时表明:整个结构体的生命周期不能大于这个字符串切片的生命周期

结构体 Interface

struct Interface<'a> {
    manager: &'a mut Manager<'a>
}

可以看出 Interface 中的生命周期 'a 来自于 Manager 中的生命周期 'a
Manager 中的生命周期 'a 就是其中字符串切片 text 的生命周期,如下图

image-1741066995778

同样的对于结构体 List 也是此逻辑

struct List<'a> {
    manager: Manager<'a>,
}

image-1741066616804

根据上面的结论,再来分析问题代码段

impl<'a> List<'a> {
      // 此处生命周期参数 'a 是编译错误的核心原因
      pub fn get_interface(&'a mut self) -> Interface {
          Interface {
              manager: &mut self.manager
          }
      }
}

List<'a> 实现了方法,方法参数为 &'a mut self一个对 self 的可变引用,生命周期为 'a,由于前面我们分析出 List'a 实际上就是 Managertext'a,此时我们可以得到一个非常重要的结论

get_interface 方法参数中对于 self 的可变引用的生命周期就取决于 self.manager.text 的生命周期

再来看 main 函数中的代码

fn main() {
    let mut list = List {
        manager: Manager {
            text: "hello"
        }
    };

    list.get_interface().noop();

    println!("Interface should be dropped here and the borrow released");

    // 下面的调用会失败,因为同时有不可变/可变借用
    // 但是Interface在之前调用完成后就应该被释放了
    use_list(&list);
}

按照一般逻辑,此处获取不可变引用,然后调用 noop 传入所有权,此行代码结束后,可变引用应该被释放才对

list.get_interface().noop();

但是由于 get_interface 生命周期的约束条件,编译器认为可变引用的生命周期还未结束,如下图

image-1741068490939

所以核心问题在于 可变引用活得太久了,如何解决呢?

思路就是不让 list 的可变引用生命周期于 text 相关,给它一个单独的生命周期,同时表明单独生命周期于 list 生命周期参数的大小关系

struct Interface<'b, 'a: 'b> {
    manager: &'b mut Manager<'a>
}

impl<'b, 'a: 'b> Interface<'b, 'a> {
    pub fn noop(self) {
        println!("interface consumed");
    }
}

struct Manager<'a> {
    text: &'a str
}

struct List<'a> {
    manager: Manager<'a>,
}

impl<'a> List<'a> {
    pub fn get_interface<'b>(&'b mut self) -> Interface<'b, 'a>
    where 'a: 'b {
        Interface {
            manager: &mut self.manager
        }
    }
}

fn main() {

    let mut list = List {
        manager: Manager {
            text: "hello"
        }
    };

    list.get_interface().noop();

    println!("Interface should be dropped here and the borrow released");

    // 下面的调用可以通过,因为Interface的生命周期不需要跟list一样长
    use_list(&list);
}

fn use_list(list: &List) {
    println!("{}", list.manager.text);
}
0

评论区