课程概览 · 第 1 章

下一篇:字符串与切片:String、&str 与 UTF-8 边界

学 Rust 的第一个真实障碍不是新语法,而是默认选择:哪些值该可变、类型标注写在哪里、数值转换在哪个边界必须受检。这些选择做错了,代码照样能编译,但接口会变贵、错误会推迟出现。本章用任务队列 CLI 里的两个小函数,把这些默认选择一次立起来;文本数据(String 与 &str)足够重要,独立成第 2 章。

Rust 中绑定权限与受检转换的分层关系
图:绑定与转换是两类独立决策;外部输入优先受检转换。

学习目标与默认选择

学完本章你能:

  • 解释 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
// 不可变绑定:默认选择。
let task_title = "ship the CLI";

// mut:同一绑定可以被重新赋值,类型不变。
let mut attempts = 0;
attempts += 1;
attempts += 1;

// 遮蔽:新绑定、新类型,旧名字从此不可见。
let raw_title = " Fix Login Bug ";
let raw_title = raw_title.trim().to_lowercase();

assert_eq!(task_title, "ship the CLI");
assert_eq!(attempts, 2);
assert_eq!(raw_title, "fix login bug");
}

选择规则:转换之后旧值没用了,用遮蔽;同名的旧值还要用,就换个名字。不要用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
// 省略类型:右侧是整数字面量,推断为 i32。
let retries = 3;

// 显式标注:解析的目标类型无法从上下文推断。
let port: u16 = "8080".parse().expect("valid literal");

// 延迟初始化:先占住名字,值在分支里产生。
let host: &str;
if port == 8080 {
host = "127.0.0.1";
} else {
host = "0.0.0.0";
}

assert_eq!((retries, port, host), (3, 8080, "127.0.0.1"));
}

延迟初始化的两条约束:使用前必须已赋值,且不可变绑定只允许赋值一次。编译器逐路径检查,任何一条路径上存在"先读后赋"都直接拒绝编译——所以 Rust 没有"未定义值",也不需要 null 占位:

1
2
3
4
5
6
7
8
9
10
fn main() {
let port: u16;
match "abc".parse::<u16>() {
Ok(p) => port = p,
Err(_) => port = 8080, // 兜底默认值
}
// 若某个分支漏了赋值,上一段直接编译错误:
// "use of possibly-uninitialized"
assert_eq!(port, 8080);
}

注意区分:延迟初始化是同一绑定的首次赋值,遮蔽是新建绑定。需要多次赋值时配合 let mut,不要靠遮蔽模拟。

let 左边是模式而非单纯的名字,元组、数组都可以在声明处拆开(模式匹配的完整规则在第 6 章):

1
2
3
4
5
6
7
fn main() {
let (host, port) = ("127.0.0.1", 8080u16);
let [first, ..] = [80, 443, 9090];
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
assert_eq!(first, 80);
}

命名约定:变量用 snake_case(task_title),const/static 用 SCREAMING_SNAKE_CASE(MAX_RETRIES),类型用 UpperCamelCase。故意不使用的值写 _name 或 _,语义见"常见误区"。

类型:标量、复合与标注的位置

标量类型(i32/u64/f64/bool/char)与复合类型(元组、数组)的推断通常够用。显式标注出现在推断失败或边界敏感的位置:

基本变量类型速查表

整数类型

类型位宽可表示范围示例常见用途
i88 位-128..=127let delta: i8 = -12;小范围、有正负的数值
i1616 位-32_768..=32_767let temperature: i16 = -20;协议字段、较小范围的数值
i3232 位约 ±21 亿let retries: i32 = 3;默认有符号整数;一般业务计算
i6464 位约 ±922 京let timestamp: i64 = 1_725_000_000;时间戳、大整数、数据库数值
i128128 位约 ±1.7 × 10³⁸let huge: i128 = 1_000_000_000_000_000_000;极大整数计算,较少使用
isize与平台指针等宽64 位机器通常为 64 位let offset: isize = -1;指针偏移、底层接口;普通业务很少直接选它
u88 位0..=255let byte: u8 = b'A';字节、二进制数据、颜色通道
u1616 位0..=65_535let port: u16 = 8080;网络端口、协议字段
u3232 位约 0..=42 亿let count: u32 = 42;明确非负且范围固定的计数
u6464 位约 0..=1844 京let id: u64 = 100_000;标识符、较大的非负计数
u128128 位约 0..=3.4 × 10³⁸let mask: u128 = 1;超大无符号整数,较少使用
usize与平台指针等宽64 位机器通常为 64 位let index: usize = 0;数组/切片索引、集合长度

其他标量类型

类型示例说明
f32let ratio: f32 = 0.75;32 位浮点数;内存或与外部接口兼容有要求时使用
f64let price: f64 = 19.99;64 位浮点数,也是未标注浮点字面量的默认类型;不适合精确金额
boollet enabled = true;只能为 true 或 false,用于条件判断
charlet initial = '中';一个 Unicode 标量值,使用单引号;不是一个字节
()let done = ();单元类型,表示“没有有意义的值”;函数未写返回值时默认返回它

文本与复合类型

类型示例说明
&strlet name = "rust";借用的 UTF-8 文本;适合只读参数和字符串字面量,不拥有数据
Stringlet 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
// 边界 1:解析的目标类型无法从上下文推断。
let port: u16 = "8080".parse().expect("test literal is valid");

// 边界 2:字面量可能超出默认推断。
let timeout_ms: u64 = 5_000;

// 复合类型:元组按位置解构,数组长度是类型的一部分。
let (width, height) = (80u32, 24u32);
let priorities = [1, 2, 3];
let total: i32 = priorities.iter().sum();

assert_eq!(port, 8080);
assert_eq!(timeout_ms, 5_000);
assert_eq!(width * height, 80 * 24);
assert_eq!(total, 6);
}

元组:固定位置的一组异构值

元组适合把数量固定、字段类型可能不同的几个值放在一起。它的类型由字段类型、顺序和字段数量共同决定:(u16, bool) 与 (bool, u16)、(u16,) 都是不同类型。数组则要求所有元素同类型;字段有稳定业务含义、会被多个地方使用时,改用第 7 章的命名结构体,不要让调用者记住 .0、.1 分别代表什么。

创建元组后有两种读取方式:临时只要一个字段时按位置访问;需要多个字段时在 let 或 match 中解构。解构不会复制或移动值本身的规则留到第 6 章;目前先把它理解为"按位置给字段命名"。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let endpoint: (&str, u16, bool) = ("127.0.0.1", 8080, true);

// 按位置访问:索引必须是编译期已知的 .0、.1、.2。
assert_eq!(endpoint.1, 8080);

// 解构:字段顺序必须与元组类型一致。
let (host, port, tls_enabled) = endpoint;
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
assert!(tls_enabled);

// 不需要的字段用 _ 忽略。
let (_, health_port, _) = ("admin", 9090u16, false);
assert_eq!(health_port, 9090);
}

单元素元组必须保留尾随逗号:(8080,) 是类型为 (i32,) 的元组,(8080) 只是带括号的 i32 表达式。空元组 () 是单元类型,常用来表示函数没有有意义的返回值。元组没有统一的元素类型和运行期索引;想按变量索引遍历一批同类值,应使用数组、切片或 Vec。

整数默认 i32;明确的非负计数用 u32/u64。第 8 章会用 TaskId(u64) newtype 把"任务编号"与裸 u64 区分开。

引用:&T、&mut T 与 C++ 引用的差别

引用是借用:不取得所有权,只拿到一段时间的访问权。&T 是共享引用(只读,可同时存在多个);&mut T 是可变引用(可写,但同一时刻只允许一个指向该值)。这条"共享与独占互斥"的规则由借用检查器在编译期强制——这是 Rust 引用与 C++ 引用最大的差别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let mut port = 8080u16;

// 共享引用:多个并存,都只读。
let r1 = &port;
let r2 = &port;
assert_eq!((*r1, *r2), (8080, 8080));

// r1、r2 不再使用之后,才能建立可变引用;写期间不存在其它别名。
let w = &mut port;
*w = 8081;
assert_eq!(*w, 8081);
}

// 悬垂引用也无法通过编译:
// let r;
// {
// let x = 1;
// r = &x;
// } // 编译错误:x 活得不够久,r 不能带出这个作用域

与 C++ 引用逐项对比

两者表面相似(都不复制数据、语法上都要求绑定到已有对象),约束完全不同:

维度Rust &T / &mut TC++ 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
2
3
4
5
6
7
8
9
fn bump(port: &mut u16) {
*port += 1; // 通过引用写,显式 *
}

fn main() {
let mut port = 8080u16; // 可写借用的前提:绑定本身是 mut
bump(&mut port); // 调用点显式 &mut:读代码的人立刻看到这里发生写
assert_eq!(port, 8081);
}
1
2
3
4
5
6
7
8
9
#include <cassert>

void bump(int& port) { port += 1; } // 引用隐式解引用,函数体内像普通变量

int main() {
int port = 8080;
bump(port); // 调用点看不出 port 会被修改,必须翻签名
assert(port == 8081);
}

评审时的实际差异:bump(&mut port) 在调用点就暴露了可变借用,搜索 &mut port 能找全所有写点;C++ 的 bump(port) 是否修改 port 只有看签名才知道。一个例外:Rust 的方法调用(v.len()、s.push('x'))会自动取引用和解引用,不显式写 * 或 &——自动解引用只发生在方法调用的接收者上。

注意一个方向相反的差异:Rust 里改指别处反而是允许的,因为安全由别名规则保证,而不是靠"绑定后不可改指向":

1
2
3
4
5
6
7
8
9
10
fn main() {
let a = 8080u16;
let b = 9000u16;

// C++ 的 int& 做不到这一点:换个指向。
let mut pick = &a;
assert_eq!(*pick, 8080);
pick = &b;
assert_eq!(*pick, 9000);
}

工程选择:函数签名里只读参数用 &T(本章后面 parse_port 的 input: &str 就是一个共享引用),需要让调用方看到原地修改才用 &mut T。遇到借用检查器报错时,先理清"谁在读、谁在写、各活多久",不要靠 .clone() 绕开。完整的所有权、借用与生命周期规则在第 6 章展开。

const 与一次性/全局初始化

const 是编译期常量,会被内联,必须在声明处可求值;运行期才能拿到的值不能是 const。全局可变状态(static mut)在安全 Rust 中基本无法正确使用,应改为 OnceLock 做一次性初始化或把状态下放为参数传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::sync::OnceLock;

const MAX_RETRIES: u8 = 3;

fn max_retries() -> u8 {
MAX_RETRIES
}

fn main() {
// 运行期一次性初始化:第一次访问求值,之后复用同一结果。
static API_TIMEOUT_MS: OnceLock<u64> = OnceLock::new();
let timeout = API_TIMEOUT_MS.get_or_init(|| {
// 实际项目中这里可能读环境变量;课程示例给确定值。
5_000
});

assert_eq!(max_retries(), 3);
assert_eq!(*timeout, 5_000);
assert_eq!(*API_TIMEOUT_MS.get_or_init(|| 99), 5_000); // 仍是首次结果
}

完整示例:端口解析

parse_port 覆盖本章大部分决策:解析边界、显式目标类型与 Result。字符串的归一化(trim/to_lowercase)随文本类型一起移入第 2 章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// 解析 TCP 端口号。
/// "8080" -> Ok(8080);"abc"(非数字)或 "99999"(超出 u16)都是 Err。
fn parse_port(input: &str) -> Result<u16, std::num::ParseIntError> {
input.parse::<u16>()
}

fn main() {
// parse_port:成功路径。
assert_eq!(parse_port("8080"), Ok(8080));
assert_eq!(parse_port("0"), Ok(0));

// parse_port:非数字。
assert!(parse_port("http").is_err());

// parse_port:数值超出 u16 范围(越界),不静默截断。
let overflow = parse_port("99999");
assert!(overflow.is_err());
assert_eq!(
overflow.unwrap_err().to_string(),
"number too large to fit in target type"
);
}

注意 "99999" 的行为:parse::<u16>() 拒绝它而不是截断成某个低 16 位值。这正是 Rust 在数值边界上的默认姿态–失败可见,而不是悄悄出错数据。

as 的陷阱与 TryFrom 受检转换

as 是有损转换:超范围时截断、取模或改符号,不报错。它只适合你已证明值必然在范围内、或明确接受截断的场景。跨越"外部数据可能超范围"的边界时,用 TryFrom/TryInto,把失败变成 Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
// 有损 as:静默截断,65_536 -> 0。危险,仅用于已证明安全的场合。
let truncated = 65_536_i64 as u16;
assert_eq!(truncated, 0);

// 受检转换:同一输入被显式拒绝。
let checked = u16::try_from(65_536_i64);
assert!(checked.is_err());
assert_eq!(
checked.unwrap_err().to_string(),
"number too large to fit in target type"
);

// 成功路径照常通过。
assert_eq!(u16::try_from(8080_i64), Ok(8080));
}

评审时的规则: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):那是"故意忽略"的信号,不是消警告工具。

自测

  1. let mut x = 5; 和 let x = 5; let x = x + 1; 的差别是什么?–答方向:前者是同一绑定的重新赋值(类型不变);后者是遮蔽,新建绑定,类型可以变。
  2. 为什么 parse_port("99999") 返回 Err 而不是截断?–答方向:parse::<u16> 做范围检查,越界是类型层面的失败;as 才会静默截断。
  3. 什么时候用 const,什么时候必须用 OnceLock?–答方向:编译期可求值且固定用 const;值要到运行期才产生(读环境变量、配置文件)用一次性初始化。
  4. 65_536_i64 as u16 和 u16::try_from(65_536_i64) 各返回什么?–答方向:前者 0(静默截断);后者 Err(范围受检)。处理外部输入用后者。
  5. 字面量 5_000 不加标注会推断成什么类型?什么时候必须标注?–答方向:默认 i32;目标类型无法推断(如 parse)或需求超出 i32 范围/有特定位宽要求时显式标注。
  6. Rust 的 &mut T 和 C++ 的 T& 最关键的差别是什么?–答方向:独占性。&mut T 存在期间编译器禁止任何其它读写别名;C++ 引用只保证绑定不可改指向,不限制别名,安全靠纪律。