第 4 课:所有权系统 —— Rust 的灵魂
预计时间:90-120 分钟 | 难度:⭐⭐⭐(本课程最重要一课,请留足时间)| 前置:第 1-3 课
4.1 为什么需要所有权?从"谁来释放内存"说起
每门语言都要回答一个残酷的问题:数据用完,谁来把内存还回去?
- 手动管理(C/C++):程序员负责
malloc/free。灵活但极容易出错——忘释放=内存泄漏,释放早了=悬垂指针(用已归还的内存),释放两次=double free。绝大多数 C 安全漏洞都来自这几类。 - 垃圾回收(Java/Go/Python/Kotlin):运行时定期扫描,"没人引用就回收"。省心但有 GC 暂停、额外内存开销,还让"什么时候释放"变得不确定。
- Rust 的答案——所有权:把"释放时机"这件事交给编译器在编译期静态分析,不靠运行时扫描,也没有手动管理的心智负担。代价是你必须遵守三条规则。
Rust 的内存核心模型是:栈 + 堆。
栈(stack) 堆(heap)
固定大小、快速、自动回收 动态大小,需手动/自动分配
┌─────────┐ ┌─────────────┐
│ 局部变量 │ │ 数据本体 │
│ 指针/len │ ──────▶ (指针) │ (String 内容等)│
└─────────┘ └─────────────┘
作用域结束 → 出栈即释放 无人拥有时 → 自动 Drop(相当于自动 free)
- 栈上:大小编译期已知的简单值(
i32、bool、固定长度数组、指针/长度/容量三个"元数据")。作用域结束自动弹出。 - 堆上:可变长数据(
String内容、Vec内容)。难点在"何时释放"。 - Rust 的机制:堆上的数据绑定给一个"所有者"变量;所有者离开作用域时,编译器自动插入释放代码(调用
Drop,底层等价于free)。
这正是第 3 课那些话的底层含义:String 拥有自己的堆内存,所以离开作用域它会"自动释放";&str 只是借用,它不负责释放。
4.2 三条所有权规则(必背)
规则一:Rust 中每个值都有一个变量作为它的「所有者」(owner)。
规则二:同一时刻,一个值只能有一个所有者。
规则三:当所有者离开作用域,这个值将被丢弃(释放)。
看作用域(scope)的直观版本:
fn main() {
let s = String::from("hello"); // s 进入作用域,成为"hello"这块堆数据的所有者
{
let inner = String::from("in"); // inner 是第二块数据的所有者
// 这里两块数据都活着
println!("{s} {inner}");
} // ← inner 离开作用域:自动 Drop,内存归还
println!("{s}"); // s 还活着,没问题
} // ← main 结束,s 离开作用域:自动 Drop
到目前为止,这跟很多语言的局部变量很像。区别在规则二:一个值同一时刻只有一个所有者,它引发的连锁反应就是大名鼎鼎的 move。
4.3 move:赋值与传参会"移动所有权"
4.3.1 为什么赋值后旧变量不能用了
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ❓ 这是"拷贝"吗?
// println!("{s1}"); // ❌ 编译错误:value borrowed here after move
// use of moved value: `s1`
println!("{s2}"); // ✅ s2 一切正常
}
在 C++/Python/JS 里,s2 = s1 通常复制一份(或引用计数+1),两个都能用。Rust 为何拒绝?
如果简单拷贝"指针+长度+容量"三个字段,会得到两个变量指向同一块堆内存:
s1 ─┐
▼
┌─────┬────────────────┐
│ ptr ├──▶ "hello" 堆块 │ ← 这块内存到底归谁管?
└─────┴────────────────┘ 两个 owner → 作用域结束时 double free!
s2 ─┐
▼
┌─────┐
│ ptr ├────── (同样的指针)
└─────┘
解决办法有两个:
- 深拷贝:把堆上内容真复制一份(各管各的)——贵,但该用时用;
- 转移所有权(move):干脆规定"这块内存现在归 s2,s1 作废"——零拷贝、零风险。Rust 默认选择 move。
所以上面代码的真实语义是:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 移动到 s2(s1 从此"不存在")
println!("{s2}");
}
💡 move 不是"深拷贝数据",是转移管理权——底层只搬"指针+len+cap"三个字,极快。C++ 的 move 语义、以及 JS/Go 里"对象引用赋给新变量"其实都在做类似的事,只是 Rust 把后果用编译器明明白白说出来:原变量作废。
4.3.2 函数调用也会 move(传值就是把所有权交出去)
fn main() {
let s = String::from("hello");
take(s); // s 的所有权被移交进函数
// println!("{s}"); // ❌ use of moved value: `s`
}
fn take(owned: String) { // owned 接管这块数据
println!("{owned}");
} // owned 离开作用域 → 自动释放
要"用了之后还能继续用"怎么办?三种思路(第三种就是 4.5 的主角):
fn main() {
// 思路一:函数把所有权还回来
let mut s = String::from("hi");
s = take_and_return(s); // 拿走 → 还回
println!("{s}");
// 思路二:克隆一份再传(代价=深拷贝,数据大时别这么干)
let s2 = String::from("hi");
take(s2.clone());
println!("{s2}"); // 原副本还在
// 思路三:借用(见 4.5)——只借不拿,最常用
}
fn take_and_return(s: String) -> String {
println!("got {s}");
s // 原样还回(所有权随返回值移出)
}
💡 "传进去再还回来"又啰嗦又容易错,于是 Rust 提供借用(引用)——这是所有权体系里最高频的设计,4.5 马上讲。
4.3.3 元组"偷回"多个值(不推荐,仅演示规则)
fn main() {
let s = String::from("len me");
let (s, n) = calc_len(s); // 解构拿回 String 和它的长度
println!("{s} 长度 {n}");
}
fn calc_len(s: String) -> (String, usize) {
let n = s.chars().count();
(s, n)
}
真实工程不会这么写——用借用只需一行 &s。它只是帮你确认:"函数返回值也能把所有权移回给调用者。"
4.4 Copy vs Clone:哪些类型赋值是"复制"而非"移动"
你可能要问:let x = 5; let y = x; 之后 x 明明还能用啊?没错——因为 i32 这类"纯栈上、无堆指针"的类型走的是 Copy。
fn main() {
let x = 5;
let y = x; // x 依然能用
println!("{x} {y}"); // 5 5
let b = true;
let c = b; // 同理
println!("{b} {c}"); // true true
}
背后的机制:实现了 Copy trait 的类型,赋值/传参时按位复制一份,新旧互不相干(没有堆指针需要转移,也就不存在 double free)。哪些类型是 Copy?
Copy(赋值即复制):
- 所有整数/浮点/bool/char
- 只含 Copy 元素的 tuple / 固定长度数组,如 (i32, bool)、[u8; 3]
- 函数指针、不可变引用 &T(注意:&mut T 不是 Copy)
非 Copy(赋值即 move):
- String、Vec<T>、&mut T、以及任何持有堆数据/资源的结构体
Clone 则不同:它是对任何类型都可以做的显式深拷贝(s.clone()),代价是分配新内存。判定口诀:
类型实现了 Copy → 赋值/传参会"隐式复制",旧变量还能用
类型没实现 Copy → 赋值/传参会"move",旧变量作废;真需要副本就 .clone()
⚠️ 为什么不能给
String实现 Copy?Copy语义承诺"复制后两边完全等价、各自独立释放",这对持有堆指针的类型是灾难(会 double free)。因此 Rust 标准库里,凡是要管内存的智能类型都故意不实现 Copy。这条设计约束你也会在自己定义类型时遇到(第 6 课 struct 讲#[derive(Clone)]vs 不能 derive Copy 的场景)。
4.4.1 判断练习(先想再对答案)
fn main() {
let a = String::from("a");
let b = a; // move(String 非 Copy)→ 之后用 a 会报错
println!("{b}");
let v = vec![1, 2];
let w = v; // move → 用 v 报错
println!("{w:?}");
let n = 3.14f64;
let m = n; // Copy → n、m 都能用
println!("{n} {m}");
}
4.5 借用(Borrowing):& 与 &mut
4.5.1 只读借用 &T:借了不拿走
"值先借我用用,用完了还你,所有权还是你的。"这就是 &。
fn main() {
let s = String::from("hello");
let len = calc_len(&s); // &s:把 s 的"只读借用"传进去
println!("'{s}' 长度 {len}"); // s 依旧可用(没被拿走)
}
fn calc_len(s: &String) -> usize { // 参数是"引用",不是 String 本身
s.chars().count()
} // s(引用)离开作用域,但它没所有权,不释放任何东西
把"借来"的引用想成一盏探照灯——它只是照向那块数据,不改变数据归属。所以:
- 只读借用期间,数据依然活着(编译器保证借用不会超过所有者生命周期,这是第 5 课生命周期的工作);
- 引用离开作用域不会释放数据(
drop只由所有者触发)。
💡 纯只读场景下,第 3 课推荐的写法更通用:
calc_len(s: &str),这样字面量和&String都能传。&String会被自动"解引用"成&str,这也是函数签名普遍写&str/&[T]的另一个原因。
4.5.2 可变借用 &mut T:借了还要改
只读借用不能改数据,要改就用 &mut:
fn main() {
let mut s = String::from("hello");
append_world(&mut s); // 可变借用
println!("{s}"); // hello world
}
fn append_world(s: &mut String) {
s.push_str(" world"); // 通过可变借用修改所有者持有的数据
}
4.5.3 借用规则(规则二在"借用维度"的延伸——必背)
借用规则:
① 同一时刻,对同一块数据:
- 要么有任意多个「只读借用」&T
- 要么有且仅有一个「可变借用」&mut T
(两种不能同时存在)
② 借用不能活得比所有者更久(由生命周期/借用检查器保证,见第 5 课)
为什么这么严?因为数据竞争(data race)只在"一个写 + 至少一个读/写 同时发生"时才出现。Rust 在编译期就把这种可能性剪掉——多个人同时只读是安全的;一个人独占写也是安全的;读写并存 → 编译不过。
fn main() {
let mut v = vec![1, 2, 3];
let r1 = &v;
let r2 = &v; // ✅ 多个只读借用共存
println!("{r1:?} {r2:?}");
let m = &mut v; // ✅ r1、r2 已不再使用(NLL,见 4.5.4),此刻可变借用独占
m.push(4);
// let x = &v; // ❌ 若在 m 仍使用时再建只读借用 → E0502
println!("{m:?}");
// ⚠️ 同一个作用域里这样写必炸:
// let a = &v;
// let b = &mut v; // ❌ error[E0502]: cannot borrow `v` as mutable
// because it is also borrowed as immutable
}
记住报错码 E0502(同时存在只读与可变借用),这是 Rust 入门最高频的报错之一。
4.5.4 NLL:借用什么时候"结束"?
早期 Rust 的借用要到作用域结束才失效,太死板。现在用的是 NLL(Non-Lexical Lifetimes,非词法生命周期):借用最后一次被使用之后,就可以算"结束了",即使代码上还在同一作用域。
fn main() {
let mut v = vec![1, 2, 3];
let r = &v; // 只读借用开始
println!("{:?}", r); // ← r 最后一次使用在这里
v.push(4); // ✅ 合法!r 已经"用完",可变借用不再冲突
println!("{v:?}"); // [1, 2, 3, 4]
}
💡 这条规则让"先读后改"的常见代码变合法。新手常困惑"我明明把只读引用那行用完了啊,为什么还报 E0502?"——答案多半是:借用变量后续还被用到(例如循环里每次都用、或函数尾还 return 它),把它"最后一次使用"提前即可。rust-analyzer 会在编辑器里标出借用的实际存活范围,报错时看一眼紫色/下划线区间很有帮助。
4.6 组合推演:一个值穿越"移动 + 借用 + 复制"的完整生命周期
用一段小函数把 4.2~4.5 全串起来,逐步注释每一行的归属:
fn main() {
let name = String::from("小灵"); // name 拥有堆数据 A
let name_len = measure(&name); // 只读借用 A → 用完即还
let nickname = make_nickname(name); // name 的 A 被 move 进函数
// println!("{name}"); // ❌ 已 move,不能再用
println!("原名长度 {name_len},昵称 {nickname}");
} // 结束:A 由 make_nickname 内部的最终持有者释放
fn measure(s: &str) -> usize { s.chars().count() } // 借的,不释放
fn make_nickname(orig: String) -> String { // orig 现在拥有 A
let mut out = String::from("nick:"); // 新堆数据 B,out 拥有
out.push_str(&orig); // 只读借用 A 拼进 out
out // B 的拥有权随返回值移给调用者
} // orig(A) 与 out(B) 在这里出作用域:
// 但 out 已 move 出去(不释放)
// orig 还在 → A 在此释放 ✅
把每行谁拥有、谁借用、何时释放理一遍,所有权就通了。这正是面试必考的口头推演题,请自己对着代码复述一遍。
4.7 读报错专项:三组典型错误与修复
① use of moved value(E0382)
fn main() {
let s = String::from("x");
let t = s;
println!("{}", s); // ❌ error[E0382]: borrow of moved value: `s`
}
// 修复思路:
// - 本就不需要 s → 删掉对 s 的使用
// - 两个都要用 → 改 let t = s.clone();(接受拷贝成本)
// - 只想借 → 改 let t = &s;
② cannot borrow as mutable because also borrowed as immutable(E0502)
fn main() {
let mut v = vec![1];
let first = &v[0];
v.push(2); // ❌ error[E0502]
println!("{first}");
}
// 修复思路:
// - 检查 first 是否真的在 push 之后还用:不用就把它放到最后使用(NLL 往往直接放行)
// - 必须同时用 → 改变数据结构(如存下标 v[0] 而不是引用),或用 RefCell(第 11 课)
③ cannot borrow as mutable more than once(E0499)
fn main() {
let mut s = String::from("a");
let r1 = &mut s;
let r2 = &mut s; // ❌ error[E0499]: cannot borrow `s` as mutable more than once
r1.push('b');
}
// 修复思路:同一时刻只保留一个可变借用;需要"分时"操作就串行写,
// 不要同时持有两个 &mut。
💡 这三条报错是最重要的"所有权体检报告"。建议像背单词一样记住 E0382 / E0502 / E0499 分别对应什么情况,实战里一眼定位。
4.8 📝 动手练习
参考实现放 code/04-ownership/(写作时同步给出),先自己写再对照。练习目的只有一个:让编译器当你的考官,每题先故意写错、看报错,再修对。
- move 观察:写
let a = String::from("hi"); let b = a; println!("{}", a);,记录报错原文(错误码应为 E0382),然后用clone和&各修一版。 - 函数传值链:写
fn push_hello(s: &mut String)与fn describe(s: &str) -> usize,在 main 里先可变借用再只读借用各调用一次,体会"借完再借"的顺序约束。 - Copy vs move 对比:同一段代码分别用
i32和String执行"赋值后打印原变量",把两种结果和报错整理成对照表。 - NLL 实验:写一个"借了 r → 用完 r → 再
push"合法的例子;再写一个"r 用到 push 之后"会报 E0502 的例子;对比两个例子的唯一差别。 - 手动推演:写一个
fn build(prefix: String, items: Vec<i32>) -> String(拼接 prefix + 对 items 求和的字符串),全程不 clone,在注释里标注每一步:谁拥有、谁借用、谁在何时释放。 - 综合修复题(重点):下面的代码有 5 处所有权错误,逐条改并说明错误码:
fn main() {
let text = String::from("hello rust");
let words = split_words(text); // 意图:text 还得用
let first = first_word(&words); // 意图:借用
words.push(String::from("!")); // 意图:还能继续 push
print_all(&words);
println!("{text} 共 {} 个词", first);
}
fn split_words(s: String) -> Vec<String> {
s.split_whitespace().map(|w| w.to_string()).collect()
}
fn first_word(words: &Vec<String>) -> String {
words[0].clone()
}
fn print_all(words: &Vec<String>) {
for w in words { println!("{w}"); }
}
(提示:main 里的前两行与 push 一处涉及移动后使用;函数签名可改成接收 &str/返回不变量冲突处调整调用顺序。)
验收门禁:能不看笔记默写三条所有权规则与两条借用规则;能解释"为什么 String 赋值是 move 而 i32 是复制";遇到 E0382/E0502/E0499 能说出修法方向。
✅ 本节小结
- 动机:手动释放易错、GC 不可控 → Rust 用编译期"所有权"解决内存释放;
- 三条规则:每个值只有一个所有者;同一时刻只一个所有者;所有者出作用域即 Drop;
- move:赋值/传参把堆数据的"管理权"移交,旧变量作废(零拷贝);要副本用
.clone(); - Copy vs Clone:纯栈类型实现 Copy(赋值即复制);堆类型只能显式 Clone;
- 借用:
&T只读可多个、&mut T独占一个、两者不同存;NLL 让借用在最后一次使用后就结束; - 报错三兄弟:E0382(move 后使用)、E0502(读写借用并存)、E0499(多个可变借用)。
下一课预告:第 5 课《引用与生命周期》——借用检查器凭什么敢保证"借用不会比所有者活得更久"?生命周期标注 <'a>、三条省略规则、'static 到底指什么。这是所有权体系最后一块拼图,也是面试深入题的常客。