Skip to content

一、为什么 Rust 要创造“所有权”? ​

我们先看其他语言怎么管内存:

C/C++:程序员手动 malloc/free 或 new/delete。忘了释放就内存泄漏,过早释放就悬垂指针,二次释放直接崩。

带 GC 的语言(Java/Go 等):垃圾回收器定期扫描,自动清理不再使用的内存。省心,但运行时会偶尔停顿(Stop The World),且对系统底层控制力弱。

Rust 选了第三条路:在编译期通过一套所有权规则检查内存,保证运行时绝对安全,还不需要 GC。 这套规则就是所有权系统。代价是,你需要按编译器的方式去思考,但它回报你的,是零成本的抽象和极高的性能。

二、所有权规则(三条铁律,刻在脑子里) ​

  1. Rust 中每一个值,都有且只有一个对应的变量,这个变量被称为该值的所有者。
  2. 任一时刻,值只能有一个所有者。
  3. 当所有者离开自己的作用域时,这个值会被自动丢弃(释放内存)。

这三条规则就是全部。我们接下来所有的学习,本质上都是在反复理解、验证这三条规则。

三、作用域与 String 类型 ​

变量的作用域很简单:从它被声明的地方开始,到当前代码块结束为止。

rust
{                      // s 还不可用,未声明
    let s = "hello";   // s 从此可用
    // 可以用 s 做各种事情
}                      // 作用域结束,s 不再可用

上面例子里的 s 是字符串字面量,它被硬编码在二进制文件中,不可变,存在哪里我们不太需要操心。但 Rust 里还有一个 String 类型,它在堆上分配内存,能存放编译期未知大小的文本,我们就拿它来观察所有权。

rust
let mut s = String::from("hello");
s.push_str(", world!");
println!("{}", s); // 输出 "hello, world!"

这背后的内存发生了什么?

  • String::from 会在堆上申请一块足够大的内存来存放 "hello"。
  • s 变量本身存在栈上,内部是一个指针、长度和容量(三个字),指向堆上的数据。
  • 当 s 离开作用域时,Rust 会调用一个叫 drop 的函数,自动把堆内存归还给操作系统。这就是规则 3 的体现。

但问题来了:如果两个变量指向同一块堆内存,离开作用域时分别释放一次,不就变成可怕的“二次释放”了吗?Rust 用“移动”解决了这个问题。

四、变量与数据交互:移动(Move) ​

看一段看起来简单的代码:

rust
let s1 = String::from("hello");
let s2 = s1;

你以为这和 Java/Python 一样,s2 和 s1 都指向同一个 "hello" 吗?在 Rust 里不是。为了避免二次释放,Rust 在 let s2 = s1; 之后,会认为 s1 已经失效,s1 不能再被使用。这相当于所有权从 s1 移动到了 s2。

rust
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 方法,进行深拷贝。

rust
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2); // 完全合法,各自拥有独立数据

clone 会在堆上重新开辟一块内存,把数据原样复制过去。代价你很清楚:开销更大。


六、栈上数据的 Copy trait ​

那么为什么下面这段代码又没问题呢?

rust
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> 不是。


七、所有权与函数 ​

把值传给函数,或者从函数返回值,也同样会发生所有权的移动或复制。

rust
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);
}

如果函数要返回一个值,所有权也会跟着移出来:

rust
fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string // 返回并移动给调用者
}

但你会发现,如果每次把 String 传进函数计算后还想继续用,就必须把所有权再传回来,特别啰嗦。例如:

rust
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length) // 把所有权还回去
}

为了解决这种“临时使用而不想夺取所有权”的需求,Rust 提供了引用(References)与借用(Borrowing)。


八、引子:引用与借用(一瞥) ​

引用允许你使用某个值,但不获取其所有权。创建一个引用的过程,称为借用。

rust
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 一丝不苟地审计,一旦有违规,程序根本编译不过。

Java · Golang · Rust