Rust-编程猜谜游戏

让我们一起完成一个动手项目,进入 Rust!本章通过向您展示如何在实际程序中使用它们,向您介绍了一些常见的 Rust 概念。您将了解let、match、 方法、相关函数、使用外部 crate 等等!以下章节将更详细地探讨这些想法。在本章中,您将练习基础知识。

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

一、设置新项目

要设置一个新项目。

cargo new guessing_game
cd guessing_game

二、处理猜测

猜谜游戏程序的第一部分将要求用户输入、处理该输入并检查输入是否符合预期形式。首先,我们将允许玩家输入猜测。

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

这段代码包含了很多信息,让我们一行一行地看一遍。要获取用户输入然后将结果打印为输出,我们需要将 io(输入/输出)库引入作用域。该io库来自标准库(称为std):
use std::io;

默认情况下,rust带来的只有少数类型到每一个程序的范围 的序幕。如果要使用的类型不在序言中,则必须使用use 语句将该类型显式引入作用域。使用该std::io库为您提供了许多有用的功能,包括接受用户输入的能力。

接着打印两行文字:

println!("Guess the number!");
println!("Please input your guess.");

用变量存储值

接下来,我们将创建一个地方来存储用户输入,如下所示:

let mut guess = String::new();

请注意,这是一个let用于创建变量的语句 。这是另一个例子:
let apples = 5;

这一行创建了一个名为apples5的新变量,并将其绑定到值 5。在 Rust 中,变量默认是不可变的。
下面的例子展示了如何使用mut变量名使变量可变:
let apples = 5; // immutable
let mut bananas = 5; // mutable

让我们回到猜谜游戏程序。您现在知道这let mut guess 将引入一个名为 的可变变量guess。在等号 ( =)的另一侧guess是绑定到的值,它是调用 的结果String::new,该函数返回 a 的新实例String。
String是标准库提供的字符串类型,它是可增长的、UTF-8 编码的文本位。
该行中的::语法::new表明它new是该类型的关联函数String。关联函数是在类型上实现的,在本例中为String。
此new函数创建一个新的空字符串。

现在我们将调用模块中的stdin函数io:

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

如果我们没有把这use std::io行代码放在程序的开头,我们可以把这个函数调用写成std::io::stdin. 该stdin函数返回 的一个实例std::io::Stdin,它是一种表示终端标准输入句柄的类型。

代码的下一部分.read_line(&mut guess),调用 read_line标准输入句柄上的方法来获取用户的输入。我们还将一个参数传递给read_line: &mut guess。

read_line是将用户输入的任何内容输入标准输入并将其附加到字符串中(不覆盖其内容),因此它将该字符串作为参数。字符串参数需要是可变的,以便该方法可以通过添加用户输入来更改字符串的内容。

该&表示,这种说法是一个参考,它给你一个方法,让数据的代码访问一片多个部分,而无需将数据复制到内存中多次。引用是一个复杂的特性,Rust 的主要优点之一是使用引用是多么安全和容易。你不需要知道很多这些细节来完成这个程序。现在,您需要知道的是,与变量一样,默认情况下引用是不可变的。

使用Result类型处理潜在的故障

下一部分是这个方法:

.expect("Failed to read line");

当您使用.method_name()语法调用方法时,引入换行符和其他空格以帮助拆分长行通常是明智的。我们可以将这段代码写成:
io::stdin().read_line(&mut guess).expect("Failed to read line");

然而,一长行难以阅读,因此最好将其分开。现在让我们讨论一下这条线的作用。

read_line将用户输入的内容放入我们传递给它的字符串中,但它也返回一个值——在本例中为 io::Result. RustResult在其标准库中命名了许多类型 :Result 子模块的通用版本和特定版本,例如io::Result.

Result类型是枚举,通常被称为枚举。枚举是一种可以具有一组固定值的类型,这些值称为枚举的变体。

对于Result,变体是Ok或Err。所述Ok变体指示操作是成功的,并且内部Ok是成功生成值。该Err变种意味着操作失败,并Err包含有关操作如何或为何失败的信息。

这些Result类型的目的是编码错误处理信息。

如果此实例io::Result是一个Err值, expect将导致程序崩溃并显示您作为参数传递给 的消息expect。如果该read_line方法返回Err,则很可能是来自底层操作系统的错误的结果。如果这个实例io::Result是一个Ok值,expect将取返回值Ok持有并将该值返回给您,以便您可以使用它。在这种情况下,该值是用户在标准输入中输入的字节数。

如果您不调用expect,程序将编译,但您会收到警告:

cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut guess);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Rust 警告你没有使用从Result返回的值read_line,表明程序没有处理可能的错误。

使用println!占位符打印值

除了结束大括号之外,到目前为止添加的代码中只有一行要讨论,如下所示:

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

这一行打印了我们保存用户输入的字符串。大括号{}, 是一个占位符:把它想象{}成一个小螃蟹钳,把一个值固定在适当的位置。您可以使用大括号打印多个值:第一组大括号保存格式字符串后列出的第一个值,第二组保存第二个值,依此类推。在一次调用中打印多个值println!如下所示:
let x = 5;
let y = 10;

println!("x = {} and y = {}", x, y);

此代码将打印x = 5 and y = 10.

测试第一部分

运行它使用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,库 crate 可以包含任意能被其他程序使用的代码。
Cargo 对外部 crate 的运用是其真正闪光的地方。在我们使用 rand 编写代码之前,需要编辑 Cargo.toml ,声明 rand 作为一个依赖。现在打开这个文件并在底部的 [dependencies] 部分标题之下添加:

文件名:Cargo.toml

rand = "0.8.3"

在 Cargo.toml 文件中,标题以及之后的内容属同一个部分,直到遇到下一个标题才开始新的部分。 [dependencies] 部分告诉 Cargo 本项目依赖了哪些外部 crate 及其版本。本例中,我们使用语义化版本 0.8.3 来指定 rand crate。
cargo build
Updating crates.io index
Downloaded rand v0.8.3
Downloaded libc v0.2.86
Downloaded getrandom v0.2.2
Downloaded cfg-if v1.0.0
Downloaded ppv-lite86 v0.2.10
Downloaded rand_chacha v0.3.0
Downloaded rand_core v0.6.2
Compiling rand_core v0.6.2
Compiling libc v0.2.86
Compiling getrandom v0.2.2
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.10
Compiling rand_chacha v0.3.0
Compiling rand v0.8.3
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s

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

Cargo 有一种机制可确保您每次或其他任何人构建代码时都可以重建相同的工件:Cargo 将仅使用您指定的依赖项的版本,除非您另有说明。例如,如果下周的randcrate版本 0.8.4发布,并且该版本包含一个重要的错误修复,但它也包含一个会破坏您的代码的回归,会发生什么?
这个问题的答案的问题是Cargo.lock文件,它创建的第一次运行cargo build,现在是你的guessing_game目录。当您第一次构建项目时,Cargo会计算出符合条件的所有依赖项版本,然后将它们写入Cargo.lock文件。当你以后构建你的项目时,Cargo 会看到Cargo.lock文件存在并使用那里指定的版本,而不是再次做所有的找出版本的工作。这使您可以自动获得可重现的构建。换句话说,0.8.3由于Cargo.lock 文件,您的项目将保持不变,直到您明确升级。

更新板条箱以获得新版本

当你不希望更新一箱,货物提供了另一个命令,update,这将忽略Cargo.lock文件,并找出所有符合您的规范的最新版本Cargo.toml。如果可行,Cargo 会将这些版本写入Cargo.lock文件。
但默认情况下,Cargo 只会查找大于0.8.3和小于 的版本0.9.0。如果randcrate 发布了两个新版本,0.8.4并且 0.9.0,如果您运行,您将看到以下内容cargo update:

cargo update
Updating crates.io index
Updating rand v0.8.3 -> v0.8.4

此时,您还会注意到Cargo.lock文件中的更改,指出rand您现在使用的crate版本是0.8.4.

生成随机数

现在您已经将randcrate添加到Cargo.toml,让我们开始使用 rand.

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

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

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

println!("The secret number is: {}", s_num);

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一行:use rand::Rng。该Rng特征定义方法是随机数生成器,而这种特质必须是在范围上我们使用这些方法。
接下来,我们在中间添加两行。该rand::thread_rng函数将为我们提供将要使用的特定随机数生成器:一个位于当前执行线程的本地并由操作系统播种的随机数生成器。然后我们调用gen_range随机数生成器上的方法。这个方法是由Rng我们带入use rand::Rng语句范围的特征定义的。该gen_range方法将范围表达式作为参数并在范围内生成一个随机数。我们在这里使用的范围表达式的形式是start..end. 它包含下限但不包含上限,因此我们需要指定1..101请求 1 到 100 之间的数字。或者,我们可以传递 range 1..=100,它是等效的。
cargo run

三、比较猜测和秘密数字

现在我们有了用户输入和一个随机数,我们可以比较它们。

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

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

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

println!("The secret number is: {}", s_num);

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(&s_num) {
Ordering::Less => println!("Too Small"),
Ordering::Greater => println!("Too Big"),
Ordering::Equal => println!("You Win!"),
}
}

这里的第一个新位是另一个use语句,将std::cmp::Ordering标准库中调用的类型 引入作用域。像Result, Ordering是另一种枚举,但变数Ordering是Less, Greater和Equal。这是比较两个值时可能出现的三种结果。

该 cmp方法比较两个值,并且可以在任何可以比较的东西上调用。它需要引用您想与之比较的任何内容:这里是将guess与secret_number. 然后它返回Ordering我们用use语句带入范围的枚举的变体 。我们用一个 match表达式来决定基于是哪个变种下一步做什么Ordering从调用返回到cmp与价值观guess和secret_number。

一个 match 表达式由 分支(arms) 构成。一个分支包含一个 模式(pattern)和表达式开 头的值与分支模式相匹配时应该执行的代码。Rust 获取提供给 match 的值并挨个检查每个 分支的模式。 match 结构和模式是 Rust 中强大的功能,它体现了代码可能遇到的多种情 形,并帮助你确保没有遗漏处理。

Ordering::Greater 是 match 表达式得到的值。它检查第一个分支的 模式, Ordering::Less 与 Ordering::Greater 并不匹配,所以它忽略了这个分支的动作并来 到下一个分支。下一个分支的模式是 Ordering::Greater ,正确 匹配!这个分支关联的代码 被执行,在屏幕打印出 Too big! 。 match 表达式就此终止,因为该场景下没有检查最后一 个分支的必要。

运行:

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.3
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:22:21
|
22 | match guess.cmp(&st_num) {
| ^^^^^^^^^^^^^^ expected struct `String`, found integer
|
= note: expected reference `&String`
found reference `&{integer}`

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

错误的核心表明这里有 不匹配的类型(mismatched types)。Rust 有一个静态强类型系统, 同时也有类型推断。当我们写出 let guess = String::new() 时,Rust 推断出 guess 应该是 一个 String ,不需要我们写出的类型。另一方面, secret_number ,是一个数字类型。多种 数字类型拥有 1 到 100 之间的值:32 位数字 i32 ;32 位无符号数字 u32 ;64 位数字i64 等等。Rust 默认使用 i32 ,所以它是 secret_number 的类型,除非增加类型信息,或 任何能让 Rust 推断出不同数值类型的信息。这里错误的原因在于 Rust 不会比较字符串类型 和数字类型。

所以我们必须把从输入中读取到的 String 转换为一个真正的数字类型,才好与秘密数字进 行比较。这可以通过在 main 函数体中增加如下两行代码来实现:

let guess: u32 = guess.trim().parse().expect("Please type a number!");
println!("You guessed: {}", guess);

我们创建了一个名为 的变量guess。但是等等,程序不是已经有一个名为 的变量了guess吗?确实如此,但 Rust 允许我们用一个新的值来掩盖之前的值guess。此功能通常用于要将值从一种类型转换为另一种类型的情况,:让我们可以重用guess变量名,而不是强迫我们创建两个唯一的变量,

guess 被绑定到 guess.trim().parse() 表达式。表达式中的 guess 是包含输入的原始String 类型。 String 实例的 trim 方法会去除字符串开头和结尾的空白。 u32 只能由数字字符转换,不过用户必须输入 return 键才能让 read_line 返回,然而用户按下 return 键let guess: u32 = guess.trim().parse().expect(“Please type a number!”);时,会在字符串中增加一个换行(newline)符。例如,用户输入 5 并按下 return, guess 看起来像这样: 5\n 。 \n 代表 “换行”,回车键。 trim 方法消除 \n ,只留下 5 。

字符串的 parse 方法 将字符串解析成数字。因为这个方法可以解析多种数字类型,因此需 要告诉 Rust 具体的数字类型,这里通过 let guess: u32 指定。 guess 后面的冒号( : ) 告诉 Rust 我们指定了变量的类型。Rust 有一些内建的数字类型; u32 是一个无符号的 32 位整型。对于不大的正整数来说,它是不错的类型,第三章还会讲到其他数字类型。另外, 程序中的 u32 注解以及与 secret_number 的比较,意味着 Rust 会推断出 secret_number 也是 u32 类型。现在可以使用相同类型比较两个值了!

parse 调用很容易产生错误。例如,字符串中包含 A% ,就无法将其转换为一个数字。因 此, parse 方法返回一个 Result 类型。像之前 “使用 Result 类型来处理潜在的错误” 讨论 的 read_line 方法那样,再次按部就班的用 expect 方法处理即可。如果 parse 不能从字 符串生成一个数字,返回一个 Result::Err 时, expect 会使游戏崩溃并打印附带的信息。 如果 parse 成功地将字符串转换为一个数字,它会返回 Result::Ok ,然后 expect 会返回
Ok 中的数字。

运行程序!

cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!

四、允许多个循环猜测

loop关键字创建一个无限循环。我们现在将添加它,让用户有更多机会猜测数字:

// --snip--

println!("The secret number is: {}", secret_number);

loop {
println!("Please input your guess.");

// --snip--

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语句将游戏编程为在用户获胜时退出:

		match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}

在用户正确猜出密码break后You win!,添加这行使程序退出循环。退出循环也意味着退出程序,因为循环是main.

处理无效输入

为了进一步优化游戏的行为,与其在用户输入非数字时使程序崩溃,不如让游戏忽略非数字,以便用户可以继续猜测。我们可以通过改变其中线做guess 从转换String到u32。

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

从expect调用切换到match表达式是从因错误而崩溃到处理错误的一种方式。请记住,它parse返回一个Result类型并且Result是一个具有变体Ok或的枚举Err。我们在match这里使用了一个表达式,就像我们Ordering对cmp方法的结果所做的那样。

如果 parse 能够成功的将字符串转换为一个数字,它会返回一个包含结果数字的 Ok 。这个 Ok 值与 match 第一个分支的模式相匹配,该分支对应的动作返回 Ok 值中的数字 num ,最后如愿变成新创建的 guess 变量。

如果 parse 不 能将字符串转换为一个数字,它会返回一个包含更多错误信息的 Err 。 Err 值不能匹配第一个 match 分支的 Ok(num) 模式,但是会匹配第二个分支的 Err() 模 式: 是一个通配符值,本例中用来匹配所有 Err 值,不管其中有何种信息。所以程序会 执行第二个分支的动作, continue 意味着进入 loop 的下一次循环,请求另一个猜测。这 样程序就有效的忽略了 parse 可能遇到的所有错误!