编写猜数字游戏
让我们通过一个实践项目开始学习 Rust!本章会通过展示如何在真实程序中运用 Rust,介绍一些常见概念。你将学习 let、match、方法、关联函数、外部 crate 等内容。后续章节会更详细地探讨这些概念;本章先练习基础知识。
我们将实现一个经典的编程入门问题:猜数字游戏。游戏规则如下:程序会生成一个 1 到 100 之间的随机整数,然后提示玩家输入猜测。输入后,程序会指出猜测过低还是过高。如果猜对了,游戏会打印祝贺消息并退出。
设置新项目
要设置新项目,请进入第 1 章中创建的 projects 目录,并使用 Cargo 创建项目,如下所示:
$ cargo new guessing_game
$ cd guessing_game
第一条命令 cargo new 接收项目名称(guessing_game)作为第一个参数。第二条命令进入新项目的目录。
查看生成的 Cargo.toml 文件:
文件名:Cargo.toml
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
[dependencies]
如第 1 章所示,cargo new 会为你生成一个“Hello, world!”程序。看看 src/main.rs 文件:
文件名:src/main.rs
fn main() {
println!("Hello, world!");
}
现在使用 cargo run 命令,一步编译并运行这个“Hello, world!”程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/guessing_game`
Hello, world!
当你需要快速迭代项目时,run 命令会很有用。正如我们在这个游戏中要做的那样,你可以快速测试每次修改,再继续下一步。
重新打开 src/main.rs 文件。你将在这个文件中编写所有代码。
处理猜测
猜数字程序的第一部分会请求用户输入、处理输入,并检查输入是否符合预期格式。首先,我们允许玩家输入猜测。将代码清单 2-1 中的代码输入 src/main.rs。
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}");
}
这段代码包含许多信息,让我们逐行来看。为了获取用户输入并打印结果,我们需要将输入/输出库 io 引入作用域。io 库来自标准库,也就是 std:
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}");
}
默认情况下,Rust 会将标准库中定义的一组条目引入每个程序的作用域。这组条目称为 prelude,你可以在标准库文档中查看完整列表。
如果你想使用的类型不在 prelude 中,就必须通过 use 语句显式地将它引入作用域。使用 std::io 库可以获得许多实用功能,包括接收用户输入。
如第 1 章所示,main 函数是程序的入口点:
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}");
}
fn 语法用于声明新函数;圆括号 () 表示没有参数;左花括号 { 则开始函数体。
你也在第 1 章学到,println! 是一个将字符串打印到屏幕上的宏:
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}");
}
这段代码会打印提示,说明游戏内容并请求用户输入。
使用变量存储值
接下来,我们创建一个变量来存储用户输入,如下所示:
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}");
}
现在程序开始变得有意思了!这一小行代码包含了不少内容。我们使用 let 语句创建变量。再看一个例子:
let apples = 5;
这一行创建了一个名为 apples 的新变量,并将它绑定到值 5。在 Rust 中,变量默认不可变,也就是说,一旦给变量赋值,这个值就不会改变。我们会在第 3 章的“变量与可变性”一节详细讨论这个概念。要让变量可变,可以在变量名前添加 mut:
let apples = 5; // immutable
let mut bananas = 5; // mutable
注意:// 语法用于开始一条注释,注释会持续到行尾。Rust 会忽略注释中的所有内容。我们会在第 3 章更详细地讨论注释。
回到猜数字程序,你现在知道 let mut guess 会引入一个名为 guess 的可变变量。等号(=)告诉 Rust 我们要将某个值绑定到这个变量。等号右侧是绑定给 guess 的值,也就是调用 String::new 的结果;这个函数会返回一个新的 String 实例。String 是标准库提供的一种字符串类型,用于存储可增长的 UTF-8 文本。
::new 中的 :: 语法表示 new 是 String 类型的关联函数。关联函数是在某个类型上实现的函数,这里就是 String。这个 new 函数会创建一个新的空字符串。许多类型都有 new 函数,因为这是创建某种新值时常用的函数名。
总的来说,let mut guess = String::new(); 这一行创建了一个可变变量,并将它绑定到一个新的空 String 实例。内容不少吧!
接收用户输入
还记得我们在程序第一行通过 use std::io; 引入了标准库的输入/输出功能。现在,我们将调用 io 模块中的 stdin 函数,以便处理用户输入:
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}");
}
如果我们没有在程序开头通过 use std::io; 导入 io 模块,仍然可以通过将函数调用写成 std::io::stdin 来使用它。stdin 函数会返回一个 std::io::Stdin 实例,这种类型代表终端标准输入的句柄。
接下来,.read_line(&mut guess) 这一行会在标准输入句柄上调用 read_line 方法来获取用户输入。我们还将 &mut guess 作为参数传给 read_line,告诉它把用户输入存入哪个字符串。read_line 会读取用户在标准输入中输入的内容,并将其追加到字符串中(而不是覆盖原有内容),因此我们将该字符串作为参数传入。字符串参数必须可变,这样方法才能修改字符串内容。
& 表示这个参数是一个引用,它让代码的多个部分可以访问同一份数据,而不必多次将数据复制到内存中。引用是一项复杂功能,而 Rust 的主要优势之一,就是引用既安全又易于使用。完成这个程序并不需要了解太多细节。现在只需知道,引用和变量一样,默认不可变。因此,要使引用可变,需要写成 &mut guess,而不是 &guess。(第 4 章会更全面地解释引用。)
Handling Potential Failure with Result
We’re still working on this line of code. We’re now discussing a third line of text, but note that it’s still part of a single logical line of code. The next part is this method:
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}");
}
We could have written this code as:
io::stdin().read_line(&mut guess).expect("Failed to read line");
不过,太长的一行代码不容易阅读,所以最好将它拆开。使用 .method_name() 语法调用方法时,适当换行并添加空白通常是明智的做法。现在来看看这行代码的作用。
如前所述,read_line 会将用户输入的内容放入我们传给它的字符串中,同时还会返回一个 Result 值。Result 是一种枚举,也称为 enum,它是一种可以处于多个状态之一的类型。每种可能的状态都称为一个变体。
第 6 章会更详细地介绍枚举。Result 类型的用途是编码错误处理信息。
Result 有两个变体:Ok 和 Err。Ok 表示操作成功,并包含成功产生的值。Err 表示操作失败,并包含有关失败方式或原因的信息。
和其他类型的值一样,Result 值也定义了可调用的方法。Result 实例有一个expect 方法。如果这个实例是 Err 值,expect 会导致程序崩溃,并显示你传给 expect 的消息。如果 read_line 方法返回 Err,通常表示底层操作系统发生了错误。如果这个 Result 实例是 Ok 值,expect 会取出 Ok 中包含的返回值,并将其交还给你使用。在这里,这个值是用户输入的字节数。
如果不调用 expect,程序仍能编译,但会收到警告:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut guess);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
10 | let _ = io::stdin().read_line(&mut guess);
| +++++++
warning: `guessing_game` (bin "guessing_game") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s
Rust 提醒你没有使用 read_line 返回的 Result 值,这意味着程序没有处理可能发生的错误。
消除警告的正确方法是编写实际的错误处理代码;不过在本例中,我们只希望问题发生时程序崩溃,因此可以使用 expect。你将在第 9 章学习如何从错误中恢复。
使用 println! 占位符打印值
除了右花括号,前面的代码还剩最后一行需要讨论:
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}");
}
这一行会打印现在包含用户输入的字符串。花括号 {} 是一个占位符:可以把 {} 想象成一对小钳子,用来夹住一个值。打印变量的值时,可以将变量名放在花括号中。打印表达式的计算结果时,可以在格式字符串中放置空花括号,然后在格式字符串后按顺序提供以逗号分隔的表达式列表,分别填入各个占位符。在一次 println! 调用中同时打印变量和表达式结果,可以这样写:
#![allow(unused)]
fn main() {
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
}
这段代码会打印 x = 5 and y + 2 = 12。
测试第一部分
让我们测试猜数字游戏的第一部分。使用 cargo run 运行程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
到这里,游戏的第一部分就完成了:我们从键盘获取输入,然后将其打印出来。
生成秘密数字
接下来,我们需要生成一个供用户猜测的秘密数字。为了让游戏可以反复游玩,每次生成的秘密数字都应该不同。我们会使用 1 到 100 之间的随机数,让游戏不至于太难。Rust 的标准库目前尚未提供随机数功能,不过 Rust 团队提供了具有此功能的 rand crate。
借助 crate 扩展功能
还记得 crate 是一组 Rust 源代码文件。我们一直在构建的项目是一个二进制 crate,也就是可执行程序。rand crate 则是一个库 crate,其中包含供其他程序使用、不能独立执行的代码。
协调外部 crate 正是 Cargo 的强项。在编写使用 rand 的代码之前,我们需要修改 Cargo.toml,将 rand crate 添加为依赖。现在打开该文件,在 Cargo 创建的 [dependencies] 区段标题下方添加以下行。请务必像这里一样指定 rand 及其版本号,否则本教程中的代码示例可能无法运行:
文件名:Cargo.toml
[dependencies]
rand = "0.10.1"
在 Cargo.toml 文件中,区段标题之后的所有内容都属于该区段,直到另一个区段开始。在 [dependencies] 中,你可以告诉 Cargo 项目依赖哪些外部 crate,以及需要这些 crate 的哪些版本。本例使用语义化版本号 0.10.1 指定 rand crate。Cargo 理解语义化版本(有时简称 SemVer),这是一种编写版本号的标准。版本号 0.10.1 实际上是 ^0.10.1 的简写,表示版本不低于 0.10.1 且低于 0.11.0。
Cargo 认为这些版本的公共 API 与 0.10.1 兼容;这一指定方式能确保你获得仍可与本章代码一起编译的最新补丁版本。不能保证 0.11.0 或更高版本具有与后续示例相同的 API。
现在不修改任何代码,按照代码清单 2-2 构建项目。
$ cargo build
Updating crates.io index
Locking 8 packages to latest Rust 1.96.0 compatible versions
Downloaded rand_core v0.10.1
Downloaded chacha20 v0.10.1
Downloaded rand v0.10.1
Downloaded 3 crates (162.9KiB) in 0.59s
Compiling libc v0.2.186
Compiling rand_core v0.10.1
Compiling getrandom v0.4.3
Compiling cfg-if v1.0.4
Compiling chacha20 v0.10.1
Compiling rand v0.10.1
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.03s
rand crate 添加为依赖后运行 cargo build 的输出你看到的版本号可能不同(但得益于 SemVer,它们都与代码兼容!),输出行也可能因操作系统而异,且顺序也可能不同。
当我们加入外部依赖时,Cargo 会从注册表获取该依赖所需的一切最新版本。注册表是来自 Crates.io 的一份数据副本。Crates.io 是 Rust 生态系统中的用户发布开源 Rust 项目、供他人使用的平台。
更新注册表后,Cargo 会检查 [dependencies] 区段,并下载其中尚未下载的 crate。本例虽然只列出了 rand 作为依赖,Cargo 也会下载 rand 正常运行所需的其他 crate。下载后,Rust 会先编译这些 crate,再使用这些依赖编译项目。
如果不做任何更改就立即再次运行 cargo build,除了 Finished 行之外不会看到其他输出。Cargo 知道依赖已经下载并编译过,而且你没有在 Cargo.toml 中更改它们。Cargo 也知道代码没有变化,因此不会重新编译。没有其他工作要做时,它就会直接退出。
如果你打开 src/main.rs 文件,做一个微小修改,保存后重新构建,就只会看到两行输出:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
这些行表明,Cargo 只针对你对 src/main.rs 所做的微小修改更新了构建结果。依赖没有变化,因此 Cargo 知道可以复用已经下载并编译好的依赖。
确保构建可复现
Cargo 有一种机制,可以确保你或其他人每次构建代码时都能重新生成相同的产物:除非你另行指定,否则 Cargo 只会使用你指定的依赖版本。例如,假设下周 rand crate 发布了 0.10.2 版本,其中修复了一个重要错误,但也引入了会破坏代码的回归问题。为应对此类情况,Rust 会在你首次运行 cargo build 时创建 Cargo.lock 文件;现在 guessing_game 目录中就有了这个文件。
首次构建项目时,Cargo 会找出所有符合条件的依赖版本,并将它们写入 Cargo.lock。之后构建项目时,Cargo 会发现该文件已经存在,并使用其中指定的版本,而不是重新执行确定版本的全部工作。这样就能自动实现可复现构建。换句话说,得益于 Cargo.lock,项目会一直使用 0.10.1,直到你明确升级。由于 Cargo.lock 对可复现构建很重要,通常会与项目中的其他代码一起纳入版本控制。
更新 crate 以获取新版本
如果你确实想更新 crate,Cargo 提供了 update 命令。该命令会忽略 Cargo.lock,并找出符合 Cargo.toml 规格的所有最新版本,然后将这些版本写入 Cargo.lock。默认情况下,Cargo 只会查找高于 0.10.1 且低于 0.11.0 的版本。如果 rand crate 发布了 0.10.2 和 0.999.0 这两个新版本,运行 cargo update 后会看到如下输出:
$ cargo update
Updating crates.io index
Locking 1 package to latest Rust 1.96.0 compatible version
Updating rand v0.10.1 -> v0.10.2 (available: v0.999.0)
Cargo 会忽略 0.999.0 版本。此时你也会发现 Cargo.lock 已更新,记录当前使用的 rand 版本为 0.10.2。若要使用 rand 0.999.0 或 0.999.x 系列中的任何版本,就必须将 Cargo.toml 改成如下内容(请不要真的这样修改,因为后续示例假设你使用的是 rand 0.10):
[dependencies]
rand = "0.999.0"
下次运行 cargo build 时,Cargo 会更新可用 crate 注册表,并根据你指定的新版本重新评估 rand 的版本要求。
关于 Cargo及其生态系统还有很多内容,我们会在第 14 章讨论;目前你只需了解这些。Cargo 让复用库变得非常容易,因此 Rustacean 可以通过组合多个包来编写更小的项目。
生成随机数
让我们开始使用 rand 生成一个供猜测的数字。下一步是按照代码清单 2-3 更新 src/main.rs。
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_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}");
}
首先,我们添加了 use rand::prelude::*; 这一行。prelude 模块包含 rand crate 中最常用的部分,而 use 会让这些条目在程序作用域中可用。
接下来,我们在中间添加两行。第一行调用 rand::rng 函数,获取我们要使用的随机数生成器:它只属于当前执行线程,并由操作系统提供种子。然后,我们在随机数生成器上调用 random_range 方法。这个方法由 RngExt trait 定义,它属于我们通过 use rand::prelude::*; 语句引入作用域的 rand::prelude 模块。random_range 方法接收一个范围表达式作为参数,并在该范围内生成随机数。这里使用的范围表达式形式为 start..=end,上下界都包含在内,因此要生成 1 到 100 之间的数字,需要指定 1..=100。
注意:你不可能直接知道应该将哪些条目引入作用域,以及该调用 crate 中的哪些方法和函数,因此每个 crate 都提供了使用说明文档。Cargo 的另一个实用功能是,运行 cargo doc --open 会在本地构建所有依赖提供的文档,并在浏览器中打开。如果你想了解 rand crate 的其他功能,可以运行 cargo doc --open,然后点击左侧边栏中的 rand。
第二条新代码会打印秘密数字。在开发程序时,这有助于我们测试程序,但最终版本会删除它。程序一启动就公布答案,那游戏也就没什么意思了!
试着多运行几次程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5
你应该会得到不同的随机数,而且它们都应在 1 到 100 之间。如果出现警告,可以放心忽略。如果出现错误,请检查 Cargo.toml 中是否指定了 rand = "0.10.1"。后续版本的 rand 可能会使用不同的 API,但 0.10 系列中的任何版本都应适用于本章代码。
比较猜测与秘密数字
现在我们已经有了用户输入和随机数,可以将二者进行比较。代码清单 2-4 展示了这一步。注意,这段代码暂时还无法编译,原因稍后解释。
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
// --snip--
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_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}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
首先,我们添加另一条 use 语句,将标准库中的 std::cmp::Ordering 类型引入作用域。Ordering 也是一个枚举,有 Less、Greater 和 Equal 三种变体,分别对应比较两个值时可能出现的三种结果。
接着,我们在底部添加五行使用 Ordering 类型的新代码。cmp 方法用于比较两个值,可以在任何可比较的值上调用。它接收一个你想与之比较的值的引用:这里是比较 guess 和 secret_number。随后它会返回一个 Ordering 枚举变体;该枚举已通过 use 语句引入作用域。我们使用 match表达式,根据 cmp 比较 guess 与 secret_number 后返回的 Ordering 变体,决定下一步操作。
match 表达式由多个分支组成。每个分支包含一个要匹配的模式,以及当传给 match 的值符合该模式时应运行的代码。Rust 会依次检查每个分支的模式。模式和 match 结构是 Rust 的强大功能:它们让你能够表达代码可能遇到的各种情形,并确保这些情形都得到处理。第 6 章和第 19 章会分别详细介绍这些功能。
让我们通过一个例子看看这里的 match 表达式如何工作。假设用户猜了 50,而这次生成的秘密数字是 38。
代码比较 50 和 38 时,cmp 方法会返回 Ordering::Greater,因为 50 大于 38。match 表达式接收到 Ordering::Greater 后,开始逐个检查分支模式。它先检查第一个分支的模式 Ordering::Less,发现 Ordering::Greater 与之不匹配,于是忽略该分支中的代码,继续检查下一个分支。下一个分支的模式是 Ordering::Greater,这次匹配成功了!该分支中的代码会执行,并在屏幕上打印 Too big!。match 表达式在第一次成功匹配后结束,因此在这种情况下不会检查最后一个分支。
不过,代码清单 2-4 目前还无法编译。试着编译它:
$ cargo build
Compiling libc v0.2.186
Compiling rand_core v0.10.1
Compiling getrandom v0.4.3
Compiling cfg-if v1.0.0
Compiling chacha20 v0.10.1
Compiling rand v0.10.1
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:23:21
|
23 | match guess.cmp(&secret_number) {
| --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
| |
| arguments to this method are incorrect
|
= note: expected reference `&String`
found reference `&{integer}`
note: method defined here
--> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/cmp.rs:1000:7
For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
错误的核心信息是类型不匹配。Rust 有严格的静态类型系统,同时也支持类型推断。编写 let mut guess = String::new() 时,Rust 能推断出 guess 应该是 String,所以无需显式写出类型。另一方面,secret_number 是数字类型。Rust 的一些数字类型可以表示 1 到 100 之间的值,例如 32 位数字 i32、32 位无符号数字 u32、64 位数字 i64 等。除非另有指定,Rust 默认使用 i32;因此,如果没有其他类型信息促使 Rust 推断为不同的数值类型,secret_number 就是 i32。出错原因是 Rust 无法比较字符串和数字类型。
最终,我们希望将程序读取的 String 输入转换为数字类型,以便与秘密数字进行数值比较。为此,需要在 main 函数体中添加下面这一行:
文件名:src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_number}");
println!("Please input your guess.");
// --snip--
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!");
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
这一行是:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
我们创建了一个名为 guess 的变量。不过,程序不是已经有一个名为 guess 的变量了吗?没错,但 Rust 允许我们用新值覆盖之前的 guess。遮蔽让我们可以重复使用 guess 这个变量名,而不必强行创建两个不同的变量,例如 guess_str 和 guess。我们会在第 3 章详细介绍;现在只需知道,当你想将值从一种类型转换为另一种类型时,经常会用到这一功能。
我们将这个新变量绑定到表达式 guess.trim().parse()。表达式中的 guess 指向原来的 guess 变量,它以字符串形式保存用户输入。String 实例的 trim 方法会去掉开头和结尾的空白字符;在将字符串转换为只能包含数值数据的 u32 之前,必须先进行这一步。用户必须按 enter 才能让 read_line 完成输入,这会在字符串中添加换行符。例如,用户输入 5 并按下 enter 后,guess 看起来是 5\n。其中 \n 表示“换行”。(在 Windows 上,按下 enter 会产生回车符和换行符,即 \r\n。)trim 方法会去掉 \n 或 \r\n,最终只剩下 5。
字符串的 parse 方法会将字符串转换为其他类型。这里我们用它将字符串转换为数字。通过写出 let guess: u32,可以告诉 Rust 我们需要的确切数字类型。guess 后面的冒号(:)表示我们要标注变量类型。Rust 有几种内置数字类型;这里的 u32 是 32 位无符号整数,是表示较小正数的不错默认选择。你将在第 3 章学习其他数字类型。
此外,本例中 guess 的 u32 类型标注,以及它与 secret_number 的比较,意味着 Rust 会推断 secret_number 也应该是 u32。这样,比较的就是两个相同类型的值了!
parse 方法只适用于能够合理转换为数字的字符,因此很容易出错。例如,如果字符串中包含 A👍%,就无法将它转换为数字。由于转换可能失败,parse 方法会像 read_line 方法一样返回 Result 类型(前面在“处理可能发生的失败”中讨论过)。我们会再次使用 expect 方法处理这个 Result。如果 parse 因无法从字符串创建数字而返回 Err 变体,expect 调用会让游戏崩溃并打印我们提供的消息。如果 parse 成功将字符串转换为数字,它会返回 Ok 变体,而 expect 会从 Ok 值中取出我们需要的数字。
现在运行程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!
很好!即使猜测前面有空格,程序仍然能识别出用户猜的是 76。多运行几次程序,验证不同输入会产生不同结果:猜对数字、猜得过高,以及猜得过低。
现在游戏的大部分功能已经完成,但用户只能猜一次。让我们添加循环来改变这一点!
使用循环允许多次猜测
loop 关键字会创建一个无限循环。我们将添加循环,让用户有更多机会猜数字:
文件名:src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
// --snip--
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
// --snip--
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!");
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
}
如你所见,我们已将从猜测提示开始的所有内容都移入循环。记得将循环中的每行代码再缩进四个空格,然后重新运行程序。现在程序会不停地请求用户猜测,这又带来了一个新问题:用户似乎无法退出!
用户始终可以使用键盘快捷键 ctrl-C 中断程序。不过,如“比较猜测与秘密数字”中讨论 parse 时提到的,还有另一种方法可以摆脱这个停不下来的程序:如果用户输入的不是数字,程序就会崩溃。我们可以利用这一点让用户退出,如下所示:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' (6694925) panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
输入 quit 会退出游戏,但你会发现,输入任何其他非数字内容也会退出。显然这不太理想;我们还希望用户猜对数字时游戏能够停止。
猜对后退出
让我们添加 break 语句,使用户获胜时游戏退出:
文件名:src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_number}");
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 = guess.trim().parse().expect("Please type a number!");
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
在 You win! 之后添加 break 行,就能让用户猜对秘密数字时退出循环。由于循环是 main 的最后一部分,退出循环也就意味着退出程序。
处理无效输入
为了进一步完善游戏行为,我们不再让用户输入非数字时程序崩溃,而是让游戏忽略非数字输入,使用户可以继续猜测。按照代码清单 2-5 修改将 guess 从 String 转换为 u32 的那一行即可。
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
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}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
我们用 match 表达式替换了 expect 调用,从而将出错时崩溃改为处理错误。还记得 parse 会返回 Result 类型,而 Result 是一个包含 Ok 和 Err 两种变体的枚举。这里的 match 用法与处理 cmp 方法返回的 Ordering 结果时相同。
如果 parse 成功将字符串转换为数字,就会返回一个包含转换结果的 Ok 值。这个 Ok 值会匹配第一个分支的模式,match 表达式会直接返回 parse 生成并放在 Ok 中的 num 值。这个数字正好会赋给我们正在创建的新 guess 变量。
如果 parse无法将字符串转换为数字,就会返回一个包含更多错误信息的 Err 值。它与第一个分支中的 Ok(num) 模式不匹配,但会匹配第二个分支中的 Err(_) 模式。下划线 _ 是一个通配值;本例中,我们表示要匹配所有 Err 值,无论其中包含什么信息。因此程序会执行第二个分支中的 continue,也就是进入 loop 的下一轮并请求用户再次猜测。这样,程序实际上忽略了 parse 可能遇到的所有错误!
现在程序中的所有功能应该都能按预期工作。试试看:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!
太棒了!再做一个小小的最后调整,猜数字游戏就完成了。还记得程序仍会打印秘密数字吗?这对测试很有用,但会破坏游戏体验。让我们删除用于输出秘密数字的 println!。代码清单 2-6 展示最终代码。
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Guess the number!");
let secret_number = rand::rng().random_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;
}
}
}
}
到这里,你已经成功构建了猜数字游戏。恭喜!
总结
这个项目通过实践介绍了许多 Rust 新概念:let、match、函数、使用外部 crate 等。接下来的几章会更详细地介绍这些概念。第 3 章讲解许多编程语言共有的概念,例如变量、数据类型和函数,并展示如何在 Rust 中使用它们。第 4 章探讨所有权,这是 Rust 区别于其他语言的一项特性。第 5 章讨论结构体和方法语法,第 6 章解释枚举的工作方式。