Rust 引用与借用:为什么 &String 能让你“用完不丢”,还能在编译期防止数据竞态?

关键词:所有权(ownership)、移动(move)、引用(reference)、借用(borrowing)、可变引用(mutable reference)、悬垂引用(dangling reference)、生命周期(lifetime)


1) 背景引入:为什么“把 String 传进函数”会出问题?

在 Rust 里,String 这类“拥有堆内存”的类型,默认遵循 所有权(ownership) 规则:

  • 谁拥有(own)数据,谁负责在离开作用域时释放它。
  • 当你把一个值传给函数,如果参数类型是 String(不是引用),通常会发生 移动(move): 调用者把所有权交给被调用函数。

这会带来一个非常典型的“初学者困惑”:

我只是想让函数算一下长度,为什么算完我就不能再用这个 String 了?

原因是:你把 String 移进函数里了,调用结束后它已经不再属于你。

传统“补救方案”的缺点:用元组把 String 再还回来

很多语言里会复制或隐式共享;但 Rust 不这么做。为了继续使用那个 String,你可能不得不让函数把 String 连同结果一起返回(比如返回 (String, usize))。

问题是:太啰嗦,而且只是为了“看一下数据”,却搞得像“转交产权再归还”。


2) 核心原理解析:引用是什么?借用是什么?它解决了什么历史难题?

2.1 引用(reference)是什么?

引用(reference)可以理解为:

  • 它像指针(pointer)一样保存一个地址:你可以“顺藤摸瓜”访问那块数据。
  • 但它和传统指针有关键区别: Rust 保证引用在其生命周期内指向有效值(不会指向已经释放的内存)。

也就是说,你可以“看/用”数据,但不必拥有它。

这就是借用(borrowing):我用一下你的东西,但我不拿走所有权,用完还给你。

2.2 为什么这很重要?

这是 Rust 设计上非常核心的目标: 在不依赖 GC(垃圾回收)的情况下,尽可能在编译期消灭内存安全问题。

在 C/C++ 这类语言里,你常见两类灾难:

  • 悬垂指针(dangling pointer):指针还在,但内存已被释放或复用。
  • 数据竞态(data race):多个线程/指针同时读写同一数据,没有同步导致未定义行为。

Rust 用“所有权 + 借用规则”在编译期把这两类问题直接堵死。


3) 实操步骤:从 &String&mut String,再到借用规则

3.1 用引用传参:只读借用(immutable borrowing)

我们先实现“计算长度但不拿走 String”的版本:

fn main() {
// s1 拥有这段堆内存(String 的内容在堆上)
let s1 = String::from("hello");
// 传入 &s1:创建一个对 s1 的引用(只读借用)
// 注意:这里没有把 s1 “移动”进去
let len = calculate_length(&s1);
// 依然可以使用 s1,因为它的所有权从未被转移
println!("The length of '{s1}' is {len}.");
}
// 参数类型是 &String:表示“我只借来看看”
fn calculate_length(s: &String) -> usize {
// s.len() 只读访问,不改变 String
s.len()
// s 在这里离开作用域,但它只是引用:不会触发 String 的释放
}

这一段代码背后发生了什么?

  • s1 仍然是数据的拥有者。
  • calculate_length 得到的是 s1 的引用:可以读,但不负责释放。

下面用一个“内存关系图”把它讲清楚:

image-20260205195202138
image-20260205195202138

你可以把它理解成:

  • String 本体(指针/长度/容量)在栈上
  • 真正的字符内容在堆上
  • &String 只是指向某个 String 的“借条”

3.2 反向概念:解引用(dereferencing, *

引用的“反义动作”是解引用(dereferencing),通过 * 运算符完成。

本章你暂时不需要深入,只要记住: & 是“拿到引用”,* 是“从引用拿到值”。


3.3 试图修改借来的东西:为什么不让?

来看一个“必炸”的例子:

fn main() {
let s = String::from("hello");
// ❌ change 只接收 &String(只读借用)
change(&s);
}
fn change(some_string: &String) {
// ❌ 报错:不能通过只读引用修改数据
some_string.push_str(", world");
}

这背后的逻辑非常朴素:

你拿的是“只读借条”,凭什么改人家的东西?

所以 Rust 的规则是:

  • 引用默认也是不可变的(immutable)。
  • &T 只允许读,不允许写。

3.4 想修改怎么办?可变引用(mutable reference, &mut T

要修改,就得明确表达“我要借来修改”:

fn main() {
// ✅ 变量本身必须是可变的
let mut s = String::from("hello");
// ✅ 借用时用 &mut:创建可变引用
change(&mut s);
println!("{s}"); // hello, world
}
// ✅ 参数类型是 &mut String:表示“我借来并会修改它”
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

这里有两个“必须同时满足”的点:

  1. 被借用者得是 mutlet mut s = ...
  2. 借用方式得是 &mutchange(&mut s)

这让代码读起来非常诚实:

“这个函数会改变传入的值。”


4) Rust 借用的关键规则:为什么它能防数据竞态?

Rust 对可变引用有一条很“严格但值钱”的限制:

同一时刻,对同一份数据:只能存在一个可变引用(&mut)。

4.1 两个可变引用会怎样?直接拒编译

fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ❌ 同时第二次可变借用:不允许
println!("{r1}, {r2}");
}

编译器在担心什么?

如果允许多个 &mut 同时存在,那么就可能出现:

  • 两个“写入口”同时改同一份数据
  • 写入顺序不确定
  • 读到中间状态
  • 线程下更是直接形成数据竞态

Rust 的做法是:把这种风险在编译期就掐断。

用图表示“为什么不行”:

image-20260205195427155
image-20260205195427155

4.2 想要多个可变引用?可以,但不能“同时”

Rust 允许你“分时复用”,用作用域({})切开即可:

fn main() {
let mut s = String::from("hello");
{
// ✅ r1 在这个小作用域内独占可变借用
let r1 = &mut s;
r1.push_str("!");
} // ✅ r1 结束,借用释放
// ✅ 现在才可以创建新的可变引用 r2
let r2 = &mut s;
r2.push_str("?");
}

4.3 可变引用和不可变引用能混用吗?

规则同样直接:

同一时刻:要么多个只读(&T),要么一个可写(&mut T)。

下面这个会报错:

fn main() {
let mut s = String::from("hello");
let r1 = &s; // ✅ 只读借用
let r2 = &s; // ✅ 只读借用
let r3 = &mut s; // ❌ 同时存在只读借用时,不允许可写借用
println!("{r1}, {r2}, and {r3}");
}

原因也很直觉:

  • 读的人会默认数据“不会突然变”
  • 如果你允许同时存在 &mut,读者看到的值可能在读的过程中变掉

4.4 “引用的作用域”不是看花括号,而是看最后一次使用

这一点非常关键,很多人第一次会误解:

引用从创建开始,一直持续到它最后一次被使用的位置。

所以这段是可以编译的:

fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
// ✅ r1/r2 最后一次使用在这里
println!("{r1} and {r2}");
// ✅ 在 r1/r2 不再使用之后,才创建可变引用:OK
let r3 = &mut s;
println!("{r3}");
}

Rust 编译器能判断“引用后面还用不用”,从而放宽限制——这也是 Rust 借用检查器强大的地方。


5) 悬垂引用:Rust 为什么死也不让你返回 &String

在指针语言里,这类 bug 很致命:

  • 你返回一个指向局部变量的指针
  • 函数结束局部变量被释放
  • 指针还指着那块已经无效的内存 => 悬垂指针(dangling pointer),运行时可能崩溃或产生诡异数据

Rust 直接在编译期阻止这种事情发生。

看这个例子:

fn main() {
let reference_to_nothing = dangle();
}
// ❌ 试图返回一个引用
fn dangle() -> &String {
let s = String::from("hello");
&s // ❌ 返回对局部变量 s 的引用
} // s 在这里被释放,引用将指向无效内存 -> Rust 不允许

你可以用一句人话理解编译器的核心抱怨:

“你返回的是借来的东西,但你没告诉我它到底是从谁那借来的;而且借主马上就要消失了。”

这会牵扯到一个重要概念:生命周期(lifetime) (原文也说会在后续章节展开)。

正确做法:直接返回拥有权(return owned value)

解决方案非常简单:

fn no_dangle() -> String {
let s = String::from("hello");
// ✅ 直接把所有权移动出去:函数结束不会释放它
s
}

这一步背后的思想是:

  • 如果你想让数据在函数外继续活着 就把所有权交给调用者
  • 不要试图让调用者拿着“指向已死亡对象的借条”

6) 扩展思考:把这些规则串起来,你就理解了 Rust 的“安全哲学”

6.1 为什么 Rust 这么“烦”?

因为它把两类运行时灾难,提前到编译期解决:

  • 内存悬垂/野指针问题 → 通过生命周期与借用规则消灭
  • 数据竞态 → 通过“同一时刻唯一可写入口”消灭

6.2 你写 Rust 时真正该培养的直觉

你每次报借用错误时,都可以问自己三句话:

  1. 谁拥有这份数据?(owner 是谁)
  2. 我现在是想读它还是改它?(& 还是 &mut)
  3. 在同一时刻,还有没有其他人也在拿着它?(是否重叠借用)

6. 3 题目测试

image-20260205200211514
image-20260205200211514

let x = Box::new(0); // x: Box<i32>
let y = Box::new(&x); // y: Box<&Box<i32>>

也就是说:

  • x 是一个 Box<i32>(Box 指向堆上的 0)

  • &x 是一个 &Box<i32>(引用指向栈上的 x)

  • y 是一个 Box<&Box<i32>>(Box 里面装着“&x”这个引用)

为什么是 3 次解引用? 我们从 y 出发“追指针”到 0,要跨 3 条“箭头”:

  • y: Box<&Box<i32>> 解引用 1 次:*y 得到里面装的东西 → &Box<i32>

  • 现在是 &Box<i32> 解引用 2 次:*(*y) 得到 → Box<i32>(也就是 x)

  • 现在是 Box<i32> 解引用 3 次:*(*(*y)) 得到 → i32,也就是 0

所以从“层级结构/指向关系”上看,确实是:

y → &x → x → 0 (3 次)

用 ASCII 画一下更直观:

y : Box< &Box<i32> >
|
v (第1次解引用:Box -> 里面的 &Box)
&x : &Box<i32>
|
v (第2次解引用:& -> x)
x : Box<i32>
|
v (第3次解引用:Box -> 0)
0 : i32

但你可能会遇到一个“坑”:***y 在 Rust 里不一定能直接写出来 因为第 2 次解引用会尝试把 x: Box<i32> 从 &x 里“搬走”(move out of borrowed content),这在 Rust 里通常是不允许的。

更“Rust”的写法:别让 y 存 &Box<i32>,而是存 &i32 你想拿到 0,其实只需要借到 i32 的引用就够了:

let x = Box::new(0); // x: Box<i32>
let y = Box::new(x.as_ref()); // y: Box<&i32>
// 现在 y: Box<&i32>
// 取 0 只需要两次:*y 得到 &i32,再 * 得到 i32
let v = **y;

一句话总结 题目答案“3 次”讲的是指向层级:Box → 引用 → Box → 值。