Rust 基础:变量、类型与转换的工程选择
课程概览 · 第 1 章
学 Rust 的第一个真实障碍不是新语法,而是默认选择:哪些值该可变、类型标注写在哪里、数值转换在哪个边界必须受检。这些选择做错了,代码照样能编译,但接口会变贵、错误会推迟出现。本章用任务队列 CLI 里的两个小函数,把这些默认选择一次立起来;文本数据(String 与 &str)足够重要,独立成第 2 章。
学习目标与默认选择
学完本章你能:
- 解释
let、mut、遮蔽(shadowing)各自控制什么; - 在解析/转换的边界处写出显式类型标注;
- 区分
const与运行期一次性/全局初始化; - 用
TryFrom而不是as处理可能超范围的数值转换。 - 区分
&T与&mut T,说出它们与 C++ 引用的关键差别。
默认写法:
| 需求 | 写法 | 说明 |
|---|---|---|
| 不需要修改 | let value = ...; | 默认不可变,减少意外修改 |
| 需要重新绑定值 | let mut value = ...; | mut 属于绑定,不属于值本身 |
| 同名新值、可能是新类型 | let value = ...;(遮蔽) | 转换后旧名复用,避免 value_str 这类后缀 |
| 编译期常量 | const LIMIT: usize = 100; | 必须标注类型,内联进代码 |
| 运行期一次性全局初始化 | OnceLock | 本章不展开,见第 19 章;优先避免全局可变状态 |
| 可能超范围的数值转换 | u16::try_from(x) | 失败变成 Result,不静默截断 |
绑定:let、mut 与遮蔽
let 建立绑定:一个名字 + 一个值 + 一段作用域。mut 修饰的是绑定,声明"这个名字可以再被赋值"。遮蔽(shadowing)则建立新绑定,类型可以变。
1 | |
选择规则:转换之后旧值没用了,用遮蔽;同名的旧值还要用,就换个名字。不要用 mut 存"稍后不同类型的值"–那是遮蔽的场景。
声明的完整形态
let 语句的一般形式是 let 模式: 类型 = 初值;。名字必须有,其余部分按场景取舍:
| 写法 | 何时使用 |
|---|---|
let x = expr; | 默认:类型可由右侧推断 |
let x: T = expr; | 推断失败,或想在边界处固定类型 |
let x: T; 之后再 x = expr; | 值要等分支/循环走到某处才产生 |
let (a, b) = expr; | 声明处直接解构 |
let _ = expr; | 只关心副作用,丢弃值 |
类型标注的位置在名字之后(x: T),与 C++/Java 的 T x 相反。原因:推断是常态,标注是补充,可省略的部分放后面,读代码时先看到名字。同样的位置规则适用于函数参数(input: &str)与返回值(-> u16)。
1 | |
延迟初始化的两条约束:使用前必须已赋值,且不可变绑定只允许赋值一次。编译器逐路径检查,任何一条路径上存在"先读后赋"都直接拒绝编译——所以 Rust 没有"未定义值",也不需要 null 占位:
1 | |
注意区分:延迟初始化是同一绑定的首次赋值,遮蔽是新建绑定。需要多次赋值时配合 let mut,不要靠遮蔽模拟。
let 左边是模式而非单纯的名字,元组、数组都可以在声明处拆开(模式匹配的完整规则在第 6 章):
1 | |
命名约定:变量用 snake_case(task_title),const/static 用 SCREAMING_SNAKE_CASE(MAX_RETRIES),类型用 UpperCamelCase。故意不使用的值写 _name 或 _,语义见"常见误区"。
类型:标量、复合与标注的位置
标量类型(i32/u64/f64/bool/char)与复合类型(元组、数组)的推断通常够用。显式标注出现在推断失败或边界敏感的位置:
基本变量类型速查表
整数类型
| 类型 | 位宽 | 可表示范围 | 示例 | 常见用途 |
|---|---|---|---|---|
i8 | 8 位 | -128..=127 | let delta: i8 = -12; | 小范围、有正负的数值 |
i16 | 16 位 | -32_768..=32_767 | let temperature: i16 = -20; | 协议字段、较小范围的数值 |
i32 | 32 位 | 约 ±21 亿 | let retries: i32 = 3; | 默认有符号整数;一般业务计算 |
i64 | 64 位 | 约 ±922 京 | let timestamp: i64 = 1_725_000_000; | 时间戳、大整数、数据库数值 |
i128 | 128 位 | 约 ±1.7 × 10³⁸ | let huge: i128 = 1_000_000_000_000_000_000; | 极大整数计算,较少使用 |
isize | 与平台指针等宽 | 64 位机器通常为 64 位 | let offset: isize = -1; | 指针偏移、底层接口;普通业务很少直接选它 |
u8 | 8 位 | 0..=255 | let byte: u8 = b'A'; | 字节、二进制数据、颜色通道 |
u16 | 16 位 | 0..=65_535 | let port: u16 = 8080; | 网络端口、协议字段 |
u32 | 32 位 | 约 0..=42 亿 | let count: u32 = 42; | 明确非负且范围固定的计数 |
u64 | 64 位 | 约 0..=1844 京 | let id: u64 = 100_000; | 标识符、较大的非负计数 |
u128 | 128 位 | 约 0..=3.4 × 10³⁸ | let mask: u128 = 1; | 超大无符号整数,较少使用 |
usize | 与平台指针等宽 | 64 位机器通常为 64 位 | let index: usize = 0; | 数组/切片索引、集合长度 |
其他标量类型
| 类型 | 示例 | 说明 |
|---|---|---|
f32 | let ratio: f32 = 0.75; | 32 位浮点数;内存或与外部接口兼容有要求时使用 |
f64 | let price: f64 = 19.99; | 64 位浮点数,也是未标注浮点字面量的默认类型;不适合精确金额 |
bool | let enabled = true; | 只能为 true 或 false,用于条件判断 |
char | let initial = '中'; | 一个 Unicode 标量值,使用单引号;不是一个字节 |
() | let done = (); | 单元类型,表示“没有有意义的值”;函数未写返回值时默认返回它 |
文本与复合类型
| 类型 | 示例 | 说明 |
|---|---|---|
&str | let name = "rust"; | 借用的 UTF-8 文本;适合只读参数和字符串字面量,不拥有数据 |
String | let name = String::from("rust"); | 拥有的、可增长的 UTF-8 文本;适合保存、拼接和返回独立结果 |
(T, U, ...) | let point = (10, 20); | 元组:字段可为不同类型,按位置访问或解构 |
[T; N] | let ports = [80, 443]; | 数组:元素类型相同,长度在编译期固定 |
&[T] | let first_two = &ports[..]; | 切片:借用连续的一段元素,长度可在运行期变化 |
工程上通常先选 i32、bool、&str 和 String;只有需求明确时再选择特定整数位宽。索引和集合长度使用 usize,网络端口使用 u16,金额不要直接用 f32 或 f64。
1 | |
元组:固定位置的一组异构值
元组适合把数量固定、字段类型可能不同的几个值放在一起。它的类型由字段类型、顺序和字段数量共同决定:(u16, bool) 与 (bool, u16)、(u16,) 都是不同类型。数组则要求所有元素同类型;字段有稳定业务含义、会被多个地方使用时,改用第 7 章的命名结构体,不要让调用者记住 .0、.1 分别代表什么。
创建元组后有两种读取方式:临时只要一个字段时按位置访问;需要多个字段时在 let 或 match 中解构。解构不会复制或移动值本身的规则留到第 6 章;目前先把它理解为"按位置给字段命名"。
1 | |
单元素元组必须保留尾随逗号:(8080,) 是类型为 (i32,) 的元组,(8080) 只是带括号的 i32 表达式。空元组 () 是单元类型,常用来表示函数没有有意义的返回值。元组没有统一的元素类型和运行期索引;想按变量索引遍历一批同类值,应使用数组、切片或 Vec。
整数默认 i32;明确的非负计数用 u32/u64。第 8 章会用 TaskId(u64) newtype 把"任务编号"与裸 u64 区分开。
引用:&T、&mut T 与 C++ 引用的差别
引用是借用:不取得所有权,只拿到一段时间的访问权。&T 是共享引用(只读,可同时存在多个);&mut T 是可变引用(可写,但同一时刻只允许一个指向该值)。这条"共享与独占互斥"的规则由借用检查器在编译期强制——这是 Rust 引用与 C++ 引用最大的差别。
1 | |
与 C++ 引用逐项对比
两者表面相似(都不复制数据、语法上都要求绑定到已有对象),约束完全不同:
| 维度 | Rust &T / &mut T | C++ T& / const T& |
|---|---|---|
| 别名规则 | &mut T 独占:存在期间不允许任何其它读写别名,编译期拒绝 | 无限制:int&、指针、另一个引用可同时读写同一对象,安全靠纪律 |
| 悬垂 | 借用检查器保证引用不超过目标的寿命;返回局部变量的引用无法编译 | 能编译通过,解引用是未定义行为 |
| 重新指向 | 绑定加 mut 后可以让引用变量改指别处(r = &other;) | 初始化后永远绑定同一对象 |
| 可变性 | &T 恒只读;要写必须用 &mut T,且目标绑定本身是 mut 的 | const T& 只读、T& 可写,但 const 可被 const_cast 剥掉(UB 风险) |
| 空性 | 类型上不可能为空;可空语义显式写成 Option<&T> | 语法要求初始化,但 *(T*)nullptr 仍能造出"空引用",解引用是 UB |
| 函数签名表达力 | &T = 只读,&mut T = 独占可写,签名即契约 | const T& / T& 表达只读/可写,但无法表达"独占" |
写法对照
同一操作各写一遍。核心差别:C++ 把可变性放在引用的类型上(const T& vs T&),取引用与解引用大多是隐式的;Rust 把可变性拆到三处显式位置——目标绑定(let mut x)、借用表达式(&mut x)、调用点(f(&mut x)),通过引用读写要显式写 *:
| 操作 | C++ | Rust |
|---|---|---|
| 只读绑定 | const int& r = x; | let r = &x; |
| 可写绑定 | int& r = x; | let r = &mut x;(x 还须是 let mut) |
| 通过引用读值 | int v = r;(隐式解引用) | let v = *r;(显式 *) |
| 通过引用写值 | r = 5;(隐式解引用) | *r = 5;(显式 *) |
| 只读参数 | void show(const int& x); | fn show(x: &i32) |
| 可写参数 | void bump(int& x); | fn bump(x: &mut i32) |
| 调用可写参数的函数 | bump(x);(调用点无痕迹) | bump(&mut x);(调用点显式可见) |
| 引用变量改指别处 | 不支持 | r = &y;(r 须是 let mut) |
用同一个 bump 函数对照完整的声明—调用链:
1 | |
1 | |
评审时的实际差异:bump(&mut port) 在调用点就暴露了可变借用,搜索 &mut port 能找全所有写点;C++ 的 bump(port) 是否修改 port 只有看签名才知道。一个例外:Rust 的方法调用(v.len()、s.push('x'))会自动取引用和解引用,不显式写 * 或 &——自动解引用只发生在方法调用的接收者上。
注意一个方向相反的差异:Rust 里改指别处反而是允许的,因为安全由别名规则保证,而不是靠"绑定后不可改指向":
1 | |
工程选择:函数签名里只读参数用 &T(本章后面 parse_port 的 input: &str 就是一个共享引用),需要让调用方看到原地修改才用 &mut T。遇到借用检查器报错时,先理清"谁在读、谁在写、各活多久",不要靠 .clone() 绕开。完整的所有权、借用与生命周期规则在第 6 章展开。
const 与一次性/全局初始化
const 是编译期常量,会被内联,必须在声明处可求值;运行期才能拿到的值不能是 const。全局可变状态(static mut)在安全 Rust 中基本无法正确使用,应改为 OnceLock 做一次性初始化或把状态下放为参数传递。
1 | |
完整示例:端口解析
parse_port 覆盖本章大部分决策:解析边界、显式目标类型与 Result。字符串的归一化(trim/to_lowercase)随文本类型一起移入第 2 章。
1 | |
注意 "99999" 的行为:parse::<u16>() 拒绝它而不是截断成某个低 16 位值。这正是 Rust 在数值边界上的默认姿态–失败可见,而不是悄悄出错数据。
as 的陷阱与 TryFrom 受检转换
as 是有损转换:超范围时截断、取模或改符号,不报错。它只适合你已证明值必然在范围内、或明确接受截断的场景。跨越"外部数据可能超范围"的边界时,用 TryFrom/TryInto,把失败变成 Result:
1 | |
评审时的规则:as 出现在处理外部输入的代码里,先问"这个范围是谁保证的";答不出,换成 try_from。
为什么可行:类型与内存
let 绑定的类型在编译期确定,mut 只决定绑定能否重新赋值,不改变底层类型。标量通常直接放在栈帧,复合类型的栈上部分固定大小(元组按字段排布,数组是元素 × 长度),离开作用域时由 Drop 释放。这也解释了 parse::<u16>() 为什么拒绝 "99999":u16 的范围(0…=65535)是类型的一部分,越界即类型错误,与"非数字"同属一条失败路径。String 与 &str 的内存布局(栈上三元组 + 堆上字节、胖指针借用视图)在第 2 章展开。
常见误区
- 用
.clone()压制借用报错:先问调用方是否还需要原值;不需要就转移,需要就借用。第 6 章展开。 - 用
static mut保存共享状态:安全 Rust 里几乎必然撞上数据竞争检查;用OnceLock或参数传递(第 19 章)。 - 把
as当通用转换:它有损且静默;跨外部数据边界用try_from。 - 把 C++
T&的心智直接套到&mut T:C++ 引用不保证独占,Rust 的&mut T保证;"两个别名同时读写"的写法被拒绝是特性,不是编译器碍事(第 6 章)。 - 给真正要使用的值加下划线前缀(
_name):那是"故意忽略"的信号,不是消警告工具。
自测
let mut x = 5;和let x = 5; let x = x + 1;的差别是什么?–答方向:前者是同一绑定的重新赋值(类型不变);后者是遮蔽,新建绑定,类型可以变。- 为什么
parse_port("99999")返回Err而不是截断?–答方向:parse::<u16>做范围检查,越界是类型层面的失败;as才会静默截断。 - 什么时候用
const,什么时候必须用OnceLock?–答方向:编译期可求值且固定用const;值要到运行期才产生(读环境变量、配置文件)用一次性初始化。 65_536_i64 as u16和u16::try_from(65_536_i64)各返回什么?–答方向:前者0(静默截断);后者Err(范围受检)。处理外部输入用后者。- 字面量
5_000不加标注会推断成什么类型?什么时候必须标注?–答方向:默认i32;目标类型无法推断(如parse)或需求超出i32范围/有特定位宽要求时显式标注。 - Rust 的
&mut T和 C++ 的T&最关键的差别是什么?–答方向:独占性。&mut T存在期间编译器禁止任何其它读写别名;C++ 引用只保证绑定不可改指向,不限制别名,安全靠纪律。






