rust-lang-zh_CN/src/Ch02_Programming_a_Guessing_Game.md
2023-12-07 14:26:42 +08:00

42 KiB
Raw Blame History

编写猜数游戏

Programming a Guessing Game

咱们来一起通过一个实践项目,了解 Rust 吧!本章通过演示如何在一个实际程序中,如何运用他们,从而介绍一些常见 Rust 概念。咱们将了解 letmatch、方法、关联函数、外部代码箱等!在接下来的章节中,我们将更详细地探讨这些概念。在本章中,咱们将只练习这些基本知识。

我们将实现一个经典的初学者编程问题:猜数游戏。其原理如下:程序将随机生成一个介于 1 和 100 之间的整数。然后,程序会提示玩家,输入一个猜测值。猜测值输入后,程序会显示猜测值是过低还是过高。如猜测正确,游戏将打印一条祝贺信息并退出。

建立一个新项目

Setting Up a New Project

要建立一个新项目,请进入咱们在第 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.org/cargo/reference/manifest.html

[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 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/guessing_game`
Hello, world!

当咱们需要在某个项目快速迭代,就像我们在这个游戏中将要做的,在进入下一迭代之前,快速测试每一次迭代时,run 这个命令就会派上用场。

请重新打开 src/main.rs 文件。咱们将在这个文件中,编写所有代码。

处理一个猜数

Processing a Guess

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

文件名:src/main.rs

use std::io;

fn main() {
    println! ("请猜这个数!");

    println! ("请输入你的猜数。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("读取行失败/failed to read line");

    println! ("你猜的是:{guess}");
}

清单 2-1从用户处获取一个猜数并将其打印出来的代码

这段代码包含了大量信息,所以我们来逐行查看。要获取用户输入,然后将结果打印输出,我们就需要将 io 这个输入/输出库,带入作用域。io 库来自标准库,即 std

use std::io;

默认情况下Rust 在标准库中定义了一组,其会带入到每个程序作用域中的项目。这组项目被称为 前奏prelude,咱们可以在 标准库文档 中,查看他当中的全部项目。

如果咱们打算使用的某个类型不在前奏中,那么就必须用一条 use 语句,显式地将该类型带入作用域。使用 std::io 库,提供到咱们许多有用功能,包括接受用户输入的能力。

正如咱们在第 1 章所看到的,main 函数是该程序的入口the entry point into the program

fn main() {

fn 语法声明了一个新函数;括号 () 表明没有参数;花括号,{,开启了该函数的。

同样如同咱们在第 1 章中所掌握的,println! 是个将字符串打印到屏幕上的宏a macro

    println! ("猜出这个数来!");

    println! ("请输入你猜的数。");

这段代码打印出说明游戏是什么,以及要求用户输入的提示信息。

使用变量存储值

Storing Values with Variables

接下来,我们将创建一个 变量variable,来存储用户输入,就像这样:

    let mut guess = String::new();

现在,程序开始变得有趣起来!在这短短一行中,发生了很多事情。我们使用 let 语句,创建这个变量。下面是另一个例子:

let apples = 5;

这一行创建了个名为 apples 的新变量,并将其与值 5 绑定。在 Rust 中变量默认是不可变的immutable这意味着一旦我们赋给变量某个值该值就不会改变。我们将在第 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 类型的一个关联函数。所谓 关联函数associated function,是实现于某个类型(此示例中即 String)上,实现的一个函数。这个 new 函数,会创建一个新的空字符串。在许多类型上,咱们都会发现一个 new 函数,因为他是个那些构造某种新值函数的通用名称。

在那个 ::new 代码行中的 :: 语法,表示其中的 newString 类型的一个关联函数an associated funtion of the String type。至于 关联函数associated function,指的是应用到某种类型上的函数,在此实例中,类型就是 String 了。这个 new 函数创建了一个新的、空空的字符串。由于new 是个构造某种新值的常见函数,因此在许多类型上,都将找到 new 函数。

总的来说,let mut guess = String::new(); 这行,创建了当前绑定了一个新的、空的 String 实例的一个可变变量。呼!

接收用户输入

Receiving User Input

回顾一下,在程序的第一行,我们使用 use std::io;,包含了标准库中的输入/输出功能。现在,我们将调用 io 模组中,将允许咱们处理用户输入的 stdin 函数:

    io:stdin()
        .readline(&mut guess)

如果我们没有在程序开头,使用 use std::io; 导入 io 库,我们仍然可以通过将此函数调用,写成 std::io::stdin 来使用这个函数。stdin 函数会返回 std::io::Stdin 的一个实例而这是一种表示终端标准输入句柄的类型a type that represents a handle to the standard input for your terminal。

接下来,.read_line(&mut guess) 这一行,调用了该标准输入句柄上的 read_line 方法,来获取用户输入。我们还将 &mut guess 作为参数,传递给 read_line,告诉他将用户输入的内容,存储在哪个字符串中。read_line 的全部工作,就是接收用户输入标准输入的内容,并将其追加到某个字符串中(不会覆盖其内容),因此我们要将该字符串,作为参数传递给他。这个字符串参数,必须是可变的,这样这个方法才能更改该字符串的内容。

其中的 &,表示该参数是个 引用reference,其提供了一种,让咱们的代码多个部分,在无需多次将某个数据复制到内存中的情况下,即可访问该数据的方法。引用是一项复杂的特性,而 Rust 的主要优势之一,就是引用的使用,既安全又简单。对于完成现在这个程序,咱们并不需要知道很多的这些细节。现在,咱们只需知道引用与变量一样,默认情况下是不可变的。因此,咱们需要写下 &mut guess 而不是 &guess,来使其可变。(第 4 章将更详细地解释引用)。

使用 Result 处理潜在失效

Handle Potential Failure with Result

我们仍在研究这行代码。我们现在讨论的是第三行文字,但请注意,他仍然是单个逻辑行代码的一部分。下一部分,便是这个方法:

        .expect("读取输入失败");

我们本可以将这段代码写成:

io::stdin().read_line(&mut guess).expect("读取输入失败");

不过,一个长行难于阅读,所以最好将其分开。在咱们使用 .method_name() 语法,调用某个方法时,引入一个换行符,以及另外的空白,来帮助拆分长行,通常是明智之举。现在我们来讨论一下,这一行完成了什么。

如早先曾提到的,read_line 会将用户输入的任何内容,放入我们传给他的字符串中,但他还会返回一个 Result 值。Result 是个 枚举enumeration,通常称为 enum,是可处于多种可能状态之一的一种类型。我们称每种可能状态,为一个 变种variant

第 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 (/home/peng/rust-lang/projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 | /     io::stdin()
11 | |         .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.48s

Rust 警告说咱们不曾使用 read_line 返回的那个 Result 值,表明程序还没有处理可能出现的错误。

消除这条警告的正确方法,是着手编写出错误处理代码,但在我们的例子中,我们只打算在某个问题出现时,让程序崩溃,因此咱们可以使用 expect。咱们将在 第 9 章 中,学习如何从错误中恢复。

使用 println! 占位符打印值

Printing Values with println! Placeholders

这段代码中,除了结尾的大括号,到目前为止就只有一行需要讨论了:

    println! ("你猜的数是:{guesss}");

这一行会打印现在包含了用户输入的那个字符串。其中的 {} 花括号组,是个占位符:可以把 {} 想象成一对用来固定某个值于某处的小蟹钳。在打印某个变量的值时,变量名可以放在这对花括号内。在打印表达式的计算结果时,就要在格式字符串中,放置空的大括号,然后在格式字符串后,添加以逗号分隔的表达式列表,并按照相同的顺序打印到各个空的大括号占位符中。在一次 println! 的调用中,打印一个变量和一个表达式的结果,将如下所示:

let x = 5;
let y = 10;

println! ("x = {x} 而 y + 2 = {}", y + 2);

此代码将打印出 x = 5 而 y + 2 = 12

测试第一部分

Testing the First Part

我们来测试一下,这个猜数游戏的第一部分。请使用 cargo run 运行他:

$ cargo run                ✔
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s
     Running `target/debug/guessing_game`
猜出这个数来!
请输入你猜的数。
6
你猜的数为6

至此,这个游戏的第一部分已经完成:我们从键盘获取输入,然后打印出来。

生成秘密数字

Generating a Secret Number

接下来,我们需要生成一个用户将尝试猜测的秘密数字。秘密数字应每次都不一样,这样游戏才会有趣,才能玩多次。我们将使用 1 到 100 之间的某个随机数这样游戏就不会太难。Rust 尚未在其标准库中包含随机数功能。不过Rust 团队提供了一个包含上述功能的 rand 代码箱

使用代码箱获得更多功能

Using a Crate to Get More Functionality

请记住,代码箱是一些 Rust 源代码文件的集合。我们正在构建的项目,是个 二进制代码箱binary crate,这是个可执行代码箱。而 rand 代码箱,则是个 库代码箱library crate,其中包含的代码,旨在用于其他程序,而不能在其自身上执行。

Cargo 的外部板块的协调能力,正是 Cargo 的真正亮点所在。在编写用到 rand 的代码之前,我们需要修改那个 Cargo.toml 文件,将 rand 代码箱作为一个依赖项。现在请打开该文件,在 Cargo 为咱们创建的 [dependencies] 小节标题下,添加下面一行。请务必使用这个版本号,准确指定 rand,否则本教程中的代码示例,可能无法运行:

文件名:Cargo.toml

rand = "0.8.5"

在这个 Cargo.toml 文件中,某个头部之后的所有内容,都是该小节的一部分,一直持续到另一小节开始。在 [dependencies] 中,咱们告诉 Cargo咱们的项目依赖于哪些外部代码箱以及咱们需要这些代码箱的哪些版本。在本例中我们使用语义版本说明符 0.8.5,指定了 rand 这个代码箱。Cargo 能够理解语义的版本编号Semantic Versioning有时也称为 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 ppv-lite86 v0.2.17
  Downloaded rand_chacha v0.3.1
  Downloaded cfg-if v1.0.0
  Downloaded rand_core v0.6.4
  Downloaded getrandom v0.2.11
  Downloaded rand v0.8.5
  Downloaded libc v0.2.150
  Downloaded 7 crates (910.0 KB) in 4.63s
   Compiling libc v0.2.150
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.17
   Compiling getrandom v0.2.11
   Compiling rand_core v0.6.4
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game-xfossdotcom v0.1.1 (/home/chat/rust-lang-zh_CN/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 7.08s

清单 2-2将 rand 代码箱添加为依赖项后运行 cargo build 的输出*

咱们可能会看到一些不同的版本号(但他们都与代码兼容,这要归功于 SemVer和不同的一些行取决于操作系统而且这些行的顺序也可能不同。

当我们包含了某个外部依赖项时Cargo 会从作为 Crates.io 上数据的一份拷贝的 登记簿registry获取该依赖项所需的所有内容的最新版本。Crates.io 是 Rust 生态系统中的人们,发布开源 Rust 项目供他人使用的地方。

更新登记簿后Cargo 会检查 [dependencies] 小节,并下载列出的任何尚未下载的代码箱。在本例中,虽然我们只将 rand 列为依赖项,但 Cargo 还抓取了 rand 运作所依赖的其他代码箱。下载完这些代码箱后Rust 会对他们进行编译,然后使用这些可用依赖项,编译项目。

如果咱们不做任何修改,就立即再次运行 cargo build,那么除了 Finished 那行外咱们不会得到任何输出。Cargo 知道他已经下载并编译了依赖项,而咱们也没有在 Cargo.toml 文件中对依赖项做任何修改。Cargo 也知道咱们没有修改代码,所以也不会重新编译项目。无事可做,他就直接退出了。

$ cargo build                                                            ✔
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s

如果咱们打开 src/main.rs 文件,进行一些简单的更改,然后保存并再次构建,咱们将只会看到两行输出:

cargo build                                                            ✔
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.50s

这几行显示Cargo 只会根据咱们对 src/main.rs 文件的微小改动,来更新构建。咱们的依赖依赖并没有改变,因此 Cargo 知道,他可以重复使用已经下载并编译好的那些依赖项。

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

Ensuring Reproducible Builds with the Cargo.lock File

Cargo 有着一种可以确保咱们,或其他人每次构建代码时,都能重建出相同产物的机制: Cargo 将只使用咱们所指定的依赖项版本,除非咱们另有指示。例如,下周 rand 代码箱的 0.8.6 版本将发布该版本包含了一个重要的错误修复但同时也包含了一个会破坏咱们代码的回退。为了处理这个问题Rust 会在咱们第一次运行 cargo build 时,创建 Cargo.lock 文件,所以在 guessing_game 目录下,我们现在会有这个文件。

当咱们首次构建某个项目时Cargo 会计算出符合条件依赖项的全部版本,然后将其写入 Cargo.lock 文件。在咱们以后再构建项目时Cargo 就会发现 Cargo.lock 文件的存在,并会使用其中指定的版本,而不会再重新计算版本。这样,咱们就能自动进行可重现的构建。换句话说,由于有了 Cargo.lock 文件,在咱们明确升级之前,咱们的项目将保持在 0.8.5 版本。由于 Cargo.lock 文件对于可重现性构建非常重要,因此他通常会与项目中的其他代码一起,进入源代码控制系统。

更新代码箱来获取新版本

Updating a Crate to Get a New Version

当咱们确实打算更新某个代码箱时Cargo 提供了 update 命令,他会忽略 Cargo.lock 文件,并找出所有符合咱们在 Cargo.toml 中所要求的最新版本。然后Cargo 会把这些版本写入 Cargo.lock 文件。否则默认情况下Cargo 只会查找大于 0.8.5 且小于 0.9.0 的版本。如果 rand 代码箱发布了 0.8.60.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。要使用 rand0.9.x 系列中的任何版本,咱们必须更新 Cargo.toml 文件,使其看起来像这样:

[dependencies]
rand = "0.9.0"

在咱们下次运行 cargo buildCargo 会更新可用代码箱的登记簿the registry of creates available并根据咱们所指定的新版本重新计算咱们的 rand 需求。

关于 Cargo其生态,还有很多内容要讲,我们将在第 14 章进行讨论但现在这就是咱们需要了解的全部内容。Cargo 让重用库变得非常容易,因此 Rustaceans 可以编写出,由多个包组合而成的小型项目。

生成随机数

Generating a Random Number

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

文件名:src/main.rs

use std::io;
use rand::Rng;

fn main() {
    println! ("请猜数!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println! ("秘密数字为:{secret_number}");

    println! ("请输入你的猜数。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("读取行失败/failed to read line");

    println! ("你猜的是:{guess}");
}

清单 2-3添加代码以生成随机数

首先,我们添加 use rand::Rng; 这行。Rng 特质the Rng trait定义了随机数生成器所实现的那些方法而这个特质必须位于咱们要用到那些方法的作用域中。第 10 章将详细介绍特质。

接下来,我们在中间添加两行。在第一行中,我们调用了给到我们要用到随机数生成器的 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

第二新的行,会打印秘密数字。这在我们开发程序时很有用,可以用来测试程序,但我们会在最终版本中删除他。如果程序一开始就打印出答案,那就不算是个游戏了!

请试着运行几次程序:

$ cargo run                                                           ✔  4s 
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/guessing_game`
猜出这个数来!
随机生成的秘密数字为40
请输入你猜的数。
86
你猜的数为86

$ cargo run                                                           ✔  9s 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/guessing_game`
猜出这个数来!
随机生成的秘密数字为30
请输入你猜的数。
27
你猜的数为27

咱们应得到不同的随机数字,且他们都应是 1 到 100 之间的数字。干得好!

将猜数与秘数相比较

Comparing the Guess to the Secret Number

现在我们有了用户输入和随机数,我们可以对他们进行比较。该步骤如下清单 2-4 所示。请注意,这段代码还不能编译,我们将对此进行说明。

文件名:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --跳过前面的代码--

    println! ("你猜的数为:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println! ("太小!"),
        Ordering::Greater => println! ("太大!"),
        Ordering::Equal => println! ("你赢了!"),
    }
}

清单 2-4对比较两个数字可能的返回值进行处理

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

然后,我们在底部,添加了用到这个 Ordering 类型的五个新行。cmp 这个方法,会比较两个值,并可以在任何可被比较的项目上调用。他会取一个到咱们打算比较的任何值的引用:这里他是将 guesssecret_number 进行比较。然后,他会返回我们通过那条 use 语句,带入作用域的 Ordering 枚举的某个变种。我们使用了一个 match 表达式,根据以 guesssecret_number 中的值调用 cmp 时,所返回的何种 Ordering 变体,来决定下一步的操作。

match 表达式由数个 支臂arms 组成。而一个支臂则由一个要与之匹配的 模式pattern,以及在给到 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!该支臂中的相关代码将执行,并打印 太大! 到屏幕。这个 match 表达式在第一次成功匹配后,就会结束,因此在这种情况下,其不再查看最后一个支臂。

然而,清单 2-4 中的代码还无法编译。咱们来尝试一下:

$ cargo build                                                         ✔
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `String`, found `i32`
   |
   = note: expected reference `&String`
              found reference `&i32`

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

错误的核心,表明存在 不匹配的类型mismatched types。Rust 有着强大的静态类型系统。不过他也有着类型推断Rust has a strong, static type system. However, it also has type inference。当我们写下 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 无法比较字符串和数字类型。

最后,我们打算将程序读取的字符串输入,转换为某个真正的数字,这样咱们就可以将其与秘密数字,进行数值比较。我们要通过在那个 main 函数主体中,添加下面这行,完成这一点:

文件名:src/main.rs

    // --跳过前面的代码--

    let mut guess: String = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("读取行失败......");

    let guess: u32 = guess.trim().parse().expect("请输入一个数字!");

    println! ("你猜的数为:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println! ("太小了!"),
        Ordering::Greater => println! ("太大了!"),
        Ordering::Equal => println! ("你赢了!"),
    }

该行为:

let guess: u32 = guess.trim().parse().expect("请输入一个数字!");

我们创建了一个名为 guess 的变量。但是等等,程序中不是已经有一个名为 guess 的变量了吗?是有的,但好在 Rust 允许我们用一个新值,对 guess 的前一个值进行遮蔽处理。遮蔽特性shadowing 允许咱们,重复使用这个 guess 变量名,而不必被迫创建出,诸如 guess_strguess 这样的两个唯一变量。我们将在 第 3 章 中详细介绍这一功能,而现在我们要知道,当咱们打算将某个值,从一种类型转换为另一类型时,就经常会用到这一特性。

我们将这个新变量,绑定到 guess.trim().parse() 这个表达式。表达式中的 guess,指的是包含了作为字符串的输入的那个原始 guess 变量。某个 String 实例上的 trim 方法,将消除开头和结尾的空白,我们必须这样做才能将字符串与 u32 进行比较,而 u32 只能包含数字数据。用户必须按下回车键,来满足 read_line 并输入他们的猜数,这会添加一个换行符到输入字串。例如,如果用户输入 5 并按回车键,guess 就会看起来是这样的:5\n\n 表示 “换行/newline”。(在 Windows 系统中,按下回车键会产生是回车和换行,即 \r\n)。译注 1 trim 方法可以去掉 \n\r\n,结果就只有 5 了。

译注 1:这也是为何先前的代码:

    let bytes = io::stdin()
        .read_line(&mut guess)
        .expect("读取行失败/failed to read line");

在 Windows 的 MSYS2 上运行时,bytes 的输出始终会比咱们看到的字符串,要多两个字节的原因。

字符串上的 parse 方法,可将字符串转换为另一类型。在这里,我们要用他,将字符串转换为数字。我们需要使用 let guess: u32,告诉 Rust 我们想要的确切数字类型。guess 后面的冒号(:),告诉 Rust 我们将注解这个变量的类型。Rust 有几种内置的数字类型;这里所看到的 u32,是一种无符号的 32 位整数。对于小的正数来说,这是一种不错的默认选择。咱们将在 第 3 章,了解其他数字类型。

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

parse 这个方法,只适用于逻辑上可以转换成数字的那些字符,因此很容易出错。例如,如果字符串包含着 A👍%,就无法将其转换为数字。因为其可能会失败,所以 parse 方法会返回一个结果类型,就像 read_line 方法一样(早先曾在 “使用 Result 处理潜在失败” 小节中讨论过)。我们将再次通过使用 expect 方法,以同样方式处理这个 Result。如果 parse 因无法从那个字符串,创建出一个数字而返回 ErrResult 变种,则 expect 这个调用,将导致游戏崩溃,并打印出我们给到他的信息。如果 parse 能成功将那个字符串转换为数字,他将返回 ResultOk 变种,而 expect 将从这个 Ok 值,返回我们想要的数字。

现在咱们来运行一下这个程序:

$ cargo run                                                       101 ✘  3s 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/guessing_game`
猜出这个数来!
随机生成的秘密数字为66
请输入你猜的数。
   76
你猜的数为76
太大了!

很棒!尽管在猜数前加了一些空格,程序仍然算出了用户猜的是 76。多运行几次这个程序,来验证在各种输入时其不同的表现:猜对一个数、猜个太大的数,以及猜个过小的数。

现在这个游戏大致在工作了,然而用户只能猜一次。下面就来通过添加循环对其进行修改!

用循环来实现多次猜数

Allowing Multiple Guesses with Looping

关键字 loop 创建出无限循环。这里就要添加一个循环,来让用户有更多机会去猜数:

文件名:src/main.rs

    // --跳过--

    println! ("随机生成的秘密数字为:{}", secret_number);

    loop {
        println! ("请输入你猜的数。");

        // --跳过--

        match guess.cmp(&secret_number) {
            Ordering::Less => println! ("太小了!"),
            Ordering::Greater => println! ("太大了!"),
            Ordering::Equal => { println! ("你赢了!"); break },
        }
    }
}

可以看到,这里已将自猜数输入提示开始的全部代码,移入到循环中了。请确保循环中的那些代码行,都另外缩进四个空格,然后再次运行这个程序。现在程序将会一直要求另一猜数,这实际上引入了新的问题。好像是用户无法退出了!

用户可一直通过键盘快捷键 Ctrl-C,来中断这个程序。不过还是有别的方法,来退出这头贪厌的怪兽,就像在 将猜数与秘密数字比较中对 parse 方法讨论中提到的那样:在用户输入了非数字的答案时,程序就会崩溃。这里就利用了那个,来实现用户退出,如下所示:

$ cargo run
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/guessing_game`

---猜出这个数来!---
请输入你猜的数。( Q/quit 退出游戏)
50
你猜的数为50
太小了!
请输入你猜的数。( Q/quit 退出游戏)
75
你猜的数为75
太大了!
请输入你猜的数。( Q/quit 退出游戏)
62
你猜的数为62
太大了!
太小了!
请输入你猜的数。( Q/quit 退出游戏)
55
你猜的数为55
你赢了!

---猜出这个数来!---
请输入你猜的数。( Q/quit 退出游戏)
quit
thread 'main' panicked at '请输入一个数字!: ParseIntError { kind: InvalidDigit }', src/main.rs:25:51
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

敲入 quit 就会退出这游戏,不过正如所注意到的,这样做将就要敲入别的非数字输入。至少可以是这种做法是次优的;这里想要在猜到了正确数字时,游戏也要停止。

猜对后的退出

下面就来通过添加一条 break 语句,将游戏编程为在用户赢了时退出:

文件名:src/main.rs

        // --跳过--

        match guess.cmp(&secret_number) {
            Ordering::Less => println! ("太小了!"),
            Ordering::Greater => println! ("太大了!"),
            Ordering::Equal => {
                println! ("你赢了!");
                break
            },
        }
    }
}

你赢了! 之后添加上 break 代码行,就令到游戏在用户猜中了秘密数字时,退出那个循环。由于该循环是 main 函数体的最后部分,因此退出循环也意味着退出这个程序。

无效输入的处理

为了进一步改进游戏表现,而不要在用户输入了非数字时将程序崩溃掉,那么接下来就要使得游戏忽略非数字,从而用户可以继续猜数。通过把guessString 转换为 u32 的那行加以修改,来完成这个目的,如下面的清单 2-5 所示:

文件名:src/main.rs

        // --跳过--

        io::stdin()
            .read_line(&mut guess)
            .expect("读取行失败......");

        if guess.trim().eq("Q") || guess.trim().eq("quit") { process::exit(0); }

        // let guess: u32 = guess.trim().parse().expect("请输入一个数字!");
        let guess: u32 = match guess.trim().parse() {
           Ok(num) => num,
           Err(_) => { println! ("请输入一个数字!"); continue },
        };

        println! ("你猜的数为:{}", guess);

        // --跳过--

清单 2-5忽略非数字的猜解进而询问另一猜数而不再是崩溃掉程序

这里将原来的 expect 调用,转换到了一个 match 表达式,而实现了一错误就程序崩溃,到对错误进行处理的转变。请记住 parse 返回的是个 Result 类型,而 Result 则是个枚举,有着变种 OkErr。与先前对 cmp 方法返回结果 Ordering 的处理一样,这里运用了一个 match 表达式。

parse 能够成功将那个字符串,转换为数字时,他就会返回一个包含了所得结果数的 Ok 值。那 Ok 值就会匹配上第一个支臂的模式,而这个 match 表达式将值返回 parse 产生的、放在Ok 值里头的那个 num 值。那个数字就会刚好放在这里想要他呆的地方,即这里正在创建的那个新 guess 变量了。

parse 无法将那个字符串转换成数字时,他就会返回一个包含了有关该错误详细信息的 Err 值。该 Err 值不与第一个 match 支臂中的 Ok(num) 模式匹配,不过却正好匹配第二个支臂中的 Err(_) 模式。其中的下划线,_是个收集错误信息的值a catch-all value在此示例中就是要匹配所有 Err 值,而不管这些 Err 值中包含了什么信息。那么程序就会执行第二支臂的代码,即 continue,这是告诉程序前往到那个 loop 循环的下一次迭代,进而询问另一个猜数。就这样,有效地方让程序忽略了全部 parse 可能会发生的错误了!

现在程序各方面就应如预期那样工作了。就来试试:

$ cargo run                                                       ✔
   Compiling guessing_game v0.1.0 (/home/peng/rust-lang/projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.57s
     Running `target/debug/guessing_game`

---猜出这个数来!---
请输入你猜的数。( Q/quit 退出游戏)
50
你猜的数为50
太小了!
请输入你猜的数。( Q/quit 退出游戏)
75
你猜的数为75
你赢了!

非常棒!只需最后一个小的优化,就将完成这个猜数游戏了。没忘记这个程序仍是把秘密数字打印出来的吧。那样做对测试来说没有问题,但却毁掉了这个游戏。这里就来将输出了秘密数字的那个 prinln! 给删掉。下面的清单 2-6 给出了最终代码。

文件名:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;
use std::process;

fn main() {
    loop {
        println! ("\n---猜出这个数来!---");

        let secret_number: u32 = rand::thread_rng().gen_range(1..101);

        // println! ("随机生成的秘密数字为:{}", secret_number);

        loop {
            println! ("请输入你猜的数。( Q/quit 退出游戏)");

            let mut guess: String = String::new();

            io::stdin()
                .read_line(&mut guess)
                .expect("读取行失败......");

            if guess.trim().eq("Q") || guess.trim().eq("quit") { process::exit(0); }

            // let guess: u32 = guess.trim().parse().expect("请输入一个数字!");
            let guess: u32 = match guess.trim().parse() {
               Ok(num) => num,
               Err(_) => { println! ("请输入一个数字!"); continue },
            };

            println! ("你猜的数为:{}", guess);

            match guess.cmp(&secret_number) {
                Ordering::Less => println! ("太小了!"),
                Ordering::Greater => println! ("太大了!"),
                Ordering::Equal => {
                    println! ("你赢了!");
                    break
                },
            }
        }
    }
}

清单 2-6完全的猜数游戏代码

小结

到了这里,就成功构建了这个猜数游戏。恭喜!

该项目以动手的方式,教了许多新的 Rust 概念:letmatch 等关键字,函数、运用外部代码箱及更多。在接下来的几章中,会更深入地掌握这些概念。第 3 章涵盖了大多数编程语言都有的一些概念,诸如变量、数据类型及函数,并展示了如何在 Rust 中使用他们。第 4 章对 Rust 中的所有权ownership进行了探索所有权是一项令到 Rust 不同于其他语言的特性。第 5 章对结构体structs和方法语法method syntax进行了讨论而第 6 章解释了枚举的原理。