Chapter 2:编写猜数字游戏(Programming a Guessing Game)
对齐官方第 2 章:用 Cargo、标准输入、rand 依赖、变量遮蔽、Result、Ordering、match、loop、continue 与 break 渐进完成一个健壮的猜数字程序。
学习目标
- 能实现标准输入到
u32的完整转换管线,并解释String、trim、parse、Result和变量遮蔽之间的关系 - 能分析 Cargo 如何从
rand = "0.8.5"解析直接与传递依赖,并区分 manifest 约束、lockfile 结果和增量构建 - 能设计
Ordering/match与loop/continue/break的控制流,使无效输入可恢复、猜中后正确退出
为什么用一个项目提前遇见 Rust 的主线
官方第 2 章故意在完整解释所有权、枚举和 trait 之前制作一个可运行程序。目标不是一次掌握每个语法细节,而是让输入、类型、错误、依赖和控制流在一个可观察项目中汇合。后续章节会逐一解释这些机制;本章先建立从编译错误和运行结果反推程序状态的能力。
游戏契约很小:启动时生成 1..=100 的秘密数字;每轮读取一次猜测;小了或大了就继续;猜中就退出;非数字输入不应让进程崩溃。这个契约同时给出成功路径、两条继续路径和一条错误恢复路径,足以检验第一批 Rust 观念。
创建项目并固定依赖版本
从 Cargo package 开始,先确认生成的 Rust 2024 Edition 清单和默认二进制 crate 能运行,再引入第三方依赖。这样若后续失败,可以区分是基础工具链、依赖解析还是新代码造成。
cargo new guessing_game
cd guessing_game
cargo run[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
[dependencies]
rand = "0.8.5"rand 本身还依赖 rand_core、随机源适配和平台实现等 crate,这些是当前 package 没有直接声明的传递依赖。Cargo 从 registry 元数据求解完整依赖图,下载缺失源码,按依赖顺序构建,并把具体选择写入 Cargo.lock。
先预测再切换两个条件:lockfile 存在且 manifest 未变化时,Cargo 复用已锁定版本;首次构建没有 lockfile 时,Cargo 求解后创建它;改变依赖约束时,Cargo 必须确认旧解析是否仍满足新范围,必要时更新依赖图。源码只做小改动时,已编译依赖通常不会重编。
cargo build
cargo tree
cargo update -p rand
cargo buildrand = "0.8.5" 按 Cargo 对 0.x 版本的兼容规则允许 0.8 系列内的兼容更新,而不会自动跨到 0.9。本章使用官方示例对应的 0.8.5 API;若改用其他大/小版本,应先看该版本文档,不要把方法名差异误判为 Rust 语法问题。
第一轮:读取终端输入
标准库的 std::io 不在 prelude 的自动导入集合中,因此用 use std::io 把模块名带入作用域。String::new() 通过关联函数创建空的可增长 UTF-8 字符串;变量默认不可变,所以要让 read_line 追加输入,绑定必须写成 let mut guess 并传入 &mut guess。
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}read_line 同时做两件事:修改传入的 String,并返回 io::Result<usize>,其中成功值是读取的字节数。Result 带有 #[must_use] 语义,完全忽略它会触发警告,因为输入可能因底层 I/O 失败。此处用 expect 把无法恢复的终端读取失败转成带上下文的 panic;第 9 章会系统设计错误边界。
按回车后输入并不只是数字。键入 42 时,Unix 常见缓冲内容是 "42\n",Windows 终端可能产生 "42\r\n";这正是后面必须 trim() 的原因。
第二轮:生成秘密数字
rand::Rng 是定义 gen_range 等方法的 trait,必须在作用域内,方法调用才能通过 trait 方法解析。thread_rng() 提供与当前线程关联并由系统随机源播种的生成器;1..=100 是包含两端的范围。
use rand::Rng;
fn main() {
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("temporary debug secret: {secret_number}");
}开发阶段暂时打印秘密数字,是一个明确的测试探针:它能证明范围和每次运行的变化。完成游戏后必须删除,因为测试探针不能破坏产品契约。更严谨的测试会把随机源抽象为可注入输入,但这超出本章第一次项目的范围。
第三轮:从 String 转成 u32
如果直接写 guess.cmp(&secret_number),编译器会报告类型不匹配:guess 是 String,秘密数字由上下文推断为整数,字符串比较和数值比较不是一回事。编译错误在这里揭示了必须存在的输入边界:外部文本先清洗、解析和验证,才能进入数值领域。
expected reference `&String`
found reference `&{integer}`let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Failed to read line");
let guess: u32 = guess
.trim()
.parse()
.expect("Please type a number!");这不是把原 String 原地变成 u32。右侧的 guess 指旧字符串;trim() 返回去除两端空白的 &str 视图;parse() 根据左侧 u32 注解选择目标类型并返回 Result<u32, ParseIntError>;新的 let guess 遮蔽旧绑定,使后续同一个业务名代表已验证数值。
先预测各阶段:在 stdin 阶段比较会因类型不同而失败;trim 只消除空白,不保证内容是数字;parse 可能得到 Err;只有成功的 u32 才能与同类型秘密数字调用 cmp。勾选非数字输入,再切到 parse 阶段即可看到控制流为什么要恢复而不是解包崩溃。
第四轮:用 Ordering 和 match 穷尽比较结果
↡数值或可排序值比较的三态结果枚举,变体为 Less、Greater 和 Equal;它把比较结果编码为可穷尽匹配的类型。cmp 返回 std::cmp::Ordering。match 的每个分支由模式和表达式组成,编译器要求覆盖所有可能变体;这里恰好是小于、大于、等于三种。与多个相互独立的布尔判断相比,枚举使状态空间显式且可检查。
use std::cmp::Ordering;
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}match 本身是表达式,每个 arm 可以产生值,而不只是执行打印。本章先把它用于控制输出;解析输入时还会让 Ok(num) arm 产出数字,让 Err(_) arm 改变循环控制。
第五轮:把一次尝试扩展为健壮循环
秘密数字应在 loop 外生成一次,否则每次猜测面对的答案都会变化。每轮输入缓冲应在 loop 内新建,避免 read_line 的追加语义把多轮文本积累到同一 String。这两个变量位置体现了不同生命周期:跨轮状态和单轮状态不能混放。
最终代码把解析错误从 expect 改成 match。成功时取出 num;失败时 _ 忽略具体错误并 continue。猜中时 break,其余 Ordering 结果自然走到本轮末尾,再开始下一轮。
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
loop {
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}切换四种结果并预测下一状态:无效输入在比较前 continue;Less 和 Greater 完成本轮后自然回到输入;Equal 执行 break,而 main 在 loop 后没有其他语句,所以程序正常结束。这里的退出是受控控制流,不是依赖 panic 或 Ctrl-C。
可复现验收清单
项目章必须运行代码并观察结果,不能只读最终清单。至少覆盖以下场景:
cargo fmt --check
cargo check
cargo run- 输入小于秘密数字时打印
Too small!并再次提示。 - 输入大于秘密数字时打印
Too big!并再次提示。 - 输入非数字时不 panic,而是直接开始下一轮。
- 输入正确数字时打印
You win!,随后进程正常退出。 - 重新运行时秘密数字仍在闭区间
1..=100,且正式输出不泄露答案。
可临时恢复秘密数字日志完成分支覆盖,验收后删除。更进一步可把比较逻辑提取为纯函数并测试,但官方第 11 章才会系统介绍 Rust 测试组织。
三步形成项目迭代框架
第一步:固定依赖与构建图
先让默认 package 成功运行,再声明 rand 0.8.5,观察直接依赖、传递依赖和 Cargo.lock。源码变化不应无故重编整个依赖树。
本项目引入的 rand crate 同时展示 registry dependency、Cargo.lock resolution 与 trait-provided random API;它不是标准库隐式模块。
小结
- 猜数字项目把 Cargo 依赖、标准输入、变量可变性、引用、Result、trait 方法、类型推断、match 和循环控制串成一个可运行闭环
Cargo.toml的rand = "0.8.5"是直接依赖约束,Cargo 还会求解传递依赖,并用 Cargo.lock 固定具体构建图read_line把带换行的文本追加到可变 String;trim().parse()才把输入边界转换为可比较的 u32,遮蔽让同一业务名承担新类型Ordering把比较结果建模为 Less、Greater、Equal;match穷尽状态,避免遗漏分支- 无效输入用
continue恢复,猜中用break正常退出;panic 不应充当普通游戏控制流
练习
- 问题 1:解释类型管线。 用户输入
" 76\n"后,read_line、trim、parse::<u32>和遮蔽分别产生什么值或绑定?哪一步可能失败?
- 问题 2:判断依赖重建。
Cargo.lock已锁定 rand 0.8.5,只修改src/main.rs后再次 build。Cargo 通常重编什么?若把 manifest 改为不兼容的新约束,路径又怎样变化?
- 问题 3:追踪控制流。 连续输入
foo、20、80、61,秘密数字是 61。每轮分别命中什么分支,为什么只有最后一轮结束进程?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 渐进项目
- 每次增加一个可观察行为并立即编译运行,用证据推动下一步的项目学习方式。
- 直接依赖
- 当前 package 在 Cargo.toml 中显式声明的依赖。
- 传递依赖
- 由直接依赖继续引入并参与完整构建图的依赖。
- 标准输入
- 进程读取终端或管道输入的标准流,Rust 通过 io::stdin 访问。
- Result
- 用 Ok 与 Err 明确表示成功或失败的枚举。
- Rng trait
- 为随机数生成器定义 gen_range 等共同方法的 trait。
- 变量遮蔽
- 用新的 let 绑定复用变量名,并允许新绑定具有不同类型。
- Ordering
- 将比较结果编码为 Less、Greater、Equal 三种变体的枚举。
- match 表达式
- 按模式选择第一个匹配 arm,并要求覆盖值的完整状态空间。
- loop 循环
- 无条件重复的循环表达式,由 continue 进入下一轮、break 退出。