使用 let 声明不可变变量会发生什么?

解读

在国内 Rust 社招/校招面试里,这道题几乎必问,因为它同时考察候选人对所有权语义编译期检查性能模型的理解深度。面试官通常不会满足于“不能改”这种表层回答,而是希望听到:

  1. 编译器到底在HIR/MIR层面插入了什么约束;
  2. 不可变对后续优化(LLVM 常量传播、内联、向量化)带来的具体收益;
  3. 与 C++ const、Java final 的本质差异
  4. 多线程场景下如何利用不可变保证无锁并发
  5. 若业务需要“可变”,如何通过Cell/RefCell/RwLock等线程安全工具绕开,同时不破坏语义。

只有把“不能改”背后的内存布局、生命周期、并发语义讲清楚,才能拿到高分。

知识点

  • 所有权系统:let 绑定把值语义上“移动”到变量名,不可变引用 (&T) 在编译期被标记为只读,借用检查器拒绝任何后续可变借用。
  • 冻结(freeze)机制:一旦 let 绑定完成,该内存区域被标记为只读,LLVM 收到 noalias readonly 属性,可放心做常量传播公共子表达式消除
  • 零成本抽象:不可变不需要运行时锁或写屏障,CPU 缓存一致性协议直接保证多核只读安全,性能与 C 的 const 变量同一量级。
  • 再绑定(shadowing):let x = 1; let x = x + 1; 看似“修改”,实则是新变量覆盖旧变量,旧值在MIR中被标记为不再使用,drop 按生命周期即时释放。
  • 内部可变性(interior mutability):当底层硬件或算法必须修改时,标准库提供UnsafeCell<T> 作为唯一合法入口,Cell<T>RefCell<T>Mutex<T> 都基于它封装,编译器对这类类型关闭冻结优化,确保语义安全。

答案

使用 let 声明的变量默认不可变,编译器在借用检查阶段会拒绝任何对该变量的二次赋值或可变借用。例如

let x = 42;
x = 10; // 编译错误:cannot assign twice to immutable variable

这一机制带来三点直接效果:

  1. 内存安全:杜绝了并发场景下的数据竞争,因为同一时间只允许任意数量的共享引用 (&T) 或至多一个可变引用 (&mut T)。
  2. 优化空间:LLVM 收到只读属性后,可把变量当作编译期常量进行传播、折叠,甚至完全消除冗余加载指令,性能逼近手写汇编
  3. 语义清晰:不可变变量在MIR中表现为 SSA 单赋值形式,使生命周期检查drop 顺序可静态推导,减少运行时开销。

若业务需要可变,需显式使用 let mut 或选择内部可变性容器,否则编译器在HIR→MIR转换阶段就会报错,保证“编译通过即正确”的 Rust 哲学。

拓展思考

  1. 与 C++ const 的区别:C++ 的 const 只是类型系统提示,可通过 const_cast 强行去除;Rust 的不可变是语言级承诺,除非使用 unsafe 块,否则无法在 safe 代码里绕过,ABI 层面也禁止写入。
  2. 再绑定 vs 可变:shadowing 生成全新变量,名字相同但作用域不同,旧变量立即drop;而 let mut 是同一内存区域的多次写入,二者在生命周期图谱里表现完全不同,面试时可画图说明 MIR 的drop 树差异。
  3. 并发性能:不可变变量可无缝放入 Arc<T> 跨线程共享,无需锁即可实现无锁读多写无模式;在国产高并发网关(如字节跳动 monolake)中,利用这一特性把配置结构体设为不可变,QPS 提升 18% 以上。
  4. 面试陷阱:若面试官追问“不可变是否一定在只读内存段”,需回答不一定。栈上变量仍位于可写页,只是编译器拒绝生成写指令;真正放进 .rodata 需要 const 静态项或 &'static T 字面量,结合 #[link_section = ".rodata"] 属性。