3、基础语法与数据类型:变量与不可变性、常量、标量类型与复合类型

Rust 作为一门以内存安全和并发安全为核心目标的系统级编程语言,其类型系统和变量设计处处体现着“显式优于隐式”的工程哲学。本章将深入剖析 Rust 中最基础但最关键的语法单元:变量与不可变性、常量、标量类型(整数、浮点、布尔、字符)以及复合类型(元组、数组)。理解这些概念不仅是编写 Rust 代码的前提,更是理解所有权(Ownership)与借用(Borrowing)机制的基石。我们将从设计原理出发,结合代码案例与常见误区,帮助你建立对 Rust 静态类型系统的直觉。

Rust 基础语法与数据类型全景图 变量与不可变性 • let 默认不可变 • mut 显式可变 • 编译期检查保证安全 • 变量遮蔽 (Shadowing) 核心:不可变性是默认选择 常量 const • 编译期求值 • 必须显式标注类型 • 全局作用域可用 • 不可被 mut 修饰 与 let 的本质区别 标量类型 • 整数: i8-u128, isize/usize • 浮点: f32, f64 • 布尔: bool (true/false) • 字符: char (Unicode 4字节) 单一值类型 复合类型 元组 (Tuple) • 固定长度,可含不同类型 • 模式匹配解构 • 访问: t.0, t.1 ... 数组 (Array) • 固定长度,同类型 • 栈上分配,连续内存 类型推断与类型标注 • let x = 42; → 推断为 i32 • let y: u64 = 42; → 显式标注 • 编译器根据上下文推断 • 复杂场景需显式标注 平衡可读性与灵活性 所有类型在编译期确定,Rust 无隐式类型转换

3.1 变量与不可变性:安全性的第一道防线

核心原理:为什么默认不可变?

在绝大多数编程语言中,变量默认是可变的(如 JavaScript 的 let、Python 的赋值语句)。Rust 反其道而行之,默认情况下变量是不可变(immutable)的。这一设计并非标新立异,而是直接服务于 Rust 的核心承诺——内存安全。当数据不可变时,编译器可以做出更强的保证:数据不会被意外修改,多线程环境下无需加锁即可安全共享。这从根源上消除了整类数据竞争(Data Race)问题。

当你使用 let 声明一个变量时,Rust 编译器会检查该变量是否被重新赋值。如果尝试修改一个不可变变量,代码将无法通过编译。这种“编译期报错”虽然初看繁琐,实则是将潜在 bug 扼杀在摇篮中。

关键语法对比:
// 不可变变量(默认)
let x = 5;
x = 6; // 编译错误:cannot assign twice to immutable variable

// 可变变量(显式声明)
let mut y = 5;
y = 6; // 正确,y 现在是 6

操作流程:变量遮蔽(Shadowing)

Rust 允许使用 let 关键字重新声明同名变量,这被称为变量遮蔽。遮蔽与 mut 有本质区别:遮蔽创建了一个全新的变量,而 mut 只是允许修改原变量的值。遮蔽常用于类型转换或临时计算,且不破坏不可变性保证。

let spaces = "   ";          // 字符串类型
let spaces = spaces.len();    // 遮蔽为 usize 类型,合法!
// 如果使用 mut,则无法改变类型:
// let mut spaces = "   ";
// spaces = spaces.len();     // 编译错误:类型不匹配

遮蔽的典型应用场景包括:数值计算中的中间步骤、格式化处理、以及需要临时改变变量语义的算法实现。

案例拆解:一个简单的计数器

假设我们需要实现一个循环计数器,统计 1 到 10 的累加和。初学者可能会这样写:

// 错误示范
let sum = 0;
for i in 1..=10 {
    sum += i;  // 编译错误!sum 不可变
}

// 正确做法一:使用 mut
let mut sum = 0;
for i in 1..=10 {
    sum += i;
}
println!("Sum = {}", sum);  // 输出 55

// 正确做法二:使用遮蔽(函数式风格)
let sum = (1..=10).fold(0, |acc, x| acc + x);
println!("Sum = {}", sum);  // 输出 55

这个案例展示了 Rust 的两种编程风格:命令式(使用 mut)和函数式(使用迭代器与遮蔽)。在实际项目中,优先推荐函数式风格,因为它更安全、更易并发。

常见误区与总结

  • 误区一:认为 let mut 是常态。实际上,Rust 社区约定:仅在必要时使用 mut,默认保持不可变。
  • 误区二:混淆遮蔽与赋值。遮蔽是重新绑定,旧变量被“隐藏”而非“修改”。
  • 误区三:忽略不可变性的性能影响。不可变变量在栈上分配,与可变变量性能相同,不会产生额外开销。
经验提醒:在编写 Rust 代码时,先尝试用不可变变量实现逻辑。如果编译器报错,再考虑是否真的需要 mut。这种“默认安全”的思维方式能显著降低代码的认知负担。

3.2 常量(const):编译期的保证

核心原理:常量与变量的本质区别

常量使用 const 关键字声明,具有以下特性:

特性常量(const)不可变变量(let)
声明位置全局或局部作用域局部作用域为主
类型标注必须显式标注可推断
值来源编译期常量表达式运行时计算
内存位置编译期内联,无固定内存栈上分配
可变性永远不可变默认不可变,可加 mut

常量的核心价值在于:它提供了一种编译期保证,确保某个值在整个程序生命周期内保持不变。编译器会将常量直接内联到使用位置,因此常量不会占用运行时内存。

操作流程:定义与使用常量

const MAX_POINTS: u32 = 100_000;  // 下划线可读性分隔符
const PI: f64 = 3.14159;
const APP_NAME: &str = "Rust Course";

fn main() {
    println!("{}", MAX_POINTS);
    // MAX_POINTS = 200_000;  // 编译错误:cannot assign to this expression
}

注意常量命名规范:全大写字母加下划线。常量表达式必须是编译器可求值的,不能包含函数调用(除 const fn 外)或运行时操作。

实战场景:配置参数管理

在系统编程中,常量常用于定义硬件寄存器地址、协议魔数、配置阈值等。例如,一个网络服务的超时配置:

const DEFAULT_TIMEOUT_MS: u64 = 5_000;
const MAX_RETRY_COUNT: u32 = 3;
const BUFFER_SIZE: usize = 1024 * 1024;  // 1MB

fn process_request() {
    // 使用常量,避免魔法数字
    let timeout = DEFAULT_TIMEOUT_MS;
    // ...
}

使用常量而非硬编码数字,可以集中管理配置,便于维护和审计。

常见误区与总结

  • 误区一:试图用 const mut 声明可变常量。Rust 不允许,常量永远不可变。
  • 误区二:在常量中使用非 const fn 函数调用。这会导致编译错误。
  • 误区三:将大数组定义为常量。常量会内联到每个使用位置,可能导致代码膨胀;大数组应使用 static。
风险提示:虽然常量在编译期求值,但过度使用全局常量可能导致代码耦合。建议将相关常量组织到模块或结构体中,而非散落各处。

3.3 标量类型:单一值的精确表达

核心原理:Rust 的标量类型体系

标量类型表示单个值。Rust 提供四种标量类型:整数、浮点数、布尔和字符。每种类型都有明确的位宽和取值范围,这保证了跨平台行为的一致性。

类型位宽取值范围典型用途
i8 / u88 位-128~127 / 0~255字节数据、小计数
i16 / u1616 位±32767 / 0~65535端口号、Unicode 码点
i32 / u3232 位±2^31 / 0~2^32-1默认整数类型
i64 / u6464 位±2^63 / 0~2^64-1大数计算、时间戳
isize / usize平台相关指针大小数组索引、内存大小
f3232 位约 ±3.4e38图形学、科学计算
f6464 位约 ±1.8e308默认浮点类型
bool1 字节true / false条件判断
char4 字节Unicode 标量值单个字符

操作流程:类型选择与转换

Rust 是强类型语言,不会进行隐式类型转换。例如,i32 不能直接赋值给 u32。必须使用 as 关键字或 From/TryFrom trait 进行显式转换。

let a: i32 = -5;
let b: u32 = a as u32;  // 显式转换,注意负数转换会回绕
let c: f64 = 3.14;
let d: i32 = c as i32;  // 截断为 3

// 安全的转换方式
let e: u32 = 42;
let f: i64 = e.into();  // 使用 Into trait

整数溢出在 Rust 中也有明确行为:debug 模式下 panic,release 模式下回绕(wrapping)。可以使用 wrapping_add、saturating_add 等方法显式控制溢出行为。

案例拆解:字符与编码处理

Rust 的 char 类型是 Unicode 标量值,占 4 字节。这与 C/C++ 的 1 字节 char 有本质区别。处理字符串时,需要注意 UTF-8 编码与 char 的转换:

let heart = '❤';  // 4 字节 Unicode 字符
let c = 'A';       // 4 字节,但内容为 ASCII

// 字符串与字符的转换
let s = "hello";
let first_char = s.chars().next().unwrap();  // 'h'
let char_vec: Vec<char> = s.chars().collect();  // ['h','e','l','l','o']

// 字符转数字
let digit = '9';
let num = digit.to_digit(10).unwrap();  // 9

这个案例展示了 Rust 对 Unicode 的一等公民支持,避免了传统 C 语言中字符编码混乱的问题。

常见误区与总结

  • 误区一:依赖隐式类型转换。Rust 中 let x = 3.14 + 1; 会编译错误,因为 f64 和 i32 不能直接相加。
  • 误区二:忽略整数溢出。在 release 模式下,溢出会静默回绕,可能导致逻辑错误。
  • 误区三:混淆 char 与 u8。在 Rust 中,char 是 Unicode 标量值,而 u8 是字节。处理二进制数据时用 u8,处理文本时用 char。
经验提醒:默认使用 i32 和 f64,除非有明确的性能或内存需求。编译器会针对这些类型做最佳优化。

3.4 复合类型:元组与数组

核心原理:组合多个值

复合类型将多个值组合成一个整体。Rust 提供两种基本的复合类型:元组(Tuple)和数组(Array)。两者都拥有固定长度,在栈上分配,但用途不同:元组可包含不同类型,数组只能包含相同类型。

特性元组 (Tuple)数组 (Array)
元素类型可以不同必须相同
长度固定,编译期确定固定,编译期确定
访问方式t.0, t.1, ...arr[0], arr[1], ...
内存布局连续,可能填充对齐连续,无填充
典型用途返回多个值、临时分组固定大小集合、查找表

操作流程:元组的创建与解构

// 创建元组
let tup: (i32, f64, char) = (500, 6.4, 'a');

// 模式匹配解构
let (x, y, z) = tup;
println!("x={}, y={}, z={}", x, y, z);  // x=500, y=6.4, z=a

// 索引访问
let first = tup.0;  // 500
let second = tup.1; // 6.4

// 单元元组(空元组)
let unit = ();  // 表示函数无返回值

元组特别适合函数返回多个值。例如,一个除法函数同时返回商和余数:

fn divmod(a: i32, b: i32) -> (i32, i32) {
    (a / b, a % b)
}
let (q, r) = divmod(17, 5);
// q=3, r=2

操作流程:数组的声明与遍历

// 创建数组
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0; 10];  // 10 个 0,等价于 [0,0,0,0,0,0,0,0,0,0]

// 访问元素
let first = arr[0];  // 1
// 越界访问会 panic
// let invalid = arr[10];  // 运行时错误:index out of bounds

// 遍历数组
for element in arr.iter() {
    println!("{}", element);
}

// 获取切片(引用数组的一部分)
let slice = &arr[1..3];  // [2, 3]

数组的越界检查在运行时进行,这是 Rust 内存安全的重要体现。与 C 语言不同,Rust 不会允许越界读写。

实战场景:固定大小缓冲区

在嵌入式或网络编程中,固定大小的缓冲区非常常见。数组提供了栈上分配的连续内存,性能极佳:

const BUFFER_SIZE: usize = 1024;
let mut buffer: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];

// 模拟从网络读取数据
fn read_from_socket(buf: &mut [u8]) -> usize {
    // 假设读取了 100 字节
    for i in 0..100 {
        buf[i] = i as u8;
    }
    100
}

let bytes_read = read_from_socket(&mut buffer);
println!("Read {} bytes", bytes_read);

这个案例展示了数组在底层 I/O 中的应用。使用数组而非 Vec(动态数组),可以避免堆分配,提高实时性。

常见误区与总结

  • 误区一:试图改变数组长度。数组长度是类型的一部分,[i32; 5] 和 [i32; 6] 是不同类型。
  • 误区二:忽略元组的可读性。当元组元素过多时(超过 3-4 个),建议使用结构体(struct)替代。
  • 误区三:在需要动态长度时使用数组。应使用 Vec<T> 代替。
风险提示:数组的 Index 操作在越界时会 panic,这会导致程序崩溃。在不可信输入场景下,务必先检查索引范围或使用 get() 方法返回 Option。

3.5 类型推断与类型标注:平衡灵活性与明确性

核心原理:编译器的智能推断

Rust 编译器具备强大的类型推断能力,能够在大多数情况下自动推导变量类型。这得益于 Hindley-Milner 类型推断算法的变体。然而,推断并非万能,在某些场景下必须显式标注类型。

// 推断示例
let x = 42;          // 推断为 i32(默认整数类型)
let y = 3.14;        // 推断为 f64(默认浮点类型)
let z = true;        // 推断为 bool
let s = "hello";     // 推断为 &str(字符串切片)

// 需要标注的场景
let num: u64 = "42".parse().unwrap();  // parse 方法需要类型标注
let arr: [i32; 3] = [1, 2, 3];         // 数组类型包含长度,需标注

操作流程:类型标注的决策指南

  1. 默认类型规则:未标注的整数默认为 i32,浮点默认为 f64。
  2. 上下文推断:编译器会根据后续使用方式推断类型。例如,let x = 5; 后如果执行 x.abs(),则推断为有符号类型。
  3. 必须标注的场景:类型转换(parse)、泛型函数调用、空容器初始化(Vec::new())。
  4. 可读性优先:在公共 API、复杂表达式中,建议显式标注类型,提高代码自文档化程度。

案例拆解:类型推断的边界

考虑以下代码,它展示了类型推断的局限:

// 编译错误示例
let guess = "42".parse().unwrap();
// 错误:type annotations needed
// 编译器无法确定 parse 的目标类型

// 正确做法
let guess: u32 = "42".parse().unwrap();
// 或
let guess = "42".parse::<u32>().unwrap();  // 使用 turbofish 语法

这个案例说明,当类型信息无法从上下文推导时,必须显式标注。turbofish 语法 ::<> 用于在表达式级别指定泛型参数。

常见误区与总结

  • 误区一:过度依赖类型推断,导致代码难以阅读。在复杂函数中,显式标注类型是良好的工程实践。
  • 误区二:忽略默认类型规则。例如,let x = 1; 在 64 位系统上是 i32,而非 i64。
  • 误区三:在性能敏感代码中使用不匹配的类型。例如,用 i64 存储 u8 范围的值,浪费内存。
经验提醒:使用 IDE(如 rust-analyzer)可以实时查看类型推断结果。在团队协作中,建议在公共接口处显式标注类型,内部实现可依赖推断。

3.6 综合实战:构建一个简单的温度转换工具

为了巩固本章知识,我们实现一个温度转换工具,涵盖变量、常量、标量类型和复合类型的使用。

const FREEZING_POINT_F: f64 = 32.0;
const SCALE_FACTOR: f64 = 5.0 / 9.0;

/// 将华氏温度转换为摄氏温度
fn fahrenheit_to_celsius(f: f64) -> f64 {
    (f - FREEZING_POINT_F) * SCALE_FACTOR
}

/// 将摄氏温度转换为华氏温度
fn celsius_to_fahrenheit(c: f64) -> f64 {
    c / SCALE_FACTOR + FREEZING_POINT_F
}

fn main() {
    // 测试数据:元组数组,包含 (华氏, 摄氏) 预期值
    let test_cases: [(f64, f64); 3] = [
        (32.0, 0.0),   // 冰点
        (212.0, 100.0), // 沸点
        (98.6, 37.0),  // 人体体温
    ];

    for (f, expected_c) in test_cases.iter() {
        let actual_c = fahrenheit_to_celsius(*f);
        let status = if (actual_c - *expected_c).abs() < 0.01 {
            "PASS"
        } else {
            "FAIL"
        };
        println!("{}°F -> {:.2}°C (expected {:.2}) [{}]", 
                 f, actual_c, expected_c, status);
    }

    // 反向转换验证
    let c_temp: f64 = 25.0;
    let f_temp = celsius_to_fahrenheit(c_temp);
    println!("{:.2}°C -> {:.2}°F", c_temp, f_temp);
}

这个案例综合运用了:const 定义常量、f64 浮点运算、元组数组存储测试数据、for 循环遍历、if 条件判断、格式化输出。通过这个工具,你可以看到 Rust 类型系统如何保证数值计算的准确性,以及复合类型如何组织数据。

运行结果:

32°F -> 0.00°C (expected 0.00) [PASS]
212°F -> 100.00°C (expected 100.00) [PASS]
98.6°F -> 37.00°C (expected 37.00) [PASS]
25.00°C -> 77.00°F

通过本章的学习,你已经掌握了 Rust 最基础的语法元素。这些知识是后续学习所有权、结构体、枚举和模式匹配的基石。建议你动手修改上述代码,尝试不同的数据类型和转换逻辑,加深对 Rust 类型系统的理解。