阶段二:核心突破——物理世界的内存映射与所有权法则

全系列的核心转折点:把栈比作弹簧餐盘架、堆比作大型货场,逐条推演所有权三大定律、Move 语义的产权过户、只读借用与可变借用的排他铁律,以及作用域结束时内存如何被确定性释放。

发布于 6 分钟阅读

适用读者:零基础学习者。

核心目标:彻底理解计算机内存的真实物理布局(栈与堆),掌握 Rust 享誉世界的“所有权”心智模型,搞懂变量在内存中的生命轮回。


1. 物理内存的实相:为什么需要分为“栈”与“堆”?

在阶段零中我们知道,内存就像一排排排满储物格子的仓库。但在现代计算机操作系统中,为了平衡运行速度与灵活性,内存被清晰地划分成两种完全不同的工作区域:栈(Stack) 与 堆(Heap)。

┌──────────────────────────────────────────────────────────────────┐
│                           进程内存空间                            │
│                                                                  │
│   ┌───────────────────────────┐      ┌───────────────────────┐   │
│   │        栈内存 (Stack)     │      │      堆内存 (Heap)     │   │
│   ├───────────────────────────┤      ├───────────────────────┤   │
│   │  【整齐严密的餐盘架】       │      │  【自由申请的大型货场】 │   │
│   │                           │      │                       │   │
│   │  • 速度极快 (指针上下移动) │      │  • 速度较慢 (需寻址)   │   │
│   │  • 编译期大小必须固定     │      │  • 空间巨大、动态伸缩 │   │
│   │  • 进出严格后进先出 (LIFO)│      │  • 返回门牌号 (指针)   │   │
│   └───────────────────────────┘      └───────────────────────┘   │
└──────────────────────────────────────────────────────────────────┘

1.1 栈(Stack):整齐紧凑的“餐盘架”

  • 生活模型:想象食堂里的弹簧餐盘架。洗好的盘子一个个往上压叠,取用时只能从最顶上拿走最后一个放上去的盘子。这种先进后出、后进先出的结构,在计算机中称为 LIFO(Last In, First Out)。
  • 决定性特征:
    1. 大小必须绝对固定且已知:放进栈里的每一个数据,在代码还没运行前(编译期),编译器就必须确切知道它占几个字节。
    2. 速度登峰造极:CPU 不需要任何复杂的寻找操作,它只需要把栈指针向下或向上拨动几个刻度,数据就存好了或取出了。
    3. 存放内容:整数(i32 占 4 字节)、浮点数、布尔值(1 字节)以及指向外部的门牌号(指针)。

1.2 堆(Heap):自由申请的“大型货架仓库”

  • 为什么需要堆?

假设你正在开发一个用户注册系统,用户输入的名字可能是 2 个字,也可能是 20 个字,甚至可能是一本几万字的小说。你在写代码时根本不可能提前预知它的大小。这种动态增长、大小未知的数据,严禁存入紧凑的栈中。

  • 生活模型:堆就像一个需要登记入驻的巨大集装箱货场。
    1. 程序向操作系统前台登记:“请在货场给我划一块 100 字节大小的空地”;
    2. 操作系统在广袤的堆内存里四处搜寻,找到一块足够大的空闲区域;
    3. 操作系统登记该区域,并将这块区域的首地址(门牌号,即指针 Pointer)交回给你。
    4. 你把这个小小的“门牌号”(大小是固定的 8 字节)揣在随身的栈内存里,而真正庞大臃肿的数据则安放在远处的堆内存中。

2. 堆内存管理的“历史死结”:谁来打扫垃圾?

堆内存空间极其宝贵,用完之后必须及时归还给操作系统。但数十年来,其他编程语言陷入了两难境地:

  1. 手动管理模式(如 C 语言)——“谁借谁还,全靠自律”:
    • 规定程序员在申请堆内存后,用完必须亲自写一行释放指令。
    • 人类天生是健忘的:
      • 忘记释放 →\rightarrow 内存泄漏(Memory Leak):电脑内存越耗越空,最后系统卡死;
      • 释放了两次 →\rightarrow 双重释放(Double Free):系统内存严重混乱,黑客趁虚而入;
      • 内存释放了,但手里的门牌号忘了扔 →\rightarrow 野指针(Dangling Pointer):凭着旧门牌号去非法读取别人新入住的数据,引发系统闪退。
  2. 垃圾回收模式(如 Java / Go / Python)——“专职保洁,强制停工”:
    • 不让程序员操心,后台派一个保洁员定期巡逻,看哪个堆对象没人用了就回收。
    • 高昂代价:保洁员巡逻需要占用很多 CPU 和额外内存;在特定扫描阶段,整个系统所有工作必须全部强行挂起(Stop-The-World),导致工业控制和游戏产生掉帧卡顿。

3. Rust 的革命突破:所有权三大定律(Ownership Rules)

Rust 既不信任粗心的人类,也不愿忍受保洁阿姨的昂贵打扰。它确立了三条如同物理定律般铁面无私的规则:

👑 所有权三大定律

1. Rust 中的每一个值,都有一个被称为其“所有者(Owner)”的变量。

2. 在任意时刻,每个值有且仅能有一个所有者。

3. 当所有者变量离开其所在的作用域大括号 {} 时,该值占用的内存将被立即自动清扫归还。

3.1 作用域清扫机制:人走茶凉(RAII)

{                      // s 在这里还不存在
    let s = String::from("hello"); // 从这里开始,s 诞生在栈上,堆上分配了 "hello",s 成为这片堆内存的唯一所有者
    // 尽情使用 s
}                      // 走到这里,作用域围墙结束!
                       // Rust 在编译时在此处自动插入代码,调用 drop() 销毁 s,堆内存瞬间被自动归还操作系统!

你不需要写任何释放内存的代码,编译器通过精准的作用域分析,把内存释放的时机完全固化在了编译后的机器指令中。这在计算机工业中被称为 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。


4. 数据的传递方式:复制、移动与克隆

4.1 栈上数据的轻松复制:Copy 特征

对于完全保存在栈上的简单数据(如整数、浮点数),因为它们体积微小,复制一份的物理代价极低:

let x = 5;
let y = x; // 栈上直接快速复制了一份新的 5 存进 y
// x 和 y 此时均完全有效且互不影响

4.2 堆上数据的“产权过户”:移动语义(Move)

如果是堆内存数据,事情将发生颠覆性的改变:

let s1 = String::from("hello");
let s2 = s1; // 发生所有权转移 (Move)!

// println!("{}", s1); // 编译报错!s1 已经被彻底废弃,无法使用!

房产证过户类比

  • 堆上的数据是一套真正的实体房屋;
  • 栈上的 s1 是写着房屋物理地址的房产证原件;
  • 当你执行 let s2 = s1; 时,你将房产证亲手过户移交给了 s2。此时房子依然只有一套,但唯一合法的产权人变成了 s2。
[栈 Stack]                         [堆 Heap]
  s1 (已作废失效 ❌)
  s2 (有效所有者 ✅) ──指针指向──>  [ 'h', 'e', 'l', 'l', 'o' ]

为什么 s1 必须被立即废弃?

设想反证:如果允许 s1 和 s2 同时有效。当代码运行到大括号结尾时,按照定律 3,s1 会去叫拆迁队释放一次这片堆内存,紧接着 s2 又会带着拆迁队去释放同一片内存!这正是臭名昭著的双重释放崩溃。

因此,Rust 强制规定:所有权过户的那一刻起,原所有者 s1 立即死亡,彻底扼杀隐患于萌芽之中。

4.3 显式深拷贝:克隆(Clone)

如果你真的需要一份完完全全独立的堆数据副本,你必须显式调用 .clone():

let s1 = String::from("hello");
let s2 = s1.clone(); // 显式向堆重新申请一片新内存,两套房子各自独立
// s1 和 s2 此时各自拥有独立的房屋产权

5. 借用与引用:只借不给产权的生活智慧

如果每次把数据传给函数或交给别人算一算,都必须发生所有权转移(Move),那写代码将极其痛苦(每次传出去后还得想办法从返回值里把数据再移交回来)。

在现实生活中,朋友想借你的书读一读,你绝不需要把整本书的产权永久赠送给他,你可以借给他翻阅几天,阅后归还。在 Rust 中,这被称为借用(Borrowing),借出的凭据被称为引用(Reference)。

5.1 不可变引用(&T):只读借阅证

在变量名前加上取地址符 &,代表创建对该值的引用。你只借出了访问权,原所有权依然牢牢掌握在主人手中:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 借出只读引用 &s1
    println!("'{}' 的长度是 {}", s1, len); // s1 依然完好可用!
}

fn calculate_length(s: &String) -> usize {
    s.len() // s 只是一个借阅证,它不拥有底层内存
} // s 离开作用域,因为它不是所有者,所以绝对不会触发内存销毁动作!

5.2 可变引用(&mut T):允许涂改的修改借条

如果借用人不仅想看,还想在上面增删内容,可以使用 &mut(可变引用):

fn main() {
    let mut s = String::from("hello");
    change(&mut s); // 借出可变借条
    println!("{}", s); // 输出: hello, world
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

6. 借用检查器的至高天条:读写排他铁律

为了从数学逻辑上消灭并发多线程与复杂代码中的数据竞争,Rust 编译器的“借用检查器(Borrow Checker)”强制推行一条核心准则:

⚡ 读写排他铁律 (Aliasing XOR Mutability)

对于同一块数据,在任意给定的同一作用域范围内:

- 要么允许无数个人同时拥有只读引用(&T);

- 要么只允许一个人独占拥有可变修改引用(&mut T);

- 绝不允许只读引用与可变修改引用同时并存!

   ┌──────────────────────────────────────────────────┐
   │             读写排他状态机 (XOR)                 │
   │                                                  │
   │    【多读者模式】           【单写者独占模式】     │
   │   &T, &T, &T... (全员只读)   OR      &mut T (唯一独占) │
   │                                                  │
   │           ⚡ 绝对禁止两者交叉并存 ⚡              │
   └──────────────────────────────────────────────────┘

为什么必须读写排他?

生活常识反证法:

假设黑板上写着一份航班计划。如果有 5 位旅客正在同时聚精会神地抄录这份航班表(只读引用);突然跑来一个工作人员,一边拿抹布把黑板上的字全部擦掉重写(可变引用),那这 5 位旅客抄下来的信息必定支离破碎、前后矛盾,这就是计算机系统中最致命的数据竞争(Data Race)。

Rust 在编译期间就以极高敏锐度扫描所有借用的存活周期。只要发现存在一边读一边写、或者两人同时写的苗头,编译器立刻阻断编译。


7. 切片(Slices):安全透视集合的视窗

切片(Slice)是对集合中某段连续局部序列的只读借用,它同样不拥有底层数据所有权:

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

let hello: &str = &s[0..5];  // 借出前 5 个字母构成的切片
let world: &str = &s[6..11]; // 借出后 5 个字母
  • 切片的内部构成:仅由两个数字组成——指向切片起始字节的指针 + 切片包含的长度(在 64 位系统上仅占 16 字节)。
  • 安全防线:当你持有字符串的切片时,由于切片是只读借用,借用检查器会断然禁止原字符串调用 .clear() 清空内容,防止指针指向已经被销毁的字串残骸。