深入浅出生命周期:认识生命周期

深入浅出生命周期:认识生命周期

很多人学 Rust 第一座大山就是生命周期,甚至因为它直接放弃,所以我打算出一个系列文章讲 Rust 的生命周期,从基础概念到进阶,带你一篇一篇带你吃透它。

生命周期是干什么的

生命周期的核心作用是:让编译器明确引用的存活范围,以及引用之间的生命周期关系,在编译期就杜绝悬垂引用这个内存安全问题。

先从一个最简单的例子开始,Rust 编译器其实自己就能识别垂悬引用:

rust 复制代码
fn main() {
    let borrow;
    {
        let s = String::from("hello rust");
        borrow = &s; // 将 s 的引用赋值给 borrow
    } // 这里 s 被销毁(释放内存),borrow 成了悬垂引用
    println!("borrow: {}", borrow); // 试图使用已经失效的引用,编译器直接报错
}

这种单作用域内的简单场景,Rust 的借用检查器能轻松推断出引用的存活范围,直接拦截错误。

但是,当涉及到引用跨函数、跨作用域传递时,编译器就无法自动推断引用的生命周期关系了。它无法判断函数返回的引用,到底和哪个入参的生命周期绑定,也就没法判断返回的引用会不会变成垂悬引用。

比如这个 Rust 生命周期最经典的入门示例,直接编译会报错:

rust 复制代码
// 报错:missing lifetime specifier(缺少生命周期标注)
fn longer_str(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

这个函数逻辑很简单:传入两个字符串切片,返回更长的那个。但编译器不知道:返回的引用,到底是跟着 x 的生命周期走,还是跟着 y 的生命周期走?

这时候,就需要我们通过生命周期标注,手动告诉编译器引用之间的生命周期关系。

生命周期显式标注

基础语法规则

记住这三个核心语法规则,就能上手标注:

  • 生命周期参数以单引号 开头,后跟小写字母(社区惯例用 'a'b,也可以用有意义的名字如 'name);
  • 生命周期参数必须先在 <> 中声明,才能在参数和返回值中使用;
  • 标注位置固定在 & 符号之后,类型之前,比如 &'a str&'a mut i32

函数中的生命周期标注

函数是生命周期标注最基础、最常用的场景,核心目的只有一个:把返回值的生命周期,和入参的生命周期绑定

我们给上面报错的 longer_str 示例加上正确的生命周期标注:

rust 复制代码
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

在函数中,生命周期标注的含义是:在所有被同名生命周期标注的入参和返回值中,返回值的生命周期等于所有入参生命周期的最小值,在这个示例中也就是:

plaintext 复制代码
返回值的生命周期 = min(x 的生命周期, y 的生命周期)

为什么是"最小值"呢?这里我们通过一个调用 longer_str 函数的示例来更直观的理解,示例如下:

rust 复制代码
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let ret;
    let s1 = String::from("hello");
    let s1 = s1.as_str();                    //  - s1  
    {                                        //  |
        let s2 = String::from("world!");     //  |
        let s2 = s2.as_str();     //  - s2   //  |
        ret = longer_str(s1, s2); //  |      //  |
        print!("{}", ret);        //  |      //  |
    }                             //  -      //  |
}                                            //  -

在这个示例中,我模拟编译器标注出了 s1s2 的生命周期,s2 的生命周期更小,所以 'a 被解析为 s2 的生命周期,编译器也就能推断出 ret 的生命周期为 s2 的生命周期。毕竟 longer_str(s1, s2) 有可能返回 s2,如果 ret 离开 s2 的生命周期,就会立即变成悬垂引用。

最后,有条函数标注的铁则需要谨记:Rust 绝对不允许函数返回内部局部变量的引用,哪怕你加了生命周期标注也不行:

rust 复制代码
// 绝对报错:返回了局部变量 i 的引用
fn get_num<'a>() -> &'a i32 {
    let i = 10;
    &i
}

原因很简单:函数执行完,局部变量 i 就会被销毁,返回的引用必然是悬垂引用。这也印证了标注的规则:函数返回的引用,生命周期来自入参,唯一的例外是 'static 静态生命周期(后续系列文章会讲)

结构体中的生命周期标注

只要你的结构体里包含引用类型的字段,就必须给结构体加上生命周期标注。

rust 复制代码
// 声明生命周期参数'a,标注所有引用字段
struct User<'a> {
    name: &'a str,
    desc: &'a str,
}

在结构体中,生命周期标注的含义是:结构体实例的生命周期等于它包含的引用字段的生命周期的最小值,在这个示例中也就是:

plaintext 复制代码
User 实例的生命周期 = min(name 的生命周期, desc 的生命周期)

换句话说,结构体实例必须"死在"它引用的内容之前,避免内部字段变成悬垂引用。我们看一个反例:

rust 复制代码
struct User<'a> {
    name: &'a str,
    desc: &'a str,
}

fn main() {
    let user;
    let name = String::from("张三");
    {
        let desc = String::from("Rust 开发者");
        user = User { name: &name, desc: &desc };
    }
    // 报错:desc 已经销毁,user 内部的引用成了垂悬引用
    println!("{}: {}", user.name, user.desc);
}

方法中的生命周期标注

给带生命周期的结构体实现方法时,必须在 impl 关键字后声明和结构体一致的生命周期参数,才能在方法中使用。

rust 复制代码
impl<'a> User<'a> {
    // 入参和返回值的生命周期绑定,这里的 Self 等价于 User<'a>
    fn new(name: &'a str, desc: &'a str) -> Self {
        User { name, desc }
    }

    // 这里没有标注生命周期,编译器会自动补全(后面省略规则会讲)
    fn get_name(&self) -> &str {
        self.name
    }
}

这里有个关键细节:impl<'a> User<'a> 中,第一个 'a 是生命周期参数的声明,第二个 'a 是给结构体绑定生命周期,二者必须对应。

Trait 中的生命周期标注

Trait 中的生命周期标注,核心作用是把 Trait 方法的返回值生命周期,和实现该 Trait 的类型的生命周期绑定。对于入门阶段,我们只需要掌握最基础的写法即可:

rust 复制代码
// Trait 声明生命周期参数 'l,绑定返回值的生命周期
trait GetName<'l> {
    fn get_name(&self) -> &'l str;
}

// 给 User 结构体实现 Trait,用 'a 统一绑定二者的生命周期
impl<'a> GetName<'a> for User<'a> {
    fn get_name(&self) -> &'a str {
        self.name
    }
}

生命周期省略规则

看到这里你可能会问:我平时写 Rust 代码,很多时候根本没写生命周期标注,也能编译通过,这是为什么?

答案是:Rust 设计了生命周期省略规则(Lifetime Elision Rules),对于常见的简单场景,编译器会自动帮你补全生命周期,不用手动标注。

在讲规则之前,先记住两个核心概念:

  • 输入生命周期:函数/方法入参中引用的生命周期
  • 输出生命周期:函数/方法返回值中引用的生命周期

省略规则一共三条,按顺序执行,仅适用于函数和方法,结构体的生命周期不能省略。

规则一:每个没有标注的引用入参,自动分配一个独立的生命周期

编译器会给函数中每一个没有标注的引用参数,分配一个互不相同的生命周期参数。

rust 复制代码
// 你写的代码
fn eq_str(x: &str, y: &str) -> bool {
    x == y
}

// 编译器自动补全后的代码
fn eq_str<'a, 'b>(x: &'a str, y: &'b str) -> bool {
    x == y
}

这个函数没有返回引用,所以补全输入生命周期就够了,完全不用手动标注。

规则二:如果只有一个输入生命周期,自动把它分配给所有输出生命周期

如果函数只有一个引用入参,编译器会默认返回值的生命周期和这个入参完全一致,这是日常开发中最常用的规则。

rust 复制代码
// 你写的代码
fn first_char(s: &str) -> &str {
    &s[0..1]
}

// 编译器自动补全后的代码
fn first_char<'a>(s: &'a str) -> &'a str {
    &s[0..1]
}

规则三:如果有多个输入生命周期,但第一个参数是 & self/&mut self,自动把 self 的生命周期分配给输出生命周期

这条规则是专门为方法设计的,方法的返回值,绝大多数情况都来自 self 的内部字段,所以编译器会自动把返回值的生命周期和 self 绑定。

rust 复制代码
// 你写的代码
impl<'a> User<'a> {
    fn get_name(&self) -> &str {
        self.name
    }
}

// 编译器自动补全后的代码

运行
impl<'a> User<'a> {
    fn get_name<'b>(&'b self) -> &'b str {
        self.name
    }
}

什么时候必须手动标注

三条省略规则执行完后,如果输出生命周期仍然无法确定,编译器就会报错,这时候必须手动标注生命周期。

总结

本篇文章,我们搞懂了生命周期到底是用来解决什么问题的,学会了显式标注的场景与规则,也掌握了生命周期的省略规则,知道什么时候可以省略,什么时候必须手动标注。

这只是我们生命周期系列的开篇,后续文章里,我们会继续讲解生命周期的进阶特性,比如再借用、静态生命周期,生命周期约束、高阶生命周期等等,一步步带你吃透 Rust 生命周期,彻底攻克这个入门路上的"拦路虎",敬请期待。

相关推荐
小杍随笔7 小时前
【Rust 语言编程知识与应用:元编程详解】
开发语言·后端·rust
希夷小道7 小时前
gitru:一个由 Rust 打造的零依赖 Git 提交信息校验工具
git·rust
Ivanqhz7 小时前
linearize:控制流图(CFG)转换为线性指令序列
开发语言·c++·后端·算法·rust
集智飞行8 小时前
安装rust和cargo
开发语言·后端·rust
beifengtz9 小时前
Rust 实现 KCP 可靠 UDP 通信:kcp-io 库快速上手指南
网络协议·rust·udp·kcp
Source.Liu1 天前
【Rust】Cargo 命令详解
rust
大卫小东(Sheldon)1 天前
集成AI 的 Redis 客户端 Rudist发布新版了
ai·rust·rudist
无心水1 天前
【时间利器】5、多语言时间处理实战:Go/C#/Rust/Ruby统一规范
golang·rust·c#·时间·分布式架构·openclaw·openclaw变现