Rust 凭什么不用垃圾回收?先搞懂 Owner

Rust 的内存管理乍看有些复杂。但理解所有权、借用和生命周期之后,你会发现,几乎所有规则都围绕着同一个思路:在程序运行之前,由编译器证明你的内存使用是安全的。

当然了,作为开发者,你需要写出符合这些规则的代码,让编译器能够验证你的内存使用是安全的。

内存管理是每门编程语言都要解决的问题。

创建的数据需要有地方存放。不再需要时,它占用的内存最终需要回收。这里不只是"如何分配内存"的问题,更难的是:

这份数据由谁负责?到底什么时候才能安全地释放它?

不同语言给出的答案很不一样。

C 把控制权直接交给你。Java、Go 这类带垃圾回收的语言,由运行时系统管理不可达的对象。

Rust 则采用另一种方式:通过所有权、借用和生命周期,在编译期检查内存安全规则,不需要依赖垃圾回收器。

这也是刚学 Rust 时,会觉得它有些特别、有些难以理解的主要原因之一。

开始之前,我们先回顾一下三种内存管理手段。

内存管理的三种常见方式

假设你在应用中创建了一个对象,过了一段时间,不再使用它。

对于这块内存,编程语言通常有三种处理方式。

1. 手动管理内存

在 C 中,程序员需要显式分配和释放内存。

大致像这样:

c 复制代码
char *data = malloc(100);
/* use data */
free(data);

好处是控制权在自己手里,问题是,你必须把生命周期处理正确。一旦忘记调用 free(),可能造成内存泄漏。

释放得太早,可能访问到已经失效的内存。释放两次,又是一个严重的 bug。

对于普通 C 代码,编译器通常无法替你挡住所有这些错误。

2. 垃圾回收

带垃圾回收器的语言,把这部分责任从程序员手里接了过去。

你不必逐个显式释放对象。运行时会追踪哪些对象仍然可达,并在之后回收不再需要的内存。

这让编程更轻松,但垃圾回收本身也需要在运行时做事:追踪内存,定期清理。

现代垃圾回收器可以很复杂,也很高效,不能简单理解成"让整个应用停下来"。不过,垃圾回收依然是一种运行时内存管理策略。

3. Rust 的所有权模型

Rust 走了另一条路。

它通过编译期规则,确定数据归谁所有、谁可以借用,以及引用可以在多长时间内保持有效,而不是等运行时垃圾回收器来判断内存是否还在使用。

因此,许多内存安全问题在程序执行之前就能被拦下来。

这就引出了 Rust 的核心概念:所有权。

所有权

在 Rust 通常的所有权模型中,每个值都有一个所有者。

来看一个简单的例子:

rust 复制代码
fn main() {
    let name = String::from("Rust");
}

变量 name 拥有这个 String。

String 可以把数据存放在堆上,这个例子就涉及动态分配的内存。当 name 离开作用域时,Rust 的析构规则会清理这个值。Rust 把这个过程叫作 drop,也就是销毁一个值。

通常,你不需要手动写:

text 复制代码
free(name);

也不需要垃圾回收器之后再来扫描堆。

在这个例子里,所有者的存续时间决定了值何时被销毁。

可以这样理解:

text 复制代码
name owns String
      ↓
scope ends
      ↓
String is dropped
      ↓
resources are released

这是理解 Rust 时最重要的思路之一。

Rust 并没有消除内存管理

这里需要分清一件事。

Rust 并没有让内存管理消失,而是改变了由谁来证明内存管理规则是正确的。

手动管理内存时,程序员说:

相信我,我会正确释放这块内存。

带垃圾回收的语言里,运行时说:

我会追踪对象,回收那些已经不可达的对象。

而 Rust 的说法更接近:

先在编译期证明你的所有权和引用是有效的,生成的程序就可以按这些规则执行,不需要全局垃圾回收器。

这背后的思路差别很大。

所有权移动时,会发生什么?

当你在变量或函数之间传递值时,所有权模型就更有意思了。

看这段代码:

rust 复制代码
fn main() {
    let first = String::from("Hello");
    let second = first;
    println!("{second}");
}

执行下面这行之后:

rust 复制代码
let second = first;

String 的所有权从 first 移动到了 second。

因此,下面的写法是不合法的:

rust 复制代码
fn main() {
    let first = String::from("Hello");
    let second = first;
    println!("{first}");
}

编译器会拒绝这段代码,因为 first 已经不再拥有这个值。

如果你之前用的是 JavaScript、Java、Python 这类语言,可能会觉得奇怪。

但 Rust 这样做是有原因的。

如果两个普通变量都可以认为自己拥有同一块堆内存,Rust 就需要另一套机制,判断到底由谁负责释放它。

所有权给出了一个更简单的规则:只有一个所有者。

所有权移动之后,原来的所有者不再负责这个值。Rust 编译器会追踪这种关系,在编译期拒绝无效的使用方式。

移动有什么用

我当然知道,很多开发者刚开始学习 Rust,觉得移动(move)看起来像个烦人的限制。

实际上,它可以避免一整类 bug。

假设一个函数接收了 String 的所有权:

rust 复制代码
fn process(text: String) {
    println!("{text}");
}
fn main() {
    let message = String::from("Hello");
    process(message);
    // message is no longer available here
}

重点不在于 Rust 想为难你,而在于编译器现在明确知道,程序的哪一部分拥有这个值。

这样就更容易推断内存的生命周期。

借用:使用但不取得所有权

如果每次使用数据都必须转移所有权,Rust 用起来就太不方便了。

好在并不需要,你可以借用一个值。

例如:

rust 复制代码
fn print_message(message: &String) {
    println!("{message}");
}
fn main() {
    let message = String::from("Hello");
    print_message(&message);
    println!("{message}");
}

函数接收的是字符串的引用,没有取得它的所有权。

原来的变量仍然拥有这份数据。

借用表达的就是这种关系:

text 复制代码
Owner
  │
  └── String
        ↑
        │
     borrowed reference

函数可以使用数据,但不会成为它的所有者。

Rust 同时支持不可变引用和可变引用。

不可变借用

不可变引用的写法是:

rust 复制代码
let reference = &value;

同一个值可以同时存在多个不可变引用。

例如:

rust 复制代码
let message = String::from("Hello");
let a = &message;
let b = &message;
println!("{a}");
println!("{b}");

这两个引用都只读取值,没有修改它,所以这种访问是安全的。

可变借用

可变引用的写法是:

rust 复制代码
let reference = &mut value;

例如:

rust 复制代码
let mut message = String::from("Hello");
let reference = &mut message;
reference.push_str(" Rust");

这里有一条重要限制。

对于同一份数据,在借用的有效范围内,你可以拥有:

  • 多个不可变引用;或者
  • 一个可变引用。

两者不能以会造成访问冲突的方式同时存在。

这条规则是 Rust 内存安全与安全并发模型的基础之一。

可以把它理解成一个简单的约定,在同一时刻:

text 复制代码
Many readers
     OR
One writer

两者不能同时发生。

这避免了程序的一部分正在读取数据,另一部分却通过冲突的访问修改同一份数据的情况。

借用检查器

Rust "严格"的名声,很大一部分就来自这里。

**借用检查器(borrow checker)**会在编译时分析所有权和引用。

例如,下面的代码无法通过编译:

rust 复制代码
let mut value = String::from("hello");
let first = &value;
let second = &mut value;
println!("{first}");

问题在于:程序创建了指向同一个值的可变引用,之后还要使用先前的不可变引用。

Rust 不会等到运行时出了 bug 才处理。

只要代码不满足所有权和借用规则,编译器就不让它通过。

这也是 Rust 设计思路中很重要的一部分:

先阻止不合法的程序通过编译,而不是等进入错误状态后再设法恢复。

引用能有效多久

所有权回答的是:

谁拥有这个值?

借用回答的是:

谁可以暂时使用它?

生命周期回答另一个重要问题:

这个引用能在多长时间内保持有效?

Rust 中的每个引用都有生命周期,也就是引用保持有效的那段范围。许多日常场景下,Rust 可以自动推断,不需要你手动标注。

注意,这里的生命周期针对的是引用!

例如:

rust 复制代码
fn print_name(name: &str) {
    println!("{name}");
}

这里不需要手写生命周期标注。

但有时,函数会返回一个引用,而它是否有效,取决于多个输入引用之间的关系。

这时,你可能会看到这样的语法:

rust 复制代码
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

这里的 'a 并不是说:

"让这个对象恰好存活这么长时间。"

它表达的是引用之间的关系。

在这个函数中,两个输入引用都必须在 'a 内有效,返回的引用也在这个共同的生命周期范围内有效。

Rust 利用这些关系,确保引用不会比它指向的数据活得更久。

Drop 到底做了什么?

你经常会听到:值离开作用域时,Rust 会自动调用 drop()。

这样理解有帮助,但有一个技术细节需要说明。

Rust 提供了 Drop trait,让类型可以定义值被销毁时的清理行为。这里的析构方法是 Drop::drop,与可以手动调用、提前销毁值的 std::mem::drop 函数不同。标准库类型可以利用这套机制,释放堆内存等资源。

例如:

rust 复制代码
struct Logger;
impl Drop for Logger {
    fn drop(&mut self) {
        println!("Logger is being dropped");
    }
}

接着使用它:

rust 复制代码
fn main() {
    let logger = Logger;
    println!("Using logger");
}

当 logger 离开作用域时,它的析构逻辑就会执行。

编译器会按照语言规则生成所需的析构行为。优化后的二进制中,具体实现可能被内联,也可能经过其他优化。因此,更准确的理解是,Rust 按语言规则执行相应的析构语义 ,而不是每个作用域末尾都原样放着一次 drop() 函数调用。

解释 Rust 的底层行为时,这个区别很重要。这里讨论的是正常离开作用域的情况,并不意味着析构一定发生;例如,std::mem::forget 可以跳过析构,进程直接终止时也不会正常执行这套清理流程。

如果多个部分都需要所有权呢

到这里,我们讨论的还是简单的所有权模型:

text 复制代码
one value
   ↓
one owner

但实际程序有时需要共享所有权。

例如,应用中的多个部分都需要访问同一份堆上数据。

对于确实需要多个所有者的场景,Rust 提供了 Rc<T>、Arc<T> 这样的智能指针。

Rc<T>

Rc<T> 中的 Rc 指的是 reference counted,也就是引用计数。

在单线程场景中,它允许同一个值拥有多个所有者。

大致可以这样理解:

text 复制代码
Owner A ──┐
Owner B ──┼──→ Data
Owner C ──┘

强引用计数会记录还有多少个所有者。

最后一个强引用被销毁时,内部的值也会被销毁。

Arc<T>

Arc<T> 是使用原子引用计数的指针。

它同样提供共享所有权,但计数机制支持跨线程使用。这里的线程安全针对引用计数;要在线程间共享 Arc<T>,T 本身也必须满足 Send 和 Sync 的要求。

关键在于,Rust 不会自动给每个值都套上一层引用计数。

只有设计确实需要共享所有权时,你才显式选择它。

这样既能让所有权关系保持明确,也能支持更复杂的数据共享方式。

Rust 为什么这么严格

Rust 编译器有时像一个要求很高的代码审查者。

有些代码乍看完全合理,它也可能不接受。

这是有原因的。编译器在检查这些问题:

  • 谁拥有这个值?
  • 所有权是不是已经移动了?
  • 这个引用还有效吗?
  • 多个引用之间有没有冲突?
  • 可变引用会不会与其他引用的有效范围重叠?
  • 一个值被销毁时,是否还有引用指向它?

在安全 Rust 中,目标是让通过检查的代码无法表达无效的内存访问。

这不意味着 Rust 能神奇地免疫所有内存 bug。Rust 仍然有 unsafe 代码、外部函数接口、逻辑错误,以及软件出错的其他可能。

不过,安全子集能在所有权和引用方面,提供很强的编译期保证。

取舍

手动管理内存,意味着更多控制权,也意味着更多责任。

垃圾回收让内存管理自动化,但对象回收要依赖运行时系统。

Rust 则把更多推理工作交给了编译器。

你需要理解这些概念:

所有权 → 借用 → 生命周期 → 移动 → 可变引用

刚开始,这些规则会让人有些不顺手。

编译器报错,你改代码,然后编译器又报错。

慢慢地,你开始理解这些规则为什么这样设计。

理解之后,一个有意思的变化就发生了:内存安全不再只是运行时可能遇到的各种意外,而成了你写代码时,程序就必须满足的一项要求。

Rust 背后的思路

这里最重要的启发,其实不在于 String、&T、&mut T、Rc<T> 或生命周期本身。

而在于,一门语言选择在什么时候解决问题。

垃圾回收器在程序运行时解决内存回收问题。

Rust 则把内存安全问题中的很大一部分移到了编译阶段。

这会改变开发体验。

对于无效引用,编译器可以在生成程序之前就拒绝它,而不是等部署之后才发现释放后使用(use-after-free)的 bug。

你也不需要逐个手动决定普通拥有值的销毁时机。通过代码结构定义所有权,Rust 会执行由此产生的规则。

这并不意味着内存管理从此没有成本,只是让内存所有权变得明确。

用四个问题理解 Rust

学习 Rust 时,把整套机制归结为四个问题很有帮助:

  1. 谁拥有这个值?
  2. 所有权移动了吗?
  3. 谁在借用它?
  4. 这次借用能有效多久?

如果能回答这四个问题,Rust 中相当大的一部分内容就容易理解了。

例如:

rust 复制代码
fn main() {
    let mut message = String::from("Hello");
    add_world(&mut message);
    println!("{message}");
}
fn add_world(message: &mut String) {
    message.push_str(", world!");
}

这段代码中,message 拥有这个 String。

add_world 接收到一个可变借用,可以修改字符串,但不会成为它的所有者。

借用结束后,原来的所有者可以继续使用这个值。

你不需要手动释放字符串。

编译器检查所有权和借用规则;所有者离开作用域时,Rust 的析构语义负责处理这个值。

开始这样看代码之后,所有权就不再像是 Rust 特有的一道奇怪门槛。

它成了描述数据生命周期的一种方式。

一点想法

如果你放弃处处拿 C 或带垃圾回收的语言来直接对照 Rust 的内存管理,Rust 的内存模型反而更容易理解。

Rust 不只是想提供"自动内存管理"。它更想让内存所有权的规则足够明确,以便编译器在程序运行之前就能验证。

所有权决定谁负责一个值。

移动转移这份责任。

借用让你使用数据,而不必取得所有权。

生命周期描述引用能在多长时间内保持有效。

Drop 定义清理行为。

当一个所有者不够用时,Rc 和 Arc 提供受控的共享所有权。

这就是 Rust 背后的核心思路:

你仍然需要思考内存,但不必等到运行时,再手动应对每一种内存安全问题。

理解这一点之后,Rust 严格的编译错误就不再只是阻碍。它们也在告诉你,程序的设计有哪些关系需要理清。

相关推荐
Amos_Web1 小时前
Rspack 源码解析(十五):Loader Runner 与 JS Loader 桥接
前端·rust·前端框架
柯南46688 小时前
【AI开发之Rust】第 18 课:SQLite 持久化与本地缓存 —— 给 store 填上真实现
rust·编程语言
柯南46688 小时前
【AI开发之Rust】第 17 课:项目总览与核心架构 —— AI 助手 Rust 核心从 0 到 1
rust·编程语言
柯南46689 小时前
【AI开发之Rust】第 16 课:FFI 手写绑定与内存布局 —— 把 Rust 交给别的语言
rust·编程语言
达子6669 小时前
RUST 图解 第 2 章:写小游戏
rust
Amos_Web9 小时前
Rspack 源码解析(十四):Resolver 与 NormalModuleFactory
前端·rust·源码
Kapaseker9 小时前
秒懂 Rust 的 7 个核心概念
rust
RobinDevNotes9 小时前
用 godot-rust 给 Godot 写 Rust 扩展
rust·游戏开发
十万公里通票9 小时前
Rust 高级特性秀场:spawn 闭包的类型约束
rust