跳转到内容

1.3 最简模型:为什么赋值会让旧变量失效

上一节结尾留了一个线索:堆上的资源自己没有确定的释放时刻,但指着它的那个栈变量有——函数返回、代码块闭合(}),栈变量离开作用域的时刻在编译期一清二楚。

这一节我们不急着看 Rust 语法,先顺着这条线索,亲手搭一套最简的自动内存回收模型,看看它能否完美解决自动回收的问题。

把上一节的线索立成模型的第一条规则:

栈上的变量离开作用域(})时,自动释放它指向的堆资源。

先看这个最简模型怎么工作。假设在一个代码块里申请一块堆内存,用栈变量 s 指着它。这里还不要求先掌握 Rust 语法。

{
s = alloc_heap("hello")
// ... 在当前代码块内读写 s ...
} // ← s 在这里离开作用域,它指向的堆资源自动释放

只要从头到尾只有 s 这一个变量,模型就能完成释放:

  • 你不需要写 free,不会因为漏写而内存泄漏;
  • 释放时机就在 } 这一行,编译期完全确定,运行时没有任何扫描或计数开销。

现在用一行赋值语句 = 测试这个模型。

先试第一种情况:栈上的普通整数。

{
a = 1
b = a
} // ← b、a 离开作用域,栈帧直接收回

在底层,赋值 b = a 只做一件事:把 a 在栈上的字节按位抄一份给 b。因为整数 1 的字节全在栈上,根本没有申请任何堆资源,抄完之后 a 和 b 各自拥有一份独立的 1。到 } 闭合时,两个栈变量随栈帧一起消失,什么堆释放都不会触发,平安无事。

再试第二种情况:带着堆资源的动态字符串。

{
s = alloc_heap("hello")
t = s
} // ← 作用域结束,会发生什么?

上一节看过,这类数据在栈上只有三个机器字的控制头(指针 ptr、长度 len、容量 cap),真正的字符资源 "hello" 在堆上。

在这个模型里,执行 t = s 时,把 s 的三个机器字抄给 t。堆地址也被复制了,两个变量指向同一块资源,而不是各有一份字符缓冲区。

t = s 之后,两个变量同时指向同一块堆。
两个变量同时指向同一块堆栈上 s 和 t 各占一格,格里都是指针。两条箭头指向堆上同一块 hello。栈sptrtptr堆hello

现在走到代码块的结尾 },变量按声明的相反次序退场,t 先走,s 后走。最简模型的那条规则,对每个离开作用域的变量各执行一次:

  1. t 离开作用域,自动释放它指向的那块堆资源;
  2. s 还没离开,手里的 ptr 已经指向一块被释放的内存——悬垂指针;
  3. 紧接着 s 也离开作用域,对着同一块堆资源再释放一次——双重释放,程序通常当场崩溃。

规则本身只说了一件事:栈变量离开作用域,就释放它指向的堆。赋值 t = s 并没有违反这条规则——它只是把栈上的三个机器字抄了一份。真正出事的是抄完之后的局面:两个栈变量同时指向同一块堆,于是「离开作用域就释放」会执行两次。

由此可以抽出一条必须守住的结论:

每一块堆资源,同一时刻只能由一个栈变量负责释放。

一行最普通的赋值,恰恰把两个变量压到了同一块堆上,打破了这条结论。最简模型要想继续成立,就必须处理赋值带来的这份冲突。

最简模型的补丁,也要保持同样的粗暴风格。既然同一块堆不能同时由两个变量负责释放,赋值之后就只留一个:

带着堆资源的值在赋值时,栈上的控制头照抄给新变量;旧变量当场失效,释放责任一并交给新变量。

赋值之后,t 指向这块堆,s 失效。
赋值之后,原来的变量失效栈上两行。s 那一行标成失效,没有箭头。t 那一行是复制来的指针,箭头指向堆上那唯一一块。栈s失效ptrtptr堆hello

堆上的 "hello" 本身没有被复制一份——复制的只是栈上那三个机器字。等到作用域结束时,只有有效的 t 触发释放,已经失效的 s 什么都不做。代价也很直接:赋值之后,旧变量不能再使用。

看到图里 s 被画成虚线框并标上「失效」,你可能会问:在物理内存里,s 的栈空间是被释放了吗?还是内存里某个地方被打上了「失效」的标记位(flag)?

都不是。 这里要把运行时的物理内存和编译期的状态表分开看:

  1. 在运行时的物理内存里,什么都没被释放,也没有任何标记位:上一节讲过,局部变量所在的栈帧要等函数返回时才整块收回,没法半路单独释放某一个栈变量的槽位。执行 t = s 时,s 的三个机器字依然留在栈帧原处,CPU 只是把这 24 个字节按位复制了一份给 t。
  2. 「失效」和「责任转移」,记在编译器的内部状态表里:编译器在扫描代码时,会在自己内部维护一张变量状态表,记录每个变量当前是否有效、归不归它负责释放。扫描到 t = s 这一行时,编译器在状态表里把 s 的状态改记为「已失效」,把释放责任改记到 t 名下:
    • 如果后面的代码还试图读取 s,编译器一查状态表发现 s 已失效,直接在编译期报错拦下,根本不会生成可执行文件;
    • 等编译到代码块结尾的 } 时,编译器同样根据状态表,只为状态有效的 t 生成一条释放堆内存的机器指令,对已失效的 s 什么指令都不生成。

同一个 =,两种语义:Copy 与 Move

Section titled “同一个 =,两种语义:Copy 与 Move”

分清了物理内存和编译期状态表,再看同一个等号 = 就非常清楚了。

因为硬件内存里的字节只能被读取、复制或覆盖,数据在物理内存里是没法自己「搬家」的。所以在运行时(暂不考虑编译器的指令优化),下面这两行赋值在栈上做的物理动作完全一样——都是一次按位复制:

b = a // a 是整数
t = s // s 指向一块堆资源

但在编译器的状态表里,这两行代码走出了两条截然不同的分支:

  • 对 b = a:栈上字节里没有需要释放的堆资源。栈上复制完之后,编译器状态表里 a 和 b 保持各自有效,各管各的。这种语义叫复制(Copy)。
  • 对 t = s:栈上字节里带着需要独占释放的堆资源。运行时虽然也是把栈上的控制头复制了一份,但编译器状态表当场把旧变量 s 记为已失效,并将释放责任转交给新变量 t。

第二种情况里,运行时做的是「栈字节复制」,编译期做的是「旧变量失效 + 责任转移」。从最终的使用效果看——s 不能再用了,t 接手了那块堆和释放责任——就像把值从 s 手里移交到了 t 手里,所以我们把这套编译期语义叫作移动(Move)。从此,同一个赋值符号,在不带堆资源的类型上是 Copy,在带着堆资源的类型上是 Move。

把这一节推出来的两件事合在一起,最简模型已经能回答第一节留下的目标:

  1. 变量离开作用域,自动释放它指向的堆——不靠人手写 free,也不靠运行时扫描或计数;
  2. 带着堆资源的赋值走 Move,旧变量失效——同一块堆同一时刻只由一个变量负责释放,双重释放被挡住。

到这里,自动回收、非 GC、也不会双重释放,似乎都齐了。

但这也是一张还要继续检验的成绩单。请先停下来想一想:如果这套模型已经足够完美,日常写程序时最普通的协作——比如把一个值交给函数用一下——还能不能自然写出来?如果某处写不顺,卡在了哪一步?

带着这个问题,下一节继续。