跳转到内容

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}");
}
在 Rust Playground 中运行

运行结果依次打印 8 和 eight。同样是改写 n,为什么前面加了一个 let,上一节的两条限制就都不起作用了?更重要的是,上一节在重新赋值时会当场释放旧值,那多写一个 let 时,旧变量手里的堆内存也会当场释放吗?

解开疑惑的关键只有一句话:每写一次 let,都是在栈上创建一个全新的变量。

逐行看 shadow.rs 里发生了什么:

  1. let n = 7;:在栈上创建第一个变量(类型为 i32,值为 7),把名字 n 绑定到它身上;
  2. let n = n + 1;:等号右侧先读取当前名字 n 指向的第一个变量(取出 7),算出 8;随后左侧的 let 在栈上创建第二个变量(放入 8),并把名字 n 改绑到这第二个变量上;
  3. let n = String::from("eight");:左侧的 let 在栈上创建第三个变量。因为这是一个全新的变量,它的类型单独确定为 String,并不是前面那个 4 字节的整数变量在运行时变换了类型。

在 Rust 中,用新的 let 声明同名变量、使后续代码中的该名字指向新变量,从而无法再直接通过该名字访问旧变量的机制,叫作遮蔽(Shadowing)。

把「重新赋值」与「同名遮蔽」放在一起对照,两者的边界一清二楚:

对比维度 重新赋值(n = n + 1;) 同名遮蔽(let n = n + 1;)
操作的是哪个变量 同一个已有变量 创建一个全新的变量,把名字改绑到新变量上
原变量是否需要 mut 需要(否则报 E0384) 不需要(并不是在修改原变量)
类型能否改变 不能,新值必须匹配原变量的类型 能,新变量的类型独立推导确定

既然遮蔽只是「让名字在当前范围内改指新变量」,如果把第二次 let n 放进一个内层代码块 {} 里,会发生什么?

源文件:nested-shadow.rs
fn main() {
let n = 7;
{
let n = n + 1;
println!("{n}");
}
println!("{n}");
}
在 Rust Playground 中运行

运行结果先打印 8,再打印 7。

作用域(Scope)决定了在源码的哪一段范围内可以通过某个绑定访问变量。内层 {} 里的 let n = n + 1; 创建了一个只属于内层块的新变量:

  • 在内层 {} 内部,名字 n 指向内层的新变量(值为 8),外层的 n 被暂时遮蔽;
  • 一旦走过内层的 },内层变量离开作用域退场,遮蔽随之解除——外层的 n 从头到尾都没有被修改过,在外层打印 n 依然读到原来的 7。

现在来回答本节最核心的物理问题。

在上一节的重新赋值 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}");
}
在 Rust Playground 中运行

程序顺利通过编译,打印出 hello 7。

执行 let s = 7; 遮蔽之后:旧 s(String)与新 s(i32)在栈帧上同时并存;名字 s 改指新变量,但引用 r 仍指向旧 s,堆上的 "hello" 并未当场释放。
执行 let s = 7; 遮蔽之后的栈与堆布局栈帧里三个变量同时并存:上方引用 r(&String)占一个机器字,存着旧 s 的栈地址,实线箭头向下指向旧 s;中间旧 s(String,被遮蔽但仍有效)占三个机器字(ptr、len · 5、cap · 5),ptr 向右画出实线箭头指向堆上仍有效的五个字符 "hello";下方新 s(i32,当前名字 s 指向此处)占一个槽位,存放数值 7。整张图中没有任何变量或堆内存被释放。栈帧r&String旧 s 的栈地址指向旧 s旧 sString(被遮蔽,但仍有效)ptrlen · 5cap · 5新 si32(当前名字 s 指向此处)7堆字符缓冲区(仍由旧 s 负责释放)hello

这个实验结果直接证实了遮蔽在底层的真实物理图景:

  1. 遮蔽不会使旧变量失效,也不会当场释放旧值。
  2. 当写下 let s = String::from("hello"); 和 let s = 7; 时,当前栈帧上同时并存着两个不同的变量:旧变量 s(类型为 String)依然留在栈上,握着 "hello" 的三格控制头;新变量 s(类型为 i32)则是栈上新开出来的 4 字节整数。
  3. let s = 7; 唯一改变的,是让源码里后续出现的名字 s 解析到新变量上。而旧变量本身既没有失效,也没有离开作用域——所以此前创建的引用 r 依然能合法地顺着地址读到旧变量里的 "hello";旧变量手里的堆内存,依然严格遵循第一章的规则,一直等到 main 函数结尾的 } 闭合时才会自动释放。

(这里也可以顺带核对第一章第五节的一个细节:借用检查器计算引用 r 的有效区间时,算到 r 最后一次使用的 println! 那一行为止;而旧变量释放堆内存的时刻,则是在更后面的作用域结尾 } 处。引用的使用区间完全落在所有者的存活区间之内,因此编译顺利通过。)

如果遮蔽语句的等号右侧,正好直接使用了被遮蔽的那个旧变量本身呢?看下面这三行代码:

源文件:shadow-move.rs
fn main() {
let s = String::from("hello");
let s = s;
println!("{s}");
}
在 Rust Playground 中运行

在 let s = s; 这一行里,遮蔽与移动同时发生:

  • 右侧的 s 读取旧变量;因为 String 未实现 Copy,旧变量手里的控制头与释放责任当场移动给左侧 let 创建的同名新变量;
  • 这一次旧变量确实失效了,但让它失效的原因是右侧发生了移动,而不是因为左侧写了同名 let。到 main 结尾的 } 时,只由接手了责任的新变量 s 释放一次 "hello"。

用这三节走完本章,面对真实 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}");
}
在 Rust Playground 中运行

请回答三个验收问题:

  1. 三次 println! 分别打印什么内容?
  2. 堆上的字符串 "second" 是在内层代码块结束(})时释放,还是在后面执行 s = t; 时当场释放?
  3. 到最后一行打印结束、离开 main 的 } 时,哪一个变量负责释放 "first"?
点击核对逐行推演过程
  1. 三次打印输出:依次为 second、7 first、first。
  2. 外层前四句(移动与空置重填):
    • 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。
  3. 内层 {} 块(借用与遮蔽):
    • let r = &t;:借用 t(t 手里是 "first");
    • let s = 7;:在内层块里创建一个全新的整数变量 s,遮蔽了外层的字符串变量 s。第二次 println! 打印内层 s(7)与引用 r(first);
    • 内层 } 闭合:退场的只是内层的整数 s 和引用 r。外层的字符串变量 s 不在内层块里,所以 "second" 不会在内层 } 处释放。
  4. 外层最后三句(覆盖释放与同名移动):
    • 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 都不再执行释放。

核对无误后,在 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() 中尝试)。请满足以下三个需求:

  1. 先创建一个持有堆上文本 "draft" 的字符串变量 doc,并向它末尾追加 " v1"(使用 .push_str(" v1"));
  2. 在修改之前,有人曾尝试写下下面这段初版代码,却触发了编译错误。请先指出它为什么会报错(对应哪条错误码),再给出修复写法:
    let doc = String::from("draft");
    let preview = &doc;
    doc.push_str(" v1");
    println!("{preview}");
    在 Rust Playground 中运行
  3. 当 " v1" 追加完成后,要求后续代码里仍然使用变量名 doc,但收回它的可变权限(后续如果有人误写 doc.push_str(" v2") 必须被编译器拦下),且全程不能多申请一块新的堆内存,最后打印出 doc。
点击核对参考实现与错误解析
  1. 初版代码的两个编译错误:
    • 第一处: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 悬垂。应先完成修改,再创建只读引用或打印。
  2. 完整参考实现(利用 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 中运行

用这两章走完第一篇,我们已经从物理内存推导出了所有权、借用与生命周期模型,并在真实的 Rust 代码里逐一验证了变量的声明、重新赋值与遮蔽。

与此同时,在写这三节代码的过程中,另一条更深层的主线已经浮出了水面:

  • 为什么写出来的语法全都是 let b = a;,只因为右侧是 i32、String 还是 &String,编译器的处理就截然不同?
  • 为什么在变量声明上加 mut 能放开修改,却改不掉变量在声明时确定的类型?上一节留下的那个疑问——编译器为什么连函数体都不看,就能在调用 s.push_str("!") 时知道它会修改内存——又是怎么实现的?
  • 第一章遗留的第一个缺口——「如果需要通过引用去修改数据,读写权限怎么分工才安全」——在代码里又该如何表达?

所有这些问题的答案,都指向同一个核心:在 Rust 里,「类型」到底是什么? 它绝不只是一张说明「占几个字节」的内存标签,而是把内存表示与使用约束(谁能改、谁负责释放、在哪段范围内有效)打包在一起的编译期规则集合。

下一篇,我们就正式进入「类型」,从「类型 = 表示 + 约束」出发,拆解 Rust 的类型系统是如何接住并扩展这套模型的。

进入第二篇:类型 →