Rust 所有权深入:借用、生命周期,以及那个真正帮你的编译器
Rust 的第一个月是和 borrow checker 打架。第二个月你开始发现它在抓真 bug。第三个月你内化了模型,编译器变成协作者。这是我希望第一个月就看到的指南。
三条规则,重新理解
多数教程列出来就完事。我想多停留一下。
- 每个值有且只有一个所有者。
- 借用可以共享(多个
&T)或独占(一个&mut T),但不能同时存在。 - 借用不能比所有者活得长。
注意缺什么:没有 GC、没有引用计数(默认)、没有手动 free()。所有者作用域决定值何时 drop。其他规则都是这条的推论。
move、copy,以及为什么 String 和 i32 行为不同
let s = String::from("hello");
let s2 = s;
// println!("{}", s); // 错误:s 已被 move
对比:
let n = 42;
let n2 = n;
println!("{} {}", n, n2); // 都能用
区别:String 拥有堆内存,没实现 Copy。赋给 s2 转移所有权,s 失效。i32 是 Copy(小、不在堆上),赋值复制位。
第一反应是"到处加 .clone()"。别这样。对的反应是改成借用。
借用:日常货币
fn greet(name: &str) { // 借用
println!("Hello, {}", name);
}
let s = String::from("world");
greet(&s); // 传借用
println!("{}", s); // s 仍可用
&str 是字符串的借用视图。greet 读它但不接管。返回后 s 仍是它堆数据的所有者。
容易踩的规则:借用活着时,不能改原值,也不能创建独占借用。
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // 错误
println!("{}", first);
如果 v.push 触发扩容,first 就是悬空指针。编译器拒绝。这是 borrow checker 配工资的时候。
共享 vs 独占:防止数据竞争的规则
fn append(buf: &mut String, s: &str) {
buf.push_str(s);
}
let mut buf = String::new();
append(&mut buf, "hi");
let a = &buf;
let b = &mut buf; // 错误:有共享借用时不能创建独占借用
&mut T 是独占的 —— 它存在时,没有其他引用(共享或独占)能存在。这就是 Vec、HashMap 等可以不加锁地安全修改的原因:类型系统已经证明没人在读。
口号:共享 XOR 可变。同一洞见驱动整个 Sync / Send 和并发故事。
生命周期:没那么吓人
生命周期是编译器追踪"引用必须保持有效多久"的方式。多数时候 你不用写,因为有生命周期省略。
三条省略规则:
- 每个输入引用拿到自己的生命周期。
- 只有一个输入生命周期时,赋给所有输出。
- 有
&self/&mut self时,把 self 的生命周期赋给所有输出。
这些规则覆盖 ~95% 函数签名。剩下 5% 显式写:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
'a 说"返回的引用至少和两个输入一样长寿"。不写编译器不知道继承哪个。
当 borrow checker 跟你打架,听它的
borrow checker 的"误报"几乎总是真设计问题。
我和一个 cannot borrow as mutable 死磕过快一个小时。当时在一个循环里遍历某个 Vec,同时想往里面 push 元素,怎么调都不过。后来才反应过来:编译器拦的根本不是语法,是我那个一边读一边改同一份数据的设计本身有问题。我把要改的部分先收集到另一个 vec、循环结束再合并,错误消失,逻辑也比原来清楚。从那以后我对借用报错的态度就变了——它在拦的往往不是这一行,是我没想清楚的那个数据流。
- "cannot borrow as mutable because already borrowed as immutable." 真问题:抓着不可变引用还想改容器。要么先丢借用,要么先复制出需要的部分。
- "borrowed value does not live long enough." 真问题:返回了局部变量的引用。要么返回所有权,要么把引用当参数收。
- "use of moved value." 真问题:值已交出去还在用。改成借用,或重构。
真的需要共享可变状态?用显式工具:单线程 Rc<RefCell<T>>,多线程 Arc<Mutex<T>>。这些把检查从编译期挪到运行期 —— 它们是逃生口,不是默认。
几个我自己反复踩到的点,列出来:
| 工具 | 何时用 | 踩坑点 |
|---|---|---|
&T / &mut T | 绝大多数函数参数,借了就还 | 默认就该是它;先想能不能用借用,再考虑别的 |
Rc<RefCell<T>> | 单线程内多处持有、需要改 | borrow_mut() 重入会运行期 panic,不是编译错误;跨线程直接编译不过 |
Arc<Mutex<T>> | 多线程共享可变 | 锁里再去拿同一把锁会死锁;持锁别做耗时操作,尽快 drop guard |
struct View<'a> | 借用确实和某个作用域绑定 | 生命周期参数会传染到每个用到它的签名,写多了很痛苦 |
编译过的常见模式
返回 self 的借用:
struct Db { rows: Vec<String> }
impl Db {
fn first(&self) -> Option<&str> {
self.rows.first().map(|s| s.as_str())
}
}
返回的 &str 通过省略规则 3 继承 &self 生命周期。
owned self 的链式构建器:
impl Request {
pub fn header(mut self, key: &str, val: &str) -> Self {
self.headers.insert(key.into(), val.into());
self
}
}
owned self 允许链式 —— 每次调用把 self move 到下一次。
避免在 struct 字段写生命周期:
// 痛苦
struct View<'a> { data: &'a Data }
// 简单
struct View { data: Arc<Data> }
Arc(或 Rc)用一点点运行期开销换"不用把生命周期参数透过每个用 View 的函数"。借用真和作用域绑定时用生命周期,共享所有权用 Arc。
心智的转变
其他语言让你赖账:"先抓着引用,GC 之后清"或"先加锁,运行时调度"。Rust 强迫你提前决定:谁拥有?谁借?多久?独占还是共享?
那个决定就是数据结构设计。一旦你把所有权当成一等的 API 选择(和类型与函数签名并列),borrow checker 就不再是障碍。它就是类型检查你本来想写的那个设计。
TL;DR
- 每个值一个所有者;赋值时 move,除非类型是
Copy。 - 借用要么共享(
&T)要么独占(&mut T),不能同时。 - 生命周期多数被省略;只有编译器分不清输出借用谁时才写。
- borrow checker 错误通常指向真设计问题 —— 听它的。
- 真需要共享可变状态用
Rc<RefCell<T>>或Arc<Mutex<T>>显式声明。
第一个月编译器是敌人,第三个月它是抓 linter 抓不到的 bug 的同事。相信过程。
我们是一支开发者小团队,负责构建和维护 ToolsForge —— 150+ 个完全在浏览器本地运行、隐私优先的工具。这些文章来自我们日常的工程实践,而不是纸上谈兵。
了解我们相关阅读
一张照片能做多少事:AI 改图工具实战指南
换发型、换发色、换装、转卡通、风格迁移、室内设计、老照片修复——用一张照片走完这些 AI 改图流程,以及怎么让结果更稳、更像本人。
把脸保住:AI 改图提示词怎么写才不"换人"
换发型、换装、换风格时,AI 老把脸也改了?这篇讲清楚改图提示词的写法:保人约束、双通道(gpt-image-2 / Nano Banana)各自的脾气,以及一套能直接抄的模板。
TypeScript 类型体操:什么时候值,什么时候在炫技
务实的 TypeScript 高级类型指南 — mapped types、conditional types、template literal types 真正能给你什么,什么时候用,什么时候应该退回到朴素代码。