告别linter驱动编程(※怎么可能) - Rust生命周期
草,第一次感觉需要给 blog 加一个 Toc。还有不喜欢这个 typography。
学习 Rust 很长一段时间,我虽然理解「生命周期」这个概念,但在实际写代码时,往往不知道什么时候该标注,标注有时也看不懂💦,都是靠 linter 提示,我甚至觉得编译器有能力推断出大部分生命周期。
这次借助喷气大脑学院的课程重新学习了下。
前置知识:所有权和借用
这个在单线程的模型里应该比较好理解,感觉没必要写了。
为什么要有生命周期?
在大多数有 GC(垃圾回收)的语言中,我们不需要关心对象活多久。但在 Rust 中,为了保证内存安全且无需 GC,编译器必须确保:引用的数据在引用被使用期间必须是有效的,换句话说引用的生命周期不能长于它所引用的数据的生命周期,这是为了防止悬垂指针。
fn main() {
let i = 3; // Lifetime for `i` starts. ────────────────┐
{ │
let borrow = &i; // `borrow` lifetime starts. ──┐│
println!("borrow: {}", borrow); ││
} // `borrow` ends. ──────────────────────────────────┘│
} // Lifetime ends. ─────────────────────────────────────┘
虽然此处
borrow的作用域到花括号结束,但在现代 Rust (NLL) 中,引用的实际有效生命周期是在最后一次使用处结束。
自动省略规则(什么时候可以不写)
Rust 有一组借用省略规则(lifetime elision),所以常见简单情况不需显式标注。
-
第一条规则是每一个是引用的参数都有它自己的生命周期参数。
-
如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:
fn foo(x: &str) -> &str { x }
编译器自动把返回值和输入 x 绑定。
- 如果输入参数中有 &self 或 &mut self,那么 self 的生命周期会被赋给所有输出生命周期参数。
impl A { fn name(&self) -> &str { &self.field } }
编译器假设返回值的生命周期与 self 相同。
生命周期标注
当函数返回的引用可能来自多个输入引用时,这类签名无法套用生命周期省略规则,编译器也无法自动推断,必须显式标注。
典型情况是“两个输入引用,返回其中一个”:
fn pick(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
| fn pick(x: &str, y: &str) -> &str {
| ---- ----- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
| fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
这时编译器建议了让函数的两个输入使用相同的生命周期'a
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
这段标注的真实含义是什么? 有人会误以为这里的“相同”意味着输入的生命周期真的一样长。它们当然可能不一样长,标注只是让编译器把它们放进“同一约束里”而已。
- 对输入的约束:x 和 y 的生命周期必须涵盖
'a。换句话说,'a是x和y生命周期的交集(即两者中较短的那个生命周期)。 - 对输出的约束:返回值的生命周期也不会超过
'a。
生命周期标注不会改变任何引用的实际存活时间,它只是在类型签名中描述引用之间的关系,以便编译器进行安全检查。
回到调用处:
fn main() {
let ret: &str;
let a = String::from("Hello");
{
let b = String::from("World!!");
ret = pick(a.as_str(), b.as_str());
}
println!("{}", ret);
}
编译器在检查 pick 的调用时,会执行以下逻辑:
- 确定
'a的范围:- 输入
x来自a(活到main结束)。 - 输入
y来自b(活到内部花括号}结束)。 - 根据签名
fn pick<'a>(x: &'a str, y: &'a str),'a必须能同时被两个输入满足。因此,编译器推断'a的长度不能超过 两者中较短的那个(即b的生命周期)。
- 输入
- 检查返回值的有效性:
- 函数签名承诺返回值的生命周期也是
'a。 - 这意味着
ret的有效性被限制在了内部花括号之内。
- 函数签名承诺返回值的生命周期也是
- 发现冲突
- 代码试图在内部花括号之外(
println!处)使用ret。 - 编译器发现
ret的推断生命周期('a,即内部作用域)无法覆盖实际使用的地方。 - 报错:
b活得不够久。
- 代码试图在内部花括号之外(
在这个例子中,x (“Hello”) 长度 5,y (“World!!”) 长度 7。函数逻辑实际会返回 y。y 确实销毁了,报错是应该的。但如果我反过来,让逻辑返回长命的 x 呢?
即使逻辑上返回的是 x,编译器依然会报错。
因为 Rust 的编译器在编译阶段只看函数签名,不看函数体内的运行时逻辑。签名 -> &'a str 告诉编译器:“返回值可能引用了 x,也可能引用了 y”。为了保证内存安全,编译器必须假设最坏的情况——即返回值引用了那个生命周期最短的数据。
Rust的编译器或许有能力从函数体推导出生命周期,但它不会这么做。除了确定性的省略规则,其余情况都是需要标注生命周期。
结构体定义中的生命周期标注
如果一个结构体的字段包含引用(reference),那么该结构体的定义必须包含生命周期参数。
这归根结底还是为了避免悬垂引用,结构体本身(借用方)的存活时间,不能超过它所引用的数据(出借方)的存活时间。
因此,我们需要在定义结构体时显式声明生命周期参数(如 'a),并将其应用到对应的引用字段上。这意味着:“该结构体实例的存活时间,不能超过生命周期 'a”。
// 1. 在结构体名称后声明生命周期参数 <'a>
// 2. 在引用类型的字段中使用该生命周期 &'a str
struct ImportantExcerpt<'a> {
part: &'a str,
}
方法定义中的生命周期标注
当我们在具有生命周期的结构体上实现方法时,语法可能会让人感到一点点繁琐,因为我们需要在两个地方声明生命周期:impl 关键字之后和方法签名中。
impl 块的标注
如果是给一个带有生命周期的结构体写方法,必须在 impl 之后声明生命周期,以便在结构体名称中使用它。
struct ImportantExcerpt<'a> {
part: &'a str,
}
// 这里的 <'a> 是为了声明 impl 块中用到的生命周期
// 后面的 ImportantExcerpt<'a> 是在使用这个生命周期
impl<'a> ImportantExcerpt<'a> {
// 根据省略规则,返回值的生命周期默认与 &self 相同(如果返回值是引用)
fn level(&self) -> i32 {
3
}
}
方法签名的独立生命周期
有时候,方法参数的生命周期和结构体本身的生命周期('a)是独立的。这时我们可以在方法名称后声明新的生命周期参数。
impl<'a> ImportantExcerpt<'a> {
// 这里声明了新的生命周期 'b
// 根据省略规则,如果不写标注,返回值的生命周期会默认跟 &self 一样是 'a
// 但如果我们想明确指定返回值跟第二个参数 announcement 绑定,就需要手动标注
fn announce_and_return_part<'b>(
&self,
announcement: &'b str
) -> &'b str {
println!("Attention please: {}", announcement);
announcement // 返回值的生命周期是 'b
}
}
在这个例子中,编译器清楚地知道:ImportantExcerpt 实例活多久('a)与 announcement 活多久('b)没有必然联系,且返回值跟随的是 announcement。
Trait 实现中的生命周期
有时我们需要为引用类型实现 Trait,比如为了实现 for x in &collection,我们需要为 &'a TicketStore 实现 IntoIterator。
此时,Trait 内部的关联类型(如 Item 和 IntoIter)也必须带上生命周期,显式告诉编译器:迭代器产生的数据,其存活时间与原始引用 'a 是一致的。
// 1. 声明生命周期 <'a>,并在目标类型中使用 &'a
impl<'a> IntoIterator for &'a TicketStore {
// 2. 产生的元素是引用,必须标注 &'a
type Item = &'a Ticket;
// 3. 迭代器内部持有引用,也必须标注 <'a>
type IntoIter = std::slice::Iter<'a, Ticket>;
fn into_iter(self) -> Self::IntoIter {
self.tickets.iter()
}
}
这里的关键在于:impl 声明了 'a,&TicketStore, Item 的生命周期都标记为 'a, 这样编译器就能确保:只要迭代器还在用,原始的 TicketStore 就不能被销毁。
变型 强制转换
通常我们说 Rust 没有传统 OOP 那样基于类的继承(Inheritance),因此也没有结构体层面的子类型。但是,在生命周期的范畴内,Rust 确实存在子类型(Subtyping)的概念。
生命周期的子类型
规则非常直观:生命周期越长,它是越具体的子类型。
如果 'long 的生命周期比 'short 更长('long: 'short),那么:
'long是'short的 子类型(subtype)'short是'long的 超类型(supertype)'static是所有生命周期的子类型,'static表示该引用指向的数据在程序的整个剩余运行期间都有效
换句话说:更长的生命周期是更具体的类型,是子类型。
变型 (Variance)
变型描述的是复合类型(如 &'a T 或 Vec<T>)相对于其内部类型('a 或 T)的兼容性关系。
简单来说就是:如果 A 是 B 的子类型,那么 Box<A> 还是 Box<B> 的子类型吗?
主要有三种变型:
- 协变 (Covariant):如果
A是B的子类型,那么Wrapper<A>也是Wrapper<B>的子类型。 - 逆变 (Contravariant):如果
A是B的子类型,那么Wrapper<B>才是Wrapper<A>的子类型。- 只出现在函数的参数中。
- 不变 (Invariant):“不能替”。必须完全匹配,不能互相替代。
&mut T,为了防止将短生命周期的数据写入长生命周期的容器中。
常见类型的变型表:
| 类型 | ’a | T | U | 说明 |
|---|---|---|---|---|
&'a T | 协变 | 协变 | 只读引用,生命周期和类型都可缩小范围 | |
&'a mut T | 协变 | 不变 | 重要:可变引用的内容类型不能变,防止写入错误 | |
Box<T> / Vec<T> | 协变 | 拥有所有权的容器通常是协变的 | ||
UnsafeCell<T> / Cell<T> | 不变 | 内部可变性类型必须不变 | ||
fn(T) -> U | 逆变 | 协变 | 参数逆变,返回值协变 | |
*const T | 协变 | |||
*mut T | 不变 |
函数中的强制转换(Coercion)
fn(T) -> U 中 U 上是协变的
这意味着函数实际返回的生命周期,可以比函数签名声明的生命周期更长。
// `<'a: 'b>` 表示 'a 至少和 'b 一样长('a 是 'b 的子类型)
fn choose_first<'a: 'b, 'b>(first: &'a i32, _: &'b i32) -> &'b i32 {
// 既然 &'a i32 是 &'b i32 的子类型,它就可以替代 &'b i32 被返回。
first
}
'static
这也意味着,返回一个 'static 也是合法的
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
"I am static string constant" // 这是一个 &'static str
}
然而,必须注意的是:Rust 编译器在检查调用端的安全性时,只看函数签名,不看函数体实现。
即使 pick 实际上返回了 'static,调用者看到的签名依然是 -> &'a str。编译器必须假设最坏的情况——即返回值可能只是其中一个输入参数的引用。
因此,下面这段代码依然无法通过编译:
fn main() {
let ret: &str;
let a = String::from("Hello");
{
let b = String::from("World!!");
ret = pick(a.as_str(), b.as_str());
}
println!("{}", ret);
}
参考
Validating References with Lifetimes - The Rust Programming Language