编写猜谜游戏

让我们一起通过一个动手项目来开始 Rust 之旅!本章将通过向您展示如何在实际程序中使用一些常见的 Rust 概念来介绍它们。您将学习 letmatch、方法、关联函数、外部 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 = "2021"

# See more keys and their definitions at https://doc.rust-lang.net.cn/cargo/reference/manifest.html

[dependencies]

如您在第 1 章中所见,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` profile [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;

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::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,使其可变。(第 4 章将更彻底地解释引用。)

使用 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枚举,通常称为 enum,它是一种可以处于多种可能状态之一的类型。我们将每个可能的状态称为 变体

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

Result 的变体是 OkErrOk 变体表示操作成功,并且 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: 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。您将在 第 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 [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 的协调是 Cargo 真正闪光的地方。在我们编写使用 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 理解 语义版本控制(有时称为 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
  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 libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_core v0.6.3
   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 crate 作为依赖项后运行 cargo build 的输出

你可能会看到不同的版本号(但由于 SemVer,它们都将与代码兼容!)和不同的行(取决于操作系统),并且这些行可能按不同的顺序排列。

当我们包含外部依赖项时,Cargo 会从注册表中获取该依赖项所需的所有内容的最新版本,该注册表是来自 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 [unoptimized + debuginfo] target(s) in 2.53 secs

这些行显示 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 文件存在,并且将使用其中指定的版本,而不是重新完成计算版本的所有工作。这使你可以自动进行可重复构建。换句话说,你的项目将保持在 0.8.5,直到你明确升级,这要归功于 Cargo.lock 文件。由于 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
    Updating rand v0.8.5 -> v0.8.6

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 还有很多要说的它的生态系统,我们将在第 14 章中讨论,但就目前而言,这就是你需要知道的全部内容。Cargo 使重用库变得非常容易,因此 Rustaceans 可以编写从多个包组装而成的小型项目。

生成一个随机数

让我们开始使用 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 特性定义了随机数生成器实现的方法,并且此特性必须在作用域内,我们才能使用这些方法。第 10 章将详细介绍特性。

接下来,我们在中间添加了两行。在第一行中,我们调用 rand::thread_rng 函数,该函数为我们提供了我们将要使用的特定随机数生成器:一个对当前执行线程本地,并由操作系统播种的生成器。然后,我们在随机数生成器上调用 gen_range 方法。此方法由我们使用 use rand::Rng; 语句引入作用域的 Rng 特性定义。gen_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 [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;
use std::cmp::Ordering;
use std::io;

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表达式来决定基于从使用 guesssecret_number 中的值调用 cmp 返回的 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::GreaterOrdering::Less 不匹配,因此它忽略该分支中的代码并移至下一个分支。下一个分支的模式是 Ordering::Greater,它确实Ordering::Greater 匹配!该分支中的关联代码将执行,并在屏幕上打印 Too big!match 表达式在第一个成功匹配后结束,因此它不会在此场景中查看最后一个分支。

但是,清单 2-4 中的代码还不会编译。让我们尝试一下

$ cargo build
 Downloading crates ...
  Downloaded rand_core v0.6.2
  Downloaded getrandom v0.2.2
  Downloaded rand_chacha v0.3.0
  Downloaded ppv-lite86 v0.2.10
  Downloaded libc v0.2.86
   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
  --> /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/core/src/cmp.rs:839:8

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 之间的值:i32,一个 32 位数字;u32,一个无符号 32 位数字;i64,一个 64 位数字,以及其他类型。除非另有指定,否则 Rust 默认使用 i32,除非您在其他地方添加类型信息导致 Rust 推断出不同的数值类型,否则 secret_number 的类型就是 i32。出现错误的原因是 Rust 无法比较字符串和数字类型。

最终,我们希望将程序读取的 String 输入转换为数字类型,以便我们可以将其与秘密数字进行数值比较。为此,我们在 main 函数体中添加这一行代码

文件名: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);

    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 的值,这很有帮助。遮蔽 让我们能够重用 guess 变量名,而不是强制我们创建两个唯一的变量,例如 guess_strguess。我们将在 第三章 中更详细地介绍这一点。,但现在,请知道,当你想要将一个值从一种类型转换为另一种类型时,通常会使用此功能。

我们将这个新变量绑定到表达式 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 我们将注释变量的类型。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 [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 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);

    // --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 [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

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

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

    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 [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:完整的猜谜游戏代码

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

总结

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