前言
让我们一起动手完成一个项目,来快速上手 Rust!本章将介绍 Rust 中一些常用概念,并向您展示如何在实际项目中运用它们。您将会学到 let、match、方法、关联函数、引用外部 crate 等知识!后续章节会深入探讨这些概念的细节。
内容
我们会实现一个经典的新手编程问题:猜数字游戏。这是它的工作原理:程序会随机生成一个 1 到 100 之间的整数。接着它会提示玩家猜一个数并输入,然后指出猜测是大了还是小了。如果猜对了,它会打印祝贺信息并退出。
创建项目
首先我们使用cargo
在我们学习的项目下,创建一个新的项目guessing_game
,命令如下:
$ cargo new guessing_game
$ cd guessing_game
目录结构如下:
代码语言:shell复制.
├── Cargo.toml
└── src
└── main.rs
Cargo.toml
的内容:
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"
[dependencies]
main.rs
的内容:
fn main() {
println!("Hello, world!");
}
现在让我们一起来使用cargo run
来运行这个程序:
$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
Finished `dev` profile [unoptimized debuginfo] target(s) in 4.65s
Running `target/debug/guessing_game`
Hello, world!
编写猜谜游戏
猜数字程序的第一部分请求用户输入,处理该输入,并检查输入是否符合预期格式。首先,我们将允许玩家输入猜测。
代码语言:rust复制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);
}
现在让我们一起来运行下这个程序,看看是否可以跑起来;
代码语言:shell复制$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
Finished `dev` profile [unoptimized debuginfo] target(s) in 0.12s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
45
You guessed: 45
我们已经成功了处理并运行了第一部分的工作,让用户输入,并将用户的输入打印到终端,接下来,我们来逐步分析上面的代码。
引入io库到当前作用域,标准库在rust中被称为std:
代码语言:rust复制默认情况下,Rust会将少量标准库中定义的程序项(item)引入到每个程序的作用域中。这些项称作 prelude,可以在标准库文档中了解到关于它的所有知识。
如果需要的类型不在 prelude 中,您必须使用
use
语句显式地将其引入作用域。std::io
库提供很多有用的功能,包括接收用户输入的功能。
use std::io;
main函数
代码语言:rust复制main函数是程序的入口点,使用fn声明了一个新的函数,这个函数没有接收额外的参数;
fn main() {}
println!
代码语言:rust复制
println!
是一个在屏幕上打印字符串的宏,打印相应的引导,让用户知道这是一个猜数字的游戏并需要输入自己猜的数字。
println!("Guess the number!");
println!("Please input your guess.");
变量
代码语言:rust复制创建一个变量用来存储用户的输入,变量默认是不可变的,想要让变量可变,可以在变量名前添加
mut
(mutability,可变性)
let mut guess = String::new();
现在我们知道了 let mut guess
会引入一个叫做 guess
的可变变量。等号(=
)告诉 Rust 现在想将某个值绑定在变量上。等号的右边是 guess
所绑定的值,它是 String::new
的结果,这个函数会返回一个 String
的新实例。String
是标准库提供的字符串类型,是一个 UTF-8 编码的可增长文本。
::new
那一行的 ::
语法表明 new
是 String
类型的一个关联函数。关联函数(associated function)是实现一种特定类型的函数,在这个例子中类型是 String
。这个 new
函数创建了一个新的空字符串。您会在很多类型上找到一个 new
函数,因为它是创建类型实例的惯用函数名。
总的来说,let mut guess = String::new();
这一行创建了一个可变变量,并绑定到一个新的 String
空实例上。
接收用户输入
我们在程序的第一行使用 use std::io;
从标准库中引入了输入/输出功能。现在我们可以从 io
模块调用 stdin
函数,这将允许我们处理用户输入:
io::stdin()
.read_line(&mut guess)
如果程序的开头没有使用 use std::io
引入 io
库,我们仍可以通过 std::io::stdin
来调用函数。stdin
函数返回一个 std::io::Stdin
的实例,这是一个类型,代表终端标准输入的句柄。
接下来,.read_line(&mut guess)
这一行调用了read_line
方法,来从标准输入句柄中获取用户输入。我们还将 &mut guess
作为参数传递给 read_line()
,以告诉它在哪个字符串存储用户输入。read_line
的全部工作是,将用户在标准输入中输入的任何内容都追加到一个字符串中(而不会覆盖其内容),所以它需要字符串作为参数。这个字符串应是可变的,以便该方法可以更改其内容。
&
表示这个参数是一个引用(reference),这为您提供了一种方法,让代码的多个部分可以访问同一处数据,而无需在内存中多次拷贝。引用是一个复杂的特性,Rust 的一个主要优势就是安全而简单的使用引用。完成当前程序并不需要了解太多细节。现在,我们只需知道就像变量一样,引用默认是不可变的。因此,需要写成 &mut guess
来使其可变,而不是 &guess
。
使用Result类型处理潜在的错误
我们仍在研究这行代码。我们现在正在讨论第三行文本,但请注意,它仍然是单个逻辑代码行的一部分。下一部分是这个方法:
代码语言:rust复制 .expect("Failed to read line");
我们可以将这段代码编写为:
代码语言:rust复制io::stdin().read_line(&mut guess).expect("Failed to read line");
但是,一行过长的代码很难阅读,所以最好拆开来写。当您使用 .method_name()
语法调用方法时,用换行和空格来拆分长代码行通常是明智的。现在让我们来看看这行代码干了什么。
如前所述, read_line
将用户输入的任何内容放入我们传递给它的字符串中,但它也返回一个 Result
值。 Result
是一个枚举(enumeration),通常称为枚举(enum),枚举类型持有固定集合的值,这些值被称为枚举的成员(variant)。
这些 Result
类型的用途是对错误处理信息进行编码,Result
的成员是 Ok
和 Err
。 Ok
表示操作成功, Ok
内部包含成功生成的值。 Err
表示操作失败, Err
包含有关操作失败的方式或原因的信息。
Result
类型的值,就像任何类型的值一样,都有为其定义的方法。io::Result
的实例拥有 expect
方法。如果 io::Result
实例的值是 Err
,expect
会导致程序崩溃,并显示传递给 expect
的参数。如果 read_line
方法返回 Err
,则可能是操作系统底层引起的错误结果。如果 io::Result
实例的值是 Ok
,expect
会获取 Ok
中的值并原样返回,以便您可以使用它。在本例中,这个值是用户输入的字节数。
如果不调用 expect
,程序也能编译,但会出现警告提示:
代码语言:shell复制Rust 警告您尚未使用 返回
read_line
的Result
值,表明程序尚未处理可能的错误。
$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
error: expected `;`, found `println`
--> src/main.rs:9:38
|
9 | io::stdin().read_line(&mut guess)
| ^ help: add `;` here
10 |
11 | println!("You guessed: {}", guess);
| ------- unexpected token
warning: unused import: `std::io`
--> src/main.rs:1:5
|
1 | use std::io;
| ^^^^^^^
|
= note: `#[warn(unused_imports)]` on by default
warning: `guessing_game` (bin "guessing_game") generated 1 warning
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error; 1 warning emitted
消除警告的正确做法是实际编写错误处理代码,但在这个例子中,我们只希望程序在出现问题时立即崩溃,因此我们可以直接使用 expect
打印值
很好,现在我们终于来到了最后一行代码,呼呼!
代码语言:rust复制 println!("You guessed: {}", guess);
这行代码现在打印了存储用户输入的字符串。里面的 {}
是预留在特定位置的占位符,使用 {}
也可以打印多个值:第一对 {}
使用格式化字符串之后的第一个值,第二对则使用第二个值,依此类推。调用一次 println!
打印多个值看起来像这样:
let x = 5;
let y = 10;
println!("x = {x} and y 2 = {}", y 2);
此代码将打印 x = 5 and y 2 = 12
生成谜底数字
接下来,我们需要生成一个用户将尝试猜测的数字,数字应该每次都不同,这样重复玩才不会乏味;范围应该在 1 到 100 之间,这样才不会太困难。Rust 标准库中尚未包含随机数功能。然而,Rust 团队还是提供了一个包含上述功能的 rand
crate。
使用 crate 来增加更多功能
记住,crate 是一个 Rust 代码包。我们正在构建的项目是一个 二进制 crate,它生成一个可执行文件。 rand
crate 是一个 库 crate,库 crate 可以包含任意能被其他程序使用的代码,但是不能独自执行。
Cargo 对外部 crate 的运用是其真正的亮点所在。在我们使用 rand
编写代码之前,需要修改 Cargo.toml 文件,引入一个 rand
依赖。现在打开这个文件并将下面这一行添加到 [dependencies]
表块标题之下。请确保按照我们这里的方式指定 rand
及其这里给出的版本号,否则本教程中的示例代码可能无法工作。
Cargo.toml:
代码语言:rust复制[dependencies]
rand = "0.8.5"
在 Cargo.toml 文件中,表头以及之后的内容属同一个表块,直到遇到下一个表头才开始新的表块。在 [dependencies]
表块中,您要告诉 Cargo 本项目依赖了哪些外部 crate 及其版本。本例中,我们使用语义化版本 0.8.5
来指定 rand
crate。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。
现在,在不更改任何代码的情况下,让我们构建项目:
代码语言:shell复制$ cargo build
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.17
Compiling libc v0.2.155
Compiling getrandom v0.2.15
Compiling rand_core v0.6.4
Compiling rand_chacha v0.3.1
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
Finished `dev` profile [unoptimized debuginfo] target(s) in 0.82s
您可能会看到不同的版本号(但它们都与代码兼容,这要归功于语义化版本!)和不同的行(取决于操作系统),并且行的顺序可能不同。
当我们引入了一个外部依赖后,Cargo 将从 registry 上获取所有依赖所需的最新版本,这是一份来自 Crates.io 的数据拷贝。Crates.io 是 Rust 生态环境中开发者们向他人贡献 Rust 开源项目的地方。
在更新完 registry 后,Cargo 检查 [dependencies]
表块并下载缺失的 crate 。本例中,虽然只声明了 rand
一个依赖,然而 Cargo 还是额外获取了 rand
所需的其他 crate,rand
依赖它们来正常工作。下载完成后,Rust 编译依赖,然后使用这些依赖编译项目。
如果不做任何修改,立刻再次运行 cargo build
,则不会看到任何除了 Finished
行之外的输出。Cargo 知道它已经下载并编译了依赖,同时 Cargo.toml 文件也没有变动。Cargo 还知道代码也没有任何修改,所以它也不会重新编译。无事可做,它只是退出。
如果打开 src/main.rs 文件,进行简单的更改,然后保存并重新生成,则只会看到两行输出:
代码语言:shell复制$ cargo build
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
Finished `dev` profile [unoptimized debuginfo] target(s) in 0.10s
这些行表明 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 文件存在,并将使用其中指定的版本,而不是再次执行找出版本的所有工作。这使您可以自动获得可重现的构建。换句话说,由于 Cargo.lock 文件,您的项目将保持在 0.8.5 版本,直到您明确升级。由于 Cargo.lock 文件对于可重现的构建非常重要,因此它通常与项目中的其余代码一起签入源代码管理。
更新crate到一个新版本
当您确实想要更新carte时,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 会从 registry(注册源) 更新可用的 crate,并根据您指定的新版本重新计算。
生成随机数
让我们开始使用 rand
来生成一个要猜测的数字。
代码语言:rust复制注意:您不仅知道要使用哪些特征以及要从 crate 调用哪些方法和函数,因此每个 crate 都有包含使用说明的文档。Cargo 的另一个简洁功能是,运行该
cargo doc --open
命令将在本地构建所有依赖项提供的文档,并在浏览器中打开它。例如,如果您对rand
crate中的其他功能感兴趣,请运行cargo doc --open
并单击左侧边栏中的按钮rand
。
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);
}
首先,我们新增了一行 use rand::Rng
。Rng
是一个 trait,它定义了随机数生成器应实现的方法,想使用这些方法的话,此 trait 必须在作用域中。
接下来,我们在中间添加两行。在第一行中,我们调用了为我们提供将要使用的特定随机数生成器的 rand::thread_rng
函数:该生成器是当前执行线程的本地变量,并由操作系统设定种子。然后我们在随机数生成器上调用该 gen_range
方法。此方法由 Rng
我们在 use rand::Rng;
语句中引入范围的特征定义。该 gen_range
方法将范围表达式作为参数,并在该范围内生成一个随机数。我们在这里使用的范围表达式采用的形式 start..=end
是包含下限和上限的,因此我们需要指定 1..=100
请求一个介于 1 和 100 之间的数字。
新添加的第二行代码打印出数字。这在开发程序时很有用,因为可以测试它,不过在最终版本中会删掉它。如果游戏一开始就打印出结果就没什么可玩的了!
尝试运行程序几次:
代码语言:shell复制$ cargo run
Finished `dev` profile [unoptimized debuginfo] target(s) in 0.00s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 39
Please input your guess.
39
You guessed: 39
$ cargo run
Finished `dev` profile [unoptimized debuginfo] target(s) in 0.03s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 2
Please input your guess.
2
You guessed: 2
您应该得到不同的随机数,它们都应该是 1 到 100 之间的数字。
将猜测与秘密数字进行比较
现在我们有了用户输入和随机数,我们可以比较它们。
代码语言:rust复制use std::io;
use rand::Rng;
use std::cmp::Ordering;
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);
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
首先我们增加了另一个 use
声明,从标准库引入了一个叫做 std::cmp::Ordering
的类型到作用域中。Ordering
也是一个枚举,不过它的成员是 Less
、Greater
和 Equal
。这是比较两个值时可能出现的三种结果。
接着,底部的五行新代码使用了 Ordering
类型,cmp
方法用来比较两个值并可以在任何可比较的值上调用。它获取一个被比较值的引用:这里是把 guess
与 secret_number
做比较。 然后它会返回一个刚才通过 use
引入作用域的 Ordering
枚举的成员。使用一个 match
表达式,根据对 guess
和 secret_number
调用 cmp
返回的 Ordering
成员来决定接下来做什么。该表达式根据调用 cmp
返回的变量 Ordering
,其中的值为 guess
和secret_number
。
一个 match
表达式由分支(arm) 构成。一个分支包含一个用于匹配的模式(pattern),给到 match
的值与分支模式相匹配时,应该执行对应分支的代码。Rust 获取提供给 match
的值并逐个检查每个分支的模式。模式和 match
构造是 Rust 强大的功能:它们可以让你表达你的代码可能遇到的各种情况,并确保你处理所有这些情况。
让我们用我们在这里使用的 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
表达式在第一次成功匹配后结束,因此在此方案中,它不会查看最后一只分支。
现在我们来运行这个代码:
代码语言:shell复制$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:21:21
|
21 | 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
--> /Users/wangyang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/cmp.rs:840:8
|
840 | 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
错误的核心表明这里有不匹配的类型(mismatched type)。Rust 有一个静态强类型系统,同时也有类型推断。当我们写出 let guess = String::new()
时,Rust 推断出 guess
应该是 String
类型,并不需要我们写出类型。另外,secret_number
是数字类型。Rust 中有好几种数字类型拥有 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!");
完整代码如下:
代码语言:rust复制use std::io;
use rand::Rng;
use std::cmp::Ordering;
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");
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!"),
}
}
我们创建一个名为 guess
的变量。但是等等,程序不是已经有一个名为 guess
?确实如此,确实如此,不过 Rust 允许用一个新值来掩盖 guess
之前的值。允许我们重用变量名称, guess
而不是强制我们创建两个唯一的变量,例如 guess_str
和 guess
。
我们将这个新变量绑定到表达式 guess.trim().parse()
。表达式 guess
中的 引用包含字符串形式的输入的原始 guess
变量。 String
实例上 trim
的方法将消除开头和结尾的任何空格,我们必须这样做才能将字符串与只能包含数值数据的 u32
进行比较。用户必须按回车键才能满足 read_line
并输入他们的猜测,这会向字符串添加一个换行符。例如,如果用户键入 5 并按 Enter 键, guess
则如下所示: 5n
。表示 n
“换行符”。(在 Windows 上,按 Enter 键会导致回车符和换行符 rn
.)该 trim
方法消除 n
或 rn
,结果仅 为5
。
字符串 parse
上的方法将字符串转换为另一种类型。在这里,我们用它来从字符串转换为数字。我们需要告诉 Rust 我们想要的 let guess: u32
确切数字类型。冒号 ( :
) 告诉 guess
Rust 我们将注释变量的类型。Rust 有一些内置的数字类型;这里 u32
看到的是一个无符号的 32 位整数。对于小正数来说,这是一个很好的默认选择。
此外,此示例程序中的 u32
注释以及与 secret_number
means 的比较 Rust 将推断出 secret_number
它也应该是一个 u32
。所以现在比较的是同一类型的两个值!
由于 parse
方法只能用于可以逻辑转换为数字的字符,所以调用它很容易产生错误。例如,字符串中包含 A