深入理解 Rust 所有权与借用:建立简单而准确的心智模型

所有权(Ownership)与借用(Borrowing)是 Rust 语言最重要的特性之一,也是初学 Rust 时最容易产生困惑的部分。

例如,为什么下面的代码无法编译?

let s1 = String::from("hello");
let s2 = s1;

println!("{s1}"); // 编译错误

为什么这段代码却完全合法?

let s1 = String::from("hello");
let s2 = &s1;

println!("{s1}");
println!("{s2}");

再比如,为什么两个共享引用可以同时存在,而共享引用和可变引用却有可能发生冲突?为什么有时候一个引用变量仍在作用域内,Rust 却允许重新修改原变量?

这些现象看起来涉及许多规则,但实际上,它们可以通过一个统一的心智模型来理解:

数据有主人,主人可以将数据暂时借出去。共享借用允许多人读取,独占借用允许修改;借用结束后,主人可以继续正常使用数据。

下面我们从所有权开始,逐步建立这套模型。


一、所有权:每个值都有自己的主人

在 Rust 中,每个值都有一个所有者(Owner)。

Rust 的所有权机制遵循三个基本原则:

  1. 每个值都有一个所有者。
  2. 在任意时刻,一个值只有一个所有者。
  3. 当所有者离开作用域时,它所拥有的值会被清理,除非所有权已经转移。

考虑:

fn main() {
    let s = String::from("hello");

    println!("{s}");
}

这里,变量 s 拥有一个 String 值。

String 管理着字符串所使用的堆内存。当 main 函数结束时,s 离开作用域,Rust 会自动调用相应的析构逻辑,释放字符串占用的资源。

可以简单理解为:

s
│
│ 拥有 String
▼
"hello"

作用域结束
    ↓
String 被销毁
    ↓
所管理的堆内存被释放

整个过程不需要程序员手动调用 free(),也不依赖传统的垃圾回收器。

Rust 在编译阶段检查所有权规则,并在适当位置自动执行资源清理。

1.1 所有权可以转移:Move

考虑:

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

    let s2 = s1;

    println!("{s2}");
}

这里:

let s2 = s1;

并没有创建一份新的堆字符串数据,而是将 String 的所有权从 s1 转移到 s2。

这种行为称为所有权移动(Move)。

移动之前:

s1 ──────> String("hello")


执行 let s2 = s1;


移动之后:

s1          不再能够使用
s2 ──────> String("hello")

因此:

println!("{s1}");

会发生编译错误。

这是因为 s1 已经失去了对该 String 值的所有权。

需要注意,Move 首先是一种语言层面的所有权语义,并不意味着底层机器一定没有发生任何字节复制。关键在于:移动完成后,原来的变量不能再作为该值的有效所有者使用。

1.2 为什么整数赋值不会导致原变量失效?

考虑:

let a = 100;
let b = a;

println!("{a}");
println!("{b}");

这段代码完全合法。

原因是 i32 实现了 Copy Trait。

对于实现了 Copy 的类型,赋值会产生独立的值副本,原变量仍然有效。

因此:

let a = 100;
let b = a;

与:

let s1 = String::from("hello");
let s2 = s1;

虽然语法相似,但所有权语义不同。

类型 赋值行为 原变量能否继续使用
i32 Copy 可以
bool Copy 可以
[i32; 30] Copy 可以
String Move 不可以
Vec<T> Move 不可以

这里需要记住:

所有权移动和数据复制是不同的概念。某个类型是否实现 Copy,决定了普通赋值是否保留原变量的有效性。


二、可变性:let 和 let mut 的区别

在学习借用之前,我们先理解变量的可变性(Mutability)。

Rust 中的变量默认是不可变的。

例如:

let x = 100;

x = 200; // 编译错误

如果希望修改变量,需要使用 mut:

let mut x = 100;

x = 200; // 正确

对于 String 也一样:

let mut s = String::from("hello");

s.push_str(" world");

这里的 mut 允许我们通过变量 s 修改字符串。

但是,必须区分两个概念:

  • 所有权:谁拥有这个值?
  • 可变性:当前绑定是否允许修改这个值?

拥有一个值,并不意味着当前绑定一定能够修改它。

同样,一个值曾经被不可变绑定持有,也不意味着它以后永远不能修改。

例如:

fn add_suffix(mut text: String) -> String {
    text.push_str(" world");
    text
}

fn main() {
    let s = String::from("hello");

    let result = add_suffix(s);

    println!("{result}");
}

这段代码合法。

虽然 main 中的 s 是不可变绑定,但调用:

add_suffix(s)

会将 String 的所有权转移给函数参数 text。

而函数参数:

mut text: String

是一个新的可变绑定,因此可以修改该字符串。

这说明:

mut 约束的是通过某个绑定所允许的操作,而不是给数据添加永久不可改变的属性。

不过,声明为 mut 并不意味着随时都可以修改数据。如果数据正在被借用,还必须遵守借用规则。


三、借用:不转移所有权,也能访问数据

现在我们已经知道,String 在按值赋值或传参时会发生 Move。

但是,这带来了一个问题:

如果我们只是希望函数读取字符串,而不希望函数取得字符串的所有权,该怎么办?

考虑:

fn print_string(s: String) {
    println!("{s}");
}

fn main() {
    let s = String::from("hello");

    print_string(s);

    println!("{s}"); // 编译错误
}

函数 print_string 按值接收 String,因此调用时会转移所有权。

但打印字符串并不需要拥有它。

为了解决这个问题,Rust 提供了引用(Reference)。

3.1 共享引用:&T

我们可以修改函数:

fn print_string(s: &String) {
    println!("{s}");
}

fn main() {
    let s = String::from("hello");

    print_string(&s);

    println!("{s}"); // 正确
}

这里:

&s

创建了对 s 的共享引用。

函数参数:

s: &String

表示接收一个 String 的共享引用,而不是接收 String 的所有权。

因此:

main 函数

s ──────────> String("hello")
                  ▲
                  │
                  │ 借用
                  │
             print_string
                &String

函数可以通过引用读取字符串,但不会取得该字符串的所有权。

当函数调用结束后,原变量 s 依然有效。

这一过程称为借用(Borrowing)。

借用的本质是:在不转移数据所有权的情况下,暂时获得访问数据的能力。

对于只需要读取字符串内容的函数,实际开发中通常更推荐使用 &str 参数:

fn print_string(s: &str) {
    println!("{s}");
}

这样既可以接受 String 的借用,也可以接受字符串切片,接口更加灵活。


四、共享借用与可变借用:Rust 最重要的访问规则

Rust 提供两种最基本的引用:

&T
&mut T

它们分别表示共享引用和可变引用。

理解这两种引用的区别,是掌握 Rust 借用机制的关键。

4.1 共享引用:允许多个引用同时读取

考虑:

fn main() {
    let s = String::from("hello");

    let r1 = &s;
    let r2 = &s;

    println!("{r1}");
    println!("{r2}");
}

这段代码完全合法。

因为 r1 和 r2 都是共享引用。

它们可以同时读取同一份字符串数据。

          String("hello")
             ▲     ▲
             │     │
             │     │
             r1    r2
             &s    &s

由于普通共享引用不会允许我们直接修改目标数据,因此多个共享引用可以安全地共存。

由此得到第一条借用规则:

同一份数据可以同时存在多个共享引用 &T。

4.2 可变引用:修改数据需要独占访问

如果我们希望通过引用修改数据,可以使用可变引用:

fn main() {
    let mut s = String::from("hello");

    let r = &mut s;

    r.push_str(" world");

    println!("{r}");
}

这里:

let r = &mut s;

创建一个可变引用。

它允许通过 r 修改 s 所拥有的字符串。

但是,可变引用有一条重要限制:

在可变借用有效期间,不能存在与它冲突的其他访问。

例如:

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    r1.push_str(" world");
    r2.push_str("!");
}

这段代码无法编译。

原因是 r1 的可变借用仍然需要使用时,又创建了一个与它冲突的可变引用 r2。

如果允许两个可变引用独立地修改同一份数据,就可能产生难以推断的数据变化,并破坏 Rust 对引用独占性的保证。

因此,我们得到第二条借用规则:

&mut T 提供独占访问能力,相互冲突的可变借用不能同时有效。

注意,可变引用并不要求引用变量自身也必须声明为 mut。

例如:

let mut x = 100;

let r = &mut x;

*r = 200;

完全合法。

因为:

  • mut r 决定能否重新给引用变量 r 赋值。
  • &mut i32 决定能否通过引用修改目标数据。

它们并不是同一个概念。

4.3 为什么共享引用和可变引用不能冲突?

考虑:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &mut s;

    println!("{r1}");
}

这段代码无法编译。

原因是:

  1. r1 是共享引用。
  2. r2 试图获得独占的可变访问权限。
  3. 创建 r2 时,r1 在后面仍然需要使用。

如果允许这种操作,就意味着同一份数据既处于共享借用状态,又可以通过另一个引用修改。

这种行为可能破坏共享引用所依赖的数据稳定性。

例如,对于 Vec<T>,某些修改操作可能导致底层内存重新分配,使已有的元素引用失效。

因此,Rust 的普通引用规则可以概括为:

同一份数据,要么允许多个共享引用读取,要么允许一个可变引用独占访问;相互冲突的借用不能同时有效。

这也是 Rust 中经常提到的:

Aliasing XOR Mutability

即共享别名访问与独占可变访问之间的互斥关系。

这里讨论的是普通引用的基本模型。Rust 还存在内部可变性、重新借用等更深入的机制,但它们并不改变我们当前需要掌握的核心原则。


五、借用什么时候结束?

到目前为止,我们已经知道:

  • 共享引用允许多个读取者。
  • 可变引用需要独占访问。
  • 相互冲突的借用不能同时有效。

但还有一个非常关键的问题:

一个引用的借用会一直持续到所在的大括号结束吗?

答案是:不一定。

现代 Rust 的借用检查器会分析引用实际需要保持有效的范围。

在简单的顺序代码中,我们可以先这样理解:

借用通常可以在引用最后一次使用之后结束。

5.1 第一段代码:无法编译

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &mut s;

    println!("{r1}");
}

为什么无法编译?

分析执行顺序:

创建 s
   │
   ▼
创建共享引用 r1
   │
   ▼
创建可变引用 r2  ← 借用冲突
   │
   ▼
继续使用 r1

问题在于:

创建 r2 的时候,r1 的共享借用仍然需要保持有效,因为后面还有:

println!("{r1}");

因此,不能创建与它冲突的 &mut s。

5.2 第二段代码:可以编译

现在调整两行代码的顺序:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;

    println!("{r1}");

    let r2 = &mut s;
}

这段代码可以编译。

为什么?

因为在创建 r2 之前,r1 已经完成最后一次使用。

创建 s
   │
   ▼
创建共享引用 r1
   │
   ▼
println!("{r1}")
   │
   ▼
r1 最后一次使用
   │
   ▼
共享借用结束
   │
   ▼
创建可变引用 r2  ← 合法

虽然 r1 仍然位于 main 函数的大括号之内,但是它的共享借用已经不需要继续保持有效。

因此,Rust 允许之后创建可变引用。

这就是非词法生命周期(Non-Lexical Lifetimes,NLL)所支持的重要行为。

5.3 变量作用域和借用有效范围不是一回事

这里需要明确区分两个概念:

变量作用域(Scope):变量名可以在源代码的哪些位置被使用。

借用的有效范围:为了保证引用的使用安全,某次借用需要在程序的哪些位置保持有效。

两者并不完全相同。

例如:

fn main() {
    let mut s = String::from("hello");

    let r = &s;

    println!("{r}");

    s.push_str(" world");

    println!("{s}");
}

虽然 r 的词法作用域仍然覆盖之后的代码,但它已经不再被使用,因此共享借用可以结束。

不过,“最后一次使用”只是理解简单顺序代码的便捷方式。

对于存在条件分支、循环等控制流的程序,Rust 会进行更精细的借用分析,而不是机械地根据源代码中的某一行决定生命周期。

5.4 可变借用也遵循同样的原则

例如:

fn main() {
    let mut s = String::from("hello");

    let r = &mut s;

    r.push_str(" world");

    println!("{r}");

    println!("{s}");
}

这段代码完全合法。

因为在:

println!("{r}");

之后,r 不再被使用,因此对 s 的可变借用限制可以结束。

原来的 s 便可以继续正常访问。

需要特别注意:

借用结束并不意味着所有权被归还,因为所有权从未发生转移。

更准确地说,是借用造成的临时访问限制不再生效。


六、数据必须比引用存活得更久

前面讨论的规则主要解决一个问题:

同一份数据可以被哪些引用访问,以及什么时候允许修改?

但是,Rust 还需要保证另一个重要条件:

引用指向的数据,在引用被使用时必须仍然有效。

考虑:

fn main() {
    let r;

    {
        let s = String::from("hello");

        r = &s;
    }

    println!("{r}");
}

这段代码无法编译。

为什么?

因为变量 s 在内部作用域结束时就会被销毁,其管理的字符串数据也随之释放。

但程序却试图通过 r 继续访问 s。

内部作用域开始
    │
    ▼
创建 String s
    │
    ▼
r 引用 s
    │
    ▼
内部作用域结束
    │
    ▼
s 被销毁
    │
    ▼
继续访问 r  ← 危险

如果允许这种情况,就会产生悬垂引用(Dangling Reference)。

因此,Rust 在编译阶段拒绝这段代码。

我们可以把这一要求理解为:

借用的数据必须在整个有效借用期间保持存在。

这也是 Rust 生命周期(Lifetime)机制最根本的目的之一。

对于普通的局部借用,编译器通常能够自动推断生命周期,我们不需要手动编写任何生命周期标注。

只有在某些函数签名等更复杂的场景中,才需要使用显式的生命周期参数来描述引用之间的关系。


七、通过一段代码串联所有规则

现在,我们已经建立了完整的基础模型。

最后用一段程序,将所有权、可变性、共享借用、可变借用以及借用结束串联起来:

fn main() {
    // 1. s 拥有 String,且允许修改
    let mut s = String::from("hello");

    // 2. 创建两个共享引用
    let r1 = &s;
    let r2 = &s;

    // 3. 通过共享引用读取数据
    println!("{r1}, {r2}");

    // 4. 共享借用不再需要,创建可变引用
    let r3 = &mut s;

    // 5. 通过可变引用修改字符串
    r3.push_str(" world");

    // 6. 可变引用最后一次使用
    println!("{r3}");

    // 7. 借用结束,原变量继续使用
    println!("{s}");
}

输出:

hello, hello
hello world
hello world

逐步分析:

第一步,s 创建并拥有一个 String 值。

第二步,r1 和 r2 共享借用该字符串。由于没有发生修改,因此两个共享引用可以同时存在。

第三步,在 println! 中读取 r1 和 r2,随后它们的共享借用不再需要继续存在。

第四步,创建可变引用 r3。由于之前的共享借用已经结束,因此不会发生冲突。

第五步,通过 r3 修改字符串。

第六步,r3 完成最后一次使用,其可变借用可以结束。

第七步,原变量 s 继续正常访问字符串。

整个过程中,String 的所有权始终属于 s,没有发生任何所有权移动。

引用只是暂时提供了访问字符串的能力。

这段代码正是 Rust 所有权与借用模型的典型体现。


八、最终总结:一个心智模型,三条借用规则

经过前面的分析,我们可以将 Rust 的所有权与借用归纳为几个简单的概念。

首先,所有权决定谁拥有数据。

一个值拥有一个所有者。对于没有实现 Copy 的类型,赋值或按值传参通常会移动所有权。

其次,可变性决定某个绑定是否允许修改数据。

let 默认创建不可变绑定,let mut 创建可变绑定。但是,即使变量可变,修改操作仍然需要满足借用规则。

最后,借用允许在不转移所有权的情况下访问数据。

它遵循三条核心规则:

规则一:共享引用可以共存。

let r1 = &s;
let r2 = &s;

多个共享引用可以同时读取同一份数据。

规则二:可变引用需要独占访问。

let r = &mut s;

在可变借用有效期间,不能存在与它冲突的其他借用或访问。

规则三:借用不再需要后,原变量可以继续正常访问。

let r = &s;

println!("{r}");

s.push_str(" world");

只要 r 的共享借用已经结束,便可以重新修改 s。

此外,还必须始终满足一条基本的安全要求:

引用指向的数据,在引用有效使用期间必须保持存在。

将这些知识归纳起来,我们便得到一个简单而准确的心智模型:

数据有主人,主人可以把数据暂时借出去。共享借用允许多人读取,独占借用允许修改;借用结束后,主人可以继续正常使用数据。同时,被借用的数据必须始终有效。

这套模型虽然简单,却已经能够解释 Rust 中大多数基础所有权与借用行为。

对于初学者,没有必要一开始就记忆复杂的权限图、生命周期推断算法或者借用检查器的内部实现。

更有效的学习方式,是每次面对一段 Rust 代码时,依次思考三个问题:

  1. 谁拥有这份数据?
  2. 现在有哪些引用正在借用它?这些引用是否发生冲突?
  3. 这些借用什么时候不再需要?

只要能够回答这三个问题,大多数 Rust 所有权与借用问题都会变得清晰。

当这套心智模型足够稳固之后,再深入学习生命周期标注、重新借用、内部可变性和智能指针,就会容易得多。


参考资料

  1. The Rust Programming Language — What Is Ownership?
  2. The Rust Programming Language — References and Borrowing
  3. Brown University — References and Borrowing
  4. The Rust Reference — References
posted @ 2026-10-30 09:46  ZhengQC  阅读(9)  评论(0)    收藏  举报