变量、常量与遮蔽:可变性的取舍

几乎所有语言入门都从变量开始,但 Rust 的变量有几个其他国家语言没有的脾气:默认不可变、允许遮蔽、常量与变量是两套机制。这篇解决的问题是:什么时候用 let、什么时候加 mut、常量与静态变量有何区别、遮蔽和重新赋值为什么不是一回事。读完你在写声明时能下意识地选择正确的形式,并理解 Rust 为什么把不可变作为默认。

一、默认不可变,而不是默认可变

用 let x = 5; 声明的变量之后再赋值会直接编译失败,必须写成 let mut x = 5; 才允许修改。这个设计是刻意的:可变性是一种需要明确声明的属性,而不是免费的默认。原因在于编译器的借用检查依赖它——如果所有变量都默认可变,编译器就无法在不细看控制流的前提下推断哪里会有并发修改风险;把 mut 写在声明处,等于给读代码的人一个信号:这个变量的值后续会变,也等于给编译器一个静态可识别的标记。

fn main() {
    let x = 5;
    // x = 6; // 编译错误:cannot assign twice to immutable variable
    let mut y = 5;
    y += 1;
    println!("x={}, y={}", x, y);
}

如果你硬改一个不可变变量,错误信息会非常直白:cannot assign twice to immutable variable,并提示你把声明改成 mut。习惯之后你会发现,绝大多数变量在生命周期里并不需要修改——不可变默认逼迫你把可变范围压缩到最小,读代码时只需要盯着几处 mut 看,心智负担反而更轻。这不是语言纯洁性的说教,而是工程上可验证的收益。

再往深一层想,不可变默认还给并发安全打了地基。借用检查器在判断「这段代码能否安全地多线程访问」时,依赖的正是类型层面有没有 mut;一个全程不可变的值天然可以被多个线程共享读取,编译器不必分析使用细节就能放行。也就是说,你在变量声明处省下的每一个 mut 关键字,都在为编译器的静态分析减少一分负担,最终以编译错误的形式替你挡住潜在的数据竞争。

一个有意思的对照是:很多语言里 const 是修饰符,Rust 里 mut 才是修饰符——两者恰好把默认值掉了个个儿。从其他语言转来时,肌肉记忆需要反过来:想清楚这个变量到底要不要改,再决定写不写 mut。

二、遮蔽:同名变量的重新声明

Rust 允许用同一个名字声明新变量,旧变量就此不可访问,这叫遮蔽(shadowing)。它和给旧变量重新赋值是两回事:

fn main() {
    let guess = "42";          // 字符串
    let guess: i32 = guess.trim().parse().unwrap(); // 新变量,类型变了
    println!("guess = {}", guess);
}

这段代码里第二个 guess 是全新变量,类型从字符串转成了整数。如果用 mut 加重新赋值,类型不同根本做不到,只能新起名字。遮蔽的典型用途正是这类「同一概念、换种表示」的场景:从输入字符串解析成数字、从对象里取出字段后继续用原名。它比 let guess_str = ...; let guess_num = ... 这种强行起名的写法干净得多。命名 wart 少了,读者跟踪概念的负担也轻了。

遮蔽的边界要心里有数:内层作用域里的遮蔽在离开作用域后失效,旧名字重新可见;遮蔽可以改变类型与可变性,但不会改变旧值的生命周期,旧值在该离开作用域时照常被析构。遮蔽还有一个不受欢迎的用法需要警惕:在长函数里隔了几十行才遮蔽同名变量,读代码的人会误以为还是旧值。规范的做法是遮蔽与被遮蔽的代码靠得足够近,或者干脆换名字。

把「遮蔽」和「可变绑定」放一起对比会更清楚:

维度 重新赋值(mut) 遮蔽(重新 let)
是否允许改变类型 不允许 允许
旧值何时清理 一直存活到作用域结束 立即不可访问
是否引入新内存位置 不引入 可能引入
语义 同一变量的状态演进 一个新概念顶替旧概念

理解这层差异后,看到同一段代码里两次 let x 就不会再误以为是忘记加 mut 了——那是作者有意在做概念替换。

三、常量与静态变量

常量用 const 声明,静态变量用 static 声明,它们与普通变量不是同心圆,注意区分。

声明方式 关键字 可变性 类型标注 生命周期
普通变量 let / let mut 可选 mut 可推导 所在作用域
常量 const 永远不可变 必须显式 整个程序,内联展开
静态变量 static 可加 mut(不安全) 必须显式 整个程序,固定地址
const MAX_POINTS: u32 = 10_000;
static GREETING: &str = "你好";

常量没有固定内存地址,编译器会把值内联到使用处,因此它必须是编译期可求值的常量表达式;静态变量有固定地址,适合需要稳定引用的全局数据。二者的命名惯例都是全大写加下划线。实际开发里 90% 的场景用 const 就够,只有需要可变全局或固定地址时才轮到 static,而 static mut 在多线程下不安全,基本不推荐手写。常量还有两点工程价值:其一它是真正的全局符号,任何模块都可以按路径引用,适合放系统级配置上限;其二编译器会对常量做内联与折叠,不会带来运行时查表开销。代价是常量的值不能用任何运行期计算,包括函数调用的结果——想跑一次计算再固定下来,那不是常量该干的活,而是一次变量绑定。

四、可变性怎么取舍

默认不可变不等于鼓励完全不可变。数值累加、缓冲区填充、状态机推进都需要 mut,硬要绕开可变反而写出更绕的代码。判断原则是:一个变量的值是否会随业务推进而改变,如果是,就明确写 mut;如果只是分阶段的中间结果,优先用遮蔽或新的声明。明确性换可读性,这笔交易在团队协作里很划算。

一个常见的转译思路是把「可变变量」换成「函数式链」:与其原地修改集合,不如用迭代器链生成新集合。后文讲迭代器时会展开这种写法,配合 Rust 的零成本抽象,这么写在性能上通常没有损失。当你发现一段代码需要三个以上的 mut 变量交错推进时,往往是数据结构没选好,值得退一步重新设计,而不是继续在可变性上打补丁。如果你正在重构一处复杂可变逻辑,也可以借助 Claude Code 使用指南 里的小步重构技巧,一步步把可变状态压缩到局部。

五、编译错误是最好的文档

本节涉及的三个声明错误,编译器都会精确指出位置并给出修复建议,值得记住它们的样子。

错误信息 触发原因 修复方向
cannot assign twice to immutable variable 改不可变变量 加 mut
cannot borrow ... as mutable 借用不可变变量为可变 改声明或改借用
expected type, found another type 遮蔽后类型不匹配 检查遮蔽处的显式标注

新手常把第二种错误当成 bug,其实是借用检查器在替你挡数据竞争。看到这些信息不必慌,后文「引用与借用」一章会把每一条错误的来龙去脉拆开讲清楚。实际写代码时一个高效的习惯是:先不加 mut 写,等编译器报错了再加。这本是偷懒做法,但它恰好与语言的默认设计同向,长期下来你的可变声明会自然收敛到真正必要的位置。如果团队里每个人都有这个习惯,代码库里 mut 的密度会成为可读性的晴雨表。几乎每条声明都要经过编译器检查,结果是代码在运行前就被筛过一遍可变性风险,这就是不可变默认在工程层面的复利。

延伸阅读

  • 理解变量声明后,下一节「基本数据类型」把整数、浮点、元组、数组的边界讲清楚,两者合起来才算补齐声明语法。
  • 若要挖文本输入里的结构化数据,正则表达式精进 的解析示例与本篇遮蔽用法可以配合使用,把捕获结果逐层遮蔽成目标类型会很顺手。
  • 当可变逻辑复杂到难以控制时,读读 Tauri 桌面开发指南 里的状态管理思路,其中前端与 Rust 状态分层的做法很有启发,能帮你把可变范围框在明确的边界之内。
  • 想验证本章概念,可以在本地建一个项目,把每段示例代码的编译错误都亲自触发一遍,错误信息的措辞值得记住。