0%

Rust 权威指南(01):入门指南

《Rust 权威指南》一书由 Rust 核心团队成员编写而成,由浅入深地探讨了 Rust 语言的方方面面。作为开源的系统级编程语言,Rust 可以帮助你编写出更有效率且更加可靠的软件。在给与开发者底层控制能力的同时,通过高水准的工程设计避免了传统语言带来的诸多麻烦。

Rust 是什么

作为系统级语言事实上的标杆,C/C++ 语言诞生至今已经 40 多余年,四十多年的积累从某种角度上讲也是 40 年的负担。Rust 站在前人的肩膀上,借助于最近几十年的语言研究成果,创造出所有权与生命周期等崭新概念。相比于 C/C++,它具有天生的安全性;同时它遵循了零开销抽象规则,并为开发者保留了最大的底层控制能力

另一方面,Rust 从设计伊始便致力于提供高水准的人体工程学体验,这包括代数数据类型、卫生宏、迭代器等饱经证明的优秀语言设计,在语言本身之外,Rust 核心开发团队还规划并实现了一系列顶尖的工具链。

Rust 是一门可以帮助你开发出高效率、高可靠性软件的编程语言。以往的编程语言往往无法同时兼顾高水准的工程体验与底层控制能力,而 Rust 则被设计出来挑战这一目标。它力图同时提供强大的工程能力以及良好的开发体验,在给与开发者控制底层细节能力的同时,避免传统语言带来的诸多麻烦。

  • 大部分的错误(甚至包括并发环境中产生的错误)都可以在编译阶段被编译器拦截并发现
  • Rust 提供了一系列面向系统级编程的现代化开发工具:
    • Cargo 提供了一套内置的依赖管理与构建工具。通过 Cargo 可以在 Rust 生态系统中一致地、轻松地增加、编译及管理依赖
    • Rustfmt 用于约定一套统一的编码风格
    • The Rust Language Server 则为 IDE 提供了可供集成的代码补全和错误提示工具

Rust 编程语言的核心在于赋能:无论你在编写什么样的代码,Rust 赋予的能力都可以帮助你走的更远,并使你在更为广阔的领域中充满自信地编写程序。Rust 最大的目标在于同时通过保证安全与效率、运行速度与编程体验,消除数十年来程序员们不得不接受的那些取舍。

安装 Rust

首先需要安装 Rust。我们是通过一个叫做 rustup 的命令行工具来完成 Rust 的下载与安装。这个工具还被用来管理不同的 Rust 发行版本及其附带的工具链。执行如下命令安装 Rust:

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装完成后,Rust 工具链路径(默认为 ~/.cargo/bin/)会被自动添加到环境变量 PATH 并在下一次登录终端时生效。也可以直接执行 source $HOME/.cargo/env 使配置立即生效。

另外,为了正常地编译执行 Rust 程序,还需要一个链接器。由于 C 语言编译器通常会附带安装链接器,所以可以通过安装一个 C 语言编译器来获得所需的链接器。而且常用的 Rust 包会依赖于使用 C 语言编写的代码,为了编译这些 Rust 代码,也需要一个 C 编译器。

执行如下命令,检查 Rust 是否正确安装:

1
2
# rustc --version
rustc 1.66.0 (69f9c33d7 2022-12-12)

安装工具会在本地生成一份离线的文档,可以通过命令 rustup doc 在网页浏览器打开它。

更新与卸载

在使用 rustup 成功安装 Rust 后,可以通过如下命令更新 Rust 版本:

1
# rustup update

通过如下命令卸载 rustup 以及 Rust 工具链:

1
# rustup self uninstall

Hello World

接下来将使用 Rust 输出 Hello, World。首先创建一个文件夹保存 Rust 代码

1
2
# mkdir hello_world
# cd hello_world/

Rust 文件总是以 .rs 扩展名结尾。如果文件名中包含多个单词,可以使用下划线来隔开它们。在该目录下新建 main.rs 的源文件,其包含如下代码:

1
2
3
fn main() {
println!("Hello, World!");
}

通过如下命令编译并运行该文件:

1
2
3
4
5
# rustc main.rs
# ls
main main.rs
# ./main
Hello, World!

接下来将分析一下该程序:

  • 当运行一个可执行 Rust 程序时,所有代码都会从 main 入口函数开始运行
  • 这段代码定义了一个名为 main、没有任何参数、返回值的函数
  • 函数体部分都位于花括号内。推荐把左花括号与函数声明放置于同一行并以空格分隔
  • 标准 Rust 风格使用 4 个空格而不是 Tab 来实现缩进
  • 这里调用了 println! 宏将字符串输出到标准输出。以 ! 结尾的调用意味着正在使用一个宏而不是普通函数
  • 使用 ; 表明当前的表达式已经结束

在 Rust 中,编译和运行是两个不同的步骤。在运行 Rust 程序之前,可以通过 rustc 命令及附带的源文件名参数来编译它。编译成功后即可获得一个二进制的可执行文件。

不同于 Python 之类的动态语言,Rust 是一种预编译语言,这意味着当你编译完 Rust 程序之后,就可以将可执行文件交给其他人,并运行在没有安装 Rust 的环境中。

仅仅使用 rustc 编译简单的程序并不会有太大的麻烦,但是随着项目的规模越来越大,协同开发的人员越来越多,管理项目依赖、代码构建这样的事情就会变得越来越复杂和琐碎。接下来将介绍一个帮助我们简化问题,并且能够实际运用于生产的 Rust 构建工具:Cargo。

Hello Cargo

Cargo 是 Rust 工具链中内置的构建系统与包管理器,绝大多数 Rust 用户使用它来管理自己的 Rust 项目。通过如下命令确认 Cargo 是否安装正确:

1
2
# cargo --version
cargo 1.66.0 (d65d197ad 2022-11-15)

接下来将使用 Cargo 创建一个项目。通过如下命令创建一个项目,Cargo 会根据项目名创建同名项目目录并放置它生成的文件:

1
2
3
4
5
6
7
8
# cargo new hello_cargo
Created binary (application) `hello_cargo` package
# cd hello_cargo/
# tree .
.
├── Cargo.toml
└── src
└── main.rs

Cargo 生成了一个 Cargo.toml 的文件,以及 src 目录,该目录下包含一个名为 main.rs 的源代码文件同时,Cargo 还会初始化一个新的 Git 仓库并生成默认的 .gitignore 文件。

Cargo.toml 的内容如下:

1
2
3
4
5
6
7
8
9
# cat Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Cargo 使用 TOML(Tom’s Obvious, Minimal Language)作为标准的配置格式:

  • 首行的 [package] 是一个区域标签,它表明接下来的语句会被用于配置当前的程序包。之后则是程序名、版本号以及作者信息。
  • [dependencies] 同样是一个区域标签,它表明随后的区域会被用来声明项目依赖。在 Rust 中,把代码的集合称为包(crate)

src/main.rs 的内容如下:

1
2
3
fn main() {
println!("Hello, world!");
}

Cargo 默认把所有源文件保存到 src 目录下,而项目的根目录则用来保存 README文档、许可证、配置文件等与源文件无关的文件。使用 Cargo 可以帮助你合理并一致地组织自己的项目文件,从而使一切井井有条。如果想把手动创建的项目转换为使用 Cargo 管理的项目,只需要把源文件放置到 src 目录下,并且创建一个对应的 Cargo.toml 配置文件即可。

对于 Cargo 项目,通过如下命令来完成构建任务:

1
2
3
# cargo build
Compiling hello_cargo v0.1.0 (/root/code/private/rust/chapter_01/hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 1.05s

该命令会将可执行程序生成在路径 target/debug/hello_cargo。首次使用命令 cargo build 构建时,它还会在项目根目录下创建一个名为 Cargo.lock 的新文件,该文件记录了当前项目所有依赖库的具体版本号,最好不要手动编辑其内容,Cargo 自动维护它。

编译完成后,即可运行该程序。也可以直接使用 cargo run 命令来依次完成编译和运行任务:

1
2
# ./target/debug/hello_cargo
Hello, world!
1
2
3
4
# cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/hello_cargo`
Hello, world!

另外,Cargo 还提供了一个叫做 cargo check 的命令,可以使用该命令来快速检查当前代码是否可以通过编译,而不需要花费额外的时间去真正生成可执行程序。由于它跳过了生成可执行程序的步骤,所以它运行速度远快于 cargo build。所以在编码过程中需要不断通过编译器检查错误,可以使用 cargo check 命令来加速这个过程。

1
2
3
# cargo check
Checking hello_cargo v0.1.0 (/root/code/private/rust/chapter_01/hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.07s

当准备发布自己的项目时,使用 cargo build --release 在优化模式下构建并生成可执行程序。它生成的可执行文件位于 target/release 目录下。该模式会以更长的编译时间为代价来优化代码,从而使得代码拥有更好的运行时性能。cargo run 命令也支持 --release 选项。

随着程序越来越复杂,Cargo 一定会证明其价值。对于那些由多个包构成的复杂项目而言,使用 Cargo 来协调整个构建过程要比手动操作简单的多。另一个使用 Cargo 的优势在于它的命令在不同的操作系统中都是相同的。

编写一个猜数游戏

接下来将编写一个更为复杂的程序并在实际编码中快速熟悉 Rust。该程序是一个猜数,它会首先生成一个 1-100 之间的随机整数,并紧接着让玩家对数字进行猜测,如果输入数字与随机数不同,程序将给出偏大或偏小的提示。如果猜中了准备的数字,则打印信息,程序退出。

创建项目

按如下方式创建项目:

1
2
3
# cargo new guessing_game
Created binary (application) `guessing_game` package
# cd guessing_game

接下来编辑 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
28
29
30
31
32
33
34
35
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, 101);

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

输入输出处理:

  • 为了获得用户输入并将其打印出来,需要把标准库(即 std)中的 io 模块引入当前作用域,即 use std::io
  • 作为默认行为,Rust 会将预导入(prelude)模块内的条目自动引入每一段程序的作用域中,它包含了一部分常用的类型。但是假如你需要的类型不在预导入模块内,就需要使用 use 语句来显式地进行导入声明
  • let 开头的语句创建了一个新的变量。在 Rust 中,变量默认都是不可变的。需要使用关键字 mut 来声明一个变量是可变的
  • // 开始注释。它意味着从当前位置到本行结尾的所有内容都是注释,Rust 在编译过程中会忽略注释
  • String 是标准库的中一个字符串类型,它在内部使用了 UTF-8 格式的编码并可以按照需求扩展自己的大小
  • String::new 中的 :: 语法表明 new 是 String 类型的一个关联函数,我们会针对类型本身定义关联函数。关联函数在某些语言中也被称为静态方法。在 String::new() 会创建一个新的空白字符串,许多其他类型也提供 new 函数,因为这是创建类型实例的惯用函数名称
  • io::stdin().read_line(&mut guess) 首先调用 io 模块的关联函数 stdin(),它返回 std::io::Stdin 的实例,它被用作句斌来处理终端中的标准输入。之后 .readline(&mut guess) 调用标准输入句柄的 read_line 方法来获得用户输入,并传入参数 &mut guess。参数前面的 & 意味着当前参数是一个引用,你的代码可以通过引用在不同的地方访问同一份数据,而无需付出多余的拷贝开销。
  • Rust 的核心竞争力之一,就是它保证了我们可以简单并安全地使用引用功能。引用与变量一样,在默认情况下也是不可变的,因此需要使用 &mut guess 而不是 &guess 来声明一个可变引用。
  • 过长的语句会显得难以阅读。我们可以将链式调用的两个方法拆分到不同的文本行。在使用 .foo() 调用函数时可以引入换行和缩进来格式化一些较长的代码
  • read_line() 返回一个 io::Result 值。在 Rust 标准库中,可以找到许多以 Result 命名的类型,它们通常是各个子模块中 Rusult 泛型的特定版本。Result 是一个枚举类型,枚举类型由一系列固定的值组合而成,这些值称为枚举的变体。
  • 对于 Result 而言,它拥有 OkErr 两个变体。Ok 变体表明当前的操作执行成功,并附带代码产生的结果值。而 Err 变体则表明当前操作执行失败,并附带引发失败的原因
  • Result 类型的值也定义了一系列方法,expect 就是其中之一。如果 io::Result 实例的值是 Err,那么 expect 方法会中断当前程序,并将传入的字符串参数显示出来。如果 io::Result 实例的值是 Ok,expect 就会提取 Ok 中附带的值,并将它作为结果返回给用户
  • println! 宏支持占位符 {},它用于将后面的参数值插入自己预留的特定位置

引入 rand 包:

  • Rust 中的包(crate)代表了一系列源代码文件的集合,rand 包就是一个提供随机数生成功能的库包(library crate)
  • 修改 Cargo.toml,将 rand 包声明为依赖
1
2
3
[dependencies]

rand = '0.3.14'
  • Cargo 会按照标准的语义化版本系统(Semantic Versioning,SemVer)来理解所有的版本号。它这里的 0.3.14 实际是 ^0.3.14 的一个简写,它表示任何与 0.3.14 版本公共 API 相兼容的版本
  • Cargo 可以从注册表中获取所有可用库的最新版本信息,这些信息通常是从 crates.io 复制而来
  • 在下载完所需的包后,Rust 会开始编译它们,并基于这些依赖编译我们自己的项目
  • Cargo 提供了一套机制来确保构建结果是可以重现的,任何人在任何时候重新编译我们的代码都会生成相同的产物:Cargo 会一直使用某个特定版本的依赖直到你手动指定了其他版本
  • 当我们第一次使用 cargo build 构建项目时,Cargo 会依次遍历我们声明的依赖及其对应的语义化版本,找到符合要求的具体版本号,并将它们写入 Cargo.lock 文件中。再次构建时,Cargo 会优先检索 Cargo.lock 文件,如果该文件中已经存在具体版本的依赖库,就会跳过计算版本号的过程,并直接使用文件中指明的版本。这就使得我们拥有了一个自动化、可重现的构建系统
  • 如果想升级某个依赖包,cargo update 命令会强制 Cargo 忽略 Cargo.lock 文件,并重新计算出所有依赖包中符合 Cargo.toml 声明的最新版本
  • 在本例中,基于语义化版本的规则,Cargo 在自动升级时只会寻找大于 0.3.0 并小于 0.4.0 的最新版本
  • use rand::Rng 中的 Rng 是一个 Trait(特征),它定义了随机数生成器需要实现的方法集合。为了使用这些方法,需要显式地将它引入当前作用域中
  • rand::thread_rng() 会返回一个特定的随机数生成器,它位于本地线程空间,并通过操作系统获得随机数种子,之后调用该随机数生成器的 gen_range() 方法

比较逻辑:

  • 从标准库中引入的 std::cmp::Ordering 类型也是一个枚举类型,它拥有 Less、Greater 以及 Equal 这 3 个变体。
  • cmp 方法能够为任何可比较的值类型计算出它们比较后的结果,并返回 Ordering 枚举类型的变体
  • match 表达式由数个分支组成,每个分支都包含一个用于匹配的模式(pattern),以及匹配成功后要执行的相应代码。match 提供了依据不同条件执行不同代码的能力,并能够确保你不会遗漏任何分支条件
  • Rust 有一个强类型系统。同时它还拥有自动进行类型推导的能力。由于 guess 是 String 类型,而 secret_number 为整数类型(默认为 i32 类型)。为了进行比较操作,需要将 String 类型转换为数值类型
  • Rust 允许使用同名的新变量来隐藏旧变量的值。该特性常备用于需要准换值类型的场景。let guess: u32 显式地声明我们需要的数值类型。

循环逻辑:

  • loop 关键字会创建一个无限循环
  • 当猜中游戏后,执行 break 语句退出循环
  • 当 parse 解析成功后,它将返回一个包含了该数字的 Ok 值。该 Ok 值会匹配到 match 表达式的第一个分支模式,并返回 parse 产生的、放置在 Ok 中的 num 值
  • 当用户输入非法数据时,match 表达式会匹配 Err(_) 模式。这里下划线是一个通配符,它可以匹配所有可能得 Err 值,而不管其中的具体错误信息。程序继续执行第二个分支中的代码 continue