简介
欢迎来到猜数字游戏编程。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将用 Rust 实现一个猜数字游戏,程序会生成一个随机数并提示玩家猜测,根据猜测结果给出反馈,提示猜测过高或过低,如果玩家猜对则表示祝贺。
This tutorial is from open-source community. Access the source code
💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版
欢迎来到猜数字游戏编程。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将用 Rust 实现一个猜数字游戏,程序会生成一个随机数并提示玩家猜测,根据猜测结果给出反馈,提示猜测过高或过低,如果玩家猜对则表示祝贺。
让我们通过一起完成一个实践项目来深入学习 Rust!本章将通过向你展示如何在实际程序中使用一些常见的 Rust 概念,来让你对它们有所了解。你将学习 let
、match
、方法、关联函数、外部 crate 等等!在接下来的章节中,我们将更详细地探讨这些概念。在本章中,你只需练习基础知识。
我们将实现一个经典的初学者编程问题:猜数字游戏。它的工作原理如下:程序将生成一个介于 1 到 100 之间的随机整数。然后它会提示玩家输入猜测。输入猜测后,程序将指示猜测是过高还是过低。如果猜测正确,游戏将打印一条祝贺消息并退出。
要创建一个新项目,进入你在第一章中创建的 project
目录,然后使用 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 = "2021"
## 更多键及其定义请参阅
https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
正如你在第一章中看到的,cargo new
为你生成了一个 “Hello, world!” 程序。看看 src/main.rs
文件:
文件名:src/main.rs
fn main() {
println!("Hello, world!");
}
现在让我们编译这个 “Hello, world!” 程序,并使用 cargo run
命令在同一步骤中运行它:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 1.50s
Running `target/debug/guessing_game`
Hello, world!
当你需要在项目上快速迭代时,run
命令非常方便,就像我们在这个游戏中要做的那样,在进入下一次迭代之前快速测试每次迭代。
重新打开 src/main.rs
文件。你将在这个文件中编写所有代码。
猜数字游戏程序的第一部分将要求用户输入,处理该输入,并检查输入是否符合预期格式。首先,我们将允许玩家输入猜测。将清单2-1中的代码输入到 src/main.rs
中。
文件名: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}");
}
清单2-1:从用户获取猜测并打印的代码
这段代码包含了很多信息,所以让我们逐行分析。为了获取用户输入并将结果作为输出打印出来,我们需要将 io
输入/输出库引入作用域。io
库来自标准库 std
:
use std::io;
默认情况下,Rust 在标准库中定义了一组项,并将其引入每个程序的作用域。这组项被称为 标准 prelude,你可以在 https://doc.rust-lang.org/std/prelude/index.html 查看其中的所有内容。
如果你想要使用的类型不在 prelude 中,你必须使用 use
语句将该类型显式引入作用域。使用 std::io
库为你提供了许多有用的功能,包括接受用户输入的能力。
正如你在第一章中看到的,main
函数是程序的入口点:
fn main() {
fn
语法声明一个新函数;括号 ()
表示没有参数;花括号 {
开始函数体。
同样在第一章中你学到,println!
是一个宏,用于将字符串打印到屏幕上:
println!("Guess the number!");
println!("Please input your guess.");
这段代码打印了一个提示,说明游戏是什么,并请求用户输入。
接下来,我们将创建一个 变量 来存储用户输入,如下所示:
let mut guess = String::new();
现在程序变得有趣起来了!这一行代码包含了很多内容。我们使用 let
语句来创建变量。这里有另一个例子:
let apples = 5;
这一行创建了一个名为 apples
的新变量,并将其绑定到值 5。在 Rust 中,变量默认是不可变的,这意味着一旦我们给变量赋了一个值,这个值就不会改变。我们将在“变量与可变性”中详细讨论这个概念。要使变量可变,我们在变量名之前添加 mut
:
let apples = 5; // 不可变
let mut bananas = 5; // 可变
注意:
//
语法开始一个注释,该注释一直持续到行尾。Rust 会忽略注释中的所有内容。我们将在第 3 章更详细地讨论注释。
回到猜数字游戏程序,现在你知道 let mut guess
将引入一个名为 guess
的可变变量。等号(=
)告诉 Rust 我们现在想要将某个东西绑定到这个变量上。等号右边是 guess
所绑定的值,它是调用 String::new
的结果,String::new
是一个返回 String
新实例的函数。String
是标准库提供的一种字符串类型,它是一个可增长的、UTF-8 编码的文本片段。
::new
这一行中的 ::
语法表示 new
是 String
类型的一个关联函数。关联函数 是在某个类型上实现的函数,在这种情况下是 String
类型。这个 new
函数创建一个新的空字符串。你会在许多类型上找到 new
函数,因为它是创建某种新值的函数的常用名称。
完整地说,let mut guess = String::new();
这一行创建了一个可变变量,该变量目前绑定到一个新的、空的 String
实例。呼!
还记得我们在程序的第一行使用 use std::io;
引入了标准库中的输入/输出功能吧。现在我们将调用 io
模块中的 stdin
函数,它将允许我们处理用户输入:
io::stdin()
.read_line(&mut guess)
如果我们在程序开头没有使用 use std::io;
导入 io
库,我们仍然可以通过将这个函数调用写成 std::io::stdin
来使用该函数。stdin
函数返回一个 std::io::Stdin
的实例,std::io::Stdin
是一种表示终端标准输入句柄的类型。
接下来,.read_line(&mut guess)
这一行在标准输入句柄上调用 read_line
方法以从用户获取输入。我们还将 &mut guess
作为参数传递给 read_line
,告诉它将用户输入存储到哪个字符串中。read_line
的全部工作是获取用户输入到标准输入的任何内容,并将其追加到一个字符串中(而不覆盖其内容),所以我们将那个字符串作为参数传递。字符串参数需要是可变的,以便该方法可以更改字符串的内容。
&
表示这个参数是一个 引用,它为你提供了一种方式,让代码的多个部分可以访问同一数据片段,而无需将该数据多次复制到内存中。引用是一个复杂的特性,而 Rust 的主要优势之一在于使用引用是多么安全和容易。完成这个程序你不需要了解很多这些细节。目前,你只需要知道,和变量一样,引用默认是不可变的。因此,你需要写成 &mut guess
而不是 &guess
来使其可变。(第 4 章将更全面地解释引用。)
我们仍在处理这行代码。现在我们要讨论第三行文本,但请注意它仍然是单个逻辑行代码的一部分。接下来的部分是这个方法:
.expect("Failed to read line");
我们本可以将这段代码写成:
io::stdin().read_line(&mut guess).expect("Failed to read line");
然而,一长行代码很难阅读,所以最好将其拆分。当你使用 .method_name()
语法调用方法时,引入换行符和其他空白字符来帮助拆分长行通常是明智的。现在让我们来讨论这一行代码的作用。
如前所述,read_line
会将用户输入的任何内容放入我们传递给它的字符串中,但它也会返回一个 Result
值。Result
是一种 枚举类型,通常称为 枚举,它是一种可以处于多种可能状态之一的类型。我们将每个可能的状态称为一个 变体。
第 6 章将更详细地介绍枚举。这些 Result
类型的目的是对错误处理信息进行编码。
Result
的变体是 Ok
和 Err
。Ok
变体表示操作成功,Ok
内部是成功生成的值。Err
变体表示操作失败,Err
包含有关操作如何或为何失败的信息。
Result
类型的值,就像任何类型的值一样,都定义了一些方法。Result
的一个实例有一个 expect
方法,你可以调用它。如果这个 Result
实例是一个 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: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
warning: `guessing_game` (bin "guessing_game") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.59s
Rust 会警告你没有使用 read_line
返回的 Result
值,这表明程序没有处理可能出现的错误。
抑制警告的正确方法是实际编写错误处理代码,但在我们的例子中,我们只是希望在出现问题时使程序崩溃,所以我们可以使用 expect
。你将在第 9 章学习如何从错误中恢复。
到目前为止,除了结尾的花括号外,代码中只剩下一行需要讨论:
println!("You guessed: {guess}");
这一行打印出现在包含用户输入的字符串。花括号 {}
是一个占位符:可以把 {}
想象成小螃蟹钳子,用来固定一个值。当打印变量的值时,变量名可以放在花括号内。当打印表达式求值的结果时,在格式字符串中放置空花括号,然后在格式字符串后面跟上一个逗号分隔的表达式列表,这些表达式将按照相同的顺序打印到每个空花括号占位符中。在一次调用 println!
中打印变量和表达式的结果看起来像这样:
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
这段代码将打印出 x = 5 and y = 12
。
让我们测试猜数字游戏的第一部分。使用 cargo run
运行它:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [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 团队在 https://crates.io/crates/rand 提供了一个具有该功能的 rand
包。
请记住,包是 Rust 源代码文件的集合。我们一直在构建的项目是一个 二进制包,它是一个可执行文件。rand
包是一个 库包,它包含的代码旨在供其他程序使用,不能单独执行。
Cargo 对外部包的协调是它真正发挥作用的地方。在我们编写使用 rand
的代码之前,需要修改 Cargo.toml
文件,将 rand
包作为依赖项包含进来。现在打开该文件,在 Cargo 为你创建的 [dependencies]
部分标题下方的底部添加以下行。请确保按照我们这里的写法准确指定 rand
及其版本号,否则本教程中的代码示例可能无法正常工作:
文件名:Cargo.toml
[dependencies]
rand = "0.8.5"
在 Cargo.toml
文件中,跟在标题后面的所有内容都是该部分的一部分,一直延续到另一个部分开始。在 [dependencies]
中,你要告诉 Cargo 你的项目依赖哪些外部包以及你需要这些包的哪些版本。在这种情况下,我们使用语义化版本规范 0.8.5
指定了 rand
包。Cargo 理解语义化版本控制(有时称为 SemVer),这是一种编写版本号的标准。规范 0.8.5
实际上是 ^0.8.5
的简写,意思是任何至少为 0.8.5 但低于 0.9.0 的版本。
Cargo 认为这些版本具有与 0.8.5 版本兼容的公共 API,并且这个规范确保你将获得最新的补丁版本,该版本仍然可以与本章中的代码编译。任何 0.9.0 或更高版本不能保证具有与以下示例中使用的相同 API。
现在,在不更改任何代码的情况下,让我们像清单 2-2 中那样构建项目。
$ cargo build
Updating crates.io index
Downloaded rand v0.8.5
Downloaded libc v0.2.127
Downloaded getrandom v0.2.7
Downloaded cfg-if v1.0.0
Downloaded ppv-lite86 v0.2.16
Downloaded rand_chacha v0.3.1
Downloaded rand_core v0.6.3
Compiling rand_core v0.6.3
Compiling libc v0.2.127
Compiling getrandom v0.2.7
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.16
Compiling rand_chacha v0.3.1
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
清单 2-2:添加 rand
包作为依赖项后运行 cargo build
的输出
你可能会看到不同的版本号(但由于 SemVer,它们都将与代码兼容!)和不同的行(取决于操作系统),并且这些行的顺序可能也不同。
当我们包含一个外部依赖项时,Cargo 会从 注册表 中获取该依赖项所需的所有内容的最新版本,注册表是来自 https://crates.io 的 Crates.io 数据的副本。Crates.io 是 Rust 生态系统中的人们发布他们的开源 Rust 项目以供其他人使用的地方。
更新注册表后,Cargo 会检查 [dependencies]
部分并下载任何尚未下载的列出的包。在这种情况下,虽然我们只将 rand
列为依赖项,但 Cargo 也会获取 rand
运行所需的其他依赖包。下载完这些包后,Rust 会对它们进行编译,然后使用可用的依赖项编译项目。
如果你在不做任何更改的情况下立即再次运行 cargo build
,除了 Finished
行之外你不会得到任何输出。Cargo 知道它已经下载并编译了依赖项,并且你在 Cargo.toml
文件中没有对它们进行任何更改。Cargo 也知道你没有对你的代码进行任何更改,所以它也不会重新编译代码。由于无事可做,它就直接退出了。
如果你打开 src/main.rs
文件,进行一个小的更改,然后保存并再次构建,你只会看到两行输出:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
这些行表明 Cargo 仅根据你对 src/main.rs
文件所做的微小更改来更新构建。你的依赖项没有更改,所以 Cargo 知道它可以重用已经为这些依赖项下载和编译的内容。
Cargo 有一种机制,可确保每次你或其他任何人构建你的代码时,都能重建相同的工件:在你另行指定之前,Cargo 将仅使用你指定的依赖项版本。例如,假设下周 rand
包发布了 0.8.6 版本,该版本包含一个重要的错误修复,但也包含一个会破坏你代码的回归问题。为了处理这种情况,在你第一次运行 cargo build
时,Rust 会创建 Cargo.lock 文件,所以现在我们在 guessing_game 目录中有了这个文件。
当你第一次构建项目时,Cargo 会找出所有符合条件的依赖项版本,然后将它们写入 Cargo.lock 文件。在你未来构建项目时,Cargo 会看到 Cargo.lock 文件存在,并将使用其中指定的版本,而不是再次进行所有确定版本的工作。这使你能够自动进行可重现的构建。换句话说,由于 Cargo.lock 文件,在你明确升级之前,你的项目将保持在 0.8.5 版本。因为 Cargo.lock 文件对于可重现的构建很重要,所以它通常会与你项目中的其他代码一起被检入到版本控制系统中。
当你确实想要更新一个包时,Cargo 提供了 update
命令,它会忽略 Cargo.lock 文件,并找出 Cargo.toml
中符合你指定要求的所有最新版本。然后,Cargo 会将这些版本写入 Cargo.lock 文件。否则,默认情况下,Cargo 只会查找大于 0.8.5 且小于 0.9.0 的版本。如果 rand
包发布了 0.8.6 和 0.9.0 这两个新版本,当你运行 cargo update
时,你会看到以下内容:
$ cargo update
Updating crates.io index
Updating rand v0.8.5 - > v0.8.6
Cargo 会忽略 0.9.0 版本。此时,你还会注意到 Cargo.lock 文件有变化,表明你现在使用的 rand
包版本是 0.8.6。要使用 0.9.0 版本的 rand
或 0.9.x 系列中的任何版本,你需要将 Cargo.toml
文件更新为如下内容:
[dependencies]
rand = "0.9.0"
下次你运行 cargo build
时,Cargo 会更新可用包的注册表,并根据你指定的新版本重新评估你对 rand
的需求。
关于 Cargo 及其生态系统还有很多内容可说,我们将在第 14 章讨论,但目前,这些就是你需要知道的全部内容。Cargo 使得重用库变得非常容易,所以 Rust 开发者能够编写由多个包组装而成的更小的项目。
让我们开始使用 rand
来生成一个要猜测的数字。下一步是更新 src/main.rs
,如清单 2-3 所示。
文件名:src/main.rs
use std::io;
1 use rand::Rng;
fn main() {
println!("Guess the number!");
2 let secret_number = rand::thread_rng().gen_range(1..=100);
3 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}");
}
清单 2-3:添加生成随机数的代码
首先,我们添加了 use rand::Rng;
这一行 [1]。Rng
特性定义了随机数生成器实现的方法,并且为了使用这些方法,这个特性必须在作用域内。第 10 章将详细介绍特性。
接下来,我们在中间添加了两行。在第一行 [2] 中,我们调用 rand::thread_rng
函数,它为我们提供要使用的特定随机数生成器:一个是当前执行线程本地的并且由操作系统播种的生成器。然后我们在随机数生成器上调用 gen_range
方法。这个方法是由我们通过 use rand::Rng;
语句引入作用域的 Rng
特性定义的。gen_range
方法接受一个范围表达式作为参数,并在该范围内生成一个随机数。我们这里使用的范围表达式形式为 start..=end
,并且包含下限和上限,所以我们需要指定 1..=100
来请求一个 1 到 100 之间的数字。
注意:你不会仅仅知道要使用哪些特性以及从一个包中调用哪些方法和函数,所以每个包都有使用说明的文档。Cargo 的另一个很棒的特性是运行
cargo doc --open
命令将在本地构建你所有依赖项提供的文档并在你的浏览器中打开它。例如,如果你对rand
包中的其他功能感兴趣,运行cargo doc --open
并在左边的侧边栏中点击rand
。
第二行新代码 [3] 打印秘密数字。在我们开发程序以便能够测试它的时候这很有用,但我们会在最终版本中删除它。如果程序一启动就打印出答案,那就没什么游戏可言了!
试着运行几次这个程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
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 [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 之间的数字。干得好!
既然我们已经有了用户输入和一个随机数,就可以对它们进行比较了。这一步如清单 2-4 所示。请注意,这段代码目前还无法编译,稍后我们会解释原因。
文件名:src/main.rs
use rand::Rng;
1 use std::cmp::Ordering;
use std::io;
fn main() {
--snip--
println!("You guessed: {guess}");
2 match guess.3 cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
清单 2-4:处理比较两个数字时可能的返回值
首先,我们添加了另一个 use
语句 [1],从标准库中引入一个名为 std::cmp::Ordering
的类型。Ordering
类型是另一个枚举,它有 Less
、Greater
和 Equal
这几个变体。这些是比较两个值时可能出现的三种结果。
然后,我们在底部添加了五行使用 Ordering
类型的代码。cmp
方法 [3] 用于比较两个值,并且可以在任何可比较的类型上调用。它接受一个你想要与之比较的引用:这里是将 guess
与 secret_number
进行比较。然后它返回我们通过 use
语句引入作用域的 Ordering
枚举的一个变体。我们使用一个 match
表达式 [2] 根据调用 cmp
时使用 guess
和 secret_number
中的值返回的 Ordering
变体来决定接下来要做什么。
一个 match
表达式由 分支 组成。一个分支由一个用于匹配的 模式 以及如果传递给 match
的值符合该分支的模式时应该运行的代码组成。Rust 会获取传递给 match
的值,并依次查看每个分支的模式。模式和 match
结构是 Rust 的强大特性:它们让你能够表达代码可能遇到的各种情况,并确保你处理所有这些情况。这些特性将分别在第 6 章和第 18 章中详细介绍。
让我们通过这里使用的 match
表达式来走一个示例。假设用户猜了 50,而这次随机生成的秘密数字是 38。
当代码将 50 与 38 进行比较时,cmp
方法将返回 Ordering::Greater
,因为 50 大于 38。match
表达式获取 Ordering::Greater
值并开始检查每个分支的模式。它查看第一个分支的模式 Ordering::Less
,发现值 Ordering::Greater
与 Ordering::Less
不匹配,所以它忽略该分支中的代码并移动到下一个分支。下一个分支的模式是 Ordering::Greater
,它 确实 与 Ordering::Greater
匹配!该分支中的相关代码将执行并在屏幕上打印 Too big!
。match
表达式在第一次成功匹配后结束,所以在这种情况下它不会查看最后一个分支。
然而,清单 2-4 中的代码目前还无法编译。让我们试试看:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:22:21
|
22 | match guess.cmp(&secret_number) {
| ^^^^^^^^^^^^^^ expected struct `String`, found integer
|
= note: expected reference `&String`
found reference `&{integer}`
错误的核心表明存在 类型不匹配。Rust 有一个强大的静态类型系统。不过,它也有类型推断。当我们编写 let mut guess = String::new()
时,Rust 能够推断出 guess
应该是一个 String
类型,而不需要我们显式写出类型。另一方面,secret_number
是一个数字类型。Rust 有几种数字类型的值可以在 1 到 100 之间:i32
,一个 32 位的数字;u32
,一个无符号 32 位的数字;i64
,一个 64 位的数字;以及其他类型。除非另有指定,Rust 默认使用 i32
,除非你在其他地方添加了类型信息导致 Rust 推断出不同的数值类型,否则 secret_number
的类型就是 i32
。错误的原因是 Rust 无法比较字符串和数字类型。
最终,我们希望将程序读取为输入的 String
转换为实际的数字类型,以便能够将其与秘密数字进行数值比较。我们通过在 main
函数体中添加这一行来实现:
文件名:src/main.rs
use std::io;
use rand::Rng;
use std::cmp::Ordering;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_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");
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!"),
}
}
我们创建了一个名为 guess
的变量。但是等等,程序不是已经有一个名为 guess
的变量了吗?没错,不过幸运的是 Rust 允许我们用一个新值 遮蔽 之前的 guess
值。遮蔽 让我们可以重用 guess
变量名,而不必强迫我们创建两个不同的变量,比如 guess_str
和 guess
。我们将在第 3 章更详细地介绍这一点,但现在要知道,当你想将一个值从一种类型转换为另一种类型时,这个特性经常会被用到。
我们将这个新变量绑定到表达式 guess.trim().parse()
。表达式中的 guess
指的是最初包含输入字符串的 guess
变量。String
实例上的 trim
方法会去除字符串开头和结尾的任何空白字符,为了能够将字符串与只能包含数值数据的 u32
进行比较,我们必须这样做。用户必须按下回车键以满足 read_line
并输入他们的猜测,这会在字符串中添加一个换行符。例如,如果用户输入 5
并按下回车键,guess
看起来像这样:5\n
。\n
表示“换行”。(在 Windows 上,按下回车键会产生一个回车符和一个换行符,即 \r\n
。)trim
方法会去除 \n
或 \r\n
,结果只剩下 5
。
字符串上的 parse
方法用于将字符串转换为另一种类型。在这里,我们用它将字符串转换为数字。我们需要通过使用 let guess: u32
告诉 Rust 我们想要的确切数字类型。guess
后面的冒号(:
)告诉 Rust 我们将注释变量的类型。Rust 有几种内置的数字类型;这里看到的 u32
是一个无符号的 32 位整数。对于一个小的正数来说,它是一个不错的默认选择。你将在第 3 章了解其他数字类型。
此外,这个示例程序中的 u32
注释以及与 secret_number
的比较意味着 Rust 也会推断出 secret_number
应该也是一个 u32
。所以现在比较将在两个相同类型的值之间进行!
parse
方法只对可以逻辑上转换为数字的字符有效,因此很容易出错。例如,如果字符串包含 A
👍%
,就没有办法将其转换为数字。因为它可能会失败,所以 parse
方法返回一个 Result
类型,就像 read_line
方法一样(在前面的“使用 Result 处理潜在失败”中讨论过)。我们将再次使用 expect
方法以相同的方式处理这个 Result
。如果 parse
因为无法从字符串创建数字而返回 Err
Result
变体,expect
调用将使游戏崩溃并打印我们给它的消息。如果 parse
能够成功地将字符串转换为数字,它将返回 Result
的 Ok
变体,并且 expect
将从 Ok
值中返回我们想要的数字。
现在让我们运行程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
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::io;
use rand::Rng;
use std::cmp::Ordering;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_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}");
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 [unoptimized + debuginfo] target(s) in 1.50s
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' panicked at 'Please type a number!: ParseIntError
{ kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
输入 quit
会退出游戏,但正如你会注意到的,输入任何其他非数字输入也会这样。至少可以说,这并不理想;我们希望游戏在猜出正确数字时也能停止。
让我们通过添加一个 break
语句来编写程序,使其在用户猜对时退出:
文件名:src/main.rs
--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
函数的最后一部分。
为了进一步优化游戏的行为,当用户输入非数字时,我们不要让程序崩溃,而是让游戏忽略这个非数字输入,这样用户就可以继续猜测。我们可以通过修改将 guess
从 String
转换为 u32
的那一行代码来实现,如清单 2-5 所示。
文件名:src/main.rs
--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--
清单 2-5:忽略非数字猜测并要求再次猜测,而不是让程序崩溃
我们从使用 expect
调用改为使用 match
表达式,以便从在错误时崩溃转变为处理错误。记住,parse
返回一个 Result
类型,而 Result
是一个枚举,它有 Ok
和 Err
这两个变体。我们在这里使用了一个 match
表达式,就像我们对 cmp
方法的 Ordering
结果所做的那样。
如果 parse
能够成功地将字符串转换为数字,它将返回一个包含结果数字的 Ok
值。这个 Ok
值将匹配第一个分支的模式,并且 match
表达式将只返回 parse
生成并放在 Ok
值中的 num
值。这个数字最终会在我们创建的新 guess
变量中处于我们想要的位置。
如果 parse
不能将字符串转换为数字,它将返回一个包含有关错误的更多信息的 Err
值。Err
值与第一个 match
分支中的 Ok(num)
模式不匹配,但它与第二个分支中的 Err(_)
模式匹配。下划线 _
是一个通配符值;在这个例子中,我们说我们想要匹配所有的 Err
值,无论它们内部包含什么信息。所以程序将执行第二个分支的代码 continue
,这会告诉程序进入 loop
的下一次迭代并要求再次猜测。所以,实际上,程序忽略了 parse
可能遇到的所有错误!
现在程序中的所有内容都应该按预期工作了。让我们试试看:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 4.45s
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 展示了最终代码。
文件名:src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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;
}
}
}
}
清单 2-6:完整的猜数字游戏代码
至此,你已经成功构建了猜数字游戏。恭喜!
恭喜你!你已经完成了“编写猜数字游戏”实验。你可以在 LabEx 中练习更多实验来提升你的技能。