[深入理解 Rust] 1 生命周期

前言

既上次的反思后, 我开始继续学 Rust. 但是我对生命周期一直没搞懂. 现在我略微搞懂了一点, 由此记录.

这也可以说是我的一点暴论吧, 也算是思考过程.

参考资料

1 生命周期: 连接时空的桥梁

对我来说, 生命周期很神奇.

在一段程序中, 时间 是变量的存活时间, 而空间是变量指向的物理内存区域.

写过操作系统的人都知道, 内存地址其实甚至不由 C 语言来管理, 它们由操作系统的内核管理.

也就是说, 实际上访问了一块错误内存, 会立马触发 #PF, 然后内核的 page_fault_handler 发送 SIGSEGV 信号给程序, 程序执行默认的 SIGSEGV Handler.

至于时间, 那 C 语言更是管不了了, push_ref 之类的操作全凭程序员自觉.

但是, Rust 却有意识的将时间和空间维度给结合起来, 创建了一套新的时间规则, 来约束空间规则 , 也就是生命周期.

这也是我感觉精妙的点.

2 生命周期的意义

一个 Rust 萌新可能会对这样的代码产生生理性的厌恶:

rust 复制代码
fn foo(a: &str, b: &str) -> &str {
    b
}

这段代码可以说必然会被 Rust 斩首, 因为它未明确生命周期.

正确的代码应该是这样的:

rust 复制代码
fn foo<'a>(a: &'a str, b: &'a str) -> &'a str {
    b
}

这类型的问题其实我们已经司空见惯. 但是为什么第一个为什么就会被斩首, 而第二个就行呢? 生命周期到底是什么?

2.1 生命周期的标记

在我看来, Rust 的生命周期其实是一段范围. 但是这个范围我们目前不知道, 由编译器确定. 所以我们设一个未知数, 例如 'x, 来表示这个生命周期的范围.

我也不解释太多, 更多的解释详见:

相信我, 如果你学过 The Book 的话, 你肯定看得懂, 并且会 "哦~~~~~~". 它会教你如何对一段 Rust 程序进行脱糖.

2.2 Rust 的引用变量类型

众所周知, 生命周期的初衷, 就是 Rust 为了避免悬垂引用而整出来的工具.

而两个不同的生命周期, 在 rustc 的眼中, 它们的类型其实是不一样的. 也就是说, &'a str&'b str 是两个不同的类型.

这个时候有人就会问了, 那我们平时见到的对某个变量的 &str 呢? 它们是同一个类型吗?

我觉得不是(这也是上面编译报错的原因). 因为 &str 其实只是 Rust 帮你省略了生命周期, 生命周期并不是不存在了.

所以, 我们也知道, 生命周期它不会凭空产生 , 也不会凭空消失. 毕竟你不可能凭空造出一个生命周期范围对不对.

那假设引用变量当作一个函数的形参呢?

因为函数它不一样, 函数面对生命周期可以说"具体问题具体分析". 函数变量的生命周期它是不确定的.

那有人说, 我这样行不行呢?

rust 复制代码
// Code A:
fn foo(a: &'a str, b: &'a str) -> &'a str {

}

别忘了, 这个 'a 只是我们暂时设的未知数! 它最终的结果是由编译器确定的, 也就是已知的! 这样子声明形参的话, 我们只知道 ab 是一个类型的变量, 那编译器看到这就会很疑惑:

诶? 这个生命周期变量 'a 我没见到过啊? 什么鬼? 我不到啊! 报错, 报错!!!!

所以, 我们必须将 foo 改为 foo<'a>, 这个代表我们从外部传递进来一个生命周期 'a, 参数 a 和 b 的生命周期是这个 'a . 所以, 在这个函数中, 参数 a 和参数 b 的生命周期是一样的.

这个就是生命周期在形参的本质.

那思考一下, 那为什么我们在单个引用形参的函数中, 一般不带生命周期呢? 例如

rust 复制代码
fn foo(a: &str) -> &str {

}

那我把它详细写出来, 变成这样可不可以?

rust 复制代码
fn foo<'a>(a: &'a str) -> &'a str {

}

Perfect! 和上一段是一样的! 因为你没必要考虑多参数下的变量生命周期关系, 所以这一段和上一段完全等价.

那既然如此, 我们还是选择上面的吧, 至少写起来简单点, 下面的一看就瘆人(笑)。

复制代码
The Rustonomicon Introduction:
本知识"按原样"提供,不作任何明示或暗示的保证,包括但不限于保证释放无法形容的恐怖,这些恐怖会粉碎你的精神,让你的思想在未知无限的宇宙中漂流。

好了, 这个时候就必须面对一个问题:

那为什么

rust 复制代码
fn debug<'a>(a: &'a str, b: &'a str) {
    println!("a = {a:?} b = {b:?}");
}

fn main() {
    let hello: &'static str = "hello";
    {
        let world = String::from("world");
        let world = &world; // 'world has a shorter lifetime than 'static
        debug(hello, world);
    }
}

这样的代码也能通过呢? 明明 hello 的生命周期是 &'static, world 的生命周期是 &'world. 按照道理, 它们应该不通过啊?

这个说来话长...

3. 生命周期的变异性(协变, 逆变, 不变)

我们先来看上面的 foo<'a> 函数.

我们给 foo 加上了生命周期模板 'a, 那这个 'a 肯定是外部传递进来的.

然后我们来看这么一段脱糖后的调用代码:

rust 复制代码
fn main() {
    let hello: &'static str = "hello";
    'world: { // 错误示范, 对应 The Rustonomicon 的脱糖代码
        let world = String::from("world");
        let world = &world;
        let c: &'x = foo<&'x>(&'static hello, &'world world);
    }
    println!("{c}");
}

3.1 'a 到底是什么?

但是这个 'a 到底是什么呢?

我们可以看到, 假设 'x 的生命周期不是 'world 而是 'static, 那么这段代码会瞬间 UAF, 然后收到 Linux 的黄牌警告.

同理, 如果我们将 worldhello 反一下, 结果是一样的, 生命周期保护成了摆设.

所以, 为了防止这种事情的发生, 我们必须让 x 的生命周期位于 &'world 中.

由此我们可以看出, 传入的生命周期必须为对应参数的交集, 否则就有可能出现 UAF.

3.2 子类型

这个不多讲, The Rustonomicon 中都有, 简单略过.

假设有两个生命周期变量 'life1'life2

  • 如果 'life1 存活时间包含 'life2
  • 那么我们把 'life1 称为 'life2子类型.

3.3 协变

&'static&'world 明明不一样, 为什么它们仍然能被当作函数中的 &'a 被传递进去呢? 这就涉及到一个关键点--生命周期协变.

The Rustonomicon 中, 协变是这么定义的:

给定两个类型 Sub 和 Super,其中 Sub 是 Super 的子类型:

F 是协变的,如果 F<Super> 是 F<Sub> 的子类型.

吓哭了, 隔着屏幕都能感受到那"不可名状的恐怖".

别慌, 我们来解构一下.

在 3.1 我们讲过, 实际传入的 'x = 'world. 那么, 根据子类型的定义:

  • F<Sub> = &'static str, F<Super> = &'world str, Sub = 'static, Super = 'world
  • 因为 'static'world 的子类型
  • 所以 &'static str&'world str 的子类型
  • 所以 &'static str 可以被自动转换成 &'world str

所以, 这段代码

rust 复制代码
let c: &'x = foo<&'x>(&'static hello, &'world world);

其实等价于下面这段代码

rust 复制代码
let c: &'world = foo<&'world>(&'world hello, &'world world);

像这种因为 'static'world 的子类型, 所以 &'static str&'world str 的子类型, 所以可以安全转换的, 我们叫做 变量的协变.

3.4 不变

来看位于 The Rustonomicon 的下面一段代码:

rust 复制代码
fn assign<T>(input: &mut T, val: T) {
    *input = val;
}

fn main() {
    let mut hello: &'static str = "hello";
    {
        let world = String::from("world");
        assign(&mut hello, &world);
    }
    println!("{hello}");
}

老办法, 我们进行脱糖

rust 复制代码
fn assign<'a, T>(input: &'a mut T, val: T) {
    *input = val;
}

fn main() {
    let mut hello: &'static str = "hello";
    'world: {
        let world = String::from("world"); 
        'life1: {
            assign<'life1, &'world mut str>(&'life1 mut &'static  hello, &'world world);
        }
    
    println!("{hello}");
}

这一看就是出问题的节奏啊. 类似 C 的指针一样, 我们传递了 input(&'static str) 的引用, 然后改变了 input 的值.

如果我们将这个函数的 T 泛化为一个引用 , 那么 &T 本质上就是 C 语言的一个二级指针 , 而 assign 函数其实就是对这个二级指针进行解引用和赋值.

问题是, 这段代码它也会 UAF! 因为你将一个存活期较小的变量赋值给了一个存活期较大的变量!

在这个例子中:

  • input 的类型是 &'static str
  • world 的类型是 &'world str
  • world 的生命周期小于 input 的生命周期!
  • 那么, 当代码运行到 println 的时候, world 变量已经被释放掉了!
  • UAF!

所以, 我们可以看到, 若 T 为引用类型, 纵然 &'a T 的生命周期 'a 被协变了, 但是 T 是不能变成比它更小的生命周期的.

这个时候, 我们把 T(&'b str) 看作 Sub, &'a T(&'a &'b str) 看作 F<Sub>, Super 同理.

像这种 SubSuper 的子类型, 但是 F<Sub> 却并不能看作 F<Super> 的子类型的, 我们把它叫做不变.

3.5 逆变

这个主要考虑很特殊的一种情况: 函数指针 , 也就是 fn(x: &'a str) -> ().

道理其实很简单. 我们对比两个函数签名:

rust 复制代码
fn f1(x: &'a str);
fn f2(x: &'static str);

假设我定义一个 fn(x: &'a str), 传入 f1, f2. 那这个 &'a 可以处理 &'static 的生命周期吗?

当然可以!

像这种因为 'a'static 的子类型, 但是 &'static str&'a str 的子类型, 所以可以安全转换的, 我们叫做 变量的逆变.

The End

本期文章写到这, 感谢大家的观看哦~萌新初涉 Rust 编程, 有错误也请多多指正~

版权声明: 本文采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

作者: Sudo-su-Bush

发布时间: 2026-07-18

原文链接: https://www.cnblogs.com/SudosuBash/p/21631773