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% 的机制:

  1. Rust 中的每个值都有一个所有者(owner)变量。
  2. 在任何时刻,只有一个所有者。
  3. 当所有者离开作用域时,值被丢弃(释放)。

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、借用机制和一个-可变-或多个-不可变的规则、生命周期注解——它们协同工作让编译器在编译时捕获内存不安全的错误,而不是让它们在运行时崩溃。起初,借用检查器可能会让你与编译器“争执”相当多,但一旦你习惯了,你会写出比从未使用这种检查的代码更少错误的代码,而且无需任何运行时守护程序在幕后守护。

rustownershipmemory-safetysystems-programmingborrow-checker