用 Rust 编写猜数字游戏
这是你的第一个真正的项目。计算机在 1 到 100 之间选一个秘密数字,由你来猜。它会告诉你“太小了”或者“太大了”,直到你猜对为止,然后告诉你一共猜了几次。
程序虽小,却用到了你目前学过的很多东西:变量、match、循环和 Result。我们会一步一步地搭建它,而且每一步都能单独运行。
你将做出什么
Section titled “你将做出什么”完成后,在你自己的计算机上,游戏看起来是这样的:
猜数字!(1 到 100)请输入你的猜测:50太大了!请输入你的猜测:hello这不是一个整数,再试一次!请输入你的猜测:25太小了!请输入你的猜测:37猜对了!你一共猜了 3 次!注意两个贴心的细节。玩家输入 hello 时,游戏不会崩溃,而是再问一次。而且这次无效的输入不算作一次猜测。
第 1 步:创建项目
Section titled “第 1 步:创建项目”打开终端,新建一个 Cargo 项目:
cargo new guessing_gamecd guessing_game这一页的所有代码都写在 src/main.rs 里。随时可以用下面的命令运行:
cargo run如果你还没有安装 Rust,先看看快速开始。
第 2 步:从键盘读取猜测
Section titled “第 2 步:从键盘读取猜测”要读取玩家输入的内容,可以用 std::io::stdin() 和它的 read_line 方法:
use std::io;
fn main() { println!("请输入你的猜测:");
let mut guess = String::new(); io::stdin() .read_line(&mut guess) .expect("Failed to read line");
println!("你猜的是:{guess}");}这个程序会等你输入内容并按下回车,所以要在你自己的计算机上用 cargo run 运行。(Rust Playground 没有键盘输入,所以这里没有“动手试一试”按钮。)
这里发生了几件事:
String::new()创建一个空的、可以增长的 String,用来存放文本。read_line(&mut guess)把输入的那一行追加到guess的末尾。它需要&mut,因为它会修改这个字符串。如果你还不熟悉&mut,请看借用。read_line返回一个Result,因为读取输入可能会失败。.expect(...)的意思是“如果失败了,就带着这条消息停止程序”。现在这样就够了;稍后我们会更温和地处理它。
有一个意外:文本里包含了回车键。如果你输入 42,guess 里存的是 "42\n"。下一步要记住这一点。
第 3 步:把文本变成数字
Section titled “第 3 步:把文本变成数字”玩家输入的是文本,但我们想要一个可以比较的数字。.trim() 会去掉两端的空格和换行符。然后 .parse() 尝试把文本转换成数字。
解析可能会失败,"seven" 就不是数字。所以 parse() 返回一个 Result,我们用 match 来处理两种情况:
fn main() { // 假装玩家输入了这几行(每行都以回车结尾)。 let inputs = ["42\n", " 7 \n", "seven\n", "-3\n"];
for input in inputs { match input.trim().parse::<u32>() { Ok(num) => println!("{:?} -> 数字 {num}", input), Err(e) => println!("{:?} -> 不是有效的猜测({e})", input), } }}输出:
"42\n" -> 数字 42" 7 \n" -> 数字 7"seven\n" -> 不是有效的猜测(invalid digit found in string)"-3\n" -> 不是有效的猜测(invalid digit found in string)parse::<u32>() 告诉 Rust 要解析成哪种类型。u32 是无符号(永远不为负)的整数,所以 -3 被拒绝了。这正是我们想要的:秘密数字总是在 1 到 100 之间。括号里的 invalid digit found in string 是标准库自带的英文错误信息,意思是“字符串里有无效的数字字符”。
在游戏里,同样的思路是这样写的:
let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => { println!("这不是一个整数,再试一次!"); continue; }};这里的类型来自 let guess: u32,所以不需要写 ::<u32>。如果解析成功,match 就返回这个数字。如果失败,就打印一条友好的提示,然后用 continue 再问一次。(continue 只能在循环里使用,我们会在第 5 步加上循环。)
为什么不直接用 .expect()?因为那样的话,输错一个字就会让整个游戏崩溃。处理好 Err 的情况,程序用起来才会舒服。
第 4 步:把猜测和秘密数字比较
Section titled “第 4 步:把猜测和秘密数字比较”每个数字都有一个 .cmp() 方法。它比较两个值,返回一个 std::cmp::Ordering。这是一个枚举,有三个变体:Less、Greater 和 Equal。对它做 match,读起来几乎就像英语句子:
use std::cmp::Ordering;
fn main() { let secret: u32 = 42;
for guess in [10, 42, 99] { match guess.cmp(&secret) { Ordering::Less => println!("{guess}:太小了!"), Ordering::Greater => println!("{guess}:太大了!"), Ordering::Equal => println!("{guess}:你赢了!"), } }}输出:
10:太小了!42:你赢了!99:太大了!你也可以用 if guess < secret ... else if ... 写出同样的逻辑。match 版本的好处是,编译器会检查你是否处理了全部三种情况。漏掉一种,就编译不过。
第 5 步:一直问,直到猜对为止
Section titled “第 5 步:一直问,直到猜对为止”现在我们把所有代码包进一个 loop 里。它会一直运行,直到我们用 break 跳出。我们还要维护一个计数器 attempts,只有当猜测是一个真正的数字时才加 1。
在真正的游戏里,输入来自键盘。为了做一个能在 Playground 里运行的版本,我们使用固定的秘密数字和一组固定的“输入”行。inputs.next() 会给出下一行,列表用完时返回 None:
use std::cmp::Ordering;
fn main() { let secret: u32 = 37; // 假装玩家依次输入了这些内容,每行一个。 let mut inputs = ["50", "hello", "25", "37"].into_iter(); let mut attempts = 0;
println!("猜数字!(1 到 100)");
loop { let input = match inputs.next() { Some(line) => line, None => { println!("没有更多猜测了。答案是 {secret}。"); break; } }; println!("> {input}");
let guess: u32 = match input.trim().parse() { Ok(num) => num, Err(_) => { println!("这不是一个整数,再试一次!"); continue; } };
attempts += 1;
match guess.cmp(&secret) { Ordering::Less => println!("太小了!"), Ordering::Greater => println!("太大了!"), Ordering::Equal => { println!("猜对了!你一共猜了 {attempts} 次!"); break; } } }}输出:
猜数字!(1 到 100)> 50太大了!> hello这不是一个整数,再试一次!> 25太小了!> 37猜对了!你一共猜了 3 次!试着改一改秘密数字或者猜测列表,再运行一次。把正确答案放在第一个,或者干脆不放,看看会发生什么。
None 这个分支很重要。它是 Playground 版本里的“玩家没有更多输入了”,真正的游戏也需要同样的处理(见第 7 步)。
第 6 步:随机选一个秘密数字
Section titled “第 6 步:随机选一个秘密数字”如果每次的秘密数字都一样,游戏就没什么意思了。在真实的项目里,你会用 rand(英文)crate,它是 Rust 里生成随机数的标准选择。随机数这一课讲了怎么用它。用下面的命令添加:
cargo add rand这个项目里我们只用标准库,这样你什么都不用配置。诀窍是:向时钟要当前时间,取出其中的纳秒部分(一个 0 到 999,999,999 之间、一直在变的数),再用 %(取余)把它压缩到 1 到 100 的范围内:
use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("时钟被设置在了 1970 年之前") .subsec_nanos(); nanos % 100 + 1}
fn main() { let secret = secret_number(); let in_range = (1..=100).contains(&secret); println!("秘密数字已经选好了。它在 1 到 100 之间吗?{in_range}");}输出:
秘密数字已经选好了。它在 1 到 100 之间吗?truenanos % 100 总是在 0 到 99 之间,所以加 1 之后就是 1 到 100。我们只打印它是否在范围内,所以每次的输出都一样。想偷看的话,就把 secret 本身打印出来。
要清楚这到底是什么:它只是“对游戏来说足够难以预测”,并不是真正的随机。千万不要把这种技巧用在密码、安全令牌,或者任何有人可能会去猜的值上。那些场合请使用正规的 crate。
第 7 步:完整的游戏
Section titled “第 7 步:完整的游戏”下面是全部代码合在一起的样子。把这个版本放进 src/main.rs,然后用 cargo run 来玩。
有一处新东西:我们对 read_line 的结果做了 match,而不是调用 .expect()。没有更多输入时,read_line 会返回 Ok(0),比如玩家按了 Ctrl+D(Windows 上是先按 Ctrl+Z 再按回车),或者输入是从文件通过管道传进来的。如果不做这个检查,循环就会在空输入上永远空转下去。
use std::cmp::Ordering;use std::io;use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("时钟被设置在了 1970 年之前") .subsec_nanos(); nanos % 100 + 1}
fn main() { println!("猜数字!(1 到 100)");
let secret = secret_number(); let mut attempts = 0;
loop { println!("请输入你的猜测:");
let mut guess = String::new(); match io::stdin().read_line(&mut guess) { Ok(0) => { // 输入结束:没有更多内容可读了。 println!("没有更多输入了。答案是 {secret}。再见!"); break; } Ok(_) => {} Err(e) => { println!("无法读取你的输入:{e}"); break; } }
let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => { println!("这不是一个整数,再试一次!"); continue; } };
attempts += 1;
match guess.cmp(&secret) { Ordering::Less => println!("太小了!"), Ordering::Greater => println!("太大了!"), Ordering::Equal => { println!("猜对了!你一共猜了 {attempts} 次!"); break; } } }}如果想在浏览器里试试,就用第 5 步的版本,它会按一组固定的猜测来玩。🦀
忘了给字符串加 mut。 read_line 需要修改这个字符串,所以它必须是可变的:
use std::io;
fn main() { let guess = String::new(); io::stdin().read_line(&mut guess).expect("Failed to read line");}error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable --> src/main.rs:5:27 |5 | io::stdin().read_line(&mut guess).expect("Failed to read line"); | ^^^^^^^^^^ cannot borrow as mutable |help: consider changing this to be mutable |4 | let mut guess = String::new(); | +++修复方法和编译器建议的完全一样:let mut guess = String::new();。
拿文本和数字比较。 如果忘了解析,guess 仍然是一个 String,而 String 不能和 u32 比较:
use std::cmp::Ordering;
fn main() { let secret: u32 = 42; let guess = String::from("50");
match guess.cmp(&secret) { Ordering::Less => println!("Too small!"), Ordering::Greater => println!("Too big!"), Ordering::Equal => println!("You win!"), }}error[E0308]: mismatched types --> src/main.rs:7:21 |7 | match guess.cmp(&secret) { | --- ^^^^^^^ expected `&String`, found `&u32` | | | arguments to this method are incorrect编译器说的是:类型不匹配,这里期望的是 &String,却得到了 &u32。Rust 从来不会替你在文本和数字之间做转换。先解析,再比较。
忘了 .trim()。 这段代码能编译,但每次猜测都会被拒绝,因为换行符不是数字:
fn main() { let typed = "42\n"; println!("{:?}", typed.parse::<u32>()); println!("{:?}", typed.trim().parse::<u32>());}输出:
Err(ParseIntError { kind: InvalidDigit })Ok(42)游戏能跑起来以后,自己试试下面这些改进。每一项都不大:
- 检查范围。 如果猜测是 0 或者大于 100,就提示“请选择 1 到 100 之间的数字”,并且不计入次数。
- 限制次数。 给玩家 7 次机会。用完了就显示秘密数字,结束游戏。
- 再玩一局。 玩家赢了以后,问一句“再玩一局吗?(y/n)”,然后用新的秘密数字开始新的一轮。
- 记录最好成绩。 记住所有轮次里最少的猜测次数,最后打印出来。
- 给出更贴心的提示。 当猜测和秘密数字相差不超过 3 时,提示“非常接近了!”。
guess.abs_diff(secret)能算出两个数之间的距离。 - 使用真正的
randcrate。 运行cargo add rand,按照 docs.rs(英文)上的文档替换掉secret_number()。 - 把逻辑移到函数里。 编写
fn check_guess(guess: u32, secret: u32) -> Ordering,再给它加上测试。
io::stdin().read_line(&mut s)读取一行,包括换行符,并返回一个Result。Ok(0)表示没有更多输入了。.trim().parse()把文本变成数字。它返回Result,所以要对它做match,友好地处理错误输入。a.cmp(&b)返回Ordering::Less、Greater或Equal,而match会确保你处理了全部三种情况。- 配合
continue和break的loop让游戏一直进行,直到玩家获胜。 - 时钟技巧对游戏来说够用了,但真实的项目会使用
randcrate。
小测验
1. 玩家输入 42 并按下回车。read_line 得到的字符串里是什么?
2. 为什么游戏对 parse() 使用 match,而不是 .expect()?
3. read_line 返回 Ok(0) 是什么意思?
4. guess.cmp(&secret) 可能返回哪些值?
得分: 0 / 4
- 编写一个猜数字游戏 — Rust 程序设计语言:官方教程里的同一个项目。它使用
randcrate,并逐行详细讲解。 Stdin::read_line— 标准库文档(英文):为什么文本会被追加到你的String后面,为什么会保留换行符,以及为什么在输入结束时返回Ok(0)。str::parse— 标准库文档(英文):parse怎样根据你的类型注解选择目标类型,以及文本不是数字时错误长什么样。std::cmp::Ordering— 标准库文档(英文):cmp返回的Less/Equal/Greater枚举,以及is_lt和reverse等辅助方法。randcrate — docs.rs(英文):当你准备好添加第一个 crate 时,这才是生成随机数的正规方法。