【AI开发之Rust】第 4 课:所有权系统 —— Rust 的灵魂

第 4 课:所有权系统 ------ Rust 的灵魂

预计时间:90-120 分钟 | 难度:⭐⭐⭐(本课程最重要一课,请留足时间)| 前置:第 1-3 课


4.1 为什么需要所有权?从"谁来释放内存"说起

每门语言都要回答一个残酷的问题:数据用完,谁来把内存还回去?

  • 手动管理(C/C++) :程序员负责 malloc/free。灵活但极容易出错------忘释放=内存泄漏,释放早了=悬垂指针(用已归还的内存),释放两次=double free。绝大多数 C 安全漏洞都来自这几类。
  • 垃圾回收(Java/Go/Python/Kotlin):运行时定期扫描,"没人引用就回收"。省心但有 GC 暂停、额外内存开销,还让"什么时候释放"变得不确定。
  • Rust 的答案------所有权 :把"释放时机"这件事交给编译器在编译期静态分析,不靠运行时扫描,也没有手动管理的心智负担。代价是你必须遵守三条规则。

Rust 的内存核心模型是:栈 + 堆

arduino 复制代码
栈(stack)                        堆(heap)
固定大小、快速、自动回收             动态大小,需手动/自动分配
┌─────────┐                       ┌─────────────┐
│ 局部变量 │                       │ 数据本体      │
│ 指针/len │ ──────▶ (指针)        │ (String 内容等)│
└─────────┘                       └─────────────┘
作用域结束 → 出栈即释放            无人拥有时 → 自动 Drop(相当于自动 free)
  • 栈上 :大小编译期已知的简单值(i32bool、固定长度数组、指针/长度/容量三个"元数据")。作用域结束自动弹出。
  • 堆上 :可变长数据(String 内容、Vec 内容)。难点在"何时释放"。
  • Rust 的机制:堆上的数据绑定给一个"所有者"变量;所有者离开作用域时,编译器自动插入释放代码(调用 Drop,底层等价于 free)。

这正是第 3 课那些话的底层含义:String 拥有自己的堆内存,所以离开作用域它会"自动释放";&str 只是借用,它不负责释放。

4.2 三条所有权规则(必背)

复制代码
规则一:Rust 中每个值都有一个变量作为它的「所有者」(owner)。
规则二:同一时刻,一个值只能有一个所有者。
规则三:当所有者离开作用域,这个值将被丢弃(释放)。

看作用域(scope)的直观版本:

rust 复制代码
fn main() {
    let s = String::from("hello");   // s 进入作用域,成为"hello"这块堆数据的所有者
    {
        let inner = String::from("in");  // inner 是第二块数据的所有者
        // 这里两块数据都活着
        println!("{s} {inner}");
    }                                  // ← inner 离开作用域:自动 Drop,内存归还
    println!("{s}");                   // s 还活着,没问题
}                                      // ← main 结束,s 离开作用域:自动 Drop

到目前为止,这跟很多语言的局部变量很像。区别在规则二 :一个值同一时刻只有一个所有者,它引发的连锁反应就是大名鼎鼎的 move

4.3 move:赋值与传参会"移动所有权"

4.3.1 为什么赋值后旧变量不能用了

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;              // ❓ 这是"拷贝"吗?

    // println!("{s1}");      // ❌ 编译错误:value borrowed here after move
    //                          use of moved value: `s1`
    println!("{s2}");         // ✅ s2 一切正常
}

在 C++/Python/JS 里,s2 = s1 通常复制一份(或引用计数+1),两个都能用。Rust 为何拒绝?

如果简单拷贝"指针+长度+容量"三个字段,会得到两个变量指向同一块堆内存

c 复制代码
s1 ─┐
    ▼
┌─────┬────────────────┐
│ ptr ├──▶ "hello" 堆块 │  ← 这块内存到底归谁管?
└─────┴────────────────┘     两个 owner → 作用域结束时 double free!
s2 ─┐
    ▼
┌─────┐
│ ptr ├────── (同样的指针)
└─────┘

解决办法有两个:

  • 深拷贝:把堆上内容真复制一份(各管各的)------贵,但该用时用;
  • 转移所有权(move) :干脆规定"这块内存现在归 s2,s1 作废"------零拷贝、零风险。Rust 默认选择 move。

所以上面代码的真实语义是:

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;              // 所有权从 s1 移动到 s2(s1 从此"不存在")
    println!("{s2}");
}

💡 move 不是"深拷贝数据",是转移管理权 ------底层只搬"指针+len+cap"三个字,极快。C++ 的 move 语义、以及 JS/Go 里"对象引用赋给新变量"其实都在做类似的事,只是 Rust 把后果用编译器明明白白说出来:原变量作废。

4.3.2 函数调用也会 move(传值就是把所有权交出去)

rust 复制代码
fn main() {
    let s = String::from("hello");
    take(s);                 // s 的所有权被移交进函数
    // println!("{s}");      // ❌ use of moved value: `s`
}

fn take(owned: String) {     // owned 接管这块数据
    println!("{owned}");
}                            // owned 离开作用域 → 自动释放

要"用了之后还能继续用"怎么办?三种思路(第三种就是 4.5 的主角):

rust 复制代码
fn main() {
    // 思路一:函数把所有权还回来
    let mut s = String::from("hi");
    s = take_and_return(s);          // 拿走 → 还回
    println!("{s}");

    // 思路二:克隆一份再传(代价=深拷贝,数据大时别这么干)
    let s2 = String::from("hi");
    take(s2.clone());
    println!("{s2}");                // 原副本还在

    // 思路三:借用(见 4.5)------只借不拿,最常用
}

fn take_and_return(s: String) -> String {
    println!("got {s}");
    s                                // 原样还回(所有权随返回值移出)
}

💡 "传进去再还回来"又啰嗦又容易错,于是 Rust 提供借用(引用)------这是所有权体系里最高频的设计,4.5 马上讲。

4.3.3 元组"偷回"多个值(不推荐,仅演示规则)

rust 复制代码
fn main() {
    let s = String::from("len me");
    let (s, n) = calc_len(s);      // 解构拿回 String 和它的长度
    println!("{s} 长度 {n}");
}

fn calc_len(s: String) -> (String, usize) {
    let n = s.chars().count();
    (s, n)
}

真实工程不会这么写------用借用只需一行 &s。它只是帮你确认:"函数返回值也能把所有权移回给调用者。"

4.4 Copy vs Clone:哪些类型赋值是"复制"而非"移动"

你可能要问:let x = 5; let y = x; 之后 x 明明还能用啊?没错------因为 i32 这类"纯栈上、无堆指针"的类型走的是 Copy

rust 复制代码
fn main() {
    let x = 5;
    let y = x;               // x 依然能用
    println!("{x} {y}");     // 5 5

    let b = true;
    let c = b;               // 同理
    println!("{b} {c}");     // true true
}

背后的机制:实现了 Copy trait 的类型,赋值/传参时按位复制一份,新旧互不相干(没有堆指针需要转移,也就不存在 double free)。哪些类型是 Copy

rust 复制代码
Copy(赋值即复制):
  - 所有整数/浮点/bool/char
  - 只含 Copy 元素的 tuple / 固定长度数组,如 (i32, bool)、[u8; 3]
  - 函数指针、不可变引用 &T(注意:&mut T 不是 Copy)

非 Copy(赋值即 move):
  - String、Vec<T>、&mut T、以及任何持有堆数据/资源的结构体

Clone 则不同:它是对任何类型都可以做的显式深拷贝s.clone()),代价是分配新内存。判定口诀:

rust 复制代码
类型实现了 Copy  → 赋值/传参会"隐式复制",旧变量还能用
类型没实现 Copy  → 赋值/传参会"move",旧变量作废;真需要副本就 .clone()

⚠️ 为什么不能给 String 实现 Copy?Copy 语义承诺"复制后两边完全等价、各自独立释放",这对持有堆指针的类型是灾难(会 double free)。因此 Rust 标准库里,凡是要管内存的智能类型都故意不实现 Copy 。这条设计约束你也会在自己定义类型时遇到(第 6 课 struct 讲 #[derive(Clone)] vs 不能 derive Copy 的场景)。

4.4.1 判断练习(先想再对答案)

rust 复制代码
fn main() {
    let a = String::from("a");
    let b = a;            // move(String 非 Copy)→ 之后用 a 会报错
    println!("{b}");

    let v = vec![1, 2];
    let w = v;            // move → 用 v 报错
    println!("{w:?}");

    let n = 3.14f64;
    let m = n;            // Copy → n、m 都能用
    println!("{n} {m}");
}

4.5 借用(Borrowing):&&mut

4.5.1 只读借用 &T:借了不拿走

"值先借我用用,用完了还你,所有权还是你的。"这就是 &

rust 复制代码
fn main() {
    let s = String::from("hello");
    let len = calc_len(&s);      // &s:把 s 的"只读借用"传进去
    println!("'{s}' 长度 {len}");  // s 依旧可用(没被拿走)
}

fn calc_len(s: &String) -> usize {   // 参数是"引用",不是 String 本身
    s.chars().count()
}                                    // s(引用)离开作用域,但它没所有权,不释放任何东西

把"借来"的引用想成一盏探照灯------它只是照向那块数据,不改变数据归属。所以:

  • 只读借用期间,数据依然活着(编译器保证借用不会超过所有者生命周期,这是第 5 课生命周期的工作);
  • 引用离开作用域不会释放数据(drop 只由所有者触发)。

💡 纯只读场景下,第 3 课推荐的写法更通用:calc_len(s: &str),这样字面量和 &String 都能传。&String 会被自动"解引用"成 &str,这也是函数签名普遍写 &str/&[T] 的另一个原因。

4.5.2 可变借用 &mut T:借了还要改

只读借用不能改数据,要改就用 &mut

rust 复制代码
fn main() {
    let mut s = String::from("hello");
    append_world(&mut s);        // 可变借用
    println!("{s}");             // hello world
}

fn append_world(s: &mut String) {
    s.push_str(" world");        // 通过可变借用修改所有者持有的数据
}

4.5.3 借用规则(规则二在"借用维度"的延伸------必背)

markdown 复制代码
借用规则:
 ① 同一时刻,对同一块数据:
    - 要么有任意多个「只读借用」&T
    - 要么有且仅有一个「可变借用」&mut T
    (两种不能同时存在)
 ② 借用不能活得比所有者更久(由生命周期/借用检查器保证,见第 5 课)

为什么这么严?因为数据竞争(data race)只在"一个写 + 至少一个读/写 同时发生"时才出现。Rust 在编译期就把这种可能性剪掉------多个人同时只读是安全的;一个人独占写也是安全的;读写并存 → 编译不过

rust 复制代码
fn main() {
    let mut v = vec![1, 2, 3];

    let r1 = &v;
    let r2 = &v;              // ✅ 多个只读借用共存
    println!("{r1:?} {r2:?}");

    let m = &mut v;           // ✅ r1、r2 已不再使用(NLL,见 4.5.4),此刻可变借用独占
    m.push(4);

    // let x = &v;            // ❌ 若在 m 仍使用时再建只读借用 → E0502
    println!("{m:?}");

    // ⚠️ 同一个作用域里这样写必炸:
    // let a = &v;
    // let b = &mut v;        // ❌ error[E0502]: cannot borrow `v` as mutable
    //                           because it is also borrowed as immutable
}

记住报错码 E0502(同时存在只读与可变借用),这是 Rust 入门最高频的报错之一。

4.5.4 NLL:借用什么时候"结束"?

早期 Rust 的借用要到作用域结束才失效,太死板。现在用的是 NLL(Non-Lexical Lifetimes,非词法生命周期)借用最后一次被使用之后,就可以算"结束了",即使代码上还在同一作用域。

rust 复制代码
fn main() {
    let mut v = vec![1, 2, 3];

    let r = &v;              // 只读借用开始
    println!("{:?}", r);     // ← r 最后一次使用在这里

    v.push(4);               // ✅ 合法!r 已经"用完",可变借用不再冲突
    println!("{v:?}");       // [1, 2, 3, 4]
}

💡 这条规则让"先读后改"的常见代码变合法。新手常困惑"我明明把只读引用那行用完了啊,为什么还报 E0502?"------答案多半是:借用变量后续还被用到(例如循环里每次都用、或函数尾还 return 它),把它"最后一次使用"提前即可。rust-analyzer 会在编辑器里标出借用的实际存活范围,报错时看一眼紫色/下划线区间很有帮助。

4.6 组合推演:一个值穿越"移动 + 借用 + 复制"的完整生命周期

用一段小函数把 4.2~4.5 全串起来,逐步注释每一行的归属:

rust 复制代码
fn main() {
    let name = String::from("小灵");     // name 拥有堆数据 A
    let name_len = measure(&name);       // 只读借用 A → 用完即还
    let nickname = make_nickname(name);  // name 的 A 被 move 进函数
    // println!("{name}");               // ❌ 已 move,不能再用
    println!("原名长度 {name_len},昵称 {nickname}");
}                                        // 结束:A 由 make_nickname 内部的最终持有者释放

fn measure(s: &str) -> usize { s.chars().count() }        // 借的,不释放

fn make_nickname(orig: String) -> String {                 // orig 现在拥有 A
    let mut out = String::from("nick:");                   // 新堆数据 B,out 拥有
    out.push_str(&orig);                                   // 只读借用 A 拼进 out
    out                                                    // B 的拥有权随返回值移给调用者
}                                                          // orig(A) 与 out(B) 在这里出作用域:
                                                           // 但 out 已 move 出去(不释放)
                                                           // orig 还在 → A 在此释放 ✅

把每行谁拥有、谁借用、何时释放理一遍,所有权就通了。这正是面试必考的口头推演题,请自己对着代码复述一遍。

4.7 读报错专项:三组典型错误与修复

① use of moved value(E0382)

rust 复制代码
fn main() {
    let s = String::from("x");
    let t = s;
    println!("{}", s);   // ❌ error[E0382]: borrow of moved value: `s`
}
// 修复思路:
//   - 本就不需要 s → 删掉对 s 的使用
//   - 两个都要用 → 改 let t = s.clone();(接受拷贝成本)
//   - 只想借 → 改 let t = &s;

② cannot borrow as mutable because also borrowed as immutable(E0502)

rust 复制代码
fn main() {
    let mut v = vec![1];
    let first = &v[0];
    v.push(2);           // ❌ error[E0502]
    println!("{first}");
}
// 修复思路:
//   - 检查 first 是否真的在 push 之后还用:不用就把它放到最后使用(NLL 往往直接放行)
//   - 必须同时用 → 改变数据结构(如存下标 v[0] 而不是引用),或用 RefCell(第 11 课)

③ cannot borrow as mutable more than once(E0499)

rust 复制代码
fn main() {
    let mut s = String::from("a");
    let r1 = &mut s;
    let r2 = &mut s;     // ❌ error[E0499]: cannot borrow `s` as mutable more than once
    r1.push('b');
}
// 修复思路:同一时刻只保留一个可变借用;需要"分时"操作就串行写,
// 不要同时持有两个 &mut。

💡 这三条报错是最重要的"所有权体检报告"。建议像背单词一样记住 E0382 / E0502 / E0499 分别对应什么情况,实战里一眼定位。

4.8 📝 动手练习

参考实现放 code/04-ownership/(写作时同步给出),先自己写再对照。练习目的只有一个:让编译器当你的考官,每题先故意写错、看报错,再修对。

  1. move 观察 :写 let a = String::from("hi"); let b = a; println!("{}", a);,记录报错原文(错误码应为 E0382),然后用 clone& 各修一版。
  2. 函数传值链 :写 fn push_hello(s: &mut String)fn describe(s: &str) -> usize,在 main 里先可变借用再只读借用各调用一次,体会"借完再借"的顺序约束。
  3. Copy vs move 对比 :同一段代码分别用 i32String 执行"赋值后打印原变量",把两种结果和报错整理成对照表。
  4. NLL 实验 :写一个"借了 r → 用完 r → 再 push"合法的例子;再写一个"r 用到 push 之后"会报 E0502 的例子;对比两个例子的唯一差别。
  5. 手动推演 :写一个 fn build(prefix: String, items: Vec<i32>) -> String(拼接 prefix + 对 items 求和的字符串),全程不 clone,在注释里标注每一步:谁拥有、谁借用、谁在何时释放。
  6. 综合修复题(重点):下面的代码有 5 处所有权错误,逐条改并说明错误码:
rust 复制代码
fn main() {
    let text = String::from("hello rust");
    let words = split_words(text);       // 意图:text 还得用
    let first = first_word(&words);      // 意图:借用
    words.push(String::from("!"));       // 意图:还能继续 push
    print_all(&words);
    println!("{text} 共 {} 个词", first);
}

fn split_words(s: String) -> Vec<String> {
    s.split_whitespace().map(|w| w.to_string()).collect()
}

fn first_word(words: &Vec<String>) -> String {
    words[0].clone()
}

fn print_all(words: &Vec<String>) {
    for w in words { println!("{w}"); }
}

(提示:main 里的前两行与 push 一处涉及移动后使用;函数签名可改成接收 &str/返回不变量冲突处调整调用顺序。)

验收门禁 :能不看笔记默写三条所有权规则与两条借用规则;能解释"为什么 String 赋值是 move 而 i32 是复制";遇到 E0382/E0502/E0499 能说出修法方向。

✅ 本节小结

  • 动机:手动释放易错、GC 不可控 → Rust 用编译期"所有权"解决内存释放;
  • 三条规则:每个值只有一个所有者;同一时刻只一个所有者;所有者出作用域即 Drop;
  • move :赋值/传参把堆数据的"管理权"移交,旧变量作废(零拷贝);要副本用 .clone()
  • Copy vs Clone:纯栈类型实现 Copy(赋值即复制);堆类型只能显式 Clone;
  • 借用&T 只读可多个、&mut T 独占一个、两者不同存;NLL 让借用在最后一次使用后就结束;
  • 报错三兄弟:E0382(move 后使用)、E0502(读写借用并存)、E0499(多个可变借用)。

下一课预告 :第 5 课《引用与生命周期》------借用检查器凭什么敢保证"借用不会比所有者活得更久"?生命周期标注 <'a>、三条省略规则、'static 到底指什么。这是所有权体系最后一块拼图,也是面试深入题的常客。

相关推荐
guwentian2 小时前
Rolldown vs esbuild vs Webpack:Rust 打包器大战我们该怎么选
前端·webpack·rust·esbuild
传奇开心果编程4 小时前
【Rust入门知识点学与练】第26课:Unsafe Rust
开发语言·学习·rust
yume_sibai6 小时前
09-Rust 测试与质量保证(单元测试 + 集成测试 + Mock + Benchmark + Fuzzing + CI/CD)
rust·单元测试·集成测试
传奇开心果编程15 小时前
【Xilem 0.4 基础语法学与练】第15课:状态管理与 memoize 性能优化
学习·rust·前端框架
qq_452396231 天前
第三篇:《变量、类型与函数:Rust 的“基本盘”》
开发语言·后端·rust
传奇开心果编程1 天前
【xilem0.4基础语法学与练】第22课:on_click / on_change 与手势系统的实战
学习·rust·前端框架
对象存储与RustFS1 天前
用 Restic 把本地备份存进 RustFS:S3 兼容仓库实战
后端·rust·开源
yume_sibai1 天前
07-Rust 异步编程完全指南(async/await + Tokio + Future + 并发原语 + 异步流)
开发语言·后端·rust