前言
既上次的反思后, 我开始继续学 Rust. 但是我对生命周期一直没搞懂. 现在我略微搞懂了一点, 由此记录.
这也可以说是我的一点暴论吧, 也算是思考过程.
参考资料
- The Rust Programming Book Section 4.2
- The Rust Programming Book Section 10.3
- The Rustonomicon Section 3
- 大名鼎鼎的 AI: Gemini, ChatGPT (笑)
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 只是我们暂时设的未知数! 它最终的结果是由编译器确定的, 也就是已知的! 这样子声明形参的话, 我们只知道 a 和 b 是一个类型的变量, 那编译器看到这就会很疑惑:
诶? 这个生命周期变量 '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 的黄牌警告.
同理, 如果我们将 world 和 hello 反一下, 结果是一样的, 生命周期保护成了摆设.
所以, 为了防止这种事情的发生, 我们必须让 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 strworld的类型是&'world strworld的生命周期小于input的生命周期!- 那么, 当代码运行到
println的时候,world变量已经被释放掉了! - UAF!
所以, 我们可以看到, 若 T 为引用类型, 纵然 &'a T 的生命周期 'a 被协变了, 但是 T 是不能变成比它更小的生命周期的.
这个时候, 我们把 T(&'b str) 看作 Sub, &'a T(&'a &'b str) 看作 F<Sub>, Super 同理.
像这种 Sub 为 Super 的子类型, 但是 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