变量与类型
本章把 Rust 的「语法地基」一次铺完:绑定(binding)与可变性、标量与复合类型、类型转换、函数与返回值、语句/表达式的区分、全部流程控制,以及模式匹配(pattern matching)。前置知识:环境搭建与工具链(会写
cargo new、会读rustc报错)。
本章目标
- 能说清
let、let mut、遮蔽(shadowing)与const/static各自的适用场景,并解释它们为什么不是同一种东西。 - 能默写整数/浮点/
bool/char的类型名、位宽与默认类型,能主动选择wrapping_*/checked_*/saturating_*/overflowing_*处理溢出。 - 能区分元组、数组
[T; N]、切片&[T]、String与&str的存储位置与所有权含义。 - 能写出带参数、带返回值、带发散(diverging)分支的函数,并理解「最后一个表达式即返回值」与分号陷阱。
- 能把
if/match/块当表达式使用,并用loop的break value、标签(label)与for的三种迭代形式写出地道循环。 - 能用
match/if let/let else/matches!完成穷尽的分支处理,并看懂non-exhaustive patterns类报错。
let 与可变性:默认不可变的设计
Rust 的绑定默认不可变(immutable)。这不是「建议」,而是类型系统的一部分:没有 mut 就无法取得可变权限。
rust
fn main() {
let x = 5;
println!("{x}");
// x = 6; // 取消注释:error[E0384]: cannot assign twice to immutable variable `x`
}💡 对照:Python/JS 里
x = 5之后随便改;Java 要写final int x = 5;Go 的变量一直可写。Rust 反过来——默认冻结,需要时才解冻。
mut 与遮蔽的本质区别
这是初学者最容易混淆的一对概念。
rust
fn main() {
// 方式 A:mut —— 同一块内存/同一个绑定被写入,类型不能变
let mut a = 1;
a += 1;
// a = "text"; // error[E0308]: mismatched types:mut 不改变类型
// 方式 B:遮蔽 —— 造一个全新绑定,旧绑定被「遮住」,类型可以变
let b = 1;
let b = b + 1; // 新绑定,类型仍是 i32
let b = b as f64; // 再遮蔽一次,类型变成 f64
let b = format!("{b}"); // 又变成 String
println!("{a} {b}");
}| 维度 | let mut x | let x = ...; 再 let x = ... |
|---|---|---|
| 绑定的数量 | 1 个 | 多个(旧的被遮蔽,仍在作用域末尾按序析构) |
| 类型能否改变 | 不能 | 能 |
能否取 &mut | 能 | 只有最新绑定可写,且需要 mut |
| 语义 | 「这个值之后会被改写」 | 「我换了一个值/换了含义」 |
| 典型用途 | 累加器、缓冲区、循环状态 | 类型转换、把 Option 解开、改变变量的「含义」 |
🧠 原理:遮蔽不是赋值,而是新绑定。旧绑定仍然存在,只是名字被覆盖,它会在作用域结束时被析构(drop)。因此在〈所有权〉一章会看到:
let s2 = s1;对String是移动(move),而let mut s2 = s1;只是把同一个值搬到了可写的绑定上。
遮蔽的作用域也遵循块规则:
rust
fn main() {
let n = 10;
{
let n = n * 2; // 只在这个块内生效
println!("inner: {n}"); // inner: 20
}
println!("outer: {n}"); // outer: 10
}⚠️ 陷阱:遮蔽会让后面的变量悄悄换类型,长函数里连续遮蔽
let data = ...会让读者迷惑。社区惯例是:只在紧接着的一两行内遮蔽,并且让新值语义上仍是「同一个概念」(例如let input = input.trim();)。
常量 const 与静态变量 static
rust
const MAX_LEVEL: u8 = 10; // 编译期常量,必须标注类型
static APP_NAME: &str = "demo"; // 静态变量,有固定地址
static mut COUNTER: u32 = 0; // 可变静态变量:危险,见下
fn main() {
// const 在字面量、数组长度、类型参数里都能用
const N: usize = 4;
let arr = [7u8; N];
println!("{MAX_LEVEL} {APP_NAME} {arr:?}");
// Rust 2024:访问 static mut 必须用 unsafe,且不能随便造引用
unsafe {
COUNTER += 1;
let snapshot = *&raw const COUNTER; // 用裸指针(raw pointer)读,避免静态可变引用
println!("{snapshot}");
}
}输出:
10 demo [7, 7, 7, 7]与1。
| 维度 | const | static | let |
|---|---|---|---|
| 求值时机 | 编译期(const 上下文) | 运行期启动时初始化 | 运行到该行时 |
| 是否有固定地址 | 没有(每个使用点内联,可能有多份副本) | 有(整个程序一份) | 有(栈或所属对象内) |
| 能否取引用 | 只能引用临时副本 | &'static T 合法 | 受生命周期限制 |
| 可否可变 | 否 | 只有 static mut(需 unsafe) | let mut |
| 典型用途 | 魔法数字、配置上限 | 全局只读表、AtomicUsize 计数器、FFI | 局部数据 |
为什么 static mut 危险:它带来的是全局可变状态,任何线程都能无同步地读写,编译器的借用检查(borrow checking)对它完全不起作用——检查只在函数内部做数据流分析,无法跨线程追踪谁在何时写了它。结果是数据竞争(data race),也就是未定义行为(undefined behavior)。Rust 2024 edition 把「创建 static mut 的引用」升级为硬错误(static_mut_refs lint 默认 deny),正是为了逼你改用 AtomicU32、Mutex<T> 或 OnceLock<T>。
🚀 进阶:需要全局可变状态时,正确顺序是——先问「能不能用
const/static只读」→ 再考虑AtomicUsize(只需计数/标志)→ 需要复合数据用Mutex<T>/RwLock<T>(可能配合OnceLock<T>做懒初始化)。
命名规范
Rust 编译器会用 non_snake_case、non_upper_case_globals 等 lint 主动纠正你:
| 对象 | 规范 | 例子 |
|---|---|---|
| 变量、函数、参数、模块、字段 | snake_case | total_price、parse_level |
类型、trait、enum 变体 | UpperCamelCase | HttpClient、Coin::Quarter |
常量、static | SCREAMING_SNAKE_CASE | MAX_LEVEL、DEFAULT_TIMEOUT_MS |
| 泛型参数 | 单个大写字母或 UpperCamelCase | T、K、V、Alloc |
| 生命周期参数 | 短小写,常为 'a | 'a、'de |
| 宏 | snake_case! | println!、matches! |
| 未使用但必须绑定的值 | 前缀 _ | _unused、_guard |
| 解构时忽略的字段 | 单个 _ | let (_, b) = pair; |
⚠️ 陷阱:
_x和_不等价。_x会绑定并立即析构(值还在,只是不警告);_是不绑定,对let _ = value;而言值会被立刻丢弃。〈所有权〉一章讲 RAII 时这个差别很关键。
标量类型:整数、浮点、bool、char
标量(scalar)指不可再分的单值类型。Rust 一共四种:整数、浮点数、bool、char。
整数
| 有符号 | 无符号 | 位宽 | 取值范围(有符号) | 取值范围(无符号) |
|---|---|---|---|---|
i8 | u8 | 8 | −128 ~ 127 | 0 ~ 255 |
i16 | u16 | 16 | −32768 ~ 32767 | 0 ~ 65535 |
i32 | u32 | 32 | ±约 21.4 亿 | 0 ~ 约 42.9 亿 |
i64 | u64 | 64 | ±约 9.22×10¹⁸ | 0 ~ 约 1.84×10¹⁹ |
i128 | u128 | 128 | ±约 1.7×10³⁸ | 0 ~ 约 3.4×10³⁸ |
isize | usize | 指针宽度(64 位平台即 64 位) | 随平台 | 随平台 |
rust
fn main() {
let a = 42; // 默认 i32
let b: u8 = 200;
let c = 5_000_000_000i64; // 超出 i32 必须显式后缀
println!("{} {} {}", a, b, c);
// usize 是「与指针同宽」的整数,索引、长度、容量一律用它
let len: usize = [1, 2, 3].len();
println!("{len}");
}💡 对照:Java 的
int固定 32 位、long64 位,没有无符号;Python 整数是任意精度(想要溢出还溢不出);C 的int宽度由实现决定。Rust 的做法是宽度写进类型名,且usize明确表示「用于索引和内存大小的整数」,让「索引必须和指针同宽」这件事显式化。
浮点、bool、char
rust
fn main() {
let f = 1.5; // 默认 f64
let g: f32 = 1.5; // 需要省内存/对接图形 API 时才用 f32
let ok = true;
let letter = '中'; // char 是 Unicode 标量值,不是字节
let emoji = '🦀';
println!("{f} {g} {ok} {letter} {emoji}");
println!("{} {}", size_of::<char>(), size_of::<f32>()); // 4 4
}f32:单精度,有效十进制数字远少于f64;f64:双精度,是默认选择。Rust 不隐式提升f32到f64,混用直接报类型错误。bool只有true/false,不能和整数互转(没有1 == true这回事)。char是 Unicode 标量值(Unicode scalar value),占 4 字节,覆盖U+0000..=U+D7FF与U+E000..=U+10FFFF;它不是「一个字节」,也不能直接当字符串用。用单引号'a'是char,双引号"a"是&str。
🧠 原理:为什么
char要 4 字节?因为 Rust 承诺char一定是合法的 Unicode 标量值,任何char都能安全地编码进 UTF-8。这换来两件事:字符串按字节索引会得到「半个字符」的风险被消灭(&s[0]直接编译不通过),代价是char比 C 的char大 3 倍。
整数溢出:debug panic、release 环绕
rust
fn main() {
let x: u8 = 255;
println!("{}", x + 1); // cargo run(debug)会 panic:attempt to add with overflow
}⚠️ 陷阱:
x + 1在cargo run(debug)下 panic,在cargo run --release下会环绕(wrap)成0。这是 Rust 的刻意设计:开发期把错误暴露出来,发布期不让它变成未定义行为。
| 模式 | 溢出时的行为 |
|---|---|
cargo run / cargo test(debug,overflow-checks = true) | panic:attempt to add with overflow |
cargo run --release(默认 overflow-checks = false) | 二进制补码环绕(wrapping),不是 UB |
编译期常量表达式(如 let x: u8 = 300;) | 直接编译错误:literal out of range for u8 |
想要明确语义就别依赖模式开关,用四种显式 API:
rust
fn main() {
let m: u8 = 250;
// 1) checked_*:溢出返回 None,绝不 panic
println!("{:?}", m.checked_add(10)); // None
// 2) wrapping_*:二进制补码环绕
println!("{}", m.wrapping_add(10)); // 4
// 3) saturating_*:钉在类型的最大值/最小值
println!("{}", m.saturating_add(10)); // 255
// 4) overflowing_*:返回 (结果, 是否溢出)
let (v, overflowed) = m.overflowing_add(10);
println!("{v} {overflowed}"); // 4 true
}🧠 原理:
checked_*返回Option<T>,所以在const上下文不可用;saturating_*适合音频增益、颜色分量这类「夹紧」语义;overflowing_*是实现大整数/加密算法的原语。记住判断口径:需要「绝不 panic 且不想环绕」就checked_*,需要「数学上等价于取模 2ⁿ」就wrapping_*。
⚠️ 陷阱:
250u8.wrapping_add(10)不会 panic,但(250u8 + 10).wrapping_add(0)会——因为括号里的+先在 debug 下 panic 了。wrapping_*是方法级保证,不会传染给表达式里的+。
复合类型:元组、数组、切片与字符串初探
复合类型把多个值组合起来。Rust 的两大基础复合类型是元组(tuple)和数组(array)。
元组
rust
fn main() {
let t: (i32, char, f64) = (1, 'a', 2.5);
// 解构(destructuring)
let (p, q, r) = t;
// 按索引访问
println!("{} {} {} {}", t.0, p, q, r);
// 单元类型 ():零个元素的元组 = 「没有有意义的值」
let unit: () = ();
// 返回 () 的函数
fn nothing() {}
nothing();
println!("{unit:?}");
}输出:
1 1 a 2.5、()。
💡 对照:Python 的 tuple 是「不可变列表」,可以循环、能变长;Rust 元组是定长异构的记录,不能遍历,元素类型可以各不同。JS 没有真正的定长异构类型(数组可以塞任何东西)。Rust 里需要「有名字的元组」时应改用结构体(struct),可读性更好——
user.0远不如user.id。
数组 [T; N]、切片 &[T]
rust
fn main() {
let arr: [i32; 4] = [1, 2, 3, 4];
let zeros = [0u8; 4]; // 重复表达式:4 个 0
println!("{} {:?}", arr.len(), zeros);
// 索引越界:运行时 panic(编译期能算出来就直接报错)
println!("{}", arr[3]);
// println!("{}", arr[4]); // panic: index out of bounds: the len is 4 but the index is 4
// 切片:指向连续内存的「视图」,不含长度以外的元数据
let part: &[i32] = &arr[1..3];
println!("{part:?}"); // [2, 3]
println!("{}", part.len()); // 2
// &str 就是「指向 UTF-8 字节」的切片
let text: &str = "hello";
let head: &str = &text[0..2];
println!("{head}"); // he
}| 维度 | 数组 [T; N] | 切片 &[T] / &str | Vec<T> / String |
|---|---|---|---|
| 长度 | 类型的一部分,编译期已知 | 运行期确定(胖指针:指针 + 长度) | 运行期确定,可增长 |
| 存储 | 变量就在栈上(或内联在结构体里) | 只是借用,不拥有数据 | 数据在堆上 |
| 能否改变长度 | 不能 | 不能(视图) | 能(push/pop/truncate) |
| 所有权 | 拥有 | 不拥有(借来的) | 拥有,丢弃时释放 |
| 典型用途 | 定长缓冲、查表 | 函数参数(接受的输入形式的并集) | 动态数据集 |
🧠 原理:切片是胖指针(fat pointer)——两个字(word):地址 + 长度。所以
size_of::<&[i32]>() == 16(64 位平台)。这也是为什么给函数传&[T]比传&Vec<T>更好:&[T]能同时接受数组、Vec、其他切片,而&Vec<T>只能接受Vec。
索引与切片为什么会 panic:Rust 的 a[i] 会插入边界检查(bounds check)。这是 Rust 安全承诺的代价之一——换来的是「绝不可能因为索引越界读到别的内存」。编译器通常能把循环里的检查优化掉(消除边界检查),所以地道的写法是 for v in &arr 而不是 for i in 0..arr.len() { arr[i] }。
String vs &str:先认识字面量的身份
rust
fn main() {
let literal: &'static str = "hello"; // 字面量:编译进二进制,类型是 &'static str
let owned: String = String::from("hi"); // 堆上分配的、可增长的字符串
let also_owned: String = "hi".to_string();
println!("{literal} {owned} {also_owned}");
println!("{}", owned == also_owned); // String 之间可比较
println!("{}", owned.as_str() == "hi"); // 借用成 &str 再比较
}输出:
hello hi hi、true、true。
本章只需要记住三条:
&str是借用(一个胖指针),String是拥有(堆缓冲区 + 长度 + 容量)。- 想从借用变拥有:
.to_string()或String::from(...);想从拥有变借用:.as_str()或&s。 - 比较时借用类型可以混搭:
owned == also_owned(String对String)和owned.as_str() == "hi"(&str对&str)都能直接写,因为PartialEq把这些组合都实现好了。
🚀 进阶:
String/&str的完整规则(UTF-8 边界、Deref强制转换、format!)留到 所有权与借用与字符串专章。
类型转换:as、From/Into、TryFrom、parse
Rust 没有隐式数值提升:i32 + i64、u8 as i32 必须显式。这条规则消灭了 C 的整型提升陷阱和 JS 的 "1" + 1 == "11"。
rust
fn main() {
let a: i32 = 7;
let b: i64 = 1000;
// let c = a + b; // error[E0308]: mismatched types
let c = i64::from(a) + b; // 无损且显式
println!("{c}");
let n: u32 = "42".parse().expect("不是合法数字"); // parse 返回 Result
println!("{n}");
let f = "3.5".parse::<f64>().expect("不是合法浮点数");
println!("{f}");
let small = u8::try_from(n); // 可能失败:返回 Result<u8, TryFromIntError>
println!("{small:?}"); // Ok(42)
let too_big = u8::try_from(300u32);
println!("{too_big:?}"); // Err(TryFromIntError(()))
}as 的截断与环绕语义
rust
fn main() {
println!("{}", 300u32 as u8); // 44:取低 8 位,静默截断
println!("{}", -1i32 as u32); // 4294967295:二进制补码环绕
println!("{}", 3.99f64 as i32); // 3:向零截断
println!("{}", 1000i64 as i8); // -24:截断 + 环绕
}⚠️ 陷阱:
as永远不会失败、永远不会 panic,也默认不会被 lint 拦下。x as u8是「我知道可能丢数据」的强断言,不是安全网。要避免静默丢数据,用u8::try_from(x)、x.try_into()或checked_*。另外f64 as i32在溢出时(如1e30 as i32)会饱和到i32::MAX,这是浮点转整数特有的规则。
rust
fn main() {
let v: i32 = 300;
let safe: u8 = u8::try_from(v).unwrap_or(u8::MAX);
println!("{safe}"); // 255
}说明:教学示例里可以用
unwrap_or/expect保持简短,但生产代码中要按语义选择——能给出兜底值用unwrap_or/unwrap_or_else,要向上传播错误用?(见 错误处理)。
From/Into/TryFrom 的分工
rust
fn main() {
let from = u32::from(7u8); // From:保证成功
let into: u32 = 7u8.into(); // Into 由 From 自动提供
println!("{from} {into}");
// TryFrom:可能失败,返回 Result
let ok: Result<u8, _> = u8::try_from(200u32);
let bad: Result<u8, _> = u8::try_from(2000u32);
println!("{ok:?} {bad:?}");
}输出:
7 7、Ok(200) Err(TryFromIntError(()))。
| 转换方式 | 失败可能 | 失败时行为 | 适用场景 |
|---|---|---|---|
x as T | 有 | 静默截断/环绕/饱和 | 位运算、哈希、已证明安全的窄化 |
T::from(x) / x.into() | 无 | 不可能失败 | 无损拓宽(u8→u32、&str→String) |
T::try_from(x) / x.try_into() | 有 | 返回 Result | 有损或不确定的转换 |
s.parse::<T>() | 有 | 返回 Result | 从文本解析 |
🧠 原理:
From/Into/TryFrom是trait(类似 Java 的接口、Python 的协议)。因为impl From<A> for B自动给出impl Into<B> for A,所以实现方只需写From,调用方两种都能用。这套机制让「转换」变成可被泛型约束的能力(fn f<T: Into<String>>(x: T)),这是 Rust 泛型的日常用法。
字面量写法:下划线、进制、指数、字节字面量
rust
fn main() {
let dec = 1_000_000; // 下划线只是可读性分隔符,对值无影响
let hex = 0xff;
let oct = 0o755;
let bin = 0b1010_1010;
let exp = 1e3; // 1000.0(浮点)
let byte = b'A'; // u8 的 65
let ch = 'A'; // char
let ws = b'\n'; // 字节字面量支持转义
println!("{dec} {hex} {oct} {bin} {exp} {byte} {ch} {ws}");
}输出:
1000000 255 493 170 1000 65 A 10。
⚠️ 陷阱:
1e3是f64的1000.0,不是整数 1000;b'A'是u8的 65,不能和char混用(b'A' == 'A'编译不通过)。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。
- 本章的移动语义在下一章所有权与借用完整展开。
- 全站统一的中英术语译法见附录 A · 术语中英对照。