Rust 生命周期标注在解决什么:返回值依赖、min 约束与 Java 为何不用
最近在读 Rust 的生命周期(lifetime)章节,编译器报错时常常让人一头雾水:明明只是返回一个引用,为什么非要写 <'a>?写了一个 'a 还不够,有时还要 'a, 'b。下面用几个可编译的例子说明返回值依赖怎么写进签名,并和 Java 的 GC 做个对照。
返回值绑在哪个参数上
签名里的 <'a, 'b> 表示两个参数各有各的生命周期;返回值写 &'a str 还是 &'b str,就是在声明它跟哪一个参数走。
'a 和 'b 分开:返回值只绑在 x 上
函数始终返回第一个参数时:
fn always_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
x
}
x 是 'a,y 是 'b,返回值是 &'a str——只依赖 x,和 y 无关。于是 y 可以在内层作用域里释放,只要 x 还在,返回值就合法:
fn main() {
let outer = String::from("I live long");
let result;
{
let inner = String::from("I die soon");
result = always_first(&outer, &inner);
} // inner 释放,无妨
println!("{}", result); // ✅ "I live long"
}
若误写成 fn always_first<'a>(x: &'a str, y: &'a str) -> &'a str,编译器会认为返回值也可能指向 y,上面的 main 就通不过——两个参数共用 'a,约束会变严。
HashMap 查找
返回值来自 map,和临时 key 无关:
use std::collections::HashMap;
fn lookup<'a, 'b>(map: &'a HashMap<String, String>, key: &'b str) -> Option<&'a str> {
map.get(key).map(|s| s.as_str())
}
fn main() {
let map = HashMap::from([("name".to_string(), "Alice".to_string())]);
let value;
{
let key = String::from("name");
value = lookup(&map, &key);
} // key 释放
println!("{:?}", value); // ✅ Some("Alice")
}
标准库里的 HashMap::get 就是这种形状:返回的是 &V,生命周期跟 &self(map)走,和传入的 key: &Q 无关1:
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq,
若误写成 fn lookup<'a>(map: &'a HashMap<...>, key: &'a str),编译器会把 key 的生命周期也绑进返回值,上面的写法就通不过——标注错了,合法代码会被误杀。
两个参数共用 'a:longest 与 min 约束
若返回值可能是 x,也可能是 y,两个引用要共用同一个 'a。官方书第十章的 longest 示例就是这种签名2:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
所有引用共用 'a。编译器不知道最后返回的是 x 还是 y,只能按最保守的规则——返回值存活 ≤ 两者里较短的那个,也就是 min(x, y)。调用方在持有返回值期间,x 和 y 都必须还有效。
下面这段 main 会编译失败(E0597:s does not live long enough)3:
fn main() {
let result;
{
let s = String::from("hello");
result = longest(&s, "world");
// "world" 是 &'static str,活得够久
// 但 result 仍可能指向 s
} // s 在这里 drop
println!("{}", result); // ❌ E0597
}
| 签名 | 返回值含义 | 调用方约束 |
|---|---|---|
fn f<'a, 'b>(x: &'a T, y: &'b T) -> &'a T |
明确来自 x |
返回值存活 ≤ x 的生命周期 |
fn f<'a>(x: &'a T, y: &'a T) -> &'a T |
可能来自 x 或 y |
返回值存活 ≤ min(x, y) |
写 API 时先问:调用者拿到返回引用后,最少需要谁还活着? 能分开就写 'a, 'b;返回值真可能来自多个参数,再共用 'a。
多数时候不用手写 'a:lifetime elision
日常代码里,签名上的 'a 往往可以省略。官方书把编译器内置的几条模式叫做 lifetime elision rules:它们不是给程序员背的「编码规范」,而是编译器在特定、无歧义的情况下自动补全标注;补不全就报错,不会猜4。
三条规则的要点是:
- 每个引用参数先各自获得一个生命周期参数(两个参数会先被看成
'a和'b)。 - 若只有一个输入生命周期,它会赋给所有输出生命周期——所以
fn first(s: &str) -> &str不必写成fn first<'a>(s: &'a str) -> &'a str。 - 若是方法且参数里有
&self/&mut self,输出生命周期默认跟self走。
longest 之所以必须手写 <'a>,正因为 elision 走完三条规则后,仍然不知道返回值该跟 x 还是 y24。结构体里存放引用(如后面的 Parser<'a>)则几乎总要在类型上写出生命周期——那是「值里藏着借用」,和函数签名省略是另一回事。
和 Java 对照:Java 方法签名里没有「返回的引用最多活多久」这一栏,因为堆对象靠 GC 按可达性续命;Rust 把这件事放进类型,多数情况由 elision 默默填好,只有有歧义时才要你写清楚。
Java 为什么不需要生命周期标注?
和「要不要写 lifetime」最直接相关的一点:Java 有 tracing GC,Rust 没有。 Java 靠运行时可达性续命;Rust 靠编译期所有权与借用规则保证内存安全。生命周期标注是借用规则的一部分,只在编译期存在,运行时没有「寿命监视器」25。
《The Rustonomicon》开篇把动机说得很直白:GC 让「指针指着的东西别提前消失」这件事变得轻松,但在需要手动管内存的场景里成本太高;Rust 的所有权系统就是为了在不要 GC的前提下,仍然拦住悬垂引用这类错误6。
常有人再补一句:「Java 有虚拟机,Rust 没有。」方向大致对——常见路径是 Java 跑在 JVM 上,Rust 经 LLVM 生成本地代码、没有同等托管堆运行时——但这不是 lifetime 问题的充要解释。反例很清楚:Go 也基本编译成本地代码,却仍有 GC,同样不靠生命周期标注;没有 VM 也可以像 C 一样手写 malloc/free,安全靠人而不是借用检查。JVM 带来字节码、JIT、类加载等一整套能力,和生态/部署关系很大;本文关心的轴心仍是内存管理模型(GC / 可达性 vs 所有权 / 借用),不是「有没有虚拟机」本身。
同一段逻辑,两种结局
Java 里可以这样写:
public static void main(String[] args) {
String result;
{
String s = new String("hello");
result = s;
} // s 变量出栈,堆上对象仍在
System.out.println(result); // ✅ hello
}
Rust 里若写成借用:
fn main() {
let result;
{
let s = String::from("hello");
result = &s;
} // s drop
println!("{}", result); // ❌ 悬垂引用
}
JVMS 规定:类实例与数组的内存从堆分配,堆由自动存储管理(garbage collector)回收,对象从不显式释放;每个线程另有私有的 JVM 栈,帧里放局部变量与运算中间结果7。因此上面 Java 例子里,出作用域的是栈上的局部变量 s,堆上的 String 只要还被 result 可达,就不会被回收。
更贴切的对照不是「Java 用 GC 管理所有权」,而是:
| Java | Rust | |
|---|---|---|
| 寿命判据 | 可达性(从 GC roots 能否摸到对象) | 所有权 / 借用(谁拥有、借用能否活过所有者) |
| 谁保证安全 | 运行时 tracing GC(现代 HotSpot 远不止经典 mark-and-sweep 一种) | 编译期借阅检查 + 所有者结束时的 Drop |
| 「所有权」一词 | 语言模型里几乎没有 Rust 那种唯一所有者 / move | 类型系统一等公民 |
Rust 里 String 的缓冲在堆上,但所有权在栈上的 s;作用域结束时走 Drop 释放堆缓冲58。&s 若活得比 s 更久,就是悬垂引用——借阅检查在编译期拒绝,而不是靠运行时 GC 把对象「留住」。
sequenceDiagram
participant Stack as 栈
participant Heap as 堆
participant GC as Java GC
Note over Stack,Heap: Java
Stack->>Heap: new String
Stack->>Stack: result = s
Stack->>Stack: s 出栈
Heap->>GC: 仍被 result 引用
Note over Stack,Heap: Rust
Stack->>Heap: String::from
Stack->>Stack: result = &s
Stack->>Stack: s drop,释放堆
Note over Stack: 编译拒绝悬垂引用
String 出作用域:编译器插 drop,标准库里 deallocate
「所有者离开作用域就释放」分两层,不宜混成一句「运行时自动 deallocate」59:
- 编译器在作用域结束处插入 drop glue:按规则调用
Drop::drop、并析构字段。业务代码里通常看不到deallocate。 deallocate是Drop实现里的普通调用——drop glue 跑到那里,才把堆块还给分配器。没有 tracing GC 在背后扫一遍。
标准库里 String 没有自己的 impl Drop,只是包着 Vec<u8>8:
pub struct String {
vec: Vec<u8>,
}
Vec 再拆成缓冲与长度10:
pub struct Vec<T, A: Allocator = Global> {
buf: RawVec<T, A>,
len: usize,
}
作用域结束时的实际路径是:
s 离开作用域
→ drop glue:析构 String 的字段 vec
→ Vec::drop:先 drop_in_place 元素(对 u8 基本无事)
→(随后)RawVec::drop:alloc.deallocate(ptr, layout)
Vec::drop 只负责元素析构,注释写明缓冲由 RawVec 处理10:
unsafe impl<#[may_dangle] T, A: Allocator> Drop for Vec<T, A> {
fn drop(&mut self) {
unsafe {
ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.as_mut_ptr(), self.len))
}
// RawVec handles deallocation
}
}
RawVec::drop 才调用分配器11:
unsafe impl<#[may_dangle] T, A: Allocator> Drop for RawVec<T, A> {
fn drop(&mut self) {
unsafe { self.inner.deallocate(T::LAYOUT) }
}
}
因此:
| 问题 | 答案 |
|---|---|
deallocate 是 GC 自动扫出来的吗? |
不是 |
编译器会直接插入一条裸 deallocate 吗? |
一般不;插入的是 drop glue → Drop::drop |
| 堆何时释放? | RawVec::drop 里的 Allocator::deallocate 随 drop 执行 |
借用检查保证「没有人在 drop 之后还拿着指向这块缓冲的引用」;Drop 保证「该释放时释放」。move 之后旧绑定不再 drop,避免 double-free。
这套做法常被归到 零成本抽象(zero-cost / zero-overhead abstractions) 的叙事里。说法来自 C++ 传统,Stroustrup 概括为两层:不用的特性不付费;用了的应接近合理手写的效率12。落到本文:
| 抽象 | 「零成本」在说什么 | 对照 |
|---|---|---|
| lifetime / 借用检查 | 检查在编译期完成;生成代码里引用就是指针 | Java:传递引用方便,成本在 tracing GC |
Drop / RAII |
作用域结束是普通析构调用(如 deallocate),没有 GC 线程 |
tracing GC 的标记、并发、暂停 |
| 泛型单态化等 | 优化后常可内联到接近手写 | 装箱 + 虚分发另算,那是别的税 |
「零」不是绝对零指令,而是相对手写、没有系统性的抽象税。Box、Vec 分配、dyn Trait 仍有真实成本——零成本说的是设计目标:代价透明、可优化到接近手写,而不是默认绑一个重型托管运行时统一收费。
一个粗浅的类比
Java 像酒店服务员:你下单(分配对象),服务员记录谁在用(GC 跟踪可达性),没人用了再收拾(回收)。Rust 像图书馆借书:借书(获取引用)必须在还书期限(生命周期)内读完,违反规则管理员当场拦住(编译错误);还书时管理员按登记清场(Drop / deallocate),不是事后全馆大扫除式的 tracing GC。前者开发轻松,但有 STW 和延迟尖刺;后者学习曲线陡,但没有 tracing GC 暂停。
Java 也有栈上变量,和 Rust 差在哪?
容易误解成「Java 完全没有栈」,其实差别不在有没有栈,而在对象本身放哪、引用能活多久靠谁保证。
JVM 栈上究竟存什么
按 JVMS,每个线程有私有的 Java Virtual Machine Stack,里面压的是一串 帧(Frame):方法调用时创建,返回时销毁。一帧里主要有713:
| 组成部分 | 内容 |
|---|---|
| 局部变量表 | 参数与局部变量:原始类型的值,或类型为 reference 的槽 |
| 操作数栈 | 计算表达式、传参、接返回值时用的临时槽(同样是值或 reference) |
| 其它帧数据 | 指向当前类运行时常量池的引用等(支持动态链接) |
规范还写明:因为程序从不直接捅 JVM 栈,帧在实现上甚至可以分配在堆上;对程序员模型来说,仍叫「栈上的帧」7。
常见写法对应到这张表:
void method() {
int x = 42; // 局部变量表里的原始值
String s = new String("hello"); // 局部变量表里是 reference;String 实例在堆
}
类实例与数组从堆分配,由 GC 回收,对象从不显式 free7。栈帧里的 s 出作用域,只是少了一个 GC root;堆上的对象只要还有别的引用可达,就不会被收。
Rust 没有 JVM 那种「bytecode 帧 + 操作数栈」模型,但每个线程同样有调用栈。差别在默认布局与释放时机:许多值直接放在栈上;需要堆时用 String、Box 等,由所有者在 drop 时释放。返回 String 是转移所有权,不是把栈上借用递出去:
let s = String::from("hello");
fn get() -> String { s } // move,不是借栈上引用出去
若返回 &str,就要靠 lifetime(或 elision)说明它跟谁走。Java 可以把堆引用存进字段、传来传去,靠可达性续命;Rust 若持有借用,类型系统会追问「借的那块数据谁还活着」。
逃逸分析:论文里的栈分配 vs HotSpot 的标量替换
Escape analysis(逃逸分析)问的是:一个对象会不会逃出创建它的方法,或逃出创建它的线程。Choi 等人 OOPSLA’99 的工作用 connection graph 做这类分析,并明确列出两类用途:(1) 不逃出方法则可以分配在该方法的 stack frame 上;(2) 不逃出线程则可消同步等。论文也提到:不逃逸时还可能弱化对象访问、甚至消除对象创建14。
这和「语言语义上对象在堆上」不矛盾:JVMS 的堆模型仍然成立;栈分配 / 消分配是编译器优化,成功了少占堆、少进 GC,失败了语义不变。
今天的 HotSpot C2 实现了该谱系的 flow-insensitive 逃逸分析,但落地方式与「把完整对象放到栈帧」不同。OpenJDK Wiki 写明:分析之后,C2 会消除 scalar replaceable 对象的分配及相关锁,也会为未全局逃逸对象做消锁;同时明确——C2 does NOT replace a heap allocation with a stack allocation15。更直白地说:
| 结果 | 含义 |
|---|---|
| 逃逸 / 无法标量替换 | 正常堆上分配,GC 管理 |
| NoEscape 且可标量替换 | 取消这次分配:字段变成局部变量/寄存器,往往既没有堆上的完整对象,也没有「栈上的完整 Java 对象」 |
| 真正的栈分配 | 栈帧里保留带对象头的完整布局——Choi 论文作为优化目标讨论过;C2 当前不做 |
因此「C2 不做真正的栈分配」不是「所有对象永远只能待在堆上」,而是:能优化时优先消掉对象(标量替换),而不是在栈上摆一个完整实例;优化不掉的,仍在堆上。口语说「JIT 把小对象放到栈上」,常常是对标量替换的不精确概括,不是语言保证,也不是 HotSpot 的字面行为。
Rust 侧没有与 HotSpot 一一对应的「逃逸分析 → 标量替换」故事:借用检查约束的是引用是否悬垂,不是 JIT 事后决定能不能少一次 malloc。未逃逸的临时值本来就常在栈上或被优化进寄存器;需要共享所有权时用 Rc/Arc 等,那是另一条显式路径。
结构体里的借用,以及 RustBelt 证明了什么
同一个「解析器」设计,两种写法:
public class Parser {
private String source;
public String slice() {
return source.substring(0, 10); // 新 String 在堆,GC 管
}
}
struct Parser<'a> {
source: &'a str,
}
impl<'a> Parser<'a> {
fn slice(&self) -> &'a str {
&self.source[..10]
}
}
// Parser 不能比 source 活得更久——编译期保证
Java 用运行时可达性(GC)换传递引用的便利;Rust 用生命周期把「引用最多活多久」写进类型。
RustBelt(POPL’18)用 Iris 分离逻辑,在精简模型 λRust 上形式化 ownership、borrowing 与 lifetime inclusion,并用 Coq 机器检查:核心规则健全,且某些带 unsafe 的库在满足验证条件时可安全扩展语言16。这支持一个判断——lifetime 纪律可以是语义上可检验的安全基础,而不只是教学约定。
它没有证明「现实中的 rustc + 全部标准库 + crates.io 永远内存安全」。证明范围是模型与部分核心故事;任意 unsafe、FFI、编译器 bug、历史上的 soundness issue,都不在「已证永不可能」之列。Safe Rust(不写 unsafe、并信任编译器与已审计的标准库)在工程上以内存安全为设计目标,和实践里 tracing GC 语言一样,仍要区分设计目标与全系统形式化盖章。
不是谁更「高级」,是两种内存管理哲学17。就「要不要写 'a」而言:有没有 tracing GC 是主因;有没有 JVM 是重要背景,但不是充要条件。
小结
- 分离
'a, 'b:返回值只来自某一个参数 → 其它参数可以更早 drop(HashMap::get一类 API 即如此)。 - 共用
'a(如longest):返回值可能来自多个参数 → 约束取 min;也是 elision 补不全、必须手写的典型情况。 - 多数函数靠 lifetime elision 即可;标注只在编译期存在,运行时无开销——这是零成本抽象叙事的一部分。
- 主因:Java 有 tracing GC(可达性),Rust 没有,故用所有权 / 借用 /
Drop。有无 JVM 解释不了 Go(无 JVM、有 GC)这类反例。 - JVM 栈存的是帧;HotSpot 逃逸分析常做标量替换,不是 C2 里的真正栈分配。
- Rust:
String→Vec→RawVec;编译器插 drop glue,deallocate在RawVec::Drop里执行。 - RustBelt 给 ownership/lifetime 打了形式化地基,不等于「全部 Rust 程序已证内存安全」。
- 别用 Java 的直觉直接写 Rust 的返回引用——要么返回 owned 类型,要么把依赖关系写进签名。
后续如果继续写 Rust 内存模型,可以单独聊 impl 块里的生命周期、以及和 Pin 的关系。
References
-
Rust 标准库文档,
HashMap::get:返回值生命周期绑定在&self上,与key参数的生命周期分离。参见:https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.get ↩ -
The Rust Programming Language,第 10 章 Validating References with Lifetimes(含
longest示例与 lifetime 语法):https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html ↩ ↩2 ↩3 -
Rustc 错误索引,
E0597(this value lives only at certain times during execution):https://doc.rust-lang.org/error_codes/E0597.html ↩ -
The Rust Programming Language,第 10 章 Lifetime Elision——三条省略规则;歧义时不猜测而是报错:https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-elision ↩ ↩2
-
The Rust Programming Language,第 4 章 What is Ownership?——对比 GC 语言与所有权:
drop在作用域结束时由编译器插入,运行时不引入 tracing GC:https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html ↩ ↩2 ↩3 -
The Rustonomicon,Ownership and Lifetimes——为何在拒绝 GC 的前提下仍要拦住悬垂引用:https://doc.rust-lang.org/nomicon/ownership.html ↩
-
The Java Virtual Machine Specification, Java SE 21 Edition,§2.5.2 Java Virtual Machine Stacks 与 §2.5.3 Heap(堆由 garbage collector 回收,对象从不显式释放;栈存储 frames,帧在实现上可以堆分配):https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.5.3 ↩ ↩2 ↩3 ↩4
-
alloc::string::String定义为vec: Vec<u8>,自身无impl Drop,释放委托给Vec(rust-lang/rustlibrary/alloc):https://doc.rust-lang.org/src/alloc/string.rs.html ↩ ↩2 -
The Rust Programming Language,第 15 章 Drop——作用域结束时自动调用
drop(RAII):https://doc.rust-lang.org/book/ch15-03-drop.html ↩ -
alloc::vec::Vec的布局(buf: RawVec+len)与Drop(drop_in_place元素;缓冲由RawVec释放):https://doc.rust-lang.org/src/alloc/vec/mod.rs.html ↩ ↩2 -
alloc::raw_vec::RawVec的Drop:调用inner.deallocate,不 drop 内容:https://doc.rust-lang.org/src/alloc/raw_vec/mod.rs.html ↩ -
Bjarne Stroustrup,Foundations of C++(ETAPS 2012):zero-overhead principle——What you don’t use, you don’t pay for;What you do use, you couldn’t hand code any better。PDF:https://www.stroustrup.com/ETAPS12-corrected.pdf。概述亦见 cppreference Zero-overhead principle:https://en.cppreference.com/w/cpp/language/Zero-overhead_principle ↩
-
The Java Virtual Machine Specification, Java SE 21 Edition,§2.6 Frames(局部变量表、操作数栈、运行时常量池引用):https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.6 ↩
-
Jong-Deok Choi, Manish Gupta, Mauricio J. Serrano, Vugranam C. Sreedhar, Samuel P. Midkiff. Escape Analysis for Java. OOPSLA 1999. 摘要与引言将「不逃出方法 → 可栈分配」与「不逃出线程 → 可消同步」列为主要应用,并提到消除对象创建的可能。DOI: 10.1145/320384.320386 ↩
-
OpenJDK Wiki,EscapeAnalysis(HotSpot C2):基于 Choi et al. 的 flow-insensitive 算法;消除 scalar replaceable 分配与相关锁;不把未全局逃逸对象的堆分配替换为栈分配:https://wiki.openjdk.org/display/HotSpot/EscapeAnalysis ↩
-
Ralf Jung, Jacques-Henri Jourdan, Robbert Krebbers, Derek Dreyer. RustBelt: Securing the Foundations of the Rust Programming Language. PACMPL 2(POPL), 2018. 证明对象为 λRust 模型及满足验证条件的 unsafe 库扩展,而非完整 rustc 与全部生态。PDF:https://plv.mpi-sws.org/rustbelt/popl18/paper.pdf ↩