所有权与借用
Rust 核心机制:所有权规则、移动与复制、借用与引用、切片、生命周期与内存安全
1. 从”图书馆借书”说起:为什么需要所有权
1.1 内存管理的三难问题
任何语言都要回答一个问题:谁负责内存的分配与释放? 三种流派各有取舍:
| 流派 | 代表语言 | 机制 | 问题 |
|---|---|---|---|
| 手动管理 | C/C++ | 程序员 malloc/free | 悬垂指针、double-free、内存泄漏 |
| 垃圾回收 | Java/Go/Python | 运行时 GC | 停顿、内存开销 |
| 所有权 | Rust | 编译期静态检查 | 学习曲线陡 |
Rust 选择了第三条路:所有权(Ownership)——在编译期确定每个值的生命周期,既无 GC 停顿、也无手动释放,安全且零开销。
1.2 图书馆的类比
想象图书馆的管理规则:
- 每本书只有一个”借书人”(值有且只有一个所有者)
- 借书人离开图书馆时必须把书放回(所有者离开作用域,值自动释放)
- 借书人可以把书转借给另一个人,转借后原借书人失去资格(所有权转移)
Rust 的所有权系统就是这套”图书馆规则”:编译器像一个严格的图书管理员,在代码编译时逐行检查”谁在管理这本书”,任何违规(比如两个人都声称拥有这本书)直接拒绝编译。
Rust 的承诺:所有内存错误(空指针、悬垂引用、数据竞争、缓冲区溢出)在编译期就被拒绝——不是”尽量安全”,而是”编译不过”。
2. 所有权三规则
规则一:每个值有且只有一个所有者(owner)变量。
规则二:所有者离开作用域时,值被自动释放(drop)。
规则三:值可以被转移(move)给新的所有者,旧所有者随即失效。
fn main() {
let s = String::from("hello"); // s 是 String 的所有者
println!("{}", s.len());
} // 此处 s 离开作用域,String 的内存自动释放
解读:
String::from在堆上分配内存,s持有它的所有权- 无需手动
free——离开作用域即析构(Rust 自动调用 drop) - 栈上的整数等类型同样适用此规则,只是释放成本趋近于零
关键认知:Rust 没有 GC,内存释放靠”所有者离开作用域”这个可预测的时机。这就是为什么 Rust 能做到”零开销抽象”。
3. 移动(Move)与复制(Copy)
3.1 移动语义
let s1 = String::from("hello");
let s2 = s1; // 所有权转移(move)
// println!("{s1}"); // 错误:s1 已失效
println!("{s2}"); // 正常
为什么不能再用 s1:let s2 = s1 没有深拷贝堆数据,只是把”指针+长度+容量”这三块栈数据转移给 s2,并让 s1 失效。
这避免了两个严重问题:
- double-free:如果 s1、s2 都有效,离开作用域时同一块内存会被释放两次(崩溃)
- 悬垂指针:如果 s1 先被释放,s2 就成了悬垂引用
代价是零:转移只是拷贝几个字节的栈数据,堆数据原封不动。而编译器会阻止继续使用 s1——移动不是隐藏的深拷贝,而是所有权转移。
3.2 Copy 类型
像整数、布尔、浮点这样的”纯栈上数据”,赋值是按位复制,不会移动:
let a = 5;
let b = a; // a 仍然可用,因为 i32 实现了 Copy
println!("{a} {b}"); // 输出 5 5
Copy 与 Move 的判断标准:
- 实现了
Copytrait 的类型(标量、元组内全 Copy、&T引用):赋值即复制,原变量仍可用 String、Vec等堆类型:实现的是Move,赋值后原变量失效
规则:实现 Copy 的类型赋值后原变量仍可用,否则原变量失效。
String不能实现 Copy,因为深拷贝代价高;移动则是零成本的”改名”。
3.3 函数传参与返回
fn take(s: String) { /* 消耗传入的所有权 */ }
fn give() -> String { String::from("new") }
fn main() {
let s = String::from("x");
take(s); // s 的所有权被函数消耗
// println!("{s}"); // 错误:s 已 move 进函数
let t = give(); // 返回值转移所有权给 t
}
模式:把所有权交给函数(消耗)、让函数返回所有权(产出),是 Rust 管理资源的基本节奏。但每次都这样传来传去很繁琐——借用就是为此而生。
4. 借用与引用
4.1 不可变引用:只借不拿
不想转移所有权、只想”借来看看”,用引用 &T:
fn calc_len(s: &String) -> usize {
s.len() // 只读访问,不获取所有权
}
fn main() {
let s = String::from("hello");
let len = calc_len(&s);
println!("{len}"); // s 仍可用
}
解读:&s 创建不可变引用(借用),借出期间原所有者不受影响,借完自动归还。可以同时存在多个不可变引用(多个读者同时看书没问题)。
4.2 可变引用:独占借用
fn push_hello(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
push_hello(&mut s);
println!("{s}");
}
核心约束(Rust 内存安全的关键):
同一时刻,一个值要么有多个不可变借用,要么只有一个可变借用。二者不可同时存在。
let mut s = String::from("hi");
let r1 = &s; // 不可变借用,可以
let r2 = &s; // 多个不可变借用,可以(读读不冲突)
let r3 = &mut s; // 错误:已有不可变借用时不能再创建可变借用
这条规则在编译期消灭了数据竞争:
- 数据竞争 = 多个线程同时读写同一内存
- Rust 规则:写(可变借用)必须独占,读(不可变借用)可以并行
- 无论单线程还是多线程,这条规则都成立——数据竞争在编译期就被拒绝
4.3 借用作用域(NLL)
let mut s = String::from("hi");
let r = &s; // r 的借用开始
println!("{r}"); // 最后一次使用 r
let m = &mut s; // 此时 r 已不再使用,可以创建可变借用
m.push_str("!");
借用结束于最后一次使用(NLL,非词法生命周期)。上例中 r 打印后就不再被使用,可变借用随后合法。
实用技巧:借用冲突时,通常可以”缩小借用作用域”解决——把借用的使用范围控制在最小区域,让借用尽早结束。
5. 切片(Slice):数据的”窗口视图”
切片是对连续数据的一段借用视图,无所有权。字符串切片 &str 是最常见的:
let s = String::from("hello world");
let hello = &s[0..5]; // "hello"
let world = &s[6..]; // "world"(6 到末尾)
println!("{hello} {world}");
注意(字节 vs 字符):切片范围是字节索引。中文字符占 3 字节,按字节切可能 panic(多字节边界需谨慎)。处理中文建议用 .chars() 迭代。
字符串字面量本身就是 &str:
let greeting: &str = "你好"; // "你好" 是编译期内置的 &str
数组切片:
let arr = [1, 2, 3, 4, 5];
let mid = &arr[1..4]; // [2, 3, 4],类型 &[i32]
for v in mid {
println!("{v}");
}
5.1 参数用切片而非引用(重要最佳实践)
函数接收参数时优先用切片而非 &String/&Vec,因为 &str/&[T] 能同时接收字面量、String、数组,通用性更强:
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
let s = String::from("hello world");
println!("{}", first_word(&s)); // 传 &String 可自动转为 &str
println!("{}", first_word("hi ok")); // 直接传字面量
}
为什么参数写 &str 最灵活:
&String可自动强转为&str(deref coercion)- 字面量、
&String、&Vec都能传给&str参数 - 返回的切片生命周期与输入绑定,保证不会悬垂
6. 生命周期:编译器怎么知道”引用还活着”
6.1 为什么要生命周期标注
引用必须保证”被引用的值还活着”。多数时候编译器能自动推断(NLL),但某些情况下需要显式标注:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
解读:
'a是一个生命周期参数,声明”x、y、返回值共享同一个生命周期”- 含义:返回值的存活时间,不会超过 x 和 y 中较短的
- 编译器用这个约束检查调用点:如果返回值在某个参数失效后还在用,编译失败
6.2 三种常见模式
| 模式 | 写法 | 场景 |
|---|---|---|
| 省略(自动) | fn f(x: &str) -> &str | 单个输入引用,返回其引用 |
| 多个输入需标注 | fn f<'a>(x: &'a str, y: &'a str) -> &'a str | 多个引用,需关联 |
| 结构体含引用 | struct S<'a> { s: &'a str } | 结构体持有引用 |
实用建议:生命周期标注是”编译器需要帮助时的工具”。90% 的代码用省略规则自动推断;只有返回引用且涉及多个输入时,才需要显式标注。不必一开始就掌握全部细节,先理解”生命周期防止悬垂引用”这个核心思想即可。
7. 综合示例:统计单词数
fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
fn main() {
let text = String::from("Rust ownership is safe");
println!("{}", count_words(&text)); // 输出 4
println!("{}", count_words("你好 Rust")); // 输出 2
}
解读:全程只借用不拷贝;split_whitespace 返回迭代器直接数个数,零分配。所有权系统的收益在此体现:简洁、安全、无 GC、无手动释放。
8. 常见错误与对策
| 编译错误 | 原因 | 对策 |
|---|---|---|
| use of moved value | 使用了已转移所有权的变量 | 改用引用传参,或 clone 一份 |
| cannot borrow as mutable | 同时存在不可变与可变借用 | 缩小借用作用域,或调整借用顺序 |
| cannot move out of borrowed content | 尝试从借用中拿走所有权 | 用 clone 或返回引用 |
| temporary value dropped | 引用指向了临时值 | 用变量持有临时值再借用 |
| lifetime may not live long enough | 返回值可能悬垂 | 检查返回值是否关联输入的生命周期 |
通用调试手段:
- 遇到借用错误时,按编译器提示信息(E0502/E0505 等)逐条阅读
- rust-analyzer 会标注问题行
- 必要时用
clone()快速通过,再回头优化为引用 - 先编译通过,再优化借用——编译器是最好的老师,它的提示几乎总是指向正确方向
9. 小结
所有权三规则(每值一主、主离即释、可转不移)+ 借用两条约束(不可变可并行、可变要独占)+ 切片视图(零拷贝的窗口)+ 生命周期(防止悬垂),构成了 Rust 内存安全的地基。
理解”移动 vs 复制""借用 vs 拥有”两组对立概念,就能读懂编译器的大部分报错——Rust 编译器不是敌人,而是全天候的导师。下一步学习结构体、枚举与模式匹配(见《结构体、枚举与模式匹配》),把这些机制组合成真实的数据结构。
一句话记忆:Rust 用”所有权”替代”手动管理/GC”——每个值一个主人、主人离开作用域自动释放、转移所有权后旧主人失效;借用让”只借不拿”(
&T可多个,&mut T要独占)成为可能,编译期就消灭了悬垂引用与数据竞争。