第 2 章:变量与可变性——从 `final` 到 `let` 和 `mut` 的思维转变

在上一章,我们成功地运行了第一个 Rust 程序,并提出了一个核心问题:在没有 GC 的情况下,Rust 如何保证内存安全?答案指向了 Rust 的灵魂——“所有权”(Ownership)。

在深入那个庞大而精妙的系统之前,我们必须先掌握一个看似简单,却蕴含着 Rust 核心安全哲学的基础知识:变量的声明与赋值。对于在 Java 世界里畅游多年的我们来说,这似乎是“不值一提”的小事。但相信我,正是从这里开始,你将体会到第一次深刻的思维转变。

Rust 的方式:默认不可变

让我们在 cargo new variables 创建的新项目中的 main.rs 里写下几行非常简单的代码:

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6; // 尝试修改 x 的值
    println!("The value of x is: {}", x);
}

这段代码的意图非常明显:声明一个变量 x 并赋值为 5,打印它,然后把它修改为 6,再打印出来。在 Java 中,这是天经地义的操作。

然而,当我们运行 cargo run 时,迎接我们的不是成功的结果,而是一条鲜红的编译错误!

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         -
  |         |
  |         first assignment to `x`
  |         help: consider making this binding mutable: `mut x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

请仔细阅读这段错误信息。Rust 编译器不但精确地指出了错误的位置(x = 6;)和原因(cannot assign twice to immutable variable),甚至还贴心地给出了修复建议(help: consider making this binding mutable: mut x)。这就是我们常说的:“Rust 的编译器是你的良师益友”。

这个错误揭示了 Rust 的一个核心原则:变量默认是不可变的(Immutable by default)。

思维转变:从 finallet

现在,让我们暂停一下,回到我们熟悉的 Java。在 Java 中,如果你想实现同样的效果——让一个变量不可变——你会怎么做?

你会使用 final 关键字:

final int x = 5;
x = 6; // 这同样会在 Java 中导致编译错误

看到了吗?这正是思维转变的关键点:

  • 在 Java 中,变量默认是可变的。 你需要主动选择(Opt-in) 通过 final 关键字来获得不可变性。这是一种值得提倡的“良好实践”,但并非强制。
  • 在 Rust 中,变量默认是不可变的。 你需要主动选择(Opt-in) 通过一个关键字来获得可变性。这是一种“默认安全”的语言设计。

Rust 为什么要这么“固执”地选择默认不可变呢?这并非无事生非,而是出于深思熟虑的考量,这些考量对于编写大型、复杂的系统至关重要:

  1. 提高代码的可读性和可推理性: 当你阅读一段陌生的 Rust 代码时,只要看到 let x = ...,你就可以在脑海中打上一个标签:“这个变量的值在它的作用域内不会再变了”。这极大地减轻了你的心智负担,你不需要像在 Java 中那样,通读整个方法才能确定一个变量的值是否被中途修改。
  2. 为“无畏并发”奠定基石: 这是最重要的一点。如果一份数据是不可变的,那么在多线程环境下共享它就是绝对安全的。你根本不需要担心数据竞争(Data Race)的问题,因为没有任何线程能修改它。默认不可变性是 Rust 实现“无畏并发”的第一块,也是最重要的一块基石。
  3. 让编译器进行更激进的优化: 当编译器知道一个值不会改变时,它可以进行更多的优化操作,比如将值直接内联,从而产生更高效的机器码。

请求可变性:mut 关键字

那么,如果我确实需要一个可变的变量呢?(比如循环中的计数器)。Rust 当然提供了这个选项,这正是编译器提示我们的 mut 关键字。mut 是 mutable(可变的)的缩写。

让我们遵循编译器的建议,修复我们的代码:

fn main() {
    let mut x = 5; // 在变量名前加上 mut
    println!("The value of x is: {}", x);
    x = 6; // 现在,这行代码完全合法了!
    println!("The value of x is: {}", x);
}

再次 cargo run,程序完美运行,并输出:

The value of x is: 5
The value of x is: 6

在 Rust 的世界里,使用 mut 不仅仅是一个语法要求,它更是一个明确的意图表达。当你写下 mut 时,你是在告诉自己、告诉你的同事、也告诉编译器:“注意,这个变量的状态将会改变,请对它保持警惕。” 这种明确性使得代码的维护和重构都变得更加安全。

一个进阶话题:变量遮蔽(Shadowing)

谈到变量,Rust 还有一个有趣且强大的特性,叫做“遮蔽”(Shadowing),这在 Java 中是没有直接对应概念的。看下面的代码:

fn main() {
    let x = 5;

    let x = x + 1; // 这里,我们再次使用 let 声明了 x

    {
        let x = x * 2; // 在内部作用域,我们又声明了一次 x
        println!("The value of x in the inner scope is: {}", x); // 输出 12
    }

    println!("The value of x is: {}", x); // 输出 6
}

这段代码是完全合法的。这里发生了什么?我们使用 let 关键字重复声明了同名变量 x。第二次的 let x 创建了一个全新的变量,它“遮蔽”了第一个 x。在内部作用域中,第三个 let x 又遮蔽了第二个。当内部作用域结束,遮蔽也随之结束,x 的值回到了 6。

这和 mut 有什么本质区别?

  • let mut x = 5; x = 6; 是在改变同一个内存地址上存储的值,且变量的类型不能改变。
  • let x = 5; let x = x + 1;创建了一个全新的变量,它恰好和旧变量同名。因为是新变量,我们甚至可以改变它的类型!这在 Java 中是不可想象的。
let spaces = "   "; // spaces 是一个字符串切片 &str 类型
let spaces = spaces.len(); // spaces 现在被遮蔽为一个全新的变量,类型是 usize (一个整数)

这个特性非常实用,它允许我们在不引入新变量名(如 spaces_str, spaces_num)的情况下,对一个值进行一系列变换。

别忘了常量:const

最后,提一下常量(Constants)。它和 final 变量有些相似,但更加严格。

const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;

constlet 的不可变变量有什么区别?

  • const 必须在声明时注明类型(如 u32)。
  • const 只能被设置为一个常量表达式,而不能是任何需要在运行时才能计算出的值。它在编译期间就已被“写死”在程序里。
  • const 可以在任何作用域中声明,包括全局作用域。

你可以把它理解为 Java 中的 public static final int ...,是一个真正意义上的、全局的、编译期常量。

本章小结

我们今天深入探讨了 Rust 中最基础的构件——变量。但我们看到的远不止语法。我们见证了一场从 Java 到 Rust 的思维转变:

从“可变为常态,不可变为选择” 到 “不可变为常态,可变为选择”。

这个看似微小的差异,是 Rust 安全大厦的奠基石。它鼓励我们编写更可预测、更易于推理、且为并发编程做好了准备的代码。let 的不可变性给了我们信心,而 mut 则成了我们表达“此处需要特别注意”的清晰信号。

现在我们理解了变量本身的行为。但是,这些变量所“持有”的数据,比如一个简单的 i32,一个 String,在内存中又是如何存在的呢?当我们把一个变量赋值给另一个变量时,背后又发生了什么?这正是所有权系统开始闪耀光芒的地方。

在下一章 《数据类型——不只是 intString 中,我们将探索 Rust 的数据世界,并首次近距离观察,当数据与所有权规则相遇时,会碰撞出怎样有趣的火花。