所有权与借用:Rust 内存安全的核心

🔊

本文基于 Rust 1.80+。

如果你有 Go 或 Java 背景,所有权系统可能是 Rust 中最让你困惑的概念。Go 有垃圾回收器(GC),你很少需要关心内存何时释放。Rust 选择了一条完全不同的路——编译期通过所有权规则保证内存安全,零运行时开销。

所有权系统是 Rust 的灵魂。它解决了两个经典难题:如何在不使用 GC的情况下保证内存安全,以及如何避免数据竞争。这两个问题在其他语言中要么需要运行时检查,要么需要开发者极度谨慎。

所有权三大规则

Rust 的所有权系统建立在三条简单的规则之上:

  1. 每个值都有一个所有者(owner)
  2. 同一时刻只能有一个所有者
  3. 当所有者离开作用域(scope)时,值被 drop(自动释放)

让我们逐条理解。

规则一:每个值都有一个所有者

在 Rust 中,每个值都绑定到一个变量——这个变量就是该值的所有者。

rust
1
2
let x = 5;       // x 是整数 5 的所有者
let s = String::from("hello");  // s 是 String 的所有者

规则二:同一时刻只能有一个所有者

当所有权转移时,原来的所有者立即失效。这就是 Move 语义

rust
1
2
3
4
5
let s1 = String::from("hello");
let s2 = s1;  // 所有权从 s1 转移到 s1

// println!("{}", s1);  // 编译错误!s1 已失效
println!("{}", s2);  // ✅ s2 有效

这个编译错误信息通常会是这样:

1
2
3
4
5
6
7
8
error[E0382]: use of moved value: `s1`
 --> src/main.rs:4:20
  |
2 |     let s2 = s1;
  |         -- value moved here
3 |
4 |     println!("{}", s1);
  |                    ^^ value used here after move

规则三:离开作用域时自动释放

当所有者离开作用域,Rust 自动调用 drop 函数清理内存。这类似于 C++ 的 RAII,但不需要手动实现。

rust
1
2
3
{
    let s = String::from("hello");
}  // s 离开作用域,drop 被调用,内存自动释放

Move 语义:Copy 类型 vs Move 类型

Rust 中的类型分为两类:

Copy 类型(栈上简单类型)

实现了 Copy trait 的类型在赋值时会自动复制,原变量保持有效:

rust
1
2
3
4
let x = 5;
let y = x;  // x 被复制到 y
println!("{}", x);  // ✅ x 仍然有效
println!("{}", y);  // ✅ y 也有效

常见的 Copy 类型:

  • 所有整数类型(i32, u64, usize 等)
  • 浮点数(f32, f64
  • 布尔值(bool
  • 字符(char
  • 元组(如果所有元素都是 Copy 类型)

Move 类型(堆上复杂类型)

没有实现 Copy trait 的类型在赋值时会移动所有权:

rust
1
2
3
let s1 = String::from("hello");
let s2 = s1;  // s1 的所有权移动到 s2
// println!("{}", s1);  // ❌ 编译错误

常见的 Move 类型:

  • String
  • Vec<T>
  • Box<T>
  • HashMap<K, V>
  • 用户自定义的 struct(默认是 Move 类型)

为什么需要区分?

Copy 类型的大小在编译期已知,且完全存储在栈上,复制成本很低。Move 类型通常包含堆分配,复制成本高昂,Rust 选择移动而非深拷贝来避免不必要的性能开销。

如果需要深拷贝,显式调用 .clone()

rust
1
2
3
4
let s1 = String::from("hello");
let s2 = s1.clone();  // 深拷贝,两者都有效
println!("{}", s1);  // ✅
println!("{}", s2);  // ✅

函数调用中的所有权转移

传参也会触发所有权转移:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn takes_ownership(s: String) {
    println!("{}", s);
}  // s 离开作用域,drop 被调用

fn makes_copy(i: i32) {
    println!("{}", i);
}  // i 是 Copy 类型,离开作用域不会特别处理

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // println!("{}", s);  // ❌ 编译错误:s 的所有权已被移动

    let x = 5;
    makes_copy(x);
    println!("{}", x);  // ✅ x 仍然有效
}

借用:不转移所有权的访问

如果我们希望函数访问数据但不取得所有权怎么办?使用引用(reference)。

不可变引用(&T

使用 & 创建不可变引用,允许多个引用同时存在:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s 离开作用域,因为它是引用,不会 drop

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

    println!(" '{}' 的长度是 {}", s1, len);
}

可变引用(&mut T

使用 &mut 创建可变引用,允许修改数据,但有严格限制:

rust
1
2
3
4
5
6
7
8
9
fn append_world(s: &mut String) {
    s.push_str(", world!");
}

fn main() {
    let mut s = String::from("hello");
    append_world(&mut s);
    println!("{}", s);  // 输出 "hello, world!"
}

借用规则

Rust 的借用检查器在编译期强制执行以下规则:

  1. 同一时刻,可以有一个可变引用,或多个不可变引用
  2. 引用必须始终有效(不能悬垂)
  3. 不能同时存在可变和不可变引用

规则 1:可变 vs 不可变

rust
1
2
3
4
5
6
7
8
let mut s = String::from("hello");

let r1 = &s;      // 不可变引用
let r2 = &s;      // ✅ 可以有多个不可变引用
println!("{} {}", r1, r2);

let r3 = &mut s;  // ❌ 编译错误:不能在已有不可变引用时创建可变引用
println!("{}", r3);

编译错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
2 |     let r1 = &s;
  |              -- immutable borrow occurs here
...
4 |     let r2 = &s;
  |              -- immutable borrow occurs here
...
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here

但如果你在不可变引用使用完后创建可变引用,是允许的:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let mut s = String::from("hello");

{
    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
}  // r1 和 r2 离开作用域,不再使用

let r3 = &mut s;  // ✅ 现在可以创建可变引用
println!("{}", r3);

规则 2:防止悬垂引用

悬垂引用是指指向已释放内存的引用,这是 C/C++ 中常见的安全漏洞。Rust 编译器在编译期就能完全防止这种情况:

rust
1
2
3
4
5
6
7
8
9
fn dangle() -> &String {
    let s = String::from("hello");

    &s  // ❌ 编译错误:返回指向 `s` 的引用,但 `s` 将被 drop
}

fn main() {
    let reference_to_nothing = dangle();
}

编译错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:16
  |
1 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `Owned` type instead
  |
1 | fn dangle() -> String {
  |                ~~~~~~~

编译器告诉你:函数返回了一个引用,但没有值可以借用——因为 s 会在函数结束时被 drop。

正确的做法是返回 String 本身(转移所有权):

rust
1
2
3
4
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // ✅ 所有权转移给调用者
}

规则 3:数据竞争预防

数据竞争发生在以下情况:

  • 两个或多个指针同时访问同一数据
  • 至少有一个指针在写数据
  • 没有同步机制

Rust 的借用规则在编译期就能防止数据竞争:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::thread;

fn main() {
    let mut data = vec![1, 2, 3, 4];

    let handle = thread::spawn(|| {
        let r1 = &mut data[0];  // ❌ 编译错误:data 可能被其他线程访问
        *r1 += 1;
    });

    println!("{:?}", data);  // 同时访问 data
    handle.join().unwrap();
}

这个代码无法编译,因为 Rust 知道 data 在另一个线程中可能被可变借用,而主线程又在访问它。

正确的做法是使用 Arc<Mutex<T>> 等并发原语:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3, 4]));

    let handle = thread::spawn({
        let data = Arc::clone(&data);
        move || {
            let mut vec = data.lock().unwrap();
            vec[0] += 1;
        }
    });

    {
        let vec = data.lock().unwrap();
        println!("{:?}", *vec);
    }
    handle.join().unwrap();
}

对比 Go 的垃圾回收

Go 的方式:运行时 GC

Go 的垃圾回收器在运行时追踪哪些变量还在使用,哪些可以安全回收。优点是开发体验好——你不需要手动管理内存。缺点是:

  • GC 停顿:回收时会暂停程序执行,延迟不可预测
  • 内存开销:需要维护 GC 元数据,实际内存占用翻倍
  • 无法完全控制:调 GC 参数是门玄学

Rust 的方式:编译期检查

Rust 通过所有权规则在编译期确定何时释放内存,零运行时开销:

维度Go(GC)Rust(所有权)
内存释放时机运行时 GC 决定编译期确定,离开作用域立即释放
运行时开销GC 停顿、元数据维护零成本
延迟可预测性不可预测完全可预测
开发者控制力
内存安全GC 保证释放,但不保证泄漏编译器保证
并发安全需要开发者小心编译器防止数据竞争

为什么 Rust 不需要 GC?

Rust 的设计哲学是:用编译期检查替代运行时回收

当变量离开作用域时,编译器插入 drop 调用。因为所有权规则保证了每个值只有一个所有者,编译器可以精确知道何时释放内存,不需要 GC 那样的运行时追踪。

rust
1
2
3
4
{
    let s = String::from("hello");
    // 使用 s
}  // 编译器自动插入 drop(&mut s)

Go 开发者常见误区

  1. 误以为 Rust 有 GC:Rust 没有运行时 GC,内存释放是确定性的。
  2. .clone() 避免编译错误:这会导致不必要的深拷贝,应该用引用传递。
  3. 认为所有权规则太严:这些规则在熟悉后会变得自然,而且编译器的错误信息非常友好。

Rust 开发者常见误区

  1. 过度使用 .clone():优先考虑引用,只有必要时才克隆。
  2. 忽略生命周期标注:虽然编译器经常能推断,但复杂场景需要显式标注。
  3. 不敢用 unsafe:某些底层操作需要 unsafe,但要极其谨慎。

生命周期入门

你可能注意到上面的代码中很少出现生命周期标注('a)。这是因为 Rust 编译器很聪明,大多数情况下可以自动推断。

rust
1
2
3
4
5
6
7
8
fn longest(x: &str, y: &str) -> &str {
    // 这个函数无法编译!编译器不知道返回值借用了 x 还是 y
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

需要显式标注生命周期:

rust
1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

生命周期标注 'a 告诉编译器:返回的引用与输入参数的引用有相同的最短生命周期。这样编译器就能保证返回的引用不会悬垂。

生命周期的深入讲解是下一篇文章的内容,这里只是预告它的存在。

总结

所有权系统是 Rust 的基石,它让编译器在编译期就能保证内存安全和并发安全。关键要点:

  • 所有权三大规则:一个值只有一个所有者、Move 语义、离开作用域自动释放
  • Copy vs Move 类型:简单类型自动复制,复杂类型转移所有权
  • 借用规则:同一时刻要么有多个不可变引用,要么有一个可变引用,不能混合
  • 防止悬垂引用:编译器保证引用始终有效
  • 对比 Go GC:Rust 用编译期检查替代运行时 GC,零成本但学习曲线陡峭
  • 生命周期:标注引用的有效范围,防止悬垂引用(下一章深入)

所有权规则初看起来繁琐,但一旦理解了背后的设计哲学,你会发现它让 Rust 代码的内存行为变得完全可预测、可验证。这正是 Rust 如何在不牺牲性能的前提下实现内存安全的秘密。

下一篇将深入探讨生命周期标注,理解它如何让编译器在复杂场景下也能保证引用安全。