2Rust程序设计语言

所有权

Rust的核心创新

所有权借用生命周期

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第二章 所有权 (Ownership)

一、导读

1.1 本章学习目标

深入理解 Rust 所有权系统(Ownership System)的三条核心规则及其在编译期的工作方式

掌握值的所有权转移(Move)语义,理解其与浅拷贝(Shallow Copy)的本质区别

理解借用(Borrowing)与引用(Reference)的概念,区分不可变引用与可变引用

掌握借用检查器(Borrow Checker)的工作机制,能够预测和解释常见的借用错误

理解切片(Slice)类型作为所有权的部分借用,掌握字符串切片和数组切片的使用

理解所有权与栈(Stack)、堆(Heap)内存分配之间的关系

掌握 Clone 与 Copy trait 的区别,理解何时数据会被复制而非移动

能够在实际编程中运用所有权思维模型设计数据结构

1.2 为什么本章如此重要

所有权系统是 Rust 最核心、最独特的特性,也是 Rust 能够在不使用垃圾回收的情况下保证内存安全的根本原因。理解所有权不仅是学习 Rust 的必经之路,更是理解 Rust 所有其他高级特性(如生命周期、智能指针、并发编程)的基础。可以说,没有对所有权系统的深刻理解,就不可能写出地道的 Rust 代码。

在传统的系统级编程语言(如 C/C++)中,内存管理是开发者最沉重的负担之一。忘记释放内存会导致内存泄漏,释放后继续使用会导致悬垂指针,多次释放会导致未定义行为。这些错误在运行时才暴露,调试极其困难。Rust 的所有权系统将这些问题全部前移到编译期解决——如果代码能通过编译,就不会存在这些内存安全问题。

所有权系统的设计灵感来源于线性类型理论(Linear Type Theory),其核心思想是:每个值都有且仅有一个"所有者"(owner),当所有者离开作用域时,值会被自动释放。这种机制类似于现实世界中的物品归属——一本书在同一时刻只能属于一个人,你可以借给别人看(借用),但书的所有权不会改变。当借书的人离开房间(作用域结束),书会自动归还。

本章将系统地讲解所有权的三条规则、移动语义、借用机制和切片类型。这些概念初看可能令人困惑,但一旦建立正确的心理模型,你会发现所有权系统不仅不是束缚,反而是帮助你编写更安全、更清晰代码的强大工具。

1.3 核心问题引导

为什么 Rust 中 String 类型赋值给另一个变量时会发生"移动"而不是"复制"?

所有权系统与 C++ 的移动语义(Move Semantics)有什么异同?

为什么 Rust 不允许同时存在可变引用和不可变引用?这与数据竞争有什么关系?

生命周期(Lifetime)与所有权是什么关系?为什么编译器有时需要显式的生命周期标注?

在什么情况下数据会被复制(Copy)而不是移动(Move)?判断标准是什么?

切片(Slice)如何在不获取所有权的情况下引用集合的一部分?


二、核心概念详解

2.1 所有权的三条规则

Rust 的所有权系统建立在以下三条规则之上,所有其他特性都是这三条规则的推论:

规则一:每个值都有且仅有一个所有者(Owner)。当声明 let s = String::from("hello"); 时,s 就是字符串数据的所有者。在任意时刻,只有一个变量拥有该数据的所有权。

规则二:同一时刻只能有一个所有者。当执行 let t = s; 时,s 的所有权被"移动"到 t,s 不再有效。这与 C/C++ 的浅拷贝不同——Rust 为了防止"双重释放"(double free),会让原变量失效。

规则三:当所有者离开作用域时,值被自动释放。这与 C++ 的 RAII(Resource Acquisition Is Initialization)机制类似。当变量 s 离开其作用域(通常是花括号结尾),Rust 会自动调用 drop 函数释放其占用的内存。

rust
{
    let s = String::from("hello"); // s 进入作用域,获得所有权
    // 使用 s
} // s 离开作用域,内存自动释放

2.2 移动语义与 Clone

移动(Move):对于存储在堆上的数据(如 String、Vec),赋值操作会转移所有权而非复制数据。这是出于性能考虑——复制堆上的数据可能非常昂贵。

rust
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到 s2,s1 不再有效
// println!("{}", s1); // 编译错误:s1 已被移动

Clone:如果确实需要深拷贝,必须显式调用 .clone() 方法。这是一种"显式代价"的设计——让开发者清楚地知道这里发生了昂贵的复制操作。

rust
let s1 = String::from("hello");
let s2 = s1.clone(); // 显式深拷贝,s1 和 s2 都有效
println!("s1 = {}, s2 = {}", s1, s2);

Copy trait:对于仅存储在栈上的简单类型(如整数、浮点数、布尔值、字符),赋值时会自动复制而非移动。这些类型实现了 Copy trait。判断标准很简单:如果一个类型的所有字段都实现了 Copy,那么该类型也可以实现 Copy。

rust
let x = 5;
let y = x; // x 被复制,x 仍然有效
println!("x = {}, y = {}", x, y);

2.3 借用与引用

借用(Borrowing)是 Rust 中传递数据而不转移所有权的机制。通过引用(Reference),你可以访问数据而不获取其所有权。

不可变引用(`&T`):可以创建多个不可变引用,但不能通过它们修改数据。不可变引用也称为"共享引用",因为多个读者可以同时访问同一数据。

rust
let s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &s; // 可以有多个不可变借用
println!("{} {}", r1, r2);

可变引用(`&mut T`):同一时刻只能有一个可变引用,且不能同时存在不可变引用。可变引用也称为"独占引用",因为写者必须独占访问权。

rust
let mut s = String::from("hello");
let r = &mut s;
r.push_str(" world");
// let r2 = &s; // 编译错误:不能同时存在可变和不可变引用

借用规则的核心约束:在同一作用域中,要么有任意数量的不可变引用,要么有且仅有一个可变引用,但不能两者兼有。这条规则在编译期就消除了数据竞争的可能性。

2.4 引用的生命周期与有效性

Rust 编译器通过借用检查器(Borrow Checker)追踪每个引用的生命周期(lifetime),确保引用始终有效。引用的生命周期不能超出其引用数据的生命周期。

rust
let r;
{
    let x = 5;
    r = &x; // 错误:x 的生命周期不够长
} // x 在这里被释放
// println!("{}", r); // r 引用了已释放的数据

在大多数情况下,编译器能够自动推断引用的生命周期(生命周期省略规则)。但当推断规则无法确定时,需要开发者显式标注生命周期参数,这将在第五章详细讨论。

2.5 切片类型

切片(Slice)是对集合中一段连续元素的引用,它不拥有数据的所有权,只是"借用"了数据的一部分。切片在底层由一个指向数据的指针和长度组成。

字符串切片(`&str`):&str 是对 String 中一段连续 UTF-8 字节的引用。注意区分 String(拥有所有权的堆分配字符串)和 &str(字符串切片,借用)。

rust
let s = String::from("hello world");
let hello = &s[0..5];   // "hello"
let world = &s[6..11];  // "world"

数组切片:类似地,可以对数组和 Vec 创建切片:

rust
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // &[2, 3]

字符串字面量(如 "hello")本身就是切片 &str,它们被直接嵌入到二进制文件中,具有 'static 生命周期。


三、深入分析与讨论

3.1 所有权与内存布局:栈与堆

理解所有权系统需要理解 Rust 的内存布局。Rust 中,固定大小且编译时已知大小的数据存储在栈(Stack)上,如整数、浮点数、布尔值等。这些数据实现了 Copy trait,赋值时会复制。

动态大小的数据(如 String、Vec)存储在堆(Heap)上。栈上存储一个"胖指针"(fat pointer),包含指向堆数据的指针、容量(capacity)和长度(length)。当所有权移动时,只有栈上的这个结构体被复制(移动),堆上的数据保持不变。这就是为什么移动操作是 O(1) 的——不需要复制堆数据。

栈:[ptr, len, capacity]  →  堆:[h, e, l, l, o]

当所有者离开作用域时,栈上的结构体自动弹出,同时 Rust 会调用 drop 函数释放堆上的数据。这种机制确保了不会发生内存泄漏或双重释放。

3.2 借用规则与并发安全的关系

借用检查器的规则看似严格,实际上是在编译期消除了数据竞争。数据竞争发生的三个条件:两个或以上的指针同时访问同一数据、至少有一个指针用于写入、没有同步机制。Rust 的借用规则恰好阻止了这种情况——可变引用的独占性确保了写入时不会有其他读者,不可变引用的共享性确保了读取时不会有写入者。

这种"编译期并发安全"是 Rust 最引以为傲的特性之一。在 C/C++ 中,数据竞争是未定义行为,可能在生产环境中偶尔触发,极难调试。Rust 将这类错误完全消灭在编译阶段。

3.3 所有权思维模型的建立

从其他语言转到 Rust,最大的挑战不是语法,而是思维模型的转变。在 GC 语言中,开发者习惯了"随便引用"的自由;在 C/C++ 中,开发者习惯了"手动管理"的责任。Rust 提供了一种新的范式——"所有权即责任"。

建立所有权思维的关键是:每次使用一个值时,问自己"我是在借用还是在获取所有权?"如果是获取所有权,原变量是否还需要使用?如果需要,应该使用借用(引用)或克隆。这种思维方式在初期可能显得繁琐,但随着练习会成为直觉。


四、实践案例

4.1 实现 first_word 函数

编写一个函数,返回字符串中第一个单词的切片。这个案例展示了切片如何在不获取所有权的情况下操作字符串。

rust
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    s
}

fn main() {
    let sentence = String::from("hello world rust");
    let word = first_word(&sentence);
    println!("第一个单词:{}", word); // "hello"
    // sentence 的所有权没有被转移,仍然可以使用
    println!("完整句子:{}", sentence);
}

4.2 向量数据的共享与修改

演示如何在多个函数之间共享和修改 Vec 数据,理解借用规则在实际代码中的应用。

rust
fn sum(values: &[i32]) -> i32 {
    values.iter().sum()
}

fn add_element(values: &mut Vec<i32>, element: i32) {
    values.push(element);
}

fn main() {
    let mut data = vec![1, 2, 3, 4, 5];
    
    // 不可变借用:计算总和
    let total = sum(&data);
    println!("总和:{}", total);
    
    // 可变借用:添加元素
    add_element(&mut data, 6);
    println!("添加后:{:?}", data);
    
    // 再次不可变借用
    let new_total = sum(&data);
    println!("新总和:{}", new_total);
}

五、常见误区与注意事项

误以为 `let t = s;` 是复制:对于 String 等堆分配类型,这是移动操作。s 在移动后不再有效。如果需要保留 s,应使用 s.clone()。

同时持有可变引用和不可变引用:这是最常见的编译错误之一。记住规则:要么多个不可变引用,要么一个可变引用,不能兼有。解决方法是重新组织代码,确保可变借用和不可变借用不在同一作用域重叠。

返回局部变量的引用:不能返回函数内部创建的值的引用,因为函数返回后局部变量会被释放。应该返回值本身(利用移动语义)或返回传入参数的引用。

忽略 `&str` 和 `String` 的区别:函数参数通常使用 &str 而非 String,因为前者更灵活(可以接受 String 的引用和字符串字面量)。这是"接受引用而非拥有值"的原则。

在循环中修改集合的同时迭代它:Rust 的借用检查器会阻止在迭代 Vec 的同时修改它。解决方案是先收集需要修改的内容,再在循环外统一修改。


六、本章小结

本章深入讲解了 Rust 的所有权系统——这门语言最核心的特性。所有权系统通过三条简洁的规则(唯一所有者、单一所有权、作用域结束自动释放)在编译期保证了内存安全,无需运行时垃圾回收。我们学习了移动语义与 Clone 的区别、借用与引用的机制、借用检查器的工作方式,以及切片类型的使用。所有权的规则虽然严格,但它们不仅消除了内存安全问题,还在编译期阻止了数据竞争,实现了"无畏并发"。建立正确的所有权思维模型是掌握 Rust 的关键一步。


七、思考题与练习

解释以下代码为什么不能通过编译,并提供至少两种修复方案:

rust
let s = String::from("hello");
let t = s;
println!("{}", s);

实现一个函数 longest_word,接受一个字符串切片,返回其中最长的单词(以空格分隔)。注意不要获取输入字符串的所有权。

解释 String、&str 和 &String 三者的区别。在函数参数中应该优先使用哪种?为什么?

以下代码能否编译通过?如果不能,解释原因并修复:

rust
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
let r3 = &mut s;
r3.push_str(" world");
println!("{}", r3);

编写一个函数,接受 &mut Vec<i32> 参数,将其中所有偶数乘以 2。解释为什么这里必须使用可变引用。

解释为什么 i32 实现了 Copy trait 而 String 没有。判断一个类型是否应该实现 Copy 的标准是什么?

设计一个数据结构来表示"学生列表",要求能够在不转移所有权的情况下查询和修改学生信息。画出所有权的转移图。

研究 Rc<T> 和 Arc<T> 智能指针如何突破"单一所有者"的限制。它们与本章讨论的所有权规则是什么关系?(预习内容)

2/10所有权
0%