所有权与借用
这一课你将能做到
- 说清所有权三条规则,判断任意一段代码里值的所有权归属
- 用
&/&mut写借用代码,读懂并修复借用检查器报错 - 分清 String 与 &str 的使用场景,写出接收引用而非抢夺所有权的函数
为什么要学
所有权是 Rust 替代 GC 的核心机制:编译器靠它决定每块内存在哪里释放、引用是否安全。这也是 Java 转 Rust 的第一道坎——Java 里赋值是「共享引用」,随手传都行;Rust 里赋值默认是「所有权转移」,传出去自己就不能再用了。
掌握它的直接收益:读得懂编译器报错(E0382 是初学者出现频率最高的错误码),并且天然写出无内存泄漏、无悬垂指针、无数据竞争的代码。面试聊 Rust 绕不开它。
先建立图像
栈(连续、快、定长) 堆(灵活、慢、动态分配)
┌─────────────┐ ┌──────────────────┐
│ s: String │──ptr──────→│ "judger" (堆内存) │
│ (ptr,len,cap)│ │ 所有者:s │
└─────────────┘ └──────────────────┘
- 整型、布尔、浮点这类定长小值放栈上,赋值时直接按位拷贝(Copy 语义)
- String、Vec 这类动态数据放堆上,栈上只留一个「胖指针」;赋值默认转移所有权(move 语义),而不是拷贝堆数据
三条规则,全部推论都从这来:
- 每个值有唯一所有者(owner)
- 同一时刻只能有一个所有者
- 所有者离开作用域,值被释放(drop)
跟着做
步骤 1:亲眼看一次 move
cargo new own-demo
cd own-demo
fn main() {
let s1 = String::from("judger");
let s2 = s1; // 所有权从 s1 move 到 s2
// println!("{s1}"); // ← 取消注释:error[E0382] s1 已失效
println!("{s2}");
}
把注释解开跑一次,读编译器报错:value borrowed here after move。这就是 Rust 在阻止你访问一块「已经不属于你」的内存——Java 里这行代码能跑(两个引用指向同一对象),Rust 在编译期直接拒绝。
步骤 2:需要两份就 clone,纯数字走 Copy
fn main() {
let s1 = String::from("judger");
let s2 = s1.clone(); // 深拷贝堆数据,s1 依然有效
println!("{s1} / {s2}");
let x: i32 = 5;
let y = x; // i32 实现了 Copy trait,按位拷贝,x 仍可用
println!("{x} + {y}");
}
规律:小而定长的类型(整数、浮点、bool、char、由它们组成的元组)赋值即拷贝;持有堆内存的类型(String、Vec、HashMap)赋值即 move。
步骤 3:用借用代替转移
fn byte_len(s: &String) -> usize { // &String:借用,不夺走所有权
s.len()
}
fn main() {
let s = String::from("hello rust");
let n = byte_len(&s); // 传引用
println!("{s} 的字节长度是 {n}"); // s 依然可用
}
& 借用就像「看一眼但不带走」:函数用完还回来。对照 Java,这相当于把参数声明为只读视图,但编译器强制执行——被借用的值在借用期内不能被释放或修改。
步骤 4:借用规则——一山不容二虎
fn main() {
let mut s = String::from("data");
let r1 = &s;
let r2 = &s; // 多个不可变借用共存:读读不冲突
println!("{r1} {r2}");
let r3 = &mut s; // 不可变借用用完后,可变借用合法
r3.push('!');
println!("{r3}"); // 同一时刻:要么多个 &,要么一个 &mut
}
把 println!("{r1} {r2}") 挪到 r3 之后,编译器立刻报 E0502:可变借用存在期间不允许任何其他借用。这条规则同时消灭了「一边遍历一边改」和「数据竞争」两类 bug。
步骤 5:编译器如何阻止悬垂引用
// fn dangle() -> &String {
// let s = String::from("x");
// &s // ← 取消注释:error[E0106] 缺少生命周期标注 → 本质是悬垂引用
// }
fn own_it() -> String { // 正解:把所有权交出去
String::from("x")
}
fn main() {
let s = own_it();
println!("{s}");
}
C++ 返回局部变量引用是未定义行为,Java 靠 GC 延迟回收兜底;Rust 直接编译不过。dangle 函数真正的修复方式是返回 String 本身——把所有权交给调用方。
步骤 6:实战——统计最长单词,全程零拷贝
fn longest_word(text: &str) -> &str {
text.split_whitespace()
.max_by_key(|w| w.len())
.unwrap_or("")
}
fn main() {
let log = "compile error borrow check failed";
let word = longest_word(log); // 借用 log 的切片,不复制任何内容
println!("最长单词:{word}");
}
&str 是字符串切片:指向原字符串某一段的只读视图。高频接口里传日志、传输出都用 &str,避免 String 来回复制——这正是 Rust 性能优势的来源之一。
讲清楚要点
- move 语义:堆数据的赋值/传参默认转移所有权,原变量立即失效。想要两份,
clone()显式付费。 - 借用规则:同一时刻,要么任意多个不可变借用
&T,要么恰好一个可变借用&mut T,二者互斥。读写锁的语义被搬到编译期。 - 借用不能比所有者活得久:这是生命周期检查(第 4 课)的核心,悬垂引用由此绝迹。
- String vs &str:String 是堆上可增长的所有权字符串(对标
StringBuilder但更常用);&str 是只读切片视图(更接近final String的引用但无 GC 依赖)。函数参数优先收&str,调用方传 String 也能自动转。 - NLL(非词法作用域生命周期):借用的有效期按「最后一次使用」计算,不是按花括号,所以先
&s后&mut s只要不重叠就合法。
你可能会懵的地方
-
为什么 Rust 不像 Java 那样随便共享引用? 随便共享的前提是 GC 兜底加运行时锁。Rust 要做到无 GC 无锁下的内存安全,就必须让编译器知道「谁能读、谁能写、用到什么时候」,所有权就是这套信息的形式化。
-
到处 clone 会不会很慢? 滥用 clone 会慢。正确姿势是函数收引用(
&str、&Vec),只在真正需要独立副本时 clone。熟练后代码里 clone 的密度会自然降到个位数。 -
&mut 参数和 Java 的对象参数是一回事吗? 表面都是「函数内改外面看得见」,本质不同:Java 传的是共享引用,任何线程随时可改;Rust 的
&mut是独占借用,编译期保证借用期间没有第二个访问者。 -
Copy 类型和 Clone 有什么区别? Copy 是隐式的位级拷贝(赋值即复制,原值仍可用),Clone 是显式调用(可能深拷贝、可能昂贵)。String 实现了 Clone 但没实现 Copy。
-
函数返回 &str 但源数据是参数,编译器怎么知道不悬垂? 靠生命周期省略规则推导(第 4 课)。推不出来时编译器要求你显式标注,而不是放行。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| move 后继续用原变量 | error[E0382] value used after move | clone 一份,或改为传引用 |
持有 &s 的同时拿 &mut s | error[E0502] 可变借用不独占 | 错开使用区间,或改用可变借用到底 |
| 函数返回局部变量的引用 | error[E0106]/E0515 悬垂 | 返回所有权(String),或让调用方传入缓冲 |
循环里 let s2 = vec.remove(0) 后又用 vec 里的旧引用 | 所有权被移走的元素不可再访问 | 用索引访问 &vec[0] 或先 clone |
| 所有函数参数都收 String | 调用方被迫复制 | 参数收 &str,需要所有权时在函数内 to_string |
卡点急救
- error[E0382] used after move:编译器消息里标了 move 发生点和使用点。三选一:clone、传引用、调整代码顺序让 move 放到最后一次使用之后。
- error[E0502] cannot borrow as mutable:找出前一个借用还活着的区间,把它的最后一次使用提前;或把读操作的结果先取出再修改。
- 报错信息太长看不懂:先看第一条 error,忽略 note/help 之外的内容;
cargo check比完整编译输出更清爽。实在卡住把报错前两行直接搜,Rust 社区对此覆盖极全。
面试常问
-
所有权三条规则是什么?它们如何替代 GC? 唯一所有者、赋值即转移、离开作用域即 drop。编译器据此在编译期插入释放调用,确定性地回收内存,无 STW、无引用计数开销;安全性由借用检查兜底。
-
move 语义和 Java 的引用赋值有什么区别? Java 赋值共享同一对象,谁都能改,靠 GC 判断回收时机;Rust move 是所有权转移,原变量编译期失效,任何时刻数据有唯一主人,无需 GC 即可判定释放点。
-
借用规则为什么能防止数据竞争? 数据竞争需要「多个指针同时访问 + 至少一个写 + 无同步」。Rust 的「多个读或一个写」互斥规则在编译期禁止了无同步的并发写与读写混用,把竞争从运行时事故变成编译错误。
自测三问
let s2 = s1;之后还能用 s1 吗?取决于什么?- 为什么「多个 & 和一个 &mut 互斥」能同时消灭迭代中修改集合的 bug?
- 写函数接收一段用户名并返回其长度,参数类型该用 String、&String 还是 &str?为什么?
小结一句话
所有权把「这块内存归谁、谁在读写、活到什么时候」变成编译期可验证的数学题——borrow checker 不是敌人,是免费的并发审查员。
延伸
- The Rust Book ch04:理解所有权
- 下一课:生命周期——给引用的有效范围贴上编译期标签。