Rust 中的所有权:无需 GC 的内存安全
1. 概述
所有权是 Rust 的核心机制,它确保内存安全,无需像 Java 或 Go 那样的垃圾回收器(GC),也无需像 C 那样手动调用 malloc/free。本文解释了三条所有权规则、借用(borrowing)的概念、生命周期(lifetime)以及常见的编译时错误。读完本文后,你将理解为什么 Rust 在编译时“挑剔”,但作为交换,你不会遇到悬垂指针、双重释放或运行时数据竞争。
2. 问题:没有 GC 的内存管理
Java 等语言使用 GC 来自动清理垃圾——很方便,但会消耗 CPU 并导致不可预测的暂停。C/C++ 给你完全的自由,但容易出现双重释放、使用已释放的内存、内存泄漏。Rust 选择第三条路:编译器跟踪谁“拥有”哪些数据,当所有者离开作用域时自动释放。没有运行时开销,也不需要 GC 线程。
fn main() {
let s = String::from("Non La Dev");
println!("{s}");
} // s 在这里离开作用域,Rust 自动调用 drop(s) 释放堆内存
String::from 行在堆上分配了一块内存。当变量 s 离开花括号 } 时,Rust 自动插入 drop 调用——类似于 finally { free(s) },但是由编译器生成的,你无需手动编写。
3. 三条所有权规则
Rust 有三条确切的规则,记住它们就能掌握 80% 的机制:
- Rust 中的每个值都有一个所有者(owner)变量。
- 在任何时刻,只有一个所有者。
- 当所有者离开作用域时,值被丢弃(释放)。
3.1. 移动语义
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 "移动" 到 s2
println!("{s2}");
// println!("{s1}"); // 编译错误:s1 已被移动
}
与 Java 不同(赋值变量只是复制引用,两者都指向同一个对象,GC 负责其余部分),Rust 认为 let s2 = s1 是转移所有权。在这行之后,s1 不再有效。如果你取消注释最后一行,你会得到错误:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}");
| ^^^^ value borrowed here after move
这从根本上防止了双重释放错误:如果 s1 和 s2 都持有指向同一堆区域的指针,当两者都离开作用域时,Rust 会尝试释放两次——导致崩溃。编译器在编译时就阻止了这个问题。
3.2. 需要保留两个所有者时使用 Clone
如果你真的需要两个独立的副本,明确调用 .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 深度复制堆区域
println!("{s1} - {s2}"); // 两者都有效
}
.clone() 分配一块新的堆内存并复制内容。运行时成本是明确的,Rust 强制你显式调用它,这样就不会有人不小心克隆一个巨大的数据结构而不知道——与许多语言的隐式复制不同。
3.3. Copy trait 用于简单数据类型
fn main() {
let x = 5;
let y = x; // 复制,而不是移动
println!("x = {x}, y = {y}"); // 两者都可以用
}
原始类型如 i32、bool、char 或包含这些类型的元组实现了 Copy trait。由于它们的大小固定且完全位于栈上,复制比移动成本更低,所以 Rust 自动复制而不是使原变量无效。
注意: 一个类型不能同时实现
Copy和Drop。如果一个类型有需要清理的资源(堆、文件句柄等),它不能被Copy。
4. 传递给函数时的所有权
将变量传递给函数也遵循与赋值变量相同的移动语义:
fn print_order(order: String) {
println!("Order: {order}");
} // order 在这里被丢弃,而不是在调用者那里
fn main() {
let order = String::from("ORDER-001");
print_order(order);
// println!("{order}"); // 错误:order 已被移动到函数中
}
当调用 print_order(order) 时,order 的所有权被转移到函数的参数。函数结束时,该值在函数内部被丢弃——main 不再有权使用 order。这就是为什么在你只想“借用读取”数据而不转移所有权时,借用(下一节)几乎总是必需的。
5. 借用:无需获得所有权即可使用数据
5.1. 不可变引用(&T)
fn print_order(order: &String) {
println!("Order: {order}");
}
fn main() {
let order = String::from("ORDER-001");
print_order(&order); // 借用,不移动
println!("仍然可以使用:{order}");
}
符号 & 创建一个引用——借用数据而无需获得所有权。函数 print_order 只读,不拥有 order。函数运行结束后,main 仍然完全拥有并可以使用该变量。
5.2. 可变引用(&mut T)
fn add_discount(total: &mut f64) {
*total -= 10.0;
}
fn main() {
let mut total = 100.0;
add_discount(&mut total);
println!("Total: {total}"); // Total: 90
}
&mut 允许借用来修改。要调用它,原始变量必须声明为 mut。符号 * 用于解引用并向引用指向的内存位置分配新值。
5.3. 借用规则
Rust 在编译时应用两条借用法则,统称为 borrow checker:
- 在任何时刻,要么有一个可变引用,要么有多个不可变引用——不能混合。
- 引用永远不能存活得比原始数据更长(防止悬垂引用)。
fn main() {
let mut total = 100.0;
let r1 = &total;
let r2 = &total; // 可以:多个不可变引用同时存在
println!("{r1} {r2}");
let r3 = &mut total; // 错误,如果 r1、r2 在这之后仍被使用
println!("{r3}");
}
如果在第一个 println! 之后 r1 和 r2 都不再被使用,编译器(由于非词法生命周期的特性)会允许 r3 在下面进行可变借用。但如果你试图在 r3 出现后使用 r1,你会看到:
error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable
这个规则在编译时完全消除了数据竞争:不可能同时有人读取和有人写入同一块内存。
6. 生命周期:确保引用不指向“虚空”
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let order1 = String::from("ORDER-001");
let order2 = String::from("ORDER-002-LONG");
let result = longest(order1.as_str(), order2.as_str());
println!("更长的:{result}");
}
符号 'a 是生命周期注解,告诉编译器:“返回的引用不会比 x 和 y 都活得更长”。Rust 无法自动推断多个引用之间的这种关系,所以你必须明确声明。大多数时候 Rust 可以自动推断生命周期(生命周期省略),所以你很少需要手动写 'a——只有当函数接收多个引用并返回其中之一时才需要。
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected named lifetime parameter
如果缺少 'a,编译器会报错,因为它不知道返回的引用应该与 x 还是 y 关联。
7. 需要多个所有者时:Rc 和 RefCell
单一所有者的所有权模型有时过于严格,例如在构建树或图数据结构时,多个节点需要引用同一个值。
<!-- 无需依赖项,Rc/RefCell 在 std 中 -->
use std::cell::RefCell;
use std::rc::Rc;
struct Customer {
name: String,
balance: RefCell<f64>,
}
fn main() {
let customer = Rc::new(Customer {
name: String::from("Nón Lá"),
balance: RefCell::new(0.0),
});
let c1 = Rc::clone(&customer);
let c2 = Rc::clone(&customer);
*c1.balance.borrow_mut() += 50.0;
*c2.balance.borrow_mut() += 30.0;
println!("{} balance = {}", customer.name, customer.balance.borrow());
println!("引用数:{}", Rc::strong_count(&customer));
}
Rc<T>(引用计数)允许多个所有者共享指向不可变数据的指针,在运行时计算引用数,当计数为 0 时自动丢弃。RefCell<T> 允许在运行时而不是编译时进行可变借用(内部可变性)——代价是,如果你违反借用规则(例如同时两次调用 borrow_mut()),程序会 panic 而不是报告编译错误。
Nón Lá balance = 80
引用数:3
注意:
Rc/RefCell仅用于单线程。如果需要在多个线程间共享数据,使用Arc(原子引用计数)结合Mutex或RwLock。不要滥用Rc<RefCell<T>>——它会失去借用检查器在编译时捕获错误这一 Rust 的最大优势。
8. 编写测试来验证所有权行为
#[derive(Clone)]
struct Order {
id: String,
amount: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.amount -= order.amount * percent / 100.0;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_original_order_untouched_when_cloned() {
let original = Order { id: "ORD-1".into(), amount: 100.0 };
let mut copy = original.clone();
apply_discount(&mut copy, 10.0);
assert_eq!(original.amount, 100.0); // 原始版本保持不变
assert_eq!(copy.amount, 90.0);
}
#[test]
fn should_mutate_through_mutable_borrow() {
let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
apply_discount(&mut order, 25.0);
assert_eq!(order.amount, 150.0);
}
}
运行 cargo test,输出:
running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
第一个测试证明了 .clone() 创建独立副本——修改 copy 不会影响 original。第二个测试确认 &mut 确实通过引用改变了原始数据。
9. 常见错误
- 使用已移动的值:试图在移动到其他地方后使用变量(函数、新变量、结构体)。解决方法是使用
.clone()如果需要两个副本,或改为传递引用(&T)。 - 无法多次可变借用:持有一个
&mut,但同时尝试创建另一个&或&mut。缩小旧引用的作用域(使用{}块或让它更早“死亡”)来解决。 - 悬垂引用:返回指向函数内局部变量的引用,该变量在函数结束时被丢弃。Rust 在编译时完全防止了这个错误——你会看到
missing lifetime specifier或borrowed value does not live long enough的错误。 - 滥用
.clone()来避开借用检查器:在编译上是合法的,但会浪费性能。把每个.clone()看作一个问题:“我真的需要独立副本吗,还是只是在逃避理解所有权?”
10. 所有权严格性何时不必要
如果你写短脚本、快速原型,或数据总是小的且没有严格的性能/安全要求,学习和遵守借用检查器的成本可能不值得——Python 或 Go 这样的语言会给你更快的开发速度。所有权在需要高性能且严格内存安全的系统中最闪耀:操作系统、嵌入式、游戏引擎或需要低延迟且不能接受 GC 暂停的后端服务。
11. 结论
所有权是使 Rust 实现一个看起来矛盾的目标的基础:内存安全如同有 GC 的语言,但性能等同于 C/C++。三条所有权规则 owner-move-drop、借用机制和一个-可变-或多个-不可变的规则、生命周期注解——它们协同工作让编译器在编译时捕获内存不安全的错误,而不是让它们在运行时崩溃。起初,借用检查器可能会让你与编译器“争执”相当多,但一旦你习惯了,你会写出比从未使用这种检查的代码更少错误的代码,而且无需任何运行时守护程序在幕后守护。