本文基于 Rust 1.80+。
如果你有 Go 或 Java 背景,所有权系统可能是 Rust 中最让你困惑的概念。Go 有垃圾回收器(GC),你很少需要关心内存何时释放。Rust 选择了一条完全不同的路——编译期通过所有权规则保证内存安全,零运行时开销。
所有权系统是 Rust 的灵魂。它解决了两个经典难题:如何在不使用 GC的情况下保证内存安全,以及如何避免数据竞争。这两个问题在其他语言中要么需要运行时检查,要么需要开发者极度谨慎。
所有权三大规则
Rust 的所有权系统建立在三条简单的规则之上:
- 每个值都有一个所有者(owner)
- 同一时刻只能有一个所有者
- 当所有者离开作用域(scope)时,值被 drop(自动释放)
让我们逐条理解。
规则一:每个值都有一个所有者
在 Rust 中,每个值都绑定到一个变量——这个变量就是该值的所有者。
1
2
| let x = 5; // x 是整数 5 的所有者
let s = String::from("hello"); // s 是 String 的所有者
|
规则二:同一时刻只能有一个所有者
当所有权转移时,原来的所有者立即失效。这就是 Move 语义。
1
2
3
4
5
| let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 转移到 s1
// println!("{}", s1); // 编译错误!s1 已失效
println!("{}", s2); // ✅ s2 有效
|
这个编译错误信息通常会是这样:
1
2
3
4
5
6
7
8
| error[E0382]: use of moved value: `s1`
--> src/main.rs:4:20
|
2 | let s2 = s1;
| -- value moved here
3 |
4 | println!("{}", s1);
| ^^ value used here after move
|
规则三:离开作用域时自动释放
当所有者离开作用域,Rust 自动调用 drop 函数清理内存。这类似于 C++ 的 RAII,但不需要手动实现。
1
2
3
| {
let s = String::from("hello");
} // s 离开作用域,drop 被调用,内存自动释放
|
Move 语义:Copy 类型 vs Move 类型
Rust 中的类型分为两类:
Copy 类型(栈上简单类型)
实现了 Copy trait 的类型在赋值时会自动复制,原变量保持有效:
1
2
3
4
| let x = 5;
let y = x; // x 被复制到 y
println!("{}", x); // ✅ x 仍然有效
println!("{}", y); // ✅ y 也有效
|
常见的 Copy 类型:
- 所有整数类型(
i32, u64, usize 等) - 浮点数(
f32, f64) - 布尔值(
bool) - 字符(
char) - 元组(如果所有元素都是 Copy 类型)
Move 类型(堆上复杂类型)
没有实现 Copy trait 的类型在赋值时会移动所有权:
1
2
3
| let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到 s2
// println!("{}", s1); // ❌ 编译错误
|
常见的 Move 类型:
StringVec<T>Box<T>HashMap<K, V>- 用户自定义的
struct(默认是 Move 类型)
为什么需要区分?
Copy 类型的大小在编译期已知,且完全存储在栈上,复制成本很低。Move 类型通常包含堆分配,复制成本高昂,Rust 选择移动而非深拷贝来避免不必要的性能开销。
如果需要深拷贝,显式调用 .clone():
1
2
3
4
| let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝,两者都有效
println!("{}", s1); // ✅
println!("{}", s2); // ✅
|
函数调用中的所有权转移
传参也会触发所有权转移:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| fn takes_ownership(s: String) {
println!("{}", s);
} // s 离开作用域,drop 被调用
fn makes_copy(i: i32) {
println!("{}", i);
} // i 是 Copy 类型,离开作用域不会特别处理
fn main() {
let s = String::from("hello");
takes_ownership(s);
// println!("{}", s); // ❌ 编译错误:s 的所有权已被移动
let x = 5;
makes_copy(x);
println!("{}", x); // ✅ x 仍然有效
}
|
借用:不转移所有权的访问
如果我们希望函数访问数据但不取得所有权怎么办?使用引用(reference)。
不可变引用(&T)
使用 & 创建不可变引用,允许多个引用同时存在:
1
2
3
4
5
6
7
8
9
10
| fn calculate_length(s: &String) -> usize {
s.len()
} // s 离开作用域,因为它是引用,不会 drop
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!(" '{}' 的长度是 {}", s1, len);
}
|
可变引用(&mut T)
使用 &mut 创建可变引用,允许修改数据,但有严格限制:
1
2
3
4
5
6
7
8
9
| fn append_world(s: &mut String) {
s.push_str(", world!");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{}", s); // 输出 "hello, world!"
}
|
借用规则
Rust 的借用检查器在编译期强制执行以下规则:
- 同一时刻,可以有一个可变引用,或多个不可变引用
- 引用必须始终有效(不能悬垂)
- 不能同时存在可变和不可变引用
规则 1:可变 vs 不可变
1
2
3
4
5
6
7
8
| let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &s; // ✅ 可以有多个不可变引用
println!("{} {}", r1, r2);
let r3 = &mut s; // ❌ 编译错误:不能在已有不可变引用时创建可变引用
println!("{}", r3);
|
编译错误:
1
2
3
4
5
6
7
8
9
10
11
| error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
2 | let r1 = &s;
| -- immutable borrow occurs here
...
4 | let r2 = &s;
| -- immutable borrow occurs here
...
6 | let r3 = &mut s;
| ^^^^^^ mutable borrow occurs here
|
但如果你在不可变引用使用完后创建可变引用,是允许的:
1
2
3
4
5
6
7
8
9
10
| let mut s = String::from("hello");
{
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
} // r1 和 r2 离开作用域,不再使用
let r3 = &mut s; // ✅ 现在可以创建可变引用
println!("{}", r3);
|
规则 2:防止悬垂引用
悬垂引用是指指向已释放内存的引用,这是 C/C++ 中常见的安全漏洞。Rust 编译器在编译期就能完全防止这种情况:
1
2
3
4
5
6
7
8
9
| fn dangle() -> &String {
let s = String::from("hello");
&s // ❌ 编译错误:返回指向 `s` 的引用,但 `s` 将被 drop
}
fn main() {
let reference_to_nothing = dangle();
}
|
编译错误:
1
2
3
4
5
6
7
8
9
10
11
| error[E0106]: missing lifetime specifier
--> src/main.rs:1:16
|
1 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `Owned` type instead
|
1 | fn dangle() -> String {
| ~~~~~~~
|
编译器告诉你:函数返回了一个引用,但没有值可以借用——因为 s 会在函数结束时被 drop。
正确的做法是返回 String 本身(转移所有权):
1
2
3
4
| fn no_dangle() -> String {
let s = String::from("hello");
s // ✅ 所有权转移给调用者
}
|
规则 3:数据竞争预防
数据竞争发生在以下情况:
- 两个或多个指针同时访问同一数据
- 至少有一个指针在写数据
- 没有同步机制
Rust 的借用规则在编译期就能防止数据竞争:
1
2
3
4
5
6
7
8
9
10
11
12
13
| use std::thread;
fn main() {
let mut data = vec![1, 2, 3, 4];
let handle = thread::spawn(|| {
let r1 = &mut data[0]; // ❌ 编译错误:data 可能被其他线程访问
*r1 += 1;
});
println!("{:?}", data); // 同时访问 data
handle.join().unwrap();
}
|
这个代码无法编译,因为 Rust 知道 data 在另一个线程中可能被可变借用,而主线程又在访问它。
正确的做法是使用 Arc<Mutex<T>> 等并发原语:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3, 4]));
let handle = thread::spawn({
let data = Arc::clone(&data);
move || {
let mut vec = data.lock().unwrap();
vec[0] += 1;
}
});
{
let vec = data.lock().unwrap();
println!("{:?}", *vec);
}
handle.join().unwrap();
}
|
对比 Go 的垃圾回收
Go 的方式:运行时 GC
Go 的垃圾回收器在运行时追踪哪些变量还在使用,哪些可以安全回收。优点是开发体验好——你不需要手动管理内存。缺点是:
- GC 停顿:回收时会暂停程序执行,延迟不可预测
- 内存开销:需要维护 GC 元数据,实际内存占用翻倍
- 无法完全控制:调 GC 参数是门玄学
Rust 的方式:编译期检查
Rust 通过所有权规则在编译期确定何时释放内存,零运行时开销:
| 维度 | Go(GC) | Rust(所有权) |
|---|
| 内存释放时机 | 运行时 GC 决定 | 编译期确定,离开作用域立即释放 |
| 运行时开销 | GC 停顿、元数据维护 | 零成本 |
| 延迟可预测性 | 不可预测 | 完全可预测 |
| 开发者控制力 | 低 | 高 |
| 内存安全 | GC 保证释放,但不保证泄漏 | 编译器保证 |
| 并发安全 | 需要开发者小心 | 编译器防止数据竞争 |
为什么 Rust 不需要 GC?
Rust 的设计哲学是:用编译期检查替代运行时回收。
当变量离开作用域时,编译器插入 drop 调用。因为所有权规则保证了每个值只有一个所有者,编译器可以精确知道何时释放内存,不需要 GC 那样的运行时追踪。
1
2
3
4
| {
let s = String::from("hello");
// 使用 s
} // 编译器自动插入 drop(&mut s)
|
Go 开发者常见误区
- 误以为 Rust 有 GC:Rust 没有运行时 GC,内存释放是确定性的。
- 用
.clone() 避免编译错误:这会导致不必要的深拷贝,应该用引用传递。 - 认为所有权规则太严:这些规则在熟悉后会变得自然,而且编译器的错误信息非常友好。
Rust 开发者常见误区
- 过度使用
.clone():优先考虑引用,只有必要时才克隆。 - 忽略生命周期标注:虽然编译器经常能推断,但复杂场景需要显式标注。
- 不敢用
unsafe:某些底层操作需要 unsafe,但要极其谨慎。
生命周期入门
你可能注意到上面的代码中很少出现生命周期标注('a)。这是因为 Rust 编译器很聪明,大多数情况下可以自动推断。
1
2
3
4
5
6
7
8
| fn longest(x: &str, y: &str) -> &str {
// 这个函数无法编译!编译器不知道返回值借用了 x 还是 y
if x.len() > y.len() {
x
} else {
y
}
}
|
需要显式标注生命周期:
1
2
3
4
5
6
7
| fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
|
生命周期标注 'a 告诉编译器:返回的引用与输入参数的引用有相同的最短生命周期。这样编译器就能保证返回的引用不会悬垂。
生命周期的深入讲解是下一篇文章的内容,这里只是预告它的存在。
总结
所有权系统是 Rust 的基石,它让编译器在编译期就能保证内存安全和并发安全。关键要点:
- 所有权三大规则:一个值只有一个所有者、Move 语义、离开作用域自动释放
- Copy vs Move 类型:简单类型自动复制,复杂类型转移所有权
- 借用规则:同一时刻要么有多个不可变引用,要么有一个可变引用,不能混合
- 防止悬垂引用:编译器保证引用始终有效
- 对比 Go GC:Rust 用编译期检查替代运行时 GC,零成本但学习曲线陡峭
- 生命周期:标注引用的有效范围,防止悬垂引用(下一章深入)
所有权规则初看起来繁琐,但一旦理解了背后的设计哲学,你会发现它让 Rust 代码的内存行为变得完全可预测、可验证。这正是 Rust 如何在不牺牲性能的前提下实现内存安全的秘密。
下一篇将深入探讨生命周期标注,理解它如何让编译器在复杂场景下也能保证引用安全。