所有权与借用
一句话理解
Rust 不用 GC,靠编译期的所有权规则决定每块内存何时释放。
- 所有权解决”谁负责释放”
- 借用解决”能不能临时用一下”
- 违反规则的代码编译不过,而不是运行时崩溃
1. 为什么需要所有权
| 方案 | 内存何时释放 | 代价 |
|---|---|---|
| GC(Java / Go / JS) | 运行时垃圾回收 | 运行时开销、停顿、内存占用偏高 |
| 手动管理(C / C++) | 开发者调用 free / delete | 悬垂指针、double free、泄漏 |
| 所有权(Rust) | 编译期确定:所有者离开作用域即 drop | 需要适应借用检查器 |
Rust 的立场:把内存安全问题提前成编译错误,运行时零成本。代价是你要学会和借用检查器相处——这是 Rust 学习曲线最陡的一段,也是最值的一段。
2. 三条核心规则
- 每个值有且只有一个所有者(owner)
- 同一时刻:要么有多个不可变借用
&T,要么只有一个可变借用&mut T,二者不能共存 - 所有者离开作用域,值被自动
drop
{
let s = String::from("hello"); // s 是所有者
println!("{}", s);
} // 作用域结束,s 在这里被释放,不需要手写 free3. 移动、复制与克隆
let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 移动(move)到 s2
// println!("{}", s1); // ❌ E0382: borrow of moved value
println!("{}", s2); // ✅String 在栈上只有一个「指针 + 长度 + 容量」的胖指针,真实字节在堆上。如果 s1 = s2 只是浅拷贝,就会 double free——所以 Rust 干脆移动所有权,让 s1 失效。
而栈上的纯数据可以直接按位复制,这类类型实现了 Copy:
let x = 5;
let y = x; // i32 实现了 Copy,x 仍然可用
println!("{} {}", x, y); // ✅| 概念 | 含义 | 典型类型 |
|---|---|---|
Copy | 按位复制,原值仍可用;不能与 Drop 共存 | i32、f64、bool、char、&T、[T; N](元素 Copy 时) |
Clone | 显式调用 .clone() 做(可能是深)拷贝 | String、Vec<T>、HashMap |
Move | 默认行为,所有权转移,原变量失效 | 大多数拥有堆数据的类型 |
.clone()不是万能解药借用检查器报错时随手加
.clone()能让代码编过,但可能悄悄引入大量无谓拷贝。先问:这里能不能改成传引用,或者干脆让所有权转移出去?
4. 借用:只借不拿
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &s; // 可以有多个
println!("{} {}", r1, r2);
let r3 = &mut s; // ✅ 前面的不可变借用已经不再使用(NLL)
r3.push_str(", world");
println!("{}", r3);
}NLL(Non-Lexical Lifetimes):借用的有效期到”最后一次使用”为止,而不是到作用域结束。这是 Rust 2018 之后的改进,让很多老代码里的”多余作用域”不再必要。
let mut s = String::from("hello");
let r = &s;
println!("{}", r);
let m = &mut s; // ✅ 上面那次不可变借用已经结束而下面这段就是典型的 E0502:
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用
v.push(4); // ❌ 可变借用:push 可能重新分配,first 会悬垂
println!("{}", first);为什么
push之后不能用first
Vec::push在容量不足时会重新分配堆内存,原来的元素地址失效。借用检查器拦住你,正是因为它防止了一次真实的悬垂引用——这不是”编译器找麻烦”,是它在替你挡 bug。
5. 函数签名上怎么选
| 签名 | 语义 | 使用场景 |
|---|---|---|
fn f(s: String) | 拿走所有权(会 drop) | 需要在函数内销毁、或转移给别人 |
fn f(s: &str) | 只读借用 | 大多数只读函数 |
fn f(s: &mut String) | 可写借用 | 需要原地修改 |
fn f(s: String) -> String | 拿走再还回来 | 链式转换 |
fn f(v: &Vec<T>) | 只读借用(不推荐) | 应写成 &[T] |
经验法则:
- 参数尽量用借用,别让调用方失去所有权
- 返回新值而不是改参数,风格更函数式
- 需要长期持有,才要求所有权
6. String / &str、Vec<T> / &[T]
| 拥有型 | 借用型 | 说明 |
|---|---|---|
String | &str | &str 可来自字符串字面量、String 切片、&String 解引用 |
Vec<T> | &[T] | 切片是通用只读视图 |
PathBuf | &Path | 同上,文件路径版 |
OsString | &OsStr | 同上,非 UTF-8 路径版 |
fn total(nums: &[i32]) -> i32 { // 接受数组、Vec、切片都行
nums.iter().sum()
}
fn main() {
let v = vec![1, 2, 3];
let arr = [4, 5, 6];
println!("{} {}", total(&v), total(&arr));
}借用型让 API 更通用
&[T]比&Vec<T>好,&str比&String好。前者能接受更多输入,且不多一层间接寻址。
7. 常见编译错误对照表
| 错误码 | 报错文本 | 典型原因 | 修法 |
|---|---|---|---|
E0382 | borrow of moved value | 用完所有权又用原变量 | 传引用、提前 clone()、或调整顺序 |
E0502 | cannot borrow as mutable because also borrowed as immutable | 可变与不可变借用重叠 | 缩短借用范围,把读取放在修改之前 |
E0499 | cannot borrow as mutable more than once | 同时两个可变借用 | 用作用域分隔,或改用索引 |
E0505 | cannot move out because borrowed | 借用还活着就转移所有权 | 先结束借用 |
E0596 | cannot borrow as mutable, not declared as mutable | 变量没加 mut | 加 mut,或重新设计 |
E0515 | cannot return reference to local variable | 返回了局部变量的引用 | 返回拥有型值,或传入外部引用 |
E0716 | temporary value dropped while borrowed | 借用了临时值 | 用 let 绑定住临时值 |
读报错的方式
rustc的报错通常会直接给出「suggestion」,照着改八九不离十。遇到借用问题,先看它说哪个借用和哪一行冲突,再想能不能缩短借用范围——而不是立刻clone。
8. 与 C++ RAII 的关系
Rust 的 Drop 和 C++ 的析构函数思路一致:资源获取即初始化,离开作用域即释放。区别在于:
- C++ 允许你复制一个持有资源的对象(触发拷贝构造),也可能出现悬垂引用和 double free,需要开发者自己保证
- Rust 在编译期强制唯一所有权,把这类错误变成类型错误
- Rust 的
Drop是确定的(不像 GC 靠回收时机),适合用来管文件句柄、锁、连接
9. 小结
关键判断
- 所有权管释放,借用管访问,二者由编译器静态检查
- 默认语义是 move,
Copy是例外,Clone是显式深拷贝- 遇到借用冲突,先想「能不能缩短借用范围 / 改成传引用」,最后才考虑
clone- API 设计上优先用借用型(
&str、&[T]),把所有权留在调用方