Rust 语言的所有权和借用规则是其核心特性之一,旨在在编译时确保内存安全。以下是 Rust 所有权和借用规则的具体工作原理:
所有权(Ownership)
- 单一所有权:在 Rust 中,每个值都有一个被称为其“所有者”的变量。一个值在任意时刻只能有一个所有者。
- 所有权转移:当所有者将值传递给另一个函数时,所有权会从原所有者转移到新所有者,原所有者将不能再使用该值。
- 所有权释放:当所有者超出作用域时,所有权会被释放,值会被自动销毁。
借用(Borrowing)
- 不可变借用:允许在不修改原始值的情况下访问值。可以有多个不可变借用,但它们不能与可变借用共存。
- 可变借用:允许修改原始值。在任意时刻,只能有一个可变借用,且不能有其他不可变借用。
- 借用的生命周期:借用必须在所有者的生命周期内,且不能比所有者活得更长。
规则细节
规则 1:在任何给定时间,要么只能有一个可变引用,要么只能有多个不可变引用,不能同时拥有多个可变引用和不可变引用。
- 这确保了对值的访问是互斥的,防止了数据竞争。
规则 2:引用必须总是有效的。
- 这意味着引用的生命周期不能超过其所指向的值的生命周期,防止了悬挂指针。
示例代码
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 不可变借用
println!("Length: {}", len);
change(&s); // 可变借用
println!("Changed: {}", s);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
fn change(s: &String) {
s.push_str(", world");
}
在这个示例中:
calculate_length函数接受一个不可变引用,计算字符串的长度。change函数接受一个可变引用,修改原始字符串。
所有权和借用的优势
- 内存安全:Rust 的所有权和借用规则确保了内存安全,避免了常见的内存错误,如空指针解引用、缓冲区溢出和数据竞争。
- 无需垃圾回收:Rust 通过所有权和借用规则管理内存,无需垃圾回收机制,减少了运行时开销。
- 并发安全:Rust 的借用规则自然地支持并发编程,因为它们限制了对共享数据的访问,防止了数据竞争。
Copy 与 Move:所有权的两种转移方式
Rust 中并非所有赋值都会导致所有权转移。类型是否实现 Copy trait 决定了赋值时是复制(Copy)还是移动(Move):
| 类型 | 是否 Copy | 行为 |
|---|---|---|
i32, f64, bool, char | ✅ | 按位复制,原变量仍可用 |
(i32, i32) | ✅ | 元组内所有元素都 Copy 则整体 Copy |
String, Vec<T>, Box<T> | ❌ | Move,原变量失效 |
struct | 默认 ❌ | 除非所有字段都 Copy 且显式 #[derive(Copy)] |
&T, &mut T | ✅ | 引用本身是 Copy 的(复制的是指针,不是数据) |
let x: i32 = 5;
let y = x; // ✅ Copy:x 仍可用
println!("{}", x); // 输出 5
let s1 = String::from("hello");
let s2 = s1; // ❌ Move:s1 的所有权转移给 s2
// println!("{}", s1); // 编译错误:value borrowed here after move
自定义 Copy:
#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // ✅ Copy:p1 仍可用,因为 Point 实现了 Copy
println!("{}", p1.x);
注意:Copy 与 Clone 不同。Copy 是隐式的按位复制,开销为零;Clone 是显式的深拷贝,可能涉及堆内存分配。Copy 类型必须同时实现 Clone。
Move 语义深入:堆数据与栈数据
理解 Move 的关键是区分栈上的值和堆上的数据:
let s = String::from("hello");
栈上(s 变量) 堆上(字符串内容)
┌──────────────┐ ┌──────────────┐
│ ptr ────────│────────►│ h e l l o │
│ len: 5 │ └──────────────┘
│ capacity: 5 │
└──────────────┘
let s2 = s; // Move 后
栈上(s 变量) 栈上(s2 变量) 堆上
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ❌ 已失效 │ │ ptr ────────│────────►│ h e l l o │
└──────────────┘ │ len: 5 │ └──────────────┘
│ capacity: 5 │
└──────────────┘
Move 只是将栈上的"控制块"(指针、长度、容量)复制给新变量,并标记原变量为无效。堆上的数据没有发生物理移动。这保证了 Move 操作始终是 O(1) 的常量时间。
如果需要在 Move 后继续使用原变量,有两个方案:
| 方案 | 写法 | 成本 | 场景 |
|---|---|---|---|
| 克隆 | let s2 = s.clone() | O(n),堆内存复制 | 确实需要两份独立数据 |
| 借用 | let s2 = &s | O(1) | 只读访问,不需要所有权 |
借用检查器的工作流程
Rust 编译器中的借用检查器(Borrow Checker)在编译期执行以下分析:
第 1 步:构建所有权图
├── 标记每个值的所有者变量
└── 标记所有权转移点(Move)
第 2 步:构建借用生命周期
├── 检查所有 &T 和 &mut T 引用的创建位置
└── 检查引用的最后一次使用位置
第 3 步:验证三条核心规则
├── 规则 A:任一时刻,对同一数据只能有一个 &mut T
├── 规则 B:&mut T 与 &T 不能共存
└── 规则 C:所有引用必须指向有效数据
第 4 步:非词法生命周期优化(NLL)
└── 将引用的有效作用域缩小到最后一次使用处(而非代码块末尾)
NLL(Non-Lexical Lifetimes)是 Rust 2018 引入的重要优化。在此之前,引用的生命周期等同于其声明所在的代码块;NLL 后,引用在最后一次使用后即视为失效:
let mut s = String::from("hello");
let r1 = &s; // r1 借用开始
println!("{}", r1); // r1 最后一次使用
let r2 = &mut s; // ✅ NLL 后:r1 在此处已失效,可以可变借用
r2.push_str(" world");
没有 NLL 时,编译器会认为 r1 在代码块结束前一直有效,因此 r2 = &mut s 会触发编译错误。NLL 显著减少了不必要的借用冲突。
生命周期省略规则
大多数情况下,编译器能自动推断生命周期,无需手动标注。以下是三条自动推断规则(Lifetime Elision Rules):
| 规则 | 条件 | 自动推断为 |
|---|---|---|
| 规则 1 | 函数参数中只有一个输入引用 | 所有输出引用共享该生命周期 |
| 规则 2 | 有 &self 或 &mut self | 输出引用继承 self 的生命周期 |
| 规则 3 | 多个输入引用且返回引用 | ❌ 不推断,需手动标注 |
// 规则 1(自动推断)
fn first_word(s: &str) -> &str { ... }
// 推断为:fn first_word<'a>(s: &'a str) -> &'a str
// 规则 2(自动推断)
impl MyStruct {
fn get_data(&self) -> &Data { ... }
// 推断为:fn get_data<'a>(&'a self) -> &'a Data
}
// 规则 3(需手动标注)
fn longest(x: &str, y: &str) -> &str { ... } // ❌ 编译错误
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... } // ✅
理解这三条规则后,生命周期标注就不再是死记硬背,而是判断编译器是否需要你的提示。
实战中的常见模式与反模式
✅ 推荐模式
模式 1:返回值传递所有权
fn create_data() -> Vec<i32> {
let mut data = Vec::new();
data.push(1);
data // 所有权从函数内部 move 到调用者
}
let my_data = create_data(); // my_data 拥有所有权
模式 2:借用返回值
fn get_first(data: &Vec<i32>) -> Option<&i32> {
data.first() // 返回对内部元素的引用,不转移所有权
}
let v = vec![1, 2, 3];
let first = get_first(&v); // v 仍拥有 Vec 的所有权
模式 3:返回 owning + borrowing 的组合
struct Parser {
text: String,
}
impl Parser {
fn content(&self) -> &str {
&self.text // 借用内部数据
}
fn into_content(self) -> String {
self.text // 消耗 self,交出所有权
}
}
❌ 常见反模式
反模式 1:函数返回局部变量的引用
fn bad() -> &String {
let s = String::from("hello");
&s // ❌ s 在函数结束时被 drop,返回悬垂引用
}
修复:返回 String(Move)而非 &String。
反模式 2:同时拥有可变和不可变引用
let mut data = vec![1, 2, 3];
let ref1 = &data[0]; // 不可变借用
let ref2 = &mut data; // ❌ 编译错误:不能同时拥有不可变和可变借用
data.push(4); // 如果允许,ref1 可能指向已释放的内存
修复:缩小不可变借用的作用域,用完即丢。
反模式 3:self 与 &self 的误用
impl MyType {
fn get_data(self) -> Data { self.data } // ❌ 消耗 self,调用后实例不可用
fn get_data(&self) -> &Data { &self.data } // ✅ 仅借用,实例可继续使用
}
只有在方法确实需要"消耗"对象时才使用 self(如 into_iter()),否则会意外剥夺调用者的所有权。
总结
Rust 的所有权系统通过以下机制在编译期保证内存安全:
- 单一所有权:每个值有且只有一个所有者,所有者离开作用域时值自动释放。
- Copy vs Move:栈上小值默认 Copy,堆上值默认 Move。Clone 是显式深拷贝。
- 不可变借用(
&T):允许多个并存,只读访问。 - 可变借用(
&mut T):同一时间只能有一个,排他性写入。 - 生命周期:编译器自动推断或手动标注,确保引用不超出被引用数据的生命周期。
- NLL:将引用有效期缩小到最后一次使用,减少不必要的借用冲突。
这些规则共同构成了 Rust “零成本抽象"的基石——你在编写代码时获得的是与 C/C++ 同等的性能,却不需要承担手动内存管理的风险。初期的编译错误是为了防止运行时的崩溃和安全漏洞,理解这一点后,借用检查器就从"绊脚石"变成了"安全网”。
Rust 的所有权和借用规则是其语言设计的核心,提供了编译时的内存安全保证。通过理解这些规则,开发者可以编写既安全又高效的 Rust 程序。虽然这些规则在一开始可能会显得复杂,但它们是 Rust 提供高性能和内存安全的关键。只有真正掌握 Copy/Move 的边界、借用的作用域规则、NLL 带来的灵活性,才能在实战中写出符合 Rust 语言哲学的代码。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。