2.3 遮蔽与作用域:为什么同名变量能并存
上一节我们确认了两件事:对一个已经初始化的变量,不写 let 直接重新赋值(n = n + 1;),既要求声明时加上 mut(否则报 E0384),也绝不允许把整数变量换成 String(否则报 E0308)。
但下面这段代码里,三次出现变量名 n,全程没有写一个 mut,不仅算了 n + 1,最后还把 n 变成了 String,编译器却顺利通过:
shadow.rs
fn main() { let n = 7; let n = n + 1; println!("{n}"); let n = String::from("eight"); println!("{n}");}运行结果依次打印 8 和 eight。同样是改写 n,为什么前面加了一个 let,上一节的两条限制就都不起作用了?更重要的是,上一节在重新赋值时会当场释放旧值,那多写一个 let 时,旧变量手里的堆内存也会当场释放吗?
重新赋值与同名遮蔽
Section titled “重新赋值与同名遮蔽”解开疑惑的关键只有一句话:每写一次 let,都是在栈上创建一个全新的变量。
逐行看 shadow.rs 里发生了什么:
let n = 7;:在栈上创建第一个变量(类型为i32,值为7),把名字n绑定到它身上;let n = n + 1;:等号右侧先读取当前名字n指向的第一个变量(取出7),算出8;随后左侧的let在栈上创建第二个变量(放入8),并把名字n改绑到这第二个变量上;let n = String::from("eight");:左侧的let在栈上创建第三个变量。因为这是一个全新的变量,它的类型单独确定为String,并不是前面那个 4 字节的整数变量在运行时变换了类型。
在 Rust 中,用新的 let 声明同名变量、使后续代码中的该名字指向新变量,从而无法再直接通过该名字访问旧变量的机制,叫作遮蔽(Shadowing)。
把「重新赋值」与「同名遮蔽」放在一起对照,两者的边界一清二楚:
| 对比维度 | 重新赋值(n = n + 1;) |
同名遮蔽(let n = n + 1;) |
|---|---|---|
| 操作的是哪个变量 | 同一个已有变量 | 创建一个全新的变量,把名字改绑到新变量上 |
原变量是否需要 mut |
需要(否则报 E0384) |
不需要(并不是在修改原变量) |
| 类型能否改变 | 不能,新值必须匹配原变量的类型 | 能,新变量的类型独立推导确定 |
块级作用域内的遮蔽
Section titled “块级作用域内的遮蔽”既然遮蔽只是「让名字在当前范围内改指新变量」,如果把第二次 let n 放进一个内层代码块 {} 里,会发生什么?
nested-shadow.rs
fn main() { let n = 7; { let n = n + 1; println!("{n}"); } println!("{n}");}运行结果先打印 8,再打印 7。
作用域(Scope)决定了在源码的哪一段范围内可以通过某个绑定访问变量。内层 {} 里的 let n = n + 1; 创建了一个只属于内层块的新变量:
- 在内层
{}内部,名字n指向内层的新变量(值为8),外层的n被暂时遮蔽; - 一旦走过内层的
},内层变量离开作用域退场,遮蔽随之解除——外层的n从头到尾都没有被修改过,在外层打印n依然读到原来的7。
被遮蔽的旧值,何时释放?
Section titled “被遮蔽的旧值,何时释放?”现在来回答本节最核心的物理问题。
在上一节的重新赋值 target = source; 里,因为新值占了 target 的栈槽位,target 原先持有的 "old" 堆缓冲区在 = 那一行就当场释放了。
那么在遮蔽里呢?假如先写 let s = String::from("hello");,紧接着在同一层作用域里再写一行 let s = 7;——此时原来的字符串变量已经被遮蔽,后续代码再也没法通过名字 s 访问它了。那个旧字符串变量手里的 "hello" 堆缓冲区,会在 let s = 7; 这一行当场释放吗?
不用猜,我们直接用共享引用(&T)设计一个实验来试探它:在第一个 s 被遮蔽之前,先用引用 r = &s 记下它的地址;等 let s = 7; 遮蔽发生之后,再顺着 r 去读。
想一想:如果旧的 String 在 let s = 7; 那一行当场释放了,下一行 r 就会立刻变成指向已释放内存的悬垂引用,借用检查器必然报错拦截。看看编译器怎么判:
shadow-reference.rs
fn main() { let s = String::from("hello"); let r = &s; let s = 7; println!("{r} {s}");}程序顺利通过编译,打印出 hello 7。
这个实验结果直接证实了遮蔽在底层的真实物理图景:
- 遮蔽不会使旧变量失效,也不会当场释放旧值。
- 当写下
let s = String::from("hello");和let s = 7;时,当前栈帧上同时并存着两个不同的变量:旧变量s(类型为String)依然留在栈上,握着"hello"的三格控制头;新变量s(类型为i32)则是栈上新开出来的 4 字节整数。 let s = 7;唯一改变的,是让源码里后续出现的名字s解析到新变量上。而旧变量本身既没有失效,也没有离开作用域——所以此前创建的引用r依然能合法地顺着地址读到旧变量里的"hello";旧变量手里的堆内存,依然严格遵循第一章的规则,一直等到main函数结尾的}闭合时才会自动释放。
(这里也可以顺带核对第一章第五节的一个细节:借用检查器计算引用 r 的有效区间时,算到 r 最后一次使用的 println! 那一行为止;而旧变量释放堆内存的时刻,则是在更后面的作用域结尾 } 处。引用的使用区间完全落在所有者的存活区间之内,因此编译顺利通过。)
当遮蔽遇上移动:let s = s;
Section titled “当遮蔽遇上移动:let s = s;”如果遮蔽语句的等号右侧,正好直接使用了被遮蔽的那个旧变量本身呢?看下面这三行代码:
源文件:shadow-move.rs
fn main() { let s = String::from("hello"); let s = s; println!("{s}");}在 let s = s; 这一行里,遮蔽与移动同时发生:
- 右侧的
s读取旧变量;因为String未实现Copy,旧变量手里的控制头与释放责任当场移动给左侧let创建的同名新变量; - 这一次旧变量确实失效了,但让它失效的原因是右侧发生了移动,而不是因为左侧写了同名
let。到main结尾的}时,只由接手了责任的新变量s释放一次"hello"。
本章总结与综合验收
Section titled “本章总结与综合验收”用这三节走完本章,面对真实 Rust 代码里的任何一行 let 和 =,你手里已经有了核对变量状态与释放时机的三把标尺:
| 语法形式 | 本质动作 | 对旧变量与堆内存的影响 |
|---|---|---|
首次绑定(let b = a;) |
创建新变量并接手初值 | 实现 Copy 则按位复制;未实现 Copy 则移动(原变量当场失效,责任转移);加 & 则为借用(不转移责任) |
重新赋值(x = new_val;) |
更新同一个已有变量(需 let mut,不可换类型) |
若 x 当前持有有效移动值,在 = 当行先释放旧值再接新值;若旧值已移走或未初始化,直接接手新值 |
同名遮蔽(再写 let x = ...;) |
创建同名新变量,改绑后续名字指向(无需 mut,可换类型) |
旧变量并未当场释放,与新变量在栈帧上并存,仍等到旧变量所属的 } 闭合时才释放(除非右侧把旧值移走) |
先不要运行下面这段综合程序,直接用上面的三把标尺逐行推演:
源文件:review.rs
fn main() { let mut s = String::from("first"); let t = s; s = String::from("second"); println!("{s}"); { let r = &t; let s = 7; println!("{s} {r}"); } s = t; let s = s; println!("{s}");}请回答三个验收问题:
- 三次
println!分别打印什么内容? - 堆上的字符串
"second"是在内层代码块结束(})时释放,还是在后面执行s = t;时当场释放? - 到最后一行打印结束、离开
main的}时,哪一个变量负责释放"first"?
点击核对逐行推演过程
- 三次打印输出:依次为
second、7 first、first。 - 外层前四句(移动与空置重填):
let mut s = String::from("first");:外层s持有"first";let t = s;:将"first"移动给t,此时t负责释放"first",外层s变为空置(已移动)状态;s = String::from("second");:给空置的外层s重新赋入新值(情况二:旧值已移走,不触发释放),外层s恢复有效并负责释放"second"。第一次println!打印出second。
- 内层
{}块(借用与遮蔽):let r = &t;:借用t(t手里是"first");let s = 7;:在内层块里创建一个全新的整数变量s,遮蔽了外层的字符串变量s。第二次println!打印内层s(7)与引用r(first);- 内层
}闭合:退场的只是内层的整数s和引用r。外层的字符串变量s不在内层块里,所以"second"不会在内层}处释放。
- 外层最后三句(覆盖释放与同名移动):
s = t;:此时外层s手里正握着有效的"second"(情况一:持有有效值时重新赋值)。因此,"second"在执行s = t;的这一刻被当场释放;同时t手里的"first"移入外层s(t失效);let s = s;:创建同名新变量s,并将外层旧s手里的"first"移动进这个新变量s。- 最终,由
let s = s;创建的这个新变量s负责在main结尾的}处释放"first";此前已经移出值的t和旧s都不再执行释放。
动手改写验证
Section titled “动手改写验证”核对无误后,在 review.rs 中只做一个小改动:把内层块里的 let s = 7; 改成不带 let 的 s = String::from("third");,其余代码一字不改。
先思考再运行:去掉 let 之后,这一行操作的是哪个变量?"second" 的释放时机发生了什么变化?后面 s = t; 当场释放的又变成了哪份字符串?
核对答案:去掉 let 后,内层块里不再是创建新变量的遮蔽,而是直接对外层可变变量 s 执行重新赋值。于是外层 s 原先持有的 "second" 提前在内层赋值那一行当场被释放,外层 s 改持 "third"(内层打印变成 third first);等到后面执行 s = t; 时,当场被替换并释放的就变成了 "third"。
综合任务:自己写一段完成「读取—修改—锁定只读」的变量流转
Section titled “综合任务:自己写一段完成「读取—修改—锁定只读」的变量流转”现在轮到你从零写一段几行的小程序(可以在任意示例文件或 Playground 的 fn main() 中尝试)。请满足以下三个需求:
- 先创建一个持有堆上文本
"draft"的字符串变量doc,并向它末尾追加" v1"(使用.push_str(" v1")); - 在修改之前,有人曾尝试写下下面这段初版代码,却触发了编译错误。请先指出它为什么会报错(对应哪条错误码),再给出修复写法:
let doc = String::from("draft");let preview = &doc;doc.push_str(" v1");println!("{preview}");在 Rust Playground 中运行
- 当
" v1"追加完成后,要求后续代码里仍然使用变量名doc,但收回它的可变权限(后续如果有人误写doc.push_str(" v2")必须被编译器拦下),且全程不能多申请一块新的堆内存,最后打印出doc。
点击核对参考实现与错误解析
- 初版代码的两个编译错误:
- 第一处:
let doc未加mut,调用doc.push_str(" v1")会报error[E0596]: cannot borrow doc as mutable, as it is not declared as mutable。 - 第二处:即使改成
let mut doc,在只读引用preview的有效区间(一直持续到最后的println!("{preview}"))内调用doc.push_str(...)修改字符串,也会违反读写排他规则(报error[E0502]),因为追加字符可能导致堆缓冲区重新分配,使preview悬垂。应先完成修改,再创建只读引用或打印。
- 第一处:
- 完整参考实现(利用
let mut修改 + 同名遮蔽收回可变权限):fn main() {let mut doc = String::from("draft");doc.push_str(" v1");// 用同名遮蔽将控制头移动给不可变的新变量 doc,不产生新的堆分配let doc = doc;// doc.push_str(" v2"); // 若取消注释,编译器会以 E0596 拒绝修改println!("{doc}");}在 Rust Playground 中运行
走向第二篇:类型
Section titled “走向第二篇:类型”用这两章走完第一篇,我们已经从物理内存推导出了所有权、借用与生命周期模型,并在真实的 Rust 代码里逐一验证了变量的声明、重新赋值与遮蔽。
与此同时,在写这三节代码的过程中,另一条更深层的主线已经浮出了水面:
- 为什么写出来的语法全都是
let b = a;,只因为右侧是i32、String还是&String,编译器的处理就截然不同? - 为什么在变量声明上加
mut能放开修改,却改不掉变量在声明时确定的类型?上一节留下的那个疑问——编译器为什么连函数体都不看,就能在调用s.push_str("!")时知道它会修改内存——又是怎么实现的? - 第一章遗留的第一个缺口——「如果需要通过引用去修改数据,读写权限怎么分工才安全」——在代码里又该如何表达?
所有这些问题的答案,都指向同一个核心:在 Rust 里,「类型」到底是什么? 它绝不只是一张说明「占几个字节」的内存标签,而是把内存表示与使用约束(谁能改、谁负责释放、在哪段范围内有效)打包在一起的编译期规则集合。
下一篇,我们就正式进入「类型」,从「类型 = 表示 + 约束」出发,拆解 Rust 的类型系统是如何接住并扩展这套模型的。