编程一个猜数字游戏

让我们通过一起完成一个动手项目来进入 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"

[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.20s
     Running `target/debug/guessing_game`
Hello, world!

run 命令在你需要快速迭代项目时非常有用,就像我们在制作这个游戏时一样,每次迭代后快速测试,然后再进行下一次迭代。

重新打开 src/main.rs 文件。你将在该文件中编写所有代码。

处理一个猜测

猜数字游戏程序的第一部分将请求用户输入,处理该输入,并检查输入是否为预期的形式。首先,我们将允许玩家输入一个猜测。将清单 2-1 中的代码输入到 src/main.rs

Filename: 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);
}
Listing 2-1: Code that gets a guess from the user and prints it

这段代码包含了很多信息,所以我们逐行来分析。为了获取用户输入并将其作为输出打印,我们需要将 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 在每个程序的作用域中引入了一组标准库中定义的项。这组项被称为 前言,您可以在 标准库文档 中查看其中的所有内容。

如果您想使用的类型不在前言中,您必须使用 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 行中表示 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 和 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 是一组 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.5rand crate。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
     Locking 16 packages to latest compatible versions
      Adding wasi v0.11.0+wasi-snapshot-preview1 (latest: v0.13.3+wasi-0.2.2)
      Adding zerocopy v0.7.35 (latest: v0.8.9)
      Adding zerocopy-derive v0.7.35 (latest: v0.8.9)
  Downloaded syn v2.0.87
  Downloaded 1 crate (278.1 KB) in 0.16s
   Compiling proc-macro2 v1.0.89
   Compiling unicode-ident v1.0.13
   Compiling libc v0.2.161
   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.37
   Compiling syn v2.0.87
   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 3.69s
Listing 2-2: The output from running cargo build after adding the rand crate as a dependency

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

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

在更新注册表后,Cargo 检查 [dependencies] 部分并下载任何尚未下载的列出的 crates。在这种情况下,虽然我们只将 rand 列为依赖项,Cargo 还获取了 rand 依赖的其他 crates 以正常工作。下载完 crates 后,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 知道它可以重用已经下载和编译的内容。

Ensuring Reproducible Builds with the Cargo.lock File

Cargo 有一个机制,确保你或任何人在构建你的代码时都能每次都重建相同的工件:Cargo 将仅使用你指定的依赖版本,直到你另行指示。例如,假设下周发布了 rand crate 的 0.8.6 版本,该版本包含了一个重要的 bug 修复,但也包含了一个会导致你的代码出问题的回归。为了处理这种情况,Rust 在你第一次运行 cargo build 时创建了 Cargo.lock 文件,因此我们现在在 guessing_game 目录中有了这个文件。

当你第一次构建项目时,Cargo 会确定所有符合标准的依赖项版本,然后将它们写入 Cargo.lock 文件。当你将来构建项目时,Cargo 会看到 Cargo.lock 文件存在,并将使用那里指定的版本,而不是再次进行所有确定版本的工作。这让你可以自动拥有可重现的构建。换句话说,你的项目将保持在 0.8.5,直到你显式升级,这要归功于 Cargo.lock 文件。因为 Cargo.lock 文件对于可重现的构建很重要,所以它通常会与项目中的其他代码一起提交到源代码控制中。

Updating a Crate to Get a New Version

当你确实想要更新一个 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 将更新可用的 crates 注册表,并根据您指定的新版本重新评估您的rand需求。

关于Cargo其生态系统还有很多要说的,我们将在第14章中讨论,但目前,这些就是你需要知道的全部。Cargo使得重用库变得非常容易,因此Rustaceans能够编写由多个包组装而成的较小项目。

生成随机数

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

Filename: 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}");
}
Listing 2-3: Adding code to generate a random number

首先我们添加一行 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` 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 中展示。请注意,这段代码还不能编译,我们将对此进行解释。

Filename: 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!"),
    }
}
Listing 2-4: Handling the possible return values of comparing two numbers

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

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

一个 match 表达式由 组成。一个臂包含一个 模式 用于匹配,以及如果给定给 match 的值符合该臂的模式时应运行的代码。Rust 会取 match 给定的值,并依次检查每个臂的模式。模式和 match 构造是强大的 Rust 特性:它们让你能够表达代码可能遇到的各种情况,并确保你处理了所有这些情况。这些特性将分别在第 6 章和第 19 章中详细讨论。

让我们通过一个使用这里的 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 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.82/lib/rustlib/src/rust/library/core/src/cmp.rs:838:8
    |
838 |     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

错误的核心在于存在 类型不匹配。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 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 帮助性地允许我们用新值覆盖 guess 的先前值。这种 遮蔽 让我们可以重用 guess 变量名,而不是强制我们创建两个独特的变量,例如 guess_strguess。我们将在 第 3 章 中更详细地讨论这一点,但目前,要知道此功能通常在您希望将一个类型的值转换为另一个类型的值时使用。

我们将这个新变量绑定到表达式 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 位整数。对于一个小的正数,它是一个很好的默认选择。您将在 第 3 章 中了解其他数字类型。

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

The 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 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` 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 '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 所示。

Filename: 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;
            }
        }
    }
}
Listing 2-5: Ignoring a non-number guess and asking for another guess instead of crashing the program

我们将从一个 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显示了最终的代码。

Filename: 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;
            }
        }
    }
}
Listing 2-6: Complete guessing game code

到目前为止,您已经成功构建了猜数字游戏。恭喜!

摘要

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