编程一个猜数游戏

让我们一起通过一个动手实践的项目来学习 Rust!本章将通过在一个实际程序中使用一些常见的 Rust 概念来介绍它们。你会学到关于 letmatch、方法、关联函数、外部 crate 等知识!在接下来的章节中,我们将更详细地探讨这些概念。在本章中,你将只练习基础知识。

我们将实现一个经典的初学者编程问题:猜数游戏。它是这样工作的:程序会生成一个介于 1 到 100 之间(包含边界)的随机整数。然后会提示玩家输入一个猜测的数字。在输入猜测后,程序会指示猜测太低或太高。如果猜测正确,游戏会打印祝贺消息并退出。

设置新项目

要设置一个新项目,请进入你在第一章中创建的 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]

正如你在第一章中看到的,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 `file:///projects/guessing_game/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;

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 库为你提供了许多有用的功能,包括接受用户输入的能力。

正如你在第一章中看到的,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 语法声明一个新函数;括号 () 表示没有参数;花括号 { 开启函数体。

正如你在第一章中也学到的,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 中,变量默认是不可变的,这意味着一旦我们给变量一个值,这个值就不会改变。我们将在“变量与可变性”一节中详细讨论这个概念。要使变量可变,我们在变量名前加上 mut

let apples = 5; // immutable
let mut bananas = 5; // mutable

注意:// 语法开启一个注释,持续到行尾。Rust 会忽略注释中的所有内容。我们将在第三章中更详细地讨论注释。.

回到猜数游戏程序,你现在知道 let mut guess 会引入一个名为 guess 的可变变量。等号(=)告诉 Rust 我们现在想把某些东西绑定到这个变量上。等号的右边是 guess 绑定到的值,它是调用 String::new 函数的结果,这个函数返回一个新的 String 实例。String是标准库提供的一种字符串类型,它是可增长的、UTF-8 编码的文本片段。

::new 中的 :: 语法表示 newString 类型的关联函数。关联函数 是在一个类型上实现的函数,在本例中是 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 来使其可变。(第四章会更彻底地解释引用。)

使用 Result 处理潜在失败

我们还在处理这行代码。我们现在讨论的是第三行文本,但请注意它仍然是单一逻辑代码行的一部分。下一部分是这个方法

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::stdin().read_line(&mut guess).expect("Failed to read line");

然而,一行过长会难以阅读,所以最好将其分开。当你使用 .method_name() 语法调用方法时,引入换行符和其他空白字符来帮助分割长行通常是明智的。现在让我们讨论一下这行代码的作用。

如前所述,read_line 将用户输入的任何内容放入我们传递给它的字符串中,但它也返回一个 Result 值。Result是一个枚举(enumeration),通常称为 enum,它是一种可以处于多种可能状态之一的类型。我们将每种可能的状态称为 变体(variant)

第六章将更详细地介绍枚举。这些 Result 类型的目的是编码错误处理信息。

Result 的变体是 OkErrOk 变体表示操作成功,并包含成功生成的值。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: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` 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。你将在第九章学习如何从错误中恢复。.

使用 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,它包含的代码旨在用于其他程序,自身不能直接执行。

Cargo 对外部 crate 的协调是其真正出彩的地方。在我们编写使用 rand 的代码之前,需要修改 Cargo.toml 文件,将 rand crate 作为依赖项包含进来。现在打开该文件,并在 Cargo 为你创建的 [dependencies] 部分标题下方添加以下一行。确保严格按照此处所示指定 rand 和此版本号,否则本教程中的代码示例可能无法工作

文件名: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 文件中,标题后面的所有内容都属于该部分,直到另一个部分开始。在 [dependencies] 中,你告诉 Cargo 你的项目依赖于哪些外部 crate 以及需要这些 crate 的哪个版本。在本例中,我们使用语义版本说明符 0.8.5 指定了 rand crate。Cargo 理解语义版本控制 (Semantic Versioning)(有时称为 SemVer),这是一种编写版本号的标准。说明符 0.8.5 实际上是 ^0.8.5 的缩写,表示任何版本 >= 0.8.5 但 < 0.9.0。

Cargo 认为这些版本的公共 API 与 0.8.5 版本兼容,并且这个规范确保你将获得能与本章代码一起编译的最新补丁版本。任何 0.9.0 或更高版本不保证具有与以下示例所使用的相同的 API。

现在,不改变任何代码,让我们构建项目,如列表 2-2 所示。

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
列表 2-2: 添加 rand crate 作为依赖后运行 cargo build 的输出

你可能会看到不同的版本号(但多亏了 SemVer,它们都将与代码兼容!)和不同的行(取决于操作系统),并且行的顺序可能有所不同。

当我们包含一个外部依赖时,Cargo 会从 注册表(registry) 中获取该依赖所需的所有内容的最新版本,注册表是 Crates.io 的数据副本。Crates.io 是 Rust 生态系统中人们发布他们的开源 Rust 项目供他人使用的地方。

更新注册表后,Cargo 会检查 [dependencies] 部分,并下载其中列出的尚未下载的任何 crate。在这种情况下,尽管我们只将 rand 列为依赖项,但 Cargo 也会获取 rand 正常工作所依赖的其他 crate。下载 crate 后,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` profile [unoptimized + debuginfo] target(s) in 0.13s

这些行显示 Cargo 只会使用你对 src/main.rs 文件所做的微小改动来更新构建。你的依赖项没有改变,所以 Cargo 知道它可以重用已经下载和编译好的那些。

使用 Cargo.lock 文件确保可复现构建

Cargo 有一种机制,可以确保你或任何其他人每次构建代码时都能重建相同的制品:Cargo 只会使用你指定的依赖项版本,除非你另行指示。例如,假设下周 rand crate 发布了 0.8.6 版本,该版本包含一个重要的错误修复,但同时也包含一个会导致你的代码出错的回归问题。为了处理这个问题,Rust 在你第一次运行 cargo build 时会创建 Cargo.lock 文件,所以现在 guessing_game 目录中就有了这个文件。

当你第一次构建项目时,Cargo 会找出所有符合条件的依赖项版本,然后将它们写入 Cargo.lock 文件。将来构建项目时,Cargo 会看到 Cargo.lock 文件存在,并会使用其中指定的版本,而不是再次进行找出版本的所有工作。这让你能够自动获得可复现的构建。换句话说,多亏了 Cargo.lock 文件,你的项目将保持在 0.8.5 版本,直到你明确升级。因为 Cargo.lock 文件对于可复现构建很重要,所以它通常会与项目中的其余代码一起提交到版本控制。

更新 Crate 以获取新版本

当你确实想更新 crate 时,Cargo 提供了 update 命令,它会忽略 Cargo.lock 文件,并根据你在 Cargo.toml 中的规范找出所有最新的版本。Cargo 然后会将这些版本写入 Cargo.lock 文件。在这种情况下,Cargo 只会查找大于 0.8.5 且小于 0.9.0 的版本。如果 rand crate 发布了两个新版本 0.8.6 和 0.9.0,当你运行 cargo update 时会看到以下内容

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo 会忽略 0.9.0 版本。此时,你还会注意到 Cargo.lock 文件发生了变化,其中注明你现在使用的 rand crate 版本是 0.8.6。要使用 rand 0.9.0 版本或 0.9.x 系列中的任何版本,你必须将 Cargo.toml 文件更新为如下所示

[dependencies]
rand = "0.9.0"

下次运行 cargo build 时,Cargo 会更新可用 crate 的注册表,并根据你指定的新版本重新评估你的 rand 要求。

关于 Cargo以及其生态系统,还有很多内容,我们将在第十四章讨论,但目前,你只需要知道这些就够了。Cargo 让重用库变得非常容易,因此 Rustacean 们能够编写由许多包组装而成的更小的项目。

生成随机数

让我们开始使用 rand 来生成要猜测的数字。下一步是更新 src/main.rs,如列表 2-3 所示。

文件名: src/main.rs
use std::io;

use rand::Rng;

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");

    println!("You guessed: {guess}");
}
列表 2-3: 添加生成随机数的代码

首先,我们添加 use rand::Rng; 这行代码。Rng trait 定义了随机数生成器实现的方法,这个 trait 必须在作用域内才能使用这些方法。第十章将详细介绍 trait。

接下来,我们在中间添加两行代码。第一行,我们调用 rand::thread_rng 函数,它提供了我们将要使用的特定随机数生成器:一个本地于当前执行线程并由操作系统种子化的生成器。然后我们在随机数生成器上调用 gen_range 方法。这个方法是由我们通过 use rand::Rng; 语句引入作用域的 Rng trait 定义的。gen_range 方法接受一个范围表达式作为参数,并在该范围内生成一个随机数。我们在这里使用的范围表达式采用 start..=end 的形式,包含下界和上界,因此我们需要指定 1..=100 来请求一个介于 1 和 100 之间的数字(包含边界)。

注意:你不可能仅仅通过查看 crate 就知道使用哪些 trait 以及调用哪些方法和函数,因此每个 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 之间的数字。干得好!

比较猜测与秘密数字

现在我们有了用户输入和随机数,就可以比较它们了。这一步如列表 2-4 所示。请注意,这段代码目前还无法编译,我们稍后会解释原因。

文件名: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    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");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
列表 2-4: 处理比较两个数字的可能返回值

首先,我们添加另一个 use 语句,将标准库中的一个名为 std::cmp::Ordering 的类型引入作用域。Ordering 类型是另一个枚举,具有 LessGreaterEqual 这三个变体。这是你比较两个值时可能出现的三种结果。

然后我们在底部添加五行使用 Ordering 类型的代码。cmp 方法比较两个值,可以在任何可比较的事物上调用。它接受一个你想要比较对象的引用:这里它比较 guesssecret_number。然后它返回我们通过 use 语句引入作用域的 Ordering 枚举的一个变体。我们使用一个match表达式,根据 cmp 调用 guesssecret_number 的值返回的 Ordering 变体来决定接下来做什么。

一个 match 表达式由 分支(arms) 组成。一个分支包含一个要匹配的 模式(pattern),以及当给 match 的值符合该分支模式时应该运行的代码。Rust 获取给 match 的值,然后依次检查每个分支的模式。模式和 match 构造是强大的 Rust 特性:它们允许你表达代码可能遇到的各种情况,并确保你处理了所有情况。这些特性将分别在第六章和第十九章详细介绍。

让我们结合此处使用的 match 表达式来看一个例子。假设用户猜测的是 50,而这次随机生成的秘密数字是 38。

当代码将 50 与 38 进行比较时,cmp 方法会返回 Ordering::Greater,因为 50 大于 38。match 表达式获取 Ordering::Greater 值,并开始检查每个分支的模式。它查看第一个分支的模式 Ordering::Less,发现值 Ordering::GreaterOrdering::Less 不匹配,因此忽略该分支中的代码,移至下一个分支。下一个分支的模式是 Ordering::Greater,它 确实Ordering::Greater 匹配!该分支中关联的代码将执行并打印 Too big! 到屏幕上。match 表达式在第一个成功匹配后结束,因此在这种情况下不会查看最后一个分支。

然而,列表 2-4 中的代码还无法编译。让我们试试看

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   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 `&String`, found `&{integer}`
    |                 |
    |                 arguments to this method are incorrect
    |
    = note: expected reference `&String`
               found reference `&{integer}`
note: method defined here
   --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/cmp.rs:964:8
    |
964 |     fn cmp(&self, other: &Self) -> Ordering;
    |        ^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

错误的核心信息指出存在 类型不匹配(mismatched types)。Rust 拥有一个强大、静态的类型系统。然而,它也具有类型推断能力。当我们写 let mut guess = String::new() 时,Rust 能够推断出 guess 应该是一个 String,并且没有要求我们写明类型。另一方面,secret_number 是一个数字类型。Rust 的一些数字类型可以存储 1 到 100 之间的值:i32,一个 32 位数字;u32,一个无符号 32 位数字;i64,一个 64 位数字;以及其他类型。除非另有指定,Rust 默认使用 i32,这是 secret_number 的类型,除非你在其他地方添加类型信息导致 Rust 推断出不同的数字类型。错误的原因是 Rust 无法比较字符串和数字类型。

最终,我们希望将程序作为输入读取的 String 转换成数字类型,这样我们就可以用数字方式与秘密数字进行比较了。我们通过将这行代码添加到 main 函数体中来实现这一点

文件名: src/main.rs

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);

    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 很方便地允许我们用一个新的值来 遮蔽(shadow) 先前 guess 的值。遮蔽(Shadowing) 允许我们重用 guess 变量名,而不是强迫我们创建两个唯一的变量,例如 guess_strguess。我们将在第三章中更详细地介绍这一点,但目前,你需要知道这个特性常用于将一个值从一种类型转换为另一种类型。

我们将这个新变量绑定到表达式 guess.trim().parse()。表达式中的 guess 指的是最初包含输入字符串的 guess 变量。String 实例上的 trim 方法会消除字符串开头和结尾的所有空白字符,这是我们在将字符串转换为只能包含数字数据的 u32 之前必须做的。用户必须按 enter 才能满足 read_line 并输入猜测,这会给字符串添加一个换行符。例如,如果用户输入 5 然后按 enterguess 看起来像这样:5\n\n 代表“换行”。(在 Windows 上,按 enter 会产生一个回车符和一个换行符,即 \r\n。)trim 方法会消除 \n\r\n,结果只剩下 5

字符串上的parse 方法将字符串转换为另一种类型。这里,我们用它来将字符串转换为数字。我们需要通过使用 let guess: u32 来告诉 Rust 我们想要的具体数字类型。guess 后面的冒号(:)告诉 Rust 我们将标注变量的类型。Rust 有几种内置数字类型;这里看到的 u32 是一个无符号的 32 位整数。对于小的正数来说,这是一个很好的默认选择。你将在第三章学习其他数字类型。.

此外,本示例程序中的 u32 类型标注以及与 secret_number 的比较意味着 Rust 会推断出 secret_number 也应该是 u32 类型。所以现在比较将在两个相同类型的值之间进行!

parse 方法只对可以逻辑上转换为数字的字符有效,因此很容易导致错误。例如,如果字符串包含 A👍%,则无法将其转换为数字。由于它可能失败,parse 方法返回一个 Result 类型,就像 read_line 方法一样(之前在“使用 Result 处理潜在失败”)中讨论过)。我们将再次使用 expect 方法来处理这个 Result。如果 parse 因为无法从字符串创建数字而返回 Err Result 变体,expect 调用将导致游戏崩溃并打印我们提供给它的消息。如果 parse 能够成功将字符串转换为数字,它将返回 ResultOk 变体,并且 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::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_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' 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::Rng;

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}");

        // --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 的最后一部分。

处理无效输入

为了进一步改进游戏的行为,当用户输入非数字时,我们不让程序崩溃,而是让游戏忽略非数字输入,这样用户可以继续猜测。我们可以通过修改将 guessString 转换为 u32 的那一行来实现,如列表 2-5 所示。

文件名: src/main.rs
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);

    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;
            }
        }
    }
}
列表 2-5: 忽略非数字猜测并要求重新猜测而不是使程序崩溃

我们将从调用 expect 切换到 match 表达式,从而从错误时崩溃转为处理错误。记住 parse 返回一个 Result 类型,而 Result 是一个具有 OkErr 变体的枚举。我们在这里使用 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` 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 显示了最终代码。

文件名: src/main.rs
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;
            }
        }
    }
}
列表 2-6: 完整的猜数游戏代码

至此,你已成功构建了猜数游戏。恭喜!

总结

这个项目是一种动手实践的方式,向你介绍了许多新的 Rust 概念:letmatch、函数、外部 crate 的使用等等。在接下来的几章中,你将更详细地了解这些概念。第三章介绍了大多数编程语言都具备的概念,例如变量、数据类型和函数,并展示了如何在 Rust 中使用它们。第四章探讨了所有权,这是一个使 Rust 与其他语言不同的特性。第五章讨论结构体和方法语法,第六章解释了枚举的工作原理。