一、为什么 Rust 要创造“所有权”?
我们先看其他语言怎么管内存:
C/C++:程序员手动 malloc/free 或 new/delete。忘了释放就内存泄漏,过早释放就悬垂指针,二次释放直接崩。
带 GC 的语言(Java/Go 等):垃圾回收器定期扫描,自动清理不再使用的内存。省心,但运行时会偶尔停顿(Stop The World),且对系统底层控制力弱。
Rust 选了第三条路:在编译期通过一套所有权规则检查内存,保证运行时绝对安全,还不需要 GC。 这套规则就是所有权系统。代价是,你需要按编译器的方式去思考,但它回报你的,是零成本的抽象和极高的性能。
二、所有权规则(三条铁律,刻在脑子里)
- Rust 中每一个值,都有且只有一个对应的变量,这个变量被称为该值的所有者。
- 任一时刻,值只能有一个所有者。
- 当所有者离开自己的作用域时,这个值会被自动丢弃(释放内存)。
这三条规则就是全部。我们接下来所有的学习,本质上都是在反复理解、验证这三条规则。
三、作用域与 String 类型
变量的作用域很简单:从它被声明的地方开始,到当前代码块结束为止。
{ // s 还不可用,未声明
let s = "hello"; // s 从此可用
// 可以用 s 做各种事情
} // 作用域结束,s 不再可用上面例子里的 s 是字符串字面量,它被硬编码在二进制文件中,不可变,存在哪里我们不太需要操心。但 Rust 里还有一个 String 类型,它在堆上分配内存,能存放编译期未知大小的文本,我们就拿它来观察所有权。
let mut s = String::from("hello");
s.push_str(", world!");
println!("{}", s); // 输出 "hello, world!"这背后的内存发生了什么?
String::from会在堆上申请一块足够大的内存来存放"hello"。s变量本身存在栈上,内部是一个指针、长度和容量(三个字),指向堆上的数据。- 当
s离开作用域时,Rust 会调用一个叫drop的函数,自动把堆内存归还给操作系统。这就是规则 3 的体现。
但问题来了:如果两个变量指向同一块堆内存,离开作用域时分别释放一次,不就变成可怕的“二次释放”了吗?Rust 用“移动”解决了这个问题。
四、变量与数据交互:移动(Move)
看一段看起来简单的代码:
let s1 = String::from("hello");
let s2 = s1;你以为这和 Java/Python 一样,s2 和 s1 都指向同一个 "hello" 吗?在 Rust 里不是。为了避免二次释放,Rust 在 let s2 = s1; 之后,会认为 s1 已经失效,s1 不能再被使用。这相当于所有权从 s1 移动到了 s2。
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1); // 编译错误! value borrowed here after move编译器会告诉你:s1 的值已经移动给了 s2,你再使用 s1 就是错的。这样就完美避免了二次释放:只有 s2 是堆内存的所有者,作用域结束时只由 s2 来释放一次。
这种“浅拷贝 + 让旧变量失效”的操作,Rust 称之为移动(move)。它完美满足了规则 2:任意时刻只有一个所有者。
五、克隆(Clone):真的想深拷贝怎么办?
如果确实需要两个变量各自拥有一份独立的堆数据,你可以显式调用 clone 方法,进行深拷贝。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // 完全合法,各自拥有独立数据clone 会在堆上重新开辟一块内存,把数据原样复制过去。代价你很清楚:开销更大。
六、栈上数据的 Copy trait
那么为什么下面这段代码又没问题呢?
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y); // x 和 y 都能用因为整数这种类型大小在编译期就确定了,完全存在栈上,复制一份栈上数据非常廉价,而且不会有二次释放问题。Rust 为这类类型提供了一个特殊的 trait:Copy。如果一个类型实现了 Copy trait,那么赋值时就是自动按位复制,旧变量仍然有效,不会发生移动。
实现了 Copy 的类型通常都是标量类型和它们的组合:
- 所有整数类型,如
u32、i64。 - 布尔类型
bool。 - 浮点数类型
f32、f64。 - 字符类型
char。 - 元组,当且仅当其所有元素都实现了
Copy时,比如(i32, i32)实现了Copy,但(i32, String)没有。
注意:任何需要分配堆内存或某种资源的类型,都不会是 Copy 的。String 不是,Vec<T> 不是。
七、所有权与函数
把值传给函数,或者从函数返回值,也同样会发生所有权的移动或复制。
fn main() {
let s = String::from("hello");
takes_ownership(s); // s 的值移动进了函数
// s 在此处已失效,不能再使用
let x = 5;
makes_copy(x); // x 是 Copy 类型,自动复制一份传入,x 仍可用
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string 离开作用域,内存被释放
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
}如果函数要返回一个值,所有权也会跟着移出来:
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string // 返回并移动给调用者
}但你会发现,如果每次把 String 传进函数计算后还想继续用,就必须把所有权再传回来,特别啰嗦。例如:
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length) // 把所有权还回去
}为了解决这种“临时使用而不想夺取所有权”的需求,Rust 提供了引用(References)与借用(Borrowing)。
八、引子:引用与借用(一瞥)
引用允许你使用某个值,但不获取其所有权。创建一个引用的过程,称为借用。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 把 s1 的引用传进去,s1 的所有权没动
println!("'{}' 的长度是 {}", s1, len); // s1 依然可用
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s 是引用,离开作用域时不会释放它指向的数据引用让你能非常优雅地使用数据,同时遵守所有权铁律。当然,引用也有它自己的严格规则(同一作用域内只能有一个可变引用,或者多个不可变引用),这套规则又把数据竞争扼杀在了编译期。这个我们可以之后专门讲。
总结一下,帮你消化
- 所有权的目的:在编译期保证内存安全,无需 GC。
- 三条规则:每个值有唯一所有者;一次只有一个所有者;所有者离开作用域值被释放。
- 移动:赋值或传参时,对于堆数据(非 Copy 类型),所有权转移,旧变量失效。
- 克隆:显式深拷贝,代价高。
- Copy 类型:栈上数据自动复制,不发生移动(整数、浮点、布尔、char、包含 Copy 的元组等)。
- 函数:传参和返回都会遵循移动/复制语义。
- 引用与借用:是“临时借用”所有权的解决方案,让你不拿所有权也能安全使用数据。
所有权就像一本严格的物业账簿,谁持有房产证(所有者),谁就能决定房子的命运。你临时借人住一晚(借用),房产证还是你的。这套账本在编译时会被 Rust 一丝不苟地审计,一旦有违规,程序根本编译不过。