为什么Rust生命周期这么难?因为你一直在用语法糖

前言

很多rust教程里,把生命周期标记'a视为rust中最难理解的一部分,尤其某些函数、结构体,必须声明生命周期标记,否则会报错,这个情况尤其让很多人难以理解,对着官方教程里的longest函数空流泪。

其实生命周期理解困难的本质在于:你写&可以省略生命周期标记。正是这个官方给你偷懒的一个语法糖,但却造成了生命周期有了编译器魔法的难理解。你只要不偷懒,给每个引用都加上生命周期标记,你就知道为什么longest函数为什么要加上'a了。

要理解生命周期,我们首先要树立一个观念:Rust 里的每一个“引用(&)”,其实头顶上都顶着一个生命周期标记。

1.不省略生命标记,写echo函数

比如我们非常简单的一个echo的函数,你会这么写:

fn main() {
    let a  = "Hello, world!";
    let result = echo(a);
    println!("{}", result);
}

fn echo(input: &str) -> &str {
    input
}

非常简单对吧,其实你这里省略了引用的生命周期,真正完整的写法应该是:

fn echo<'a>(input: &'a str) -> &'a str {
    input
}

返回值的生命周期,一定来自于输入参数的生命周期。这句话好理解吧?

这里的输入参数就一个input,它的生命周期是'a,所以返回的也是'a

在编译器魔法里:如果只有一个输入参数,那么这个标签会自动赋给所有输出参数。所以当你写下fn echo(input: &str) -> &str的时候,编译器会把它自动脑补成fn echo<'a>(input: &'a str) -> &'a str

2.理解longest函数

文档里有个longest函数,教程里经典的生命周期教学,但是理解起来并不容易:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

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

这里为什么要有生命周期呢?其实我们只需要还原它真正的写法,你就知道为什么了:

  • 可能性1
    'b>='a,那么返回值的时间周期肯定要取min('a, 'b)='a
fn longest<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
  • 可能性2
    'a>='b,那么返回值的生命周期肯定要取min('a,'b)='b
fn longest<'a: 'b, 'b>(x: &'a str, y: &'b str) -> &'b str {
    if x.len() > y.len() { x } else { y }
}

合并下这两种情况:

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

这里的 'min_lifetime 不再是某一个变量的实际寿命,它代表的是 所有相关引用的重叠安全区。只要 xy 的寿命都覆盖了这个区域,代码就是安全的。

再缩略一点:

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

就是教程里的那个形式了。

3.min_lifetime最小公约数的局限性

我相信看了第2个例子,你会有个疑问,如果函数标记用的是多个引用输入参数的生命周期的最小公约数为什么编译器不自动用min_lifetime来处理存在多个引用输入的函数呢?

因为多个引用输入参数中,可能有的完全不参与返回值引用的计算,比如我们有个偏心的函数get_first,是只返回第一个参数,第二个参数没用

如果编译器“自作聪明”自动加了 min_lifetime,编译器会把它脑补成:

fn get_first<'a>(first: &'a str, _second: &'a str) -> &'a str {
    first
}

这会导致一个非常荒唐的结果:

fn main() {
    let s1 = String::from("long long lifetime");
    let res;
    {
        let s2 = String::from("near death lifetime");
        res = get_first(&s1, &s2); // 编译器:res 现在的寿命等于 s2 的寿命
    } // s2 死了,res 也被编译器判了死刑
    
    println!("{}", res); // 报错!尽管 res 指向的 s1 还活得好好的。
}

这里我们就会看到min_lifetime的副作用了。要想在这个环境下成功运行,应该你自己手动写完整的生命周期才行:

fn get_first<'a, 'b>(first: &'a str, _second: &'b str) -> &'a str {
    first
}

生命周期的本质,是你给编译器做的一个承诺。实际上每个引用,都带有生命周期,如果你不理解的话,你只需要不怕麻烦地给每个引用参数都加上生命周期标记,你就很容易理解这其中的门道了。

4.结构体里&self的生命周期

我们写结构体的时候,比如:

struct Student {
    name: &str,
    age: u8,
}

这里只要有一个引用,就会提示报错,一定要你写上生命周期标记:

struct Student<'a> {
    name: &'a str,
    age: u8,
}

这是因为函数是瞬时状态,可以推导类型;而结构体是一个长期的“容器”,必须强制要求这个“容器”的生命周期不能长于内部引用的生命周期。

多引用参数的结构体如下:

struct Student<'a, 'b> {
    name: &'a str,
    description: &'b str,
    age: u8,
}

impl<'a, 'b> Student<'a, 'b> {
    fn longest_str(&self) -> &str {
        if self.name.len() > self.description.len() {
            self.name
        } else {
            self.description
        }
    }
}

我们在这里实现了和longest类似的一个longest_str,这里其实真正去掉缩略生命周期之前的标记是这样的:

impl<'a, 'b> Student<'a, 'b> {
    fn longest_str<'c>(&'c self) -> &'c str {
        if self.name.len() > self.description.len() {
            self.name
        } else {
            self.description
        }
    }
}

'c = self的生命周期,在编译器的规则里:方法返回值默认绑定&self,这里头隐含了一个条件,就是'c ≤ 'a && 'c ≤ 'b,这样才能保证安全。'c并不是min('a, 'b),只是受约束 'c ≤ min('a,'b)

5.impl后面为什么要写生命周期?

其实生命周期标记,本质上跟范型没什么区别。

struct Student<T, U> {
    name: T,
    description: U,
    age: u8,
}

impl<T, U> Student<T, U> {
    // ...
}

这里头,impl 后面必须写 <T, U>,表示我为所有TU类型版本的Student都实现这个方法。

要记住!!

生命周期 'a, 'b 本质就是 泛型类型的一种特殊形式。

可以说在编译器里面,&'a str&'b str是不同类型,哪怕他们的空间类型一致,但是因为时间维度不同,可以视作不同类型。

假设我们不想给所有生命周期的Student实现方法,而是只想给生命周期为'static(也就是和整个程序活得一样长的静态生命周期)的Student实现某个特有的方法,我们会这么写:

struct Student<'a, 'b> {
    name: &'a str,
    description: &'b str,
    age: u8,
}

impl Student<'static, 'static> {
    fn only_static(&self) {
        println!("This Student lives forever!");
    }
}

下面我们在main函数里验证一下:


fn main() {
    let static_student = Student {
        name: "Alice",                 // &'static str
        description: "A top student",  // &'static str
        age: 20,
    };

    static_student.only_static(); 
}

这里会完美运行,因为static_student满足Student<'static, 'static>的类型签名。

但是如果我们传入的不是&'static str

fn main() {
    let local_name = String::from("Bob");
    
    let normal_student = Student {
        name: local_name.as_str(), // 这里的生命周期被限制在 main 函数的作用域内,并非 'static
        description: "Just a normal guy", 
        age: 21,
    };

    normal_student.only_static(); 
}

这里会直接报错:

   |
17 |     let local_name = String::from("Bob");
   |         ---------- binding `local_name` declared here
...
20 |         name: local_name.as_str(), // 这里的生命周期被限制在 main 函数的作用域内,并非 'static
   |               ^^^^^^^^^^ borrowed value does not live long enough
...
25 |     normal_student.only_static(); 
   |     ---------------------------- argument requires that `local_name` is borrowed for `'static`
...
30 | }
   | - `local_name` dropped here while still borrowed

这个例子我们可以看到,生命周期标记甚至可以作为“类型签名”的一部分参与方法解析

6.深入对比生命周期与范型

1. 单通用实现

还是拿上面的Student做例子:

// 类型泛型:为所有类型的 Student 实现
impl<T, U> Student<T, U> {
    fn hello(&self) { /* ... */ }
}

// 生命周期泛型:为所有生命周期的 Student 实现
impl<'a, 'b> Student<'a, 'b> {
    fn hello(&self) { /* ... */ }
}

2. 单特化实现

// 类型特化:只给存储字符串和整数的 Student 实现
impl Student<String, i32> {
    fn special_logic(&self) { /* ... */ }
}

// 生命周期特化:只给引用静态字符串的 Student 实现
impl Student<'static, 'static> {
    fn only_static(&self) {
        println!("I live forever!");
    }
}

3.生命周期+范型混合

3.1 通用实现

struct Student<'a, T> {
    name: &'a str,
    data: T,
}

// 这里在 impl 后面声明了两个变量:'a (活多久) 和 T (是什么)
impl<'a, T> Student<'a, T> {
    // 构造函数
    fn new(name: &'a str, data: T) -> Self {
        Self { name, data }
    }

    // 打印方法
    // 注意:即使 data 的类型 T 不确定,只要 T 实现了 Display 就能打印
    // 但这里我们先写一个最基础的
    fn get_name(&self) -> &'a str {
        self.name
    }
}

3.2 特化实现

  • 特化范型
// 注意:这里 impl 后面只剩 <'a> 了,因为 T 已经被确定为 i32
impl<'a> Student<'a, i32> {
    fn add_to_data(&mut self, val: i32) {
        self.data += val;
    }
}
  • 特化生命周期
// 注意:这里 impl 后面什么都不加!
// 因为生命周期是具体的 'static,类型是具体的 String
impl Student<'static, String> {
    fn promote_to_permanent(&self) {
        println!("{} is a permanent student with data: {}", self.name, self.data);
    }
}

可以看到在rust中,手动特化一个具体的生命周期几乎只有 'static 这一种情况

感慨rust的类型系统实在是复杂又强大。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容