变量、常量与遮蔽:可变性的取舍
几乎所有语言入门都从变量开始,但 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 状态分层的做法很有启发,能帮你把可变范围框在明确的边界之内。
- 想验证本章概念,可以在本地建一个项目,把每段示例代码的编译错误都亲自触发一遍,错误信息的措辞值得记住。
