编写一个猜数游戏

猜数游戏,它会首先生成一个1到100之间的随机整数,并紧接着请求玩家对这个数字进行猜测。假如玩家输入的数字与随机数不同,那么程序将给出数字偏大或偏小的提示。而假如玩家猜中了我们准备的数字,那么程序就会打印出一段祝贺信息并随之退出。

创建一个新的项目

使用Cargo来开始一个新的项目:

1
2
$ cargo new guessing_game 
$ cd guessing_game

处理一次猜测

猜数游戏的第一部分会请求用户进行输入,并检查该输入是否满足预期的格式。

src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::io;

fn main() {
println!("猜数游戏!");

println!("请输入你猜测的数字:");

let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

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

接下来看看上面的代码做了什么?

为了获得用户的输入并将其打印出来,需要把标准库(也就是所谓的std)中的io模块引入当前的作用域中:

1
use std::io;

作为默认行为,Rust会将预导入(prelude)模块内的条目自动引入每一段程序的作用域中,它包含了一小部分相当常用的类型。但假如你需要的类型不在预导入模块内,那么我们就必须使用use语句来显式地进行导入声明。std::io库包含了许多有用的功能,可以使用它来获得用户的输入数据。

main函数是一段程序开始的地方:

1
fn main() {

上面的fn语法声明了一个新的函数,而紧随名称后的圆括号()则意味着当前函数没有任何参数,最后的花括号{被用来标识函数体的开始。

println! 宏,它被用来将字符串打印到屏幕上:

1
println!("猜数游戏!");

使用变量存储值

接下来,创建了一个存储用户输入数据的地方:

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

这个以let开头的语句创建了一个新的变量(variable)。在Rust中,变量都是默认不可变的,使用mut关键字来声明一个变量是可变的:

1
2
let foo = 5; // foo是不可变的
let mut bar = 5; // bar是可变的

上面let mut guess语句会创建出一个名为guess的可变变量了。在这行语句中,等号(=)的右边是guess被绑定的值,也就是调用函数String::new后返回的结果:一个新的String实例。String是标准库中的一个字符串类型,它在内部使用了UTF-8格式的编码并可以按照需求扩展自己的大小。

String::new中的::语法表明newString类型的一个关联函数(associated function)。关联函数在某些语言中也被称为静态方法(static method)

这个new函数会创建一个新的空白字符串。你会在许多类型上发现new函数,因为这是创建类型实例的惯用函数名称。

为了引入标准库中的输入/输出功能,在程序的第一行使用了语句use std::io。现在将调用io模块中的关联函数stdin

1
2
io::stdin().read_line(&mut guess)
.expect("Failed to read line");

stdin函数会返回类型std::io::Stdin的实例,它被用作句柄来处理终端中的标准输入。这行代码随后的部分,.read_line(&mut guess),调用了标准输入句柄的read_line方法来获得用户输入。另外,read_line还在调用的过程中使用了一个参数:&mut guess

由于read_line方法会将当前用户输入的数据不加区分地存储在字符串中,所以它需要接收一个传入的字符串作为参数。传入的变量还需要是可变的,因为这一方法会在记录用户输入的过程中修改字符串。

参数前面的&意味着当前的参数是一个引用。你的代码可以通过引用在不同的地方访问同一份数据,而无须付出多余的拷贝开销。引用与变量一样,默认情况下也是不可变的。因此,需要使用&mut guess而不是&guess来声明一个可变引用。

使用Result类型来处理可能失败的情况

1
.expect("Failed to read line");

read_line会将用户输入的内容存储到我们传入的字符串中,但与此同时,它还会返回一个io::Result值。在Rust标准库中,可以找到许多以Result命名的类型,它们通常是各个子模块中Result泛型的特定版本,比如这里的io::Result。Result是一个枚举类型。枚举类型由一系列固定的值组合而成,这些值被称作枚举的变体。

对于Result而言,它拥有OkErr两个变体。其中的Ok变体表明当前的操作执行成功,并附带代码产生的结果值。相应地,Err变体则表明当前的操作执行失败,并附带引发失败的具体原因。

通过println! 中的占位符输出对应的值

最后一行代码:

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

将存储的用户输入打印出来。这段宏调用的第一个参数是用于格式化的字符串,而字符串中的那对花括号{}则是一个占位符,它用于将后面的参数值插入自己预留的特定位置。也可以使用花括号来同时打印多个值:第一对花括号对应格式化字符串后的第一个参数,第二对花括号对应格式化字符串后的第二个参数,以此类推。下面的代码展示了如何调用println! 来同时打印多个值:

1
2
3
4
let x = 5;
let y = 10;

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

生成一个幸运数字

下一步,需要生成一个幸运数字来供玩家进行猜测。为了保证一定的可玩性,并使每局游戏都有不同的体验,这个生成的幸运数字将会是随机的。为了让游戏不会过分困难,这个随机数字被限制在1到100之间。Rust团队并没有把类似的随机数字生成功能内置到标准库中,而是选择将它作为rand包(rand crate)提供给用户。

借助包来获得更多功能

Cargo最主要的功能就是帮助我们管理和使用第三方库。在使用rand编写代码之前,我们需要修改Cargo.toml文件来将rand包声明为依赖。现在让我们打开文件,并在Cargo生成的[dependencies]区域下方添加依赖:

1
2
[dependencies]
rand = "0.8.5"

运行cargo build时,Cargo就会自动更新注册表中所有可用包的最新版本信息,并根据指定的新版本来重新评估你对rand的需求。

生成一个随机数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::io;
use rand::Rng;

fn main() {
println!("猜数游戏!");

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

println!("当前的幸运数字是: {lucky_number}");

println!("请输入你猜测的数字:");

let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

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

首先,额外增加了一行use语句:use rand::Rng。这里的Rng是一个trait(特征),它定义了随机数生成器需要实现的方法集合。为了使用这些方法,我们需要显式地将它引入当前的作用域中。

另外,我们还在中间新增了两行代码。第一行中的函数rand::thread_rng会返回一个特定的随机数生成器:它位于本地线程空间,并通过操作系统获得随机数种子。随后,我们调用了这个随机数生成器的方法gen_range。这个方法是在刚刚引入作用域的Rng rait中定义的,它接收两个数字作为参数,并生成一个范围在两者之间的随机数。值得指出的是,它的随机数空间包含下限但不包含上限,所以我们可以指定1和101来获得1到100之间的随机整数。

比较猜测数字和幸运数字

src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main() {
println!("猜数游戏!");

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

println!("当前的幸运数字是: {lucky_number}");

println!("请输入你猜测的数字:");

let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

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

match guess.cmp(&lucky_number){
Ordering::Less => println!("太小了"),
Ordering::Greater => println!("太大了"),
Ordering::Equal => println!("恭喜你,猜中了"),
}
}

当我们尝试编译上面的代码,会报如下错误:

1
2
3
4
5
6
    |
781 | fn cmp(&self, other: &Self) -> Ordering;
| ^^^

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

该错误的核心在于示例中的代码存在不匹配的类型。Rust有一个静态强类型系统,同时,它还拥有自动进行类型推导的能力。当我们编写let guess = String::new()时,虽然我们没有做出任何显式的声明,但Rust会自动将变量guess的类型推导为String。另一方面,lucky_number是一个数值类型。有许多数值类型可以包含从1到100之间的整数,比如i32(32位整数)、u32(32位无符号整数)、i64(64位整数)等。除非我们在代码中增加更多用于推导类型的信息,否则Rust会默认将lucky_number视作i32类型。总而言之,编译器指出的错误就是:Rust无法将字符串类型(String类型)和数值类型直接进行对比。

为了正常进行比较操作,我们需要将程序中读取的输入从String类型转换为数值类型。这一转换可以通过在main函数中增加两行代码来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main() {
println!("猜数游戏!");

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

// println!("当前的幸运数字是: {lucky_number}");

println!("请输入你猜测的数字:");

let mut guess = String::new();

io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

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

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

match guess.cmp(&lucky_number){
Ordering::Less => println!("太小了"),
Ordering::Greater => println!("太大了"),
Ordering::Equal => println!("恭喜你,猜中了"),
}
}

在这里创建了一个新的变量guess,不过等等,我们不是已经使用过这个名字了吗?没错,但Rust允许使用同名的新变量guess来隐藏(shadow)旧变量的值。这一特性通常被用在需要转换值类型的场景中,它在本例中允许我们重用guess这个变量名,而无须强行创造出guess_str之类的不同的名字。

字符串的parse方法会尝试将当前的字符串解析为某种数值。由于这个方法可以处理不同的数值类型,所以我们需要通过语句let guesss: u32来显式地声明我们需要的数值类型。guess后面的冒号(:)告诉Rust我们将手动指定当前变量的类型。而这里的u32则是一个32位无符号整型,它是Rust内置的数值类型之一。对于不大的正整数来说,u32已经完全可以满足需求了,值得指出的是,由于我们将guess手动标记为了u32,并且将它和lucky_number进行了比较,所以Rust会将lucky_number也推导为相同的u32类型。

现在让我们运行程序试试:

1
2
3
4
5
6
7
8
D:\Github\rust\guessing_game\src>cargo run   
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `D:\Github\rust\guessing_game\target\debug\guessing_game.exe`
猜数游戏!
请输入你猜测的数字:
50
你猜的数字是: 50
太小了

这个游戏已经大体成型了,但玩家只能做出一次猜测,这显然是不够的。接下来,我们会加入一个循环来完善这个游戏。

使用循环来实现多次猜测

在Rust中,loop关键字会创建一个无限循环。我们可以将它加入当前的程序中,进而允许玩家反复地进行猜测抉择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
// 略
loop {
// 略
println!("你猜的数字是: {guess}");

match guess.cmp(&lucky_number) {
Ordering::Less => println!("太小了"),
Ordering::Greater => println!("太大了"),
Ordering::Equal => println!("恭喜你,猜中了"),
}
}
}

在猜测成功时优雅地退出

现在让我们给程序增加一条break语句,使得玩家在猜对数字后能够正常退出游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use rand::Rng;
// 略

match guess.cmp(&lucky_number) {
Ordering::Less => println!("太小了"),
Ordering::Greater => println!("太大了"),
Ordering::Equal => {
println!("恭喜你,猜中了");
break;
}
}
}
}

处理非法输入

为了进一步改善游戏的可玩性,我们可以在用户输入了一个非数字数据时简单地忽略这次猜测行为,并使用户可以继续进行猜测,从而避免程序发生崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
// 略

io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};

// 略
}
}
}

最终的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
println!("猜数游戏!");

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

// println!("当前的幸运数字是: {lucky_number}");
loop {
println!("请输入你猜测的数字:");

let mut guess = String::new();

io::stdin()
.read_line(&mut guess)
.expect("获取数字失败,请重新输入!");

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};

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

match guess.cmp(&lucky_number) {
Ordering::Less => println!("太小了"),
Ordering::Greater => println!("太大了"),
Ordering::Equal => {
println!("恭喜你,猜中了");
break;
}
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
猜数游戏!
请输入你猜测的数字:
90
你猜的数字是: 90
太大了
请输入你猜测的数字:
50
你猜的数字是: 50
太小了
请输入你猜测的数字:
75
你猜的数字是: 75
太小了
请输入你猜测的数字:
80
你猜的数字是: 80
太小了
请输入你猜测的数字:
85
你猜的数字是: 85
太大了
请输入你猜测的数字:
83
你猜的数字是: 83
恭喜你,猜中了