Rust 踩坑日记:被 Borrow Checker 毒打的一天与 Ownership 深度解析

前言:又被编译器教做人了

作为一个写了多年 C++ 的“老司机”,我自认为对内存管理早已烂熟于心。什么 malloc/free,什么 shared_ptr/unique_ptr,哪怕是手写引用计数也不在一个话下。直到我遇到了 Rust,这门号称“能让你在编译期就崩溃,但在运行期稳如老狗”的语言。

今天是个风和日丽的周末,我突发奇想,打算用 Rust 重构一下我那个简陋的 KV 存储引擎。本以为是手到擒来,结果足足跟编译器搏斗了三个小时,屏幕上满屏的红色波浪线仿佛在嘲笑我的无知。

“Borrow of moved value”、“Cannot borrow as mutable more than once at a time”……这些报错信息就像紧箍咒一样,念得我头疼。

痛定思痛,我决定不再盲目试错,而是静下心来,把 Rust 的 Ownership(所有权)机制彻底扒皮抽筋,看看到底是何方神圣能把无数英雄好汉折磨得欲仙欲死。这篇文章,就是我这一天“被毒打”后的血泪总结,也是一份给后来者的避坑指南。

1. 为什么我们需要 Ownership?

要理解 Ownership,首先得回到计算机科学最本质的问题:内存管理

在 C 语言时代,内存是自由的,也是危险的。你申请了内存,就得记得释放。

1
2
3
4
5
int* ptr = malloc(sizeof(int));
*ptr = 42;
// 忘记 free(ptr); -> 内存泄漏
// free(ptr); free(ptr); -> Double Free
// free(ptr); *ptr = 10; -> Use After Free

Java/Python 等语言引入了 GC(垃圾回收),通过一个并在运行的 Runtime 来定期打扫战场。这很舒服,但有代价:STW (Stop The World) 带来的延迟,以及沉重的 Runtime 开销。

Rust 想要鱼和熊掌兼得:既要有 C++ 的零成本抽象(Zero Cost Abstraction),又要有 Java 的内存安全。于是,Ownership 诞生了。它不是魔法,而是一套编译期的严格纪律

三大铁律

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

这三句话听起来简单,实践起来简直就是“一步一坑”。

2. 踩坑第一站:Move Semantics(移动语义)

我是怎么掉进第一个坑的?看这段代码:

1
2
3
4
5
6
7
fn main() {
let s1 = String::from("hello");
let s2 = s1;

// 此时我想打印 s1
// println!("{}, world!", s1);
}

在 C++ 里,std::string s2 = s1 通常意味着拷贝(Deep Copy 或者 Ref Count 增加),s1 依然可用。
但在 Rust 里,如果你取消注释最后一行,编译器会直接甩你一脸:

error[E0382]: borrow of moved value: s1

原因解析
Rust 中,赋值操作默认是 Move(移动),而不是 Copy。当 let s2 = s1; 执行时,String 内部的指针、长度、容量等元数据被“移交”给了 s2。为了保证内存安全(避免 Double Free),Rust 强制废弃了 s1。

这是一个非常激进但有效的设计。它在编译期就杜绝了“两个指针指向同一块堆内存且都拥有释放权”的可能性。

图解 Move

graph TD
    subgraph Stack
    S1[s1 (Invalid)]
    S2[s2 (Valid)]
    end
    
    subgraph Heap
    Data["Data: 'hello'"]
    end
    
    S1 -.-x Data
    S2 --> Data

如果你真的想要两个独立的字符串怎么办?显式调用 .clone(),明明白白告诉编译器:我要深拷贝,我不怕性能开销。

3. 踩坑第二站:Borrowing(借用)与引用

Move 虽然安全,但太麻烦了。难道我传个参数给函数,所有权就没了?

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);
// s1 已经死了,下面这行会报错
// println!("The length of '{}' is {}.", s1, len);
}

为了解决这个问题,Rust 引入了 Borrowing(借用),也就是引用 &

1
2
3
4
5
6
7
8
9
fn calculate_length(s: &String) -> usize {
s.len()
}

fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 借出去,所有权还在我这
println!("The length of '{}' is {}.", s1, len);
}

这看起来很美好,直到我想修改借来的东西。

可变引用的独占性

这是我今天摔得最惨的地方。我想写一个函数,给字符串追加点内容,顺便并在打印它几次。

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

let r1 = &s;
let r2 = &s;
let r3 = &mut s; // BIG ERROR!

// println!("{}, {}, and {}", r1, r2, r3);
}

编译器怒吼:

error[E0502]: cannot borrow s as mutable because it is also borrowed as immutable

Rust 的借用规则(RWLock 编译期版)

  1. 在任意给定时间,要么 只能有一个可变引用,要么 只能有多个不可变引用。
  2. 引用必须总是有效的。

也就是:你可以读,大家一起读;你要写,就只能你一个人写,而且别人这时候连读都不行。

为什么?为了防止 Data Race。如果我有指针 r1 指向堆内存,而 r3 突然在别处在这个 vector 扩容了(realloc),导致堆内存搬家了,那 r1 就立刻变成了悬垂指针(Dangling Pointer)。Rust 通过编译锁死了这种可能性。

4. 终极 Boss:生命周期 (Lifetimes)

如果前两关只能算是热身,那 Lifetime 就是劝退 Rust 新手的终极 Boss。

当我试图写一个返回两个字符串中较长者的函数时:

1
2
3
4
5
6
7
8
// 这是一个无法编译的函数
// fn longest(x: &str, y: &str) -> &str {
// if x.len() > y.len() {
// x
// } else {
// y
// }
// }

编译器问出了直击灵魂的问题:

missing lifetime specifier

它在问:你返回的这个引用,到底是指向 x 还是 y?它的寿命有多长?

在 Rust 看来,如果我不标注生命周期,它就无法确定返回值的合法范围。如果调用者传进来的 x 活得长,y 活得短,而我返回了 y,但调用者却以为返回值能像 x 一样活那么久,那就会发生 Use After Free。

正确的写法是给它们打上标记:

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
}
}

这行代码的含义是:我不管 x 和 y 到底活多久,但我保证返回值的生命周期,至少和 x、y 中寿命较短的那个一样长(’a 是它们的交集)。

这不仅仅是语法糖,这是 Rust 编译器进行静态代码分析的依据。

一个绝妙的生命周期陷阱

看看这个结构体:

1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}

这里的 'a 确保了 ImportantExcerpt 实例存活的时间,绝对不能超过它引用的 novel 字符串。如果 novel 被 drop 了,持有引用的 i 也就必须立刻死掉,否则就是不安全的。

5. 什么时候该用 Cell, RefCell, Rc, Arc?

既然借用规则这么严,那我想实现一个双向链表,或者一个图结构怎么办?这时候“单打独斗”的所有权就不够用了。我们在这个领域通常称之为 Interior Mutability(内部可变性)Shared Ownership(共享所有权)

Rc<T> (Reference Counting)

当你需要一个数据被多处拥有时,用 Rc。这就像是垃圾回收的简易版,引用计数归零才释放。但它只能用于单线程,且不可变。

RefCell<T> (Runtime Borrow Checking)

如果你非要在一个不可变引用的结构里修改数据,比如 mock 对象记录调用次数。
RefCell 让你在运行时执行借用规则检查。如果你违反了规则(比如同时搞两个 borrrow_mut),它不会让你编译失败,而是直接 panic。这是对编译器的妥协,要慎用。

Arc<T> + Mutex<T>

这是多线程并发的神器。Arc 是原子引用计数,Mutex 提供互斥访问。
Rust 的并发哲学是:Do not communicate by sharing memory; instead, share memory by communicating. (Go 也是这么说的,但 Rust 是在类型系统层面强制你的)。

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

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

这一段代码优雅地展示了所有权如何在多线程间传递。Arc 让数据有了多个所有者,Mutex 保证了每次只有一个所有者能更改变身。

总结:与编译器和解

经过这一天的折腾,我终于明白:Rust 编译器不是在阻碍我写代码,而是在教我写这世界上最安全的代码。

那些恼人的红色波浪线,其实是一个个严厉的教官,在告诉你:

  • “这里内存会泄漏!”
  • “这里会有数据竞争!”
  • “这里引用可能失效!”

当你习惯了 Rust 的思维方式(Ownership, Borrowing, Lifetimes),你会发现你写代码的信心指数级上升。因为你知道,只要代码能编译通过,它就几乎不可能在运行时发生 Memory Panic。

Rust 不仅仅是一门语言,更是一种对系统底层保持敬畏的思维方式。 下一篇文章,我们将深入操作系统底层,看看如何在 Linux 内核层面上玩出花来。

请关注我的博客 QixYuan’s Tech Blog,我们下期再见!


Rust 踩坑日记:被 Borrow Checker 毒打的一天与 Ownership 深度解析
https://www.qixyuan.top/2025/01/20/1-rust-pitfalls/
作者
QixYuan
发布于
2025年1月20日
许可协议