所有权和借用
所有权(Ownership)是 Rust 最为独特且核心的特性。它让 Rust 能够脱离垃圾回收(GC)机制,在编译期即确保内存安全。
一、 内存管理的三大流派
在计算机科学中,管理内存通常有三种方式:
- 垃圾回收 (GC) :如 Java、Go、Python。程序运行时自动寻找不再使用的内存。优点是开发快,缺点是运行时开销大,可能出现“停顿”。
- 手动管理 :如 C/C++。程序员手动调用
malloc/free。优点是极致性能,缺点是极其容易出现 悬空指针 、双重释放或 内存泄漏 。 - 所有权系统 :Rust 的路径。通过编译器在编译时根据一套规则检查内存管理。 零运行时开销 。
预备知识:栈 (Stack) 与 堆 (Heap)
栈
栈按照顺序存储值并以相反顺序取出值,这也被称作 后进先出 。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,再从顶部拿走。不能从中间也不能从底部增加或拿走盘子!
增加数据叫做 进栈 ,移出数据则叫做 出栈 。
因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。
堆
与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。
当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针 ,该过程被称为 在堆上分配内存 ,有时简称为 “分配”(allocating)。
接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的 指针 ,来获取数据在堆上的实际内存位置,进而访问该数据。
由上可知,堆是一种缺乏组织的数据结构。想象一下去餐馆就座吃饭:进入餐馆,告知服务员有几个人,然后服务员找到一个够大的空桌子(堆上分配的内存空间)并领你们过去。如果有人来迟了,他们也可以通过桌号(栈上的指针)来找到你们坐在哪。
性能区别
在栈上分配内存比在堆上分配内存要快,因为入栈时操作系统无需进行函数调用(或更慢的系统调用)来分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备,如果当前进程分配的内存页不足时,还需要进行系统调用来申请更多内存。 因此,处理器在栈上分配数据会比在堆上分配数据更加高效。
所有权与堆栈
当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
对于其他很多编程语言,你确实无需理解堆栈的原理,但是 在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助 。
二、 所有权
- 什么是所有权? :每个值都有一个“所有者”(owner),负责在值超出作用域时释放它。Rust 使用所有权来管理堆内存,而不依赖垃圾回收器。
- 为什么重要? :防止双重释放(double free)、使用后释放(use after free)和数据竞争。
- 三条铁律构建 :
每一个值都有一个变量,称为它的“所有者”
在 Rust 中,内存中的数据(值)不能孤立存在,必须绑定到一个变量上。
fn main() {
// 这个 String 字符串值在堆上创建
// 变量 s 成了这个字符串的“所有者”
let s = String::from("hello");
println!("s 拥有这个值: {}", s);
} // 函数结束,s 超出作用域,值被丢弃
同一时间内,一个值只能有一个所有者
这是 Rust 安全性的核心。如果一个堆上的值有两个所有者,就会发生“二次释放”内存错误。因此,Rust 强制执行所有权移动(Move)。
fn main() {
let s1 = String::from("hello");
// 所有权从 s1 转移到了 s2
// 此时内存中仍然只有一个 "hello",但所有者变成了 s2
let s2 = s1;
// println!("{}", s1);
// ❌ 编译报错!s1 不再拥有该值,它已经“失效”了。
println!("现在所有权在 s2 手里: {}", s2);
}
当所有者超出作用域时,该值将被丢弃(drop)
Rust 自动管理内存的秘诀就在这里:通过大括号 {} 定义作用域,一旦走出大括号,变量就会被销毁,内存立即回收。
fn main() {
{
// s 从这里开始有效
let s = String::from("hello");
println!("作用域内: {}", s);
}
// 💡 执行到这里,作用域结束。
// Rust 自动调用 `drop` 函数,释放 s 占用的堆内存。
// println!("{}", s);
// ❌ 编译报错!s 已经不在这里了,内存已经还给系统了。
}
三、 变量交互:移动、克隆与复制
1. 移动 (Move)
对于存储在堆上的复杂类型(如 String),赋值操作默认是“移动”。为了防止 双重释放 ,Rust 会使原变量失效。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权移动到了 s2,s1 此时已失效
// println!("{s1}"); // ❌ 编译错误:使用了已移动的值
println!("{s2}"); // ✅ 有效
}
2. 克隆 (Clone) —— 深拷贝
如果你确实需要复制堆上的数据,必须显式调用 clone。
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 在堆上产生了一份完整副本
println!("s1 = {s1}, s2 = {s2}"); // ✅ 两者均有效
}
3. 复制 (Copy) —— 栈数据拷贝
对于简单、固定大小且完全存储在栈上的类型,Rust 会执行自动拷贝,而不会使原变量失效。常见的 Copy 类型包括:所有的整数、浮点数、布尔值、字符,以及只包含这些类型的元组。
fn main() {
let x = 5;
let y = x; // 栈数据直接拷贝,不涉及所有权转移
println!("x = {x}, y = {y}"); // ✅ 两者均有效
}
四、 引用与借用 (References & Borrowing)
如果你不想转移所有权,但又想使用数据,就需要“借用”。引用(&)就像是现实中的借书:你可以看书,但书不属于你,看完得还。
1. 不可变借用 (&T)
你可以同时拥有多个不可变引用,因为“只读”不会引起数据竞争。
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}"); // ✅ s1 依然有效
}
2. 可变借用 (&mut T)
如果你需要修改借用的数据,必须使用可变引用。但它有极强的限制:在同一作用域内,特定数据只能有一个可变引用。
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{s}");
}
3. 借用规则总结
为了彻底消除 数据竞争 ,Rust 强制执行以下规则:
- 在任何给定时间,你要么只能有一个可变引用,要么可以有任意数量的不可变引用。
- 引用必须始终有效(防止悬垂引用)。
五、 NLL:更智能的借用检查
在旧版 Rust 中,引用的作用域持续到大括号结束。现代 Rust 使用了 NLL (Non-Lexical Lifetimes) ,引用的作用域在最后一次使用处结束。这解决了许多“本该通过但没通过”的编译问题。
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}");
// r1 和 r2 在此处之后不再使用,其作用域结束
let r3 = &mut s; // ✅ 允许,因为之前的不可变借用已失效
println!("{r3}");
}
六、 悬垂引用 (Dangling References)
Rust 会在编译期阻止你返回局部变量的引用,因为局部变量在函数结束时会被释放。
// ❌ 无法通过编译
// fn dangle() -> &String {
// let s = String::from("hello");
// &s // 返回了对局部变量 s 的引用
// }
// ✅ 正确做法:直接返回 String (移动所有权)
fn no_dangle() -> String {
let s = String::from("hello");
s
}
fn main() {
let s = no_dangle();
}
七、 自动释放:Drop 与 RAII
Rust 通过 Drop trait 实现 RAII (资源获取即初始化) 。当变量超出作用域时,Rust 自动调用 drop 方法释放堆内存。
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("正在清理数据: `{}`", self.data);
}
}
fn main() {
let c = CustomSmartPointer { data: String::from("my stuff") };
let d = CustomSmartPointer { data: String::from("other stuff") };
println!("CustomSmartPointers 已创建。");
} // 此处 d 先被 drop,然后 c 被 drop
如何写出符合所有权的代码?
- 优先借用 :除非你确实需要获取数据的所有权(例如要把数据存入结构体中),否则优先使用引用
&T。 - 减少 Clone :如果发现代码里到处是
.clone(),通常说明所有权设计有误。 - 利用作用域 :可以通过手动添加
{ }来缩短变量或引用的生命周期,从而解决借用冲突。