第 6 章:所有权(Ownership)——Rust 的“终极规则”,告别内存泄漏

欢迎来到本专栏的“天王山之战”。到目前为止,我们学习的所有内容,都是在为理解本章的**所有权(Ownership)**系统做铺垫。这正是 Rust 能够做到“无需垃圾回收器(GC)的内存安全”的秘密所在,也是它与其他所有主流语言最根本的区别。

作为一名经验丰富的 Java 开发者,你可能已经很久没有真正地“关心”过内存了。我们使用 new 关键字创建对象,然后就心安理得地把它抛之脑后。JVM 中那个勤勤恳恳的垃圾回收器(GC)会帮我们处理好一切。这是一种奢侈,也是 Java 强大生产力的来源之一。

现在,请暂时忘掉 GC。我们将进入一个由编译器执行“物理定律”的新世界。所有权不是一个你可以“使用”的库或功能,它是你必须遵守的一套规则。编译器就是这套规则的唯一、绝对且不知疲倦的执法官。


舞台背景:栈(Stack)与堆(Heap)

在我们揭示规则之前,让我们快速地回顾一下程序的内存布局,这对于理解所有权至关重要。

  • 栈(Stack):它以“后进先出”(LIFO)的方式工作。所有存储在栈上的数据都必须拥有已知的、固定的大小。它的操作速度非常快,因为操作系统无需去寻找内存,只需在栈顶进行推入(push)和弹出(pop)。函数调用、局部变量、以及我们之前学过的所有标量类型(i32, bool, f64 等)都存储在栈上。
  • 堆(Heap):当我们需要在编译时大小未知,或者大小可能发生变化的数据时,我们就会在堆上存储。操作系统会在堆的某处找到一块足够大的空间,把它标记为已使用,然后返回一个指向该位置地址的指针(Pointer)。这个过程称为在堆上分配内存(allocating on the heap)。它的速度比栈慢,因为需要更多的管理工作。我们之前学到的 String 类型,其内容就是存储在堆上。

内存管理的终极问题: 如何管理堆上的内存?

  • Java 的方式(GC):运行时,GC 会定期扫描,找出不再使用的堆内存并回收。优点是方便,缺点是会带来运行时开销和不可预测的暂停(Stop-The-World)。
  • C/C++ 的方式(手动管理):程序员必须手动使用 malloc/freenew/delete 来申请和释放内存。优点是极致的性能和控制力,缺点是极易出错(忘记释放导致内存泄漏、释放后继续使用导致悬垂指针、释放两次导致双重释放)。
  • Rust 的方式(所有权):通过一套在编译期强制执行的规则,来管理堆内存的生命周期,并在合适的位置自动插入释放代码。优点是兼具手动管理的性能和 GC 的内存安全。缺点是你,程序员,必须向编译器证明你的代码遵守了这些规则。

所有权的三大定律

好了,现在揭晓 Rust 宇宙的三条核心定律。它们简单、普适、且不容违反。

  1. 每一个值(value)都有一个被称为其“所有者”(owner)的变量。
  2. 在同一时间点,一个值只能有“一个”所有者。
  3. 当所有者离开其作用域(scope)时,它所拥有的值将被“丢弃”(dropped)。

让我们通过实例来彻底理解这三条定律。


定律的实际应用:CopyMove

场景一:栈上的数据 - Copy

对于完全存储在栈上的数据,情况很简单。

fn main() {
    let x = 5;  // 1. x 是 5 的所有者
    let y = x;  // 2. 5 被复制了一份,y 是这份新数据的所有者

    // x 和 y 都是 i32 类型,它实现了 Copy trait
    println!("x = {}, y = {}", x, y); // x 和 y 此刻都有效
} // 3. y 离开作用域,然后 x 离开作用域

这里的 i32 是一个已知大小的简单类型,复制它的成本极低。所有这类“可复制”的类型都实现了一个特殊的标记——Copy trait。包括所有标量类型(i*, u*, f*, bool, char)以及只包含 Copy 类型的元组。

场景二:堆上的数据 - Move

现在,让我们看看 String,它的数据存储在堆上。这才是所有权系统大放异彩的地方。

一个 String 变量,包含三个部分,都存储在栈上:一个指向堆上实际内容的指针,字符串的长度(length),以及分配的总容量(capacity)

fn main() {
    let s1 = String::from("hello"); // s1 是 "hello" 的所有者
    let s2 = s1;                   // 这里发生了什么?
}

如果 Rust 在这里也像 i32 那样,把 s1 的栈上数据(指针、长度、容量)复制一份给 s2,会发生什么?那样 s1s2 将指向同一块堆内存

这就引出了一个致命问题:当 s1s2 离开作用域时,它们都会尝试释放同一块内存。这就是“双重释放(double free)”错误,一个严重的安全漏洞。

为了保证内存安全,Rust 遵守定律 2:同一时间只能有一个所有者。因此,当 let s2 = s1; 执行后,Rust 会认为 s1 不再有效。这个操作不叫“浅拷贝”,它被称为 移动(Move)"hello" 这个值的所有权,从 s1 转移给了 s2

现在,s2 是唯一的所有者。当 s2 离开作用域时,它会安全地释放堆内存。s1 什么都不会做。

如果我们试图在 s1 被移动后继续使用它,执法官——编译器——就会出动:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("s1 is: {}", s1); // 编译错误!
}
error[E0384]: borrow of moved value: `s1`
  --> src/main.rs:5:28
   |
2  |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
3  |     let s2 = s1;
   |              -- value moved here
4  |
5  |     println!("s1 is: {}", s1);
   |                            ^^ value borrowed here after move

这个编译错误完美地诠释了所有权系统。它在编译期就阻止了一场潜在的运行时灾难。


所有权与函数

所有权的移动同样适用于函数传参。

fn main() {
    let s = String::from("world"); // s 进入作用域,成为所有者

    takes_ownership(s); // s 的所有权被“移动”到函数 takes_ownership 中...
                        // ...因此 s 在这里之后就失效了

    let x = 5;          // x 进入作用域
    makes_copy(x);      // x 是 i32,是 Copy 的,所以 x 的一个副本被传入函数
                        // x 在这里之后依然有效

} // x 在这里离开作用域,然后 s 也离开作用域,但因为它已被移动,所以什么都不会发生

fn takes_ownership(some_string: String) { // some_string 进入作用域,获得所有权
    println!("{}", some_string);
} // some_string 在这里离开作用域,它的值被 drop,堆内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // some_integer 在这里离开作用域,无事发生

将值传给函数,要么 move,要么 copy,和赋值操作的行为完全一致。

那么,如果我希望函数处理完数据后,我还能继续使用它呢?当然,我们可以让函数把所有权再返回回来:

fn main() {
    let s1 = String::from("hello");

    let s2 = takes_and_gives_back(s1);

    // s1 已经失效
    println!("s2 is valid: {}", s2); // s2 现在是所有者
}

fn takes_and_gives_back(a_string: String) -> String { // 获得所有权
    a_string  // 返回所有权
}

本章小结

我们今天直面了 Rust 最核心的概念。让我们再次铭记这三条定律:一个所有者,所有权转移,离开作用域则丢弃。

  • 我们理解了所有权是 Rust 用来替代 GC 和手动内存管理,以实现内存安全的编译期机制。
  • 我们区分了 Copy(对于栈数据)和 Move(对于堆数据)这两种不同的行为。
  • 我们看到了所有权如何在赋值和函数调用中发生转移。

你可能会觉得,为了让函数用一下我的数据,就得把所有权交出去,用完了还得再还回来,这也太麻烦了!你说得对,确实很麻烦。

如果有一种方法,能让函数在不获取所有权的情况下,临时“使用”一下我们的数据,那该多好?就像我们把一本书借给朋友看,而不是把书送给他。

这,正是我们要进入的下一个主题——所有权故事的另一半:借用(Borrowing)

在下一章 (《借用(Borrowing)与引用(References)——“我可以看看,但不能拿走”》)[https://silentstormic.top/post/from_java_to_rust/07/] 中,我们将学习如何优雅地、高效地、安全地“出借”我们的数据。