1.0 简介
1.0. 简介
1.0.1. 为什么要使用Rust
-
Rust代码可靠且高效。
-
Rust可以代替C和C++。在具有相近性能的前提下,Rust比它们更安全。在实际编程中最明显的感受就是:Rust不需要像前两者那样写几行就编译一下查看是否报错。具体如下:
- 内存安全:无空指针解引用、悬空指针或数据竞争
- 线程安全:在程序运行之前就可以保证多线程代码是安全的
- 避免未定义行为:例如数组越界、未初始化的变量和使用已释放的内存
-
Rust提供了现代语言特性,如泛型、trait、模式匹配等。
-
Rust提供了更现代化的工具链。Rust的Cargo和Python的包管理工具(如pip)有着同一理念。用过C/C++的人都知道,这两个语言的依赖项配置比较麻烦,而Python的包管理工具十分灵活且简单。Cargo让Rust用户能在拥有C/C++级性能的同时,获得类似Python般令人舒适的依赖管理体验。
1.0.2 适用场景
-
需要运行速度:Rust既可以像C一样精细控制内存(通过
unsafe),也可以提供现代高级语言的便利性(如所有权系统和模式匹配)。Python是一种非常高级的语言,开发效率高,但牺牲了性能和控制。 -
需要内存安全:Rust通过编译时的静态检查,提供了强大的内存安全保证,这使其在需要避免内存错误的场景(如操作系统、嵌入式开发、网络服务器等)中极为适用。
-
需要高效利用多处理器:Rust为高效并发和多处理器编程提供了原生支持,而不牺牲安全性。这对于需要处理高吞吐量和并发任务的场景(如Web服务器、分布式系统、实时计算)尤其重要。
擅长的领域:
- Web服务
- WebAssembly (C#和Java的性能对比Rust和C/C++相形见绌)
- 命令行工具
- 网络编程
- 嵌入式设备
- 系统编程
1.0.3 与其他语言的对比
| 类别 | 语言 | 特点 |
|---|---|---|
| 机器语言 | 二进制指令 | 最接近硬件,由CPU直接执行 |
| 汇编语言 | Assembly | 使用助记符代替机器指令,如 MOV AX, BX |
| 低级语言 | C、C++ | 更贴近硬件,提供有限的抽象 |
| 中级语言 | Rust、Go | 性能接近低级语言,但提供了更高的抽象 |
| 高级语言 | Python、Java | 更高层次的抽象,易读易用 |
高级语言与低级语言并不是绝对对立的,而是一个连续的光谱:
- 更低级的语言提供更多的硬件控制能力,但编写代码复杂,开发效率低。
- 更高级的语言提供更多的抽象和自动化功能,但可能会引入运行时开销,失去对硬件的精细控制。
Rust的优点:
- 性能好
- 安全性高
- 极好的并发支持
Rust作为一种中级语言相比于其他语言有这些优势:
- C / C++性能非常好,但是不够安全;Rust能够做到在维持大致相同性能的前提下保证安全。
- Java / C#能保证内存安全(有GC垃圾回收程序),也有很多特性,但是性能不行;Rust不但拥有与之相媲美的安全性,而且性能还更强。
1.0.4. Rust的历史
Rust最早是Mozilla公司下的一个研究性项目,火狐(Firefox)浏览器就是其应用的重要例子。
Mozilla公司使用Rust创建了Servo实验性浏览器引擎(2012年启动,2016年发布首个预览版本),其组件被设计为并行运行。不幸的是,在2020年8月,Mozilla裁撤了大部分Servo开发团队。从2020年11月17日起,Servo由Linux基金会接管。目前Servo的部分功能已经被集成到火狐里了。
Firefox量子版包含了Servo的CSS渲染引擎。Rust使得Firefox获得了巨大的性能改进。
1.0.5. Rust的用户与案例
- Google: 操作系统Fuchsia,Rust代码量占30%
- Amazon: 基于Linux开发的、可以直接在裸机或虚拟机上运行容器的操作系统
- Redox OS: 纯Rust开发的下一代安全操作系统
- 斯坦福大学和密歇根大学: 嵌入式实时操作系统,应用于Google的加密产品
- 微软:使用Rust重写Windows系统中的一些低级组件
- 微软:WinRT/Rust项目
1.1 安装Rust
1.1.1. 官网安装
去Rust官网,右上角可以设置语言
点击“Get Started”,你会看到如下的界面:
根据自己的系统版本来选择下载:32位下32-BIT,64位下64-BIT。目前大部分电脑都是64位,如果你不知道自己的电脑是64位还是32位,那么只要你的电脑不是老古董,下64位大概率没问题。
如果想要为macOS、Linux,或是Windows的Linux子系统安装Rust,就在终端执行如下命令:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
打开下载好的安装程序,会看到类似如下的菜单:
Current installation options:
default host triple: x86_64-pc-windows-msvc
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>
这里有三个选项:
- 选项一(默认选项):标准安装
- 选项二:自定义安装,可以自定义安装路径、安装的组件、安装的工具链版本等
- 选项三:取消安装
对于大多数人来说用选项一即可(先输入1再回车,或是直接回车都可以)
如果你看到类似如下的输出,那么Rust就已经成功安装了:
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
info: default toolchain set to 'stable-x86_64-pc-windows-msvc'
stable-x86_64-pc-windows-msvc installed - rustc 1.96.0 (ac68faa20 2026-05-25)
Rust is installed now. Great!
To get started you may need to restart your current shell.
This would reload its PATH environment variable to include
Cargo's bin directory (%USERPROFILE%\.cargo\bin).
Press the Enter key to continue.
安装程序会提示你需要重启Shell,按下回车键,程序就会退出,Rust也就安装完毕了。
1.1.2. Rust各项命令行操作
Rust的各项命令在Windows环境下可以在Terminal中执行(Win11自带;如果没有,去微软商城搜Windows Terminal下载即可)
-
更新Rust:
rustup updateRust作为新兴的语言,目前的更新非常频繁,建议不时地执行此操作来获得最新版本。 -
卸载Rust:
rustup self uninstall -
安装验证:
rustc --version或是rustc -V结果格式:rustc x.y.z (xxxxxxxxx yyyy-mm-dd)x.y.z表示版本号xxxxxxxxx表示当前版本的哈希值yyyy-mm-dd表示该版本的提交日期
示例:
$ rustc -V rustc 1.96.0 (ac68faa20 2026-05-25) -
打开本地Rust文档手册:
rustup doc
开发工具
- VS Code 安装Rust插件
- VIM
- Helix
- RustRover
- …
1.2 Rust的基本认识与“Hello World”
1.2.0. 题外话
本人非常推荐使用JetBrains开发的RustRover (目前对非商业用途是免费的) 作为编写Rust的IDE,在之后的文章中本人也会继续使用RustRover作为演示。本文章需要你有一定的编程经验(如果有C/C++的经验那就再好不过)
1.2.1. 编写Rust程序
-
文件后缀名:
.rs -
命名规范: 蛇形命名法(小写字母,用下划线分割单词) 例子:
hello_world.rs
1.2.2. 打印Hello World
Step 1:新建Rust项目
打开RustRover,点击新建项目,会出现如下的界面:
根据自己的需求来更改项目的储存路径或是选择工具链所在位置,点击创建即可。如果IDE没有识别到工具链,请你检查是否下载并安装了Rust,安装教程见 1.1. 安装Rust。
Step 2:写代码
因为RustRover会对新项目自动配置Cargo(详见 1.3. Rust Cargo),所以项目中会直接生成main.rs并且在其中写下了打印Hello World的代码:

理解代码:
fn main(){
println!("Hello World");
}
-
fn:表示建立一个函数(等同于js的function,go的func,python的def) -
main(){}:main是这个函数的函数名,()内放参数,没有就什么都不填,{}内是函数体。main函数很特别,它是每个rust可执行程序最先执行的代码 -
println!();:println!()是打印函数,括号内填需要打印的内容,这个函数名中带有一个!,代表这是一个宏函数,这个概念之后会涉及。这个宏函数的调用需要以;结束,因为它们相当于语句。 -
"Hello World":""代表字符串,Hello World是这个字符串的内容
注意:Rust的缩进是4个空格而不是1个Tab,因为TAB有个缺点是不同编辑配置下显示可能不同,有些2字符位,有些4字符位,所以空格缩进比较稳当。
Step 3:运行
直接点击RustRover左上角的运行按钮(或是 Windows/Linux 上的 Shift + F10,macOS 上的 ⌃R),就能看到Hello World被成功打印出来了:
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/hello_demo`
Hello World
对于非RustRover用户,你也可以通过terminal来运行:
-
打开终端,复制
.rs文件所在的文件夹路径,输入命令cd 文件夹路径来在终端中打开此文件夹。
-
输入命令
rustc main.rs来编译,如果你的程序名不是main.rs也可以换成自己的程序名。你会看到程序所在目录下多出了同名但后缀不同的另外两个文件(Linux/macOS只有一个,没有.pdb文件),.pdb文件是 Windows 平台 的调试符号文件,.exe即是可执行文件。
-
对于Windows,在终端输入
.\main.exe即可;对于Linux/MacOS,在终端输入./main即可。如果程序名字不是main只需要把这里的main换成你的程序名字就行。示例输出:
$ ./main
Hello World
注意:编译和运行是单独的两步
- 运行Rust程序之前必须先编译,命令为
rustc 你的程序名.rs - 编译成功后会生成一个二进制文件(Windows平台上还会生成
.pdb文件) - Rust是ahead-of-time编译的语言,意味着可以先编译程序,然后把可执行文件交给别人运行(无需安装Rust)
- rustc只适合简单Rust程序,复杂的rust程序需要Cargo(详见 1.3. Rust Cargo)
1.3 Rust Cargo
1.3.0. 回顾
1.2. Rust的基本认识与打印“Hello World”文章的末尾提到了只有小型简单的Rust项目适合使用rustc来编译,大型项目需要Cargo,本篇就对Cargo进行详细的介绍。
1.3.1. 什么是Cargo
Cargo是Rust的构建系统和包管理工具,它可以构建代码、下载依赖的库、构建这些库等。
Cargo是随Rust一起安装的。判断Cargo是否正确地安装:在终端中输入命令cargo --version
$ cargo --version
cargo 1.96.0 (30a34c682 2026-05-25)
1.3.2. 使用Cargo创建项目
RustRover中创建的项目都会自动配置Cargo,在左侧项目结构中就能看到叫做Cargo.toml的文件。
对于非RustRover用户,可以在终端中配置Cargo:
- 复制想要Cargo项目所在的文件夹路径,打开终端,输入命令
cd 想要的路径 - 接着输入命令
cargo new 想要的项目名来创建项目 - 在IDE中打开这个路径即可,项目在你取的Cargo项目名文件夹下
最后的项目结构应该是这样:
PS: 有一些IDE不会有target这个文件夹和Cargo.lock这个文件,在第一次编译后才会出现
解析项目结构:
-
src是Source Code的缩写,这个文件夹下存储的是你的代码。 -
.gitignore说明在创建这个项目的同时已经初始化了一个Git仓库。当然也可以使用其他VCS(Version Control System,版本控制系统)或是不使用VCS,只需要在创建项目(cargo new 想要的项目名这一步)时加上--vcs进行设置即可。 -
Cargo.toml的内容会在下文阐述。
1.3.3. Cargo.toml
.toml(Tom’s Obvious, Minimal Language)格式是Cargo的配置文件格式。
其内容如下:

内容解析:
-
[package]是一个区域标题,表示下方的内容是用来配置包(package)的name指项目名version指项目版本authors指项目作者,可有可无,这里没有;有的话这一行格式应为:authors = ["your_name <your_email@xxx.com>"]edition指使用的Rust edition
-
[dependencies]是另一个区域标题,下方内容是用来配置依赖项(dependencies)的,它会列出项目的依赖项。没有依赖项时,这一节就是空的。
PS: 在Rust里,代码的包(库)被称作crate。
1.3.4. 项目结构的格式
- 所有的源代码都应该在
src目录下 Cargo.toml应在顶层目录下- 顶层目录可以放置README、许可证、配置文件等与源码无关的文件
1.3.5. 非Cargo项目转化为使用Cargo
- 把源代码移动到
src目录下 - 创建
Cargo.toml,然后根据源代码填写配置
1.3.6. 构建Cargo项目
-
复制Cargo项目所在的文件夹路径,打开终端,输入命令
cd Cargo项目路径 -
输入命令
cargo build。这个命令会创建可执行文件,在Windows上,其路径在target\debug\你的Cargo项目名.exe;在Linux/macOS上,其路径在target/debug/你的Cargo项目名 -
执行这个可执行文件,先确保你已执行第一步。对于Windows,在终端中输入
.\target\debug\你的Cargo项目名.exe;对于Linux/macOS,在终端中输入./target/debug/你的Cargo项目名 -
第一次运行
cargo build会在顶层目录生成Cargo.lock文件
1.3.7. Cargo.lock
Cargo.lock会在项目第一次编译后生成(有的IDE在第一次编译前就会自动生成),其内容如下:
这个文件的作用是追踪项目依赖的精确版本,如这个文件内的注释所说,不需要也不建议手动修改该文件。
1.3.8. 运行Cargo项目
- 复制Cargo项目所在的文件夹路径,打开终端,输入命令
cd Cargo项目路径 - 输入命令
cargo run
cargo run实际上是两步操作——编译代码并执行结果:先生成一个可执行文件,然后再运行这个可执行文件。如果之前编译成功过,并且源码没有改变,那就会直接运行可执行文件。
1.3.9. 代码的检查
cargo check的作用是检查代码确保能成功编译,但不会产生可执行文件。cargo check比cargo build的速度快很多,编写代码时可以反复使用cargo check来提高效率。
用法:
- 复制Cargo项目所在的文件夹路径,打开终端,输入命令
cd Cargo项目路径 - 输入命令
cargo check
1.3.10. 为发布构建
cargo build这个命令是用于开发(调试)时的。当你编写完代码想要发布时,就应该使用cargo build --release这个构建发布版的指令,而不是cargo build。这两者对比起来,前者编译时间更长,但运行速度更快。前者生成的可执行文件会在target/release中,而不是target/debug。
2.1 猜数游戏Pt.1 一次猜测
2.1.0 本篇知识点
在本篇中,你将学到:
- 变量的声明
- 相关的函数
- 枚举类型
println!()的进阶使用- …
2.1.1 游戏目标
- 生成一个 1 到 100 间的随机数
- 提示玩家输入一个猜测(本篇会涉及)
- 猜完之后,程序会提示猜测是太大了还是太小了
- 如果猜测正确,那么打印一个庆祝信息,程序退出
2.1.2 代码实现
Step 1:打印出游戏名并提示用户输入
- 构建
main函数。如何构建函数以及其格式在 1.2. Rust的基本认识与打印“Hello World” 中已提及,这里不再重复:
fn main() {
}
- 通过
println!()这个宏来打印文本:
fn main() {
println!("Number Guessing Game");
println!("Guess a number");
}
Step 2:创建变量来存储用户的输入
在提示用户输入后,这个程序会需要一个变量来存储用户输入,这一行的代码应如下:
#![allow(unused)]
fn main() {
let mut guess = String::new();
}
let用于声明一个新变量,默认情况下变量是不可变的。- 在
let后面加上mut代表声明的这个变量是可变的。 guess是这个变量的名字。=用于赋值。String::new()是用来创建一个新的、空的字符串的静态方法。String是 Rust 标准库提供的 UTF-8 动态字符串类型。::表明new()是String类型的关联函数,关联函数是针对类型本身实现的,而不是针对某个具体字符串实例实现的,类似于 C# 或 Java 中的静态方法。调用String::new()会返回一个新的String实例,且其中没有任何内容,也就是空字符串。
Rust 的很多类型都有 new() 函数,new() 是创建类型实例的常见名称。
Step 3:获取用户的输入
接下来我们需要读取用户的输入,这部分代码如下:
#![allow(unused)]
fn main() {
io::stdin().read_line(&mut guess).expect("Could not read the line");
}
io是模块名。这个模块中有我们所需的stdin()函数。- 这里的
::用于通过模块路径访问其中的项(如std::io::stdin)。 stdin()是一个函数,这个函数用于获取标准输入流,并返回Stdin类型的实例。它会被用作句柄(handle) 来处理终端中的标准输入。.read_line()是Stdin类型提供的方法。它从标准输入中读取一行内容放到字符串中,并传递给一个可变的字符串变量。read_line()还会返回一个Result,这是一种有两个变体的枚举:Ok和Err。如果read_line()成功,它会返回Ok以及读取到的字节数;如果失败,它会返回Err以及失败原因。&mut guess指的是把.read_line()所读取到的内容传入到这个可变变量guess里。这里的&表示取引用,通过引用就可以在代码的不同地方访问同一块数据(内存地址)。mut表示被引用的变量是可变的。- 在读取时可能发生错误,这里就需要调用
.expect(),它是read_line()返回的Result类型上的一个方法。如果读取失败,read_line()就会返回Err,.expect()会立即触发panic!,终止当前程序,并打印传给expect的错误信息。如果读取成功,read_line()就会返回Ok,.expect()会把附加的值返回出来。
PS:也可以省略 .expect(),但cargo build时会发出警告。
如果你正在 IDE 里写到这里,你会发现 io 这处被标红了。这是因为这个程序还没有声明依赖这个模块。只需要在程序开头添加导入即可:
#![allow(unused)]
fn main() {
use std::io;
}
use是导入的关键字。std::io是指标准库(std)下的io这个模块。
也可以直接在调用了 io 模块的这一行前加上库名,这样就不用在程序开头添加导入:
#![allow(unused)]
fn main() {
std::io::stdin().read_line(&mut guess).expect("Could not read the line");
}
事实上,在默认情况下 Rust 会把一个叫 prelude 的模块的内容导入到每个程序的作用域(这个概念之后会讲)中,有人把它叫做预导入模块。如果你要使用的类型不在 prelude 里,就需要显式导入。
Step 4:打印用户的输入
最后,打印出用户输入:
#![allow(unused)]
fn main() {
println!("The number you guessed is:{}", guess);
}
"The number you guessed is:{}"中的{}是占位符,它的值在输出时会被替换为后面变量的值,这里就是guess。
2.1.3 代码效果
这是完整的代码:
use std::io;
fn main() {
println!("Number Guessing Game");
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
println!("The number you guessed is:{}", guess);
}
效果:
Number Guessing Game
Guess a number
10
The number you guessed is:10
2.2 猜数游戏Pt.2 生成随机数
2.2.0 本篇知识点
在本篇中,你将学到:
- 外部 crate 的搜索与下载
- Cargo 依赖项管理
- 基于语义化版本的升级规则
rand随机数生成器- …
2.2.1 游戏目标
- 生成一个 1 到 100 间的随机数(本篇会涉及)
- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了
- 如果猜测正确,那么打印一个庆祝信息,程序退出
2.2.2 代码实现
Step 1:寻找外部库
虽然 Rust 标准库内并没有提供与生成随机数相关的函数,但 Rust 团队开发了具有这个功能的外部库。在 Rust 官方 crates 注册中心 中搜索 rand 就可以找到它。这个页面提供了非常详细的 crate 介绍。

Rust 的 crate 一共分为两种:
- 库 crate(Library crate):提供功能或逻辑模块的 crate。它没有
main函数,不能单独运行。通常用于与其他代码共享功能。rand这个 crate 就属于库 crate。 - 二进制 crate(Binary crate):可执行程序,包含一个
main函数,编译后会生成可运行的二进制文件。用于构建独立、可运行的 Rust 应用程序。
Step 2:把这个外部 crate 写入 Cargo 依赖项
接下来就需要把这个外部库写入 Cargo 依赖项(有关 Cargo 的介绍在 1.3. Rust Cargo 基础知识 中已提及,这里不再重复),以供程序调用。
打开项目中的 Cargo.toml 文件,在 dependencies 下面添加依赖项,格式为 依赖项名 = "依赖项版本"(crate 页面的 Install 一栏下也有这种写法)。这个程序需要 rand 这个依赖项,并且使用 0.8.5 这个版本,就应该写 rand = "0.8.5"。如果这个依赖项还有自己的依赖项,Cargo 就会在编译时自动下载它们。
实际上,0.8.5 这种版本号写法是一种简写,其完整写法为 ^0.8.5,表示任何与 0.8.5 版本公共 API 兼容的版本都可以(至少是 0.8.5,但低于 0.9.0)。比如某个依赖项的版本是 1.2,那就相当于 ^1.2.0,意味着允许任意 >=1.2.0 且 <2.0.0 的版本——因此可以解析到 1.3.0 等更高的 1.x 版本,但不会升级到 2.0.0 或更高版本。
Cargo 会把实际选中的精确版本记录在 Cargo.lock 中,并在之后的构建中复用这些版本,直到你更新依赖(例如使用 cargo update)。
如果某个依赖项的更新会破坏基于旧版本依赖项编写的代码,那么重新构建后会发生什么呢?答案在 Cargo.lock 文件中。在构建时,Cargo 会检查是否已经存在 Cargo.lock 文件;如果有,就使用这个文件里指定的版本,从而避免兼容性问题。
如果想在当前标准下更新版本,可以在终端中使用 cargo update。具体步骤如下:
- 复制 Cargo 项目所在路径,打开终端,输入
cd Cargo_project_path - 输入
cargo update
这个命令会更新 Cargo.lock:向注册表查询仍然满足 Cargo.toml 中要求的最新依赖版本;Cargo.toml 里写下的版本要求本身不会改变。举个例子,假如某个依赖项在 Cargo.toml 中声明的版本是 1.2,cargo update 就可以把锁定的版本升级到不低于 1.2.0 的最新 1.x.x,但不会升级到 2.0.0 或更高版本;同时 Cargo.toml 中写下的要求依然是 1.2。
Step 3:在代码中使用这个依赖项
在程序开头需要使用关键字 use 来导入依赖项:
#![allow(unused)]
fn main() {
use rand::Rng;
}
rand::Rng 是一个 trait。trait 类似于其他语言中的接口(如 Java 的接口或 C++ 的纯虚基类),用于规定一组类型必须实现的函数和方法。rand::Rng 定义了随机数生成器所需的一些方法。
接下来在 main 中使用这个 trait 来生成随机数:
#![allow(unused)]
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
}
PS:在旧版本中,应写为 gen_range(1, 101)。
let range_number:声明了一个叫做range_number的不可变变量=:赋值rand::thread_rng():返回一个ThreadRng值,它是一个随机数生成器。这个随机数生成器位于本地线程空间,并通过操作系统获得种子。.gen_range(1..101):rand::thread_rng()上的一个方法,它接收一个范围,并在该范围内生成随机数。这里会生成从 1 开始、到但不包括 101 的数字。
最后再打印出这个随机数(println! 的使用在 2.1 猜数游戏Pt.1 一次猜测 已作介绍,这里不再重复):
#![allow(unused)]
fn main() {
println!("The secret number is: {}", range_number);
}
2.2.3 代码效果
这是完整的代码:
use std::io;
use rand::Rng;
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
println!("Number Guessing Game");
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
println!("The number you guessed is:{}", guess);
println!("The secret number is: {}", range_number);
}
运行效果如下(每次运行的神秘数字都会不同):
Number Guessing Game
Guess a number
10
The number you guessed is:10
The secret number is: 65
2.3 猜数游戏Pt.3 输入数与随机数的对比
2.3.0 本篇知识点
在本篇中,你将学到:
match的用法- 遮蔽(shadowing)
- 类型转换
Ordering类型
2.3.1 游戏目标
- 生成一个 1 到 100 间的随机数
- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了(本篇会涉及)
- 如果猜测正确,那么打印一个庆祝信息,程序退出
2.3.2 代码实现
这是截止到 2.2 猜数游戏Pt.2 生成随机数 所写出来的代码:
use std::io;
use rand::Rng;
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
println!("Number Guessing Game");
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
println!("The number you guessed is:{}", guess);
println!("The secret number is: {}", range_number);
}
Step 1:数据类型的转换
由代码可知,guess 是字符串,而 range_number 是整数。这两个变量类型不同,不能直接比较。我们需要把字符串转换成整数——这里会使用 u32(无符号 32 位整数)。range_number 最终被推断成哪一种整数类型,会在下文写出比较代码后更清楚。
#![allow(unused)]
fn main() {
let guess: u32 = guess.trim().parse().expect("Please enter a number");
}
-
let guess: u32:声明一个名为guess、类型为u32(无符号 32 位整数,也就是不能表示负数)的变量。 但这里有一个问题:在之前的代码中(let mut guess = String::new();)已经声明了一个叫做guess的变量,不会报错吗?答案是不会,因为 Rust 允许用同名的新变量遮蔽旧变量。这叫做遮蔽(shadowing)(当一个变量、函数或类型的名称在当前作用域中被重新定义时,会隐藏外部作用域中同名的变量、函数或类型)。它允许代码复用同一个变量名,而无需声明新的变量。这个特性会在 3.1 变量与可变性 仔细介绍。这里可以举一个例子:
fn main() {
let a = 1;
println!("{}", a);
let a = "one";
println!("{}", a);
}
这么做程序不会报错,并且会打印出:
1
one
当程序执行到第一个 let 绑定处时,a 被赋值为 1,所以紧接着的 println! 打印出的是 1;当第二个 let 复用名字 a 时,它会用 "one" 遮蔽旧值,所以下一行打印的就是 one。这就是遮蔽。
=:赋值guess.trim():这里的guess指的是旧的guess,类型为字符串,内容是用户输入。因为read_line()也会把用户的回车记录进去,所以需要使用.trim()。.trim()的作用是去掉字符串前后的空格和换行,类似于 Python 中的.strip()。.parse():它可以把字符串解析为某种数值类型。用户的正常输入会是 1 到 100 间的数,这个数可以放进i32、u32或i64等类型。那么解析后到底是哪种类型呢?你得告诉 Rust 你要哪种类型,所以在声明变量时才要显式指定为u32(类似于 Python 中的静态类型标注,在变量名后面加上:desired_type)。 当然,转换有可能会失败。比如说输入xyz,就没法解析成整数。Rust 足够聪明,让.parse()返回一个Result类型(在 2.1 猜数游戏Pt.1 一次猜测 中讲到过)。这种枚举有两个变体:Ok和Err。如果转换成功,枚举就会返回Ok和转换后的结果;如果失败,就会返回Err和失败原因。.expect():它是Result类型上的一个方法,与.parse()的返回值类型相同。如果解析失败,.parse()就会返回Err,.expect()会立即触发panic!,终止当前程序,并打印expect中的错误信息。反之,.parse()就会返回Ok,.expect()会把附加的值返回出来,也就是把转换好的数字赋给guess。
Step 2:数字的比较
在成功转换数据类型后,我们就可以比较这两个数字了。 先在代码开头导入类型:
#![allow(unused)]
fn main() {
use std::cmp::Ordering;
}
这段代码表示从 std 标准库里引入一个叫做 Ordering 的类型。Ordering 是一个枚举,它有三个变体(可以把它理解为三个可能的值):Ordering::Less、Ordering::Greater 和 Ordering::Equal,分别表示小于、大于和等于。
再在 main 里写下对比代码:
#![allow(unused)]
fn main() {
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win"),
}
}
-
guess.cmp(&range_number):guess上有一个方法叫.cmp()(cmp是 compare 的缩写)。它会比较点号前面的值和括号里的值。这里点号前面的值是guess,括号里的值是对range_number的引用(&是取地址符,代表引用)。.cmp()的返回值类型是Ordering,也就是上文导入的类型。这里还涉及到了 Rust 的类型推断。这里有两张 IDE 截图,一张是还没写这段
match表达式时,一张是写了之后。注意看let range_number = rand::thread_rng().gen_range(1..101);这一行(第 5 行):
可以看到,没有写 match表达式时,IDE 提示range_number的类型是i32;写了match之后,IDE 提示range_number的类型是u32。这是为什么呢?因为guess.cmp(&range_number)进行了比较,虽然range_number没有被显式标注类型,但guess已经被显式定义为u32。得益于 Rust 强大的基于上下文的类型推断,guess.cmp(&range_number)的需求会让range_number被推断为u32。而没有match时,因为 Rust 默认的整数类型是i32,且没有任何其他约束要求range_number是别的类型,所以编译器会推断为i32。 -
match:Rust 的模式匹配表达式。它让我们可以根据.cmp()返回的Ordering枚举值来决定下一步操作。一个match表达式由多个分支(也叫臂,英文是 arm)组成。每个分支都包含一个匹配模式(用来匹配输入值的条件)和一个要执行的代码块(模式匹配成功时运行的代码)。如果match后面的值(在这个程序中就是guess.cmp(&range_number))匹配上了某个分支,程序就会执行这个分支下的代码。在这个程序中,
Ordering::Less、Ordering::Greater和Ordering::Equal就是匹配模式,println!("Too small")、println!("Too big")和println!("You win")就是对应的代码块。举个例子,如果guess等于range_number,.cmp()就会返回Ordering::Equal,match找到与之匹配的第三个分支,然后执行这个分支的代码块,也就是println!("You win")。match会从上到下检查分支。在这个程序里,就是先检查Ordering::Less,再检查Ordering::Greater,最后检查Ordering::Equal。我们会在 6.3 控制流运算符-match 中更详细地讲解
match。
2.3.3 代码效果
以下是截至目前的完整代码:
use std::io;
use rand::Rng;
use std::cmp::Ordering;
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
println!("Number Guessing Game");
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
let guess: u32 = guess.trim().parse().expect("Please enter a number");
println!("The number you guessed is:{}", guess);
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win"),
}
println!("The secret number is: {}", range_number);
}
效果如下(每次运行的神秘数字都会不同;这里猜测 10 偏小):
Number Guessing Game
Guess a number
10
The number you guessed is:10
Too small
The secret number is: 48
2.4 猜数游戏Pt.4 循环询问
2.4.0 本篇知识点
这是猜数游戏的最后一部分。在本篇中,你将学到:
loop循环breakcontinuematch的灵活使用- 如何处理枚举
2.4.1 游戏目标
- 生成一个 1 到 100 间的随机数
- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了
- 循环询问。如果猜测正确,那么打印一个庆祝信息,程序退出(本篇会涉及)
2.4.2 代码实现
Step 1:实现循环
在之前的代码中,我们实现了一次输入和比较。接下来,我们需要让程序反复询问和比较,直到用户猜到正确的数字。
以下是截止到 2.3 猜数游戏Pt.3 输入数与随机数的对比 的代码:
use std::io;
use rand::Rng;
use std::cmp::Ordering;
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
println!("Number Guessing Game");
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
let guess: u32 = guess.trim().parse().expect("Please enter a number");
println!("The number you guessed is:{}", guess);
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win"),
}
println!("The secret number is: {}", range_number);
}
而我们需要重复执行的代码,就是从提示用户输入,到比较猜测并打印结果的部分:
#![allow(unused)]
fn main() {
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
let guess: u32 = guess.trim().parse().expect("Please enter a number");
println!("The number you guessed is:{}", guess);
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win"),
}
}
Rust 提供了用于无限循环的关键字 loop,其结构如下:
#![allow(unused)]
fn main() {
loop {
// Write code here that wants to loop indefinitely
}
}
只需要把需要重复执行的代码放入这个结构中即可:
#![allow(unused)]
fn main() {
loop {
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
let guess: u32 = guess.trim().parse().expect("Please enter a number");
println!("The number you guessed is:{}", guess);
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win"),
}
}
}
Step 2:退出程序的条件
但是需要注意的是,虽然这样实现了循环询问,程序却会一直询问下去、永远不会退出。按逻辑,用户猜对并打印祝贺信息后,程序就应该停止询问。这里就需要用于跳出循环的关键字 break,把它放在 Ordering::Equal 这个分支(分支的概念在 2.3 猜数游戏Pt.3 输入数与随机数的对比 中已有解释,这里不再重复)后面即可。还要记住:如果一个分支需要执行多行代码,就要用 {} 把代码块包起来。
#![allow(unused)]
fn main() {
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => {
println!("You win");
break;
}
}
}
Step 3:错误输入的处理
这段代码还有另一个问题:如果用户的输入不是整数,.parse() 会返回 Err,.expect() 会立即终止程序。正确的行为是打印错误信息,然后让用户再试一次。
这该怎么办呢?在 2.1 猜数游戏Pt.1 一次猜测 中我们见过,read_line() 返回的是一个 Result 枚举。在 2.3 猜数游戏Pt.3 输入数与随机数的对比 里,.parse() 同样返回 Result:如果转换成功,返回值是 Ok 加上转换后的内容;如果失败,返回值是 Err 加上失败原因。那我们之前在哪里用 match 处理过枚举呢?没错,2.3 猜数游戏Pt.3 输入数与随机数的对比 介绍了 Ordering 枚举。在那里,我们用 match 处理了大于、小于和等于的情况。这里同样可以用 match 处理 .parse() 的返回值,并对不同情况执行不同操作:如果转换成功,继续执行;如果失败,打印错误信息,跳过循环体剩余代码并开始下一次迭代。Rust 中跳过当前循环迭代的关键字和其他语言一样,都是 continue。
具体该怎么改代码呢?就是把 let guess: u32 = guess.trim().parse().expect("Please enter a number"); 替换为:
#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Please enter a number");
continue;
}
};
}
Ok(num) => num:这个分支处理转换成功的情况。返回值是Ok加上转换后的值。Ok是这个枚举的一个变体,Ok后面括号里的值就是转换后的内容(u32)。这里写成num表示把转换后的内容绑定到num,然后num会作为match表达式的结果,最终赋给guess。Err(_) => { ... continue; }:这个分支处理转换失败的情况。Err是枚举变体,Err后面括号里的值是错误值(解析整数时是ParseIntError)。_表示我们不关心错误细节,只需要知道它是Err。这里先打印一条简短提示,再continue进入下一轮循环。
用 match 代替 .expect() 来处理错误,是 Rust 中的常见做法。
2.4.3 代码效果
这是完整的代码:
use std::io;
use rand::Rng;
use std::cmp::Ordering;
fn main() {
let range_number = rand::thread_rng().gen_range(1..101);
println!("Number Guessing Game");
loop {
println!("Guess a number");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Could not read the line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Please enter a number");
continue;
},
};
println!("The number you guessed is:{}", guess);
match guess.cmp(&range_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => {
println!("You win");
break;
},
}
}
println!("The secret number is: {}", range_number);
}
效果(一次本地运行;你的神秘数字和猜中路径会不同):
Number Guessing Game
Guess a number
10
The number you guessed is:10
Too small
Guess a number
50
The number you guessed is:50
Too small
Guess a number
100
The number you guessed is:100
You win
The secret number is: 100
3.1 变量与可变性
3.1.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性(本文)
- 数据类型:标量类型
- 数据类型:复合类型
- 函数和注释
- 控制流:
if else - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.1.1. 可变/不可变变量的声明
-
声明变量使用
let关键字。 -
默认情况下,变量是不可变的。以下是错误例:
fn main(){
let machine = 6657;
machine = 0721;
println!("machine is {}", machine);
}
Output:
error[E0384]: cannot assign twice to immutable variable `machine`
--> src/main.rs:3:2
|
2 | let machine = 6657;
| ------- first assignment to `machine`
3 | machine = 0721;
| ^^^^^^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut machine = 6657;
| +++
- 在
let后加上mut才能声明可变变量。以下是成功例,输出内容在注释里:
fn main(){
let mut machine = 6657;
machine = 721;
println!("machine is {}", machine); // Output: machine is 721
}
3.1.2. 变量与常量
有很多人在刚开始学Rust的时候都会搞不清不可变变量与常量的区别在哪里。常量在绑定值后也是不可变的,但它与不可变变量有几处重要区别:
- 常量不能使用
mut,一旦声明就不可变。 - 声明常量需要使用
const关键字,并且必须显式标注类型;不可变变量不必显式标注。 - 常量可以在任何作用域内声明,包括全局作用域。
- 常量只能绑定到常量表达式,无法绑定到函数的调用结果或只能在运行时才能计算出的值。
- 在程序运行期间,常量在其声明的作用域中一直有效。
- 命名规范:Rust里常量使用全大写字母,每个单词之间用下划线分开,例如:
MAX_POINTS。
常量声明的例子:
const WJQ: i32 = 66570721;
fn main(){
const WJQ_MACHINE: u32 = 6_657;
let mut machine = 6657;
machine = 721;
println!("machine is {}", machine); // Output: machine is 721
println!("WJQ is {}", WJQ); // Output: WJQ is 66570721
println!("WJQ_MACHINE is {}", WJQ_MACHINE); // Output: WJQ_MACHINE is 6657
}
其中的i32、u32是其类型。Rust支持插入下划线增强可读性,这个例子中的6_657写成6657也是可以的。
这个常量既可以在全局声明,也可以声明在main函数里,也可以在其他作用域中。
3.1.3. 遮蔽(Shadowing)
在 2.3 猜数游戏Pt.3 输入数与随机数的对比 中声明变量时就提过一嘴,Rust允许使用同名新变量来遮蔽原来的变量,这叫做遮蔽(当一个名称在当前作用域中被重新定义时,会隐藏外部作用域中同名的变量、函数或类型)。每次遮蔽时,原变量的值和类型都会被新变量替代。它允许代码复用同一个变量名,而无需再声明一个全新的变量。
这里可以举一个例子:
fn main(){
let a = 1;
println!("{}", a);
let a = "one";
println!("{}", a);
}
这么做程序不会报错,并且打印出了:
1
one
当程序执行 let a = 1; 时,a被绑定为1,所以打印出的是1;之后执行 let a = "one"; 时,程序注意到a被复用了,就会抛弃原来的值1,把a绑定为"one",所以下一行打印的就是one。这就是遮蔽。
要注意的是,使用遮蔽和把变量声明为可变变量是有不同之处的:
- 在遮蔽中,使用
let声明的新变量依然是不可变变量。 - 在遮蔽中,使用
let声明的同名新变量的类型可以与之前不同。
fn main(){
let machine = "wjq";
let machine = 6657;
println!("{}", machine);
}
上边这个程序使用了遮蔽,不会报错。第二次 let machine = 6657; 是新声明的变量,与之前的 machine 并没有关系。
fn main(){
let mut machine = "wjq";
machine = 6657;
println!("{}", machine);
}
Output:
error[E0308]: mismatched types
--> src/main.rs:3:15
|
2 | let mut machine = "wjq";
| ----- expected due to this value
3 | machine = 6657;
| ^^^^ expected `&str`, found integer
上边这个程序使用了可变变量。Rust 是强类型语言,变量的类型在首次声明时确定。 赋值 machine = 6657 试图将一个整数赋值给一个字符串类型变量,类型不匹配,编译器会报 expected &str, found integer。
3.2 数据类型:标量类型
3.2.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性
- 数据类型:标量类型(本文)
- 数据类型:复合类型
- 函数和注释
- 控制流:
if else - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.2.1. Rust中的变量特性
Rust是静态编译语言,因此编译器在编译时必须知道每个变量的类型。
- 基于使用的值,编译器通常能够推断出它的具体类型。
- 如果可能的类型比较多,就必须添加类型标注,否则编译会失败。以下是一个例子:
#![allow(unused)]
fn main() {
let guess = "6657".parse().expect("Please enter a number");
}
如果你把这句话放到IDE中,你就会发现类似error[E0284]: type annotations needed的错误。这是因为字符串6657可以被解析成i32或u32等类型,编译器不知道你想要哪一种,所以需要显式标注类型。将代码改成如下即可编译通过:
#![allow(unused)]
fn main() {
let guess: u32 = "6657".parse().expect("Please enter a number");
}
3.2.2. 标量类型的简介
- 一个标量类型代表一个单一的值。
- Rust主要有4个标量类型:
- 整数类型
- 浮点类型
- 布尔类型
- 字符类型
3.2.3. 整数类型
- 无符号整数类型(不能表示负数)以
u开头,u是unsigned的简写。 - 有符号整数类型(可以表示负数)以
i开头,i是integer的简写。 - 字母后的数字表示该类型占据多少位。例如,
u32中的32表示它使用32位,能表示从0到2^32 - 1的值。 - Rust的整数类型列表如下:
- 每种都分
i和u,以及固定的位数。 - 有符号范围:
-(2^(n-1))到2^(n-1) - 1 - 无符号范围:
0到2^n - 1
- 每种都分
| Length | Signed | Unsigned |
|---|---|---|
| 8-bit | i8 | u8 |
| 16-bit | i16 | u16 |
| 32-bit | i32 | u32 |
| 64-bit | i64 | u64 |
| 128-bit | i128 | u128 |
| arch | isize | usize |
isize和usize类型是比较特殊的两个整数类型,其大小由程序运行的计算机架构所决定:
- 在64位机器上,它们是64位。
isize相当于i64,usize相当于u64。 - 在32位机器上,它们是32位。
isize相当于i32,usize相当于u32。
使用isize和usize的主要场景是对某种集合进行索引操作。
fn main(){
let machine: u32 = 6657;
}
3.2.4. 整数字面值
整数不一定是十进制的,也支持其他进制。使用固定格式能让程序理解你使用的进制,也使别人更容易读懂你的代码。
| Number literals | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
| Byte (u8 only) | b’A’ |
- 十进制中可以加上下划线来增强可读性。
- 十六进制以
0x开头。 - 八进制以
0o开头。 - 二进制以
0b开头,也可以加上下划线来增强可读性。 - 字节字面值比较特殊。在Rust中,字节整数字面值写作
b'X',其中X是表示一个字节值的单个字符。这种字面值只能用于u8,因为字节值的范围是0到255,且X必须是ASCII字符。例如,b'A'的值是65,因为A的ASCII码是65。 - 除了字节字面值,所有数值字面值都可以使用类型后缀。
- 如果不太清楚该使用哪种类型,可以依赖Rust相应的默认类型。
- 整数的默认类型是
i32,总体上来说速度很快,即使是在64位系统中。
3.2.5. 整数溢出
举个例子,u8的范围是0到255。如果把一个u8变量的值设为256,会出现两种情况:
- 在调试构建中,Rust会检查溢出。如果发生溢出,程序会在运行时panic。
- 在发布构建(
--release)中,Rust不会检查可能导致panic的溢出。- 如果确实发生溢出,Rust会执行环绕运算:256变成0,257变成1,以此类推,但不会panic。
3.2.6. 浮点类型
Rust有两种基础的浮点类型:
f32:32位单精度f64:64位双精度
Rust使用IEEE-754标准来表示浮点类型。
f64是默认类型,因为在现代CPU中,f64的运行速度与f32差不多,而且f64精度更高。
fn main(){
let machine: f32 = 6657.0721;
}
3.2.7. 数值操作
- 加:
+ - 减:
- - 乘:
* - 除:
/ - 余:
%这些与其他语言无异。
3.2.8. 布尔类型
Rust的布尔类型与其他语言无异。它有两个值:true和false,占一个字节,关键字是bool。
fn main(){
let machine: bool = true;
}
3.2.9. 字符类型
- Rust的
char类型用来表示语言中最基础的单个字符。 - 字符字面值使用单引号。
- 占用4个字节。
- 它是Unicode标量值,因此可以表示远超ASCII的内容,包括拼音、中日韩文、零宽字符、emoji等。其范围是从
U+0000到U+D7FF以及从U+E000到U+10FFFF。 - Unicode实际上并没有我们通常理解的那种“字符”概念,所以直觉上认识的字符也许与Rust中的概念并不完全对应。
fn main(){
let x: char = '🥵';
}
3.3 数据类型:复合类型
3.3.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性
- 数据类型:标量类型
- 数据类型:复合类型(本文)
- 函数和注释
- 控制流:
if else - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.3.1. 复合类型的简介
- 复合类型可以将多个值组合成一个类型。
- Rust提供了两种基础的复合类型:元组和数组。
3.3.1. 元组(Tuple)
元组的特点:
- 元组可以将多个不同类型的值组合成一个类型。
- 元组的长度是固定的:一旦声明就无法改变。
创建元组:
- 在小括号里,将值用逗号分开。
- 元组中的每个位置都对应一个类型,元组中各元素的类型不必相同。
fn main(){
let tup: (u32, f32, i64) = (6657, 0.0721, 114514);
println!("{},{},{}", tup.0, tup.1, tup.2);
// Output: 6657,0.0721,114514
}
获取元组元素值:
- 可以使用模式匹配来解构(destructure)一个元组,从而获取元素值。
fn main(){
let tup: (u32, f32, i64) = (6657, 0.0721, 114514);
let (x, y, z) = tup;
println!("{},{},{}", x, y, z);
// Output: 6657,0.0721,114514
}
访问元组的元素:
- 在元组变量后使用点标记法,后接元素的索引号。
#![allow(unused)]
fn main() {
println!("{},{},{}", tup.0, tup.1, tup.2);
}
3.3.2. 数组
数组的特点:
- 数组中的每个元素的类型必须相同。
- 数组也可以将多个值放入一个类型。
- 数组的长度是固定的。
声明数组:
- 在中括号里,各值用逗号分开。
#![allow(unused)]
fn main() {
let a = [1, 1, 4, 5, 1, 4];
}
数组的用处:
- 如果想把数据放在栈(Stack)上而不是堆(Heap)上,或者想保证有固定数量的元素,这时使用数组更有好处。
- 数组没有向量(vector,以后会讲)灵活。
- 向量由标准库提供,而数组内置于语言中,并通过prelude模块提供(prelude也是标准库的一部分)。
- 向量的长度可以改变。
- 不确定应该使用数组还是向量时,大概率应该使用向量。
数组的类型语法:
- 数组的类型以
[类型; 长度]的形式表示。
#![allow(unused)]
fn main() {
let machine: [u32; 4] = [6, 6, 5, 7];
}
声明数组的其他方法:
- 如果数组的每个元素值都相同,那么可以:
- 在中括号里指定初始值
- 然后跟着一个
; - 最后加上数组的长度
#![allow(unused)]
fn main() {
let a = [3; 3];
let b = [3, 3, 3];
}
这个例子中,a和b是等价的。
访问数组的元素:
- 数组是栈上分配的一块连续内存。
- 可以使用索引来访问数组的元素。
#![allow(unused)]
fn main() {
let machine = [6, 6, 5, 7];
let wjq = machine[0];
}
- 如果索引越界:
- 在编译器能够证明错误的情况下,Rust可能在编译时发现它
- 否则会在运行时panic,因为Rust不允许程序继续读取该地址的内存
数组背后是一块连续内存。假设数组的第一个元素在内存位置x,那么第二个元素位于x + 第一个元素的大小,之后以此类推。
如果索引大于数组的实际长度,程序就会读取数组之外的内存,而那里的内容可能是任意值。在C中完全没有边界检查。在C++中普通数组也没有,只有std::array有;在Rust里强制进行边界检查。
| Feature | C | C++ | Rust |
|---|---|---|---|
| Memory model | Contiguous | Contiguous | Contiguous |
| Safety | No bounds checking | std::array has bounds checking; ordinary arrays do not | Bounds checking is enforced |
| Dynamic arrays | Manual memory management required | std::vector | Vec |
| Multidimensional arrays | Yes | Yes | Yes |
| Special abilities | Simple and efficient | Rich STL containers | Ownership and borrow checking |
如果编译器能够证明索引越界,就会在编译期拒绝该程序:
#![allow(unused)]
fn main() {
let a = 5;
let machine = [6, 6, 5, 7];
let wjq = machine[a]; // Error: this operation will panic at runtime
}
如果索引要到运行时才能确定,程序可以编译通过,但越界访问会在运行时panic:
fn get_index() -> usize {
5 // 设想这个值来自输入或其他地方
}
fn main() {
let machine = [6, 6, 5, 7];
let wjq = machine[get_index()];
}
Output:
thread 'main' panicked at src/main.rs:7:15:
index out of bounds: the len is 4 but the index is 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
3.4 函数和注释
3.4.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性
- 数据类型:标量类型
- 数据类型:复合类型
- 函数和注释(本文)
- 控制流:
if else - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.4.1. 函数的基本认识
- 声明函数使用关键字
fn。 - 依照惯例,函数名和变量名使用蛇形命名规范:
- 所有字母都是小写,单词之间使用下划线分开
- 例子:
another_function
- Rust语言不在乎自定义函数写在被调用前还是被调用后,只要函数已经被声明并且能够被调用就可以。这比某些古老的语言要好得多(C/C++:有被冒犯到)。下面是一个例子:即使自定义函数写在调用之后,依然可以正常运行。
fn main(){
println!("Hello World");
another_function();
}
fn another_function(){
println!("Another Function");
}
3.4.2. 函数的参数
函数的参数实际上有两个名词:parameter(形参)和argument(实参)。
- 形参指的是在定义函数或方法时声明的占位符,用来接收调用时传入的值。其作用是为函数提供一个通用的方式来处理外部数据,而不依赖具体的值。
- 实参指的是传进这个函数的实际值。其作用是为函数逻辑在执行时提供具体的值。
fn main() {
greet("Alice");
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}
在这个例子中:
main函数中传给greet的"Alice"就是实参,它是调用greet时传给参数name的实际值。greet函数中的name是一个形参,表示greet需要一个&str类型的值作为输入。
在函数签名里,必须声明每个参数的类型,这样编译器就无需再对它进行推断。在上个例子中,name: &str里的&str就是name的类型。
函数可以有多个参数,每个参数都用逗号隔开。
3.4.3. 函数体中的语句和表达式
- 函数体由一系列语句组成,可选地以一个表达式结束。
- Rust是一个基于表达式的语言,下面所讲的语法大部分都跟Scala很像,因为两者都是以表达式为核心的编程模型。
- 语句是执行一些动作的指令。
- 表达式会计算产生一个值;表达式本身就是一个值。
- 函数的定义也是语句。
- 语句不返回值,所以不可以使用
let把一个语句赋给一个变量。
fn main(){
let x = (let y = 6);
}
Output:
error: expected expression, found `let` statement
--> src/main.rs:2:11
|
2 | let x = (let y = 6);
| ^^^
|
= note: only supported directly in conditions of `if` and `while` expressions
在这个例子中,Rust编译器期待右边是一个表达式,但它发现的是一个 let 语句,所以会报错。有些语言允许类似写法,但Rust不行。
fn main(){
let y = {
let x = 1;
x + 3
};
println!("The value of y is: {}", y);
}
在这个例子中,let y =后面大括号内的代码是一个表达式。这个代码块首先定义变量x并赋值为1,然后通过x + 3计算出一个值。这里,x + 3是一个表达式,而且因为它是代码块中最后一个表达式,所以它的值(1 + 3的结果,即4)就成为了整个代码块的返回值。最终,这个返回值被赋给y。程序运行时会打印The value of y is: 4。
如果在x + 3后加上分号;,那么x + 3就不再是表达式,而是语句。因为语句不会返回值,整个代码块的返回值就变成了(),也就是单元类型。在Rust中,()是一个特殊类型,它的唯一值就是()本身。因此,如果在x + 3后加上分号,y的类型就会是(),这意味着y不再存储计算结果,而是存储一个单元值。需要注意的是,()是一种有效类型,但不能通过println!直接打印。如果尝试打印y,编译器会报错,提示无法格式化()类型的值。
3.4.4. 函数的返回值
- 在
->符号后边声明返回类型,但是不可以为返回值命名。 - 在Rust里,返回值就是函数体里面最后一个表达式的值。
- 若想提前返回,需使用
return关键字,并指定一个值。
fn machine() -> u32 {
6657
}
fn main(){
let wjq = machine();
println!("The value of wjq is: {}", wjq);
}
在这个例子中,machine函数的返回类型被声明为u32。函数体中只有一个表达式,就是6657。因为它是表达式,所以后面没有分号。又因为它是函数体中的最后一个表达式(其实也就这一个表达式),所以它就是函数的返回值。
3.4.5. 注释
- 单行注释以
//开头。 - 多行注释使用
/* */结构。 例子:
fn machine() -> u32 {
6657
}
/*Let's go G2
Let's go Spirit
Let's go NAVI
*/
fn main(){
let wjq = machine(); // 6657, go, go!
println!("The value of wjq is: {}", wjq);
}
Rust还有一种很重要的文档注释,以后单独讲。
3.5 控制流:if else
3.5.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.5.1. if表达式的基本认识
if表达式允许根据条件执行不同的代码分支。- 这个条件必须是布尔类型。这点不同于Ruby、JS和C++,它们会把
if后的非布尔值转换为布尔值。 - 条件可以是一个字面值、一个表达式或是一个变量。
- 这个条件必须是布尔类型。这点不同于Ruby、JS和C++,它们会把
- 在
if表达式中,与条件相关联的代码就叫做分支(在讲match时就有提到过这个概念)。 - 可选地,在后面可以加上一个
else表达式。
fn main(){
let machine = 6657;
if machine < 114514 {
println!("condition is true");
} else {
println!("condition is false");
}
}
在这个例子中,machine的值小于114514,所以程序会执行println!("condition is true");这一行。如果修改machine的值使其不再小于114514,那么程序就会执行else后的代码块。
3.5.2. 用else if处理多重条件
如果需要进行多重条件判断又不想在else下不停地写嵌套,那么使用else if就是很好的选项。
fn main(){
let number = 6;
if number % 4 == 0 {
println!("Number is divisible by 4");
} else if number % 3 == 0 {
println!("Number is divisible by 3");
} else if number % 2 == 0 {
println!("Number is divisible by 2");
} else {
println!("Number is not divisible by 4, 3, or 2");
}
}
由于6既能被3整除也能被2整除,所以else if number % 3 == 0和else if number % 2 == 0都是true。因为if、else if和else是按从上到下的顺序判断的,所以谁先出现就执行谁。在这个例子中,else if number % 3 == 0在前面,所以程序就会执行println!("Number is divisible by 3");,而else if number % 2 == 0下的代码块就不会被执行。
如果程序中使用了多于一个else if,通常最好使用match来重构代码。
比如上面那段代码就可以重构为(非唯一解):
fn main() {
let number = 6;
match number {
n if n % 4 == 0 => println!("Number is divisible by 4"),
n if n % 3 == 0 => println!("Number is divisible by 3"),
n if n % 2 == 0 => println!("Number is divisible by 2"),
_ => println!("Number is not divisible by 4, 3, or 2"),
}
}
显而易见,使用match的代码更加直观。
3.5.3. 在let语句中使用if
if在Rust中是一个表达式,所以可以将它放在let语句中等号的右边。
fn main(){
let condition = true;
let number = if condition { 5 } else { 6 };
println!("The value of number is: {}", number);
}
在这个例子中,因为condition是true,所以会把5赋给number,最后的输出结果就是The value of number is: 5。如果condition是false,那么就会把else后的值6赋给number。
这种写法与Python非常相像,但是两者有本质上的区别:
-
Rust:
- 在Rust中,
if-else是表达式,可以直接返回值。换句话说,if结构本身可以参与到其他表达式的计算中。 - 在Rust中,几乎任何代码块都可以是表达式,因此
{}块也可以返回一个值。
- 在Rust中,
-
Python:
- 在Python中,
if-else是一种特定的类三元形式,专门为单行条件表达式设计。 - Python的普通
if-else语句是控制流的一部分,它不返回值,也不能嵌入到其他表达式中。
- 在Python中,
fn main(){
let condition = true;
let number = if condition { 5 } else { "6" };
println!("The value of number is: {}", number);
}
Output:
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:3:41
|
3 | let number = if condition { 5 } else { "6" };
| - ^^^ expected integer, found `&str`
| |
| expected because of this
意思是if和else返回了不兼容的类型。因为Rust是静态类型、强类型语言,在编译时就必须知道变量的类型,以便这个变量在其他地方使用。在这个例子中,if分支的返回值是i32,而else分支的返回值是字符串类型。编译器无法在编译时确定number的类型到底是i32还是字符串,所以会报错。
一句话总结:if-else 表达式的分支必须返回相同类型的值。
3.6 控制流:循环
3.6.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性
- 数据类型:标量类型
- 数据类型:复合类型
- 函数和注释
- 控制流:
if else - 控制流:循环(本文)
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.6.1. Rust的循环
Rust一共提供了三种循环:
loopwhilefor
3.6.2. loop循环
loop关键字告诉Rust反复执行一段代码,直到被告知停止。以下是一个例子,它会不断打印6657 up up!。
fn main(){
loop {
println!("6657 up up!");
}
}
可以在loop循环中使用break关键字来告诉程序何时停止。
fn main(){
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2;
}
};
println!("The result is: {}", result);
}
代码逻辑:
counter初始化为0,每次循环递增1。- 当
counter等于10时,break退出循环,并返回counter * 2(即20)。 loop是一个表达式,其返回值是传给break的值,因此可以直接赋给result。result最终打印为20。
代码特点:
- Rust的
loop是表达式,因此其结果可以直接绑定到变量。 break可以携带返回值(这里是counter * 2),并将其作为loop的结果。let语句要求赋值表达式后加分号,因此loop的结束大括号}后需要加分号。
3.6.3. while条件循环
while循环在每次执行循环体之前都要判断一次条件。
fn main() {
let mut countdown = 10; // 倒计时从10开始
println!("Rocket Launch Countdown:");
while countdown > 0 {
println!("T-minus {}...", countdown);
countdown -= 1; // 每次减少1
}
println!("🚀 Liftoff!");
println!("Houston, we have a problem.");
}
这是一个简单的while循环示例,其运行结果是:
Rocket Launch Countdown:
T-minus 10...
T-minus 9...
T-minus 8...
T-minus 7...
T-minus 6...
T-minus 5...
T-minus 4...
T-minus 3...
T-minus 2...
T-minus 1...
🚀 Liftoff!
Houston, we have a problem.
3.6.4. 使用for循环遍历集合
当然也可以使用while和loop来遍历集合,但那样既容易出错又低效。
这是一个使用while的例子:
fn main() {
let numbers = [10, 20, 30, 40, 50];
let mut index = 0;
println!("Using while loop:");
while index < 5 {
println!("Number at index {}: {}", index, numbers[index]);
index += 1;
}
}
使用while时极有可能因索引越界而触发panic,而且运行速度较慢,因为每次都要检查index < 5这个条件。
这是一个使用for的例子(实现同样的效果):
fn main() {
let numbers = [10, 20, 30, 40, 50];
println!("Using for loop:");
for (index, number) in numbers.iter().enumerate() {
println!("Number at index {}: {}", index, number);
}
}
1. numbers.iter()
- 调用集合
numbers的.iter()方法,创建一个不可变迭代器,用于逐个访问元素。在Rust中,for循环并不直接操作集合,而是操作实现了Iterator特征的迭代器。.iter()是Vec及其他集合常用的方法,用于生成指向元素引用的迭代器。for循环简洁清晰,可以对集合中的每个元素执行代码。由于其安全性和简洁性,它在Rust中使用最多。
2. .enumerate()
• 为迭代器的每个元素附加一个索引。索引从0开始,是一个usize值。.enumerate()将迭代器的每个元素包装成(index, value)的形式,其中index是元素在集合中的位置,value是当前迭代器指向的元素。.enumerate()返回一个新的迭代器,其元素类型是(usize, &T),其中T是集合中元素的类型。这里,numbers是一个i32数组,因此&T是&i32。
3. for (index, number) in ...
• for循环支持解构元组。(index, number)表示我们直接将enumerate()产生的(usize, &T)元组解构成两个变量:index,当前元素的索引;以及number,当前元素的引用(不可变)。
假设numbers是[10, 20, 30, 40, 50],执行过程如下:
- 调用
numbers.iter()创建迭代器。 - 调用
.enumerate(),生成(索引, 元素引用)的迭代器。 for循环解构出索引和元素:- 第一次循环:
index = 0, number = &10 - 第二次循环:
index = 1, number = &20 - 第三次循环:
index = 2, number = &30 - …
- 第一次循环:
- 打印
index和number,输出每个元素的索引和值。
由于for循环安全且简洁,所以它在Rust中使用最多。
3.6.5. Range
范围(Range)由标准库提供。你可以通过范围生成两个边界之间的数字:a..b不含结束,而a..=b包含结束。使用rev方法可以反转一个范围。
fn main() {
println!("Rocket Launch Countdown:");
for countdown in (1..=10).rev() {
println!("T-minus {}...", countdown);
}
println!("🚀 Liftoff!");
println!("Houston, we have a problem.");
}
这个例子使用for循环、Range和rev,实现了上文while示例中的火箭倒计时。
代码解析
(1..=10):- 这是一个
Range,表示从1到10(包含两端)。 ..=是包含上限的范围操作符。
- 这是一个
.rev():- 反转迭代器,生成一个从10到1的递减序列。
4.1 所有权:栈内存 vs. 堆内存
4.1.0 写在正文之前
在学习了 Rust 的通用编程概念后,就来到了整个 Rust 的重中之重——所有权。它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有五小节:
- 所有权:栈内存 vs. 堆内存(本文)
- 所有权规则、内存与分配
- 所有权与函数
- 引用与借用
- 切片(Slice)
4.1.1 什么是所有权?
所有权是 Rust 最独特的特性。它让 Rust 无需 GC(垃圾收集器)就可以保证内存安全。
所有程序在运行时都必须管理它们使用计算机内存的方式。有的语言依靠垃圾收集机制:在程序运行时,它们会不断寻找不再使用的内存(比如 C#)。在其他语言中,程序员必须显式地分配和释放内存(比如 C/C++)。
Rust 不同于前两种。Rust 使用所有权系统来管理内存。这个系统里还有一套规则,而编译器在编译时就会检查这套规则。这种做法不会产生任何运行时开销。也就是说,在程序运行时,这种所有权特性不会减慢程序运行的速度,因为 Rust 把内存管理相关工作都提前到了编译时。
4.1.2 栈内存(Stack)vs. 堆内存(Heap)
一般来说,程序员不会经常考虑栈内存与堆内存之间的区别。对于 Rust 这样的系统级编程语言来说,一个值是在栈内存上还是在堆内存上,对语言的行为和你要做的某些决定有更大影响。
在代码运行时,栈内存和堆内存都是可用的内存,但它们的结构很不相同。
4.1.3 存储数据
1. 栈内存
栈内存按值的接收顺序来存储,按相反的顺序来将它们移除(后进先出,Last In First Out,简写为 LIFO)。
添加数据叫压入栈(push),移除数据叫弹出栈(pop)。
所有存储在栈内存上的数据必须拥有已知的、固定的大小。 相反,编译时大小未知的数据,或运行时大小可能发生变化的数据,必须存放在堆内存上。
2. 堆内存
堆内存的组织性差一些。当把数据放入堆内存时,会请求一定的空间。操作系统会在堆内存中找到一块足够大的空间,把它标记为在用,并返回一个指针,也就是这个空间的地址。这个过程叫做在堆上进行内存分配,有时简称为“分配”。
3. 指针与内存
因为指针是固定大小的,可以把指针本身放在栈内存上。但如果想要指针所指向的具体数据,就必须使用指针中的地址来访问它。
把数据压到栈内存上比在堆内存上分配要快得多:
- 在栈内存上,操作系统不需要寻找用来存储新数据的空间;那个位置永远都在栈内存的顶端(栈内存的末尾)——也就是当前可用的栈内存的起始位置。
- 在堆内存上分配空间则需要做更多的工作:操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录,方便下一次分配。
4.1.4 访问数据
访问栈内存中的数据要比访问堆内存中的数据快,因为需要通过指针才能找到堆内存中的数据——多了一层间接访问。对于现代处理器来说,由于缓存的缘故,内存访问需要跳转的次数越少,速度往往就越快。
如果数据存放得比较近,处理器的处理速度就会更快一些——例如放在栈内存上。反之,如果数据之间距离较远,处理速度就会慢一些——例如放在堆内存上(在堆内存上分配大量空间也需要时间)。
4.1.5 函数调用
当代码调用函数时,值被传入函数(也包括指向堆内存数据的指针)。函数的局部变量被压到栈内存上。当函数结束后,这些值会从栈内存上弹出。
4.1.6 所有权存在的原因
所有权解决的问题:
- 跟踪代码分配的堆内存——换句话说,就是跟踪代码的哪些部分正在使用堆内存上的哪些数据
- 最小化堆内存上的重复数据
- 清理堆内存上未使用的数据,以避免空间不足
一旦懂了所有权,就不用经常去想栈内存和堆内存了。但是知道管理堆数据是所有权存在的原因,有助于解释它为什么会这样工作。
4.2 所有权规则、内存与分配
4.2.0 写在正文之前
在学习了 Rust 的通用编程概念后,就来到了整个 Rust 的重中之重——所有权。它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有五小节:
- 所有权:栈内存 vs. 堆内存
- 所有权规则、内存与分配(本文)
- 所有权与函数
- 引用与借用
- 切片(Slice)
4.2.1 所有权规则
所有权有三条规则:
- 每个值都有一个变量,这个变量是该值的所有者
- 每个值同时只能有一个所有者
- 当所有者离开作用域后,这个值将被删除
4.2.2 变量作用域
作用域是程序中一个项目的有效范围。
fn main(){
// machine 不可用
let machine = 6657; // machine 可用
// 可以对 machine 进行操作
} // machine 的作用域到此结束,machine 不再可用
在示例代码第三行声明了变量 machine,而在第二行还没有声明变量,所以在第二行它是不可用的。在第三行由于进行了声明,所以它可用了。而在第四行就可以对 machine 进行相关操作了。在第五行,machine 的作用域就结束了,从第五行及以后,machine 就不再可用了。
这个例子涉及两个重点:
machine在进入作用域后就变得有效了machine会保持有效,直到离开作用域为止 这两点和其他语言都类似,所以就不多说了。
4.2.3 String 类型
为了演示所有权的一些相关规则,需要一个稍微复杂一点的数据类型,String 就满足需求。
String 类型比那些标量类型更复杂:之前提到的基础数据类型,它们的数据都存放在栈内存上,离开作用域时数据就会弹出栈;而 String 类型是存储在堆内存上的。
这章讲 String 主要是讲与所有权相关的部分。如果想要深入了解 String 本身,就得等到后面的章节。
字符串字面值(&'static str)是代码里直接写出的那些字符串值。但是它不能满足所有的需求。一是因为它们是不可变的;二是因为不是所有的字符串值都能在编写程序时确定(比如用户输入)。
对于这些情况,Rust 提供了第二种字符串类型 String。String 能在堆上分配,它能够存储在编译时未知大小的文本。
4.2.4 创建 String 类型的值
使用 from 函数从字符串字面值创建出 String,例如:
#![allow(unused)]
fn main() {
let machine = String::from("6657");
}
::表示from是String下的函数。可以把它理解为其他语言中的静态方法。
这样声明的 String 是可以修改的,例如:
fn main(){
let mut machine = String::from("6657");
machine.push_str(" up up!");
println!("{}", machine);
}
- 在
let后加上mut,表示变量machine可以修改 .push_str()是这个变量上的一个方法,用来向值的末尾追加一个字符串字面值;示例中就是" up up!"
其输出为:
6657 up up!
为什么 String 是可以修改的,而 &'static str(字符串字面值)不能:
String是一个堆分配的可变字符串类型,可以动态增长或缩小其内容。- 字符串字面值是
&'static str类型,存储在程序的静态内存中(只读区域)。
4.2.5 内存和分配
对于字符串字面值,因为它写在源代码中,所以在编译时就知道它的内容。其文本内容被直接硬编码到最终的可执行文件中。它速度快、高效,得益于它的不可变性。
为了支持可变性,String 需要在堆内存上分配内存,来保存编译时未知大小的文本。这要求在运行时向操作系统请求内存(这一步通过 String::from 完成)。
用完 String 之后,需要某种方式把内存返回给操作系统:
-
在有 GC(垃圾回收器)的语言中,比如 C#,GC 会跟踪并清理不再使用的内存
-
在没有 GC 的语言中,比如 C/C++,就需要程序员去识别内存何时不再使用,并编写代码将它返回
- 如果忘了,就会浪费内存
- 如果提前做了,变量就会变为非法
- 如果做了两次,就会出现非常严重的 Bug——二次释放(double free)。这可能导致某些仍在使用的数据发生损坏,并带来潜在的安全隐患。一次分配必须对应一次释放。
-
Rust 采用了不同的机制:对于某个值来说,当拥有它的变量离开作用域时,Rust 会调用一个特殊的函数——drop 函数,内存会立即交还给操作系统,也就是立即释放。
4.2.6 变量与数据的交互方式
1. 移动(Move)
多个变量可以用一种独特的方式与同一份数据交互。
#![allow(unused)]
fn main() {
let x = 5;
let y = x;
}
在这个例子中,5 被绑定到变量 x 上;下一行相当于创建了 x 的副本,并把这个副本绑定到 y 上。由于整数是已知且固定大小的简单值,这两个 5 被压到了栈内存中。
但如果情况更加复杂,比如说是 String 类型,情况又会有所不同。
#![allow(unused)]
fn main() {
let machine = String::from("Niko");
let wjq = machine;
}
在这个例子中,第一行通过 String 下的 from 函数,从字符串字面值得到一个名为 machine 的 String 值。然后第二行把 machine 绑定到 wjq 上。
虽然代码看起来很相似,但 两者的运行方式完全不同。
首先我们得了解,一个 String 由三个部分组成,如下图所示:

- 一个指向存放字符串内容的内存的指针
- 一个长度
- 一个容量
这部分数据被压到了栈内存中,而存放字符串内容的部分在堆内存上。长度(len)是存放字符串内容所需的字节数,容量(capacity)是 String 从操作系统总共获得的内存总字节数。
当把 machine 的值赋给 wjq 时,是把栈内存上的数据复制给了 wjq,而并没有复制指针所指向的堆内存上的数据。

当变量离开作用域时,Rust 会自动调用 drop 函数,并释放该变量使用的堆内存。这是上文说过的。但当 machine 和 wjq 同时离开作用域时,它们都会尝试释放相同的内存,从而引发非常严重的 bug——二次释放(double free)。其危害上文已经解释过,这里不再赘述。
为了保证内存安全,Rust 会直接让第一个变量 machine 失效,并把值移动到 wjq 上。当 machine 离开作用域时,Rust 不需要释放任何与 machine 相关的内存(当然 wjq 还是要释放的,因为它是有效的),因为 machine 已经失效。
如果在 machine 失效后还尝试使用它,就会报错(代码和结果如下):
代码:
fn main(){
let machine = String::from("Niko");
let wjq = machine;
println!("{}", machine);
}
结果:
error[E0382]: borrow of moved value: `machine`
--> src/main.rs:4:17
|
2 | let machine = String::from("Niko");
| ------- move occurs because `machine` has type `String`, which does not implement the `Copy` trait
3 | let wjq = machine;
| ------- value moved here
4 | println!("{}", machine);
| ^^^^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let wjq = machine.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership-move` (bin "ownership-move") due to 1 previous error
学过其他语言的人可能接触过浅拷贝和深拷贝。有些人会把复制指针、长度和容量视为浅拷贝,但由于 Rust 让 machine 失效了,所以这里使用一个新术语:移动(move)。
这里隐藏了一个设计原则:Rust 不会自动创建数据的深拷贝。也就是说,就运行时性能而言,任何自动赋值操作都是廉价的。
2. 克隆(Clone)
如果真想对堆内存上的 String 数据进行深拷贝,而不仅仅是栈内存上的数据,可以使用 clone 方法。
#![allow(unused)]
fn main() {
let machine = String::from("Niko");
let wjq = machine.clone();
}
通过这种方法,栈内存和堆内存上的数据都会被完整复制一份。

不过克隆比较消耗资源,所以要谨慎使用。
3. 栈上的数据:复制
对于栈上的数据,不需要克隆,复制就可以。
#![allow(unused)]
fn main() {
let x = 5;
let y = x;
println!("{},{}", x, y)
}
在这个例子中,x 和 y 都是有效的,因为 x 是整数类型。整数类型是 Rust 中的基本类型(如 i32、u32 等)。它们的大小在编译时就已经确定,并且它们的值完全存储在栈内存中。由于这些类型实现了 Copy trait(可以把 trait 简单理解为接口),赋值操作实际上是对值的直接拷贝,而不是所有权的转移。
对于实现了 Copy trait 的类型,创建一个新变量(如 y)时会发生位拷贝操作,这种拷贝非常高效。同时,原变量(如 x)仍然保持有效。因此,在这种情况下,调用 clone 与直接赋值没有任何区别,因为两者的拷贝行为本质相同。
如果一个类型实现了 Copy trait,那么旧变量在赋值之后仍然可用。如果一个类型或者该类型的一部分实现了 Drop trait,那么 Rust 就不会允许它实现 Copy trait。
一些拥有 Copy trait 的类型:
- 任何仅由简单标量值组成的复合类型都可以实现 Copy trait
- 任何需要分配内存或某种其他资源的都不能实现 Copy trait
对于元组,如果其中所有元素都能实现 Copy trait,那么这个元组也可以;如果其中哪怕有一个不能实现 Copy trait,那整个元组就不能。
(i32, u32)可以实现 Copy trait(i32, String)不能实现 Copy trait,因为String不能实现 Copy trait
4.3 所有权与函数
4.3.0 写在正文之前
在学习了 Rust 的通用编程概念后,就来到了整个 Rust 的重中之重——所有权。它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有五小节:
- 所有权:栈内存 vs. 堆内存
- 所有权规则、内存与分配
- 所有权与函数(本文)
- 引用与借用
- 切片(Slice)
4.3.1 把值传递给函数
在语义上,把值传递给函数和把值赋给变量是类似的,所以一句话总结:函数参数传递跟赋值操作是一样的
接下来详细解释一下:把值传递给函数将会发生移动或者复制。
- 对于实现了 Copy trait 的数据类型,会发生复制,所以原本的变量不受影响,能够继续使用。
- 对于没有实现 Copy trait 的数据类型,会发生移动,所以原本的变量会被弃用,不可使用。
Copy trait、移动、复制的详细介绍在上一篇文章 4.2. 所有权规则、内存与分配 有讲,这里不再赘述。
fn main() {
let machine = String::from("6657");
wjq(machine);
let x = 6657;
wjq_copy(x);
println!("x is: {}", x);
}
fn wjq(some_string: String) {
println!("{}", some_string);
}
fn wjq_copy(some_number: i32) {
println!("{}", some_number);
}
-
对于变量
machine:String是一种复杂数据类型,分配在堆上,并且没有实现 Copy trait。- 当
machine被传递给wjq函数时,发生了移动,即所有权从变量machine转移到了函数参数some_string。 - 此时,
machine的所有权已被转移。函数wjq可以正常使用它,但原来的变量machine不再可用。如果之后尝试使用machine,编译器会报错。
-
对于变量
x:i32是一种基本数据类型,大小固定,分配在栈上,并且实现了 Copy trait。- 当
x被传递给wjq_copy函数时,发生了复制,即变量x的值被复制一份传递给函数参数some_number。 - 由于只是值的复制,原变量
x不受影响,可以在函数调用之后继续使用。
-
对于变量
some_string:- 其作用域从第 10 行被声明开始,到第 12 行的
}时结束。 - 离开作用域时,Rust 会自动调用
drop函数,释放some_string所占的内存。
- 其作用域从第 10 行被声明开始,到第 12 行的
-
对于变量
some_number:- 其作用域从第 14 行被声明开始,到第 16 行的
}时结束。 - 离开作用域时不会有特殊事情发生,因为实现了 Copy trait 的类型在离开作用域时不会调用
Drop。
- 其作用域从第 14 行被声明开始,到第 16 行的
4.3.2 返回值与作用域
函数在返回值的过程中同样也会发生所有权的转移。
fn main() {
let s1 = give_ownership();
let s2 = String::from("6657");
let s3 = takes_and_gives_back(s2);
}
fn give_ownership() -> String {
let some_string = String::from("machine");
some_string
}
fn takes_and_gives_back(a_string: String) -> String {
a_string
}
-
函数
give_ownership的行为:give_ownership函数创建了一个String类型的变量some_string,它的所有权属于give_ownership函数。- 当
some_string作为函数返回值返回时,其所有权被转移到调用者,即变量s1。 - 结果是,
some_string离开give_ownership的作用域后不会被 drop,因为它的所有权已交给s1。
-
函数
takes_and_gives_back的行为:takes_and_gives_back函数接受一个String类型的参数a_string。调用该函数时,传入实参(s2)的所有权被转移到函数参数a_string。- 函数返回
a_string时,其所有权再次从a_string转移给调用者,即变量s3。 - 此时,变量
s2不再可用,因为其所有权已被转移给takes_and_gives_back,而函数的返回值赋给了s3。
一个变量的所有权总是遵循同样的模式:
- 把一个值赋给其他变量时就会发生移动。只有实现了 Copy trait 的类型(如基本类型
i32、f64等),在赋值时才会进行复制。 - 当一个包含堆数据的变量离开作用域时,它的值就会被
drop函数清理掉,除非数据的所有权已被移动到另一个变量上。
4.3.3 让函数使用某个值而不获得其所有权
有的时候代码的本意是让函数使用某个变量,但不想因此失去对数据的使用权。这时候可以这么写:
fn main() {
let s1 = String::from("Hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
在这个例子中,s1 不得不把所有权交给 s,但这个函数在返回时又把 s 原封不动地返回,把数据所有权交给了 s2。这样一来,数据所有权又回到了 main 函数里的变量,使得 s1 下的数据又能在 main 中使用(虽然换了个变量名)。
这种做法太麻烦,也太笨了。 Rust 针对这种场景提供了一个叫做引用的特性,让函数使用某个值而不获得其所有权。这个特性将会在下一篇文章 4.4. 引用与借用 中讲解。
4.4 引用与借用
4.4.0 写在正文之前
这一节的内容其实就相当于 C++ 的智能指针移动语义在编译器层面做了一些约束。Rust 中引用的写法,通过编译器的约束,变成了 C++ 中最理想、最规范的指针写法。所以学过 C++ 的人对这一章肯定会非常熟悉。
4.4.1 引用
引用让函数使用某个值而不获得其所有权。声明时在类型前加上 & 即代表引用。例如,String 的引用就是 &String。如果学过 C++,C++ 中的解引用运算符是 *,Rust 中也是一样的。
学了引用之后,就可以把上一篇文章 4.3. 所有权与函数 最后的示例简化。
这是先前的代码:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
这是修改后的代码:
fn main() {
let s1 = String::from("hello");
let length = calculate_length(&s1);
println!("The length of '{}' is {}", s1, length);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
对比两者,后者把指向数据的指针传入 calculate_length 函数供其操作,而数据所有权依然在变量 s1 上。不需要返回元组,也不需要再声明一个变量 s2,因此更加简洁。
函数 calculate_length 的参数 s 实际上是一个指针,指向 s1 所在的栈内存位置(不会直接指向堆内存中的数据)。当这个指针走出作用域时,Rust 并不会销毁它所指向的数据,因为 s 并不拥有它。Rust 只会弹出栈上存储的指针信息,也就是释放下图中最左侧部分所占的内存。

以引用作为函数参数叫做借用。
4.4.2 借用的特性
借用的内容不能被修改,除非是可变引用。
以房产为例:你把自己有所有权的房子租给别人,就是借用。租户可以住,但不能随便装修;这就是借用内容不能被修改的特性。如果你允许租客装修,那就是可变引用。
以这段代码为例:
fn main() {
let s1 = String::from("hello");
let length = calculate_length(&s1);
println!("The length of '{}' is {}", s1, length);
}
fn calculate_length(s: &String) -> usize {
s.push_str(", world");
s.len()
}
这段代码在编译时会报错:
error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | s.push_str(", world");
| ^ `s` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn calculate_length(s: &mut String) -> usize {
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error
报错原因在于 s.push_str(", world"); 这一行:引用默认是不可变的,但这一行修改了数据。
引用跟普通变量声明一样,默认不可变,但加上 mut 关键字后就可变了:
fn main() {
let mut s1 = String::from("hello");
let length = calculate_length(&mut s1);
println!("The length of '{}' is {}", s1, length);
}
fn calculate_length(s: &mut String) -> usize {
s.push_str(", world");
s.len()
}
这样写就不会报错了,但记得在声明 s1 时把它声明为可变变量。
这种可以修改数据的引用叫做可变引用。
4.4.3 可变引用的限制
可变引用有两个非常重要的限制。第一个是:在任意给定时刻,对某一块数据,只能有一个可变引用。
以这段代码为例:
fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &mut s;
println!("{}, {}", s1, s2);
}
因为 s1 和 s2 都是指向 s 的可变引用,且同时被使用,所以编译器会报错:
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:4:14
|
3 | let s1 = &mut s;
| ------ first mutable borrow occurs here
4 | let s2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
5 |
6 | println!("{}, {}", s1, s2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `mutable-ref` (bin "mutable-ref") due to 1 previous error
这么做的目的是防止数据竞争。当以下三个条件同时满足时,就会发生数据竞争:
- 两个或多个指针同时访问同一数据
- 至少一个指针用于写入数据
- 没有使用任何机制来同步对数据的访问
报错信息中提到了 at a time,意思是同时——也就是前一个借用仍在被使用的期间。所以只要它们不重叠,两个可变引用在不同作用域中指向同一块数据是允许的。下面的代码就体现了这一点:
fn main() {
let mut s = String::from("hello");
{
let s1 = &mut s;
}
let s2 = &mut s;
}
s1 和 s2 的作用域不相同,所以指向同一块数据是允许的。
可变引用的第二个重要限制是:不可以同时拥有一个可变引用和一个不可变引用。 可变引用的目的是修改数据,而不可变引用的目的是保持数据不变。如果两者同时存在,一旦可变引用改变了值,不可变引用就失去了作用。
fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &s;
println!("{}, {}", s1, s2);
}
因为 s1 是可变引用,s2 是不可变引用,两者同时指向同一块数据并被使用,所以编译器会报错:
error[E0502]: cannot borrow `s` as immutable because it is also borrowed as mutable
--> src/main.rs:4:14
|
3 | let s1 = &mut s;
| ------ mutable borrow occurs here
4 | let s2 = &s;
| ^^ immutable borrow occurs here
5 |
6 | println!("{}, {}", s1, s2);
| -- mutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `mixed-ref` (bin "mixed-ref") due to 1 previous error
当然,多个不可变引用可以同时存在。
总结:多个读者(不可变引用)可以同时存在,多个写者(可变引用)可以存在但不能同时,多个写者以及同时读写是不允许的。
4.4.4 悬空引用
在使用指针时,很容易引出叫做悬空指针的错误。其定义为:一个指针引用了内存中的某个地址,而这块内存可能已经被释放并重新分配给其他人使用了。
如果你引用了某些数据,Rust 的编译器保证在引用离开作用域之前,数据不会离开作用域。 这就是 Rust 确保悬空引用永远不会出现的方式。
以这段代码为例:
fn main() {
let r = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
- 创建了一个局部变量
s: 变量s是一个String。它被分配在栈上,但其底层数据存储在堆上。 - 返回对
s的引用: 函数最后通过&s返回了s的引用。 s离开作用域: 函数dangle返回后,变量s离开作用域。根据 Rust 的所有权规则,s的内存会被自动释放。&s所指向的内存数据已不再存储s的数据,因此返回的引用指向的是已经被释放的内存地址,变成了悬空引用。
Rust 的编译器会检查到这一点,并在编译时报错。
4.4.5 引用的规则
- 在任何给定时刻,只能满足下列条件之一:
- 一个可变引用
- 任意数量的不可变引用
- 引用必须一直有效
4.5 切片(Slice)
4.5.0 写在正文之前
这是第四章的最后一篇文章,在这里也顺便对这章做一个总结:
所有权、借用和切片这些概念,确保了 Rust 程序在编译时的内存安全。Rust 允许程序员以与其他系统编程语言相同的方式控制内存使用;但当数据所有者离开作用域时,让所有者自动清理数据,意味着你无需再编写和调试额外的代码来获得这种控制权。
看完这篇文章,相信你会由衷感叹 Rust 的所有权机制到底有多么神奇和先进。
4.5.1 切片的特性
-
1. 类型和结构
- 切片类型表示为
&[T]或&mut [T],其中T是切片中元素的类型。 - 不可变切片:
&[T],只允许读取操作。 - 可变切片:
&mut [T],允许修改。
- 切片类型表示为
-
2. 不拥有数据
- 切片本质上是对底层数据的引用,因此它不拥有数据。
- 切片的生命周期与底层数据一致。当底层数据被销毁时,切片也会失效。
4.5.2 字符串切片
以一道题为例: 编写一个函数,它接受一个字符串作为参数,并返回它在这个字符串中找到的第一个单词。如果函数没有找到任何空格,那么就返回整个字符串。
fn main() {
let s = String::from("Hello world");
let word_index = first_word(&s);
println!("{}", word_index);
}
fn first_word(s:&String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
- 因为需要逐个元素地遍历
String并检查每个值是否为空格,所以使用as_bytes方法将String转换为字节数组。 - 迭代器以后会讲到。现在只需要知道,
iter是一个用来逐一获取集合中每个元素的方法。enumerate是一个工具,它在iter的基础上为每个元素附加一个索引,并将结果作为元组返回。返回元组的第一个元素是索引,第二个元素是对该元素的引用。
程序成功编译,输出是 5。那就是 Hello 后面空格的索引。
我们现在有办法找出字符串中第一个单词末尾的索引,但有一个问题。我们自己返回一个 usize,但它只是在 &String 上下文中才有意义的一个数字。换句话说,因为它是与 String 不同的值,所以不能保证它在将来仍然有效。
比如因为某些原因,代码在调用 first_word 之后写了 s.clear(); 来清空 s。此时 word_index 这个变量就没有意义了。换句话说,Rust 编译器发现不了“代码使用了 s.clear() 但 word_index 仍然存在”这种错误。如果你之后还用 word_index 去打印字符,显然就会出错。
这类 API 设计要求你随时关注 word_index 的有效性,并确保这个索引与 String 变量 s 之间保持同步。偏偏这类工作往往相当繁琐,而且特别容易出错,所以针对这类问题,Rust 提供了字符串切片。
字符串切片是指向字符串中一部分内容的引用。
在原字符串名前加上 & 表示对它的引用,在后面加上 [start_index..end_index],表示引用这个字符串的一部分。注意,[] 内的区间是左闭右开,所以结束索引是切片终止位置的下一个索引。通俗地说:包左不包右。
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
}
在这个例子中,把 s 从 0 到 5 的索引区间(包括 0 不包括 5),也就是 "hello",赋给了 hello 变量;把从 6 到 11 的索引区间(包括 6 不包括 11),也就是 "world",赋给了 world 变量。
由图可见,world 这个变量并不会独立于 s 而存在,这使得编译器能够在编译过程中发现许多潜在问题。
当然,对于索引写法,还有几种省略形式:
#![allow(unused)]
fn main() {
let hello = &s[0..5];
}
这个变量是从索引 0 开始截取的,Rust 允许这样的等价写法:
#![allow(unused)]
fn main() {
let hello = &s[..5];
}
#![allow(unused)]
fn main() {
let world = &s[6..11];
}
这个变量截取到了 s 的最后一个元素,Rust 允许这样的等价写法:
#![allow(unused)]
fn main() {
let world = &s[6..];
}
如果想截取整个字符串,可以写成:
#![allow(unused)]
fn main() {
let whole = &s[..];
}
注意事项
- 字符串切片的范围索引必须落在有效的
UTF-8边界上。 - 如果尝试从一个多字节字符的中间创建字符串切片,程序会 panic 并退出。
重写代码
学了切片之后,就可以修改文章开头的代码来进一步优化了:
fn main() {
let mut s = String::from("Hello world");
let word = first_word(&s);
println!("{}", word);
}
fn first_word(s:&String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
&str表示字符串切片。
如果在 let word = first_word(&s); 与使用 word 的 println! 之间插入 s.clear();,Rust 就能够发现错误并报错:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:4:2
|
3 | let word = first_word(&s);
| -- immutable borrow occurs here
4 | s.clear();
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{}", word);
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `slice-clear` (bin "slice-clear") due to 1 previous error
这是因为 s.clear() 带来的可变借用,与 word 持有的不可变借用发生了重叠,违反了借用规则。
PS:s.clear() 等价于 clear(&mut s)
4.5.3 字符串字面值就是切片
字符串字面值被直接存储在二进制程序之中,在程序运行时会加载到静态内存里。
#![allow(unused)]
fn main() {
let s = "Hello, World!";
}
变量 s 的类型是 &str,它是一个指向二进制程序中特定位置的切片。&str 是不可变的,所以字符串字面值也是不可变的。
4.5.4 将字符串切片作为参数传递
#![allow(unused)]
fn main() {
fn first_word(s:&String) -> &str {
}
这是刚刚优化过的代码中声明函数的那一行,这种写法本身完全没有问题。但有经验的 Rust 开发者会使用 &str 作为 s 的参数类型,因为这样函数就可以同时接受 String 和 &str 类型的参数了:
- 如果你传入的值已经是字符串切片,可以直接调用。
- 如果值是
String,可以传入&String类型的实参。当函数参数需要&str而你传递的是&String时,Rust 会隐式调用Deref,将&String转换为&str。
使用字符串切片而不是字符串引用作为函数参数,会使 API 更加通用,且不会损失任何功能。
基于此,还可以进一步优化之前的代码:
fn main() {
let s = String::from("Hello world");
let word = first_word(&s);
println!("{}", word);
}
fn first_word(s:&str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
这一行:
#![allow(unused)]
fn main() {
let word = first_word(&s);
}
也可以写成:
#![allow(unused)]
fn main() {
let word = first_word(&s[..]);
}
对于前者,Rust 会隐式调用 Deref,将 &String 转换为 &str;后者是手动转换为 &str。
4.5.5 其他类型的切片
fn main() {
let number = [1, 2, 3, 4, 5];
let num = &number[1..3];
println!("{:?}", num);
}
数组也可以使用切片。num 这个切片的本质,就是存储了指向 number 中切片起始点(本例中是索引 1)的指针以及长度信息。
其输出是:
[2, 3]
5.1 定义并实例化struct
5.1.1. 什么是struct
struct 的中文意思为“结构体”。它是一种自定义数据类型,允许程序为相关联的值命名并打包,形成有意义的组合。它类似于其他编程语言中的“类”或“结构”,但它只提供数据存储功能,不包含方法。
学过 C/C++ 的人可能对 struct 这个关键字已经很熟悉,但它们有区别:
-
C:
struct是一种用来组织数据的简单聚合类型。它只能包含数据,没有方法。 -
C++:
struct与class非常相似,可以包含数据和方法,唯一的语法区别是在struct中,默认的访问权限是public;在class中,默认的访问权限是private。 -
Rust:
struct仅用于定义数据结构,不包含方法。方法需要通过impl块为结构体定义。Rust 提供了更严格的所有权、生命周期和内存管理机制。
5.1.2. 定义struct
- 使用
struct关键字,用驼峰命名法为整个 struct 命名。 - 在花括号内,为所有字段定义名称和类型。
例子: 为 HLTV 上的 CS 职业选手定制存储各项数据的 struct(补充信息:CS 职业选手的数据一般由 Rating、DPR、KAST、Impact、ADR 和 KPR 组成)。

#![allow(unused)]
fn main() {
struct Stats{
rating: f32,
dpr: f32,
kast: f32,
impact: f32,
adr: f32,
kpr: f32,
}
}
5.1.3. 实例化struct
想要使用 struct,需要创建它的实例:
- 为每个字段指定具体值,不能少赋字段的值。
- 无需按声明的顺序进行指定。
就以 donk 为例创建他的数据库:
fn main() {
let donk = Stats {
rating: 1.27,
impact: 1.4,
dpr: 0.67,
adr: 88.8,
kast: 74.1,
kpr: 0.85,
};
}
5.1.4. 取得struct里某个字段的值
可以使用点标记法取得 struct 里字段的值:
fn main() {
let mut donk = Stats {
rating: 1.27,
impact: 1.4,
dpr: 0.67,
adr: 88.8,
kast: 74.1,
kpr: 0.85,
};
donk.rating = 2.59;
}
如果要更改 struct 的值,记得在实例化时使用可变变量关键字 mut。
在 struct 中,可变性的最小单位就是整个实例,不能单独控制单个字段的可变性。一旦 struct 实例被声明为可变的,那么这个实例下的所有字段都是可变的。
5.1.5. 使用struct作为函数返回值
函数里的最后一个表达式就是它的返回值,所以使用 struct 作为返回值时,只需要确保构建 struct 是这个函数的最后一个表达式(不带分号)即可:
#![allow(unused)]
fn main() {
fn change_stats(rating: f32, impact:f32, dpr:f32, adr:f32, kast:f32, kpr:f32) -> Stats{
Stats {
rating: rating,
impact: impact,
dpr: dpr,
adr: adr,
kast: kast,
kpr: kpr,
}
}
}
5.1.6. 字段初始化的简写
Rust 与 JS 和 C# 一样,在某些情况下字段初始化可以简写。
当字段名与字段值对应的变量名相同时,就可以简写。比如在上一个代码例中,所有的字段名都和字段值对应的变量名相同,所以可以将其简写为:
#![allow(unused)]
fn main() {
fn change_stats(rating: f32, impact:f32, dpr:f32, adr:f32, kast:f32, kpr:f32) -> Stats{
Stats {
rating,
impact,
dpr,
adr,
kast,
kpr,
}
}
}
当然不只是全部对应才能这么写,只要有一个字段符合简写条件就可以在那里使用简写,其他的保持正常写法就行。
5.1.7. struct的更新语法
当你基于某个已有的 struct 实例来创建新实例,并且新实例有与旧实例相同的字段时,就可以使用更新语法。
比如我要创建 sh1ro 的数据,他的 rating 是 1.25,impact 是 1.2,其余与 donk 一样,这是基础的写法:
fn main() {
let donk = Stats {
rating: 1.27,
impact: 1.4,
dpr: 0.67,
adr: 88.8,
kast: 74.1,
kpr: 0.85,
};
let sh1ro = Stats {
rating: 1.25,
impact: 1.2,
dpr: donk.dpr,
adr: donk.adr,
kast: donk.kast,
kpr: donk.kpr,
};
}
这样写比较麻烦,所以 Rust 提供了这样的语法糖:
fn main() {
let donk = Stats {
rating: 1.27,
impact: 1.4,
dpr: 0.67,
adr: 88.8,
kast: 74.1,
kpr: 0.85,
};
let sh1ro = Stats {
rating: 1.25,
impact: 1.2,
..donk
};
}
只需要写有变化的部分,其余部分只需要写 .. 加上另一个 struct 实例的名字即可,表示剩下没有赋值的字段的值都与另一个实例对应字段的值相同。
5.1.8. 元组结构体Tuple struct
元组结构体是一种类似元组的结构体。元组结构体整体有名字,但里面的元素没有。适用于想给整个元组起名,并让它不同于其他元组,而且又不需要给每个元素起名的情况。
定义元组结构体时,使用 struct 关键字,后边是名字,以及里面元素的类型。
例子:
#![allow(unused)]
fn main() {
struct Color(u8, u8, u8);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
有的人戏谑地说:元组结构体在传统编程语言中没有类似物,这是来自 Haskell 的高贵血统。这是因为在许多传统的面向对象语言(如 Java、C++)中,结构体或类是具名且字段命名的,而元组则是匿名且仅基于顺序的。没有中间形式来融合两者的优点。Rust 的元组结构体概念与 Haskell 的 新类型(Newtype Pattern) 有直接关系,在 Haskell 中可以通过 newtype 来定义类似的模式。
需要注意的是,即使两个元组结构体有相同数量的元素并且对应元素的数据类型都一样,它们也不该被视为相同的类型,因为它们是不同的 struct。
5.1.9. 类单元结构体Unit-Like Struct
unit-like struct 被称为 类单元结构体,因为它们的行为类似于单元类型 ()。当需要类型标记,或想要在某种类型上实现 trait(可以理解为接口)但不想在类型本身中存储任何数据时,就会使用它们。这类似于 Go 语言中的空结构体 struct{}。
struct ReadOnly;
struct WriteOnly;
fn process_data<T>(_mode: T) {
// Used only as a type marker
}
fn main() {
process_data(ReadOnly);
process_data(WriteOnly);
}
这个例子实现了类型标记。
5.1.10. struct数据的所有权
#![allow(unused)]
fn main() {
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
}
在这个例子中,username 和 email 都使用的是 String 类型而不是 &str,因为 String 是自有类型(owned type),拥有自身全部数据的所有权。在这种情况下,只要实例是有效的,那么里面的字段数据也肯定是有效的。
像 &str 这样的引用类型也可以存放进 struct 里,但这需要生命周期(以后讲)。简单来说,生命周期保证只要 struct 实例是有效的,那么里面的引用也是有效的。如果 struct 里面存储引用,而不使用生命周期,就会报错(missing lifetime specifier)。
5.2 struct使用例(加打印调试信息)
5.2.1. 例子需求
创建一个函数,计算长方形的面积。长和宽类型均为 u32,面积类型也为 u32。
5.2.2. 普通解法
最简单的解法就是定义这个函数有两个参数:一个宽一个长,都为 &u32 类型(例子中说了值是 u32 类型,并且这个场景下不需要函数获得数据所有权,所以使用引用,在数据类型前加 &)。在函数中返回宽乘以长的值就行。
fn main() {
let width = 30;
let length = 50;
println!("{}", area(&width, &length));
}
fn area(width: &u32, length: &u32) -> u32 {
width * length
}
输出:
1500
5.2.3. 元组解法
普通解法本身没有问题,但在可维护性上有一个问题:宽和长是独立的参数,程序中的任何地方都不清楚这些参数是相关的。将宽度和长度组合在一起会更具可读性,也更易于管理。对于数据的整合,使用元组再好不过(因为都是同一数据类型,所以在这里使用数组也是可以的)。
fn main() {
let rectangle = (30,50);
println!("{}", area(&rectangle));
}
fn area(dim:&(u32,u32)) -> u32 {
dim.0 * dim.1
}
输出:
1500
5.2.4. struct解法
元组解法虽然提升了可维护性,但代码的可读性变差了,因为如果不加注释,没人知道元组的第一个数据是代表宽还是代表长(虽然对于计算面积来说无所谓,但是对于较大的项目来说很重要)。元组的元素是没有名字的,即使是元组结构体(上一篇文章 5.1. 定义并实例化struct 中有讲),它里面的元素也是没有名字的。
那么哪种数据结构可以把两个数据整合到一起并且分别赋名呢?没错,就是 struct。
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
5.2.5. 打印结构体的调试信息
接着上面的代码,如果再加一行直接打印 rectangle 这个实例会怎么样呢?代码如下:
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
println!("{}", rectangle); // Print the instance directly
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
输出:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
--> src/main.rs:12:20
|
12 | println!("{}", rectangle);
| -- ^^^^^^^^^ `Rectangle` cannot be formatted with the default formatter
| |
| required by this formatting parameter
|
help: the trait `std::fmt::Display` is not implemented for `Rectangle`
--> src/main.rs:1:1
|
1 | struct Rectangle {
| ^^^^^^^^^^^^^^^^
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
先解释一下报错:println! 这个宏可以执行很多种格式化打印。占位符 {} 就是告诉 println! 来使用 std::fmt::Display 这个 trait(理解成接口),类似于 Python 的 toString。报错信息告诉我们,Rectangle 并没有实现 std::fmt::Display 这个 trait,所以不能这样打印。
实际上,目前所讲的基础数据类型默认都实现了 std::fmt::Display,因为它们的展示方式都比较单一。比如说把 1 打印出来,程序只可能打印出阿拉伯数字 1。但是对于有两个字段的 Rectangle,是要都打印,还是只打印 width,还是只打印 length 呢?可能性太多了,所以 Rust 并没有为 struct 默认实现 std::fmt::Display。
但如果我们继续往下看到这一行:
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
编译器提示我们可以使用 {:?} 或者 {:#?} 来代替 {}。那就试试第一种:
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
println!("{:?}", rectangle); // Change `{}` to `{:?}`
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
还是报错了:
error[E0277]: `Rectangle` doesn't implement `Debug`
--> src/main.rs:12:22
|
12 | println!("{:?}", rectangle);
| ---- ^^^^^^^^^ `Rectangle` cannot be formatted using `{:?}` because it doesn't implement `Debug`
| |
| required by this formatting parameter
|
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
help: consider annotating `Rectangle` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct Rectangle {
|
但报错信息变了。上一回是没有实现 std::fmt::Display,这回是没有实现 Debug。Debug 和 Display 一样也是一种格式化方法。继续往下看到 note 这行:
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
编译器提示我们添加 #[derive(Debug)] 到代码中,或手动实现 Debug 这个 trait。这里使用前一种(手动实现 trait 会在后面的章节讲解):
#[derive(Debug)]
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
println!("{:?}", rectangle);
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
输出:
1500
Rectangle { width: 30, length: 50 }
这次就可以成功通过了。Rust 本身包含了打印调试信息的功能,但必须为自己代码中的结构体显式地选择这一功能,所以要在定义结构体前加上 #[derive(Debug)] 这个注解。这种输出把结构体的名字、字段的名字及值都显示出来了。
有的时候结构体里有很多字段,这时 {:?} 打印出的横向排列就不那么易读。如果想要输出更加易读,那就把 {:?} 改为 {:#?}:
#[derive(Debug)]
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
println!("{:#?}", rectangle);
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
输出:
1500
Rectangle {
width: 30,
length: 50,
}
这个输出中字段就是纵向排列,对于有很多字段的结构体来说更加易读。
实际上 Rust 提供了很多 trait 让我们可以进行 derive(派生),这些 trait 可以为自定义类型添加很多功能。所有的 trait 和它们的行为都可以在官方指南中找到,我把网址链接附在这里。
在上边的代码中就是让 Rectangle 这个 struct 派生 Debug 这个 trait,所以在打印时就可以使用调试模式。
再举个例子,假设你有一个表示点坐标的结构体:
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let point1 = Point { x: 1, y: 2 };
let point2 = point1.clone();
println!("{:?}", point1); // Print Point using the Debug trait
assert_eq!(point1, point2); // Compare two Point values using the PartialEq trait
}
在这个例子中:
#[derive(Debug)]允许你使用{:?}格式化规范来打印Point结构体的实例。#[derive(Clone)]允许你创建一个Point实例的副本。#[derive(PartialEq)]允许你比较两个Point实例是否相等。
5.3 struct的方法(Method)
5.3.1. 什么是方法(Method)
方法和函数类似,也是用 fn 关键字进行声明,方法也有名称、参数和返回值。但方法和函数也有不同之处:
- 方法在
struct(或枚举或 trait 对象)的上下文中定义。 - 方法的第一个参数总是
self,表示方法所属(被调用)的struct实例,类似于 Python 中的self和 JavaScript 中的this。
5.3.2. 方法的实际应用
接下来还是看例子,以上一篇文章 5.2. struct使用例(加打印调试信息) 的代码为例:
struct Rectangle {
width: u32,
length: u32,
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", area(&rectangle));
}
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
area 这个函数的作用是计算面积,但它很特别:它只适用于矩形,而不适用于其他形状或其他类型。如果后面要加上计算其他图形面积的函数,那么 area 这个名字就会变得含糊。如果改名成 rectangle_area 又太麻烦,因为 main 里所有调用这个函数的地方也都要改。
所以如果能把存储矩形长宽的 Rectangle 结构体,和只能计算矩形面积的 area 函数结合到一起,就是最好的。
对于这种需求,Rust 提供了“实现”(implementation),其关键字是 impl。后边跟着 struct 名,加上一对 {},在里面像定义普通函数一样定义方法就行。
对于这个例子,struct 名就是 Rectangle,把定义 area 函数的代码剪贴到 {} 内即可:
#![allow(unused)]
fn main() {
impl Rectangle {
fn area(dim:&Rectangle) -> u32 {
dim.width * dim.length
}
}
}
但注意这里的代码还不是方法,因为方法的第一个参数必须是 self。现在的代码叫关联函数,下文会讲。
这么写是没有问题的,但还可以进一步简化。上文中说到了方法的第一个参数总是 self,所以这里也可以改一下:
#![allow(unused)]
fn main() {
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.length
}
}
}
你当前写的这个方法绑定在谁上,self 指的就是谁。这个代码中 area 函数被绑定在 Rectangle 上,所以 self 就指的是 Rectangle。area 的参数不用拿走所有权,所以在 self 前面加上 & 表示引用。
当然这么改之后,main 函数里的函数调用也要改——从函数调用改到方法调用:实例.方法名(参数)。
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", rectangle.area());
}
rectangle.area() 的括号中不写东西,是因为 area 方法在定义时只使用了 &self 作为参数,表示这个方法借用了 self(即 rectangle 实例)的不可变引用。在调用 area 时,你不需要显式地传递这个实例,因为方法调用已经隐式地知道 self 是 rectangle。
整体代码如下:
struct Rectangle {
width: u32,
length: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.length
}
}
fn main() {
let rectangle = Rectangle{
width: 30,
length: 50,
};
println!("{}", rectangle.area());
}
输出:
1500
5.3.3. 如何定义方法
在上面的实际应用中已经写过一遍了,所以这里就只做总结:
- 在
impl里定义方法 - 方法的第一个参数可以是
self、&self或&mut self。可以获得所有权、不可变引用或可变引用,这点和其他参数一样。 - 方法可以帮助更好地组织代码,因为可以把某个类型的方法都放在同一个
impl块里面,这样就不必在整个代码库里搜索与某个struct相关的行为了。
5.3.4. 方法调用的运算符
在 C/C++ 中,调用方法有两种运算符:
->:其格式为object->something()。调用指针指向的对象上的方法就使用这一种(也就是object为指针时)。.:其格式为object.something()。调用对象本身上的方法就使用这种(也就是object不为指针,是个对象时)。
而 object->something() 实际上是语法糖,它等同于 (*object).something(),* 表示解引用。两者的流程都是先解引用,得到对象,再在对象上调用方法。
Rust 提供了自动引用/解引用的特性。也就是说,在调用方法时,Rust 会根据情况自动添加 &、&mut 或 *,以便 object 可以匹配方法的签名。这点和 Go 语言一样。
举个例子,下面这两行代码效果相同:
#![allow(unused)]
fn main() {
point1.distance(&point2);
(&point1).distance(&point2);
}
Rust 会根据情况自动在 point1 前加上 &。
5.3.5. 方法的参数
方法除了 self 也可以带其他参数,一个或多个都可以。
举个例子,在 5.3.2 的代码基础上加一个判断矩形是否能容纳下另一个矩形的功能(不考虑斜着放,也不考虑矩形的长比宽长的情况):
#![allow(unused)]
fn main() {
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.length > other.length
}
}
}
逻辑非常好想:只要矩形的宽和长都比另一个大就行。
然后再在 main 函数里声明几个 Rectangle 实例,输出比较结果看看有没有问题就行。以下是完整代码:
struct Rectangle {
width: u32,
length: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.length > other.length
}
}
fn main() {
let rect1 = Rectangle{
width: 30,
length: 50,
};
let rect2 = Rectangle{
width: 10,
length: 40,
};
println!("{}", rect1.can_hold(&rect2));
}
输出:
true
5.3.6. 关联函数
可以在 impl 块里定义不把 self 作为第一个参数的函数,叫关联函数(不是方法)。它不是在实例上调用的,但它与这个类型有关联。例如:String::from() 就是 String 这个类型上叫做 from 的关联函数。
关联函数通常用于构造器,也就是用来创建关联类型的一个实例。
比如说,在 5.3.2 的代码基础上加一个构建正方形的构造器(正方形也是特殊的矩形):
#![allow(unused)]
fn main() {
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle{
width: size,
length: size,
}
}
}
}
参数只需要一个,因为构造正方形只需要一个边长。
在 main 函数里调用一下这个关联函数试试,其格式为 类型名::函数名(参数)。以下是完整代码:
#[derive(Debug)]
struct Rectangle {
width: u32,
length: u32,
}
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle{
width: size,
length: size,
}
}
}
fn main() {
let square = Rectangle::square(10);
println!("{:?}", square);
}
输出:
Rectangle { width: 10, length: 10 }
:: 不仅可以用于关联函数,也可以用于模块创建命名空间(以后会讲)。
5.3.7. 多个impl块
每个 struct 允许拥有多个 impl 块。
比如我要把这篇文章里写过的所有方法和关联函数都写到一个代码示例里。
可以这么写(多个 impl 块):
#[derive(Debug)]
struct Rectangle {
width: u32,
length: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.length
}
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.length > other.length
}
}
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle{
width: size,
length: size,
}
}
}
fn main() {
let square = Rectangle::square(10);
println!("{:?}", square);
}
也可以这么写,合在一个 impl 块里:
#[derive(Debug)]
struct Rectangle {
width: u32,
length: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.length
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.length > other.length
}
fn square(size: u32) -> Rectangle {
Rectangle{
width: size,
length: size,
}
}
}
fn main() {
let square = Rectangle::square(10);
println!("{:?}", square);
}
6.1 定义枚举
6.1.1. 什么是枚举?
枚举允许我们通过列举所有可能的值来定义一个类型。这与其他编程语言中的枚举类似,但 Rust 的枚举更加灵活和强大,因为它们可以关联数据和方法,类似于其他语言中的类或结构体。
6.1.2. 定义枚举
举个例子,IP地址只有两种可能——IPv4和IPv6。它要么是IPv4,要么是IPv6,所以非常适合用枚举,因为枚举的值只能是其所有变体(枚举所有可能的值)中的一个。
#![allow(unused)]
fn main() {
enum IpAddrKind{
V4,
V6,
}
}
这段代码使用enum关键字声明了一个名为IpAddrKind的枚举类型,它有两个变体——V4和V6——分别代表IPv4和IPv6。
6.1.3. 枚举值
创建枚举值非常简单,格式为枚举名::变体。例如:
#![allow(unused)]
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
}
枚举的变体位于该枚举标识符的命名空间下,而这个标识符就是枚举类型的名字。
我们可以声明一个接收IpAddrKind作为参数的函数,传入的值既可以是V4也可以是V6:
#![allow(unused)]
fn main() {
fn route(ip_addr: IpAddrKind) {
match ip_addr {
IpAddrKind::V4 => println!("IPv4"),
IpAddrKind::V6 => println!("IPv6"),
}
}
}
让我们试试效果: 完整代码:
enum IpAddrKind{
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
// 调用函数
route(four);
route(six);
route(IpAddrKind::V4);
}
fn route(ip_addr: IpAddrKind) {
match ip_addr {
IpAddrKind::V4 => println!("IPv4"),
IpAddrKind::V6 => println!("IPv6"),
}
}
输出:
IPv4
IPv6
IPv4
6.1.4. 将数据附加到枚举变体中
枚举是一种自定义数据类型,所以可以用作结构体字段的类型,例如:
#![allow(unused)]
fn main() {
struct IpAddr {
kind: IpAddrKind,
address: String,
}
}
IpAddr中的kind字段类型是IpAddrKind,用于存储网络协议;另一个字段address是String类型,用于存储具体的IP地址。
通过这样的结构体,我们可以在main()函数中声明一些存储IPv4、IPv6信息的变量:
fn main() {
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
}
Rust允许把数据直接附加到枚举变体上,例如:
#![allow(unused)]
fn main() {
enum IpAddr {
V4(String),
V6(String),
}
}
在每个变体后面加上一个类型(不必是同一种类型)。这里V4和V6后面都跟了String类型。
这种做法的优点是:
- 不需要额外使用结构体
- 每个变体可以有不同的类型,以及不同数量的关联数据
例如:
#![allow(unused)]
fn main() {
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
}
IPv4实际上由四个8位数字组成(也就是四个能装进u8的值),而IPv6是字符串,所以应使用String。如果我们想把V4地址存成四个u8值,同时仍把V6地址表示成String,就无法使用结构体。枚举可以轻松处理这种情况。
我们来重写一下前面的代码:
enum IpAddrKind{
V4(u8, u8, u8, u8),
V6(String),
}
fn main() {
let home = IpAddrKind::V4(127, 0, 0, 1);
let loopback = IpAddrKind::V6(String::from("::1"));
}
确实比前面的代码短多了。
6.1.5. 标准库中的IpAddr
事实上,标准库已经提供了表示IP地址的枚举。来看一下官方是怎么写的:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Ipv4Addr和Ipv6Addr的内容这里没有写出来,但这不是重点。重点是此代码说明任何类型的数据都可以放进枚举变体中:例如字符串、数字类型或结构体。甚至还可以包含另一个枚举。
6.1.6. 在枚举上使用方法(Method)
方法(Method)的概念在上一篇文章 5.3. struct的方法(Method) 中已经介绍过,这里不再过多展开。定义方法使用impl关键字,如下例:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
println!("Something happens");
}
}
fn main(){
let m = Message::Write(String::from("hello"));
m.call();
}
该枚举有四种不同的变体:
Quit:不携带任何数据。Move:包含一个匿名结构体。Write:包含一个String。ChangeColor:包含三个i32值。
在main中,变量m被声明为Message枚举的Write变体,并附带了String值hello。然后在m上调用call方法,就会打印Something happens。
6.2 Option枚举
6.2.1. 什么是Option枚举?
它定义于标准库中,并包含在prelude(预导入模块)里。它用来描述这样的场景:
某个值可能存在,如果存在则是哪种数据类型;或者它根本就不存在。
6.2.2. Rust没有Null
在大部分其他语言中都有Null这个值,它代表没有值。
在那些语言里,一个变量可以处于两种状态:
- 空值(
Null) - 非空
Null的发明者托尼·霍尔(Tony Hoare)在2009年的演讲“Null References: The Billion Dollar Mistake”中说道:
I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
用中文说就是:我称之为我的十亿美元错误。当时,我正在设计第一个面向对象语言的综合引用类型系统。我的目标是确保所有引用的使用都绝对安全,并由编译器自动执行检查。但我无法抗拒加入空引用的诱惑,只是因为它很容易实现。这导致了无数的错误、漏洞和系统崩溃,在过去四十年中可能造成了数十亿美元的痛苦和损失。
Null的问题非常显而易见,连其发明者都不认为这是个好东西。举个例子:如果一个变量是字符串类型,需要与另一个字符串拼接,但这个变量实际上是Null,那么在拼接时就会出错。对于Java用户来说,最常见的错误就是NullPointerException。一句话总结,当你尝试像使用非Null值那样使用Null值时,就会引起某种错误。
因此,Rust没有提供Null。但是针对Null试图表达的概念——即某个值当前无效,或由于某种原因不存在——Rust提供了一个类似的枚举,叫做Option<T>。
6.2.3. Option<T>
它在标准库中的定义是这样的:
#![allow(unused)]
fn main() {
enum Option<T>{
Some(T),
None,
}
}
Some变体可以携带一些数据,其数据类型就是T。<T>实际上是泛型参数(以后会讲)。None是另一个变体,但它不携带任何数据,因为它表示值不存在的情况。
因为它包含在Prelude中,所以可以直接使用Option<T>、Some(T)和None。
看个例子:
fn main(){
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
}
- 对于前两个语句,值都写在括号里了,所以Rust编译器能够推断出其数据类型。例如,
some_number的类型是Option<i32>,some_char的类型是Option<char>。当然你也可以显式写出类型,但没必要,除非你想强制指定某个类型。 - 对于最后一条语句,赋的值是
None变体。编译器无法根据None推断出Option<T>中的T到底是什么类型,所以需要显式声明具体类型。因此这里写的是Option<i32>。
在这个例子中,前两个变量是有效值,而最后一个变量不包含有效值。
6.2.4. Option<T>的优点
- 在Rust里,
Option<T>和T(T可以是任何数据类型)是不同的类型,不能把Option<T>当作T来用。 - 若想使用
Option<T>中的T,必须先把它转换成T。这避免了程序员忽略空值的可能性、直接操作可能为空的变量。Rust的Option<T>设计迫使开发者显式处理这些情况。 比如在C#中,如果先写string a = null;,再写string b = a + "12345";,却不检查a是否为空(或者说忽略了a可能为空),那么运行到第二行时就会出错。 而在Rust里,只要这个值的类型不是Option<T>,那么这个值就肯定不是空的。
举个例子:
fn main(){
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
如果运行这段代码,编译器就会报错:
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
help: the following other types implement trait `Add<Rhs>`
--> /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/arith.rs:98:9
|
98 | impl const Add for $t {
| ^^^^^^^^^^^^^^^^^^^^^ `i8` implements `Add`
...
113 | add_impl! { usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f16 f32 f64 f128 }
| ---------------------------------------------------------------------------------- in this macro invocation
|
::: /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/internal_macros.rs:22:9
|
22 | impl const $imp<$u> for &$t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&i8` implements `Add<i8>`
...
33 | impl const $imp<&$u> for $t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `i8` implements `Add<&i8>`
...
44 | impl const $imp<&$u> for &$t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&i8` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)
报错的意思是:无法把Option<i8>和i8这两种类型相加,因为它们不是同一种类型。
那怎么让x和y相加呢?很简单,把y从Option<i8>转换成i8即可:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = match y {
Some(value) => x + value, // 如果 y 是 Some,则解包并相加
None => x, // 如果 y 是 None,则返回 x
};
}
6.3 控制流运算符-match
6.3.1. 什么是match?
match允许一个值与一系列模式进行比较,并执行与匹配模式对应的代码。模式可以是字面值、变量名、通配符等等。
把match表达式想象成一台硬币分类机:硬币沿着带有不同大小孔洞的轨道滑下,每枚硬币都会从它遇到的第一个合适的孔洞落下。同样地,一个值会依次经过match中的每个模式,当它“适合”第一个模式时,就会落入执行时要使用的关联代码块中。
6.3.2. match的实际应用
来看个例子:编写一个函数,接受一枚未知的美国硬币,并以类似计数机的方式判断它是哪种硬币,然后返回其价值(以美分为单位)。
#![allow(unused)]
fn main() {
enum Coin {
Penny,// 1美分
Nickel,// 5美分
Dime,// 10美分
Quarter,// 25美分
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
}
-
match关键字后面跟着一个表达式,在本例中就是值coin。这看起来与if使用的条件表达式很像,但有一个很大的区别:if的条件必须是布尔值,而match可以处理任何类型。本例中coin的类型是我们在第一行定义的Coin枚举。 -
接下来是花括号。花括号里有四个分支(英文叫arm),每个分支都由待匹配的模式和对应的代码组成。第一个分支
Coin::Penny => 1,使用Coin::Penny作为模式。=>用来分隔模式和要运行的代码,这里要运行的代码就是值1,也就是返回1。不同分支之间用逗号分隔。 -
当
match表达式运行时,它会把match后面的表达式——这里是coin——从上到下依次与各个分支比较。如果某个模式与值匹配,就执行与该模式关联的代码;如果不匹配,就继续检查下一个分支。匹配成功的分支所对应的代码表达式会作为整个match表达式的值返回。 例如,如果match匹配到5美分硬币,也就是Coin::Nickel,那么整个表达式的结果就是5。又因为match表达式是value_in_cents中的最后一个表达式,所以它的值——5——会作为函数的返回值。 -
这里每个分支的代码都很简单,所以用
=>就够了。但如果某个分支包含多行代码,就需要用花括号把这些行包起来。例如:
#![allow(unused)]
fn main() {
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
}
6.3.3. 绑定值的模式
match的分支可以绑定到被匹配值的一部分,从而可以从枚举变体中提取值。
例如,一位朋友正在尝试收集全部50个州的25美分硬币。当我们按硬币类型对零钱分类时,还会标注每个25美分硬币关联的州名(美国州太多了,这里只写了Alabama和Alaska):
#[derive(Debug)] // 便于调试打印
enum UsState {
Alabama,
Alaska,
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
},
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
}
}
}
fn main() {
let c = Coin::Quarter(UsState::Alaska);
println!("{}", value_in_cents(c));
}
-
给代表25美分硬币的
Coin变体关联一份数据,也就是上面的UsState枚举。 -
在
value_in_cents函数中,Quarter分支也需要相应调整。匹配模式从Coin::Quarter改成Coin::Quarter(state),意思是把Coin::Quarter关联的值绑定到变量state上,这样在后面的代码块中就可以使用这个关联值。 有些情况下可能不需要Coin::Quarter关联的值。这时可以用通配符_表示不关心内容:Coin::Quarter(_) -
在
main中先声明变量c,存的是Coin::Quarter(UsState::Alaska)。也就是说,它保存了Coin::Quarter变体,关联值是UsState::Alaska变体。然后调用value_in_cents。
来看输出:
State quarter from Alaska!
25
6.3.4. 匹配Option<T>
来分析上一篇文章 6.2. Option枚举 最后的代码示例:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = match y {
Some(value) => x + value, // 如果 y 是 Some,则解包并相加
None => x, // 如果 y 是 None,则返回 x
};
}
- 如果
y不是None,就解包,把Some关联的值绑定到value,并返回x + value。 - 如果
y是None,就只返回x的值。
6.3.5. match必须穷尽
Rust要求match覆盖所有可能性,这样才能保证代码安全有效。
对上一段代码稍作修改:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = match y {
Some(value) => x + value,
};
}
输出:
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:5:21
|
5 | let sum = match y {
| ^ pattern `None` not covered
|
note: `Option<i8>` defined here
--> /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/option.rs:600:1
|
600 | pub enum Option<T> {
| ^^^^^^^^^^^^^^^^^^
...
604 | None,
| ---- not covered
= note: the matched value is of type `Option<i8>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
6 ~ Some(value) => x + value,
7 ~ None => todo!(),
|
Rust发现没有覆盖None这种可能性,所以报错。一旦补上处理None的分支,就没问题了。
如果可能性太多,或者你不想处理其中一些情况,可以使用通配符_。
6.3.6. 通配符
先照常写出你想处理的分支,其余情况用通配符_代替。
例如:v是一个u8变量,我们想判断v是否为0。
use rand::Rng; // 使用外部 crate
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
match v {
0 => println!("zero"),
_ => println!("not zero"),
}
}
u8有256种可能的值,用match自然不可能为每个值都写一个分支。因此可以为0写一个分支,其余情况用通配符_代替。
输出:
133
not zero
6.4 简单的控制流-if let
6.4.1. 什么是if let?
if let语法允许把if和let组合成一种更简洁的方式,用来处理只匹配一种模式的值,同时忽略其余模式。
可以把if let看作是match的语法糖,也就是只针对某一种特定模式来编写代码。
6.4.2. if let的实际应用
举个例子:v是一个u8变量。判断v是否为0,如果是就打印zero。
use rand::Rng; // 使用外部 crate
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
match v {
0 => println!("zero"),
_ => (),
}
}
这里只需要区分0和非0。在这种情况下,使用if let会更简单:
use rand::Rng; // 使用外部 crate
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
if let 0 = v {
println!("zero");
};
}
注意:if let用的是=而不是==。
小改一下上面的例子:v是一个u8变量。判断v是否为0;如果是就打印zero,否则打印not zero。
use rand::Rng; // 使用外部 crate
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
match v {
0 => println!("zero"),
_ => println!("not zero"),
}
}
这种情况下,只需要给if let加上else分支即可:
use rand::Rng; // 使用外部 crate
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
if let 0 = v {
println!("zero");
} else {
println!("not zero");
}
}
6.4.3. 使用if let的取舍
与match相比,if let代码更少、缩进更少、模板代码也更少。但if let放弃了穷尽性。
所以,使用if let还是match要根据实际需求来决定。这里存在简洁性与穷尽性之间的取舍。
6.4.4. if let与if的区别
很多初学者搞不清if let与if的区别,因为好像if let能做的,if也能做。但它们本质上不同:if let是模式匹配,而if是条件语句。
if后面的条件只能是布尔值,而if let是匹配是否符合某个具体模式,适合从枚举、Option、Result或其他支持模式匹配的类型中提取值。
例如:
fn main(){
let x = Some(5);
if let Some(value) = x {
println!("Found a value: {}", value);
} else {
println!("No value found");
}
}
if无法解包Option。要实现这样的效果,必须使用模式匹配(match和if let)。
7.1 Package、Crate和定义Module
7.1.1. Rust的代码组织
代码组织主要包括:
- 哪些细节可以对外暴露,而哪些细节是私有的
- 在作用域内哪些名称有效
- …
这些功能被统称为模块系统,模块系统中包含(顺序从大概念到小概念):
- Package(包):Cargo的特性,让你构建、测试和共享crate。可以理解为项目
- Crate(单元包):一个模块树,它可以产生一个library或可执行文件。
- Module(模块):它让你控制代码的组织、作用域和私有路径
- Path(路径):为struct、function或module等条目命名的方式
7.1.2. 包(Package)与单元包(Crate)
crate分为两种类型:
- binary(二进制):一个可以独立运行的可执行程序,必须包含一个 main 函数,作为程序的入口点。通常用于实现具体的应用程序或命令行工具。
- library(库):一个用于共享和重用的代码模块,不能直接运行。没有 main 函数,而是通过公开的函数或模块供其他代码调用。
crate root指的是源代码文件(也就是.rs文件),而且是入口文件(比如main.rs),Rust编译器会从这里开始构建crate的根Module。
一个Package包含:
- 一个Cargo.toml,它描述了如何构建这些Crates
- 要么有一个,要么就没有library crate
- 可以有任意数量的binary crate
- 但至少得有一个crate(不管是library还是binary)
7.1.3. Cargo的惯例
如果你打开本地Rust项目的Cargo.toml,就比如说我的:
[package]
name = "RustStudy"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8.5"
你会发现没有提到入口文件,这是因为Cargo默认把src/main.rs当作binary crate的crate root,crate的名与Package相同,也就是binary crate的名与包名相同都是RustStudy(toml文件第二行写了)。这是约定大于配置的思想。
假如说这个项目里(也可以说是Package里)在src目录下有lib.rs这么一个文件,这就是说这个Package包含一个library crate,而这个lib.rs就是library crate的crate root。而这个crate的名与package的名也是相同的,都是RustStudy。
Cargo会把crate root文件交给rustc来构建library或者binary。
刚刚提到过,一个Package里可以有很多个binary crate,这时可以把源代码文件(也就是.rs文件)放在src/bin这个目录下,这下面的每个文件都是单独的binary crate(单独的程序)。
7.1.4. Crate 的作用
crate的作用是将相关功能组合到一个作用域内,便于在项目间进行分享。同时也可以防止命名的冲突。比如生成随机数的这个rand crate,访问它的功能就需要通过它的名字rand。
7.1.5. 定义Module来控制作用域和私有性
Module是在一个crate里将代码进行分组,也就是分为若干个模块(Module)的功能,它可以增加代码的可读性,并且使功能易于复用。它可以控制条目(item)的私有性。控制它们是public(对外暴露)的还是private(私有)的。
建立module需要使用mod这个关键字,在后面写这个module的名字,在名字后边使用花括号。
其次,module是可以嵌套的,里面的就叫做子module,在module里可以包含其他项(struct、enum、常量、trait、函数等)的定义。
还是看个例子吧(在src目录下的lib.rs里写):
#![allow(unused)]
fn main() {
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}
}
在这个例子中,hosting和serving就是front_of_house的子module,front_of_house就被称为父module,而在这两个子module下还定义了好几个函数。
main.rs和lib.rs叫做crate roots。这两个文件的内容就会隐式形成名为crate的模块,位于整个模块树的根部(图中的最顶层)。下图就是刚刚那个lib.rs的模块树:
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
7.2 路径(Path)Pt.1:相对路径、绝对路径与pub关键字
7.2.1. 路径的简介
在Rust里,如果想要找到模块里的某个东西,就必须知道并使用它的路径。Rust中的路径就跟文件系统里面的路径是差不多的,与其他语言里的命名空间有点像。
路径一共有两种形式:
- 绝对路径:从crate根开始,使用crate名或字面值crate(看下面的例子就明白了)
- 相对路径:从当前模块开始,使用self(本身),super(上一级)或者当前模块的标识符
路径至少由一个标识符组成,标识符之间使用::连接。
7.2.2. 路径的使用
看个例子(lib.rs):
#![allow(unused)]
fn main() {
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
}
pub fn eat_at_restaurant(){
crate::front_of_house::hosting::add_to_waitlist();
front_of_house::hosting::add_to_waitlist();
}
}
hosting是front_of_house的子module,hosting下还定义了两个函数add_to_waitlist和seat_at_table。
在front_of_house的同一级中还声明了一个函数eat_at_restaurant,这个函数下就分别用绝对路径和相对路径调用了add_to_waitlist这个函数。
对于绝对路径,函数eat_at_restaurant与add_to_waitlist所在的front_of_house模块在同一个文件lib.rs里,也就是在同一个crate里(lib.rs的内容已经隐式地组成了crate这个模块,具体可以参考 7.1. Package、Crate和定义Module 最后一部分)。所以说绝对路径就是从crate开始写起,逐级地写,用::分开每级的标识符:
#![allow(unused)]
fn main() {
crate::front_of_house::hosting::add_to_waitlist();
}
对于相对路径,由于函数eat_at_restaurant与add_to_waitlist所在的front_of_house模块在同一级,所以就可以直接从模块名起手写,依然是逐级地写,用::分开每级的标识符:
#![allow(unused)]
fn main() {
front_of_house::hosting::add_to_waitlist();
}
在实际项目中,使用绝对路径还是相对路径主要取决于你定义条目的代码(例子中的add_to_waitlist)和使用条目的代码(例子中的eat_at_restaurant)会不会一起移动而决定。如果这两部分一起移动,也就是两者的相对路径不会变,那么就使用相对路径;反之则需要用绝对路径。但大部分情况还是使用绝对路径,因为这样定义条目的代码和使用条目的代码就可以彼此独立地进行移动。
接下来我们运行一下代码:
error[E0603]: module `hosting` is private
--> src/lib.rs:10:25
|
10 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^
error[E0603]: module `hosting` is private
--> src/lib.rs:11:18
|
11 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^
不管是绝对路径调用还是相对路径调用都报了这个错误。这个错误的意思是hosting模块是私有的。
刚好借着这个报错讲一下私有边界这个概念。
7.2.3. 私有边界(Privacy boundary)
模块的作用不仅是组织代码,还可以定义私有边界。如果想把函数或struct设为私有的就可以把它放到某个模块中,就像刚才那个例子中的函数一样,它就在hosting这个模块里。
Rust默认所有的条目(函数、方法、struct、enum、模块、常量等)都是私有的。而对于私有的条目来说,外部的代码就无法调用或者是依赖他们。Rust之所以这么规定是因为它希望这些内部细节默认隐藏来使程序员明确地知道修改哪些内部实现不会破坏外部的代码。
Rust的私有边界还有规则:父级模块无法访问子模块中的私有条目,依然是为了隐藏实现细节;在子模块里可以使用所有祖先模块中的条目,因为子模块就是定义于父模块以及其他祖先模块的上下文中。打个比方:爸爸不能看儿子日记,而儿子可以用爸爸的钱。
想要公有就需要在定义模块时加上pub关键字。
7.2.4. pub关键字
在mod关键字之前加上pub即可以把模块转为公有。在之前的代码例上稍作修改:
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
fn seat_at_table() {}
}
}
pub fn eat_at_restaurant(){
crate::front_of_house::hosting::add_to_waitlist();
front_of_house::hosting::add_to_waitlist();
}
}
注意:hosting这个模块和add_to_waitlist()这个函数的前面都需要加pub关键字
再进行编译,这下编译器没有报错。
有人可能会问:为什么front_of_house没有加pub是私有的但调用时没有报错呢?这是因为它是文件里的根级,而根级和根级之间是可以相互调用的,无论是私有的还是公有的。
7.3 路径(Path)Pt.2:访问父级模块、pub关键字在结构体和枚举类型上的使用
7.3.1. super
我们可以通过在路径开头使用super来访问父级模块路径中的内容,就像使用..语法启动文件系统路径。例如:
#![allow(unused)]
fn main() {
fn deliver_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order();
}
fn cook_order() {}
}
}
当然也可以用绝对路径实现同样的效果:
#![allow(unused)]
fn main() {
fn deliver_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
crate::deliver_order();
}
fn cook_order() {}
}
}
7.3.2. pub struct
把pub关键字加在struct前就可以把结构体声明为公共的,如下例:
#![allow(unused)]
fn main() {
mod back_of_house {
pub struct Breakfast {
toast: String,
seasonal_fruit: String,
}
}
}
需要注意的是,这个结构体虽然是公共的,但结构体中的字段默认是私有的,除非加上pub关键字。
在Rust里,绝大多数情况下如果某个东西没加pub,那就是私有的。(下文会讲到特例)
将字段设为公有也很简单。下面展示一下把Breakfast的toast改为公有后的代码:
#![allow(unused)]
fn main() {
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
}
}
我们再来看一个复杂点的代码例:
#![allow(unused)]
fn main() {
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}
pub fn eat_at_restaurant(){
let mut meal = back_of_house::Breakfast::summer("Rye");
meal.toast = String::from("Wheat");
}
}
- 在刚才的结构体之上,又构造了一个关联函数
summer,参数是字符串切片类型的toast,返回值是Breakfast类型,Breakfast.toast的值会是传进来的这个参数的值,Breakfast.seasonal_fruit的值则会被设为peaches。summer这个函数本质上是一个构造器,构造了Breakfast的实例。 - 在
eat_at_restaurant这个函数中先使用相对路径调用了summer这个构造器构造了一个实例,把它赋给了可变变量meal。而meal中的toast字段被设为了Rye,seasonal_fruit的值则是peaches(构造器中写的)。下一行中,因为toast字段是公共的,所以meal.toast可以直接被更改,这里是改为了Wheat。
在eat_at_restaurant这个函数中写下meal.seasonal_fruit = String::from("blueberries");这一行会不会报错呢?答案是会的,因为结构体中的字段默认是私有的,seasonal_fruit并没有被声明为公有,所以外部代码无法修改它,而这里这句话尝试进行修改,所以就会报错。
7.3.3. pub enum
跟struct一样,只要把pub关键字加上枚举类型也能变为公有的。如下例:
#![allow(unused)]
fn main() {
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}
pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}
}
但与struct不同,struct下的字段默认是私有的,而公共的枚举类型下的变体默认就是公共的,不需要把pub关键字加在变体之前。这一点和Rust默认私有的规则不一样,因为只有公共的枚举类型下是公共的变体它才有用,而struct下部分字段是私有的并不会影响它的使用。
但需要注意的是,枚举类型下的变体是公共的的前提条件是这个枚举类型被声明为公共的。
7.4 use关键字 Pt.1:use的使用与as关键字
7.4.1. use的作用
use的作用是将路径导入到当前作用域内。而引入的内容仍然是遵守私有性原则,也就是只有公共的部分引入进来才可以用。
7.4.2. use的使用
看个例子:
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
fn seat_at_table() { }
}
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}
这里先声明了一个front_of_house模块,在它里面又声明了公共的子模块hosting,在hosting下有两个函数——公共的add_to_waitlist和私有的seat_at_table。
然后使用use关键字把crate(也就是这整个文件)中的front_of_house模块下的子模块hosting引入到当前作用域。类似于文件系统中创建的文件链接,也有点类似于C++的using namespace。
这样引入之后hosting这个名在当前作用域内就可以直接使用了,就相当于hosting这个模块是在crate root下定义的。
在下文的eat_at_restaurant函数中,因为hosting已经被引入当前作用域了,所以调用add_to_waitlist函数时就不用从crate起手写绝对路径抑或是从front_of_house起手写相对路径,而是从hosting起手写就可以。
但需要注意的是,引入了作用域的模块仍然遵守私有性原则,所以seat_at_table函数仍然不可被调用。
use即可以使用绝对路径,也可以使用相对路径,比如上面例子中的:
#![allow(unused)]
fn main() {
use crate::front_of_house::hosting;
}
就可以被修改为:
#![allow(unused)]
fn main() {
use front_of_house::hosting;
}
但一般来说,使用绝对路径较多。
7.4.3. use的使用惯例
在上面的例子中,我们导入模块只到了hosting这一层,但调用的函数只有add_to_waitlist,能不能直接一步到位导入add_to_waitlist呢?实际上是可以的:
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
fn seat_at_table() { }
}
}
use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}
这样写也是没有问题的,但是不建议。
如果代码比较多,就不知道add_to_waitlist函数是在本地定义的还是在其他模块定义的。所以,针对函数,通常是引入到它的父模块,通过父模块来调用函数来表示它不是本地定义的。但是要注意引入到函数的上一级就可以,不用引入太多,否则重复的输入就太多了。
针对其他的条目,比如struct、枚举等等,一般都是指定完整路径(指定到本身),不用指定到父级。如下例:
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
使用标准库的collections模块下的HashMap这个结构体,就直接引入它本身。在用的时候就直接使用HashMap这个名,不带父级模块。
如果是同名条目,不论是不是函数,都指定到父级以做区分。如下例:
use std::fmt;
use std::io;
fn f1() -> fmt::Result { }
fn f2() -> io::Result { }
fn main() { }
在这个例子中(不考虑编译问题,只是作为演示例),我既需要fmt下的Result,也需要io下的Result,所以说在引入时就得引入到父级fmt和io。
如果不想这么写,也可以使用as关键字。
7.4.4. as关键字
as关键字可以为引入的路径指定本地的别名。比如说我们修改一下上边的例子:
use std::fmt::Result;
use std::io::Result as IoResult;
fn f1() -> Result { }
fn f2() -> IoResult { }
fn main() { }
这样就不用声明到父级,而是直接声明到本身。
7.5 use关键字 Pt.2 :重导出与换国内镜像源教程
7.5.1. 使用pub use重新导出名称
使用use将路径导入作用域内后,该名称在词法作用域内是私有的。
以 7.4. use关键字 Pt.1:use的使用与as关键字 的代码为例:
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
fn seat_at_table() { }
}
}
use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}
对于外部代码来说,eat_at_restaurant是可以访问到的,因为它在声明时使用了pub关键字,但eat_at_restaurant下使用的add_to_waitlist外部代码是看不见的,因为use引入默认是私有的。如果想要外部代码也能访问到,就需要在use前增加pub关键字:
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
fn seat_at_table() { }
}
}
pub use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}
这样子就可以让外部代码访问到use引入的条目。
当我们想要对外暴露代码的时候,我们可以使用这种技术,不按照内部代码的结构,而是做一些调整来对外进行暴露。这样代码内部的结构和外边看到的可能就会有点不一样。毕竟写代码的人和调用代码的人他们所期望的东西通常是不一样的。
最后总结一下:pub use重导出既可以将该条目引入作用域,也可以使该条目被外部代码引入到它们的作用域。
7.5.2. 使用外部的包(package)
首先要在Cargo.toml里添加依赖项的包(package)名与版本,而Cargo会从crates.io这个网站上下载这个包和这个包的依赖项到本地(也可以用野生的crate,去GitHub找,但非常不建议这么做)。然后就是在代码里使用use将特定条目引入到作用域。
还记得 2.2. 猜数游戏 Pt.2 生成随机数 吗?那时候我们需要rand包来生成随机数,现在我们还是以引入rand包来举例:
Step 1:修改Cargo.toml
打开项目的Cargo.toml文件,在[dependencies]下写上包名和版本,中间用=连接,如下:
[package]
name = "RustStudy"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8.5"
Step 2:在源代码中引入包
你想用包下的什么东西就用use指定对应的路径来引入即可。这里我需要生成随机数的方法,所以要把Rng这个 trait 引入作用域,引入这行的代码如下:
#![allow(unused)]
fn main() {
use rand::Rng;
}
Rust语言的标准库std也被当作是外部的包,但是它已经内置在Rust语言内了,所以就不需要在Cargo.toml里增加依赖项了,直接在源代码中用use引入就行,这有点类似于Python中的re、os、ctype这类库。
比如说我们想要引入std下的collections模块的HashMap这个结构体,就应该写:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
}
但不用修改Cargo.toml。
7.5.3. 使用嵌套路径清理大量的use语句
有的时候使用同一个包或模块下的多个条目,前面部分都是一样的,但是还是得写几遍,占用几行,如果引入的东西比较多,需要写很多遍,根本不现实,所以Rust允许使用嵌套路径在同一行内来简化引入的代码。类似于bash的花括号展开特性。
其格式如下:
#![allow(unused)]
fn main() {
use 同样的部分::{不同的部分1, 不同的部分2, ...}
}
看个例子:
#![allow(unused)]
fn main() {
use std::cmp::Ordering;
use std::io;
}
它们有公共的部分std,所以就可以用嵌套路径写为:
#![allow(unused)]
fn main() {
use std::{cmp::Ordering, io};
}
如果其中一个引用是另外一个引用的子路径,Rust还允许在使用嵌套路径时使用self关键字,如下例:
#![allow(unused)]
fn main() {
use std::io;
use std::io::Write;
}
这部分就可以简写为:
#![allow(unused)]
fn main() {
use std::io::{self, Write};
}
7.5.4. 通配符*
使用*可以把路径中所有的公共条目都引入到作用域。比如我想把std库下collections模块所有的公共条目都引入进去,就可以这么写:
#![allow(unused)]
fn main() {
use std::collections::*;
}
但是这种引入要非常谨慎的使用,通常不这样用。
它的应用场景是:
- 在测试的时候把所有被测试的代码引入到
test模块 - 有时候被用于预导入(prelude)模块
7.5.5. 给Rust依赖项下载换源
由于crates.io的网站在国外,所以国内下载很慢,可以换成清华大学镜像。
注意:Cargo 读取的配置文件在用户目录下的 .cargo 里,而不是项目根目录。Windows 上默认路径是 %USERPROFILE%\.cargo\config.toml(也就是用户文件夹下的 .cargo\config.toml)。
打开Windows Terminal(Win11自带,Win10需要去微软商店里下载,不花钱),先确保 .cargo 目录存在,然后进入该目录:
mkdir %USERPROFILE%\.cargo
cd %USERPROFILE%\.cargo
如果还没有配置文件,可以新建一个(文件名推荐 config.toml;如果文件已经存在,不要用下面这条命令覆盖,直接编辑即可):
type nul > config.toml
编辑它:输入如下指令,回车:
vim config.toml
把这段贴进去(清华大学当前推荐的稀疏索引写法;需要 Cargo 1.68 及以上):
[source.crates-io]
replace-with = 'tuna'
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
把光标(不是鼠标指针!)下移,从
移到
然后输入
:wq
再按回车就会保存。
然后再重新build你的项目就可以。
7.6 将模块拆分为不同文件
7.6.1. 将模块的内容移动到其他文件
如果在模块定义时模块名后边跟的是;而不是代码块,Rust就会在src目录下找与模块同名的.rs文件加载其中的内容。无论模块的内容是在同一个文件里面还是在不同的文件里面,模块树的结构都不会发生变化。
来看一个例子(lib.rs):
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
}
}
pub use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}
这样写就是把所有模块放在同一个文件里。如果要把它放在不同的文件里,就要这么写:
Step 1:新建文件
假如要把front_of_house分出去,就需要在src目录下创建同名的.rs文件:

Step 2:剪切代码
把原本在front_of_house下的代码从原位置剪切到这个front_of_house.rs这个文件里,也就是把这一段剪切走:
#![allow(unused)]
fn main() {
pub mod hosting {
pub fn add_to_waitlist() { }
}
}

Step 3:修改原处
打开front_of_house原来定义的地方(lib.rs)。这个时候就不用后面的代码块了,把它连着{}都删去,加上;即可(其它的无关代码不要动),原本代码是(lib.rs):
#![allow(unused)]
fn main() {
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() { }
}
}
pub use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}
改成(lib.rs):
#![allow(unused)]
fn main() {
mod front_of_house;
pub use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
}

7.6.2. 子模块的拆分
如果front_of_house下面有很多模块怎么办?这时就需要把这些子模块放到不同的文件里,以便更好地组织代码。但该怎么做呢?像刚才那样,把所有子模块都放在src目录下吗?那样src里文件会太多,而且模块之间的层级关系也体现不出来。
Rust给出了一个不错的方案:把所有子模块文件放在以父模块命名的文件夹里。具体来说,需要先创建一个与父模块同名的文件夹,然后在该文件夹内用.rs文件来存放子模块或条目。
举个例子,如果我要把hosting独立出去成一个单独的文件,操作不仅仅是在src下创建一个同名.rs文件,而是需要先新建一个父模块的同名文件夹,在这个例子中父模块的名字是front_of_house,所以就要创建名字为front_of_house的文件夹。
然后再在这个文件夹下创建与条目名/模块名相同的.rs文件,在这个例子中是要把hosting独立出去,所以这个文件应该叫做hosting.rs。

在hosting.rs里存储hosting的内容,也就是:
#![allow(unused)]
fn main() {
pub fn add_to_waitlist() { }
}
现在可以像之前处理lib.rs那样,把front_of_house.rs里hosting模块的代码块连同{}删掉,并加上;。把它从(front_of_house.rs):
#![allow(unused)]
fn main() {
pub mod hosting {
pub fn add_to_waitlist() { }
}
}
改成简单的:
#![allow(unused)]
fn main() {
pub mod hosting;
}

Rust也支持以module_name/mod.rs的形式拆分模块。所有模块内容都存放在mod.rs中,文件夹名即表示模块名。这种方式在Rust中仍然完全受支持,但在现代Rust代码中,它通常更像是旧式模块布局的延续,而不是默认偏好。
如果用这种方式拆分模块,看起来会是这样:

7.6.3. 拆分的优点
随着模块变大,该技术让程序员可以把模块的内容移动到其他文件中。
8.1 向量Vector
8.1.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector(本文)、String 和 HashMap。
8.1.1. 使用 Vector 存储多个值
Vector 这个类型的写法是 Vec<T>,其中 T 代表泛型类型参数,在实际使用时替换成自己需要的数据类型即可。
Vector 由标准库提供,在 Vector 里可以存储多个相同类型的值,它们在内存中是连续存放的。可以把它视为可扩展的数组。
创建 Vector 可以使用 Vec::new 这个函数,我们看个例子:
fn main() {
let v: Vec<i32> = Vec::new();
let v = vec![1, 2, 3];
let v = Vec::with_capacity(10);
}
let v: Vec<i32> = Vec::new(): 使用Vec::new声明了一个元素类型为i32的Vector(常用)。let v = vec![1, 2, 3]: 使用vec!宏创建带初始值的Vector。这里在声明时已经把1, 2, 3填进了向量。当然只使用vec![]不填内容也是可以的(常用)。let v = Vec::with_capacity(10): 创建空 Vector,但提前分配至少 10 个元素的容量。适合你已经知道大概要放多少元素,能减少扩容次数,提高性能。
第一种方法(Vec::new())需要显式声明类型为 Vec<i32>,是因为 Vec::new() 创建的是一个空的 Vector,里面没有元素;又因为这个例子里没有前后文供 Rust 推断,所以 Rust 就推断不出来元素类型,就会报错。如果有前后文供 Rust 推断,Rust 就能够自行判断 Vector 里的元素类型。
第二种方法(vec![])就不需要显式声明元素类型了,因为 Rust 编译器根据初始值推断出了元素类型是 i32。
8.1.2. 更新 Vector
1. 添加元素
向 Vector 末尾添加元素使用 push 方法。如下例:
fn main() {
let mut v = Vec::new();
v.push(1);
}
- 注意:向
Vector里添加元素的前提是这个Vector是可变变量,所以在声明的时候需要mut关键字。 - 这里的
let mut v = Vec::new();也没有显式声明元素类型,但是 Rust 编译器通过下文向Vector里添加1的操作推断出了元素类型是i32。
还有其他的一些添加元素的方法:
fn main() {
let mut v = Vec::new();
v.extend([1, 2, 3]); // 批量插入
v.insert(1, 99); // 在指定下标插入(越界会 panic)
let mut a = vec![1, 2, 3];
let mut b = vec![4, 5, 6];
a.append(&mut b); // 把 `b` 的所有元素移动到 `a`(`b` 会变空)
}
2. 从 Vector 中删除元素
pop():删除尾部元素,并返回被 Option 包裹的值(在 6.2. Option 枚举 中讲过)。如果为空,返回 None。
fn main() {
let mut v = vec![1, 2, 3];
let x = v.pop(); // 返回 Some(3)
}
remove(index):删除指定位置的元素,并返回它。后面的元素会前移。index 越界会 panic。
fn main() {
let mut v = vec![1, 2, 3];
let x = v.remove(1); // 返回 2(v 变为 [1, 3])
}
clear():清空所有元素。长度变成 0,但容量仍然保留。
fn main() {
let mut v = vec![1, 2, 3];
v.clear(); // v 现在是 []
}
与任何其他的 struct 结构体一样,当 Vector 离开作用域后,它和它里面的元素就自然会被清理掉。
3. 读取 Vector 的元素
一共有两种方式可以访问 Vector 里面的值:使用索引,或者使用 get 方法。如下例:一个 Vector 里面存有 [1, 2, 3, 4, 5],访问并打印出第三个元素。
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third = &v[2]; // 索引
println!("The third element is {}", third);
match v.get(2) { // get 方法加 match
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
};
}
let third = &v[2];:使用索引访问位置 2 的元素(第三个元素)。前面的&表示引用。v.get(2):使用get方法来读取。由于返回值是Option类型,所以要使用match(在 6.3. 控制流运算符-match 中讲过)来解包。如果能取到值,就会绑定给third并打印;如果不能(None),就会打印 “There is no third element.”。
这两种方法效果一样,但对非法访问(例如索引越界)的处理不同。
先试试使用索引(非法访问):
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third = &v[100]; // 索引 100 越界了
println!("The third element is {}", third);
}
输出:
thread 'main' panicked at src/main.rs:3:19:
index out of bounds: the len is 5 but the index is 100
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
程序触发了 panic!,终止执行。
再试试使用 get(非法访问):
fn main() {
let v = vec![1, 2, 3, 4, 5];
match v.get(100) { // 索引 100 越界了
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
};
}
输出:
There is no third element.
因为 get 不能从索引 100 上获取东西,所以它就会返回 None。
使用建议:遇到越界时想直接触发 panic! 结束程序,就用索引;其余情况优先用 get 做安全处理。
8.1.3. 所有权和借用规则
还记得在 4.2. 所有权规则、内存与分配 中讲的借用规则吗?同一个作用域内不能同时有可变和不可变引用。这个规则在 Vector 依然适用。看个例子:
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is {}", first);
}
输出:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("The first element is {}", first);
| ----- immutable borrow later used here
push函数的签名是&mut self, value: T,&mut表示push会把传进来的变量作为可变引用来处理。在例子中就是v在这里有一个可变引用。let first = &v[0];这里的first是v的不可变引用,两者又在同一个作用域下,所以会报错。println!会把传进去的变量作为不可变引用。
在这个作用域内同时出现了可变和不可变引用,所以程序会报错。
但有人可能会疑惑——push 是往 Vector 的后面加东西,前面的元素不会受影响,为什么 Rust 要搞这么麻烦的设计?
这是因为在内存中 Vector 的元素是连续存储的。如果往后面加一个元素,正好又有东西占用了后面的内存,腾不出地方放新的元素,系统就得重新分配内存,找个足够大的地方来放置添加了元素之后的 Vector。这样的话,原来的那块内存就会被释放或者重新分配掉,但引用仍然会指向原先的内存地址,造成悬空引用(在 4.4. 引用与借用 中有讲)。
8.1.4. 遍历 Vector 里的值
使用 for 循环是最常见的方法。如下例:
fn main() {
let v = vec![1, 2, 3, 4, 5];
for i in &v {
println!("{}", i);
}
}
输出:
1
2
3
4
5
当然,如果想要在循环里修改元素也是可以的,只需要把 v 声明成可变的,把 &v 改成 &mut v 即可:
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
for i in &mut v {
*i += 10;
}
for i in v {
println!("{}", i);
}
}
注意:第四行的 *i 前面之所以有个 *,是因为 i 本质上是 &mut i32 类型,存储的是指针而不是实际的 i32 值,需要先解引用,使 i 变为 mut i32 值,才能进行加减操作。
输出:
11
12
13
14
15
8.2 Vector + Enum的应用
8.2.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String 和 HashMap。
8.2.1. Vector 和 enum 如何互补
虽然 Vector 可以动态地变大或变小,但是它里面元素的数据类型仍然必须相同。但有的时候我们需要在堆内存上存储不同类型的数据,那这种情况怎么办呢?
还记得 6.1. 定义枚举 中介绍的枚举类型吗?枚举的变体可以附加数据,而且这些附加的数据可以是不同类型。最主要的是,枚举类型的变体都定义在同一个枚举类型下,也就是说所有的变体都是同一个类型,因此可以被存储到 Vector 中。
这样就可以通过枚举,让 Vector 能够存储不同数据类型的数据。
8.2.2. Vector + enum
来看一个实际使用 Vector + 枚举类型的例子:
enum SpreadSheetCell {
Int(i32),
Float(f64),
Text(String),
}
fn main() {
let row = vec![
SpreadSheetCell::Int(5567),
SpreadSheetCell::Text("up up".to_string()),
SpreadSheetCell::Float(114.514),
];
}
这个例子模拟了 Excel 单元格的行为:单元格内只能存储整数、浮点数或字符串其中之一。所以我们定义了 SpreadSheetCell 这个枚举类型,拥有 3 个变体,分别用于存储整数(Int)、浮点数(Float)和字符串(Text)。
在 main 函数中,声明了变量 row 用于存储一行的单元格。因为一行的单元格数量不确定,所以需要 Vector 来存储。在这个例子里初始化时有三个单元格:第一个存储整数 5567,第二个放了字符串 "up up",第三个放了浮点数 114.514。
通过这个例子,我们可以看到:通过使用可附加数据的枚举类型,就可以变相地在 Vector 里存放不同类型的数据。
那么 Rust 为什么在编译时就需要知道 Vector 里元素的类型呢?因为这样 Rust 才能确定堆内存上到底需要多少内存来容纳这个 Vector。除此之外,如果允许在 Vector 上存储不同类型的元素,那么在对元素进行批量操作时,有些操作可能对某些类型合法、对另一些类型不合法,程序就会出错。而这种枚举类型配合 match 表达式的方式,使得 Rust 能在编译时提前知晓所有可能情况,在运行时就可以正确处理了。
在这个例子上,Vector 确实实现了存储不同的数据类型,但前提是我们必须确切知道可能有哪些数据类型(也就是集合是详尽的)。否则,如果这个类型有无限种可能(或者说不详尽),那么使用枚举也没有办法,连枚举都定义不出来。针对这种情况,Rust 提供了 trait,但这个得等到后面再讲。
8.3 String类型 Pt.1:字符串的创建、更新与拼接
8.3.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String(本文) 和 HashMap。
8.3.1. 为什么字符串会困扰 Rust 开发者
Rust 开发者(尤其是新手)经常会被字符串困扰,原因如下:
- Rust 倾向于暴露可能的错误
- 字符串数据结构复杂
- Rust 字符串使用了
UTF-8编码
8.3.2. 字符串是什么
字符串是基于字节的集合,并且它提供了一些方法,这些方法能将字节解析为文本。
在 Rust 的核心语言层面,只有一个字符串类型——字符串切片 str,通常是以借用的形式出现的,也就是 &str。
字符串切片是对存储在其他地方、采用 UTF-8 编码的字符串的引用。例如字符串字面值就是直接存储在 Rust 的二进制文件中,所以它也是一种字符串切片。
String 类型来自于标准库,而不是核心语言。 它是一种可增长、可变、可拥有所有权的类型,同样采用 UTF-8 编码。
8.3.3. “字符串”到底是指谁?
通常说的“字符串”就是指 String 和 &str 这两种类型,而不是其中的一种。这两种类型在标准库里都用得非常频繁,也都使用了 UTF-8 编码,但这里主要还是讲 String 类型,因为它更复杂。
8.3.4. 其他的字符串类型
Rust 标准库还提供了其他的字符串类型,例如:OsString、OsStr、CString、CStr。注意这些类型都以 String 或者 Str 结尾,这与前面提到的 String 和字符串切片这两种类型的命名方式有关。
通常来说,以 String 结尾的字符串类型是可拥有所有权的,以 Str 结尾的类型通常是可借用的。
这些不同的字符串类型可以存储不同编码的文本,或以不同的内存布局来表示数据。
某些 library crate 针对字符串提供了更多选项,这里就不介绍了。
8.3.5. 创建一个新的字符串
由于 String 类型的本质是字节的集合,所以很多 Vec<T> 的操作都可以用于 String。
String::new() 可以用来创建一个空的字符串。看个例子:
fn main(){
let mut s = String::new();
}
但一般而言,都是使用初始值来创建 String。这时可以使用 to_string 方法来创建 String。这个方法可用于实现了 Display trait 的类型,包括字符串字面值。如下例:
fn main() {
let data = "wjq";
let s = data.to_string();
let s1 = "wjq".to_string();
}
data 是一个字符串字面值,使用 to_string 把它转为 String 类型,存储在 s 里。也可以直接写字符串字面值,然后调用 .to_string(),也就是给 s1 赋值的操作。这两个操作效果相同。
to_string 也不是唯一的方法,另一种方式是使用 String::from 函数:
#![allow(unused)]
fn main() {
let s = String::from("wjq");
}
这个函数和 to_string 方法的效果是一样的。
由于字符串用得非常多,所以 Rust 提供了很多不同的通用 API 供我们选择。有些函数可能看着很多余,但实际上它们都有各自的用处。在实际编码时可以根据喜好来选择。
8.3.6. 更新 String
之前提到了,String 类型的大小是可以增减的。由于其本质是字节的集合,里面的内容也可以修改。它的操作和 Vector 类似,此外还可以对 String 进行拼接。
1. push_str()
首先讲 push_str(),它是一个把字符串切片附加到 String 的方法。如下例:
fn main() {
let mut s = String::from("6657");
s.push_str("up up");
println!("{}", s);
}
输出:
6657up up
push_str 的签名是 push_str(&mut self, string: &str),它的参数是借用的字符串切片,而字符串字面值就是切片,所以 "up up" 可以传进去。并且这个方法不会获得参数的所有权,所以传进去的值仍然有效,还能继续使用。
2. push
第二个方法叫 push(),它能把单个字符附加到 String 里面。如下例:
fn main() {
let mut s = String::from("665");
s.push('7');
println!("{}", s);
}
注意:字符得使用单引号。
输出:
6657
3. +
Rust 允许使用 + 来拼接字符串。如下例:
fn main() {
let s1 = String::from("6657");
let s2 = String::from("up up");
let s3 = s1 + &s2;
println!("{}", s3);
}
注意:加号前必须是 String 类型,加号后必须是字符串切片。
但在这个例子中,加号后的实际类型是 &String 而不是 &str。这是因为这里 Rust 使用了解引用强制转换(deref coercion),把 &String 强制转换为 &str。
当然,因为 s2 传进去的是引用,所以 s2 在拼接后仍然有效;而 s1 是把本身的所有权交给了 s3,所以 s1 在拼接后就无效了。
输出:
6657up up
4. format!
format! 这个宏可以更加灵活地拼接字符串。如下例:
fn main() {
let s1 = String::from("cn");
let s2 = String::from("Niko");
let s3 = String::from("fan club");
let s = format!("{} {} {}", s1, s2, s3);
println!("{}", s);
}
它使用占位符来代替变量,这点和 println! 很像。区别在于:println! 会把结果打印出来,而 format! 则返回拼接好的字符串。
输出:
cn Niko fan club
当然使用 + 也能实现一样的效果,只不过写起来稍微麻烦一些:
fn main() {
let s1 = String::from("cn");
let s2 = String::from("Niko");
let s3 = String::from("fan club");
let s = s1 + " " + &s2 + " " + &s3;
println!("{}", s);
}
format! 最好的一点是它不会取得任何参数的所有权,这些参数在后续都可以继续使用。
8.4 String类型 Pt.2:字节、标量值、字形簇以及字符串的各类操作
8.4.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String(本文) 和 HashMap。
8.4.1. 不能使用索引来访问 String
Rust 中的 String 不同于其他语言:不能用索引访问。如下例:
fn main() {
let s = String::from("6657 up up");
let a = s[0];
}
输出:
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:15
|
3 | let a = s[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= note: required for `String` to implement `Index<{integer}>`
报错说明:String 类型无法使用整数来进行索引。继续往下看可以看到,String 没有实现 Index<{integer}> 这个 trait。
8.4.2. String 类型的内部表示
String 是对 Vec<u8> 的包装,其中 u8 表示字节。我们可以通过 String 上的 len() 方法来返回字符串的长度。如下例:
fn main() {
let len = String::from("Niko").len();
println!("{}", len);
}
输出:
4
这个字符串采用的是 UTF-8 编码,len 的值为 4,也就是这个字符串占了 4 个字节。所以在这个例子里,每个字母占用了一个字节。
但情况并不总是这样。比如说我们把字符串换成其他语言(这里是西里尔字母写的俄语):
fn main() {
let hello = String::from("Здравствуйте");
println!("{}", hello.len());
}
如果你数一下这个字符串,会发现有 12 个字母,但是输出却是:
24
也就是说在这个语言里面一个字母会占用两个字节(中文是一个汉字占三个字节)。这里所说的“字母”,用专业术语表示就是 Unicode 标量值,而这里的每个西里尔字母都对应两个字节。
通过这个例子你可以发现:对 String 做数字索引,并不总能对应到一个完整的 Unicode 标量值,因为有的标量值会占不止一个字节,而数字索引一次只能读到一个字节。
再举个例子:西里尔字母 З 对应两个字节,这两个字节的值分别是 208 和 151。假如数字索引是允许的,那么取 Здравствуйте 的索引 0 就会得到 208,而单独的 208 是没有意义的,因为它缺少组成一个 Unicode 标量值所需的第二个字节。所以为了避免这种无法立即发现的 bug,Rust 禁止了对 String 使用数字索引,从而在开发早期就杜绝可能的误解。
8.4.3. 字节、标量值、字形簇
Rust 中有三种看待字符串的方式:字节、标量值和字形簇。其中字形簇最接近我们通常所说的“字母”。
1. 字节
看个例子:
fn main() {
let s = String::from("नमस्ते"); // 天城文书写的印地语
for b in s.bytes() {
print!("{} ", b);
}
}
这段天城文字符串看起来好像有 4 个字母。我们使用 .bytes() 方法来获得它所对应的字节。输出如下:
224 164 168 224 164 174 224 164 184 224 165 141 224 164 164 224 165 135
这 18 个字节就是计算机存储该字符串的样子。
2. 标量值
我们再来以 Unicode 标量值的形式来看待它:
fn main() {
let s = String::from("नमस्ते");
for b in s.chars() {
print!("{} ", b);
}
}
使用 .chars() 方法能够获得这段字符串所对应的标量值。 输出如下:
न म स ् त े
它有 6 个标量值,其中有些是组合标记,而不是独立的字母。它们只有和前面的字符组合在一起才有意义。
这也解释了为什么这段天城文字符串占 18 个字节:6 个标量值每个占 3 个字节,6 × 3 = 18 个字节。
3. 字形簇
因为从 String 里获得字形簇很复杂,所以 Rust 标准库没有提供这个功能,这里也就不做演示;但你可以从 crates.io 找第三方 crate 来实现这个功能。
总之,如果这串字符串以字形簇的形式打印出来,会是这样:

8.4.4. 为什么 String 不能被索引
- 数字索引取出来的值可能并不完整,无法组成一个完整的 Unicode 标量值,从而导致无法第一时间察觉的错误。
- 索引操作理应消耗常量时间,也就是
O(1),而String无法保证这一点:要找到第 n 个字符,必须从字符串开头向后扫描,因为每个字符占用的字节数可变。
8.4.5. 切割 String
可以使用 [],在里面填上范围来创建字符串切片。关于字符串切片的详细内容,见 4.5. 切片(Slice)。如下例:
fn main() {
let hello = String::from("Здравствуйте");
let s = &hello[0..4];
println!("{}", s);
}
刚才也说了,一个西里尔字母占两个字节。这里的字符串切片切的是字符串的前 4 个字节,也就是前两个字母。输出是:
Зд
那如果字符串切片切的是前三个字节呢?也就意味着切片的内容会是第一个字母加上半个第二个字母。这种情况会怎么样呢?看下面的例子:
fn main() {
let hello = String::from("Здравствуйте");
let s = &hello[0..3];
println!("{}", s);
}
输出:
thread 'main' panicked at src/main.rs:3:19:
end byte index 3 is not a char boundary; it is inside 'д' (bytes 2..4 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
程序触发了 panic!,错误信息是:结束字节索引 3 不是一个 char 边界。也就是说,切割时必须沿着 char 的边界来切割;对于西里尔字母来说,就是按两个字节为单位切割。
8.4.6. 遍历 String
- 对于标量值,使用
.chars()方法。如下例:
fn main() {
let s = String::from("नमस्ते");
for b in s.chars() {
print!("{} ", b);
}
}
- 对于字节,使用
.bytes()方法。如下例:
fn main() {
let s = String::from("नमस्ते");
for b in s.bytes() {
print!("{} ", b);
}
}
- 对于字形簇,标准库未提供方法,但可以使用第三方 crate。
8.5 HashMap Pt.1:HashMap的定义、创建、合并与访问
8.5.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String 和 HashMap(本文)。
8.5.1. 什么是 HashMap
HashMap 的形式是 HashMap<K, V>,其中 K 代表键(key),V 代表值(value)。HashMap 以键值对的形式存储数据,一个键对应一个值。很多语言都支持这样的集合数据结构,但叫法不一定相同——例如 C# 中相同概念的数据结构叫字典(dictionary)。
HashMap 的内部实现使用了哈希函数,这个函数决定了如何在内存中存储键与值。
在 Vector 中我们使用索引来访问数据,但有的时候你想通过键来查找数据(键可以是任何实现了 Eq 和 Hash trait 的类型),而不是通过索引,或者说你不清楚这个数据在哪个索引上。这种情况就可以使用 HashMap。
需要注意的是,HashMap 是同构的,也就是说在一个 HashMap 中,所有的键必须是同一类型,所有的值必须是同一类型。
8.5.2. 创建 HashMap
- 由于
HashMap不常用,所以 Rust 并没有把它放进预导入模块(prelude)。使用前需要引入HashMap,在文件开头写上:use std::collections::HashMap; - 创建空的
HashMap使用HashMap::new()函数 - 添加数据使用
insert()方法
看个例子:
use std::collections::HashMap;
fn main() {
let mut scores: HashMap<String, i32> = HashMap::new();
}
在这里创建了一个名为 scores 的变量来存储 HashMap。由于 Rust 是强类型语言,它必须知道你在 HashMap 里存储什么数据类型。又因为没有前后文可供编译器推断,所以在声明时就必须把键和值的数据类型显式声明出来。在这段代码中,scores 的键被设为 String,值被设为 i32。
当然,如果你在后文给这个 HashMap 添加了数据,Rust 就会根据插入的数据自动推断键和值的数据类型。添加数据使用 insert() 方法。例子如下:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
}
因为在第 5 行往 scores 里插入了键值对,且键 String::from("dev1ce") 是 String 类型,值 0 是 i32(Rust 默认整数类型是 i32),所以编译器就会推断出 scores 是一个 HashMap<String, i32>,因此第四行在声明时就不需要显式声明类型了。
8.5.3. 将两个 Vector 合为一个 HashMap
在元素类型为元组的 Vector 上,可以使用 collect 方法来构建 HashMap。换个说法:假如你有两个 Vector,这两个 Vector 上的所有值都有一一对应关系,就可以使用 collect,把一个 Vector 里的数据作为键,另一个作为值,放到 HashMap 里。如下例:
use std::collections::HashMap;
fn main() {
let player = vec![String::from("dev1ce"), String::from("Zywoo")];
let initial_scores = vec![0, 100];
let scores: HashMap<_, _> = player.into_iter().zip(initial_scores).collect();
}
player这个Vector用来存储选手名字,里面的元素是String类型。initial_scores这个Vector用来存储每个选手对应的得分。player.into_iter()以及传给zip的initial_scores(也会变成迭代器)会从这两个Vector中取出拥有所有权的值。使用.zip()可以创建“player中元素在前、initial_scores中元素在后”的元组序列。如果想交换元素位置,把代码中的两个迭代器互换位置即可。然后再使用.collect()把这些元组转换成HashMap。因为使用了into_iter,HashMap会获得这些值的所有权(原来的两个Vector会被消耗掉)。- 最后要注意的一点是:
.collect()支持转换为很多数据结构。如果写代码时不显式声明其类型,程序就会报错。这里指明了类型是HashMap<_, _>。<>中的两个数据类型,编译器可以根据代码(也就是两个Vector的类型)来推断,所以这里可以写_占位符让它自行推断。
8.5.4. HashMap 和所有权
对于实现了 Copy trait 的数据类型(例如 i32 以及绝大多数简单数据类型),值会被复制到 HashMap 中,原先的变量仍然可用。对于没有实现 Copy 的类型(例如 String),所有权会被移交给 HashMap。
如果将引用插入到 HashMap,值本身就不会移动。在 HashMap 的有效期间,被引用的值必须保持有效。
8.5.5. 访问 HashMap 中的值
访问值可以使用 get 方法。get 方法的参数是 HashMap 的键,返回值是 Option<&V> 枚举。看个例子:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
scores.insert(String::from("Zywoo"), 100);
let player_name = String::from("dev1ce");
let score = scores.get(&player_name);
match score {
Some(score) => println!("{}", score),
None => println!("Player not found"),
};
}
- 首先创建了一个空的
HashMap叫做scores,然后通过insert往里面添加了两个键值对("dev1ce", 0)和("Zywoo", 100),键类型是String,值类型是i32。 - 然后声明了名为
player_name的String变量,其值为"dev1ce"。 - 接着通过
HashMap上的get方法,在scores中查找player_name这个键所对应的值(&表示引用)。但由于get返回的是Option枚举,所以这里先把这个Option值赋给score,后面再来解包。 - 最后使用
match表达式来处理score。如果找到了对应的值,score就是Some变体,把Some关联的值绑定到score上,然后再打印出来。如果找不到,score就是None变体,这时就会打印"Player not found"。
输出:
0
8.5.6. 遍历 HashMap
遍历 HashMap 一般使用 for 循环。如下例:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
scores.insert(String::from("Zywoo"), 100);
for (k, v) in &scores {
println!("{}: {}", k, v);
}
}
这个 for 循环使用的是 HashMap 的引用,也就是 &scores,因为通常遍历之后还要继续使用这个 HashMap,所以使用引用就不会失去所有权。前面的 (k, v) 是模式匹配:第一个值是键,赋给 k;第二个是值,赋给 v。
一种可能的输出如下(遍历顺序是任意的,不同运行之间可能不同):
dev1ce: 0
Zywoo: 100
8.6 HashMap Pt.2:更新HashMap
8.6.0. 本章内容
第八章主要讲的是 Rust 中常见的集合。Rust 提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String 和 HashMap(本文)。
8.6.1. 更新 HashMap
HashMap 的大小可变,指的是其中的键值对数量可变。但是在任意时刻,一个键只能对应一个值。当想要更新 HashMap 中的数据时,可能有这么几种情况:
-
想要更新的键在
HashMap中已经存在对应的值:- 用新的值替换现有的值
- 保留现有的值,忽略新的值
- 合并现有的值和新的值,也就是说对现有的值进行修改
-
键不存在:添加一对键和值
1. 替换现有的值
如果向 HashMap 插入一对键值对,但键已经存在,程序就会把新值赋给这个键,覆盖旧值。如下例:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
scores.insert(String::from("dev1ce"), 60);
println!("{:?}", scores);
}
这里为同一个键赋了两次值:第一次是 0,第二次是 60。第一次的值会被第二次覆盖,也就是说最终 "dev1ce" 对应的值是 60。
输出:
{"dev1ce": 60}
2. 只在键不存在值时才插入
这是最常见的情况。对于这种情况,首先需要检查原 HashMap 中是否已经存在这个键;如果不存在,再插入新值。
Rust 提供了 entry 方法来检查原 HashMap 中是否已经存在这个键。它的参数是键,返回值是一个 Entry 枚举,表示值是否存在。看个例子:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
let e = scores.entry(String::from("dev1ce"));
println!("{:?}", e);
}
这是键已经存在的情况。输出:
Entry(OccupiedEntry { key: "dev1ce", value: 0, .. })
也就是说,如果键已经存在,那么 entry 方法会返回一个占用中的 entry(OccupiedEntry),并关联已经存在的键值对。
再试一下键不存在的情况。代码如下:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
let e = scores.entry(String::from("Zywoo"));
println!("{:?}", e);
}
输出:
Entry(VacantEntry("Zywoo"))
如果键不存在,就会返回一个空缺的 entry(VacantEntry),并关联这个新的键。
现在有办法检查原 HashMap 中是否已经存在这个键了,那么如何根据是否存在来决定插入或不插入呢?
Rust 在 Entry 上提供了 or_insert 方法,其参数是想要添加的值。它会根据 entry 是占用还是空缺来决定是否插入:如果 entry 已被占用(键已存在),就保留现有值、不插入新值;如果 entry 空缺(键不存在),就插入传入的值。最重要的一点是,它有返回值:该键对应值的可变引用。 如果键已存在,就返回 HashMap 中原有值的可变引用;如果键不存在,就先插入键值对,再返回插入值的可变引用。利用这个特性可以实现一些简单的计数器(后文会讲)。
看下例子:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("dev1ce"), 0);
scores.entry(String::from("Zywoo")).or_insert(100);
scores.entry(String::from("dev1ce")).or_insert(60);
println!("{:?}", scores);
}
- 第一个
entry语句查找"Zywoo",没有找到,就返回空缺的 entry;or_insert会根据该键和传入的参数100,创建("Zywoo", 100)这个键值对。 - 第二个
entry语句查找"dev1ce",已经找到,就返回占用中的 entry;or_insert不会插入新值,因此("dev1ce", 0)保持不变。
输出(键的顺序可能不同):
{"Zywoo": 100, "dev1ce": 0}
如果这么讲还有些复杂,那么你可以把 scores.entry(String::from("Zywoo")).or_insert(100); 看作两行代码:
#![allow(unused)]
fn main() {
let e = scores.entry(String::from("Zywoo"));
e.or_insert(100);
}
3. 基于现有值来更新
先看例子:
use std::collections::HashMap;
fn main() {
let text = "That's one small step for [a] man, one giant leap for mankind.";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{:#?}", map);
}
- 首先声明了一个包含一句话的字符串字面值,赋给
text。 - 然后创建了名为
map的HashMap。 - 接着使用
for循环。text.split_whitespace()把text分割成字符串迭代器,再用for来遍历。 - 遍历时,判断每个单词是否已出现在
map中:出现了就不插入新值,没出现就插入0作为该键的新值。关键是理解count:因为or_insert的返回值是该键对应值的可变引用,所以每出现一次这个单词,就把可变引用解引用后加1,相当于完成一次计数。
8.6.2. Hash 函数
在默认情况下,HashMap 使用加密强度较高的哈希函数,可以抵抗拒绝服务(DoS)攻击。但这种函数并不是可用的最快哈希算法,它的优势是具有更好的安全性。如果觉得它性能不够好,也可以指定不同的 hasher 来切换到另一个函数。hasher 指的是实现了 BuildHasher trait 的类型。
9.1 不可恢复的错误以及panic!
9.1.1 Rust错误处理概述
Rust拥有极高的可靠性,而这种可靠性也延伸到了错误处理。在大部分情况下,Rust会迫使你意识到可能会出现错误的地方,然后在编译阶段就确保它们获得妥善的处理。
在Rust里,错误被分为两大类:
- 可恢复的错误:比如说文件未找到。这种情况下,可以把错误信息传递给用户,并让用户再次尝试。
- 不可恢复的错误:bug的另外一种说法,比如说索引越界。
其他大部分编程语言都没有刻意地区分这两种错误。它们通常通过异常这种单一机制来统一处理。但Rust里没有类似异常的机制。
- 针对可恢复的错误,Rust提供了
Result<T, E>这个类型,9.2. Result枚举与可恢复的错误 Pt.1会讲。 - 针对不可恢复的错误,Rust提供了
panic!这个宏。当执行这个宏时,程序会中止执行。
9.1.2 panic!
有时候在代码里会发生糟糕的问题,而针对这些问题开发者几乎束手无策。为了应对这种情况,Rust提供了panic!这个宏。
当这个宏执行时,会发生这些动作:
- 它会打印出错误信息。
- 然后展开(unwind)和清理调用栈。
- 退出程序。
9.1.3 当panic!时:展开还是终止
展开调用栈时工作量极大,因为Rust会沿着调用栈往回走,清理沿途遇到的每个函数中的数据。
与之相反,Rust也提供了终止(abort)的选择。这种做法就是不进行清理,直接停止程序,程序所使用的内存稍后交给操作系统来清理。
如果你想要二进制文件更小,就把设置从“展开”改为“终止”:在Cargo.toml中适当的profile部分设置panic = "abort"。
以我的Cargo.toml为例:
[package]
name = "RustStudy"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8.5"
[profile.release]
panic = "abort"
profile.release的意思是在发布模式下运行时。
9.1.4 panic!宏
看一个panic!宏的例子:
fn main() {
panic!("Something went wrong");
}
非常简单的例子。panic!宏的参数就是错误信息,它会在程序停止时被打印出来。
输出:
thread 'main' panicked at src/main.rs:2:5:
Something went wrong
stack backtrace:
0: __rustc::rust_begin_unwind
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/std/src/panicking.rs:689:5
1: core::panicking::panic_fmt
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/panicking.rs:80:14
2: RustStudy::main
at ./src/main.rs:2:5
3: core::ops::function::FnOnce::call_once
at /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
在前面几篇文章中也有程序恐慌的时候,但是那时候我没有把stack backtrace粘贴进文章,因为当时还没有讲到这里。上面呈现的才是完整的恐慌信息。现在我们来解析一下:
- 第一行告诉了程序恐慌的位置——在
src目录下的main.rs里的第2行第5列。 - 第二行是程序定义的错误信息。
- 从第三行开始的
stack backtrace指的是回溯信息。在标记为2的位置就是main.rs。回溯信息里包含了到达发生错误的地点的所有被调用函数的列表,而在它下边——也就是标记为3的地方——就是调用了我们代码的代码,可能包含了Rust的核心库、标准库,抑或是第三方的库。 - 最后一行的
note说可以把RUST_BACKTRACE设为full来获得所有的详细信息。在Windows上,在终端输入set RUST_BACKTRACE=full && cargo run。在macOS/Linux上,输入export RUST_BACKTRACE=full && cargo run。
为了获得像刚才这样的调试信息,还有一点前提条件:程序必须运行在调试模式(debug)下,而不是发布模式(--release)。cargo build和cargo run都是默认调试模式,所以只要确保不带--release这个flag就行。
9.2 Result枚举与可恢复的错误 Pt.1:match、expect和unwrap处理错误
9.2.1 Result枚举
通常情况下,错误都没有严重到需要停止整个程序的地步。某个函数之所以运行失败或者遇到错误,通常是由一些可以简单解释并做出响应的原因引起的。比如说程序要打开某个文件,但是这个文件并不存在;这个时候通常会考虑创建这个文件,而不是直接终止程序。
Rust提供了Result这个枚举类型来处理这种可能失败的情况。它的定义是:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
它有两个泛型的类型参数,T和E,以及两个变体,每个都关联了数据。Ok关联了T,Err关联了E。泛型在10.2. 泛型会讨论,现在只需要知道:T是操作成功时Ok变体返回的数据的类型;E是操作失败时Err变体返回的错误的类型。
看个例子:
use std::fs::File;
fn main() {
let f = File::open("6657.txt");
}
这段代码尝试打开一个文件,但这个文件不一定存在。换句话说,函数的运行可能会失败,所以File::open的返回值是Result枚举。这个Result里的第一个类型参数是std::fs::File,也就是成功时返回的文件类型;第二个是std::io::Error,也就是失败时返回的I/O错误。
9.2.2 用match处理Result
和Option枚举一样,Result及其变体也是由预导入模块(prelude)带入作用域的,在写代码时不需要额外引入。例如:
use std::fs::File;
fn main() {
let f = File::open("6657.txt");
let f = match f {
Ok(file) => file,
Err(e) => panic!("Error: {}", e),
};
}
如果返回的值是Ok,那么就把它所关联的值绑定到file上再返回赋给f;如果返回的值是Err,那么就把错误信息绑定在e上,由panic!宏打印出来并停止程序。
9.2.3 匹配不同的错误
我们把上面的例子完善一下。如果文件找不到,就创建这个文件。只有在连创建都不成功,或者出现了除了“找不到文件”以外的其他错误——比如没有权限打开——时,才触发panic!。
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("6657.txt");
let f = match f {
Ok(file) => file,
Err(e) => match e.kind() {
ErrorKind::NotFound => match File::create("6657.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating file: {:?}", e),
},
other_error => panic!("Problem opening file: {:?}", other_error),
},
};
}
- 在最外层,如果
f是Ok,就把文件返回给f。 - 但在处理
Err时有所不同。Err所附带的数据的类型是std::io::Error。这个struct上有一个.kind()方法,通过这个方法可以获得std::io::ErrorKind类型的值。它也是一个枚举,也是由标准库提供的,它里面的变体用来描述io操作可能引起的不同错误。 ErrorKind里面有一个变体叫ErrorKind::NotFound,表示文件不存在。这个时候就应该创建文件,下面再讨论创建文件。除了ErrorKind::NotFound,也可能有其他错误,比如说没有权限读取。这里把其他错误绑定给other_error,并由panic!打印出来然后停止程序。- 对于创建文件,可以使用
File::create(),其参数就是文件名。而创建文件本身也有可能会失败,比如说权限不足,所以File::create()的返回值也是Result。那就再用一个match表达式来处理。如果是Ok(创建成功),那就把Ok关联的值——也就是新创建的File句柄——绑定在fc上返回赋给f;如果是Err(创建失败),就把Err所关联的错误绑定在e上,用panic!打印出来并停止程序。
match确实用得比较多,但也比较原始。这里的嵌套大大降低了可读性,虽然对比其他语言可能可读性还要高一些。而在13.1. 闭包 Pt.1:什么是闭包、如何使用闭包会讲一个概念叫做闭包(closure)。Result类型有很多方法接受闭包作为参数,而且这些方法都是使用match实现的,能够使得代码更加简洁。我把使用闭包的例子写在这里,但现在不会讲,到第13章才会讲。
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file = File::open("6657.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("6657.txt").unwrap_or_else(|error| {
panic!("Problem creating the file: {error:?}");
})
} else {
panic!("Problem opening the file: {error:?}");
}
});
}
9.2.4 unwrap方法
match表达式确实灵活有用,但写出来的代码也确实复杂了一些。而Result这个枚举类型本身也定义了许多辅助方法来应对各式各样的任务,其中最常用的方法之一叫unwrap。
如果unwrap接收到了Ok,那么它就会把Ok附带的值返回;而如果接收到了Err,那么unwrap就会调用panic!宏。比如说,用unwrap重写9.2.2中的代码:
use std::fs::File;
fn main() {
let f = File::open("6657.txt").unwrap();
}
unwrap本质上相当于match表达式的快捷方式。它的缺点就是错误信息不可以自定义。
9.2.5 expect方法
那如果我想要unwrap的快捷性,又想要自定义错误信息怎么办?针对这种情况,Rust提供了expect方法。如果你有印象的话,在2.1. 猜数游戏 Pt.1中就用过这种方法。
试试用expect重写unwrap的例子:
use std::fs::File;
fn main() {
let f = File::open("6657.txt").expect("file not found");
}
9.3 Result枚举与可恢复的错误 Pt.2:传播错误、问号运算符与链式调用
9.3.1 传播错误
当你编写的函数中包含了一些可能会执行失败的调用时,除了在函数里处理这个错误,还可以把错误返回给调用者,让它来决定如何进一步处理这个错误。
看个例子:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("6657.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
fn main() {
let result = read_username_from_file();
}
这段代码的意图是从文件中读取用户名:
-
它的返回类型是
Result枚举。它的两个类型参数T和E对应String和io::Error。也就是说,当一切顺利时,函数会返回Result下的Ok变体,Ok里包裹着String类型的用户名;如果遇到了问题,这个函数就会返回Result下的Err变体,在这个变体里会包含io::Error的实例。 -
再看函数体:首先使用
File::open尝试打开一个文件,把Result赋给f,然后对f进行match操作(这里把第二个f设为可变,是因为下文的read_to_string会使用&mut self)。如果操作成功,会返回file并把值赋给f;如果操作失败,就会return Err(e)。这里的e就是具体发生的错误,而在函数体里面遇到return关键字就表示函数执行到此为止,返回return后面的值,也就是Err(e)。错误类型恰好是io::Error,所以返回值符合Result的类型参数。 -
如果
File::open操作成功,接下来函数就创建了一个可变的String,叫s,然后调用read_to_string把文件里的内容读取到s里面。当然read_to_string也可能会失败,所以后面还跟了一个match表达式。 -
这个
match表达式结尾没有分号,它也是这个函数的最后一个表达式,所以它就是这个函数的返回值。这个match有两个分支:如果操作成功,就返回Result的Ok变体,并且把String类型的s封装到里面;如果操作失败,就返回Err变体,把错误e包裹在里面返回。而read_to_string的返回错误类型恰好也是io::Error,所以返回值符合Result的类型参数。
9.3.2 ?运算符
在Rust里传播错误非常常见,所以Rust专门提供了?运算符来简化这个过程。
使用?实现上文例子的同样效果:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("6657.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
fn main() {
let result = read_username_from_file();
}
- 对于第一个
?(第5行):File::open返回的是Result,加上?的意思是:如果File::open返回Ok,那么包裹在Ok里的值就会作为表达式的结果赋给f;如果File::open返回Err,那么就会终止函数的执行,把Err及里面包裹的错误信息作为整个函数的返回值返回——也就是return Err(e)。换句话说,第5行代码的效果等同于:
#![allow(unused)]
fn main() {
let f = File::open("6657.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
}
-
对于第二个
?(第7行):如果read_to_string操作成功,就会继续往下执行。成功的返回值实际上在代码中没有用到;而如果执行失败,就会终止函数的执行,把Err及里面包裹的错误信息作为整个函数的返回值返回——也就是return Err(e)。 -
如果前面都操作成功,那么表达式
Ok(s)会把String类型的s包裹在Ok里返回。
总结一下:把?用于Result时,如果是Ok,那么Ok中的值就是表达式的结果,然后程序继续执行;如果操作失败,也就是Err,那么Err就是整个函数的返回值,就像使用了return。
9.3.3 ?与from函数
Rust提供了from函数。它来自std::convert::From这个trait,作用是在错误之间进行转换,将一个错误类型转化为另外一个错误类型。被?所接收的错误会隐式地被from处理:from会查看当前函数应当返回的错误类型是什么,然后转换为什么。
就以刚才的代码为例,read_username_from_file的返回值是Result<String, io::Error>,所以from看得出来函数需要io::Error作为发生错误时的返回类型,就会把不同的错误类型转化为io::Error。这里只是碰巧函数体内的所有错误类型都已经是io::Error,所以不需要转化这一步。
这个特点在需要把不同的错误原因映射成同一种错误类型时非常有用。前提条件是涉及到的错误类型实现了From trait,从而可以转换为所返回的错误类型。
9.3.4 链式调用
其实之前的例子还可以继续优化,就是使用链式调用。优化后的代码如下:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("6657.txt")?.read_to_string(&mut s)?;
Ok(s)
}
fn main() {
let result = read_username_from_file();
}
刚刚说过了,把?用于Result时,如果是Ok,那么Ok中的值就是表达式的结果,然后程序继续执行。那就可以消除原代码中赋值的步骤,直接使用链式调用。
9.3.5 ?只能用于返回Result或Option的函数
看个例子:
use std::fs::File;
fn main() {
let result = File::open("6657.txt")?;
}
输出:
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:3:40
|
2 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
3 | let result = File::open("6657.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
help: consider adding return type
|
2 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
3 | let result = File::open("6657.txt")?;
4 + Ok(())
|
报错内容是:?运算符只能用于返回值是Result或Option这类实现了Try trait的类型,而main的返回类型是(),也就是单元类型,相当于什么也没返回。
但是,谁说main的返回类型一定是单元类型呢?只要把它的返回类型改成Result不就解决了吗?
代码如下:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let result = File::open("6657.txt")?;
Ok(())
}
-
把返回类型改为
Result<(), Box<dyn Error>>,也就是说:如果程序正常运行,会返回Ok变体,里面包裹着单元类型;如果没有正常运行,会返回Err变体,包裹着Box<dyn Error>(其中的Error是std::error::Error)。这是一个trait对象,以后会讲,这里可以把它简单地理解为任何可能的错误类型。 -
如果能成功读取文件,那么
?就会把包裹在Ok里的文件数据返回赋给result,然后继续执行。Ok(())是main里的最后一个表达式,它返回Ok变体,同时把单元类型包裹着。 -
如果不能成功读取,那么
?就会把Err(e)作为main的返回值返回回去,并且函数执行到此结束。
9.4 什么时候该使用panic!
9.4.1 总体原则
在第9.1章「不可恢复的错误以及panic!」中也讲了,Rust中的错误类型有两种:可恢复的和不可恢复的。
调用panic!就相当于发生了一个不可恢复的错误。返回Result类型意味着错误得到了传播,而且这类错误是可恢复的。
当你认为自己可以代替调用你代码的调用者,来决定某些情况是不可恢复的时候,就可以写panic!。
如果你写的函数返回的是Result,就相当于你把错误的处理权交给了代码的调用者。调用者就可以根据实际情况来决定是否要恢复这个错误,当然它也可以觉得这个错误是不可恢复的,然后自己调用panic!。
总而言之,如果你定义的是一个可能失败的函数,那么优先考虑返回Result;如果你认为某种情形是肯定不可恢复的,那就使用panic!。
9.4.2 panic!适用的场景
编写示例代码、用于演示某些概念的时候,可以使用panic!。在这类程序里面,处理错误通常是使用unwrap这类可以制造恐慌的办法。在这里,unwrap就相当于一个占位符,然后针对不同功能的不同错误再分别编写代码进行个性化的处理。
在编写原型代码时可以使用panic!。因为在编写这类代码时还没想好该怎么处理错误,unwrap和expect方法在原型设计时非常方便,因为它们能制造恐慌,在代码中留下清晰的标记,后续就可以根据这些记号来对这些错误进行更具体的处理。
在编写测试代码时可以使用panic!。因为如果测试代码中的某个方法调用失败了,那么整个测试就应该被认定为失败,而失败状态正可以通过panic!来标记。
9.4.3 你比编译器更了解情况
有时候你可以确定一个函数调用返回的一定是Ok,绝对不会出现恐慌,这个时候就可以使用unwrap。但是由于返回类型是类似Result这样的类型,编译器仍然认为它可能出错,而你知道它一定不可能。
看个例子:
use std::net::IpAddr;
fn main(){
let home: IpAddr = "127.0.0.1".parse().unwrap();
}
这个例子使用了IpAddr这个枚举。在main中对字符串"127.0.0.1"进行解析。我们知道"127.0.0.1"是一个合理的IP地址,返回值一定是Ok,所以后面就可以使用unwrap,它绝对不会出现恐慌。
9.4.5 错误处理的指导性建议
当你的代码最终可能处于损坏状态(Bad State)时,通常最好使用panic!。损坏状态是指某些假设、保证、约定或不可变性被打破了。
比如说一些非法的值、矛盾的值或是空缺的值被传入代码,并且下列中的任意一条成立:
- 这种损坏状态是一个意外。
- 在此之后的代码如果处于这种损坏状态就无法继续运行。
- 使用的类型中没有一个好方法来将这些处于损坏状态的信息进行编码。
还是看一下具体的场景:
- 传入了无意义的参数值:
panic! - 调用外部不可控代码,返回非法状态,你又无法进行修复:
panic! - 如果失败是可预期的(比如把字符串解析为数字):
Result - 当你的代码对值进行操作时,首先应该验证值的合法性;如果不合法:
panic!这一点主要出于安全性的考虑,因为在尝试基于某些非法的值去进行操作的时候,就可能会暴露代码中的漏洞。这也是标准库会在代码尝试越界访问时报错的原因:尝试访问不属于当前数据结构的内存是一个普遍的安全问题。 而且函数通常是有某种约定的:只有在输入数据满足某些特定条件时才能够正常运行,而在约定被违反时就应该触发恐慌。因为破坏这些约定往往预示着调用者端产生了bug,而因此产生的错误也不应该留给调用者来解决,应该就地处理,触发恐慌。
9.4.6 为验证创建自定义类型
以2.1. 猜数游戏 Pt.1为例,有些代码不重要就跳过了:
fn main() {
loop {
// --snip--
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}
match guess.cmp(&secret_number) {
// --snip--
}
}
这里对原本的代码进行了一些修改:
- 把
guess的类型从u32改为i32,这样就能接收负数。 - 对于用户的输入小于1或大于100的情况,提醒用户神秘数字在1到100之间。
如果字符串转整数解析失败,就会触发continue进行下一次迭代;如果数字的范围不在1到100之间,还会触发continue进行下一次迭代。针对这个小程序,可以把验证直接写在main里面。但如果是一个大项目,每个函数都需要验证的话,那在每个函数里都反复写一遍验证逻辑就相当麻烦了。
针对这种情况,可以创建一个新的类型,把验证逻辑放到构造这个新类型实例的函数里。这样只有通过验证的值才能成功创建出实例,后续就不需要担心所接收值的有效性。
看下例子:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
fn main() {
loop {
// --snip--
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
let guess = Guess::new(guess);
match guess.value().cmp(&secret_number) {
// --snip--
}
}
new就是实例构造器。如果值不在1到100之间就会panic!;如果没发生恐慌,那就创建一个Guess实例,并把value字段设为传入的值。
还定义了一个方法叫value,它会从这个struct里提取value字段的值并返回。
下面的main函数里就可以删掉验证值是否在1到100之间的操作了,转而使用Guess::new这个构造器来验证。
如果要使用到guess的实际值,比如说在match的时候,就可以使用value这个方法来获取。
10.1 提取函数以消除重复代码
10.1.1. 重复代码
看个例子:
fn main(){
let number_list = vec![1,2,3,4,5];
let mut largest = number_list[0];
for &item in number_list.iter(){
if item > largest{
largest = item;
}
}
println!("The largest number is {}", largest);
}
这个程序的目的是找出 Vector 中的最大值。其逻辑很好理解:把第一个元素作为临时最大值,再使用循环比较 Vector 中的每个元素。如果当前元素大于当前存储的最大值,就把当前元素赋给 largest。
输出:
The largest number is 5
如果此时又新增了一个需求,需要在另一个 Vector 中找出最大值,仍然可以按同样的逻辑来写:
fn main(){
let number_list = vec![1,2,3,4,5];
let mut largest = number_list[0];
for &item in number_list.iter(){
if item > largest{
largest = item;
}
}
println!("The largest number is {}", largest);
let number_list = vec![6,7,8,9,10];
let mut largest = number_list[0];
for &item in number_list.iter(){
if item > largest{
largest = item;
}
}
println!("The largest number is {}", largest);
}
但可以看出,这样写会产生大量重复代码。
重复代码容易出错。一旦需要修改逻辑,就必须在多处做同样的修改。
因此非常推荐通过定义函数来创建抽象。代码如下:
fn largest(list: &[i32]) -> i32{
let mut largest = list[0];
for &item in list.iter(){
if item > largest{
largest = item;
}
}
largest
}
fn main(){
let number_list = vec![1,2,3,4,5];
let largest_num = largest(&number_list);
println!("The largest number is {}", largest_num);
let number_list = vec![6,7,8,9,10];
let largest_num = largest(&number_list);
println!("The largest number is {}", largest_num);
}
这里声明了一个名为 largest 的函数。它接收元素类型为 i32 的切片,并返回一个 i32。函数体内的逻辑与上文相同。需要注意的是,参数 &[i32] 是切片,本质上就是引用。切片的具体介绍见 4.5. 切片(Slice),这里不再赘述。
在保持逻辑不变的情况下,这个函数还可以写成:
#![allow(unused)]
fn main() {
fn largest(list: &[i32]) -> i32{
let mut largest = list[0];
for &item in list{
if item > largest{
largest = item;
}
}
largest
}
}
与前一版本相比,这里去掉了显式的迭代器调用 .iter(),但不影响代码行为,因为切片引用本身实现了 IntoIterator,所以 for 可以直接遍历 list。这两种写法在语义上完全等价。Rust 的 for 循环会为切片自动调用 iter(),因此可以省略显式的迭代器调用。选择哪种写法主要取决于代码风格和个人偏好。
还有另一种写法:
#![allow(unused)]
fn main() {
fn largest(list: &[i32]) -> i32{
let mut largest = list[0];
for item in list{
if *item > largest{
largest = *item;
}
}
largest
}
}
这个版本与前两种最大的不同是:它显式地对 item 解引用(*item),以便比较其值。
在前两种写法中,使用了通过解引用模式匹配进行解构。可以这样理解:&item = &i32,两边同时去掉 &,于是 item = i32。largest 也是 i32 类型,两者类型一致,可以直接比较,后面自然就不需要再解引用。如果 item 前面不加 &,那么 item 的类型就是 &i32,而 largest 是 i32,两者不能直接比较,所以必须先解引用,也就是在 item 前面加 *。
输出:
The largest number is 5
The largest number is 10
10.1.2. 消除重复的步骤
- 识别重复代码
- 创建函数,把重复代码提取到函数体中,并在函数签名中指定函数的输入和返回值
- 用函数调用替换重复代码
10.2 泛型
10.2.1. 什么是泛型
泛型的主要作用是提高代码的复用性。它们适合处理重复代码问题,也可以看成是把数据与算法分离。
泛型是具体类型或其他属性的抽象替代。 换句话说,你写的泛型代码并不是最终代码,而更像是一种带有一些占位符的模板。
编译器会在编译时把这些占位符替换成具体类型。看个例子:
#![allow(unused)]
fn main() {
fn largest<T>(list:&[T]) -> T {
//......
}
}
这个函数定义使用了泛型类型参数。T 就是所谓的“占位符”。写代码时,T 可以表示任意类型,但在编译时,编译器会根据实际用法把 T 替换成具体类型。这个过程叫做单态化。
T 是泛型类型参数。实际上,你可以使用任意合法标识符作为类型参数名,但按惯例通常使用大写字母 T(代表 Type)。选择泛型类型参数名时,通常会非常短,一个字母往往就够了。如果确实想写长一点,使用驼峰命名即可。
10.2.2. 函数定义中的泛型
用泛型定义函数时,需要把泛型类型参数放在函数签名里。泛型类型参数通常用于指定参数类型和返回类型。
以 10.1 提取函数以消除重复代码 的代码为例,这里做一点泛型修改:
#![allow(unused)]
fn main() {
fn largest<T>(list: &[T]) -> T{
let mut largest = list[0];
for &item in list{
if item > largest{
largest = item;
}
}
largest
}
}
可以把整个函数定义理解成:函数 largest 有一个泛型类型参数 T,它接收一个切片作为参数,切片中的元素类型是 T,返回值类型也是 T。
尝试编译一下,输出是:
error[E0369]: binary operation `>` cannot be applied to type `T`
--> src/main.rs:4:17
|
4 | if item > largest{
| ---- ^ ------- T
| |
| T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T{
| ++++++++++++++++++++++
这里先不讨论原因和修复方法。你只需要知道,泛型参数大概就是这样写的。后面的文章会讲解如何指定某个特定的 trait。
10.2.3. struct 定义中的泛型
结构体中定义的泛型类型参数,主要用在其字段上。例如:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
在结构体名称后面加上 <>,并在其中写入泛型参数名,这个泛型类型就可以应用到结构体的各个字段上。
在 main 中实例化了这个结构体。integer 中的两个字段都是 i32,float 中的两个字段都是 f64。因为 x 和 y 都被声明为 T,所以实例化后的 x 和 y 也必须是同一类型。这两个类型必须保持一致。
如果我想让 x 和 y 是两种不同的类型呢?很简单:声明两个泛型类型参数。
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let integer = Point { x: 5, y: 1.0 };
let float = Point { x: 1.0, y: 40 };
}
此时,实例化后的 x 和 y 可以是不同类型,当然也可以是相同类型。
需要注意的是,虽然允许多个泛型类型参数,但太多泛型会降低可读性。通常这意味着代码应该被重组为更多、更小的单元。
10.2.4. enum 定义中的泛型
与结构体很像,枚举中的泛型类型参数主要用在其变体中,让枚举变体可以持有泛型数据类型。最常见的例子是 Option<T> 和 Result<T, E>。
例如:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
}
- 在
Option枚举中,Some(T)是持有类型为T的值的变体,而None变体表示不持有任何值。正因为Option枚举使用了泛型,无论这个可能存在的值是什么类型,都可以用Option<T>来表示 - 同样,枚举也可以使用多个泛型类型参数。例如,
Result枚举使用了T和E:Ok变体存储T,Err变体存储E
10.2.5. 方法定义中的泛型
方法可以附加在枚举或结构体上。既然枚举和结构体可以使用泛型参数,方法当然也可以,如下所示:
#![allow(unused)]
fn main() {
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
}
x 方法本质上是一个 getter。为 Point<T> 实现方法时,需要在 impl 关键字后面加上 <T>。这表示该实现是针对泛型 T 的,而不是针对某个具体类型。
当然,如果是为某个具体类型实现方法,就不需要那样写:
#![allow(unused)]
fn main() {
impl Point<i32> {
fn x1(&self) -> &i32 {
&self.x
}
}
}
x1 方法只存在于具体类型 Point<i32> 上,其他 Point<T> 类型没有这个方法,类似于 C++ 中的特化和偏特化。
还有一点很重要:结构体中的泛型类型参数可以与方法中的泛型类型参数不同。例如:
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
为 Point<T, U> 实现了方法 mixup。它有两个泛型类型参数 V 和 W。方法中的这两个类型参数与 Point 中的两个类型参数不同,尽管实际类型最终也可能相同。mixup 的第二个参数是 other,它的类型也是 Point,但这个 Point 不一定与 self 所指向的 Point 使用相同的数据类型,因此需要两个新的泛型类型参数。再看返回类型,它是 Point<T, W>:T 来自 Point<T, U>,W 来自 Point<V, W>。
现在看 main:先声明 p1,它的两个字段分别是 i32 和 f64;再声明 p2,它的两个字段分别是 &str(字符串切片)和 char(用 '' 表示的单个字符)。接着使用 mixup。p1 对应 Point<T, U>,p2 对应 Point<V, W>。根据它们的字段类型可以推断:T 是 i32,U 是 f64,V 是 &str,W 是 char。mixup 的返回类型是 Point<T, W>,在这个例子中就是 Point<i32, char>。
输出:
p3.x = 5, p3.y = c
10.2.6. 泛型代码的性能
使用泛型编写的代码,运行速度与使用具体类型编写的代码一样快。Rust 会在编译时执行单态化,把泛型类型替换成具体类型,因此执行时不存在类型替换过程。
例如:
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
这里 integer 是 Option<i32>,float 是 Option<f64>。在编译时,编译器会把 Option<T> 展开为 Option_i32 和 Option_f64:
#![allow(unused)]
fn main() {
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
}
也就是说,泛型定义 Option<T> 被替换成了两个具体类型定义。
单态化后的 main 函数也会变成这样:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main(){
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
10.3 trait Pt.1:trait的定义、约束与实现
10.3.1. 什么是trait
trait 的意思是特征、特质。trait 用来向 Rust 编译器描述某个类型具有哪些能力,以及它可以与其他类型共享哪些行为。trait 以抽象方式定义共享行为。
还有 trait bounds(约束)的概念,它可以把泛型类型参数约束为实现了特定行为的类型。换句话说,就是要求泛型类型参数实现某些 trait。
Rust 中的 trait 与其他语言中的接口(interface)有些类似,但仍有区别。
10.3.2. 定义一个trait
类型的行为由该类型本身可以调用的方法组成。有时不同类型具有相同的方法,这时我们就说这些类型共享相同的行为。trait 提供了一种把方法组合在一起的方式,从而定义实现某种目的所必需的行为。
- 使用
trait关键字定义 trait。trait 中的方法既可以只写方法签名(以;结尾、没有方法体),也可以提供默认实现(后文会讲到) - 一个 trait 可以有多个方法,每个需要实现方提供的方法签名单独占一行,并以
;结尾 - 对于没有默认实现的方法,实现该 trait 的类型必须提供具体的方法体
例如:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String;
}
}
在 trait 前面加上 pub 表示它是公共的。这个 trait 名为 Summary,其中包含一个名为 summarize 的方法签名。除了 &self 之外没有其他参数,返回类型是 String,签名以 ; 结束。它没有方法体,因此没有具体实现。当然,一个 trait 下可以有很多方法签名:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String;
fn summarize1(&self) -> String;
fn summarize2(&self) -> String;
//......
}
}
10.3.3. 在类型上实现trait
为类型实现 trait 与为类型实现方法很类似,但也有不同之处。
为类型实现方法的写法是在 impl 关键字后面跟上类型:
#![allow(unused)]
fn main() {
impl Yyyy {....}
}
为类型实现 trait 的写法则是:
#![allow(unused)]
fn main() {
impl Xxxx for Yyyy {....}
}
Xxxx指的是 trait 名Yyyy指的是类型名- 在花括号内,需要为 trait 中的方法签名写出具体实现
例如(lib.rs):
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
}
- 结构体
NewsArticle表示新闻文章。它有四个字段:headline表示标题,location表示地点,author表示作者,content表示内容 - 结构体
Tweet表示 X(前身为 Twitter)上的推文。它有四个字段:username、content、reply和retweet
这两个结构体类型当然不同,它们的大多数字段也不同。但它们都可以有相同的行为——提供摘要 Summary,因此分别在这两个类型上实现了 Summary。
#![allow(unused)]
fn main() {
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
}
这段代码为 NewsArticle 实现了该 trait。因为 trait 定义中包含 summarize 方法签名,所以这里必须写出具体实现:使用 format! 宏把 self.headline、self.author 和 self.location 组合成一个字符串并返回。
#![allow(unused)]
fn main() {
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
}
这段代码也为 Tweet 实现了该 trait,同样提供了 summarize 的具体实现:使用 format! 宏把 self.username 和 self.content 组合成一个字符串并返回。
现在来到 main.rs,看看实例是如何调用的:
use RustStudy::{Summary, Tweet};
fn main() {
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
}
记住,我们的代码写在 lib.rs 中,在 main.rs 中使用之前,需要先把它引入作用域。写法是:
#![allow(unused)]
fn main() {
use 你的package名::...::你需要的模块;
}
你的 package 名就是 Cargo.toml 中的项目名,把它复制过来即可。
引入 Summary 是因为使用了 Summary trait 下的 summarize 方法;引入 Tweet 是因为使用了 Tweet 结构体。
看一下输出:
1 new tweet: horse_ebooks: of course, as you probably already know, people
10.3.4. trait的约束
想要为某个类型实现某个 trait,前提条件是:
- 该类型本身(例如
Tweet)或该 trait 本身(例如让Vector实现本地的Summary)必须在本地 crate 中定义 - 不能为外部类型实现外部 trait。例如,在本地 crate 中为标准库的
Vector实现标准库的Displaytrait 这个限制是语言一致性规则的一部分。更具体地说,它是孤儿规则,之所以这样命名,是因为父类型并没有定义在当前 crate 中。这条规则确保其他人的代码不能随意破坏你的代码,反之亦然。如果没有这条规则,两个 crate 可以为同一个类型实现同一个 trait,Rust 就不知道该使用哪个实现。
10.3.5. 默认实现
有时,为 trait 中的某些或全部方法提供默认行为非常有用。这让我们不必为每一个类型的实现都提供自定义行为。我们仍然可以为特定类型实现 trait 方法。
当为某些类型实现 trait 时,我们可以选择保留或覆盖每个方法的默认实现。
之前的写法是:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String;
}
}
之前的版本只写了方法签名,没有提供实现,但实际上可以添加默认实现:
默认实现:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
}
这里的默认实现只是返回字符串 "(Read more...)"。
因为这个方法在 trait 中已经有默认实现了,所以具体类型可以直接使用该默认实现,而不必自己再提供实现。
以 NewsArticle 为例,它原本有自己的实现(也可以叫做对默认实现的覆盖):
#![allow(unused)]
fn main() {
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
}
如果删掉这个具体实现,NewsArticle 就会使用默认实现:
#![allow(unused)]
fn main() {
impl Summary for NewsArticle {}
}
还有一点需要知道:带有默认实现的方法可以调用同一 trait 中的其他方法,即使那些方法没有默认实现:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
}
summarize 的默认实现调用了 summarize_author,即使 summarize_author 只是一个签名、没有具体实现。但如果想为某个类型实现 summarize,就需要先实现 summarize_author:
#![allow(unused)]
fn main() {
impl Summary for NewsArticle {
fn summarize_author(&self) -> String {
format!("@{}", self.author)
}
}
}
PS:由于 NewsArticle 使用了 summarize 的默认实现,所以这里不需要再写 summarize 的实现。
这种写法有一点需要注意:无法从被覆盖的方法实现中调用默认实现。
10.4 trait Pt.2:trait作为参数和返回类型、trait bound
10.4.1. 把trait作为参数
继续以 10.3 trait Pt.1:trait的定义、约束与实现 中的内容为例:
#![allow(unused)]
fn main() {
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
}
如果我们新定义一个函数 notify,它以 NewsArticle 和 Tweet 这两种类型作为参数,并打印 Breaking news!,后面跟上在参数上调用 Summary 中 summarize 方法的返回值,就会遇到一个问题:
这个函数接收的是两个不同的结构体类型。怎样才能让参数同时适用于这两种类型呢?
细想一下:这两个结构体有什么共同点?没错——它们都实现了 Summary trait。Rust 为这种情况提供了解决方案:
#![allow(unused)]
fn main() {
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
}
只要把参数类型写成 impl 某个trait 即可。因为这两个结构体都实现了 Summary trait,所以写成 impl Summary。又因为这个函数不需要数据的所有权,所以写成引用:&impl Summary。如果还有其他数据类型也实现了 Summary,同样可以作为参数传入。
impl trait 语法适用于简单情况。对于更复杂的情况,通常使用 trait bound 语法。
同样的代码,用 trait bound 来写:
#![allow(unused)]
fn main() {
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
}
这两种写法是等价的。
不过,在有两个参数时,这两种写法的差别会更明显。假设我要设计一个新的 notify1 函数。它接收两个参数,Breaking news! 后面的内容是分别在每个参数上调用 summarize 的返回值。
trait bound 写法:
#![allow(unused)]
fn main() {
pub fn notify1<T: Summary>(item1: &T, item2: &T) {
println!("Breaking news! {} {}", item1.summarize(), item2.summarize());
}
}
impl trait 写法:
#![allow(unused)]
fn main() {
pub fn notify1(item1: &impl Summary, item2: &impl Summary) {
println!("Breaking news! {} {}", item1.summarize(), item2.summarize());
}
}
显然,这两种写法并不等价。用 trait bound 时,item1 和 item2 必须是同一个具体类型(都是 &T)。用 impl Trait 时,只要各自都实现了 Summary,item1 和 item2 可以是不同类型(例如一个是 NewsArticle,另一个是 Tweet)。当你需要两个参数共用同一类型时,用 trait bound;当允许不同类型、且签名仍然简单时,用 impl Trait 即可。
在简单情况下,impl Trait 相当于带有 trait bound 的匿名泛型,写起来更省事。对于更复杂的签名——例如多个参数必须是同一类型,或约束很多——具名的 trait bound(或 where 子句)通常更清晰。
那么,如果 notify 函数需要其参数同时实现 Display trait 和 Summary trait 呢?换句话说,两个或更多 trait bound 该怎么写?
例如:
#![allow(unused)]
fn main() {
pub fn notify_with_display<T: Summary + std::fmt::Display>(item: &T) {
println!("Breaking news! {}", item);
}
}
使用 + 连接各个 trait bound。
还有一点:因为 Display 不在预导入模块中,写它时需要写出完整路径。也可以先在代码开头引入 Display,像这样:use std::fmt::Display。然后就可以在 trait bound 中直接写 Display:
#![allow(unused)]
fn main() {
use std::fmt::Display;
pub fn notify_with_display<T: Summary + Display>(item: &T) {
println!("Breaking news! {}", item);
}
}
别忘了,impl trait 也是语法糖,在这种语法糖中同样用 + 连接 trait bound:
#![allow(unused)]
fn main() {
use std::fmt::Display;
pub fn notify_with_display(item: &(impl Summary + Display)) {
println!("Breaking news! {}", item);
}
}
这种写法有一个缺点:如果 trait bound 太多,大量约束信息会降低函数签名的可读性。为了解决这个问题,Rust 提供了一种替代语法:在函数签名之后使用 where 子句来写 trait bound。
下面是多个 trait bound 的普通写法:
#![allow(unused)]
fn main() {
use std::fmt::Display;
use std::fmt::Debug;
pub fn special_notify<T: Summary + Display, U: Summary + Debug>(item1: &T, item2: &U) {
println!("Breaking news! {} and {}", item1.summarize(), item2.summarize());
}
}
同样的代码用 where 子句重写:
#![allow(unused)]
fn main() {
use std::fmt::Display;
use std::fmt::Debug;
pub fn special_notify<T, U>(item1: &T, item2: &U)
where
T: Summary + Display,
U: Summary + Debug,
{
println!("Breaking news! {} and {}", item1.summarize(), item2.summarize());
}
}
这种语法与 C# 很相似。
10.4.2. 把trait作为返回类型
和把 trait 作为参数一样,把 trait 作为返回值也可以使用 impl trait。例如:
#![allow(unused)]
fn main() {
fn returns_summarizable() -> impl Summary {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
}
}
这种语法有一个缺点:如果返回类型实现了某个 trait,那么必须保证这个函数/方法所有可能的返回值都只能是同一种类型。这是因为 impl 形式在工作方式上有一些限制,所以 Rust 并非在所有情况下都支持它。但 Rust 支持动态派发,之后会讲。
例如:
#![allow(unused)]
fn main() {
fn returns_summarizable(flag:bool) -> impl Summary {
if flag {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
} else {
NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh, Scotland"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
}
}
}
根据 flag 的值,可能有两种返回类型:Tweet 和 NewsArticle。这时编译器会报错:
error[E0308]: `if` and `else` have incompatible types
--> src/lib.rs:42:9
|
32 | / if flag {
33 | | / Tweet {
34 | | | username: String::from("horse_ebooks"),
35 | | | content: String::from(
36 | | | "of course, as you probably already know, people",
... | |
39 | | | retweet: false,
40 | | | }
| | |_________- expected because of this
41 | | } else {
42 | | / NewsArticle {
43 | | | headline: String::from("Penguins win the Stanley Cup Championship!"),
44 | | | location: String::from("Pittsburgh, PA, USA"),
45 | | | author: String::from("Iceburgh, Scotland"),
... | |
49 | | | ),
50 | | | }
| | |_________^ expected `Tweet`, found `NewsArticle`
51 | | }
| |_______- `if` and `else` have incompatible types
|
help: you could change the return type to be a boxed trait object
|
31 - fn returns_summarizable(flag:bool) -> impl Summary {
31 + fn returns_summarizable(flag:bool) -> Box<dyn Summary> {
|
help: if you change the return type to expect trait objects, box the returned expressions
|
33 ~ Box::new(Tweet {
34 | username: String::from("horse_ebooks"),
...
39 | retweet: false,
40 ~ })
41 | } else {
42 ~ Box::new(NewsArticle {
43 | headline: String::from("Penguins win the Stanley Cup Championship!"),
...
49 | ),
50 ~ })
|
报错信息说的是 if 和 else 的返回类型不兼容,也就是它们不是同一种类型。
使用trait bounds的实例
还记得在 10.2. 泛型 中提到的比大小代码吗?我把它粘在这里:
#![allow(unused)]
fn main() {
fn largest<T>(list: &[T]) -> T{
let mut largest = list[0];
for &item in list{
if item > largest{
largest = item;
}
}
largest
}
}
当时出现的错误我也粘在这里:
error[E0369]: binary operation `>` cannot be applied to type `T`
--> src/main.rs:4:17
|
4 | if item > largest{
| ---- ^ ------- T
| |
| T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T{
| ++++++++++++++++++++++
现在学了 trait 之后,对这段代码及其报错信息的理解是不是又不一样了?
先从报错信息开始分析。错误说比较运算符 > 不能应用于类型 T。下面的 help 行说考虑限制类型参数 T,再往下给出了具体做法:在 T 后面加上 std::cmp::PartialOrd(在 trait bound 中只需要写 PartialOrd,因为它在预导入模块中,所以不需要写完整路径)。这实际上就是用于比较的 trait。试试按照提示修改:
#![allow(unused)]
fn main() {
fn largest<T: PartialOrd>(list: &[T]) -> T{
let mut largest = list[0];
for &item in list{
if item > largest{
largest = item;
}
}
largest
}
}
仍然会报错:
error[E0508]: cannot move out of type `[T]`, a non-copy slice
--> src/main.rs:2:23
|
2 | let mut largest = list[0];
| ^^^^^^^
| |
| cannot move out of here
| move occurs because `list[_]` has type `T`, which does not implement the `Copy` trait
|
help: if `T` implemented `Clone`, you could clone the value
--> src/main.rs:1:12
|
1 | fn largest<T: PartialOrd>(list: &[T]) -> T{
| ^ consider constraining this type parameter with `Clone`
2 | let mut largest = list[0];
| ------- you could clone this value
help: consider borrowing here
|
2 | let mut largest = &list[0];
| +
error[E0507]: cannot move out of a shared reference
--> src/main.rs:3:18
|
3 | for &item in list{
| ---- ^^^^
| |
| data moved here because `item` has type `T`, which does not implement the `Copy` trait
|
help: consider removing the borrow
|
3 - for &item in list{
3 + for item in list{
|
但这次错误不同了:无法从 list 中移出元素,因为 list 中的 T 没有实现 Copy trait。下面的 help 说如果 T 实现了 Clone trait,可以考虑克隆该值。再下面还有一个 help,建议使用借用。
根据以上信息,有三种解决方案:
- 为泛型类型添加
Copytrait - 使用克隆,也就是为泛型类型添加
Clonetrait - 使用借用
该选择哪个方案呢?这取决于你的需求。我想让这个函数处理数字和字符的集合。由于数字和字符都存储在栈上,它们都实现了 Copy trait,所以只要给泛型类型加上 Copy 就够了:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T{
let mut largest = list[0];
for &item in list{
if item > largest{
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}
输出:
The largest number is 100
The largest char is y
如果我想让这个函数比较 String 集合呢?由于 String 存储在堆上,它没有实现 Copy trait,所以给泛型类型加上 Copy 的思路行不通。
那就试试克隆,也就是给泛型类型加上 Clone trait:
fn largest<T: PartialOrd + Clone>(list: &[T]) -> T{
let mut largest = list[0].clone();
for &item in list.iter() {
if item > largest{
largest = item;
}
}
largest
}
fn main() {
let string_list = vec![String::from("dev1ce"), String::from("Zywoo")];
let result = largest(&string_list);
println!("The largest string is {}", result);
}
输出:
error[E0507]: cannot move out of a shared reference
--> src/main.rs:3:18
|
3 | for &item in list.iter() {
| ---- ^^^^^^^^^^^
| |
| data moved here because `item` has type `T`, which does not implement the `Copy` trait
|
help: consider removing the borrow
|
3 - for &item in list.iter() {
3 + for item in list.iter() {
|
错误说无法移动数据,因为这种写法要求实现 Copy,而 String 做不到。该怎么办呢?
那就不要移动数据,不要使用模式匹配。去掉 item 前面的 &,这样 item 就从 T 变成了不可变引用 &T。然后在比较时使用解引用运算符 *,把 &T 解引用为 T 再与 largest 比较(下面的代码就是这种做法),或者在 largest 前面加 & 使其变成 &T。总之,被比较的两个值必须类型一致:
fn largest<T: PartialOrd + Clone>(list: &[T]) -> T{
let mut largest = list[0].clone();
for item in list.iter() {
if *item > largest{
largest = item.clone();
}
}
largest
}
fn main() {
let string_list = vec![String::from("dev1ce"), String::from("Zywoo")];
let result = largest(&string_list);
println!("The largest string is {}", result);
}
记住 T 没有实现 Copy trait,所以给 largest 赋值时需要使用 clone 方法。
输出:
The largest string is dev1ce
之所以这样写,是因为返回值是 T。如果把返回值改成 &T,就不再需要克隆了:
fn largest<T: PartialOrd>(list: &[T]) -> &T{
let mut largest = &list[0];
for item in list.iter() {
if item > largest{
largest = item;
}
}
largest
}
fn main() {
let string_list = vec![String::from("dev1ce"), String::from("Zywoo")];
let result = largest(&string_list);
println!("The largest string is {}", result);
}
但要记住,初始化 largest 时必须把它设为 &T,所以需要在 list[0] 前面加 & 使其成为引用。另外,比较时两边应是同一种值:这里 item 和 largest 都是 &T,因此可以直接写 item > largest。
10.4.3. 使用trait bound有条件地实现方法
如果在带有泛型类型参数的 impl 块上使用 trait bound,就可以有条件地为实现了特定 trait 的类型实现方法。
例如:
#![allow(unused)]
fn main() {
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
}
无论 T 的具体类型是什么,new 函数都会存在于 Pair 上。但只有当 T 同时实现了 Display 和 PartialOrd 时,才会有 cmp_display 方法。
也可以为实现了另一个 trait 的任意类型有条件地实现某个 trait。为所有满足某个 trait bound 的类型实现一个 trait,叫做覆盖实现(blanket implementation)。
以标准库中的 to_string 函数为例:
#![allow(unused)]
fn main() {
impl<T: Display> ToString for T {
// ......
}
}
这意味着对所有满足 Display trait 的类型都实现了 ToString,这就是覆盖实现:任何实现了 Display 的类型都可以调用 ToString 上的方法。
以整数为例:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
之所以能这样做,是因为 i32 实现了 Display trait,所以可以调用 ToString 上的 to_string 方法。
10.5 生命周期 Pt.1:生命周期的定义与意义、借用检查器与泛型生命周期
10.5.1. 什么是生命周期
Rust 中的每个引用都有自己的生命周期。生命周期的作用是让引用保持有效;换句话说,它就是引用保持有效的作用域。
在大多数情况下,生命周期是隐式的、可推断的。如果引用的生命周期可能以不同方式相互关联,就必须手动标注生命周期。
生命周期可以说是 Rust 与其他语言相比最与众不同的特征,因此非常难学。
10.5.2. 生命周期的存在意义
生命周期存在的主要目的是避免悬空引用。这个概念在 4.4. 引用与借用 中已经讨论过,这里重复一下之前的解释:
在使用指针时,非常容易触发一种叫做悬空指针(Dangling Pointer) 的错误。其定义如下:一个指针引用了内存中的某个地址,而这块内存可能已经被释放并重新分配给其他人使用。如果你引用了某些数据,Rust 编译器会保证在引用离开作用域之前,数据不会离开作用域。这就是 Rust 确保悬空引用永远不会出现的方式。
看这个例子:
fn main() {
let r;
{ // 小花括号
let x = 5;
r = &x;
}
println!("{}", r);
}
- 在这个例子中,先声明了
r但没有初始化。目的是让r存在于小花括号外(见注释位置)的作用域中。当然,Rust 没有Null值,所以在初始化之前不能使用r。 - 在小花括号内,声明了变量
x并赋值为5。下一行把x的引用赋给了r。 - 在那个小花括号作用域结束之后,在外面打印了
r。
这段代码是无效的,因为打印 r 时,x 已经走出作用域并被销毁了。所以 r 的值——也就是 x 所引用的内存地址——现在指向的是已经被释放的内存,它所指向的数据也不再是 x。这就造成了悬空引用,因此编译器会报错。
输出:
error[E0597]: `x` does not live long enough
--> src/main.rs:5:7
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("{}", r);
| - borrow later used here
报错信息说借用的值活得不够长。这是因为内部花括号作用域结束时,x 走出了作用域,但 r 有更大的作用域并可以继续使用。为了保证程序安全,此时任何基于 r 的操作都无法正确运行。
Rust 通过借用检查器来检查代码是否合法。
10.5.3. 借用检查器
借用检查器通过比较作用域来判断所有借用是否合法。在上面的例子中,借用检查器发现 r 是对 x 的引用,但 r 的存活时间比 x 更长,因此会报错。
怎么解决这个问题呢?很简单:让 x 至少活得和 r 一样长。
fn main() {
let x = 5;
let r = &x;
println!("{}", r);
}
在这种情况下,x 从第 2 行活到第 5 行,r 从第 3 行活到第 5 行。因此 x 的生命周期完全覆盖了 r 的生命周期,程序不会报错。
10.5.4. 函数中的泛型生命周期
看这个例子:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
-
string1是String,而string2是字符串切片&str。这两个值被传入longest函数(string1需要先转换成&str),然后打印返回值。 -
longest的逻辑是比较两个输入参数,并返回较长的那个。
输出:
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
错误说缺少生命周期标注,更具体地说是返回类型缺少生命周期参数。正如 help 文本所说,函数的返回类型包含一个借用值,但函数签名没有说明这个借用值来自 x 还是来自 y。考虑引入一个命名生命周期参数。
再看这个函数:
#![allow(unused)]
fn main() {
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
}
很明显,这个函数的返回值要么是 x,要么是 y,但无法预先知道是哪一个。如果只看这个函数本身,两个输入参数 x 和 y 的具体生命周期也是未知的。所以,与前面的例子不同,我们无法通过比较作用域来判断返回的引用是否会一直有效。借用检查器也做不到,因为它不知道返回类型的生命周期是与 x 绑定还是与 y 绑定。
实际上,即使返回值是固定的,这样写仍然会报错:
#![allow(unused)]
fn main() {
fn longest(x: &str, y: &str) -> &str {
x
}
}
输出:
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
编译器仍然无法判断,因为函数签名没有表达返回类型中的借用值来自哪里。
所以这与函数体内的逻辑无关,完全与函数签名有关。该怎么改呢?可以按照报错信息中的建议来改:
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
既然它让我们添加泛型生命周期参数,我们就添加一个:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
}
'a 表示一个名为 a 的生命周期。x、y 和返回类型都使用生命周期 a,这意味着 x、y 和返回值的生命周期是相同的。
“相同”这个说法并不完全精确,因为 main 中 x 和 y 对应值的实际生命周期其实略有不同。这一点我们会在 10.6 生命周期 Pt.2:生命周期的语法与例子 中讨论。
现在看完整代码:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
输出:
The longest string is abcd
10.6 生命周期 Pt.2:生命周期的语法与例子
10.6.1. 生命周期标注语法
- 标注生命周期并不会改变引用存活多久。
- 如果某个函数指定了泛型生命周期参数,那么它可以接收带有任何生命周期的引用。
- 生命周期标注主要用于描述多个引用的生命周期之间的关系,但它们本身并不影响生命周期。
生命周期参数名必须以 ' 开头,通常是全小写且非常短。很多开发者使用 'a 作为生命周期参数名。
生命周期标注放在 & 符号后面,并在标注与引用类型之间用空格隔开。
10.6.2. 生命周期标注例子
&i32:一个普通引用&'a i32:带有显式生命周期的引用,被引用的类型是i32&'a mut i32:带有显式生命周期的可变引用
单个生命周期标注本身没有意义。生命周期标注的目的是向 Rust 描述多个泛型生命周期之间的关系。
以 10.5 生命周期 Pt.1:生命周期的定义与意义、借用检查器与泛型生命周期 的代码为例:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
longest 中参数 x、参数 y 以及返回值的生命周期都是 'a,这意味着 x、y 和返回值必须拥有“相同”的生命周期。
从上面的例子也可以看出,在函数签名中使用生命周期标注时,必须把泛型生命周期参数声明在 <> 里。这个签名告诉 Rust:存在一个生命周期 'a,并且 x、y 和返回值必须至少活得和 'a 一样长。
因为生命周期标注主要用于描述多个引用的生命周期之间的关系,但它们本身并不影响生命周期,所以这样写并不会改变实参的生命周期。它只是给借用检查器提供了一些可用于检测非法调用的约束。因此,longest 函数并不需要知道 x 和 y 到底活多久;它只需要某个作用域可以用来代替 'a,同时满足函数签名的约束即可。
当函数引用外部代码,或被外部代码引用时,几乎不可能单靠 Rust 编译器确定参数和返回值的生命周期。这样的函数所使用的生命周期可能在每次调用时都会变化。这正是有时需要手动标注生命周期的原因。
在示例代码中,当我们把具体引用传入 longest 函数时,用来代替 'a 的作用域是哪一块呢?就是 x 和 y 作用域的重叠部分,换句话说,是两者中较短的那个生命周期。 又因为返回值也有生命周期 'a,所以返回的引用在 x 和 y 作用域的重叠部分中保持有效。
这就是为什么在 10.5 生命周期 Pt.1:生命周期的定义与意义、借用检查器与泛型生命周期 以及本文前面,都把“相同”一词放在引号里:它并不是字面意义上完全相同的生命周期,而是指重叠的部分。
接下来看看生命周期标注如何约束对 longest 的调用。如果我们修改上面的例子,让 string1 有不同的作用域,并把 string2 改成 String,会发生什么?
fn main() {
let string1 = String::from("abcd");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
这里,string1 的作用域是从第 2 行到第 8 行,string2 的作用域是从第 4 行到第 7 行。把它们传入 longest 时,函数会寻找重叠部分——或者说较短的那个生命周期——也就是 string2 的作用域,从第 4 行到第 7 行。因此 'a 所代表的作用域是从第 4 行到第 7 行。result 在内部作用域中有效,也就是直到第 7 行的结束花括号为止,所以代码在 'a 范围内仍然有效。
如果我改的是 result 的作用域呢?
fn main() {
let string1 = String::from("abcd");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在这种情况下,string1 的作用域是从第 2 行到第 9 行,string2 的作用域是从第 5 行到第 7 行。把它们传入 longest 时,函数会寻找重叠部分——或者说较短的那个生命周期——也就是 string2 的作用域,从第 5 行到第 7 行。因此函数的泛型生命周期参数 'a 指的是从第 5 行到第 7 行的作用域,返回值也应该有同样的作用域。然而,接收返回值的 result 变量实际从第 3 行活到第 9 行,超出了 'a 所代表的作用域,所以程序会报错:
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| ------ borrow later used here
编译器说 string2 活得不够长。为了保证第 8 行打印的 result 有效,string2 必须一直保持有效直到外部作用域结束。因为函数参数和返回值使用了相同的生命周期,Rust 才能指出这个问题。
让我们再重复一遍本文最重要的一点:'a 所代表的实际生命周期,是 x 和 y 两个生命周期中较短的那个。
10.7 生命周期 Pt.3:输入输出生命周期与3规则
10.7.1. 深入理解生命周期
1. 指定生命周期参数的方式取决于函数所做的事情
以 10.6 生命周期 Pt.2:生命周期的语法与例子 的代码为例:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
}
这个函数签名之所以这样写,是因为不知道返回值到底是 x 还是 y。如果我修改代码,把返回值固定为 x,那就不必再给 y 显式生命周期了:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
}
因此,这个函数签名并没有约束 y 的生命周期。
2. 当函数返回引用时,返回类型的生命周期参数必须与其中一个输入生命周期匹配
如果返回的引用没有指向任何参数,返回的内容就会变成悬空引用,因为在函数内创建的值会在函数结束时离开作用域,而返回的引用指向的是已经被释放的内存。
看这个例子:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
let result = String::from("Something");
result.as_str()
}
}
在这个函数中,创建了一个名为 result 的 String 值,然后对 result 调用 as_str 方法返回一个字符串切片(&str),其实就是一个引用。这样就会报错:
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:3:5
|
3 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
报错信息说无法返回引用本地变量 result 的值,因为返回的值是函数自身拥有的数据。这与刚才提到的原因相同:一旦内部数据离开作用域,就会被清理。
如果我想返回在函数内部创建的值呢?那就不要返回引用,直接返回这个值:
#![allow(unused)]
fn main() {
fn longest(x: &str, y: &str) -> String {
let result = String::from("Something");
result
}
}
这相当于把函数中该值的所有权转移给调用者,并由调用者负责清理这块内存。这种写法也不需要显式生命周期,因为返回值与参数无关,而且只有引用才会有生命周期问题。
从这个例子可以看出,生命周期语法从根本上就是用来关联函数中不同参数和返回值的生命周期的。 一旦建立起这些关系,Rust 就有足够的信息来支持保证内存安全的操作,并拒绝可能导致悬空指针或其他破坏内存安全的操作。
10.7.2. 结构体中的生命周期标注
在前面的文章中,我们在结构体中只定义过自持有类型,例如 i32 和 String。实际上,结构体字段也可以是引用类型;如果是引用,就需要给每个引用添加生命周期标注。
看这个例子:
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
ImportantExcerpt 只有一个字段 part,其类型是字符串切片,也就是引用类型。因为它是引用类型,所以需要生命周期标注。
标注生命周期的方式与泛型相同:在结构体名称后面加上 <>,并在里面写入生命周期泛型参数。这里是 'a。part 这个引用必须比结构体实例本身活得更久。只要实例存在,part 引用也必须存在;如果 part 先消失,实例肯定会无效。
看 main:它先创建了一个名为 novel 的 String,然后使用 split 和 next 从字符串中提取第一句(unwrap 用于解包 Option 类型,这在 9.2. Result枚举与可恢复的错误 Pt.1 中介绍过)。这个句子的类型是 &str,也就是一个引用。然后创建了 ImportantExcerpt 的实例 i,并把这个引用作为 part 字段的值。
这样写是有效的,因为 first_sentence 的作用域是从第 7 行到第 11 行,而 i 的作用域是从第 8 行到第 11 行。因此 part 字段活得比实例更久,并且完全覆盖了 i 的生命周期。
10.7.3. 生命周期省略
每个引用都有生命周期,使用生命周期的函数或结构体需要生命周期参数。
那么,为什么这段来自 4.5. 切片(Slice) 的代码,在没有任何生命周期标注的情况下也能通过编译呢?
fn main() {
let s = String::from("Hello world");
let word = first_word(&s);
println!("{}", word);
}
fn first_word(s:&str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
这个函数在没有生命周期标注的情况下也能编译,有其历史原因:在早期版本的 Rust(1.0 之前),这段代码无法通过编译,因为当时要求每个引用都必须有显式生命周期。函数签名必须写成这样:
#![allow(unused)]
fn main() {
fn first_word<'a>(s: &'a str) -> &'a str {
}
后来,Rust 团队发现在某些情况下,Rust 程序员会一遍又一遍地写相同的生命周期标注,而这些情况是可预测的。它们有清晰的模式,于是 Rust 团队把这些模式直接编入编译器,让借用检查器在这些情况下可以自动推断生命周期,而无需程序员显式标注。
了解这段历史的意义在于:未来可能会发现更多确定性的模式,并把它们加入编译器。将来可能需要写的生命周期标注会更少。谢天谢地。
Rust 引用分析中内置的这些模式叫做生命周期省略规则。程序员不需要手动遵循它们;它们是由编译器处理的特殊情况。如果你的代码符合这些情况,就不需要显式生命周期标注。
不过,生命周期省略并不提供完整推断。如果应用规则之后引用仍然不明确,仍然会发生编译错误。解决办法是手动添加生命周期,以表明引用之间的关系。
10.7.4. 输入、输出生命周期
如果生命周期出现在函数或方法的参数中,就叫做输入生命周期。
如果它出现在函数或方法的返回值中,就叫做输出生命周期。
10.7.5. 生命周期省略的三个规则
编译器使用三条规则,在没有显式标注生命周期时确定生命周期:
- 规则 1 用于输入生命周期
- 规则 2 和 3 用于输出生命周期
- 如果编译器在应用完三条规则后仍然无法确定生命周期,就会报错
- 这三条规则不仅适用于函数或方法定义,也适用于
impl块
规则 1: 每个引用参数都会获得自己的生命周期。 单参数函数有一个生命周期,双参数函数有两个生命周期,以此类推。
规则 2: 如果恰好只有一个输入生命周期参数,那么该生命周期会被赋给所有输出生命周期参数。 换句话说,如果只有一个输入生命周期,那么这个生命周期就是该函数所有可能返回值的生命周期。
规则 3: 如果有多个输入生命周期参数,但其中一个是 &self 或 &mut self(也就是说,这个函数是方法),那么 self 的生命周期会被赋给所有输出生命周期参数。
1. 成功例
规则已经清楚了,现在看一个例子:
#![allow(unused)]
fn main() {
fn first_word(s:&str) -> &str {
//...
}
}
把自己代入编译器,想想如何用这三条规则找出这个函数签名中省略的生命周期。
首先应用规则 1——每个引用参数都会获得自己的生命周期。这里只有一个参数,所以只有一个生命周期。此时编译器推断出:
#![allow(unused)]
fn main() {
fn first_word<'a>(s:&'a str) -> &str {
//...
}
}
因为只有一个输入生命周期,规则 2 在这里也适用——如果恰好只有一个输入生命周期参数,那么该生命周期会被赋给所有输出生命周期参数。因此输入生命周期被赋给了输出生命周期。此时编译器推断出:
#![allow(unused)]
fn main() {
fn first_word<'a>(s:&'a str) -> &'a str {
//...
}
}
因为只有一个输入生命周期,且这个函数不是方法,所以规则 3 不适用。
现在函数中的每个引用都有了生命周期,因此编译器可以继续分析代码,而无需程序员手动标注函数签名中的生命周期。
2. 失败例
看第二个例子:
#![allow(unused)]
fn main() {
fn longest(x:&str, y:&str) -> &str {
//...
}
}
这个函数签名有两个引用输入,返回类型也是引用。尝试应用这三条规则:
首先应用规则 1——每个引用参数都会获得自己的生命周期。这里有两个参数,所以有两个生命周期:
#![allow(unused)]
fn main() {
fn longest<'a, 'b>(x:&'a str, y:&'b str) -> &str {
//...
}
}
因为有两个引用参数,规则 2 不适用。
因为这个函数不是方法,规则 3 不适用。
应用完三条规则后,返回值的生命周期仍然无法确定,所以编译器会报错。也就是说,你必须显式声明生命周期。
10.8 生命周期 Pt.4:方法定义中的生命周期标注与静态生命周期
10.8.1. 方法定义中的生命周期标注
还记得上一篇文章 10.7. 输入输出生命周期与3规则 中提到的三条生命周期省略规则吗?
规则 1: 每个引用参数都会获得自己的生命周期。 单参数函数有一个生命周期,双参数函数有两个生命周期,以此类推。
规则 2: 如果恰好只有一个输入生命周期参数,那么该生命周期会被赋给所有输出生命周期参数。 换句话说,如果只有一个输入生命周期,那么这个生命周期就是该函数所有可能返回值的生命周期。
规则 3: 如果有多个输入生命周期参数,但其中一个是 &self 或 &mut self(也就是说,这个函数是方法),那么 self 的生命周期会被赋给所有输出生命周期参数。
在 10.7. 输入输出生命周期与3规则 的例子中,我们应用了规则 1 和 2,但没有应用规则 3,因为规则 3 只适用于方法。所以这里要讲的就是规则 3,也就是方法定义中的生命周期标注。
方法需要一个结构体,而在结构体上使用生命周期来定义方法,其写法与泛型参数相同(见 10.7. 输入输出生命周期与3规则)。
在哪里声明和使用生命周期参数,取决于该生命周期参数是否与字段、方法参数或返回值有关。
结构体字段的生命周期名总是声明在 impl 关键字后面,然后在结构体名称后面使用,因为这些生命周期是结构体类型本身的一部分。
在 impl 块内的方法签名中,引用必须绑定到结构体字段引用的生命周期,也可以是独立的。此外,生命周期省略规则常常使方法中的生命周期标注变得不必要。
多说无益,看个例子:
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
首先定义了 ImportantExcerpt 结构体,然后为它定义了 level 方法。level 方法只接收 &self 作为参数,返回值是 i32,因此它没有引用任何东西。
“结构体字段的生命周期名总是声明在 impl 关键字后面,然后在结构体名称后面使用”这句话,指的就是第 4 行在 impl 后面写了 <'a>,在结构体名 ImportantExcerpt 后面也写了 <'a>。
注意,第 4 行的两个 <'a> 标注都不能省略,但 level 函数不需要在 &self 上加生命周期标注,因为适用了生命周期省略规则 1 和 2。
现在再添加一个方法:
#![allow(unused)]
fn main() {
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
}
根据生命周期省略规则 1,&self 和 announcement 参数各自获得一个生命周期:
#![allow(unused)]
fn main() {
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part<'a, 'b>(&'a self, announcement: &'b str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
}
根据生命周期省略规则 3,返回值被赋予与 &self 相同的生命周期:
#![allow(unused)]
fn main() {
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part<'a, 'b>(&'a self, announcement: &'b str) -> &'a str {
println!("Attention please: {announcement}");
self.part
}
}
}
至此,所有生命周期都已被推断出来,因此编译器可以成功编译代码。
10.8.2. 'static 生命周期
Rust 有一个特殊的生命周期叫做 'static,它表示整个程序的持续时间,或者说整个程序的执行时间。
例如,所有字符串字面值都拥有 'static 生命周期,比如:
#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}
这是一个字符串字面值,因此可以用 'static 标注。
字符串字面值拥有 'static 生命周期的原因是:它们被直接存储在二进制文件中,并在运行时放在静态内存中,因此总是可用。
在给普通引用指定 'static——编译器报错时经常会这样建议——之前,你必须仔细思考:你是否真的需要这个引用在整个程序持续期间都存活? 更大的可能是,编译器报错是因为悬空引用或生命周期不匹配。这时,你应该尝试解决这些问题,而不是简单地给它贴上一个 'static 生命周期。
10.8.3. 泛型类型参数、trait bound 与生命周期
最后,看一个同时使用了泛型类型参数、trait bound 和生命周期的例子:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() {
x
} else {
y
}
}
}
这个函数的作用是返回两个字符串切片 x 和 y 中较长的那个,但它现在多了一个参数 ann,代表 announcement。它的类型是泛型类型 T,而根据 where 中的约束,T 可以被替换为任何实现了 Display trait 的类型。
11.1 编写和运行测试
11.1.1. 什么是测试
在Rust里,测试就是一个函数,用于验证非测试代码的行为是否符合预期。
一个测试函数通常执行三个操作:
- 准备(Arrange)数据/状态
- 运行(Act)被测试的代码
- 断言(Assert)结果
这三个操作在有些语言里叫3A步骤。
11.1.2. 解剖测试函数
测试函数本质上仍然是一个函数;不同之处在于它必须用test属性(attribute)进行标注。
Attribute就是一段Rust代码的元数据。它不会改变被它修饰的代码的逻辑,只是对代码进行修饰,或者说标注。实际上在 5.2. struct使用例(加打印调试信息) 中就已经用到过。
在函数上加#[test],就可以把函数变为测试函数。
11.1.3. 运行测试
先不管测试函数内的内容,编写完测试函数之后,如何运行它呢?使用cargo test命令来运行所有测试。
这个命令会构建一个测试运行器(test runner)可执行文件。它会逐个运行标注了test的函数,并报告它们是否成功。
当使用Cargo创建library项目时,会生成一个test module,里面有一个现成的test函数,可以参照它来编写其他测试函数。实际上,你可以添加任意数量的test module或test函数。
看个例子:
创建一个名为adder的新库项目:
$ cargo new adder --lib
Creating library `adder` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd adder
打开项目(lib.rs):
#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
}
之所以这是一个测试函数,是因为它被#[test]标注了,而不是因为它位于test模块中。test模块里也可以包含普通函数。
使用cargo test来运行测试:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
分析一下这个输出:
- 首先是编译(Compiling)、完成(Finished)和运行(Running)。
- 接下来是
running 1 test,表示正在执行一个测试。下一行显示这个测试是tests::it_works,结果是ok。这个项目只有一个测试,但如果有多个测试,cargo test会全部运行。 - 然后是
test result: ok.,表示项目中的所有测试都通过了。具体来说,1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out表示1个通过、0个失败、0个被忽略、0个基准测试、0个被过滤掉。 Doc-tests adder指的是文档测试的结果。Rust能够编译出现在API文档中的代码,这有助于保证文档始终与实际代码保持同步。
如果把函数改名,输出哪里会变呢?
#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() { // 改名为 exploration
let result = add(2, 2);
assert_eq!(result, 4);
}
}
}
输出:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
可以看到,测试名从tests::it_works变成了tests::exploration。
11.1.4. 测试失败
测试函数一旦触发panic!,测试就失败。由于每个测试都在自己的线程中运行,主线程会监视这些线程。当主线程看到某个测试因触发panic!而崩溃时,那个测试就会被标记为失败。
看个例子:
#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
}
这个another函数直接调用了panic!。运行一下看结果:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED
failures:
---- tests::another stdout ----
thread 'tests::another' (448960) panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
tests::another失败了,而tests::exploration仍然是ok。失败原因是thread 'tests::another' panicked at src/lib.rs:17:9,意思是在src/lib.rs的第17行第9列触发了panic!,也就是源代码中写该宏的位置。
总结一下,test result: FAILED表示整体测试运行失败。更具体地说,是1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out。
11.2 断言(Assert)
11.2.1. 使用assert!宏检查测试结果
assert!宏来自标准库,用于判断某个条件是否为true。它接收一个返回类型为布尔值的表达式:
- 当
assert!内的值为true时,测试通过,assert!也不会做多余的操作。 - 当
assert!内的值为false时,assert!会调用panic!,测试失败。
看个例子:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
}
结构体Rectangle存储矩形的宽和高。它定义了can_hold方法,用于判断一个矩形能否容纳另一个矩形(不考虑斜着放)。逻辑很好理解:只要看当前矩形的宽和高是否都大于另一个矩形即可。
该如何测试这个方法呢?因为它的返回类型正好是bool,所以用assert!再合适不过:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
}
由于test是一个模块,所以test模块内如果想使用外部的内容,就必须先导入到当前作用域。这里写的是use super::*;,*会把外部模块的所有内容导入进test模块。有关这部分的详细内容,可以看 7.2. 路径(Path)Pt.1 和 7.3. 路径(Path)Pt.2。
然后看下面的测试函数。首先声明了两个矩形larger和smaller,分别存储大矩形和小矩形的宽高,这就是准备(Arrange)阶段。
下面的assert!宏调用了can_hold,这就是运行(Act)阶段。
最后用assert!来判断测试是否成功。
在这个例子中,larger存储的宽高绝对可以容纳smaller,所以结果一定是true,测试通过。
运行cargo test:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running unittests src/lib.rs (target/debug/deps/rectangle-2f89d610a9fe6c00)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
那如果小矩形容纳不了大矩形呢?
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
//...
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
}
又声明了另一个测试函数smaller_cannot_hold_larger。smaller.can_hold(&larger)一定返回false,但前面加了取反运算符!,所以最终assert!收到的仍然是true,测试通过:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running unittests src/lib.rs (target/debug/deps/rectangle-2f89d610a9fe6c00)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
两个测试都能通过,说明can_hold方法大概没问题。
现在改一下这个方法,把can_hold中的宽度比较从>改成<:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
}
逻辑现在就错了。再运行同样的测试函数:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s
Running unittests src/lib.rs (target/debug/deps/rectangle-2f89d610a9fe6c00)
running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... FAILED
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' (454276) panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
有一个测试失败了,说明错误被成功捕获了。这也是编写测试的目的:尽早发现问题。
11.2.2. 使用assert_eq!和assert_ne!测试相等性
assert_eq!中的eq指的是equal(相等),assert_ne!中的ne指的是not equal(不相等)。这两者都来自标准库。
这两个宏接收两个参数,并判断这两个值是否相等。通常把被测试代码的结果作为一个参数,把期待的结果作为另一个参数,然后宏就会检查这两个结果是否相等。
实际上,这两个宏的用法很像==和!=运算符。不同之处在于,如果失败,它们会自动打印出两个参数的值,从而帮助开发者理解测试失败的原因。
使用这两个宏有一定要求。它们用debug格式打印值,所以参数必须实现PartialEq和Debug这两个trait。所有基本类型和大部分标准库类型都已经实现了,但自定义结构体和枚举必须自行实现这些trait。这两个trait都是可派生的,所以对自定义类型通常只要这样写即可:
#![allow(unused)]
fn main() {
#[derive(PartialEq, Debug)]
struct Point {
x: i32,
y: i32,
}
}
加上这个标注后,就可以用assert_eq! / assert_ne!比较Point值,并且断言失败时能够打印出这些值。
下面是一个使用assert_eq!的例子:
#![allow(unused)]
fn main() {
pub fn add_two(a: usize) -> usize {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
}
add_two函数会给参数加2。测试函数it_adds_two调用了add_two;因为2 + 2 = 4,所以期待的add_two(2)的值是4,把4和函数调用放进宏里即可。其实在Rust中,期待的值和函数调用的位置是可以互换的。有些语言对顺序有明确要求,但Rust没有。放在左边(第一个参数)的值只是叫做左值,另一个叫做右值。
输出:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
接下来引入一个逻辑错误,把add_two的a + 2改成a + 3,其余不变,看看会发生什么:
#![allow(unused)]
fn main() {
pub fn add_two(a: usize) -> usize {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
}
输出:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' (455023) panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
测试抓住了这个bug。失败信息显示left是5(也就是add_two(2)的结果),right是4。
另外还有assert_ne!:两个值不相等时通过,相等时失败。它最适合用在你不确定具体会得到什么值、但知道它绝对不该是某个值的场合。
11.3 自定义错误信息
11.3.1. 添加错误信息
在 11.2. 断言(Assert) 中我们学习了assert!、assert_eq!和assert_ne!这三个宏,而这篇文章讲的是它们的进阶用法。
这三个宏可以接收自定义错误信息,但这是可选项。如果添加了自定义信息,它们会和标准的失败信息一起打印出来:
- 对于
assert!,第一个参数是必填的,自定义信息作为第二个参数。 - 对于
assert_eq!和assert_ne!,前两个参数是必填的,自定义信息作为第三个参数。
把自定义信息传进去之后,它会被传给format!宏来构建字符串。由于format!可以使用{}占位符,所以传进去的信息也可以使用占位符。
看个例子:
#![allow(unused)]
fn main() {
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
}
greeting接收一个名为name的字符串切片参数,并返回由Hello、name和!拼接而成的字符串。greeting_contains_name测试函数先把greeting("Carol")的返回值赋给result,然后在result上调用contains方法,检查result是否包含"Carol"。
这段代码现在可以通过测试。
现在手动引入一个bug,修改greeting函数:
#![allow(unused)]
fn main() {
pub fn greeting(name: &str) -> String {
format!("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
}
这样测试会失败:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.14s
Running unittests src/lib.rs (target/debug/deps/greeter-647141eed08f233c)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (455732) panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
不过失败信息只说在第12行第9列发生了恐慌,并没有提供更友好、更有用的信息。怎么办呢?添加自定义信息:
#![allow(unused)]
fn main() {
pub fn greeting(name: &str) -> String {
format!("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
}
输出:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.09s
Running unittests src/lib.rs (target/debug/deps/greeter-647141eed08f233c)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' (456094) panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
可以看到,自定义信息出现在了错误输出里。这样的信息更有实际意义,也更容易找出错误原因。
11.4 用should_panic检查恐慌
11.4.1. 验证错误处理的情况
除了验证代码是否返回正确的值,测试还需要验证代码是否如预期处理了错误情况。例如,可以编写一个测试来验证代码是否在特定条件下发生恐慌。
这类测试需要额外的should_panic属性。用它标记的函数,如果函数内发生了恐慌,测试就通过;否则就失败。
看个例子:
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
}
- 结构体
Guess有一个类型为i32的字段value。它提供关联函数new用于创建Guess实例,但前提是传给new的参数在1到100之间;否则就会恐慌。 greater_than_100测试函数给new传入大于100的值。这时应该发生恐慌,所以给这个测试函数加了should_panic属性,也就是写#[should_panic]。
测试结果:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running unittests src/lib.rs (target/debug/deps/guessing_game-bdc6b9a45c563cc0)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
现在故意引入一个bug,把new里的value > 100检查去掉:
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
}
这时测试函数中的Guess::new(200);不会恐慌。但因为函数加了should_panic标记,本应恐慌却没有恐慌的测试就会失败:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.09s
Running unittests src/lib.rs (target/debug/deps/guessing_game-bdc6b9a45c563cc0)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
11.4.2. 让should_panic更精确
有时使用should_panic的测试会有点含糊,因为它们只能说明代码是否发生了恐慌,即使这个恐慌并不是程序员预期的那个。
为了让测试更精确,可以为should_panic添加一个可选的expected参数。这样程序就会检查失败消息中是否包含指定的文字。
看个例子:
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
}
- 上面的结构体稍作了修改:
new里value < 1和value > 100的情况现在使用两条不同的恐慌信息。 - 给
should_panic添加了expected参数,=后面的文字就是期待的错误信息。只有函数发生了恐慌,并且恐慌信息包含期待的文字,测试才通过;否则就失败。
这个程序肯定能通过。
用同样的套路手动引入错误。例如,把new里小于1和大于100时的恐慌信息交换一下:
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
}
测试结果:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running unittests src/lib.rs (target/debug/deps/guessing_game-bdc6b9a45c563cc0)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' (457156) panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Guess value must be greater than or equal to 1, got 200."
expected substring: "less than or equal to 100"
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
失败信息表明测试确实发生了恐慌,但恐慌消息没有包含期待的字符串less than or equal to 100。在这种情况下,我们实际收到的恐慌信息是Guess value must be greater than or equal to 1, got 200.。根据这些信息就足以修复这个bug。
11.5 在测试中使用Result<T, E>
11.5.1. 测试函数返回Result枚举
到目前为止,测试失败的原因一直都是panic!,但这并不是测试失败的唯一方式。
使用Result枚举的测试也比较好写。只需要接收被测试代码的返回值:如果符合预期,就返回Ok变体;否则返回Err变体。又因为枚举变体可以附带数据,还可以在Err上附带错误信息,帮助调试。
如果是Ok,测试就通过;否则就失败。
看个例子:
#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
}
it_works函数的返回类型是Result<(), String>。测试通过时返回Ok(());测试失败时返回包含错误信息String的Err。
这个测试肯定能通过。
让测试返回Result<T, E>,还可以在测试函数体里使用?运算符:任何一步返回Err时,测试就会失败,写起来很方便。
使用Result进行测试时有一点要注意:不要在用Result<T, E>编写的测试上使用should_panic属性(11.4. 用should_panic检查恐慌 中讲过)。若要断言某个操作返回Err,应使用类似assert!(value.is_err())的写法,而不是?。
11.6 控制测试运行:并行和串行(连续执行)测试
11.6.1. 控制测试如何运行
和cargo run一样,cargo test也会编译代码并生成用于测试的二进制文件,只不过cargo test是在测试模式下运行。
可以通过传参来改变cargo test的行为。如果不传任何参数,默认行为是:
- 并行运行所有测试
- 在测试通过时捕获所有输出,以便更容易阅读与测试相关的输出。如果测试失败,则会显示输出,方便程序员调试。
命令行参数分为两类:
- 针对
cargo test本身的参数,紧跟在cargo test后面 - 针对生成的可执行文件的参数,放在
--之后。例如,cargo test -- --help会显示所有能放在--之后的参数,也就是所有针对可执行文件的参数。
11.6.2. 并行运行测试
运行多个测试时,Rust默认使用多个线程,以便并行运行测试。这样更快,但这些测试彼此之间不能有依赖,也不能依赖共享状态,比如环境、工作目录或环境变量。
如果两个测试依赖共享状态,而其中一个测试在另一个完成之前改了这个状态,那么共享同一状态的其他测试就会受到影响。
如果不想并行运行测试,或者想精确控制使用多少线程,可以使用--test-threads参数,它会传给二进制文件。在这个参数后面直接写上线程数量。
例如,cargo test -- --test-threads=1使用一个线程,这意味着多个测试会比并行执行花更长时间。但它也有优点:因为测试是顺序执行的,所以它们更不容易因为共享状态而互相干扰。
11.6.3. 显示函数输出
默认情况下,如果测试通过,Rust的test库会捕获写到标准输出的内容,比如println!的输出。如果测试失败,打印出的内容会和失败信息一起显示。
看个例子:
#![allow(unused)]
fn main() {
fn prints_and_returns_10(a: i32) -> i32 {
println!("I got the value {a}");
10
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_test_will_pass() {
let value = prints_and_returns_10(4);
assert_eq!(value, 10);
}
#[test]
fn this_test_will_fail() {
let value = prints_and_returns_10(8);
assert_eq!(value, 5);
}
}
}
- 被测试的函数
prints_and_returns_10会打印它收到的值,然后返回10。 this_test_will_pass测试把4传给该函数,所以函数会打印4,再把固定的返回值和10比较。这个测试会成功。this_test_will_fail测试把8传给该函数,所以函数会打印8,再把固定的返回值和5比较。这个测试会失败。
一种可能的测试结果如下(默认并行执行时,test … ok / FAILED 行的顺序在不同运行之间可能不同):
$ cargo test
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running unittests src/lib.rs (target/debug/deps/silly_function-29f9dcbce4b62bb8)
running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' (457588) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::this_test_will_fail
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
成功的情况不会出现在测试输出中,但失败的情况会:I got the value 8。
如果希望成功的测试也打印输出,加上标志cargo test -- --show-output。并行测试的结果展示顺序可能不同,这里只展示我的一次输出:
$ cargo test -- --show-output
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
Running unittests src/lib.rs (target/debug/deps/silly_function-29f9dcbce4b62bb8)
running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED
successes:
---- tests::this_test_will_pass stdout ----
I got the value 4
successes:
tests::this_test_will_pass
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' (461014) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::this_test_will_fail
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
11.7 按测试的名称运行测试
11.7.1. 按名称运行测试的子集
如果想选择运行哪些测试,就把测试名称(一个或多个)作为参数传给cargo test。
看个例子:
#![allow(unused)]
fn main() {
pub fn add_two(a: usize) -> usize {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_and_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
#[test]
fn add_three_and_two() {
let result = add_two(3);
assert_eq!(result, 5);
}
#[test]
fn one_hundred() {
let result = add_two(100);
assert_eq!(result, 102);
}
}
}
如果只想运行one_hundred这个测试,就写cargo test one_hundred:
$ cargo test one_hundred
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::one_hundred ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
运行单个测试时,直接指定它的精确名称即可。运行多个测试时,指定测试名的一部分(模块名也可以)作为参数,所有匹配该名称的测试都会运行。
举个例子,假如我想运行add_two_and_two()和add_three_and_two,这两个测试的名称都含有add,就可以写cargo test add:
$ cargo test add
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.11s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
11.8 忽略测试
11.8.1. 忽略某些测试,运行其余测试
有些测试运行起来非常耗时,所以在大多数情况下,运行cargo test时可能想忽略它们,除非显式运行这些测试。
对于这些测试,Rust提供了ignore属性,将它们标记为默认不运行。
看个例子:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
#[ignore]
fn expensive_test() {
assert_eq!(5, 1 + 1 + 1 + 1 + 1)
}
}
}
因为expensive_test加了ignore属性,所以在cargo test下不会运行,除非你显式要求运行它。
测试输出如下:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.10s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
11.8.2. 只运行被忽略的测试
如何只运行被忽略的测试呢?加上参数cargo test -- --ignored:
$ cargo test -- --ignored
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.11s
Running unittests src/lib.rs (target/debug/deps/adder-302521ba8d0f0bdf)
running 1 test
test tests::expensive_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
控制程序运行哪些测试,可以确保cargo test快速返回。如果时间充裕,想运行所有测试(包括被忽略的和未被忽略的),就用cargo test -- --include-ignored。
11.9 单元测试
11.9.1. 测试的分类
Rust把测试分为两类:单元测试和集成测试。
- 单元测试比较小也比较专注。每一个都单独测试一个模块,还可以测试私有接口。
- 集成测试完全位于代码库之外。它们使用你的代码的方式和其他外部代码一样。集成测试只能访问公共接口,并且每个测试可能用到多个模块。
11.9.2. #[cfg(test)]标注
单元测试的目的是把一小段代码隔离出来,从而快速判断它的行为是否符合预期。我们通常把单元测试和被测试的代码放在src下的同一个文件中。
按照惯例,每个源文件还应有一个test模块来存放这些函数,并用#[cfg(test)]标注测试模块。带有这个标注的代码只有在执行cargo test时才会被编译和运行;运行cargo build时不会编译。
以上是单元测试的规则。集成测试位于不同的目录,因此不需要#[cfg(test)]标注。
#[cfg(test)]中的cfg是configuration(配置)的缩写。使用它相当于告诉Rust:被标注的条目只有在指定的配置选项下才会被包含。
看个例子:
#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
}
#[cfg(test)]中的配置选项是test。这个配置选项由Rust提供,用于编译和运行测试;只有在执行cargo test时,才会编译和运行#[cfg(test)]下的条目。
11.9.3. 测试私有函数
Rust允许测试私有函数,其他语言不一定如此。
看个例子:
#![allow(unused)]
fn main() {
pub fn add_two(a: usize) -> usize {
internal_adder(a, 2)
}
fn internal_adder(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
let result = internal_adder(2, 2);
assert_eq!(result, 4);
}
}
}
即使internal_adder没有用pub声明为公共,它仍然可以在测试模块中被调用。
11.10 集成测试
11.10.1. 什么是集成测试
在Rust里,集成测试完全位于被测试库的外部。集成测试调用库的方式和其他代码一样,这也意味着它们只能调用公共API。
集成测试的目的是验证库的多个部分能否正确地一起工作。 这一点有别于单元测试;单元测试比较小也比较专注。单元测试单独测试一个模块,还可以测试私有接口。
有时单独运行没问题的代码,合在一起使用时仍可能出问题。集成测试正是为了尽早发现并解决这类问题而存在的。因此,集成测试的覆盖率很重要。
11.10.2. tests目录
要创建集成测试,首先创建tests目录。
这个目录与src并列,cargo会自动在那里寻找集成测试文件。你可以在这个目录下创建任意数量的集成测试文件。编译时,cargo会把每个测试文件当作一个单独的包,也就是一个单独的crate。
下面演示如何创建集成测试文件:
1. 创建tests目录
在src旁边创建一个名为tests的文件夹:

2. 创建测试文件
在tests下创建一个.rs测试文件,并给它取名。这里我用的是integration_test.rs:

3. 把测试代码移到测试文件里
以 11.9. 单元测试 的代码为例(lib.rs):
#![allow(unused)]
fn main() {
pub fn add_two(a: usize) -> usize {
internal_adder(a, 2)
}
fn internal_adder(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
let result = internal_adder(2, 2);
assert_eq!(result, 4);
}
}
}
因为每个集成测试文件都是一个单独的crate,所以这个文件(integration_test.rs)如果想测试那个crate,就必须先把lib.rs的内容导入作用域。
在这个例子中,由于我把项目命名为RustStudy,所以包名也是RustStudy。如果不清楚,可以到Cargo.toml里看name字段。在这个例子中,可以写use RustStudy;来导入;如果想导入某个具体函数也可以。
导入之后可以直接写测试函数。不需要写#[cfg(test)],因为tests目录下的代码只有在执行cargo test时才会运行。只需要给测试函数标注#[test]即可。
完整代码如下(integration_test.rs):
#![allow(unused)]
fn main() {
use RustStudy;
#[test]
fn it_adds_two() {
let result = RustStudy::add_two(2);
assert_eq!(result, 4);
}
}
输出:
$ cargo test
Compiling RustStudy v0.1.0 (file:///projects/RustStudy)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.15s
Running unittests src/lib.rs (target/debug/deps/RustStudy-48a2c23cb22e1ddc)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-e60608d740742c0c)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests RustStudy
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
可以看到,这个输出显示运行了两个测试:一个来自lib.rs(单元测试),一个来自integration_test.rs(集成测试)。
11.10.3. 运行指定的集成测试
要运行某个特定的集成测试函数,使用cargo test <test_name>。要运行某个测试文件中的所有测试函数,使用cargo test --test <file_name>。
看个例子:

现在tests下有两个文件。如果我只想运行integration_test.rs里的测试函数,可以运行:
cargo test --test integration_test
11.10.4. 集成测试中的子模块
因为tests下的每个文件都被编译成单独的crate,所以这些文件彼此不共享行为,这与src下的文件不同。
那么,如果想把测试函数中重复的逻辑提取到一个helper函数里以避免重复,该怎么写呢?
例如,我在tests下创建了common.rs来存放helper函数:

试着运行测试:
$ cargo test
Compiling RustStudy v0.1.0 (file:///projects/RustStudy)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
Running unittests src/lib.rs (target/debug/deps/RustStudy-48a2c23cb22e1ddc)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/common.rs (target/debug/deps/common-5306c3915df25199)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-e60608d740742c0c)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests RustStudy
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
可以看到,common.rs出现在了测试输出中。但因为common.rs只是用来存放helper函数的,它本身不需要被测试。这种写法是错误的。
正确做法是在tests下创建common目录,在里面放一个mod.rs文件,并把helper函数移过去,然后删除原来的common.rs:

这是Rust能理解的另一种命名约定。Rust不会把common模块当作集成测试文件,测试输出中也不会再出现common,因为tests下的子目录不会被编译成单独的crate。
如果要在集成测试文件中使用那里的内容,只需在文件开头写mod <folder_name>;。在这个例子中就是mod common;。使用时写common::your_function。在这个例子中就是common::setup()。
11.10.5. 针对二进制crate的集成测试
如果项目是二进制crate,也就是只有src/main.rs而没有src/lib.rs,就不能在tests下创建集成测试;即使创建了,也无法把main.rs里的函数导入作用域。因为只有库crate(也就是有lib.rs的)才能把函数暴露给其他crate使用。
二进制crate意味着独立运行。因此,Rust的二进制项目通常会把这些逻辑放在lib.rs里,而在main.rs里只保留简单调用。这样项目就会被视为库crate,就可以用集成测试来检查代码。
12.1 接收命令行参数
12.1.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数(本文)
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.1.1. 规范输入格式
我们首先要规范一个输入格式来固定用于传入参数的方式,我这里是这么规定的:
cargo run 文本内容 指定的文件.txt
12.1.2. 读取命令行参数
完成了对输入的规范,接下来就要解决读取命令行参数的问题。
这里需要使用一个由Rust标准库提供的函数std::env::args()。这个函数会返回迭代器(13.5. 迭代器 Pt.1 会讲),产生一系列的值。对于迭代器可以使用collect这个方法把这一系列的值转化为一个集合,比如说一个Vector。
在 7.4. use关键字 Pt.1 讲过,当函数被嵌套着不止一层的模块时,通常将其父模块引入作用域。
代码如下:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
}
由于collect会产生集合,但是集合内的元素类型Rust无法推断,所以在声明时需要显式声明args的类型是Vec<String>。
使用dbg!来看看效果如何吧:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
dbg!(args);
}
输出1(没有带任何参数):
$ cargo run
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
Running `target/debug/minigrep`
[src/main.rs:5:2] args = [
"target/debug/minigrep",
]
输出2(带了参数):
$ cargo run -- needle haystack
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/minigrep needle haystack`
[src/main.rs:5:2] args = [
"target/debug/minigrep",
"needle",
"haystack",
]
在第二个例子中的--是用来区分Cargo 命令的参数和传递给程序的参数的。它的作用是告诉 Cargo,接下来的内容不是 Cargo 的选项或参数,而是运行程序时需要传递给程序的参数。env::args并不会读取并存储它。
可以看到,即使不带参数,这个Vector都会有一个元素,其值是当前执行的这个二进制程序,也就是这个例子里的“target/debug/minigrep“。所以说实际上我们需要的参数得从args的第二个元素开始获取,也就是索引1的位置。
知道了需要的参数存放在哪个位置,就可以声明变量来存储了。声明一个query用于存储需要查找的文本,声明一个filename来存储指定的文件的名称:
#![allow(unused)]
fn main() {
let query = &args[1];
let filename = &args[2];
}
这样写是没有问题的,如果用户输入的参数缺失导致索引越界了Rust会直接恐慌停止程序。当然使用match和get函数的组合也可以:
#![allow(unused)]
fn main() {
let query = match args.get(1) {
Some(arg) => arg,
None => panic!("No query provided"),
};
let filename = match args.get(2) {
Some(arg) => arg,
None => panic!("No file name provided"),
};
}
这里就使用第一种方法。
再通过打印出这两个变量来让用户确认自己的输入:
#![allow(unused)]
fn main() {
println!("Searching for {}", query);
println!("In file {}", filename);
}
12.1.3. 整体代码
以下就是截止到本文所写出的所有代码:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
}
12.2 读取文件
12.2.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
12.2.2. 回顾
这是截止到上文所写出的所有代码:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
}
代码到此解决了读取用户指令的部分,接下来要根据用户的输入读取文件。
12.2.3. 读取文件
为了实现读取文件的操作,需要引入std::fs,这个模块可以处理与文件相关的事物:
#![allow(unused)]
fn main() {
use std::fs;
}
接下来,根据filename来读取文件:
#![allow(unused)]
fn main() {
let contents = fs::read_to_string(filename);
}
当然,读取会可能发生错误,所以它的返回值并不直接就是内容而是Result枚举,针对这个枚举,可以使用expect方法来解包,expect方法的参数是如果发生错误时打印的错误信息(expect方法在 9.2. Result枚举与可恢复的错误 Pt.1 中有详细介绍)。
#![allow(unused)]
fn main() {
let contents = fs::read_to_string(filename)
.expect("Something went wrong while reading the file");//这里换行只是为了这行不过长
}
如果能成功读取,就把读取到的内容打印出来:
#![allow(unused)]
fn main() {
println!("With text:\n{}", contents);
}
12.2.4. 代码测试
实现到这一步,可以对代码进行一些测试了。
这是截止到目前所写出的所有代码:
use std::env;
use std::fs;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong while reading the file");//这里换行只是为了这行不过长
println!("With text:\n{}", contents);
}
首先在项目目录下创建一个.txt文本,名字可以自己取,我取的是poem.txt,然后在里面随便放点文本内容,我放的是:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
然后输入命令:
cargo run -- the poem.txt
- 这里的
--是代表后面所写的是参数是用来区分Cargo 命令的参数和传递给程序的参数的。它的作用是告诉 Cargo,接下来的内容不是 Cargo 的选项或参数,而是运行程序时需要传递给程序的参数。它并不会被读取和存储。 the对应的就是要查找的内容,会被存储在query中poem.txt就是文件名,会被存储在filename中
输出:
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
没有任何问题。
12.3 重构 Pt.1:改善模块化
12.3.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理(本文)
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.3.1. 重构的目的
重构的目的是要增进模块化的程度以及改善错误处理能力。
以下是截止到上一篇文章所写出的全部代码:
use std::env;
use std::fs;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
这个代码存在4个问题:
-
main函数负责的功能太多,它既负责命令行的功能解析,又负责读取文件。程序代码的编写原则是每一个函数只负责一个功能,所以说最好把函数拆开。 -
query和filename这两个变量是用来存储程序配置的,contents是用来存储文件内容的。随着代码和变量在编写时越来越多,每个变量的实际意义就变得难以追踪。所以最好把这些变量存在结构体里。 -
读取文件时使用
expect来处理错误,不论读取时出现了什么错误都只会打印出错误信息并恐慌,这并不是最好的处理方式。因为文件读取失败可能是文件找不到,也有可能是权限问题,现在指定的这个恐慌信息“Something went wrong while reading the file“并不能帮助用户排查错误。 -
如果程序里到处都使用
expect方法那么用户得到的报错信息是来自于Rust语言内部的,比如"Index out of bounds",用户根本不明白到底是什么引发了错误。最好是将错误的代码集中放置,从而使将来的维护者在需要修改错误处理相关的逻辑时只考虑这一处代码,也能确保向用户打印的错误信息是易于理解的。
12.3.2. 二进制程序关注点分离的指导性原则
很多Rust二进制项目都会面临同样的组织结构问题,它们将过多的功能和过多的任务都放到了main函数里面。针对这种情况,Rust社区做了一套为二进制程序进行关注点分离的指导性原则:
- 将程序拆分为
main.rs和lib.rs,将业务逻辑放入lib.rs - 当逻辑较少时,将它放在
main.rs也可以 - 当逻辑变复杂时,需要将它从
main.rs提取到lib.rs
经过上述拆分之后,这个例子中应该留在main函数中的功能有:
- 使用参数值调用命令行解析逻辑
- 进行其它配置
- 调用
lib.rs中的run函数 - 处理
run函数可能出现的问题
12.3.3. 分离逻辑
再看一眼代码:
use std::env;
use std::fs;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
先把获取命令行参数的部分独立出来:
#![allow(unused)]
fn main() {
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let filename = &args[2];
(query, filename)
}
}
&[String]表示是一个内部元素为String的Vector切片- 这里没有打印
query和filename的必要了,所以就去掉
然后改一下main函数,调用parse_config:
fn main() {
let args:Vec<String> = env::args().collect();
let (query, filename) = parse_config(&args);
let contents = fs::read_to_string(filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
12.3.4. 使用结构体
parse_config内把query和filename组合成元组返回,在main函数里又把元组的两个值拆分为两个变量,这种来回拆分合成表明程序中建立的抽象结构有问题。
query和filename都是配置的一部分,两者是彼此相关联的,把这两个东西放在元组里不足以表达出这种抽象的关联。最好的办法是放在结构体里:
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = parse_config(&args);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config {
query,
filename,
}
}
parse_config中必须注意:对args做索引得到的是&String(引用),因为形参args的类型是&[String],并不拥有数据。但Config这个结构体需要的是拥有所有权的String而不是&String,所以需要通过克隆来获得所有权。
克隆虽然比直接存储引用消耗了更多时间和内存,但它省去了处理生命周期的麻烦,让代码更加直接简单。在某些场景中,放弃一些性能来获取更多的简洁性是非常值得考虑的。
当然,使用String::from函数来封装也是可以的:
#![allow(unused)]
fn main() {
fn parse_config(args: &[String]) -> Config {
let query = &args[1];
let filename = &args[2];
Config {
query: String::from(query),
filename: String::from(filename),
}
}
}
当然可行的代码可能不止这两种,这里我就采用第一种克隆的方法。
12.3.5. 把函数变为结构体的方法
既然parse_config会创建一个Config的实例,也就是说它是一个构造函数。对于构造函数,可以这么写:
#![allow(unused)]
fn main() {
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config {
query,
filename,
}
}
}
}
只需要把这个函数写在Config的方法上即可(对于方法的详细解释,详见 5.3. struct的方法(Method))。这里还给parse_config改了个名叫new,是因为我把它当作了一个构造函数来处理(构造函数一般都命名为new)。
这么改,main函数里面也需要改一下:
#![allow(unused)]
fn main() {
let config = Config::new(&args);
}
12.3.6. 整体代码
以下是截止到本篇文章所写出的所有代码:
use std::env;
use std::fs;
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config {
query,
filename,
}
}
}
12.4 重构 Pt.2:错误处理
12.4.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理(本文)
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.4.1. 回顾
上一节中为了模块化我们为变量创建了结构体,还把读取指令的函数独立出去改成了结构体的方法。以下是截止到上一篇文章所写出的所有代码:
use std::env;
use std::fs;
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config {
query,
filename,
}
}
}
12.4.2. 意料之外的输入
这个程序能正确运行的前提是用户输入的输入无误,那我们试试不带参数的输入会引发什么:
$ cargo run
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.18s
Running `target/debug/minigrep`
thread 'main' (469490) panicked at src/main.rs:20:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
它提示"Index out of bounds"索引越界,作为程序编写者的我们明白这是因为参数不够导致程序在使用索引获取参数时越界触发恐慌。但是作为用户就不可能看懂这个报错信息,无法纠正错误。
这一篇文章要做的就是让程序产生的错误信息易于理解。
12.4.3. 指定报错信息
让用户理解报错信息的方式就是自己指定一个报错信息。刚刚的例子是在运行Config::new时索引越界,所以我们就修改这个地方:
#![allow(unused)]
fn main() {
impl Config {
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Config {
query,
filename,
}
}
}
}
如果args的元素数量小于三就发生恐慌打印“not enough arguments“来提示用户输入的参数太少了。
再试试不带参数的输入:
$ cargo run
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/minigrep`
thread 'main' (465520) panicked at src/main.rs:21:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这一次的错误信息比上一次就好很多了。
但是它仍然残留了一些其他的信息,比如“thread ‘main’ (465520) panicked at src/main.rs:21:13:“和“note: run with RUST_BACKTRACE=1 environment variable to display a backtrace”,这些内容是给程序员看的不是给用户看的。所以这些信息也得去掉。
12.4.4. 使用Result类型
panic!适用于程序本身出现问题时的恐慌,而这里缺少参数的输入是程序使用时的问题,针对这种问题,使用Result类型来传播错误(这部分的内容详见 9.2. Result枚举与可恢复的错误 Pt.1 & Pt.2)才是最优解:
#![allow(unused)]
fn main() {
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
}
- 报错的信息需要用
Err包裹,成功的返回值需要用Ok包裹 Result类型的Ok返回Config实例,Err返回&str字符串字面值,但是编译器不知道这个&str是从哪里来的以及它的生命周期有多长,所以得带生命周期,我们需要它在程序运行时始终保持有效,写成&'static str这个静态生命周期。
new函数的返回值都变了,main函数里接收值的逻辑也得变:
#![allow(unused)]
fn main() {
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
}
unwrap_or_else这个方法会接收Result类型,如果是Ok,就会把Ok附带的值直接返回赋给变量,类似于unwrap;如果是Err,那么这个方法会调用一个闭包(closure)。
闭包是我们定义的匿名函数,并将其作为参数传递给unwrap_or_else。其写法是两个管道符||,在中间放变量名,相当于一个参数,这里就放了err,这个err可以在闭包的函数体内被调用,比如在打印错误时就使用了err。
然后使用标准库的process::exit这个函数,使用前记得先导入一下:use std::process;,如果调用exit函数,程序的执行就会立即终止,而其参数,也就是示例代码中的1就作为程序退出时的状态码,这样显示到println!("Problem parsing arguments: {}", err);之后程序就会终止,自然就不会有比如“thread ‘main’ (465520) panicked at src/main.rs:21:13:“和“note: run with RUST_BACKTRACE=1 environment variable to display a backtrace“这些内容。
试一下:
$ cargo run
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
闭包这个概念在 13.1. 闭包 Pt.1 才会讲到,这里没看懂也没关系,只要了解个大概即可。
12.4.5. 整体代码
以下是截止到这篇文章写出的所有代码:
use std::env;
use std::fs;
use std::process;
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
12.5 重构 Pt.3:移动业务逻辑
12.5.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理(本文)
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.5.1. 回顾
之前两节分别做了模块化的优化和错误处理,这节在此基础上还要做进一步的优化。
以下是截止到上一篇文章所写出的全部代码:
use std::env;
use std::fs;
use std::process;
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
12.5.2. 从main函数中提取逻辑
在 12.3. 重构 Pt.1 中说过二进制程序关注点分离的指导性原则:
- 将程序拆分为
main.rs和lib.rs,将业务逻辑放入lib.rs - 当逻辑较少时,将它放在
main.rs也可以 - 当逻辑变复杂时,需要将它从
main.rs提取到lib.rs
根据上述拆分原则,我们应该把main函数里所有除了配置解析和错误处理之外的所有逻辑单独提取到一个run函数里。把main函数精简到足以通过阅读代码来检查正确性,而其他的逻辑就可以通过测试验证了(对于测试这部分的内容,详见 11.1. 编写和运行测试)。
对于这个截止到目前的代码,run函数应该是:
#![allow(unused)]
fn main() {
fn run(config: Config) {
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
}
main函数里也改为通过调用run函数来读取:
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
run(config);
}
12.5.3. 改善run函数的错误处理
现在的run函数对于读取错误的情况采用的是expect。而这种错误处理会调用panic!,我们需要的是像Config::new这样使用Result类型来传播错误,就应该这么写:
#![allow(unused)]
fn main() {
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
}
-
Result类型的Ok对应的是()类型(单元类型),这种类型表示什么也不返回,什么也没有,因为run函数正确执行确实什么都不需要返回。这个函数体的最后一行Ok()里加了()就代表返回Ok变体,并且包裹了一个单元类型。 -
Result的Err对应的是Box<dyn Error>,这个东西你暂且不需要深入了解,只需要知道它代表所有实现了std::error::Error这个trait的类型(这里只写了Error是因为我在代码开头写了use std::error::Error;,把它引入了作用域),但是不需要指定具体的类型。这意味着在不同的场景下可以返回不同的错误类型。dyn是dynamic动态一词的简写。 -
?这个符号在 9.3. Result枚举与可恢复的错误 Pt.2 中有详细讲过,这里就再简单讲一下:read_to_string的返回值是Result类型。加了?表示如果read_to_string的返回值是Ok,就把Ok所关联的值返回赋值给变量;如果是Err,那么会直接终止这个函数的运行,把Err及其所附带的错误信息返回。也就是说,加?的效果等同于:
#![allow(unused)]
fn main() {
let contents = match fs::read_to_string(config.filename){
Ok(contents) => contents,
Err(e) => return Err(e.into()),
};
}
(e.into()会把io::Error转换成Box<dyn Error>,这样返回类型才能对上;?运算符会替你完成这种转换。)
这么改之后就会把错误传播给调用者,也就是main函数,所以在main函数里得处理可能出现的错误:
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
这里使用到的if let是match的一个语法糖,把它理解为只处理一种分支的match即可,详细可见 6.4. 简单的控制流-if let。需要强调,if let和if不是同一回事,不要把它们相提并论。
12.5.4. 迁移业务逻辑
现在我们完成了所有函数的独立和错误处理,接下来要做的就是把它们移到lib.rs里。
迁移的对象就是这些函数、结构体和相关的引用。
迁移后的成果(lib.rs):
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
}
注意:所有的被main.rs使用的结构体、结构体上的方法和函数都得在声明时加pub关键字来声明为公共的才能被调用。
再看看main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
所有的重构任务已经完成,下一步就是编写测试(12.6. 使用TDD(测试驱动开发)开发库功能)。
12.6 使用TDD(测试驱动开发)开发库功能
12.6.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能(本文)
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.6.1. 回顾
以下是截止到上一篇文章为止所写出的全部代码。
lib.rs:
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
在前几节中我们完成了对业务逻辑的迁移,把它分离到lib.rs里。这样对编写测试帮助很大,因为lib.rs中的逻辑不需要在命令行下运行就可以直接使用不同的参数调用业务功能函数,并校验其返回值,也就是针对业务逻辑进行测试。
12.6.2. 什么是测试驱动开发TDD
TDD是Test-Driven Development的缩写,中文名为测试驱动开发,一般遵循以下步骤:
- 编写一个会失败的测试,运行该测试,并确保它是按照预期的原因失败
- 编写或修改刚好足够的代码,让新测试通过
- 重构刚刚添加或修改的代码,确保测试会通过
- 返回步骤1,继续
TDD只是众多软件开发方法中的一种,但是它能对代码的设计工作起到指导和帮助的作用。先编写测试,然后再编写能够通过测试的代码也有助于开发过程中保持较高的测试覆盖率。
本篇文章会通过测试驱动开发的步骤完成程序的搜索逻辑——在文件内容中搜索指定的字符串,将符合的内容的行放在一个列表中。这个函数会被命名为search。
12.6.3. 修改代码
按照TDD的步骤来写代码:
1. 编写会失败的测试
首先到lib.rs里编写一个测试模块:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."],search(query, contents));
}
}
}
也就是说,因为query存储的“duct“在“safe, fast, productive.“这一行,所以返回值会是元素为字符串切片(&str)的Vector,并且只有一个元素,内容会是“safe, fast, productive.”
返回值是Vector是因为search函数预期能处理多个符合的结果,当然这个测试函数只可能有一个结果,这个测试函数取名叫one_result也是因为如此。
写好了测试模块,接下来写search函数:
#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
}
- 为了让这个函数能被外部函数调用,得使用
pub来声明为公共的 - 这个函数得加生命周期标志,因为有多个非
self的参数,Rust无法判断哪个参数的生命周期跟返回值的生命周期相同。 - 返回值
Vector内的元素是字符串切片,是从contents截取的,所以返回值应和contents的生命周期相同,所以给它们两个标注了一样的生命周期'a,而query则不需要生命周期标注。 - 函数内容只需要确保能通过编译即可,因为TDD的第一步是编写一个会出错的测试,所以出错才是想要的结果。
测试结果:
$ cargo test
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
Running unittests src/lib.rs (target/debug/deps/minigrep-dfdfbb86b622af32)
running 1 test
test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ----
thread 'tests::one_result' (469719) panicked at src/lib.rs:41:9:
assertion `left == right` failed
left: ["safe, fast, productive."]
right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::one_result
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
这个测试失败了,但没问题,这就是TDD第一步想要的结果。
2. 编写或修改刚好足够的代码,让新测试通过
第一步完成,接下来执行TDD的第二步:编写或修改刚好足够的代码,让新测试通过。
思考search的思路,应该是遍历contents的每一行,在遍历的时候查找是否有符合query的字符串,有就把这一行放到返回值的列表中;如果没有,什么都不做,遍历下一行。最后把所有结果放到Vector里返回即可。
-
对于遍历每一行,可以使用
lines方法,它会返回一个迭代器(13.5. 迭代器 Pt.1 会细讲),会把字符串的内容一行一行地返回。 -
对于查找是否有符合
query的字符串,可以使用contains方法,它返回的是一个布尔类型,有符合的就返回true,反之则为false。 -
最后别忘了,要把符合的行放到
Vector里。
根据以上这些知识,就可以写出代码了:
#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
}
注意:这里results不用显示声明元素类型是因为下文中往这个Vector里添加了line这个&str类型,Rust推断出results里的元素类型是&str。
现在运行一下测试:
$ cargo test
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.15s
Running unittests src/lib.rs (target/debug/deps/minigrep-dfdfbb86b622af32)
running 1 test
test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-4c31ade9c6771135)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
测试通过,没有问题。
3. 在run函数中使用search函数
search函数目前写好了,那就可以在run函数中调用了:
#![allow(unused)]
fn main() {
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
for line in search(&config.query, &contents) {
println!("{}", line);
}
Ok(())
}
}
通过循环的方式找到符合的一行就立马打印出来。
试运行一下:
$ cargo run -- frog poem.txt
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
这个例子只有单行,试试有多行的字符:
$ cargo run -- body poem.txt
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
试一个没有的词汇:
$ cargo run -- monomorphization poem.txt
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/minigrep monomorphization poem.txt`
12.7 使用环境变量
12.7.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量(本文)
- 将错误信息写入标准错误而不是标准输出
12.7.1. 回顾
以下是截止到上一篇文章为止所写出的全部代码。
lib.rs:
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config {
query,
filename,
})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
for line in search(&config.query, &contents) {
println!("{}", line);
}
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
本文,我们将通过添加一个额外的功能来改进minigrep:用户可以通过环境变量打开不区分大小写搜索的选项。我们可以将此功能设置为命令行选项,并要求用户每次希望应用时输入它,但通过将其设置为环境变量,我们允许用户设置环境变量一次,并在那个终端会话中使所有搜索都不区分大小写。
12.7.2. 继续用TDD:不区分大小写搜索
我们再次遵循TDD的步骤:
- 编写一个会失败的测试,运行该测试,并确保它是按照预期的原因失败
- 编写或修改刚好足够的代码,让新测试通过
- 重构刚刚添加或修改的代码,确保测试会通过
- 返回步骤1,继续
本篇文章会继续用TDD来实现不区分大小写的搜索。新函数会被命名为search_case_insensitive,之后再用环境变量来控制是否启用它。
12.7.3. 编写会失败的测试
这个对大小写不敏感的函数先给它起个名叫做search_case_insensitive。
先把测试模块改一下,改出一个对大小写敏感的测试函数和不敏感的测试函数:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
}
然后再写search_case_insensitive函数的具体内容:
#![allow(unused)]
fn main() {
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
}
- 为了让这个函数能被外部函数调用,得使用
pub来声明为公共的 - 这个函数得加生命周期标志,因为有多个非
self的参数,Rust无法判断哪个参数的生命周期跟返回值的生命周期相同 - 返回值
Vector内的元素是字符串切片,是从contents截取的,所以返回值应和contents的生命周期相同,所以给它们两个标注了一样的生命周期'a,而query则不需要生命周期标注 - 函数内容只需要确保能通过编译即可,因为TDD的第一步是编写一个会出错的测试,所以出错才是想要的结果
这时候跑测试肯定会失败,但没关系,这正是TDD第一步想要的。
12.7.4. 编写或修改刚好足够的代码,让新测试通过
其实search_case_insensitive的代码与search的大部分都差不多,只需要做一些小修改即可。逻辑很好想,就是把关键词变成小写,再与文本各行的小写形式进行比较:
#![allow(unused)]
fn main() {
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
let query = query.to_lowercase();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
}
to_lowercase方法可以把字符串变成全小写to_lowercase转换后的结果是String,变量拥有所有权,也就是新的query是String而不是&str。在循环中的if语句使用的是&query,因为contains方法不接受String所以得传引用进去
再跑一下测试:
$ cargo test
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.15s
Running unittests src/lib.rs (target/debug/deps/minigrep-dfdfbb86b622af32)
running 2 tests
test tests::case_sensitive ... ok
test tests::case_insensitive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-4c31ade9c6771135)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
两个测试都通过了。
12.7.5. 在run函数中使用此函数
这个函数没问题了,就可以在run函数中调用了。
但是首先得先为Config结构体添加一个字段来作为使用普通的search还是对大小写不敏感的search_case_insensitive的依据:
#![allow(unused)]
fn main() {
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
}
修改run函数让它判断配置:
#![allow(unused)]
fn main() {
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line);
}
Ok(())
}
}
Config上的new这个构造器也得改,根据环境变量给case_sensitive这个字段赋值:
#![allow(unused)]
fn main() {
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = std::env::var("IGNORE_CASE").is_err();
Ok(Config {
query,
filename,
case_sensitive,
})
}
}
}
这里使用了std::env::var这个函数(当然也可以先把std::env导入作用域,再使用env::var),它的参数是这个环境变量的名称(按惯例全大写),这里我写的是IGNORE_CASE,中文翻译过来就是忽略大小写。这种环境变量只要出现就认为不区分大小写,不出现就认为区分大小写。
std::env::var的返回值是Result类型的,如果这个IGNORE_CASE环境变量被设置了,返回包含环境变量值的Ok(String),反之返回Err(std::env::VarError)。
这里std::env::var后面还跟了is_err这个方法:如果返回值是Err变体,is_err就返回true,于是case_sensitive被赋为true;反之(环境变量已设置)则赋false。
PS:说实话,这个小程序写成这个B样也是为了教学的无奈之举,我看到一半我都被这个代码量气笑了,真正写的时候没必要写得这么一板一眼的
12.7.6. 整体代码与试运行
写了这么多,看看截止到目前的所有代码。
lib.rs:
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = std::env::var("IGNORE_CASE").is_err();
Ok(Config {
query,
filename,
case_sensitive,
})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line);
}
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
let query = query.to_lowercase();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
来试运行一下:
首先,我们将在不设置环境变量的情况下运行程序,并使用查询to,该查询应与包含全部小写单词to的任何行匹配:
$ cargo run -- to poem.txt
Compiling minigrep v0.1.0 (/tmp/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
这次将IGNORE_CASE设置为1,其它不变:
$ IGNORE_CASE=1 cargo run -- to poem.txt
会得到:
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!
没有任何问题。
注意,如果你在powershell中,设置环境变量得这么写:
PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt
这会使这个环境变量在这个会话中一直存在,如果要去掉这个环境变量,写:
PS> Remove-Item Env:IGNORE_CASE
12.8 将错误信息写入到标准错误
12.8.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出(本文)
12.8.1. 回顾
以下是截止到上一篇文章为止所写出的全部代码。
lib.rs:
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = std::env::var("IGNORE_CASE").is_err();
Ok(Config {
query,
filename,
case_sensitive,
})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line);
}
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
let query = query.to_lowercase();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
12.8.2. 标准输出 vs. 标准错误
这个代码目前把所有的信息(包括错误信息)都打印到终端上了,而大多数的终端它都提供两种输出:标准输出(stdout) 和 标准错误(stderr)。
一般的信息应该输出到标准输出里,而错误信息应该输出到标准错误里。 这种区分的好处在于使正常的输出重定向到文件里面,而错误信息可以在屏幕上打印。
println!这个宏只能将信息打印到标准输出里。而eprintln!这个宏可以把信息输出到标准错误里。
我们使用目前的代码,在终端中执行这个命令:
cargo run > output.txt
也就是把输出重定向到output.txt里,但是这个指令没有带参数,也就是说程序应该会报错,但由于我们把错误信息也写在标准输出里,所以说报错的信息被写在output.txt里了。
更好的做法是将错误信息打印到标准错误里,这样就可以让标准输出的内容保持整洁,不与报错信息混在一起。
12.8.3. 修改代码
将错误信息打印到标准错误里的代码修改还算简单,我们只需要把所有的报错信息从用println!打印改为用eprintln!打印即可。因为所有的报错处理都在main.rs里,所以我们只需要对main.rs稍作修改即可,lib.rs就完全不需要修改:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {}", e);
process::exit(1);
}
}
然后我们再执行之前那个命令。那个指令没有带参数,所以程序会报错,但这次它不会把报错信息放在output.txt里,而是会直接在终端中打印出来:
$ cargo run > output.txt
Problem parsing arguments: not enough arguments
然后再试一下正常带参数的:
$ cargo run -- to poem.txt > output.txt
输出内容被重定向到output.txt里了,打开它:
Are you nobody, too?
How dreary to be somebody!
这就是我们想要的结果:错误直接在终端打印,而正常输出在重定向的文件里。
13.1 闭包 Pt.1:什么是闭包、如何使用闭包
13.1.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包(本文)
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.1.1. 什么是闭包(closure)
一句话概括:闭包是可以捕获其所在环境的匿名函数。
闭包的特点有四:
- 闭包是匿名函数
- 这个匿名函数可以保存为变量,或是作为参数传给另一个函数,还可以作为另外一个函数的返回值
- 可以在一个地方创建闭包,然后在另一个上下文中调用闭包来完成运算
- 闭包可以从其定义的作用域内捕获值
13.1.2. 闭包的例子
为了更好的演示闭包的功能,这里举一个例子:
做一个程序,根据人的身体指数等因素生成自定义的运动计划。这个程序的算法逻辑并不是重点,重点是算法在计算过程中会花费几秒的时间。我们的目标是不让用户发生不必要的等待。具体来说就是仅在必要的时候才调用该算法,而且只调用一次。
看下代码:
use std::thread;
use std::time::Duration;
fn main() {
let simulated_user_specified_value = 10;
let simulated_random_number = 7;
generate_workout(
simulated_user_specified_value,
simulated_random_number,
);
}
fn simulated_expensive_calculation(intensity: u32) -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
intensity
}
fn generate_workout(intensity: u32, random_number: u32) {
if intensity < 25 {
println!("Today, do {} pushups!", simulated_expensive_calculation(intensity));
println!("Next, do {} situps!", simulated_expensive_calculation(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", simulated_expensive_calculation(intensity));
}
}
}
-
simulated_expensive_calculation这个函数就是模拟那个复杂的算法,thread::sleep函数是用来模拟等待算法计算完毕所需的时间。由于就是个演示,所以最后就把intensity(意思是用户指定的强度)这个参数直接返回了。 -
generate_workout有两个参数,一个intensity,表示用户指定的锻炼强度;还有一个random_number,表示一个随机数。其函数体的逻辑是:如果强度intensity小于25就打印“Today, do {} pushups!“和“Next, do {} situps!“两句话。问题来了,这两句话都要调用耗时比较长的simulated_expensive_calculation。如果强度大于等于25,而且随机数等于3,就打印“Take a break today! Remember to stay hydrated!”,不需要调用耗时的函数。如果随机数不等于3,打印“Today, run for {} minutes!“,要调用耗时的simulated_expensive_calculation函数。
这个函数目前的写法确实没问题,但是太耗时了。我们的目标是不让用户发生不必要的等待。具体来说就是仅在必要的时候才调用该算法,而且只调用一次。
首先来看generate_workout函数中强度intensity小于25的情况:
#![allow(unused)]
fn main() {
if intensity < 25 {
println!("Today, do {} pushups!", simulated_expensive_calculation(intensity));
println!("Next, do {} situps!", simulated_expensive_calculation(intensity));
}
会打印“Today, do {} pushups!“和“Next, do {} situps!“两句话。问题来了,这两句话都要调用耗时比较长的simulated_expensive_calculation。实际上我们只需要计算一次的结果,然后把这个结果用在两个输出里重复使用即可。
我们就来优化这一部分,只需要运行一次把结果存在一个变量里在输出时调用这个变量就可以避免重复调用simulated_expensive_calculation:
#![allow(unused)]
fn main() {
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_result = simulated_expensive_calculation(intensity);
if intensity < 25 {
println!("Today, do {} pushups!", expensive_result);
println!("Next, do {} situps!", expensive_result);
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", expensive_result);
}
}
}
}
这里我顺便把强度intensity大于等于25但random_number不等于3的情况下的输出也替换为了存储算法结果的变量expensive_result。
但是这样也导致了另一个问题:
#![allow(unused)]
fn main() {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
}
}
这里并不需要调用复杂的函数,但是由于:
#![allow(unused)]
fn main() {
let expensive_result = simulated_expensive_calculation(intensity);
}
这句话是在函数开头就执行了,所以即使随机数为3时不需要调用算法函数,函数仍然会在开头调用算法函数消耗时间,这属于没有必要的调用。
这就是闭包的用武之地,把这段代码用闭包修改修改一下:
#![allow(unused)]
fn main() {
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_closure = |num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
if intensity < 25 {
println!("Today, do {} pushups!", expensive_closure(intensity));
println!("Next, do {} situps!", expensive_closure(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", expensive_closure(intensity));
}
}
}
}
闭包是这部分:
#![allow(unused)]
fn main() {
let expensive_closure = |num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
}
-
把闭包赋给了变量
expensive_closure。 -
这个闭包需要有参数,把参数放在两个管道符
||中间,这里只有一个参数num,就写|num|。如果有两个参数,就用逗号分开,比如|num1, num2|,如果不需要参数,就只写||即可。 -
这里的参数
num不需要显式声明类型是因为下文的调用中传进去的参数intensity的类型为u32,Rust推断出num的类型为u32。 -
闭包的函数体写在
{}中,写法与其它函数无异。这里我们要通过这个闭包实现调用算法函数相同的效果,所以函数体跟算法函数一样即可,这时候就可以把simulated_expensive_calculation这个函数删掉了 -
整个闭包的这部分只是定义了一个函数,没有执行。函数只有在遇到
()才会执行,例如expensive_closure(intensity)。
这样写,在intensity大于等于25且random_number等于3的时候就不会调用算法函数了,没有不必要的调用。但是这么写没有解决闭包重复调用的问题,这个问题下一篇文章来解决。
13.2 闭包 Pt.2:闭包的类型推断和标注
13.2.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包(本文)
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.2.1. 闭包的类型推断
和fn定义的函数不同,闭包不强制要求标注参数和返回值的类型。
函数需要强制标注是因为它是暴露给用户的显示接口的一部分,严格定义接口有助于所有人对参数和返回值的类型取得共识。
闭包并不会被用于这样的暴露接口,只会被存于变量中,使用时也不需要命名,更不会被暴露给我们代码库的用户。所以,闭包不强制要求标注参数和返回值的类型。
而且闭包通常很短小,只在狭小的上下文中工作,编译器通常能推断出类型。当然你手动标注出来也不是不可以。
看个例子: 这是使用函数定义的代码:
#![allow(unused)]
fn main() {
fn simulated_expensive_calculation(intensity: u32) -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
intensity
}
}
这是使用闭包的代码:
#![allow(unused)]
fn main() {
let expensive_closure = |num:u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
}
这里使用显式标注是因为没有前后文供Rust推断类型,如果有,就不需要:
#![allow(unused)]
fn main() {
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_closure = |num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
if intensity < 25 {
println!("Today, do {} pushups!", expensive_closure(intensity));
println!("Next, do {} situps!", expensive_closure(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", expensive_closure(intensity));
}
}
}
}
这里的参数num不需要显式声明类型是因为下文的调用中传进去的参数intensity的类型为u32,Rust推断出num的类型为u32。
13.2.2. 函数和闭包定义的语法
这里有4个例子:
#![allow(unused)]
fn main() {
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
}
- 第一个是函数的定义,有函数名,形参名及类型和返回值类型
- 第二个是闭包的定义,有参数和返回值的类型。这个闭包看着和函数的定义差不多。
- 第三个同样是闭包,但是没有标注参数和返回值的类型,就得靠编译器推断了。
- 第四个闭包跟第三个的不同之处在于没有了花括号
{}。因为只有一个表达式,所以闭包的{}也可以被省略
13.2.3. 闭包的类型推断
闭包的定义最终只会为参数/返回值推断出唯一具体的类型。
看个例子:
#![allow(unused)]
fn main() {
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
}
输出:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| --------------- ^ expected `String`, found integer
| |
| arguments to this function are incorrect
|
note: expected because the closure was earlier called with an argument of type `String`
--> src/main.rs:4:29
|
4 | let s = example_closure(String::from("hello"));
| --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
| |
| in this closure call
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
help: try using a conversion method
|
5 | let n = example_closure(5.to_string());
| ++++++++++++
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error
Rust编译器在闭包第一次被调用时发现它接收的值和输出的值都是String类型,就锁定这个闭包的参数和返回值都是String类型。所以后面又使用i32类型时就会报错。
13.3 闭包 Pt.3:使用泛型参数和fn trait来存储闭包
13.3.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包(本文)
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.3.1. 回顾
还记得在 13.1 中的例子吗:
做一个程序,根据人的身体指数等因素生成自定义的运动计划。这个程序的算法逻辑并不是重点,重点是算法在计算过程中会花费几秒的时间。我们的目标是不让用户发生不必要的等待。具体来说就是仅在必要的时候才调用该算法,而且只调用一次。
当时我们修改代码为:
use std::thread;
use std::time::Duration;
fn main() {
let simulated_user_specified_value = 10;
let simulated_random_number = 7;
generate_workout(
simulated_user_specified_value,
simulated_random_number,
);
}
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_closure = |num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
if intensity < 25 {
println!("Today, do {} pushups!", expensive_closure(intensity));
println!("Next, do {} situps!", expensive_closure(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", expensive_closure(intensity));
}
}
}
但是还存在一个问题:这么写没有解决闭包重复调用的问题。 在intensity小于25的情况下调用了2次闭包。
对于这个问题,一个解决方案是把闭包的值赋给某个本地变量,让这个本地变量被输出语句重复调用。这么写问题的是会造成一些代码的重复。
所以这里更适合使用另一种解决方法:创建一个结构体,它持有闭包及其调用结果。也就是说,在第一次调用闭包后把结果存到闭包持有者里,如果以后还要调用闭包就直接使用缓存的结果。它的效果是只会在需要结果时才执行该闭包,而且可缓存结果。
这种模式通常叫 记忆化(memoization) 或 延迟计算(lazy evaluation)
13.3.2. 让结构体持有闭包
根据刚才的解决方法,目前的问题在于如何让结构体持有闭包。
结构体的定义需要知道所有字段的类型,所以如果想在结构体内存储闭包,就必须指明闭包的类型。
每个闭包实例都有自己唯一的匿名类型,即使两个闭包签名完全一样,这两个实例仍然是两个类型。所以存储闭包需要使用泛型以及trait bound(泛型和trait bound的内容在 10.4. trait Pt.2 中有讲,推荐看看这篇)
13.3.3. Fn trait
Fn trait由标准库提供。所有的闭包都至少实现了以下Fn trait之一:
FnFnMutFnOnce
这三个Fn trait间的区别会在下一篇文章讲到。在本例中使用Fn就可以了。
知道这些之后就可以修改例子了。首先创建一个结构体:
#![allow(unused)]
fn main() {
struct Cache<T: Fn(u32) -> u32>
{
calculation: T,
value: Option<u32>,
}
}
- 这个结构体有一个泛型参数
T,由于它代表的是闭包的类型,它的约束是Fntrait(在本例中使用Fn就可以了),然后参数和返回值是u32,所以写Fn(u32) -> u32。 - 闭包所在的字段是
calculation,它的类型就是T - 要缓存的值在
value字段上,其类型是u32,但是要注意的是不清楚这个值是否已经计算出来并缓存在里面了,所以要用Option类型来包裹,也就是Option<u32>。
先在结构体上写一个构造函数用于创建实例:
#![allow(unused)]
fn main() {
impl<T: Fn(u32) -> u32> Cache<T> {
fn new(calculation: T) -> Cache<T> {
Cache {
calculation,
value: None,
}
}
}
}
这么写看着有点乱,可以用where子句重写一下:
#![allow(unused)]
fn main() {
impl<T> Cache<T>
where
T: Fn(u32) -> u32
{
fn new(calculation: T) -> Cache<T> {
Cache {
calculation,
value: None,
}
}
}
}
然后,为了实现value有值就取value下的值,value是None就计算的功能,再写一个函数:
#![allow(unused)]
fn main() {
fn value(&mut self, arg: u32) -> u32 {
match self.value {
Some(v) => v,
None => {
let v = (self.calculation)(arg);
self.value = Some(v);
v
}
}
}
}
如果实例的value字段有值就返回这个值,没有值就计算出这个值,存储在value字段里再返回。
这部分写好之后,就该把generate_workout的写法改一下,转为使用cache结构体:
#![allow(unused)]
fn main() {
fn generate_workout(intensity: u32, random_number: u32) {
let mut expensive_closure = Cache::new(|num|{
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
});
if intensity < 25 {
println!("Today, do {} pushups!", expensive_closure.value(intensity));
println!("Next, do {} situps!", expensive_closure.value(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Today, run for {} minutes!", expensive_closure.value(intensity));
}
}
}
}
- 把
expensive_closure作为Cache结构体的实例,使用new函数把闭包传进去。这里把expensive_closure加上mut设为可变绑定是因为后文调用时可能会改变value这个字段的值。 - 下文所有要使用值的操作都使用
value方法来获取。
13.3.4. 使用缓存器实现的限制
这里的Cache字段就是缓存器,用于缓存某个值,但这么写是有限制的。
我把Cache的声明和其方法的代码贴在这里:
#![allow(unused)]
fn main() {
struct Cache<T: Fn(u32) -> u32>
{
calculation: T,
value: Option<u32>,
}
impl<T> Cache<T>
where
T: Fn(u32) -> u32
{
fn new(calculation: T) -> Cache<T> {
Cache {
calculation,
value: None,
}
}
fn value(&mut self, arg: u32) -> u32 {
match self.value {
Some(v) => v,
None => {
let v = (self.calculation)(arg);
self.value = Some(v);
v
}
}
}
}
}
value这个方法总会得到同样的值:如果value字段没有值,那它就会计算出值然后把值存储在value字段里,之后的其他地方使用value就会得到最开始的计算的这个值,不论传进去的参数是什么。
这么说可能有点模糊,那来看个例子:
#![allow(unused)]
fn main() {
fn call_with_different_values(){
let mut c = Cache::new(|a| a);
let v1 = c.value(1);
let v2 = c.value(2);
}
}
-
c是Cache的一个实例,传进去了一个闭包。 -
在
let v1 = c.value(1);这一行时原本c的value字段没有值,这时候传进去个1,value字段就变成Some(1)了(value字段是Option类型) -
在
let v2 = c.value(2);这一行时由于value字段原本有值,所以会直接取value字段的1赋给v2,即使这行的value方法的参数与上一行不一样。
如果不想要这样,就得使用HashMap来代替单个的值,把HashMap的key作为value方法传进去的参数args;而值就作为执行闭包的结果。比如说:
#![allow(unused)]
fn main() {
struct ForFun<T: Fn(u32) -> u32>
{
calculation: T,
value: HashMap<u32, Option<u32>>,
}
impl<T> ForFun<T>
where
T: Fn(u32) -> u32
{
fn new(calculation: T) -> ForFun<T> {
ForFun {
calculation,
value: HashMap::new(),
}
}
fn value(&mut self, arg: u32) -> u32 {
match self.value.get(&arg) {
Some(v) => v.unwrap(),
None => {
let v = (self.calculation)(arg);
self.value.insert(arg, Some(v));
v
}
}
}
}
}
这个例子中的缓存器只能接受同样的参数类型和返回值类型。如果想让闭包的参数类型和返回值类型不一样,就可以引入两个及以上的泛型参数。比如说:
#![allow(unused)]
fn main() {
struct ForFun<T, R>
where
T: Fn(u32) -> R,
{
calculation: T,
value: Option<R>,
}
}
13.4 闭包 Pt.4:使用闭包捕获环境
13.4.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包(本文)
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.4.1. 闭包可以捕获它所在的环境
闭包有一项函数所不具备的功能:闭包可以访问定义它的作用域内的变量。
看个例子:
fn main() {
let x = 4;
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y));
}
闭包的部分在:
#![allow(unused)]
fn main() {
let equal_to_x = |z| z == x;
}
这样写有的人可能不太能分清=和==在这里的作用,换一种写法:
#![allow(unused)]
fn main() {
let equal_to_x = |z| {
z == x
};
}
也就是说这个闭包的形参是z,它会和x(也就是4,因为上文定义了x = 4)进行比较,返回布尔类型,如果相等就是true,反之则为false。注意z == x后面没有分号:如果加了分号,这个代码块的返回值就会变成(),而不是比较结果。
这里闭包直接访问了同在一个作用域的变量x,这是函数做不到的。
但使用这个特性是有代价的,它会产生内存开销。大多数情况下我们不需要它捕获环境,更不想产生内存开销,所以函数它就不允许从环境中捕获变量,而定义和使用函数就永远不会产生这一类型的开销。
13.4.2. 闭包从所在环境捕获值的方式
闭包通过三种方法来从环境捕获值,这三种与函数获得参数的三种方法一样:
- 取得所有权,其trait名为
FnOnce,Once代表一次,因为闭包不能多次获取并消耗同一个变量,所以它只能被调用一次。 - 可变借用,其trait名为
FnMut - 不可变借用,其trait名为
Fn
当程序员在创建闭包时,Rust会根据闭包对环境值的使用方式,推断出具体应该使用哪个trait:
- 所有的闭包都实现了
FnOnce,因为闭包都至少可以被调用一次 - 没有移动捕获变量的实现了
FnMut - 无需可变访问捕获变量的闭包实现了
Fn
实际上这三者有包含关系:所有实现了Fn的都实现了FnMut,所有实现了FnMut的都实现了FnOnce。
13.4.3. move关键字
在参数列表前使用move关键字,可以强制闭包取得它所使用的环境值的所有权。当将闭包传递给新线程以移动数据使其归新线程所有时,此方法最为有用。
看个例子:
fn main() {
let x = vec![1, 2, 3];
let equal_to_x = move |z| z == x;
println!("can't use x here {:?}", x);
let y = vec![1, 2, 3];
assert!(equal_to_x(y));
}
使用了move关键字后,x的所有权就移动到了闭包里面,后面就用不了x了。
13.4.4. 最佳实践
当你指定Fn trait bound之一时,首先用Fn。基于闭包内的情况,如果需要FnOnce或FnMut,编译器会再告诉你。
13.5 迭代器 Pt.1:迭代器的定义、iterator trait和next方法
13.5.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器(本文)
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.5.1. 什么是迭代器
提到迭代器,就得先讲迭代器模式。迭代器模式允许你依次对一系列项里的每一个元素执行某些任务。 而在这个过程中,迭代器负责:
- 遍历每个项
- 确定序列(的遍历)何时完成
Rust的迭代器是懒惰的(lazy):除非调用消费迭代器的方法,否则迭代器本身没有任何效果。这句话的意思大致是如果你在代码里写了迭代器但没有用到,那么迭代器就相当于什么都没干。
看个例子:
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
}
v1是一个Vector,v1.iter()就是在给v1产生了一个迭代器,赋给了v1_iter,但是目前v1_iter没有被使用,所以迭代器可以被看作没有任何效果。
那我们使用迭代器来遍历:
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
for val in v1_iter {
println!("Got: {}", val);
}
}
这就相当于迭代器里的每个元素都被用在了一次循环里。
13.5.2. Iterator trait
所有的迭代器都实现了Iterator trait。这个trait定义在标准库之下,定义大致如下:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// methods with default implementations elided
}
}
这里面涉及两个新语法:type Item和Self::Item,这两个语法定义了与这个trait关联的类型,这部分放在以后的文章讲。现在你需要知道的就是实现Iterator trait需要你定义一个Item类型,它用于next方法的返回类型(迭代器的返回类型)。
Iterator这个trait仅要求实现一个方法——next。next方法每次调用都会返回迭代器中的一项,也就是序列中的一个元素,而由于返回类型是Option,所以返回的结果会被包裹在Some变体里。如果迭代结束,就会返回None。
实际使用时可以直接在迭代器上调用next方法,看个例子:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn iterator_demonstration() {
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}
}
}
v1是一个Vector,v1_iter是v1的迭代器,由于下面的操作会被视为修改迭代器的状态,所以得加mut关键字声明为可变。assert_eq!(v1_iter.next(), Some(&1));这句话是第一次调用next,就会返回Vector里第一个元素,用Some包裹,也就是Some(&1),这里是&1是因为迭代器的返回值是被Option类型包裹的不可变引用。assert_eq!(v1_iter.next(), Some(&2));是第二次调用next,就会返回Vector里第二个元素,用Some包裹,也就是Some(&2)- 以此类推……
- 在迭代器上调用
next方法会更改迭代器用于跟踪其在序列中位置的内部状态。换句话说,每一次调用就是消耗了这个迭代器里一个元素。而13.5.1中例子的for循环不需要mut是因为for循环实际上取得了v1_iter的所有权。
13.5.3. 几种迭代方法
刚才使用的iter方法生成的是一个不可变引用的迭代器,通过next方法所取得的值实际上是指向Vector中的元素的不可变引用。
into_iter方法创建的迭代器会获得所有权。也就是它在迭代元素时会把元素移动到新的作用域内,并取得所有权。
iter_mut方法在遍历值时使用的是可变的引用。
13.6 迭代器 Pt.2:消耗和产生迭代器的方法
13.6.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器(本文)
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.6.1. 消耗迭代器的方法
在标准库中,Iterator trait有一些带默认实现的方法。其中有一些会调用next方法,所以说想实现Iterator trait就必须实现next方法。
调用next方法的方法叫做“消耗性适配器”,因为next方法会把迭代器内的元素一个一个消耗掉,最终会把迭代器耗尽。
举个例子,sum方法会获取迭代器的所有权,并通过重复调用next来迭代项目,从而消耗迭代器。在迭代时,它将每个项目添加到运行总和中,并在迭代完成时返回总和。
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn iterator_sum() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let total: i32 = v1_iter.sum();
assert_eq!(total, 6);
}
}
}
13.6.2. 产生其它迭代器的方法
在Iterator trait上还定义了其它方法,叫做“迭代器适配器”。它们会把当前的迭代器转换为不同种类的迭代器。而且你可以通过链式调用多个迭代器适配器来执行复杂的操作,这种调用可读性较高。
以map方法为例,它接收一个闭包,闭包作用于迭代器的每个元素。它把当前迭代器的每个元素给转换为另外一个元素,然后这些另外的元素就组成了一个新的迭代器。
#![allow(unused)]
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);
}
这段代码会对Vector内的每个元素执行加1的操作。
这么写本身没有问题,但是编译器会产生警告:
$ cargo run
Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
--> src/main.rs:4:5
|
4 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
4 | let _ = v1.iter().map(|x| x + 1);
| +++++++
warning: `iterators` (bin "iterators") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
Running `target/debug/iterators`
由于Rust的迭代器是惰性的,如果你没有消耗它们(指你不调用那些消耗性适配器方法),那么它们就什么都不会做。也就是说现在这个状态它并不会对Vector里的三个元素进行加1的操作,除非调用一些消耗性的方法:
#![allow(unused)]
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
let v2:Vec<_> = v1.iter().map(|x| x + 1).collect();
}
这里使用了collect这个消耗性的适配器方法,把结果收集到某个类型的集合里。由于可以collect可以转很多集合类型,所以这里得显式声明v2的类型是Vector,也就是写Vec<_>。Vec<_>的_代表让编译器自行推断元素的类型。
13.7 迭代器 Pt.3:使用闭包捕获环境配合迭代器的使用
13.7.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器(本文)
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.7.1. 使用闭包捕获环境
filter方法是一个迭代器适配器,一般搭配闭包捕获环境来使用。
filter方法接收一个闭包,这个闭包在遍历迭代器的每个元素时返回布尔类型。如果返回值为true,那么当前元素将会包含在filter方法产生的新一个迭代器中;反之,当前元素将不会包含在filter产生的迭代器中。
看个例子:
使用带有闭包的filter来捕获环境中的shoe_size变量,并迭代Shoe结构体实例的集合。它将仅返回指定尺寸的鞋子。
#![allow(unused)]
fn main() {
#[derive(PartialEq, Debug)]
struct Shoe {
size: u32,
style: String,
}
fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_by_size() {
let shoes = vec![
Shoe {
size: 10,
style: String::from("sneaker"),
},
Shoe {
size: 13,
style: String::from("sandal"),
},
Shoe {
size: 10,
style: String::from("boot"),
},
];
let in_my_size = shoes_in_size(shoes, 10);
assert_eq!(
in_my_size,
vec![
Shoe {
size: 10,
style: String::from("sneaker")
},
Shoe {
size: 10,
style: String::from("boot")
},
]
);
}
}
}
-
结构体
Shoe有两个字段,size代表尺码,是u32类型,style代表款式,是String类型 -
shoes_in_size这个函数接收两个参数,一个shoes,类型是Vec<Shoe>;另一个是shoe_size,类型是u32,最后返回一个Vec<Shoe>。 函数体里先把传进来的Vector调用into_iter方法创建一个获得了所有权的迭代器。 然后使用了filter方法,其参数是一个闭包,这个闭包通过size字段判断每个元素的尺码是否符合shoe_size一样,如果相等,这个元素就会包含在新生成的迭代器里。 最后调用collect方法把它变成一个集合返回。
13.8 迭代器 Pt.4:创建自定义迭代器
13.8.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器(本文)
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能
13.8.1. 使用Iterator trait创建自定义迭代器
最主要的步骤就只有一步:提供next方法的实现。
看个例子:
做一个迭代器,从1遍历到5
#![allow(unused)]
fn main() {
struct Counter {
count: u32,
}
impl Counter {
fn new() -> Counter {
Counter { count: 0 }
}
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
}
-
先创建一个结构体叫
Counter,它有count字段,用来存储迭代过程中所需要的数值,也就是迭代过程中的状态。这里count字段不使用pub而是设为私有是为了让Counter结构体独立管理它的值。 -
然后在这个结构体上写了一个关联函数
new用于创建新的实例,确保新实例从0开始。 -
下面就需要为
Counter这个结构体实现Iterator这个trait。Iteratortrait有一个关联类型type Item还有一个next方法。首先把关联类型指定为u32,也就是写type Item = u32;。这个语法在 19.2. 高级trait 会细讲,现在知道这个迭代器会返回u32类型即可。 -
next函数的返回类型是Option<Self::Item>,由于上文写了关联类型指定为u32,所以可以理解为Option<u32>。当count字段小于5的时候就继续加1,如果大于等于5就返回None。这样就能保证从1到5的遍历。
现在我们来实现复杂一些的需求:
同样使用Counter结构体:一个从1到5,另一个从2到5。把两个迭代器的每对元素相乘,只保留新迭代器中能被3整除的元素,然后返回这些元素的和
#![allow(unused)]
fn main() {
fn using_other_iterator_traits_methods() {
let sum: u32 = Counter::new()
.zip(Counter::new().skip(1))
.map(|(a, b)| a * b)
.filter(|x| x % 3 == 0)
.sum();
}
}
- 这里我分行写是因为链式调用写在一行太长了,如果链式调用的代码没多长就没必要分行写
zip方法是把两个迭代器的每对元素和到一起形成新迭代器,这个新迭代器的元素就是元组,每个元组有两个值,分别来自两个迭代器。Counter::new()就是建立一个从1到5的Counter结构体,Counter::new().skip(1)就是建立跳过1的Counter结构体,也就是从2到5。把这两个结构体实例使用zip和到一起就会形成如下表格的存储元组(Tuple)的迭代器:
Counter::new() | Counter::new().skip(1) | |
|---|---|---|
| Tuple 0 | 1 | 2 |
| Tuple 1 | 2 | 3 |
| Tuple 2 | 3 | 4 |
| Tuple 3 | 4 | 5 |
PS:Counter::new()不会遍历到5是因为Counter::new().skip(1)在那时的值是None,程序就不会再生成值 |
map接收一个闭包,闭包作用于迭代器的每个元素。它把当前迭代器的每个元素给转换为另外一个元素,然后这些另外的元素就组成了一个新的迭代器。在这个例子中就是把迭代器存储的元组里的两个值相乘得到新的迭代器。filter通过闭包把能整除3的值留下形成新的迭代器sum消耗迭代器的所有元素,把其中的所有值相加求和
最后的结果应该是18
13.9 使用闭包和迭代器改进I/O项目
13.9.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
13.9.1. 回顾
本篇文章会以第12章中的grep项目为例演示使用闭包和迭代器改进I/O项目,在此之前我们先回顾一下。
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
lib.rs:
#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line);
}
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
let query = query.to_lowercase();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {}", e);
process::exit(1);
}
}
13.9.2. new函数的改进
看一下lib.rs里的new函数:
#![allow(unused)]
fn main() {
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive})
}
}
}
其中的这两行:
#![allow(unused)]
fn main() {
let query = args[1].clone();
let filename = args[2].clone();
}
使用了克隆的方法。这是因为传进去的参数是&[String],没有所有权,但是Config结构体要求持有所有权。只有使用克隆才能让Config拥有query和filename的所有权,即使克隆会造成性能开销。
但在我们学过迭代器之后,我们可以直接把迭代器传给new,从而让它获得所有权。我们还可以通过迭代器实现长度检查和索引,使new函数的责任范围更加明确。
改new函数之前我们得先改main函数对输入参数的处理方法,原本是:
#![allow(unused)]
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
}
现在我们去掉collect方法,直接把env::args()所获得的参数传给new函数:
#![allow(unused)]
fn main() {
let config = Config::new(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
}
env::args()的返回类型是std::env::Args,它实现了Iterator trait,所以是一个迭代器。
现在来修改new函数:
#![allow(unused)]
fn main() {
impl Config {
pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Not enough arguments");
}
args.next();
let query = args.next().unwrap();
let filename = args.next().unwrap();
let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive})
}
}
}
- 把形参
args的类型改为std::env::Args,还得声明为可变变量加上mut,因为next方法是消耗性迭代器方法。 - 函数体里有一行只写了
args.next();是因为env::args()获取的第一个值是程序的名称而不是参数,写args.next();就是为了跳过这个值。 - 后面的
query和filename就依次使用next方法来获取即可,这时候的query和filename就是拥有所有权的String。由于next的返回值是Option,所以可以使用unwrap来解包。
13.9.3. search函数的改进
目前的search函数是这样的:
#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
}
contents.lines()返回的也是迭代器,我们在这里手动地判断是否包含关键字,也就是query所存储的字符串,如果包含就把这行放到Vector里,最后把Vector返回。
对于在迭代器中寻找符合某个条件的元素并组成新的迭代器,可以使用filter方法:
#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines().filter(|line| line.contains(query)).collect()
}
}
通过在闭包中使用contains来检查是否包含关键字就实现了同样的逻辑。
既然普通的搜索函数能使用迭代器,同样的,大小写不敏感的搜索函数也可以使用迭代器:
#![allow(unused)]
fn main() {
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
contents
.lines()
.filter(|line| line.to_lowercase().contains(&query))
.collect()
}
}
这里仍然先把查询字符串转成小写。对每一行,line.to_lowercase()会生成一个仅用于contains检查的临时小写String,真正被收集进结果的仍是原始的line(指向contents的&str)。这样才能让返回类型Vec<&'a str>合法。
如果写成contents.to_lowercase().lines()...collect(),迭代器产出的引用会指向临时的小写String,这些引用不能作为指向原始contents的&'a str返回——代码将无法通过编译。
不管从代码量还是可读性上比,使用filter的方法都更好。此外filter方法还减少了临时变量。消除可变状态(let mut results = Vec::new();)使我们可以在未来通过并行化来提升搜索效率,因为无需考虑并发访问results的安全问题了。
13.10 性能对比:循环 vs. 迭代器
13.10.0. 写在正文之前
Rust语言在设计过程中受到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器
- 使用闭包和迭代器改进I/O项目
- 闭包和迭代器的性能(本文)
13.10.1. 一个测试
为了运行基准测试,将阿瑟·柯南·道尔爵士所著的《夏洛克·福尔摩斯历险记》的全部内容加载到一个String中,并在内容中搜索单词the。以下是使用for循环的search版本和使用迭代器的版本的基准测试结果:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
迭代器版本稍微快一些!
我们不会在这里解释基准测试代码,因为重点不是证明这两个版本是等效的,而是为了大致了解这两个实现在性能方面的比较。
迭代器是Rust中的一种高层次抽象,它在编译后生成的代码几乎和我们手写的底层代码一样。这叫做零开销抽象(Zero-Cost Abstraction)。
13.10.2. 零开销抽象(Zero-Cost Abstraction)
零开销抽象意味着使用抽象不会引入额外的运行时开销。
Rust的迭代器能实现零开销抽象是因为:
1. 泛型与单态化
Rust的迭代器大量使用泛型来定义操作,比如Iterator trait。编译器在编译期间会对每个具体的类型实例化泛型代码,生成专门针对这些类型的高效机器代码,这个过程叫单态化(monomorphization)。
-
静态分发:编译器根据具体的类型生成直接调用函数的代码,不需要在运行时查找函数地址,这与通过虚表进行的动态分发不同。
-
优化机会:由于类型在编译期是已知的,编译器可以对代码进行深入优化,比如消除函数调用的开销以及内联。
2. 内联与LLVM优化
Rust使用LLVM作为其后端编译器。编译器可以通过以下方式优化迭代器链:
-
函数内联:Rust编译器会内联迭代器中的操作(如
map、filter等),将这些方法展开为一段紧凑的代码,而不会有函数调用开销。 -
循环展开与合并:多个迭代器方法的调用(如
map().filter().collect())在编译时会被合并为单个循环。 -
冗余消除:例如,对于一些多余的中间变量或操作,编译器会直接去掉。
结果是,迭代器链的最终执行代码效率几乎与手写的循环相当。
3. 惰性求值
Rust的迭代器是惰性的,这意味着:
-
在调用终结方法(如
collect()或for_each())之前,迭代器不会执行任何实际操作。 -
每个中间操作(如
map和filter)仅创建一个新迭代器,并不会立即应用操作。
这种惰性设计允许编译器在最终使用迭代器时直接生成针对具体场景优化的代码,而不会引入不必要的中间数据结构或计算。
4. 无运行时开销
Rust的设计原则之一是避免运行时成本。迭代器的实现避免了动态分配和运行时多态:
-
Rust迭代器是基于静态类型的,通常无需堆分配(除非显式使用
Box或dyn Iterator)。 -
Iteratortrait使用静态分发,避免了动态分发。即使需要动态分发,也必须显式声明为dyn Iterator。
5. 没有额外的抽象成本
Rust迭代器通过直接对底层数据结构的操作提供功能,而不会引入额外的抽象层。比如:
-
调用
.iter()生成的迭代器直接操作底层切片或集合,开销极低。 -
中间迭代器(如
Map、Filter)在编译时会被优化成一段紧凑的指令,而不会引入多余的封装。
13.10.3. 一个例子:音频解码程序
以下代码取自一个音频解码器。解码算法使用线性预测数学运算,根据先前样本的线性函数来估计未来值。这段代码使用迭代器链,对三个变量执行若干数学运算:数据的buffer切片、包含12个元素的coefficients数组,以及qlp_shift中的数据移位量。我们在这个例子中声明了变量,但没有给它们赋值。尽管这段代码在其原始上下文之外没有太多意义,但它仍然是Rust如何将高级思想转化为低级代码的一个简洁、真实的示例。
#![allow(unused)]
fn main() {
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;
for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
}
为了计算prediction值,这段代码会遍历coefficients中的12个值,并使用zip方法将每个系数与buffer中的前12个值配对。然后,对每一对数值相乘,对所有结果求和,再将总和向右移动qlp_shift位。
所有系数都存储在寄存器中,这意味着访问这些值非常快。运行时对数组访问没有边界检查。Rust能够应用的所有这些优化使生成的代码极其高效。现在你知道了这一点,就可以毫无顾虑地使用迭代器和闭包了!它们使代码看起来更高级,同时又不会造成运行时性能损失。
14.1 cargo:发布配置
14.1.1 Release Profile
Release profile 是发布配置的意思,它是一系列预定好的配置方案。而且它是可自定义的,我们可以自定义配置并使用不同的设置,从而让程序员对代码的编译有更多的控制权。
每个 profile 就是各自的配置档案,独立于其它的 profile。
在 Cargo 里主要有两个 profile:
dev profile:适用于开发、cargo buildrelease profile:适用于发布、cargo build --release
使用 cargo build 和 cargo build --release 指令会应用两个不同的配置档案:
$ cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
Finished `release` profile [optimized] target(s) in 0.00s
14.1.2 自定义 Profile
针对每个 profile,Cargo 都提供了默认的配置。
如果想要自定义配置(不论是 dev profile 还是 release profile),可以在 Cargo.toml 里添加 [profile.xxxx] 区域,在里面覆盖默认配置的子集。通常我们不会覆盖所有的选项,只需要覆盖那些想修改的配置。
看个例子:
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
opt-level 设置控制 Rust 将应用于你的代码的优化数量,范围为 0 到 3。应用更多优化会延长编译时间,因此,如果你经常进行开发和编译代码,即使生成的代码运行速度较慢,你也可能希望使用更少的优化,因为这样可以加快编译速度。因此 dev 的默认 opt-level 是 0。
当准备好发布代码时,最好花更多时间进行编译。代码只会在发布模式下编译一次,但会多次运行编译后的程序,因此发布模式会用更长的编译时间换取运行速度更快的代码。这就是为什么 release 的默认 opt-level 是 3。
14.2 文档注释以及发布 crate
14.2.1 crates.io
crates.io 是面向 Rust 编程语言的官方包管理平台,类似于其他语言的包管理器(如 npm 对于 JavaScript,pip 对于 Python)。它的主要作用包括:
- 托管 Rust 库(crate): 开发者可以在 crates.io 上发布自己的 Rust 库,其他开发者可以下载并使用这些库。
- 依赖管理: Rust 的构建工具和包管理器 Cargo 会从 crates.io 下载所需的依赖包,用于简化项目开发。
- 搜索和发现: 用户可以在 crates.io 上搜索已有的库,找到适合自己项目需求的解决方案。
- 版本控制和更新: crates.io 支持版本管理,开发者可以上传新版本的库,用户也可以轻松更新依赖。
在第二章的猜数游戏中,我们就使用了 crates.io 中的第三方 rand crate 来获取随机数。我们不仅可以使用他人提供的 crate,也可以发布自己的 crate 到 crates.io 供他人使用。
Rust 和 Cargo 具有使你发布的包更容易被人们找到和使用的功能。接下来我们将讨论其中一些功能,然后解释如何发布包。
14.2.2 文档注释
使用 /// 来写文档注释。文档注释用于生成项目的文档,它不同于 //,// 用于代码的标注,而文档注释说明的是紧跟其后的那个条目(通常是公共 API)。
这种文档是 HTML 文档,支持 Markdown 格式,它会显示公共 API 的文档注释,通常是教读者如何使用 API。
一般文档注释放置在被说明条目之前。
看个例子:
#![allow(unused)]
fn main() {
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}
}
PS:在 Markdown 中,# 是标题的标记,``` 是代码块的标记。
14.2.3 生成 HTML 文档的命令
在终端中运行 cargo doc 会使用 Rust 自带的 rustdoc 工具来生成文档。它会把生成的文档放在 target/doc 目录下。
cargo doc --open 会生成文档并在网页浏览器中打开结果:

14.2.4 常用章节
以下是 crate 作者在其文档中常用的一些部分:
# Examples是示例部分,下面的代码块放示例代码。# Panics:正在记录的函数可能会出现恐慌的情况。不希望程序出现恐慌的函数调用者应确保在这些情况下不会调用该函数。# Errors:如果函数返回Result,则描述可能发生的错误类型以及可能导致返回这些错误的条件对调用者很有帮助,以便他们可以编写代码以不同的方式处理不同类型的错误。# Safety:如果函数调用unsafe(以后会讲),应该有一个部分解释为什么该函数不安全,并涵盖该函数期望调用者维护的不变量。
14.2.5 文档注释作为测试
文档注释中的代码块会在执行 cargo test 命令时作为测试来调用,会在测试结果中看到这部分:
Doc-tests my_crate
running 1 test
test src/lib.rs - add_one (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
14.2.6 为包含外层注释的项添加文档注释
//! 将文档添加到外层条目,而不是添加到注释后面的项目。我们通常在 crate 根文件(按照惯例是 src/lib.rs)或模块内部使用这些文档注释来记录 crate 或整个模块:
#![allow(unused)]
fn main() {
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}
}
这个例子中,添加了描述 my_crate 用途的文档。因为使用的是 //!,所以注释的是包含它的外层条目——也就是 crate 根——而不是注释后面的某个条目。
这时候 HTML 文档也会跟着变化:

14.3 使用 pub use 导出方便使用的 API
14.3.1 使用 pub use 重导出 API
在第七章中我们介绍了 mod 关键字,我们使用它来将代码组织为模块。其中介绍的 pub 关键字可以将模块或方法设置为公共的,以便外部代码调用。而外部代码要将模块或方法引入当前作用域,就得使用 use 关键字。
使用这些关键字就可将代码组织为面向开发者友好的形式。但是这种结构对代码库的最终用户不一定特别友好。比如说,crate 的结构在开发时对于开发者很友好,但是对于使用者不够方便。开发者会把程序结构分为很多层,使用者想要找到这种深层结构中的某个类型就很费劲。比如说:my_crate::some_module::another_module::UsefulType,而比较好用的写法是 my_crate::UsefulType。
对于这种问题,不需要重新组织内部代码结构,使用 pub use 就可以重导出条目,创建一个与内部私有结构不同的对外公共结构。重导出这个操作会取得某个位置上的公共条目,并将其公开到另外一个位置,就好像它就定义在这个新的位置上。
看个例子:
lib.rs:
#![allow(unused)]
fn main() {
//! # Art
//!
//! A library for modeling artistic concepts.
pub mod kinds {
/// The primary colors according to the RYB color model.
pub enum PrimaryColor {
Red,
Yellow,
Blue,
}
/// The secondary colors according to the RYB color model.
pub enum SecondaryColor {
Orange,
Green,
Purple,
}
}
pub mod utils {
use crate::kinds::*;
/// Combines two primary colors in equal amounts to create
/// a secondary color.
pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
//...
}
}
}
kinds这个模块下有两个枚举类型,PrimaryColor和SecondaryColor,用于存储颜色变体。utils模块下有一个叫mix的函数,这个函数的功能就是把两个PrimaryColor值混合成为SecondaryColor。这里没有放出其中的代码。- 把枚举类型放在
kinds下,把函数放在utils下,对于开发者来说非常友好。
main.rs:
use art::kinds::PrimaryColor;
use art::utils::mix;
fn main() {
let red = PrimaryColor::Red;
let yellow = PrimaryColor::Yellow;
mix(red, yellow);
}
这里用到了 lib.rs 中的枚举类型和 mix 函数。为了引入作用域写了三层,而且枚举类型和函数在不同的模块中,对于使用者来说非常麻烦。
此时生成的 crate 文档长这样:

如果我们使用重导出来重构代码:
lib.rs:
#![allow(unused)]
fn main() {
//! # Art
//!
//! A library for modeling artistic concepts.
pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;
pub mod kinds {
/// The primary colors according to the RYB color model.
pub enum PrimaryColor {
Red,
Yellow,
Blue,
}
/// The secondary colors according to the RYB color model.
pub enum SecondaryColor {
Orange,
Green,
Purple,
}
}
pub mod utils {
use crate::kinds::*;
/// Combines two primary colors in equal amounts to create
/// a secondary color.
pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
//...
}
}
}
main.rs:
use art::mix;
use art::PrimaryColor;
fn main() {
let red = PrimaryColor::Red;
let yellow = PrimaryColor::Yellow;
mix(red, yellow);
}
这个时候调用枚举类型和函数就不需要一层层地写模块路径了。
此时生成的 crate 文档:
文档中出现了 Re-exports 部分,所有重新导出的条目都写在了这里。对于 crate 的实际使用者来说,查找这些类型和函数就非常方便了。
14.4 发布 crate Pt.2
14.4.1 创建并设置 crates.io 账号
在发布任何 crate 之前,你需要拥有一个 crates.io 账号并获取 API 令牌。为此,请访问 crates.io 主页并使用 GitHub 帐户登录。目前只支持 GitHub 登录。如果已登录,打开帐户设置:https://crates.io/me/,并找到 API 密钥。然后在本地使用 cargo login 命令,并在出现提示时粘贴你的 API 密钥:
$ cargo login
just1a1nexample
此命令会告诉 Cargo 你的 API 令牌,并将其本地存储在 ~/.cargo/credentials.toml。要注意的是,此令牌不能与其他任何人共享。如果你泄露了,应该撤销它并在 crates.io 上生成一个新令牌。
14.4.2 将元数据添加到 crate
在发布 crate 之前,还需要在 Cargo.toml 文件里的 [package] 区域添加一些元数据:
- 首先你要确保项目名称在网站上是独一无二的。
- 其次还需要写
description,也就是简短介绍,不需要太长,一两句话就可以。description的内容会出现在 crate 搜索结果里。 - 你需要提供这个 crate 使用的许可证标识值(可以到 spdx.org/licenses/ 中查找);可以指定多个许可证,用
OR隔开,写在license。 - 语义版本 信息写在
version。
当然可以写的信息不止这些,具体可以参阅 Cargo 手册。
整个 [package] 区域的写法应该如下:
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"
14.4.3 使用命令发布 crate
使用命令 cargo publish 即可发布 crate,但前提是元数据完整且项目名唯一。此外,你的 crates.io 账号还必须先完成邮箱验证,才允许发布。
如果出现问题,cargo publish 会报错:
$ cargo publish
Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
......
error: failed to publish to registry at https://crates.io
Caused by:
the remote server responded with an error: missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for how to upload metadata
中间我省略了一部分。看 Caused by 这部分说是缺少元数据导致的错误。
crate 一旦发布就是永久性的:该版本无法覆盖,代码除了某些有限情形外无法删除。 这样做是为了让依赖于该版本的项目可以继续正常工作。
14.4.4 发布新版本的 crate
如果你需要为已经存在的 crate 发布更新版本,可以在修改 crate 源代码后,把 Cargo.toml 中的 version 值按照语义化版本规范修改,再重新发布。
14.4.5 撤回版本
撤回版本会使新项目不能依赖于这个版本,但是已经基于这个版本构建的项目仍然可以使用并可下载。
其指令是 cargo yank --vers 指定的版本。例如要撤回 1.0.1 版本,就写:
cargo yank --vers 1.0.1
如果你撤回之后又改了主意,想要取消撤回,写:
cargo yank --vers 1.0.1 --undo
yank 意味着:
- 所有已经生成
Cargo.lock的项目都不会因版本被撤回而中断。 - 所有将来生成的
Cargo.lock文件都不会使用被撤回的版本。
14.5 cargo 工作空间 (Workspace)
14.5.1 为什么需要 Cargo Workspace
假如说我们构建了一个二进制 crate,里面既有库又有应用程序。随着项目规模不断增长,库 crate 可能不断变大。在这种情况下通常会把它拆为多个包。针对这种需求,Rust 提供了 Cargo 工作空间,也就是 cargo workspace。
Cargo workspace 会帮助管理多个相互关联且需要协同开发的 crate。其本质是一套共享同一个 Cargo.lock 和输出文件的包。
14.5.2 使用 Workspace
有多种方式可以创建工作空间(workspace)。
做一个例子,这个工作空间里有 1 个二进制 crate 和 1 个库 crate:
- 二进制 crate 里有
main函数,依赖于库 crate。 - 其中一个库 crate 提供一个叫
add_one的函数。
1. 创建 Workspace 目录
首先为工作空间创建一个目录,我取名叫 add。在终端输入以下命令:
$ mkdir add
$ cd add
2. 在主项目中使用 Workspace
接下来,在 add 目录中,创建将配置整个工作区的 Cargo.toml 文件。该文件不会有 [package] 部分。相反,它将以 [workspace] 部分开头:
[workspace]
resolver = "2"
members = [
"adder",
]
adder 就是我给二进制 crate 取的名,这个列表可以继续添加更多成员。
3. 添加库
$ cargo new adder
Creating binary (application) `adder` package
通过这个命令创建了 adder crate,位于目录 add/adder 下。
此时整个项目的结构如下:
├── Cargo.lock
├── Cargo.toml
├── adder
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
需要注意的是,这时候我们既可以对 add 这个项目使用 cargo build,也可以对 add 下的 adder crate 使用 cargo build。但是生成的 target 目录和 Cargo.lock 文件只会有一个,都在 add 目录下,而 adder crate 的构建产出物也会存放在这里。因为工作空间中的各个 crate 往往是相互依赖的,每个目录都有自己的 target 就会导致开发者不得不反复编译工作空间里的其余 crate。
接下来添加其它 crate:
另一个 crate 叫 add_one,修改工作空间信息:
[workspace]
resolver = "2"
members = [
"adder",
"add_one",
]
使用 cargo new 添加库,记得使用 --lib 标志来把它声明为 library crate:
$ cargo new add_one --lib
Creating library `add_one` package
现在整个项目的结构是:
├── Cargo.lock
├── Cargo.toml
├── add_one
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── adder
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
4. 编写代码
在 add_one/src/lib.rs 文件中,添加一个 add_one 函数和一个简单的单元测试:
#![allow(unused)]
fn main() {
pub fn add_one(x: i32) -> i32 {
x + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
assert_eq!(3, add_one(2));
}
}
}
现在我们可以让 adder 包和我们的二进制文件依赖于 add_one。首先,需要添加路径依赖 add_one 到 adder/Cargo.toml,因为 Cargo 并不假设工作区中的 crate 会相互依赖,因此我们需要明确依赖关系。在 adder/Cargo.toml 中这么写:
[dependencies]
add_one = { path = "../add_one" }
接下来,让我们使用来自 add_one crate 的 add_one 函数。打开 adder/src/main.rs,在顶部添加 use 来把 add_one 引入作用域,然后修改 main 函数来调用 add_one 函数。
use add_one;
fn main() {
let num = 10;
println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
5. 编译
对 add 这个项目使用 cargo build:
$ cargo build
Compiling add_one v0.1.0 (file:///projects/add/add_one)
Compiling adder v0.1.0 (file:///projects/add/adder)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
没有报错,正常运行。
6. 测试
我们还可以通过使用 -p 标志并指定我们要测试的包的名称,从顶级目录中对工作区中的一个特定包运行测试。比如说仅测试 add_one 函数:
$ cargo test -p add_one
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src/lib.rs (target/debug/deps/add_one-bd89fda78e7f92a7)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests add_one
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
如果将工作区中的 crate 发布到 crates.io,则工作区中的每个 crate 都需要单独发布。与 cargo test 一样,我们可以使用 -p 指定工作区中要发布的特定包,并写出想要发布的包名称。
14.6 安装二进制 crate
14.6.1 从 crates.io 安装二进制 crate
通过 cargo install 命令可以从 crates.io 安装二进制 crate。这并不是为了替换系统包,它应该是 Rust 开发人员安装其他人共享工具的便捷方式。
它的限制是只能安装具有二进制目标(binary target)的 crate。binary target 是一个可执行程序,由拥有 src/main.rs 或其它被配置为二进制 crate 的包生成。
既然有 binary target 这个概念,那就会有 library target 这个概念。library target(库目标)无法单独运行。
通常,README.md 文件里会有关于 crate 的描述,会告诉你这个 crate 是否有 library target、是否有 binary target,或者两者都有。
14.6.2 cargo install
cargo install 安装的二进制文件存放在主目录下的 bin 文件夹中。
如果你使用默认的 rustup 配置安装 Rust,那么二进制存放目录是 $HOME/.cargo/bin。
为了让 cargo install 所安装的程序能够直接执行,需要确保该目录在环境变量 $PATH 中。
例如,在第 12 章中,我们提到了 grep 工具的 Rust 实现,称为 ripgrep,用于搜索文件。要安装 ripgrep,我们可以运行:
$ cargo install ripgrep
Updating crates.io index
Downloaded ripgrep v13.0.0
Downloaded 1 crate (243.3 KB) in 0.88s
Installing ripgrep v13.0.0
......
Compiling ripgrep v13.0.0
Finished release [optimized + debuginfo] target(s) in 3m 10s
Installing ~/.cargo/bin/rg
Installed package `ripgrep v13.0.0` (executable `rg`)
中间省略了一部分,但大致就是这样。倒数第二行写到了程序被安装在 ~/.cargo/bin/rg。
在终端使用 echo $PATH 就可以查看该目录是否在环境变量中。
14.6.3 使用自定义命令扩展 Cargo
Cargo 被设计为可以使用子命令来扩展。
举个例子,如果 $PATH 中的某个二进制文件名为 cargo-something,你可以通过运行 cargo something 来运行它,就像它是一个 Cargo 子命令一样。
当你运行 cargo --list 时,也会列出像这样的自定义命令。能够使用 cargo install 来安装扩展,然后像内置的 Cargo 工具一样运行它们,这是 Cargo 设计的一个非常方便的好处。
15.0 智能指针(序):什么是智能指针及Rust智能指针的特性
15.0.1 指针的基本概念
指针是一个变量,它保存内存中的某个地址,并指向另一份数据。
Rust 中最常见的指针是引用,使用 & 符号表示,它会借用所指向的值。除了引用数据之外,它们没有任何特殊行为,也没有额外开销。
15.0.2 智能指针简介
智能指针的概念并非 Rust 独有:它起源于 C++,也存在于其他语言中。
Rust 标准库定义了多种智能指针。它们提供的能力超出了普通引用所能做到的范围。
智能指针的行为与指针类似,但提供了额外的元数据和功能。
15.0.3 智能指针与引用的区别
- 引用只能借用数据,而智能指针通常拥有它指向的数据。
- 智能指针具有额外的元数据和功能,例如自动清理和其他保障。
15.0.4 智能指针的常见类型
1. 引用计数类型
引用计数智能指针通过记录所有者的数量,支持多重所有权。当不再有任何使用者时,它们会自动清理数据。
2. 标准库中的智能指针
在前文中,我们已经接触过一些智能指针,例如:
String:拥有一片内存区域,并保障其数据是合法的 UTF-8。Vec<T>:提供容量等元数据,并允许操作动态数组。
15.0.5 智能指针的实现
智能指针通常通过 struct 实现,但与普通结构体不同的是,它们一般会实现以下两个重要的 trait:
Deref:允许智能指针的实例表现得像引用,使程序可以同时支持引用和智能指针。Drop:允许程序员自定义智能指针实例离开作用域时运行的清理代码。
在本章中,我们将讨论这两个 trait,并说明它们对智能指针为何重要。
15.0.6 本章内容
由于智能指针是一种常见的设计模式,本章将重点介绍标准库中最常用的智能指针类型:
Box<T>:最简单的智能指针,将数据存储在堆上。Rc<T>:引用计数智能指针,支持共享所有权。Ref<T>和RefMut<T>:通过RefCell<T>访问的值,它在运行时而不是编译时强制执行借用规则。
此外,本章还将讨论以下主题:
- 内部可变性模式 (Interior Mutability Pattern):一种允许不可变类型暴露可修改其内部值的 API 的设计模式。
- 引用循环 (Reference Cycles):它如何导致内存泄漏以及如何预防。
读完本章后,你将对 Rust 中智能指针及相关设计模式的使用有更深入的理解。
15.1 使用Box T 智能指针来指向堆内存上的数据
15.1.1 Box<T>
Box<T> 可以被简单地理解为一个箱子。它是最简单的智能指针,允许你把数据存储在堆上,而不是栈上。
具体实现是:Box<T> 在栈上有一小块内存,存放指向堆上数据的指针。也就是说,实际数据存储在堆上。除了把数据放在堆上之外,它没有其他开销,代价是也没有额外功能。
乍看之下,Box<T> 和普通指针好像没什么区别,但其真正不同之处在于:Box<T> 实现了 Deref 和 Drop 这两个 trait。
15.1.2 Box<T> 的常见场景
当某个类型的大小无法在编译时确定,但使用它的上下文又需要知道其确切大小时,Box<T> 是一个好选择。
当你有大量数据,想移交所有权,但又需要确保在操作过程中不会被复制时。
当你使用某个值时,只关心它是否实现了特定的 trait,而不关心其具体类型时。
15.1.3 使用 Box<T> 在堆上存储数据
看个例子:
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
我们将变量 b 定义为一个包含 Box 的值,该 Box 指向分配在堆上的值 5。这个程序会打印 b = 5。
和其他任何拥有所有权的值一样,当 b 离开作用域时,它会像其他拥有所有权的值一样释放内存——作用域结束时,堆上和栈上的内存都会被释放。
15.1.4 使用 Box<T> 赋能递归类型
在编译时,Rust 需要知道一个类型所占的空间大小。但是有一种被称为递归的类型,它的大小无法在编译时确定。
以这个图为例,Cons 类型有两个字段:一个字段是 i32,另一个字段是 Cons 类型本身。
在编译时,Rust 需要知道类型的大小。i32 的大小是固定的,但第二个 Cons 字段——也就是 Cons 类型本身——的大小无法确定。
针对这种情况,可以使用 Box<T>。对于递归类型,Box<T> 使其大小可以被确定。
这种东西在函数式语言中是存在的,叫做 Cons List。
15.1.5 关于 Cons List
Cons List 是来自 Lisp 语言的一种数据结构。在这种结构中,每个成员由两个元素组成:一个是当前项的值,比如上图中的 i32;另一个是下一个元素。
这种数据结构就这样一直递归下去,直到最后一个元素。最后一个成员只包含一个 Nil 值,没有下一个元素,而 Nil 值充当终止标记。
Nil 和 None 的概念不一样。None 表示无效或缺失的值,而 Nil 是一个终止标记。
从上图可以看出,Cons List 是一种链表。
15.1.6 Cons List 在 Rust 中的替代者
Cons List 并不是 Rust 中的常用集合。通常情况下,Vec<T> 是更好的选择。
下面这个 List 定义与上图中的 Cons List 结构相匹配:
#![allow(unused)]
fn main() {
enum List {
Cons(i32, List),
Nil,
}
}
List 枚举有两个变体:Cons 和 Nil。Cons 变体附带了两份数据:一个是 i32 类型,一个是 List 类型。
这么写逻辑上没问题,但编译时会报错:
$ cargo run
Compiling cons-list v0.1.0 (/tmp/ch15-refresh/cons-list)
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error
这是因为 Rust 需要知道类型的大小,但无法计算出递归类型的大小。
15.1.7 Rust 计算类型大小的方法
先看看 Rust 如何确定类型所占的空间大小。举个例子:
#![allow(unused)]
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
}
为了确定为 Message 值分配多少空间,Rust 会遍历每个变体,看哪个变体需要最多空间。
Rust 认为 Message::Quit 不需要任何空间,Message::Move 需要足够存放两个 i32 值的空间,依此类推。因为同一时刻只有一种变体存在,所以 Message 值所需的空间就是其最大变体所需的空间——在常见的 64 位平台上通常是 Write(String),因为 String 比三个 i32 更大。
15.1.8 使用 Box 来获得确定大小的递归类型
正如前面所说,Rust 需要知道类型的大小,但无法计算递归类型的大小。所以我们可以改用大小已知的类型,而 Box<T> 正好满足需求:它不存储数据本身,而是存储指向数据的指针,指针的大小是固定的 usize。
Rust 知道 Box<T> 的大小,因为 Box<T> 本质上是一个指针。指针不直接存储值,所以不论它指向的数据如何变化,指针本身的大小都不会变。也就是说,指针的大小不会随它所指向数据的大小变化而变化。
基于这一点,就可以修改原来的代码。具体来说,把大小不确定的部分——也就是嵌套的 List 类型——改成 Box<List>:
#![allow(unused)]
fn main() {
enum List {
Cons(i32, Box<List>),
Nil,
}
}
这仍旧是递归,但不再直接存储 List,而是间接指向堆上的 List 值,属于曲线救国。
15.1.9 Box 类型总结
- 只提供间接存储和堆分配。
- 没有额外功能。
- 没有性能开销。
- 适用于需要间接存储的场景,例如
Cons List。 - 实现了
Deref和Droptrait。
15.2 Deref trait Pt.1:什么是Deref、解引用运算符 与实现Deref trait
15.2.1 什么是 Deref trait
Deref 是 Dereference(解引用)的缩写。
如果一个类型实现了 Deref trait,它就允许我们自定义解引用运算符 * 的行为。通过实现 Deref,智能指针可以像常规引用一样被处理。
15.2.2 解引用运算符
首先强调一下:常规引用也是一种指针。看个例子:
fn main(){
let x = 5;
let y = &x;
assert_eq!(x, 5);
assert_eq!(*y, 5);
}
x是i32类型,保存的值是5;y存的是一个引用,指向x的内存地址,类型是&i32,也就是说y是x的引用。- 第一个断言把
x和5比较。由于x里存的就是5,两者相等,所以断言通过。 - 第二个断言把
*y和5比较。y是一个指针,如果想取出它指向的值,就在变量名前加解引用符号*。也就是说,y的类型是&i32,*y的类型是i32,由于5也是i32类型,所以*y可以与5比较,而y不行。
15.2.3 使用 Box<T> 当作引用
Box<T> 可以替代上例中的引用。看:
fn main(){
let x = 5;
let y = Box::new(x);
assert_eq!(x, 5);
assert_eq!(*y, 5);
}
需要注意的是,上一段代码和这段代码的逻辑略有不同:
- 上例中的
y = &x是把一个指向x的指针赋给y,这是一个指向栈内存的指针,因为i32存储在栈上。 - 这里的
y = Box::new(x)是把x的值复制到堆上,然后把指向该堆值的指针传给y。
15.2.4 定义自己的智能指针
Box<T> 被定义为拥有一个元素的 tuple struct(元组结构体,详见 5.1. 定义并实例化struct)。我们来定义一个 MyBox<T>,也是一个 tuple struct:
#![allow(unused)]
fn main() {
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
}
- 首先定义一个元组结构体
MyBox,使用泛型参数T代替具体类型,并在这个元组结构体中存储一个类型为T的值。 - 然后通过
impl块定义一个new函数,用于创建新的MyBox实例。
再写主函数,看看实际使用有没有问题:
fn main(){
let x = 5;
let y = MyBox::new(x);
assert_eq!(x, 5);
assert_eq!(*y, 5);
}
最后一个断言 assert_eq!(*y, 5) 在 *y 处报错。报错信息是:
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
--> src/main.rs:14:13
|
14 | assert_eq!(*y, 5);
| ^^ can't be dereferenced
For more information about this error, try `rustc --explain E0614`.
error: could not compile `mybox` (bin "mybox") due to 1 previous error
也就是说 MyBox 不能被解引用。
这是因为我们还没有为 MyBox 实现 Deref trait。
15.2.5 实现 Deref trait
标准库中的 Deref trait 要求我们实现一个 deref 方法:这个方法借用 self,并返回指向内部数据的引用。
以上面的代码为例,如果想为 MyBox 实现 Deref trait——也就是实现 deref 方法——可以这样写:
#![allow(unused)]
fn main() {
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
}
use std::ops::Deref;把Dereftrait 引入当前作用域。- 要为
MyBox实现Deref,就写impl<T> Deref for MyBox<T>,然后在该impl块中实现deref方法。 type Target = T;定义了Dereftrait 的关联类型。关联类型是一种稍有不同的泛型参数定义方式,以后会讲。deref方法借用self,也就是&self,并返回类型为&T的引用,具体来说就是&self.0:以引用形式返回元组结构体中索引为0的元素(在这个例子中其实只有一个元素)。正因为返回的是引用,所以我们可以使用*解引用运算符来访问这个值。
再写主函数运行一下,看看有没有问题:
fn main(){
let x = 5;
let y = MyBox::new(x);
assert_eq!(x, 5);
assert_eq!(*y, 5);
}
能够通过编译,没有问题。
实际上,Rust 编译器会把 main 里的 *y 隐式展开为:
#![allow(unused)]
fn main() {
*(y.deref())
}
它先调用 MyBox 上的 deref 方法返回一个引用,然后再使用解引用运算符 * 进行普通的解引用操作。
15.3 Deref trait Pt.2:隐式解引用转化与可变性
15.3.1 函数和方法的隐式解引用转化
隐式解引用转化(deref coercion)是为函数和方法提供的一种便捷特性。
它的原理是:假如类型 T 实现了 Deref trait,那么解引用转化可以把 T 的引用转换成对 T 应用 Deref 之后得到的引用。
当某个类型的引用被传给函数或方法,但其类型与声明的参数类型不匹配时,解引用转化会自动发生。编译器会对 deref 进行一系列调用,把它转换成所需的参数类型。这个过程在编译时完成,因此没有额外的性能开销。
这句话比较抽象,看个例子就明白了。我们接着上一篇文章的代码来写:
#![allow(unused)]
fn main() {
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
}
这是上一篇文章的代码。它定义了 MyBox 元组结构体(元组结构体的介绍详见 5.1. 定义并实例化struct),创建了 new 函数,并为其实现了 Deref trait,因此可以对 MyBox 使用普通的解引用操作。
以下是新增部分:
#![allow(unused)]
fn main() {
fn hello(name: &str) {
println!("Hello, {}", name);
}
}
hello 函数接收 &str,也就是字符串切片,然后把它打印出来。
再看主函数:
fn main(){
let m = MyBox::new(String::from("Rust"));
hello(&m);
}
m 是 MyBox<String> 类型,&m 就是 &MyBox<String>。然而 hello 期望的是 &str,但这段代码并不会报错。为什么?
首先,MyBox 已经实现了 Deref trait,所以 Rust 可以调用 deref,把 &MyBox<String> 转换成 &String。这就是刚才那个比较抽象的规则。
到这一步还没完。&String 和 &str 是不同的类型,那又是怎么转换的呢?因为 String 也实现了 Deref trait,而且它的 deref 实现返回的是 &str 类型的字符串切片,所以 Rust 会对 &String 使用 deref,把 &String 转换成 &str。最终类型就匹配了。
如果 Rust 没有解引用转化,写法会是这样:
#![allow(unused)]
fn main() {
hello(&(*m)[..]);
}
- 先使用解引用运算符
*,把m从MyBox<String>转换成String。 - 再加上引用符号
&,把String转换成&String。 - 通过切片语法
[..],可以获得String完整内容的引用,并将其值从&String转换成&str。
15.3.2 解引用与可变性
可以使用 DerefMut trait 来重载可变引用的 * 运算符。与 Deref 相比,DerefMut 多了 Mut,意思是 DerefMut 返回可变引用 &mut T,而 Deref 返回不可变引用 &T。
当类型和 trait 满足下列三种情况时,Rust 会执行解引用转化:
-
当
T: Deref<Target = U>时,&T可以转换成&U:T实现了Dereftrait,且Deref下deref的返回类型是&U,因此&T可以转换成&U。 例如,上文代码中的MyBox类型实现了Deref,其deref方法返回&T,所以&MyBox可以转换成&T。 -
当
T: DerefMut<Target = U>时,&mut T可以转换成&mut U。T实现了DerefMuttrait(DerefMut返回可变引用&mut T),且DerefMut下deref的返回类型是&mut U,因此&mut T可以转换成&mut U。 -
当
T: Deref<Target = U>时,&mut T可以转换成&U。 Rust 可以自动地把可变引用转换成不可变引用,但反过来绝对不行。把不可变引用转换成可变引用要求该引用是唯一的(借用规则中有讲,详见 4.4. 引用与借用)。
15.4 Drop trait:告别手动清理,释放即安全
15.4.1 Drop trait 的意义
如果某个类型实现了 Drop trait,就可以让程序员自定义值离开作用域时发生的操作。例如释放文件或网络资源。
在某些语言中(比如 C/C++),对于某些类型,程序员每次用完这些类型的实例时都必须写代码来释放内存或资源。如果忘记了,系统可能会过载并崩溃。在 Rust 中,程序员可以指定每当值离开作用域时运行的代码,编译器会自动插入这段代码。
任何类型都可以实现 Drop trait,而 Drop 只要求实现 drop 方法,其参数是对 self 的可变引用。Drop 在预导入模块(prelude)中,因此使用时不必手动引入。看个例子:
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("my stuff"),
};
let d = CustomSmartPointer {
data: String::from("other stuff"),
};
println!("CustomSmartPointers created.");
}
- 结构体
CustomSmartPointer有一个data字段,类型为String。 - 通过
impl Drop for CustomSmartPointer为CustomSmartPointer实现了Droptrait。在其中实现drop方法,参数是&mut self。这个方法通常用于释放资源,但出于演示目的,这里只打印一句话,并把self中data字段的内容输出出来。 - 在
main中创建了两个CustomSmartPointer实例:c存的是"my stuff",d存的是"other stuff"。最后打印"CustomSmartPointers created."。
输出:
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!
程序会先打印 main 中 println! 的内容,也就是 "CustomSmartPointers created."。因为 c 和 d 在 main 结束时离开作用域,所以程序会按声明的逆序丢弃它们:先 d,再 c。由于这里实现的 drop 会打印一句话,因此这两个值在被丢弃时都会各打印一行。
15.4.2 使用 std::mem::drop 来提前丢弃值
比较遗憾的是,我们很难直接禁用自动的 drop,也没必要这么做。因为 Drop trait 的目的就是自动处理清理逻辑。
此外,Rust 不允许手动调用 Drop trait 的 drop 方法。但你可以调用标准库函数 std::mem::drop 来提前丢弃一个值,这相当于提前调用了 Drop 的 drop 方法。它的参数就是要丢弃的值。看:
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("my stuff"),
};
let d = CustomSmartPointer {
data: String::from("other stuff"),
};
drop(c);
println!("CustomSmartPointers created.");
}
在 main 中,我们手动使用 drop 清理了 c,而 d 仍然是自动清理的。输出顺序应该是 c 在 d 之前。
输出:
Dropping CustomSmartPointer with data `my stuff`!
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
有些人可能会疑问:如果 c 在离开作用域之前就被丢弃了,那么离开作用域后编译器会不会再调用一次 drop,从而导致二次释放(double free)错误?答案是不会。Rust 的设计是安全的。它的所有权系统会保证引用有效,而 drop 也只会在确定不再使用这个值时被调用一次。
15.5 Rc T :引用计数智能指针与共享所有权
15.5.1 什么是 Rc<T>
所有权在大部分情况下都是清晰的。对于一个给定的值,程序员可以准确地推断出哪个变量拥有它。
但是在某些场景中,单个值也可能同时被多个所有者持有,如下图:

在这个数据结构中,每个节点都有多条边指向它,所以从概念上讲,这些节点同时属于所有指向它们的边。只要一个节点还有边指向它,就不应该被清理掉。这就是多重所有权。
为了支持多重所有权,Rust 提供了 Rc<T> 类型。Rc 是 Reference Counting(引用计数)的缩写。这个类型会在实例内部维护一个计数器,记录有多少引用指向该值,从而判断这个值是否仍在使用。如果引用计数为 0,就可以安全地清理该值,并且不会出现悬垂引用问题。
15.5.2 Rc<T> 的使用场景
当你希望把堆上的一些数据分享给程序的多个部分使用,但在编译时又无法确定到底是程序的哪个部分会最后使用这些数据时,就可以使用 Rc<T>。
相反,如果我们能在编译时确定程序的哪个部分会最后使用数据,那么只需要让这部分代码成为数据的所有者即可。此时,编译时的所有权规则就足以保证正确性。
需要注意的是,Rc<T> 只能用于单线程场景。以后的文章会讨论如何在多线程代码中使用引用计数。
15.5.3 Rc<T> 使用示例
使用前需要注意:Rc<T> 不在预导入模块中,所以必须先手动导入。
Rc 有这么一些基本函数:
Rc::clone(&a)增加引用计数Rc::strong_count(&a)返回引用计数,具体来说是强引用计数- 既然有强引用,就会有弱引用,也就是
Rc::weak_count
用一个例子来探究 Rc<T> 的实际应用:
一共有三个 List,分别是 a、b 和 c。其中 b 和 c 共享 a。其余细节如图:

enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
// main 函数里换行只是为了让链表结构更清晰,不是必要的。
let a = Cons(5,
Box::new(Cons(10,
Box::new(Nil))));
let b = Cons(3,
Box::new(a));
let c = Cons(4,
Box::new(a));
}
- 首先创建了一个链表
List;其结构在 15.1. 使用Box<T>来指向堆内存上的数据 中已有详细解释,这里不再重复。 - 在
main中,先写出a的结构。 - 然后写出
b和c的第一层;嵌套的下一层直接写a即可。
逻辑上没有问题,运行一下试试:
error[E0382]: use of moved value: `a`
--> src/main.rs:17:27
|
10 | let a = Cons(5,
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
...
15 | Box::new(a));
| - value moved here
16 | let c = Cons(4,
17 | Box::new(a));
| ^ value used here after move
|
note: if `List` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ consider implementing `Clone` for this type
...
15 | Box::new(a));
| - you could clone this value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `rc-list` (bin "rc-list") due to 1 previous error
报错内容是使用了已移动的值。这是因为在写 b 时,a 被移动进了 b,于是 a 的所有权就转移给了 b。
这该怎么改呢?
一种办法是修改 List 的定义,让 Cons 持有引用而不是所有权,并给它对应的生命周期参数。但这个生命周期参数会要求 List 中所有元素的存活时间至少要和 List 本身一样长。借用检查器会阻止我们编译这样的代码:
#![allow(unused)]
fn main() {
let a = Cons(10, &Nil);
}
Nil 是一个零大小(zero-sized)的枚举变体,但在表达式 Cons(10, &Nil) 或 &Nil 中,编译器会把它视作一个临时值。这个临时值通常只在当前语句(或更小的作用域)中存活,随后就会被自动丢弃。
简单来说,&Nil 是一个临时值,用完就被销毁,因此其生命周期比 enum 短。临时创建的 Nil 变体值会在 a 取得其引用之前就被丢弃。
正确的做法是使用 Rc<T>,用引用计数智能指针让多个所有者共享同一块堆上的数据,并在不再有所有者时自动释放内存:
enum List {
Cons(i32, Rc<List>),
Nil,
}
use List::{Cons, Nil};
use std::rc::Rc;
fn main() {
// main 函数里换行只是为了让链表结构更清晰,不是必要的。
let a = Rc::new(Cons(5,
Rc::new(Cons(10,
Rc::new(Nil)))));
let b = Cons(3,
Rc::clone(&a));
let c = Cons(4,
Rc::clone(&a));
}
在声明 b 和 c 时,使用 Rc::clone 并把 &a 作为参数传入,这样 b 和 c 就不会获取 a 的所有权。每使用一次 Rc::clone,智能指针内部的引用计数就会加 1。
创建 a 时,Rc::new 算第一次引用,因此计数器为 1。b 和 c 各使用了一次 Rc::clone,计数各加 1,最终计数为 3。只有当引用计数变为 0 时,a 这个智能指针中的数据才会被清理。
其实 Rc<T> 实现了 Clone trait,所以在给 b 和 c 赋值时写 a.clone() 也是可以的——它调用的就是与 Rc::clone(&a) 相同的方法。但因为这么写可能会被误解为深拷贝——尤其是对新手来说——而它实际上只是增加引用计数,所以不推荐这么写。更好的选择是 Rc::clone。
接下来我们修改一下 main,并打印一些帮助信息,看看当 c 离开作用域时引用计数如何变化:
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
这里 c 会比 a 和 b 先离开作用域,所以在 c 离开作用域后,引用计数会减 1。
输出:
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2
在此示例中我们看不到的是:当 b 和 a 在 main 末尾离开作用域时,计数变为 0,Rc<List> 会被完全清理。
因为 Rc<T> 实现了 Drop trait,所以当 Rc<T> 离开作用域时,引用计数器会自动减 1。使用 Rc<T> 允许单个值拥有多个所有者,并且计数可以确保只要还有任何所有者存在,该值就保持有效。
15.5.4 Rc<T> 总结
Rc<T> 通过不可变引用,使程序员可以在程序的不同部分之间共享只读数据。
再次强调,Rc<T> 引用是不可变的。如果 Rc<T> 允许程序员持有多个可变引用,就会违反借用规则——多个指向同一区域的可变引用会导致数据竞争以及数据不一致。
而在实际开发中肯定会遇到需要数据可变的情况。针对这一点,Rust 提供了内部可变性模式和 RefCell<T>,程序员可以将其与 Rc<T> 结合使用,以处理这种不可变性限制。下一篇文章会讲到。
15.6 RefCell与内部可变性:“摆脱”安全性限制
15.6.1 什么是内部可变性
内部可变性(interior mutability)是 Rust 的设计模式之一。它允许程序员在只持有不可变引用的前提下修改数据。
通常而言,这样的行为会被借用规则(详见 4.4. 引用与借用)所禁止,但为了能够改变数据,内部可变性模式会在数据结构内部使用 unsafe 代码,来绕过 Rust 正常的可变性和借用规则。
不安全代码告诉编译器:我们自己检查规则,而不是依赖编译器替我们检查。与不安全代码相关的概念将在以后的文章中涉及。
15.6.2 RefCell<T>
与 Rc<T> 不同,RefCell<T> 表示它所持有数据的唯一所有权。
为了理解 RefCell<T> 与 Box<T> 的区别,我们需要回顾借用规则(详见 4.4. 引用与借用):
- 在任意给定时刻,你要么拥有一个可变引用,要么拥有任意数量的不可变引用,但不能同时拥有两者。
- 引用总是有效的。
PS:“任意给定时刻”可以理解为“在给定作用域内”。
RefCell<T> 与 Box<T> 的区别如下:
| 类型 | 检查阶段 | 违反规则的结果 |
|---|---|---|
Box<T> | 编译时检查借用规则 | 编译时报错 |
RefCell<T> | 运行时检查借用规则 | 触发 panic |
在不同阶段检查借用规则具有不同特点:
-
编译时:
- 尽早暴露问题
- 没有运行时开销
- 是大多数场景的最佳选择
- 是 Rust 的默认行为
-
运行时:
- 问题暴露较晚,甚至可能到生产环境
- 因借用跟踪产生少量性能开销
- 使某些内存安全场景成为可能,例如在不可变环境中修改数据
该在什么时候使用 RefCell<T>?
Rust 编译器会在编译时检查所有代码。它能理解大部分代码;如果没有问题就通过编译,如果有问题就报错。
Rust 编译器非常保守。有些代码无法在编译时被完整分析,Rust 会直接拒绝这类代码,即使它实际上是正确的。
Rust 如此保守是为了保证安全性。虽然拒绝完全正确的代码会给开发者带来不便,但可以避免灾难性后果。
如果编译器无法分析某段代码,但开发者能够保证这段代码满足借用规则,那么 RefCell<T> 就是一个好选择。
与 RefCell<T> 类似,Rc<T> 只适用于单线程场景。
15.6.3 如何在 Box<T>、Rc<T> 和 RefCell<T> 中选择
可以根据下表列出的特性在三者之间选择:
| 特性 | Box<T> | Rc<T> | RefCell<T> |
|---|---|---|---|
| 同一数据的所有权 | 一个所有者 | 多个所有者 | 一个所有者 |
| 可变性 / 借用检查 | 可变与不可变借用(编译时检查) | 不可变借用(编译时检查) | 可变与不可变借用(运行时检查) |
额外说一句:因为 RefCell<T> 只在运行时检查,所以即使 RefCell<T> 本身是不可变的,我们仍然可以修改存储在其中的值。
15.6.4 内部可变性:可变地借用一个不可变的值
这个标题有一点绕。它的意思是:对一个没有声明为 mut 的类型使用 &mut 引用。看个例子就明白了:
fn main() {
let x = 5;
let y = &mut x;
}
借用规则的一个推论是:当你有一个不可变的值时,就不能可变地借用它。所以这段代码会报错:
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut x = 5;
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error
然而,在某些特定情况下,我们需要这样一个值:对外保持不可变,但可以在自己的方法内部修改自身;除了该值自身的方法外,其他代码都不能修改它。这叫做内部可变性。RefCell<T> 正是为这种情况而存在的。
但 RefCell<T> 并没有完全绕开借用规则。虽然编译时检查可以通过,但在运行时违反借用规则仍会导致程序 panic。
下面看一个例子(lib.rs):
功能:跟踪某个值与最大值的接近程度,并在该值达到特定级别时发出警告
#![allow(unused)]
fn main() {
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
}
这个例子的逻辑并不重要,看一下它的结构:
-
程序开头定义了
Messengertrait,其中包含send方法的签名:接收&self和一个类型为&str的字符串切片参数msg。 -
下面定义了一个名为
LimitTracker的结构体。它是一个带有生命周期'a和泛型参数T的泛型类型,其中T的生命周期为'a,并且必须实现程序开头定义的Messengertrait。LimitTracker有三个字段:messenger:类型为&'a Tvalue:类型为usizemax:类型为usize
-
接着,一个
impl块为LimitTracker定义了关联函数new。其参数是类型为&T的messenger和类型为usize的max,返回一个LimitTracker。这个函数创建一个LimitTracker实例,其中:messenger字段是messenger参数的值value字段是0max字段是max参数的值
-
LimitTracker还有一个名为set_value的方法。其第一个参数是对self的可变引用&mut self,第二个参数是value,类型为usize。 方法内部的逻辑很简单。它把self.value字段除以self.max字段(两者都转换为f64以避免丢失精度)得到一个百分比,存入percentage_of_max,并根据percentage_of_max的大小,使用Messengertrait 的send方法发送不同的警告。
使用测试替身(test double)进行测试
这里有一个问题。如果要测试这个 set_value 方法,就需要该方法输出一些可供断言的内容。但 set_value 实际上不返回任何值,因此不会提供任何可用于断言的结果。
我们要测试的是:当我们用一个实现了 Messenger trait 的值和一个 max 值创建 LimitTracker 实例时,传入不同的 value 会触发 Messenger 发送不同的消息。
为了解决这个问题,这里介绍测试替身(test double),中文也叫测试替代。它是一个通用的测试概念,表示测试中使用的替代品。在测试替身中,有一种特定类型叫做模拟对象(Mock Object),负责记录测试过程中发生的事情。我们可以利用这些记录来断言测试是否正确运行。
Rust 没有内置的等价物,标准库也不提供模拟对象,但我们可以自定义一个结构体来做同样的事情。
接着上面的代码继续写:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
}
-
在测试模块开头,我们声明一个
MockMessenger结构体,它有一个字段sent_messages,用于存储已发送的消息,类型为Vec<String>。 -
然后通过
impl块为MockMessenger添加new函数,用于创建MockMessenger实例。sent_messages字段初始化为一个空的Vector。 -
接着为
MockMessenger实现程序开头定义的Messengertrait。实现该 trait 后,就可以用MockMessenger创建LimitTracker,因为LimitTracker要求其泛型类型实现Messenger。 当调用send方法时,消息会被存入MockMessenger的sent_messages这个Vector中。 -
最后,
it_sends_an_over_75_percent_warning_message测试函数测试超过 75% 的情况。 首先创建名为mock_messenger的MockMessenger实例,再创建名为limit_tracker的LimitTracker实例,然后在该LimitTracker实例上调用方法。最后通过检查mock_messenger.sent_messages中的元素数量来断言。
此时代码逻辑有问题,运行会报错:
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
|
2 ~ fn send(&mut self, msg: &str);
3 | }
...
56 | impl Messenger for MockMessenger {
57 ~ fn send(&mut self, message: &str) {
|
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error
错误出现在为 MockMessenger 实现 send 方法时:
#![allow(unused)]
fn main() {
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
}
我们无法修改 MockMessenger 来跟踪消息,因为 send 方法的签名接收的是对 self 的不可变引用。我们也不能改成 &mut self,因为那样 send 的签名就与 Messenger trait 中定义的 &self 签名不匹配了。
针对这种需要内部可变性的情况,可以使用 RefCell<T>。我们只需要把 MockMessenger 的 sent_messages 字段用 RefCell<T> 包起来:
#![allow(unused)]
fn main() {
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
}
因为 RefCell<T> 不在预导入模块中,所以使用前需要先引入作用域:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
}
改完之后,每一处使用 sent_messages 字段的代码也需要用 RefCell<T> 包装:
#![allow(unused)]
fn main() {
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
}
RefCell 该怎么用呢?用 RefCell 创建的数据可以通过 borrow_mut 方法修改。对参数调用 borrow_mut 会得到可变引用,因此 MockMessenger 的 send 方法可以使用 borrow_mut:
#![allow(unused)]
fn main() {
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
}
这样一来,即使 send 的参数是不可变引用,也可以在函数体内部通过 borrow_mut 修改值。
最后修改测试函数中的断言:
#![allow(unused)]
fn main() {
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
对 mock_messenger 使用 borrow,即可获得该值的不可变引用用于断言。
现在代码没有任何问题。完整代码如下:
#![allow(unused)]
fn main() {
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
}
15.6.5 使用 RefCell<T> 在运行时记录借用信息
实际上,上文使用的 borrow_mut 和 borrow 方法就像提供给用户的两个安全接口:
borrow返回智能指针Ref<T>,它实现了Dereftraitborrow_mut返回智能指针RefMut<T>,它实现了Deref和DerefMuttrait
RefCell<T> 会跟踪当前存在多少活跃的 Ref<T> 和 RefMut<T>:
- 每次调用
borrow,不可变借用计数加1。 当任何一个Ref<T>值离开作用域并被丢弃时,不可变借用计数减1。 - 每次调用
borrow_mut,可变借用计数加1。 当任何一个RefMut<T>值离开作用域并被丢弃时,可变借用计数减1。
与编译时借用规则(详见 4.4. 引用与借用)一样,RefCell<T> 允许我们在任意时刻拥有许多不可变借用,或一个可变借用。
如果我们尝试违反这些规则,RefCell<T> 的实现会在运行时 panic,因为 RefCell<T> 在运行时检查借用规则。panic 信息 already borrowed: BorrowMutError 就是 RefCell<T> 在运行时处理借用规则违反的方式。
15.6.6 将 Rc<T> 和 RefCell<T> 结合使用的例子
Rc<T> 允许某些数据被多个所有者持有,但它只提供对该数据的不可变访问。如果你有一个包含 RefCell<T> 的 Rc<T>,就可以得到一个拥有多个所有者并且可变的值。
下面看一个将 Rc<T> 和 RefCell<T> 结合使用、以实现多重所有权可变数据的例子:
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {a:?}");
println!("b after = {b:?}");
println!("c after = {c:?}");
}
还记得上一篇文章中的 Cons 列表示例吗?我们使用 Rc<T> 允许多个列表共享另一个列表的所有权。因为 Rc<T> 只保存不可变值,一旦创建了列表中的任何值,就无法再更改它们。结合本文所学,我们再加上 RefCell<T>,以便能够修改列表中的值:
- 首先,在定义
List枚举时,把Cons关联的i32用RefCell<>包裹,以便可以修改该值。外面再用Rc<>包裹,以便多个所有者可以共享它,其余保持不变。 - 记得把
Rc和RefCell引入当前作用域。 - 然后用
Rc::new()和RefCell::new()创建实例。a通过Rc::clone()共享value的值,b和c通过Rc::clone()共享a的值(前提是a被Rc<>包裹)。 - 最后,对
RefCell<T>使用borrow_mut获得对value的可变借用。再用解引用运算符*像操作i32一样给它加10。
输出:
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
跟预期一样,没有问题。
15.6.7 其他可以实现内部可变性的类型
Cell<T>:通过复制来访问数据Mutex<T>:用于在多线程上下文中实现内部可变性
15.7 循环引用导致内存泄漏
15.7.1 内存泄漏
Rust 极高的安全性使得内存泄漏很难发生,但并非不可能。
例如,使用 Rc<T> 和 RefCell<T> 可能创造出引用循环并造成内存泄漏:每个指针的引用计数都不会减少到 0,因此这些值永远不会被清理。
看个例子:
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
}
- 首先创建一个链表
List,用RefCell<T>包裹Rc<T>,以便可以修改内部值。 - 通过
impl块为List定义一个名为tail的方法,用于获取Cons变体携带的第二个元素。如果存在,就用Some包起来返回;如果是Nil,则返回None。 - 然后在
main中创建两个List实例a和b,并且b内部共享了a的值。这种链表代码看着很绕,所以我把结构图放在这里:
main还通过Rc::strong_count获取a和b的强引用计数,用自定义的tail方法获取Cons携带的第二个元素,并用println!打印出来。- 接下来,
if let语句把a的Cons的第二个值绑定到link上。它用borrow_mut获得对RefCell内Rc<List>的可变借用,再通过Rc::clone把b的克隆赋进去,从而把a的内部结构改成这样:
输出:
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
- 第 1 行到第 5 行:刚创建
a时,引用计数为1。声明b时共享了a,因此a的引用计数变为2,b为1。 - 第 6 行到第 7 行:
if let语句改变了a的内部结构,使a的第二个元素指向b,于是b的引用计数增加到2。此时a指向b,b又指向a,就形成了引用循环。
当 a 和 b 都离开作用域时,Rust 丢弃变量 b,这会把 b 的引用计数从 2 减到 1。此时堆上的 Rc<List> 内存不会被删除,因为它的引用计数是 1 而不是 0。然后 Rust 丢弃 a,这会把 a 的 Rc<List> 实例的引用计数从 2 减到 1,如下所示。这个实例的内存也不能被删除,因为另一个 Rc<List> 实例仍然引用它。为列表分配的内存将永远保持未回收状态。
接下来,用下面这行代码看看循环里包含什么:
#![allow(unused)]
fn main() {
println!("a next item = {:?}", a.tail());
}
Rust 会尝试打印这个循环:a 指向 b,b 又指向 a,如此往复,直到栈溢出。最终结果会是栈溢出错误。
15.7.2 如何防止内存泄漏
那么有没有办法防止内存泄漏呢?这只能依靠开发者,不能只依赖 Rust。
否则,就需要重新组织数据结构,把引用拆分成持有所有权和不持有所有权的两类。一些引用用来表达所有权,一些引用不表达所有权。在引用循环中,一部分具有所有权关系,另一部分没有。这样,只有与所有权相关的链接才会影响值是否被清理。
15.7.3 把 Rc<T> 换成 Weak<T> 以防止循环
我们知道,Rc::clone 会创建数据的强引用,并使 Rc<T> 内部的引用计数加 1;只有当 strong_count 变为 0 时,Rc<T> 才会被清理。
然而,Rc<T> 实例可以通过调用 Rc::downgrade 来创建对某个值的弱引用。该方法的返回类型是 Weak<T>(也是智能指针)。每次调用 Rc::downgrade 会增加 weak_count 而不是 strong_count,因此弱引用不影响 Rc<T> 的清理。
15.7.4 Strong vs. Weak
强引用关乎如何分析 Rc<T> 实例的所有权。弱引用并不表达所有权,使用它也不会创建引用循环:当强引用计数变为 0 时,弱引用会自动断开。
在使用弱引用之前,需要确认它所指向的值仍然存在。在 Weak<T> 实例上调用 upgrade 方法会返回 Option<Rc<T>>,并通过 Option 枚举来验证该值是否存在。
看个例子:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
}
Node 结构体表示一个节点,有两个字段:
value字段存储当前值,类型是i32。children字段存储子节点,类型是RefCell<Vec<Rc<Node>>>。这里使用Rc<T>是为了让所有子节点共享所有权。更具体地说,我们希望一个Node拥有它的子节点,同时也希望与保存该节点本身的变量共享这份所有权,以便可以直接访问树中的每个Node。为此,我们将Vec<T>的元素定义为Rc<Node>类型的值。
这里的需求是:每个节点既能指向父节点,也能指向子节点。
再看 main 函数:
leaf被创建为一个Node实例,value为3,children为被RefCell包裹的空Vector。branch被创建为一个Node实例,value为5,其children指向leaf。
这意味着 leaf 里面的 Node 有两个所有者。目前可以通过 branch 的 children 字段访问 leaf;但反过来还不能通过 leaf 访问 branch,所以还需要修改。
要实现这一点,就需要双向引用。但双向引用会创建引用循环,因此需要使用 Weak<T> 来避免循环:
#![allow(unused)]
fn main() {
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
}
我们添加一个 parent 字段表示父节点,并使用弱引用 Weak<T>。这里不用 Vec<>,因为这是树结构,一个节点只能有一个父节点。
要这样写,需要把 Weak<T> 引入作用域,并重构下面的代码。修改后的完整代码如下:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
在创建 leaf 之后,先打印其 parent 字段的内容(此时 parent 还没有任何值)。创建 branch 之后,再打印一次 leaf 的 parent 字段内容(此时其值是 branch)。
语句 *leaf.parent.borrow_mut() = Rc::downgrade(&branch); 会创建一个指向 branch 的 Weak<Node>,并存入 leaf 的 parent 字段:
leaf.parent是表示leaf父节点的字段,类型是RefCell<Weak<Node>>,因此可以用borrow_mut得到RefMut<Weak<Node>>。- 解引用运算符
*让我们访问存储在RefMut<Weak<Node>>内部的Weak<Node>值。 downgrade方法从branch创建一个Weak<Node>,并赋给parent。
输出:
leaf parent = None
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) }, children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) }, children: RefCell { value: [] } }] } })
- 第一次打印表明
parent字段尚未被赋值,因此其值是Option下的None变体。 - 第二次打印表明父节点已被设置为
branch;输出没有无限继续,说明这段代码没有创建引用循环。
最后,我们修改 main:添加打印语句并调整作用域,看看强引用和弱引用的数量:
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!(
"branch strong = {}, weak = {}",
Rc::strong_count(&branch),
Rc::weak_count(&branch),
);
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
代码逻辑是:
-
创建
leaf之后,打印它有多少强引用和弱引用。 -
之后加上
{}创建新作用域:- 把
branch的声明以及给leaf指定父节点的操作放进去。 - 打印此时
branch和leaf的强引用、弱引用数量。
- 把
-
离开作用域后:
- 打印
leaf的parent - 打印
leaf的强引用和弱引用
- 打印
输出:
leaf strong = 1, weak = 0
branch strong = 1, weak = 1
leaf strong = 2, weak = 0
leaf parent = None
leaf strong = 1, weak = 0
- 第 1 行:创建了
leaf,有一个强引用。 - 第 2 行:创建了
branch。在用Rc::downgrade(&branch)设置leaf.parent之后,branch有一个强引用和一个弱引用——这个弱引用计数来自leaf的parent字段,而不是来自branch自己那个空的Weak::new()。 - 第 3 行:
branch.children持有对leaf的强引用,而leaf变量本身也是一个强引用,因此此时leaf有两个强引用。 - 第 4 行:因为
branch已经离开作用域,所以leaf的parent字段现在是None。 - 第 5 行:
branch离开作用域导致它对leaf的强引用失效,于是leaf的强引用减1,变为1。
16.1 使用多线程同时运行代码
16.1.1. 什么是并发
- Concurrent 指的是程序的不同部分之间独立运行
- Parallel(并行)指的是程序的不同部分同时运行
The Rust Programming Language 中用这么一段话形容了 Rust 对并发的支持:
Fearless concurrency(无畏并发)
安全高效地处理并发编程是 Rust 的另一个主要目标。随着越来越多的计算机利用其多个处理器,并发编程(程序的不同部分独立执行)和并行编程(程序的不同部分同时执行)变得越来越重要。从历史上看,在这些环境中编程一直是困难且容易出错的——Rust 希望改变这一点。
最初,Rust 团队认为确保内存安全和防止并发问题是两个独立的挑战,需要用不同的方法来解决。随着时间的推移,团队发现所有权和类型系统是一组强大的工具,可以帮助管理内存安全和并发问题!
通过利用所有权和类型检查,许多并发错误是 Rust 中的编译时错误,而不是运行时错误。因此,不正确的代码会被拒绝编译并显示解释问题的错误,而不是让你花费大量时间尝试重现发生运行时并发错误的确切情况。因此,你可以在处理代码时修复代码,而不是在将代码交付生产后修复。
我们将 Rust 的这一方面称为“无畏并发”。无畏并发允许你编写没有细微错误的代码,并且易于重构而不会引入新的错误。
其中最重要的一句话就是:无畏并发允许你编写没有细微错误的代码,并且易于重构而不会引入新的错误。
注:这章所指的“并发”泛指 Concurrent 和 Parallel。
16.1.2. 进程和线程
在大部分现代的操作系统里,代码运行在进程(process)中,系统同时管理多个进程。在你的程序里,各独立部分可以同时运行,运行这些独立部分的就是线程(thread)。
由于多个线程是可以同时运行的,所以我们通常会把程序的计算拆分成为多个线程来同时运行。这样做有利有弊:
- 提升性能表现
- 增加复杂性:无法保证各线程的执行顺序
16.1.3. 多线程可导致的问题
- 竞争状态(race condition):线程以不一致的顺序访问数据或资源
- 死锁(deadlock):两个线程彼此等待对方使用完所持有的资源,线程无法继续
- 引起只在某些情况下发生的 Bug,很难可靠地复现 Bug 并修复。
16.1.4. 实现线程的方式
-
通过调用操作系统的 API 来创建线程,叫做1:1 模型,也就是一个操作系统的线程对应一个语言的线程。它的优点是需要较小的运行时。
-
语言自己可以实现线程(也叫绿色线程),是M:N 模型。也就是 M 个绿色线程对应 N 个系统线程。它需要比较大的运行时。
每一种模型都有其自身的优势和缺点,Rust 需要权衡运行时的支持。
除了汇编语言,其他的编程语言都有一定的运行时。
即使是 C/C++,运行时的功能极少,都有较小的运行时,所以它们能生成较小的二进制文件,并且使该语言在多种场景下都可以与其他语言组合使用。
而有一些语言增加运行时来提供更多的功能,比如 Java、C# 和 Go。
对于 Rust 来说,它尽可能保持几乎没有运行时的状态,这样就能方便地与 C 语言进行交互,并且获得较高的性能。所以Rust 标准库仅提供 1:1 模型的线程。
但是由于 Rust 具有良好的底层抽象能力,在社区里也有很多支持M:N 模型的第三方包。
16.1.5. 通过 spawn 创建线程
通过 thread::spawn 函数可以创建新线程。它有一个参数,接收闭包作为在新线程里运行的代码。
看个例子:
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
-
这个闭包没有参数。里面的逻辑很简单:从 1 循环到 10(不包括 10),把数打印出来,每次循环都有一个
sleep函数,其参数是Duration::from_millis(1),表示暂停一毫秒。 -
在主线程里也有一个循环,从 1 循环到 5(不包括 5),把数打印出来,一样的每次循环都暂停 1 毫秒。
由于新创建的线程从 1 到 10,而主线程是从 1 到 5,所以主线程会先执行完,而 Rust 会在主线程执行完后立刻结束程序,不论其他线程是否还在执行。主线程和新创建的线程的打印应该是交替出现的。
输出(线程交错顺序不确定;以下为一次代表性的本地运行结果):
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
在这次样例里,主线程输出完 4 后就要结束了;分线程还能再跑一会儿,在程序关闭前又输出了两行(hi number 4 和 hi number 5)。
这样写不能保证另外一个线程能够完成它的执行,这时候就需要 JoinHandle。
16.1.6. 通过 JoinHandle 来等待所有线程完成
thread::spawn 函数的返回类型是 JoinHandle,这个类型持有已创建线程的句柄的所有权,通过调用其 join 方法,可以等待它对应的线程完成。
调用 handle.join() 会阻塞当前运行线程的执行,直到 handle 所表示的线程终结。
看个例子:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
- 把
thread::spawn的返回值赋给变量handle - 最后使用
handle上的join方法,再调用unwrap。它会阻塞当前线程——在这个例子中就是主线程——直到handle所对应的线程(就是新创建的线程)执行完毕。
使用unwrap的原因是handle.join()的返回值是一个Result类型,如果成功执行就返回Ok(T),T是线程的返回值;如果线程在执行时发生了恐慌就返回Err(e),e是错误信息。
如果你确信线程不会panic,可以直接调用unwrap来简化代码,从而忽略Err分支。
输出(线程交错顺序不确定;以下为一次代表性的本地运行结果):
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
主线程输出完 "hi number 4 from the main thread!" 之后会在 join 上等待,因此分线程不会被主循环结束打断,可以继续输出到 9。
让我们看看当将 handle.join() 移到 main 中的 for 循环之前会发生什么,如下所示:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
输出:
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
这次就是先执行完分线程,才执行主线程的循环。
使用 move 闭包
move 闭包通常和 thread::spawn 函数一起使用,它允许你使用其他线程的数据。也就是说,在创建线程时,把值的所有权从一个线程转到另一个线程里。
看个例子:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
- 在主函数里创建了
Vector,命名为v - 新线程调用了
v,打印出v - 最后
handle.join().unwrap();让主线程等待分线程结束。
输出:
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {v:?}");
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {v:?}");
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error
报错信息提到闭包里借用了 v(编译器推断出闭包使用 v 的代码只需要借用就可以了),但是闭包的生命周期可能比 v 还要长。
比如说:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
drop(v);
handle.join().unwrap();
}
在闭包作为分线程在执行时,主线程可能就已经执行到 drop(v); 把 v 丢弃了,那么分线程里的 v 就没法使用了。
最简单的方法就是把 v 的所有权移交给闭包。在管道符 || 前写上 move 关键字即可:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
这样写的缺点就是主线程就使用不了 v 了。
16.2 使用消息传递来跨线程传递数据
16.2.1. 消息传递
有一种很流行而且能保证安全并发的技术叫做消息传递。在这种机制里,线程(或 Actor)通过彼此间发送消息(数据)来进行通讯。
Go 语言有一句名言是这么说的: Do not communicate by sharing memory; instead, share memory by communicating.(不要用共享内存来通信,要用通信来共享内存)
Go 语言的并发模型体现了这种思想。Rust 也提供了基于消息传递的一种并发方式,具体就是使用标准库中的 Channel。Go 语言里也有 Channel,思路差不多。
16.2.2. 理解 Channel
可以将编程中的 Channel 想象为定向水道,例如小溪或河流。如果你把橡皮鸭之类的东西放入河中,它会顺流而下,到达水道的尽头。
通道有两部分:发送端和接收端。发送端是将橡皮鸭放入河中的上游位置,接收端是橡皮鸭最终到达下游的位置。代码的一部分使用要发送的数据调用发送端上的方法,另一部分检查接收端是否有到达的消息。如果发送端或接收端其一消失,则称通道已关闭。
具体的步骤:
- 调用发送端的方法,发送数据
- 接收端会检查和接收到达的数据
- 如果发送端、接收端中的任意一端被丢弃了,那么
Channel就关闭了。
16.2.3. 创建 channel
使用 mpsc::channel 函数来创建 Channel。mpsc 表示 multiple producer, single consumer(多个生产者、一个消费者),表示可以有多个发送端,但是只能有一个接收端。
调用这个函数返回一个元组,有两个元素,分别是发送端和接收端。
看个例子:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {received}");
}
-
首先使用
mpsc::channel函数来创建Channel,返回的元组使用模式匹配进行解构,分别用tx和rx表示发送端和接收端。 -
接下来创建了一个线程,使用
move关键字表示发送端tx的所有权被移至分线程内,因为线程必须拥有通道发送端的所有权才能往通道里发消息。
使用send方法来发送消息,返回类型是Result类型,如果接收端被丢弃了那么返回值就是Err,反之就是Ok。在这里面就简单地使用unwrap进行错误处理即可,这样如果接收端被丢弃就会恐慌。 -
接收端有两个方法来获取消息,这里使用了
recv方法(receive的简写)。它会一直阻塞这个线程,直到有消息被传入为止。
消息被包裹在Result类型中,有消息就返回Ok,反之就是Err,一样使用unwrap简单地处理错误即可。
输出:
Got: hi
发送端的 send 方法
send 方法的参数是想要发送的数据,返回 Result 类型。如果有问题(例如接收端已经被丢弃)就会返回 Err。
接收端的方法
-
recv方法:阻止当前线程执行,直到Channel中有值传来,一旦收到值,就返回Result类型,如果发送端关闭了,就会收到Err。 -
try_recv方法:不会阻塞当前线程执行,立即返回Result类型,有数据到达就是Ok变体包裹着传过来的数据;否则就返回错误。
通常是使用循环调用来检查try_recv的结果。一旦有消息来了就开始处理,如果没来,那么这时候也可以执行其他指令。
16.2.4. channel 和所有权转移
所有权在消息传递中非常重要,它能帮你编写安全、并发的代码。
看个例子:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
println!("val is {val}");
});
let received = rx.recv().unwrap();
println!("Got: {received}");
}
在刚才的代码上加了 println!("val is {val}"); 这句话。把值传入 send 函数后想继续在线程里使用值。
输出:
$ cargo run
Compiling message-passing v0.1.0 (/tmp/projects/message-passing)
error[E0382]: borrow of moved value: `val`
--> src/main.rs:10:27
|
8 | let val = String::from("hi");
| --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9 | tx.send(val).unwrap();
| --- value moved here
10 | println!("val is {val}");
| ^^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error
错误在于借用了已经移动的值 val。它的所有权已经在传入 send 时移交出去了,所以就会报错。
下一个例子通过发送多个值来观察接收者等待的过程:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
}
- 分线程以循环的方式发送
Vector里的各个元素,每次发送完之后会暂停 1 秒 - 主线程把接收端当作一个迭代器来使用(因为实现了
Iteratortrait),这样就不需要显式调用recv函数了。每收到一个值就将它打印出来。当发送端执行完毕被丢弃时,Channel就关闭了,循环就不会继续。程序退出。
输出:
Got: hi
Got: from
Got: the
Got: thread
16.2.5. 通过克隆创建多个发送者
继续在上一个代码的基础上稍作修改:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
}
这里多了一个分线程,现在有 2 个分线程都想要给主线程发消息,所以就需要两个发送端。针对这种情况,只需要对代表发送端的变量 tx 使用 clone 方法即可,也就是原文的 let tx1 = tx.clone(); 这一句。
输出(接收顺序不确定;以下为一次代表性的本地运行结果):
Got: hi
Got: more
Got: from
Got: messages
Got: the
Got: for
Got: thread
Got: you
接收端收到的数据会交错来自两个发送端。
16.3 共享状态的并发
16.3.1. 使用共享状态来实现并发
还记得 Go 语言有一句名言是这么说的:
Do not communicate by sharing memory; instead, share memory by communicating. (不要用共享内存来通信,要用通信来共享内存)
上一篇文章 16.2. 使用消息传递来跨线程传递数据 就是使用通信的方式来实现并发的。这一篇文章讲一下如何使用共享内存的方式来实现并发。Go 语言不建议使用这种方式,但 Rust 支持通过共享状态来实现并发。
上一篇文章 16.2. 使用消息传递来跨线程传递数据 讲的 Channel 类似单所有权:一旦值的所有权转移至 Channel,就无法使用它了。共享内存并发类似于多所有权:多个线程可以同时访问同一块内存。
16.3.2. 使用 Mutex 来只允许一个线程来访问数据
Mutex 是 mutual exclusion(互斥锁)的简写。
在同一时刻,Mutex 只允许一个线程来访问某些数据。
想要访问数据,线程必须首先获取互斥锁(lock),在 Rust 里就是调用 lock 方法获得。lock 数据结构是 Mutex 的一部分,它能跟踪谁对数据拥有独占访问权。Mutex 通常被描述为:通过锁定系统来保护它所持有的数据。
16.3.3. Mutex 的两条规则
- 在使用数据之前,必须尝试获取锁(lock)。
- 使用完
Mutex所保护的数据,必须对数据进行解锁,以便其他线程可以获取锁。
16.3.4. Mutex<T> 的 API
通过 Mutex::new 函数来创建 Mutex<T>,其参数就是要保护的数据。Mutex<T> 实际上是一个智能指针。
在访问数据前,通过 lock 方法来获取锁,这个方法会阻塞当前线程的运行。lock 方法也可能会失败,所以返回的值被 Result 包裹,如果成功,Ok 变体附带的值的类型就为 MutexGuard(智能指针,实现了 Deref 和 Drop)。
看个例子:
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
- 使用
Mutex::new创建了一个互斥锁,其保护的数据是5,赋给m。所以m的类型是Mutex<i32>。 - 后面使用
{}创建了新的小作用域,在小作用域里使用lock方法获取值,使用unwrap进行错误处理。由于MutexGuard实现了Dereftrait,我们就可以获得内部数据的引用。所以num是一个可变引用。 - 在小作用域内还使用了解引用
*来修改数据的值为6。 - 由于
MutexGuard实现了Droptrait,所以在小作用域结束后会自动解锁。 - 最后打印了修改后的互斥锁内的内容。
输出:
m = Mutex { data: 6, poisoned: false, .. }
16.3.5. 多线程共享 Mutex<T>
看个例子:
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
counter实际上就是一个计数器,只是使用了Mutex包裹以更好地在多线程中调用,刚开始的值是0handles目前是一个空Vector- 下面通过从 0 到 10(不包括 10)的循环创建了 10 个线程,把每个线程得到的
handle放到空集合handles里。 - 在线程的闭包里,我们的意图是把
counter这个互斥锁转移到闭包里(所以使用了move关键字),然后获取互斥锁,然后修改它的值,每个线程都加 1。当线程执行完后,num会离开作用域,互斥锁被释放,其他线程就可以使用了。 - 从 0 到 10(不包括 10)的循环里还遍历了
handles,使用join方法,这样等每个handle所对应的线程都结束后才会继续执行。 - 最后在主线程里尝试获得
counter的互斥锁,然后把它打印出来。
输出:
$ cargo run
Compiling shared-state v0.1.0 (/tmp/projects/shared-state)
error[E0382]: borrow of moved value: `counter`
--> src/main.rs:21:29
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Result: {}", *counter.lock().unwrap());
| ^^^^^^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
错误是在前一次循环中已经把所有权移到前一次的那个线程里了,而这一次循环就没法再获得所有权了。
那么如何把 counter 放到多个线程,也就是让多个线程拥有它的所有权呢?
16.3.6. 多线程的多重所有权
在 15.5. Rc<T>:引用计数智能指针与共享所有权 讲了一个多重所有权的智能指针叫 Rc<T>,把 counter 用 Rc 包裹即可:
#![allow(unused)]
fn main() {
let counter = Rc::new(Mutex::new(0));
}
在循环里,需要把克隆传进线程,这里用了变量遮蔽把新 counter 值设为旧 counter 的克隆:
#![allow(unused)]
fn main() {
let counter = Rc::clone(&counter);
}
修改后的代码(记得在使用前引入 Rc):
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
输出:
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/src/thread/functions.rs:128:8
|
125 | pub fn spawn<F, T>(f: F) -> JoinHandle<T>
| ----- required by a bound in this function
...
128 | F: Send + 'static,
| ^^^^ required by this bound in `spawn`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
看报错信息的这部分:`Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely,Rc<Mutex<i32>> 不能在线程间安全地传递。编译器也告诉我们了原因:the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`,Rc<Mutex<i32>> 没有实现 Send trait(下一篇文章 16.4. 通过 Send 和 Sync trait 来扩展并发 会讲到)。只有实现 Send 的类型才能在线程间安全地传递。
其实在 15.5. Rc<T>:引用计数智能指针与共享所有权 讲 Rc<T> 也说到了它不能用于多线程场景:Rc<T> 不能安全地跨线程共享。它不能确保计数的更改不会被另一个线程中断。这可能会导致错误的计数,进而导致内存泄漏或在我们完成之前删除某个值。我们需要的是一种与 Rc<T> 完全相同的类型,但它以线程安全的方式更改引用计数。
那么多线程应该用什么呢?有一个智能指针叫做 Arc<T> 可以胜任这个场景。
16.3.7. 使用 Arc<T> 来进行原子引用计数
Arc<T> 和 Rc<T> 类似,但是它可以用于并发场景。Arc 的 A 指的是 Atomic(原子的),这意味着它是一个原子引用计数类型,原子是另一种并发原语。这里不对 Arc<T> 做过于详细的介绍,只需要知道原子像原始类型一样工作,但可以安全地跨线程共享,其余信息详见 Rust 官方文档。
那么为什么所有的基础类型都不是原子的?为什么标准库不默认使用 Arc<T>?因为线程安全会带来性能开销,只有在真正需要时才值得付出。
幸好 Arc<T> 和 Rc<T> 的 API 相同,所以先前的代码就很好改了(记得在使用前引入 Arc):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
16.3.8. RefCell<T>/Rc<T> vs. Mutex<T>/Arc<T>
Mutex<T> 提供了内部可变性,和 Cell 家族一样。我们一般使用 Rc<T> 包裹 RefCell<T> 以获得一个有内部可变性的共享所有权数据类型。同样的,使用 Mutex<T> 可以改变 Arc<T> 里面的内容。
当使用 Mutex<T> 时,Rust 无法保护你免受各种逻辑错误的影响。使用 Rc<T> 会带来创建引用循环的风险,其中两个 Rc<T> 值相互引用,从而导致内存泄漏。同样,Mutex<T> 也存在产生死锁(deadlock) 的风险。当一个操作需要锁定两个资源并且两个线程各自获取其中一个锁,导致它们永远等待对方时,就会发生这种情况。Mutex<T> 和 MutexGuard 的标准库 API 文档提供了有用的信息。详见:Mutex<T> API 文档和 MutexGuard API 文档。
16.4 通过 Send 和 Sync trait 来扩展并发
16.4.1. Send 和 Sync trait
Rust 语言本身的并发特性较少,目前所提及的并发特性都来自于标准库,而不是语言本身。其实无需局限于标准库的开发,可以自己实现并发。
Rust 语言中有两个并发概念:
std::marker::Synctraitstd::marker::Sendtrait
这两个 trait 叫标记 trait(marker traits),因为它们没有定义任何方法,只供标记特性。
16.4.2. Send:允许线程间转移所有权
在上一篇文章 16.3. 共享状态的并发 我们曾尝试在跨线程的情况下传递 Rc<T>,失败了,失败原因就是没有实现 Send trait。
Rust 里几乎所有的类型都实现了 Send:几乎所有的基础类型都实现了 Send trait。但 Rc<T> 没有实现 Send,它只能用于单线程场景。
任何完全由 Send 类型组成的类型也被标记为 Send,相当于实现了 Send trait。
16.4.3. Sync:允许从多线程访问
实现 Sync trait 的类型可以安全地被多个线程引用。也就是说,如果 T 实现了 Sync trait,那么 &T 就实现了 Send trait。
基础类型都实现了 Sync,任何完全由 Sync 组成的类型也相当于实现了 Sync。但是 Rc<T> 不是 Sync,RefCell<T> 和 Cell<T> 家族也不是 Sync,但是 Mutex<T> 是 Sync。
16.4.4. 手动实现 Send 和 Sync 是不安全的
由于由 Send 和 Sync 部分组成的类型也自动具有 Send 和 Sync,因此我们不必手动实现这些 trait。作为标记 trait,它们甚至没有任何方法可以实现。它们只是用于强制执行与并发相关的不变量。
手动实现这些 trait 涉及编写不安全的 Rust 代码。我们将在以后的文章 19.1. 摆脱安全性限制的 unsafe Rust 讨论使用不安全的 Rust 代码(关于这部分也可以看 The Rustonomicon);目前,重要的一点是:在构建新的并发类型时,需要仔细考虑 Send 和 Sync 组成部分,以维护安全保证。
总之一句话:不要尝试手动实现 Send 和 Sync!!!
17.1 Rust的面向对象的编程特性
17.1.0 什么是面向对象的编程特性?
面向对象编程(Object-oriented programming,简称 OOP)是一种程序建模方法。对象作为编程概念在编程语言 Simula 中引入。这些对象影响了 Alan Kay 的编程架构,其中对象相互传递消息。为了描述这种架构,他在 1967 年创造了面向对象编程这个术语。
核心概念
-
对象(Object)
- 程序的基本单位,包含属性(状态)和行为(操作)。
-
类(Class)
- 对象的模板,定义了属性和行为。
-
封装(Encapsulation)
- 将数据和操作绑定在一起,隐藏内部细节,通过接口与外部交互。
-
继承(Inheritance)
- 子类继承父类的属性和行为,提高代码重用性。
-
多态(Polymorphism)
- 相同的接口表现出不同的行为,包括方法重载和重写。
-
抽象(Abstraction)
- 只关注必要部分,忽略复杂实现,通过类或接口提供高层次设计。
面向对象编程的优势
- 模块化和可维护性:代码易于维护和扩展。
- 代码重用性:通过继承和抽象减少重复代码。
- 易于扩展:新功能可轻松添加。
- 现实建模:贴近现实世界概念。
- 数据安全性:通过封装保护数据,增强安全性。
17.1.1 Rust 的面向对象编程特点
对于一种语言必须具备哪些特性才能被视为面向对象,编程社区尚未达成共识。Rust 受到许多编程范式的影响,包括 OOP。OOP 通常包括对象、封装和继承等特性。
面向对象编程有很多种定义,而这些定义有很多是相互矛盾的。其中有一些定义能够把 Rust 划为面向对象编程的语言,而另外一部分定义则不这样认为。
在第 13 章中我们说过了 Rust 函数式编程的特性,但 Rust 既不是传统的面向对象编程语言,也不是纯函数式编程语言。它是一个多范式编程语言,结合了函数式编程和面向对象编程的一些特点。
对象包含数据和行为
Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(这四人通俗地被称为“四人帮”)所著的 Design Patterns: Elements of Reusable Object-Oriented Software(《设计模式:可重用面向对象软件的元素》)是一本关于设计模式的经典著作。它这样定义 OOP:
面向对象的程序是由对象组成的。对象封装了数据和操作该数据的过程。这些过程通常称为方法或操作。
基于这个定义,Rust 是面向对象的:struct、enum 包含数据,impl 块为之提供了方法。但在 Rust 里带有方法的 struct 和 enum 并没有称之为对象。
封装
封装指的是调用对象外部的代码无法直接访问对象内部的实现细节,唯一可以与对象进行交互的方法就是通过它公开的 API。
Rust 通过 pub 关键字来决定代码中哪些模块、类型、函数或者是方法是公开的。而默认情况下它们都是私有的。
看个例子:
#![allow(unused)]
fn main() {
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
}
该结构体被标记为 pub 以便其他代码可以使用它,但该结构中的字段仍然是私有的。因为我们希望确保每当在列表中添加或删除值时,平均值也会更新。直接给字段改值做不到这一点,所以不能让用户能够修改其字段值。我们通过在结构体上实现 add、remove 和 average 方法来实现。
继承
继承是指使对象可以沿用另外一个对象的数据与行为,且无需重复定义相关的代码。Rust 并不支持这个特性。
通常使用继承的原因是代码复用和多态。
-
针对代码复用,Rust 提供了默认 trait 方法来进行代码共享。 在 trait 中某个方法有默认实现,那么任何实现了这个 trait 的类型就会自动拥有这个方法。这和面向对象很类似,在面向对象语言中,父类中实现的方法就可以被继承它的子类拥有。当实现某个 trait 时,还可以覆盖 trait 的默认实现,这类似于子类覆盖从父类继承的方法的实现。
-
多态指期望某个类型能够被应用在需要父类型的地方,换句话说,就是如果一些对象拥有某些共同的特征,那么这些对象就可以在运行时相互替换。 Rust 通过泛型和 trait bound(trait 约束)实现了这一点:泛型使得逻辑可以更好地脱离于实际的数据类型,并使用 trait bound 来决定能使用此逻辑的类型必须提供的某些具体特性,这一技术也称为限定参数多态(bounded parametric polymorphism)。
现在其实很多语言都不使用继承作为内置的程序设计方案了。 因为它经常面临共享过多代码的风险。子类不应该总是共享其父类的所有特征,但可以通过继承来实现。这会降低程序设计的灵活性。它还引入了在子类上调用没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,有些语言只允许单继承(即子类只能从一个类继承),这进一步限制了程序设计的灵活性。
17.2 使用trait对象来存储不同值的类型
17.2.1 需求
这篇文章以一个例子来介绍如何在 Rust 中使用 trait 对象来存储不同值的类型。
在第 8 章中,我们提到 Vec 的一个限制是它们只能存储一种类型的元素。我们在 8.2. Vector + Enum 的应用 中创建了一个解决方法,其中定义了一个 SpreadsheetCell 枚举,它具有保存整数、浮点数和文本的变体。这意味着我们可以在每个单元格中存储不同类型的数据,并且仍然有一个代表一行单元格的向量。当我们的可互换项是我们在编译代码时知道的一组固定类型时,这是一个非常好的解决方案。
代码如下:
enum SpreadSheetCell {
Int(i32),
Float(f64),
Text(String),
}
fn main() {
let row = vec![
SpreadSheetCell::Int(5567),
SpreadSheetCell::Text("up up".to_string()),
SpreadSheetCell::Float(114.514),
];
}
然而,有时我们希望我们的库用户能够扩展在特定情况下有效的类型集合,以下是这个例子的需求:
创建一个 GUI 工具,它会遍历某个元素的列表,依次调用元素的 draw 方法进行绘制(例如:Button、TextField 等元素)。
这样的需求在面向对象语言里(比如 Java 或 C#)可以定义一个 Component 父类,里面定义了 draw 方法。接下来定义 Button、TextField 等类,继承于 Component 这个父类。
上一篇文章 17.1. Rust的面向对象的编程特性 中说了 Rust 并没有提供继承功能,所以想使用 Rust 来构建 GUI 工具就得使用其他方法——为共有行为定义一个 trait。
17.2.2 为共有行为定义一个 trait
首先澄清一些定义:在 Rust 里我们避免将 struct 或 enum 称为对象,因为它们与 impl 块是分开的。而 trait 对象有点类似于其他语言中的对象,因为它们某种程度上组合了数据与行为。
trait 对象与传统对象也有不同之处,比如我们无法为 trait 对象添加数据。
trait 对象被专门用于抽象某些共有行为,它没有其他语言中的对象那么通用。
这个 GUI 工具这么写:
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
}
- 首先声明了一个公开的 trait 叫
Draw,里面定义了一个方法draw,但没有写具体实现。 - 然后声明了一个公开的结构体叫
Screen,它里面有一个公开的字段叫components。它的类型是Vec,里面的元素是Box<dyn Draw>。Box<>用于定义 trait 对象,表示Box里的元素实现了Drawtrait。 - 通过
impl块为Screen写了run方法,一运行就把所有元素画出来。
同样是表示某个类型实现某个/某些 trait,为什么不使用泛型呢?来看看泛型的写法:
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
}
这是因为泛型 Vec<T> 只要 T 一固定下来,这个向量里就只能存储这个类型了。举个例子,假如第一个放进这个向量的元素是 Button 类型,那么这个向量的其他元素就只能是 Button 了(因为向量里的所有元素类型必须相同)。
而如果是 Vec<Box<dyn Draw>>,那么第一个放进去是 Button 类型,后面还可以放 TextField 类型,只要是实现了 Draw trait 的类型都可以放进去。
接下来我们来写实现了 Draw trait 的类型具体是什么样的:
#![allow(unused)]
fn main() {
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 绘制按钮
}
}
}
- 一个
Button结构体可能有width、height和label字段,所以我们这么定义。 - 通过
impl块为Button实现了Drawtrait,里面的实际代码就忽略了。
这只是 lib.rs 的内容,接下来到 main.rs 写主程序:
#![allow(unused)]
fn main() {
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 绘制一个选择框
}
}
}
main.rs里的结构体SelectBox有三个字段:width、height和options。- 通过
impl块为SelectBox实现了Drawtrait,里面的实际代码就忽略了。
接着看主函数:
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
- 主程序里有一个
Screen结构体的实例,里面放了SelectBox类型和Button类型(得使用Box::new()封装)。这个向量能放不同类型的元素正是归功于定义 trait 对象。 - 然后调用
Screen上的方法run渲染出来即可。实际上run方法不管实际传进去是什么类型,只要这个类型实现了Drawtrait 即可。
17.2.3 trait 对象执行的是动态派发
将 trait bound 作用于泛型时,Rust 编译器会执行单态化:编译器会为我们用来替换泛型参数类型的每一个具体类型生成对应函数和方法的非泛型实现。
这点在 10.2. 泛型 中有阐述。
举个例子:
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
这里 integer 是 Option<i32>,float 是 Option<f64>,在编译的时候编译器会把 Option<T> 展开为 Option_i32 和 Option_f64:
#![allow(unused)]
fn main() {
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
}
也就是把 Option<T> 这个泛型定义替换为了两个具体类型的定义。
单态后的 main 函数也变成了这样:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
通过单态化生成的代码会执行静态派发(static dispatch),在编译过程中确定调用的方法。
*动态派发(dynamic dispatch)*无法在编译过程中确定你调用的究竟是哪一种方法,编译器会产生额外的代码以便在运行时找出希望调用的方法。使用 trait 对象就会执行动态派发。 代价是产生一些运行时的开销,并且阻止编译器内联方法代码,使得部分优化操作无法进行。
17.2.4 使用 trait 对象必须保证对象安全
只能把满足对象安全(object-safe)的 trait 转化为 trait 对象。Rust 使用了一系列规则来判定某个 trait 是否安全,只需要记住两条:
- 方法的返回类型不是
Self - 方法不包含任何的泛型类型参数
看个例子:
#![allow(unused)]
fn main() {
pub trait Clone {
fn clone(&self) -> Self;
}
}
标准库里 Clone trait 和 clone 这个函数的签名如上所示,由于 clone 方法的返回值是 Self,所以 Clone trait 就不符合对象安全。
17.3 实现面向对象的设计模式
17.3.1 状态模式
状态模式(state pattern) 是一种面向对象设计模式,指的是一个值拥有的内部状态由数个状态对象(state object) 表达而成,而值的行为随着内部状态的改变而改变。
使用状态模式意味着:业务需求变化时,不需要修改持有状态的值的代码,或者是使用这个值的代码;只需要更新状态对象内部的代码,以改变其规则,或者是增加一些新的状态对象。
看个例子:
博客文章一开始是一个空草稿。草稿完成后,要求对该帖子进行审查。当帖子获得批准后,就会发布。只有已发布的博客帖子才会返回要打印的内容,因此不会意外发布未经批准的帖子。
main.rs:
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
- 使用
Post::new创建新的博客文章草稿。首先创建一个Post类型的实例,命名为post。它是可变的,因为处于草稿状态的文章还可以修改。 - 然后通过
Post上的add_text方法增加了"I ate a salad for lunch today"这句话。 - 接下来使用
request_review方法请求审批。 - 最后使用
approve方法获得审批通过。
PS:添加的 assert_eq! 在代码中用于演示目的。单元测试可能包含断言草稿博客文章从 content 方法返回一个空字符串,但我们不打算为此示例编写测试。
lib.rs:
#![allow(unused)]
fn main() {
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
}
-
Post结构体有两个字段,一个字段是state,用于存储文章当下的状态,它一共有三种状态:草稿、等待审批和已发布。Box<dyn State>代表只要是实现了Statetrait 的类型就可以存入。 通过这个字段,Post类型能在内部管理状态与状态之间的变化,这个状态的变化是通过用户调用Post上的方法实现的,而用户只能通过调用这些方法来改变值(因为Post下的字段未设为公开,所以用户没办法直接修改字段的值)。 -
下文通过
impl块为Post实现了一些方法:-
new函数用于创建一个Post类型的实例,其初始的content值是一个空的字符串;初始的state处于草稿状态,所以state存储的是Draft结构体(下文有讲)。 -
add_text会往content字段使用push_str方法来添加内容。 -
即使我们调用了
add_text并向帖子添加了一些内容,我们仍然希望content方法返回一个空字符串切片,因为帖子仍处于草稿状态。 -
request_review会提取出state字段下的状态,取出来之后,state就会暂时变为None,因为所有权被移动出来了。这个时候调用状态上的request_review方法来请求审批。 当state是Draft状态时,就会调用Draft结构体上的request_review方法(下文有讲),把state字段的值从Draft变为了PendingReview,把状态更新回state上。
-
-
approve表示审批通过,其写法跟request_review差不多,把状态取出来,调用其上的approve方法来更新状态。 -
Statetrait 目前定义了两个方法,只有签名,没有具体实现:request_review表示请求审批。approve表示审批通过。
PS:注意它的签名的参数是
Box<Self>,与self和mut self有区别,Box<Self>意味着它只能被包裹着当前类型的Box实例调用。它会在调用过程中获取Box<Self>的所有权,并使旧值失效,从而修改状态。 -
Draft用于表示草稿状态,不需要实际的内容,所以只要声明一个没有字段的结构体即可。 -
通过
impl块为Draft实现了Statetrait:request_review表示请求审批,把值变为了PendingReview。approve表示审批通过。由于approve在此时没用,只需要把本身传回去即可,所以返回值是self。
-
PendingReview用于表示等待审批,不需要实际的内容,所以只要声明一个没有字段的结构体即可。 -
通过
impl块为PendingReview实现了Statetrait:request_review表示请求审批,此时状态不会变,只需要把本身传回去即可,所以返回值是self。approve表示审批通过,返回Published结构体。
-
Published用于表示已发表,不需要实际的内容,所以只要声明一个没有字段的结构体即可。 -
通过
impl块为Published实现了Statetrait。但是它都处于已发布的状态了,所以request_review和approve都没啥用,直接返回本身self就行。
我们为什么不使用枚举类型的变体作为帖子状态?这当然是一个可能的解决方案,但它的缺点之一是使用枚举时,每个检查枚举值的地方都需要一个 match 表达式或类似的表达式来处理每个可能的变体。
这样写会存在很多重复的代码,有些代码根本没用;但是它的优点也很明显:无论状态值是什么,Post 上的 request_review 方法都不需要改变,每个状态都负责自己的运行规则。
这里还有 content 方法还需要修改,我们想要在发布状态下使它可见,而其他两种情况下看不到。一样可以使用面向对象的设计模式。以下是原来的代码:
#![allow(unused)]
fn main() {
pub fn content(&self) -> &str {
""
}
}
首先在 State trait 下定义 content 方法:
#![allow(unused)]
fn main() {
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
}
写了个默认实现,返回空字符串。注意这里要使用生命周期,因为接收的是 Post 的引用,然后返回的可能是 Post 中某一部分的引用,所以返回值的生命周期和 Post 参数的生命周期是相关联的。
对于 Draft 和 PendingReview 来说默认实现就可以满足需求了。只需要在 Published 中写一个方法覆盖默认实现:
#![allow(unused)]
fn main() {
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
}
最后修改 Post 上的 content 方法:
#![allow(unused)]
fn main() {
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(&self)
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
}
我们需要先看 Option 里面值的引用,所以说调用了 as_ref 方法得到 Option<&T>,为了解包必须写一步错误处理,用 unwrap 即可。最后就调用 content 方法,根据所处的状态不同,content 的具体实现也会有所不同。
17.3.2 状态模式的取舍权衡
状态模式的优点如上所见:无论状态值是什么,Post 上的 request_review 方法都不需要改变,每个状态都负责自己的运行规则。
但它的缺点也比较明显:
- 需要重复实现一些逻辑代码
- 某些状态之间是相互耦合的,如果我们新增一个状态,这时候跟它相关联的代码就需要修改
17.3.3 将状态和行为编码为类型
如果我们严格按照面向对象的模式写当然是可行的,但是发挥不出 Rust 的全部威力。
下面我们会结合 Rust 的特点来修改,具体来说就是把状态和行为改为具体的类型。Rust 类型检查系统会通过编译时错误来阻止用户使用无效的状态。
修改后的代码如下:
lib.rs:
#![allow(unused)]
fn main() {
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> Post {
Post {
content: self.content,
}
}
}
}
-
声明了
Post和DraftPost两个结构体,这两者都有一个存储String类型的content字段。 -
通过
impl块写了Post的new方法和content方法:new方法会创建一个空的DraftPost结构体。content方法就会返回本身的content字段的值。
-
通过
impl块写了DraftPost的方法:add_text方法用于给DraftPost的content添加文字。request_review方法用于请求审批,调用这个方法就会返回另一个状态PendingReviewPost,表示正在审批中。这个状态是在下文定义的。
-
声明了
PendingReviewPost结构体,有一个存储String类型的content字段。通过impl在它上面写了一个approve方法用于通过审批。
这里的 Post 就指正式发布之后的 Post,DraftPost 就代表还处于草稿状态的文章,PendingReviewPost 表示正在审批的文章。审批成功就会把 content 的值移动到 Post 的 content 字段里以供使用。
这样写不会出现意外的情况,因为只有通过审批正式发布的状态 Post 才有 content 方法来获取文章。
此时的 main.rs 写法也需要小改:
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
let post = post.request_review();
let post = post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
17.3.4 总结
Rust 不仅能够实现面向对象的设计模式,还可以支持更多的模式。例如将状态和行为编码为类型。
面向对象的经典模式并不总是 Rust 编程实践中的最佳选择,因为 Rust 具有其他面向对象语言所没有的所有权特性。
18.1 能用到模式(匹配)的地方
18.1.1. 什么是模式
模式(pattern) 是 Rust 里一种特殊的语法,用于匹配复杂类型和简单类型的结构。
将模式与 match 表达式以及其他构造结合使用,可以更好地控制程序的流程。
模式由以下元素的一些组合构成:
- 字面值
- 解构的数组、
enum、struct和元组 - 变量
- 通配符
- 占位符
想要使用模式,需要将它与某个值进行比较:如果模式匹配,就可以在代码中使用这个值的相应部分。
18.1.2. match 的分支(Arm)
arm(分支)可以使用模式。它的形式是:
#![allow(unused)]
fn main() {
match VALUE {
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
}
}
match 必须是穷尽的,也就是说你必须覆盖所有可能的情况。
match 中也经常用到 _ 通配符。它会匹配任何东西,并且不会绑定到变量上。它通常用于 match 的最后一个分支,或用于忽略某个值。
如果想看更详细的介绍,可以去 6.3. 控制流运算符-match。
18.1.3. if let 表达式
if let 表达式可以看作是只匹配一种可能性的 match 表达式。
它还可以可选地包含:
else ifelseelse if let
与 match 相比,if let 的缺点是不会检查穷尽性。如果我们省略了最后的 else 块,从而漏掉对某些情况的处理,编译器不会提醒我们可能存在逻辑错误。看个例子:
fn main() {
let favorite_color: Option<&str> = None;
let is_tuesday = false;
let age: Result<u8, _> = "34".parse();
if let Some(color) = favorite_color {
println!("Using your favorite color, {color}, as the background");
} else if is_tuesday {
println!("Tuesday is green day!");
} else if let Ok(age) = age {
if age > 30 {
println!("Using purple as the background color");
} else {
println!("Using orange as the background color");
}
} else {
println!("Using blue as the background color");
}
}
如果用户指定了最喜欢的颜色,就用该颜色作为背景。如果没有指定最喜欢的颜色,并且今天是星期二,则背景颜色为绿色。否则,如果用户把年龄指定为字符串,并且我们能成功把它解析为数字,则颜色为紫色或橙色,具体取决于这个数值。如果这些条件都不适用,则背景颜色为蓝色。
这种条件结构让我们能够支持复杂的需求。使用这里写死的值时,这个例子会打印 Using purple as the background color。
你可以看到,if let 也可以像 match 一样引入遮蔽变量:if let Ok(age) = age 这一行引入了一个新的遮蔽变量 age,其中包含 Ok 里的值。这意味着,在使用上面这种嵌套写法时,我们需要把 if age > 30 放在那个块里:我们想与 30 比较的那个遮蔽后的 age,只在以 { 大括号开始的新作用域内有效。从 Rust 2024 edition 开始,let chains(let 链)也允许把模式匹配和布尔条件写在同一个条件里,例如 if let Ok(age) = age && age > 30;此时 Ok(age) 绑定出的变量可以在同一条 && 链的后续部分中使用。
其余详细内容可见 6.4. 简单的控制流-if let。
18.1.4. while let 条件循环
while let 和 if let 有点相似:只要模式继续匹配,它就允许 while 循环一直运行。
看个例子:
#![allow(unused)]
fn main() {
let mut stack = Vec::new();
stack.push(1);
stack.push(2);
stack.push(3);
while let Some(top) = stack.pop() {
println!("{top}");
}
}
这个例子会依次打印 3、2、1。pop 方法会从向量中取出最后一个元素并返回 Some(value)。如果向量为空,pop 返回 None。只要 pop 返回 Some,while 循环就会继续运行其块中的代码。当 pop 返回 None 时,循环停止。我们可以用 while let 把栈中的每个元素依次弹出。
18.1.5. for 循环
for 循环是 Rust 中最常见的循环。在 for 循环中,模式就是紧跟在 for 关键字后面的那个值。
在 for 循环中,直接跟在关键字 for 后面的值是一个模式。例如,在 for x in y 中,x 就是模式。下面的例子演示了如何在 for 循环中使用模式来解构元组:
#![allow(unused)]
fn main() {
let v = vec!['a', 'b', 'c'];
for (index, value) in v.iter().enumerate() {
println!("{value} is at index {index}");
}
}
输出:
a is at index 0
b is at index 1
c is at index 2
其余信息可见 3.6. 控制流:循环。
18.1.6. let 语句
let 语句也是模式,其语法是:
#![allow(unused)]
fn main() {
let PATTERN = EXPRESSION;
}
看个例子:
#![allow(unused)]
fn main() {
let (x, y, z) = (1, 2, 3);
}
我们将元组与模式进行匹配。Rust 会把值 (1, 2, 3) 与模式 (x, y, z) 比较,并发现该值与模式匹配,于是把 1 绑定到 x,把 2 绑定到 y,把 3 绑定到 z。你可以把这个元组模式看成其中嵌套了三个单独的变量模式。
18.1.7. 函数参数
函数参数也可以是模式。看个例子:
#![allow(unused)]
fn main() {
fn foo(x: i32) {
// ...
}
}
其中的 x 部分就是一个模式。
正如我们对 let 所做的那样,我们也可以在函数参数中把元组与模式进行匹配。例如:
fn print_coordinates(&(x, y): &(i32, i32)) {
println!("Current location: ({x}, {y})");
}
fn main() {
let point = (3, 5);
print_coordinates(&point);
}
18.2 可辩驳性:模式是否会无法匹配
18.2.1. 模式的两种形式
模式有两种形式:
- 可辩驳的(refutable),意味着它们可能匹配失败
- 无可辩驳的(irrefutable),意味着它们不会失败;你可以把它理解为无论怎么写都会成功的模式
能够匹配任意可能传入值的模式,就是无可辩驳的。例如:
#![allow(unused)]
fn main() {
let x = 5;
}
这个语句不可能失败,因为 x 能匹配表达式右侧所有可能的值。
无法匹配某些可能值的模式,就是可辩驳的。例如:
#![allow(unused)]
fn main() {
if let Some(x) = a_value
}
如果右侧的值是 None,模式就会匹配失败。
函数参数、let 语句和 for 循环只接受无可辩驳模式。例如:
#![allow(unused)]
fn main() {
let a: Option<i32> = Some(5);
let Some(x) = a;
}
Some(x) = a 是可辩驳的,因为也有可能是 None,但 let 语句只接受无可辩驳模式,所以编译器会报错。那该怎么改呢?可以使用 if let,也可以使用 let...else 来处理匹配失败的情况:
#![allow(unused)]
fn main() {
let a: Option<i32> = Some(5);
if let Some(x) = a {
// ...
}
}
#![allow(unused)]
fn main() {
let a: Option<i32> = Some(5);
let Some(x) = a else {
return;
};
}
if let、while let 和 let...else 同时支持可辩驳和无可辩驳模式。实际上,如果你在 if let、while let 或 let...else 中使用无可辩驳模式,编译器会发出警告,因为从概念上讲这里本来就存在失败的可能。例如:
#![allow(unused)]
fn main() {
if let x = 5 {
println!("{x}");
};
}
输出:
$ cargo run
Compiling patterns v0.1.0 (/tmp/ch18-refresh/patterns)
warning: irrefutable `if let` pattern
--> src/main.rs:2:8
|
2 | if let x = 5 {
| ^^^^^^^^^
|
= note: this pattern will always match, so the `if let` is useless
= help: consider replacing the `if let` with a `let`
= note: `#[warn(irrefutable_let_patterns)]` on by default
warning: `patterns` (bin "patterns") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/patterns`
5
编译器会警告 “irrefutable if let pattern”。那是因为:在本应用于可辩驳模式的上下文中使用无可辩驳模式,是没有意义的。
基于这些概念,再想想 match 表达式的分支:除了最后一个分支以外,其他分支都应该是可辩驳的;而最后一个分支应该是无可辩驳的,因为它需要匹配所有剩余情况。
18.3 模式(匹配)的语法
18.3.1. 匹配字面值
模式可以直接匹配字面值。看个例子:
#![allow(unused)]
fn main() {
let x = 1;
match x {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => println!("anything"),
}
}
这段代码会打印 one,因为 x 中的值是 1。当你希望代码针对某个特定值采取行动时,这种语法非常有用。
18.3.2. 匹配命名变量
命名变量是可以匹配任意值的无可辩驳模式。看个例子:
#![allow(unused)]
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(50) => println!("Got 50"),
Some(y) => println!("Matched, y = {y}"),
_ => println!("Default case, x = {x:?}"),
}
println!("at the end: x = {x:?}, y = {y}");
}
这个例子的逻辑很简单;关键在于这里出现了两个名为 y 的名字。它们互不相关,分别处于不同的作用域。let y = 10 中的 y 用来存储 10,而 Some(y) 中的 y 用来提取 Option 类型的 Some 变体所携带的数据。
match 中的执行逻辑如下:
-
第一个分支中的模式与
x的值不匹配,因此继续往下执行。 -
第二个分支中的模式引入了一个名为
y的新变量,它会匹配Some内的任意值。因为我们处于match表达式内的新作用域,所以这是一个新的y变量,而不是开头那个值为 10 的y。这个新的y绑定会匹配Some内的任意值,而我们在x里恰好就有这样的值。因此,这个新的y会绑定到x中Some的内部值。该值是5,于是该分支的表达式会执行,并打印Matched, y = 5。 -
如果
x是None而不是Some(5)——当然在这个例子里不可能发生——那么前两个分支中的模式都不会匹配,于是会匹配到_。我们没有在通配符分支中引入x变量,所以表达式里的x仍然是未被遮蔽的外部x。在那种假设情况下,match会打印Default case, x = None。
输出:
Matched, y = 5
at the end: x = Some(5), y = 10
18.3.3. 多重模式
在 match 表达式里,可以使用管道符 | 语法(意思是或)来匹配多种模式。看个例子:
#![allow(unused)]
fn main() {
let x = 1;
match x {
1 | 2 => println!("one or two"),
3 => println!("three"),
_ => println!("anything"),
}
}
例子中的第一个分支会在 x 为 1 或 2 时匹配。
18.3.4. 使用 ..= 来匹配某个范围的值
看个例子:
#![allow(unused)]
fn main() {
let x = 5;
match x {
1..=5 => println!("one through five"),
_ => println!("something else"),
}
}
这个例子的第一个分支表示:当 x 是从 1 到 5(含两端)的任意值——也就是 1、2、3、4 或 5——时都会匹配。
因为 Rust 能够判断范围是否为空的唯一类型是 char 和数值类型,所以范围只允许用于数字或 char 值。看个例子:
#![allow(unused)]
fn main() {
let x = 'c';
match x {
'a'..='j' => println!("early ASCII letter"),
'k'..='z' => println!("late ASCII letter"),
_ => println!("something else"),
}
}
这个例子的第一个分支匹配从 a 到 j 的字符,第二个分支匹配从 k 到 z 的字符。
18.3.5. 解构以分解值
我们可以使用模式来解构 struct、enum 和元组,从而引用这些类型值的不同部分。
解构 struct
看个例子:
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 0, y: 7 };
let Point { x: a, y: b } = p;
assert_eq!(0, a);
assert_eq!(7, b);
}
Point结构体有两个字段x和y,类型都是i32。- 有一个名为
p的Point实例,其x字段为 0,y字段为 7。 - 然后我们用模式解构
p,把x的值绑定到a,把y的值绑定到b。
这么写还是有些冗长。如果把变量名 a 改成 x,把 b 改成 y,就可以简写成这样:
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 0, y: 7 };
let Point { x, y } = p;
assert_eq!(0, x);
assert_eq!(7, y);
}
解构还可以灵活地使用。看个例子:
fn main() {
let p = Point { x: 0, y: 7 };
match p {
Point { x, y: 0 } => println!("On the x axis at {x}"),
Point { x: 0, y } => println!("On the y axis at {y}"),
Point { x, y } => {
println!("On neither axis: ({x}, {y})");
}
}
}
- 第一个分支要求
x字段为任意值,y字段为 0。 - 第二个分支要求
x字段为 0,y字段为任意值。 - 第三个分支对
x和y的值没有任何限制。
解构 enum
看个例子:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {
let msg = Message::ChangeColor(0, 160, 255);
match msg {
Message::Quit => {
println!("The Quit variant has no data to destructure.");
}
Message::Move { x, y } => {
println!("Move in the x direction {x} and in the y direction {y}");
}
Message::Write(text) => {
println!("Text message: {text}");
}
Message::ChangeColor(r, g, b) => {
println!("Change the color to red {r}, green {g}, and blue {b}")
}
}
}
这段代码会打印 Change the color to red 0, green 160, and blue 255。
解构嵌套的 struct 和 enum
看个例子:
enum Color {
Rgb(i32, i32, i32),
Hsv(i32, i32, i32),
}
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(Color),
}
fn main() {
let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));
match msg {
Message::ChangeColor(Color::Rgb(r, g, b)) => {
println!("Change color to red {r}, green {g}, and blue {b}");
}
Message::ChangeColor(Color::Hsv(h, s, v)) => {
println!("Change color to hue {h}, saturation {s}, value {v}")
}
_ => (),
}
}
Message 的 ChangeColor 变体所携带的数据就是 Color 枚举。使用 match 表达式时,一层一层匹配即可。在 match 的前两个分支中,外层都是 ChangeColor 变体,内层分别对应 Color 的两个变体;里面的值都可以通过变量提取出来。
解构 struct 和元组
看个例子:
struct Point {
x: i32,
y: i32,
}
fn main() {
let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}
main 中模式的外层是一个有两个元素的元组:
- 第一个元素本身又是一个有两个元素的元组。
- 第二个元素是一个
Point结构体。
在模式中忽略值
有几种方式可以在模式中忽略整个值或部分值:
_:忽略整个值_配合其他模式:忽略部分值- 使用以
_开头的名称 ..:忽略值的剩余部分
使用 _ 来忽略整个值
看个例子:
fn foo(_: i32, y: i32) {
println!("This code only uses the y parameter: {y}");
}
fn main() {
foo(3, 4);
}
这段代码会完全忽略作为第一个参数传入的值 3,并打印 This code only uses the y parameter: 4。
使用嵌套的 _ 来忽略值的一部分
看个例子:
#![allow(unused)]
fn main() {
let mut setting_value = Some(5);
let new_setting_value = Some(10);
match (setting_value, new_setting_value) {
(Some(_), Some(_)) => {
println!("Can't overwrite an existing customized value");
}
_ => {
setting_value = new_setting_value;
}
}
println!("setting is {setting_value:?}");
}
这段代码会打印 Can't overwrite an existing customized value,然后打印 setting is Some(5)。在第一个分支中,我们不需要匹配或使用 Some 变体里的值,但我们确实需要确认 setting_value 和 new_setting_value 都是 Some 变体。这就是忽略值的一部分的含义。
第二个分支表示在所有其他情况下——如果 setting_value 或 new_setting_value 是 None——就把 new_setting_value 赋给 setting_value。这就是把 _ 与其他模式配合使用来忽略值的例子。
我们还可以在同一个模式的多个位置使用下划线,来忽略特定值。看个例子:
#![allow(unused)]
fn main() {
let numbers = (2, 4, 8, 16, 32);
match numbers {
(first, _, third, _, fifth) => {
println!("Some numbers: {first}, {third}, {fifth}")
}
}
}
这里忽略了元组的第 2 个和第 4 个元素。这段代码会打印 Some numbers: 2, 8, 32,而值 4 和 16 会被忽略。
使用以 _ 开头的名称来忽略未使用的变量
看个例子:
fn main() {
let _x = 5;
let y = 10;
}
正常情况下,如果你创建了变量却没有使用它,Rust 编译器会发出警告。这里 _x 和 y 都没有被使用,但对 y 会有警告。那是因为 _x 以 _ 开头,告诉编译器这是一个临时变量。
请注意:只使用 _ 和使用以下划线开头的名称之间存在细微差别。语法 _x 仍然会把值绑定到变量,而 _ 根本不会绑定任何东西。看个例子:
#![allow(unused)]
fn main() {
let s = Some(String::from("Hello!"));
if let Some(_s) = s {
println!("found a string");
}
println!("{s:?}");
}
我们会收到一个错误,因为 s 的值仍然会被移动到 _s 中,这会阻止我们打印 s。
在这种情况下,应该使用 _ 来避免绑定值:
#![allow(unused)]
fn main() {
let s = Some(String::from("Hello!"));
if let Some(_) = s {
println!("found a string");
}
println!("{s:?}");
}
使用 .. 来忽略值的剩余部分
看个例子:
struct Point {
x: i32,
y: i32,
z: i32,
}
fn main() {
let origin = Point { x: 0, y: 0, z: 0 };
match origin {
Point { x, .. } => println!("x is {x}"),
}
}
使用 match 匹配时,我们只需要 x 字段,所以模式只写 x,其余部分用 .. 覆盖。
这么使用 .. 也是可以的:
fn main() {
let numbers = (2, 4, 8, 16, 32);
match numbers {
(first, .., last) => {
println!("Some numbers: {first}, {last}");
}
}
}
这只会取第一个和最后一个值,并忽略其余部分。
这么写 .. 是不行的:
fn main() {
let numbers = (2, 4, 8, 16, 32);
match numbers {
(.., second, ..) => {
println!("Some numbers: {second}")
},
}
}
这里前面有 ..,后面也有 ..,而我们想要中间的元素。但具体是哪个元素呢?这么写时,编译器不知道 .. 应该跳过多少个元素,因此也不知道 second 指的是哪个元素。
输出:
$ cargo run
Compiling patterns v0.1.0 (/tmp/ch18-refresh/patterns)
error: `..` can only be used once per tuple pattern
--> src/main.rs:5:22
|
5 | (.., second, ..) => {
| -- ^^ can only be used once per tuple pattern
| |
| previously used here
error: could not compile `patterns` (bin "patterns") due to 1 previous error
18.3.6. 使用 match guards 来提供额外条件
match guards(match 守卫)是 match 分支模式后面附加的 if 条件。分支要匹配,这个条件也必须满足。match guards 适用于比单纯模式更复杂的场景。
看个例子:
fn main() {
let num = Some(4);
match num {
Some(x) if x % 2 == 0 => println!("The number {x} is even"),
Some(x) => println!("The number {x} is odd"),
None => (),
}
}
在 match 的第一个分支中,Some(x) 是模式,而 if x % 2 == 0 就是 match guard,它要求 Some 所携带的数据能被 2 整除。
无法在模式本身中表达 if x % 2 == 0 这个条件,因此 match guards 让我们能够表达这种逻辑。这种额外表达能力的缺点是:一旦涉及 match guard,编译器就不会再尝试检查穷尽性。
看第二个例子:
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(50) => println!("Got 50"),
Some(n) if n == y => println!("Matched, n = {n}"),
_ => println!("Default case, x = {x:?}"),
}
println!("at the end: x = {x:?}, y = {y}");
}
这段代码现在会打印 Default case, x = Some(5)。
match 守卫 if n == y 不是模式,因此不会引入新变量。这个 y 是外部的 y(值为 10),而不是新的遮蔽变量 y。我们可以通过比较,找出与外部 y 具有相同值的那些 n。
看第三个例子:
#![allow(unused)]
fn main() {
let x = 4;
let y = false;
match x {
4 | 5 | 6 if y => println!("yes"),
_ => println!("no"),
}
}
这个例子把 match 守卫与多重模式一起使用。
匹配条件规定:只有当 x 为 4、5 或 6,并且 y 为 true 时,该分支才会匹配。运行这段代码时,x 是 4,但 match guard 中的 y 为 false,所以第一个分支不会执行,第二个分支会打印 no。
这里需要注意的是模式相对于 match 守卫的优先级。它应该是:
#![allow(unused)]
fn main() {
(4 | 5 | 6) if y => ...
}
而不是:
#![allow(unused)]
fn main() {
4 | 5 | (6 if y) => ...
}
18.3.7. @ 绑定
@ 符号让我们可以创建一个变量,该变量可以在测试某个值是否与模式匹配的同时保存该值。
看个例子:
enum Message {
Hello { id: i32 },
}
fn main() {
let msg = Message::Hello { id: 5 };
match msg {
Message::Hello {
id: id_variable @ 3..=7,
} => println!("Found an id in range: {id_variable}"),
Message::Hello { id: 10..=12 } => {
println!("Found an id in another range")
}
Message::Hello { id } => println!("Found some other id: {id}"),
}
}
在这个 match 的第一个分支中,id 字段的值被绑定到 id_variable,同时还会检查它是否落在从 3 到 7(含两端)的闭区间内。
19.1 摆脱安全性限制的unsafe Rust
19.1.1 什么是 unsafe Rust
到目前为止,我们讨论过的所有代码都在编译时强制执行 Rust 的内存安全保证。然而,Rust 内部还隐藏着第二种语言,它并不强制执行这些内存安全保证。它被称为 unsafe Rust。它和普通 Rust 一样,但给了我们额外的“超能力”。
unsafe Rust 之所以存在,是因为:
- 静态分析非常保守。编译器在判断一段代码是否安全时,宁可拒绝一个实际能正确运行的程序,也不会放过任何潜在不安全的代码。
- 计算机硬件本身就是不安全的,如果 Rust 想达到和 C 一样的底层能力,就需要
unsafe Rust。换句话说,unsafe Rust允许进行底层系统编程。
使用 unsafe Rust 就是在告诉编译器:“我知道自己在做什么,并接受相应风险。”
19.1.2 Unsafe Rust 的超能力
使用 unsafe 关键字切换到 unsafe Rust。它会开启一个代码块,写在这个块里的就是不安全代码。
unsafe Rust 可以做五件事,也就是它的超能力:
- 解引用原始指针
- 调用不安全的函数或方法
- 访问或修改可变静态变量
- 实现不安全的 trait
- 访问
union的字段
注意:
unsafe Rust并不会关闭借用检查器,也不会停用其它安全检查。如果你在代码里使用引用,这些引用仍然会被检查。unsafe关键字只是允许你执行上面那五个编译器不会替你做内存检查的操作。所以即便在unsafe块中,你仍然保留一部分安全保证。- 任何与内存安全相关的错误都必须留在
unsafe块里。 - 尽可能隔离不安全代码。理想情况下,把它封装在安全的抽象中,并提供安全的 API。标准库中有些代码在内部使用了
unsafe块,但在其上提供了安全抽象。这可以有效防止不安全代码泄漏到调用方,因为使用安全抽象是安全的,无论内部是否使用了unsafe Rust。
特性 1:解引用原始指针
unsafe Rust 提供了两种与引用类似的指针类型,称为原始指针(raw pointers)。只有在解引用原始指针时才需要 unsafe 块,因为这时可能出问题。创建原始指针本身不会造成问题,因此不必放在 unsafe 块中。
和引用一样,原始指针可以是可变的或不可变的:
- 可变:
*mut T - 不可变:
*const T
*const T 表示这个指针可以被解引用,但不能通过该指针给指向的值赋值。
注意:这里的 * 是类型的一部分,不是解引用运算符。*const T 这三个记号合在一起才是一个类型,例如 *const String。
*const T 和 *mut T 的差别很小,可以彼此自由转换。Rust 引用(&mut T 和 &T)在编译期间会被编译器转换成原始指针,这意味着无需进入 unsafe 块就能获得原始指针的性能。
引用和原始指针的区别是:
- 原始指针允许你忽略借用规则,可以同时拥有不可变指针和可变指针,或多个指向同一位置的可变指针。
- 原始指针不能保证指向有效内存,而引用可以。
- 原始指针可以为
null。 - 原始指针不实现任何自动清理。
如果放弃安全保证,就可以换取更好的性能,以及与其它语言或硬件接口的互操作性。
看一个例子:
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
}
这是一个创建原始指针的例子。在 main 中,我们同时创建了一个不可变原始指针和一个可变原始指针。
这段代码不在 unsafe 块中,但仍然可以编译。所以我们可以在不安全代码之外创建原始指针,但解引用它们只能在 unsafe 代码中进行。
这段代码在同一个作用域里同时存在指向同一块内存区域的可变指针和不可变指针,而 Rust 允许这样做。这意味着我们可以通过可变引用修改值,但必须非常小心。
创建原始指针时,我们先用引用语法写出它们,再用 as *const 和 as *mut 转换成对应的原始指针。因为这两个原始指针来自有效引用,所以我们知道它们是有效的,但它们未必会一直有效。接下来,我们创建一个无法保证有效性的原始指针:
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}
我们直接根据内存地址写出一个指针。那个地址上可能有数据,也可能没有,但我们仍然可以创建原始指针。编译器不会报错。
现在尝试解引用这些原始指针:
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
这会产生错误 dereference of raw pointer is unsafe and requires unsafe function or block,意思是原始指针的解引用只能在不安全函数或不安全块中进行。
把原始指针的解引用放进 unsafe 块就可以了:
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
}
那对直接根据内存地址创建原始指针的例子,这样做也行吗?
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
unsafe {
println!("r = {}", *r);
}
}
输出:
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/unsafe-example`
thread 'main' (483665) panicked at src/main.rs:5:9:
misaligned pointer dereference: address must be a multiple of 0x4 but is 0x12345
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread caused non-unwinding panic. aborting.
创建原始指针本身没问题,但解引用一个任意地址属于未定义行为。那个地址上可能有有效数据,也可能没有;编译器可能把这次访问优化掉;程序也可能崩溃——例如上面本地运行中的未对齐指针 panic 并 abort(退出代码 134),或段错误(退出代码 139 / SIGSEGV)。具体表现会随系统、编译器和构建选项而变化。你可以在自己的电脑上试试。
既然原始指针这么危险,为什么还要用它们?原因是:
- 与 C 进行接口交互
- 构建借用检查器无法理解的安全抽象
特性 2:调用不安全的函数和方法
不安全函数和方法是用 unsafe 关键字声明的函数或方法。除此之外,它们与普通函数或方法没有太大区别。
在调用这类函数或方法之前,你必须手动满足一些条件,通常要靠阅读文档,因为 Rust 无法替你验证这些条件。此外,调用不安全函数或方法必须发生在 unsafe 块中。
看一个例子:
unsafe fn dangerous() {}
fn main() {
unsafe {
dangerous();
}
}
我们用 unsafe 关键字声明了一个 dangerous 函数,所以它是不安全函数。这意味着 main 必须在 unsafe 块中调用它。
函数内部包含不安全代码,并不意味着整个函数都必须标记为不安全。事实上,把不安全代码封装在安全函数中是一种常见的抽象。
例如:
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len);
(&mut values[..mid], &mut values[mid..])
}
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}
- 在
main中有一个名为v的Vec。r是它的完整可变切片,然后对r调用了split_at_mut。 split_at_mut接收一个元素类型为i32的切片self和一个usize值。它把这个usize当作把self切成两个可变切片的索引。在函数体内,它先检查传入的usize是否在有效范围内(不大于self的长度),然后返回前半段和后半段。
输出:
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:6:31
|
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
6 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
Rust 的借用检查器无法理解我们借用的是切片中两个不同且互不重叠的部分。它只知道我们从同一个切片借用了两次。所以我们需要使用 unsafe 块(同时保持外层函数是安全的):
#![allow(unused)]
fn main() {
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
}
as_mut_ptr返回一个原始指针,具体是*mut i32。- 元组返回值使用了
unsafe块、原始指针和指针运算。slice模块中的slice::from_raw_parts_mut接收一个原始指针ptr和一个长度mid来创建切片:slice::from_raw_parts_mut(ptr, mid)创建一个从ptr开始、包含mid个元素的切片。slice::from_raw_parts_mut(ptr.add(mid), len - mid)创建一个从ptr.add(mid)开始、包含len - mid个元素的切片——也就是从ptr往后偏移mid个元素的位置,正好是第一个切片的结尾。
这个函数使用了 unsafe 块,但它本身并没有标记为 unsafe。这就是对不安全代码的安全抽象。
如果我们不使用安全抽象呢?
use std::slice;
fn main() {
let address = 0x01234usize;
let r = address as *mut i32;
let values: &mut [i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
我们不一定拥有这个任意地址上的内存,也无法保证这段代码创建的切片包含有效的 i32 值。试图把 values 当作有效切片使用,可能导致未定义行为。
使用 extern 调用外部代码,或被外部代码调用
extern 关键字简化了定义和使用*外部函数接口(Foreign Function Interface,FFI)*的过程。
FFI 允许一种编程语言定义函数,并让其它编程语言调用这些函数。
看一个例子:
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
- 在
extern块中声明的任何函数都是不安全的,因为其它语言不会强制执行 Rust 的规则,而 Rust 也无法检查它们。所以调用外部函数被隐式标记为不安全,安全责任落在开发者身上。 - 在
extern "C"块中,我们列出想要调用的另一种语言中外部函数的名称和签名。"C"部分定义了外部函数使用的应用程序二进制接口(Application Binary Interface,ABI)。ABI 定义了在汇编层面如何调用该函数。"C"ABI 最常见,它遵循 C 编程语言的 ABI。
既然 Rust 可以调用其它编程语言的函数,那其它编程语言能否调用 Rust 代码?答案是可以。
我们可以使用 extern 创建一个可供其它语言调用的接口。为此,在 fn 前添加 extern 关键字并指定 ABI。你还需要 #[no_mangle] 属性,这样 Rust 就不会在编译期间改变函数名。
mangle 指的是编译中的一个阶段,编译器会修改函数名,使其包含更多供后续编译阶段使用的信息。这些改名后的名字通常很难阅读,所以如果你希望其它语言能正常使用该函数,就必须阻止 Rust 改名。
看一个例子:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
}
特性 3:访问或修改可变静态变量
Rust 支持全局变量,但所有权规则可能带来一些问题,例如数据竞争。
Rust 中的全局变量叫做静态变量。它们用 static 关键字声明,遵循 UPPER_SNAKE_CASE 命名约定,并且在声明时必须标注类型。它们的生命周期是且只能是 'static,表示在整个程序运行期间都有效。你不必显式写出这一点,Rust 会自行推断。访问不可变静态变量是安全的。
例如:
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {HELLO_WORLD}");
}
HELLO_WORLD是声明的全局变量,值为"Hello, world!",类型是字符串切片&str。main打印了这个全局变量。
常量(const)和可变静态变量(static mut)的区别是:
- 静态变量有固定的内存地址,因此使用它们的值时总会访问同一份数据。
- 常量在使用时会被复制。
- 静态变量可以是可变的,而访问或修改可变静态变量是不安全的,所以这些操作必须发生在
unsafe块中。
例如:
static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
add_to_count(3);
unsafe {
println!("COUNTER: {COUNTER}");
}
}
访问和修改都是不安全操作,所以两者都被放在 unsafe 块中。
这里的输出显然是 3。但如果涉及多个线程,就很容易引入数据竞争。在多线程代码中,最好使用我们之前讨论过的并发技术,或像 Arc<T> 这样的线程安全智能指针,这样编译器就能安全地检查跨线程的数据访问。
特性 4:实现不安全的 trait
当一个 trait 中至少有一个方法包含编译器无法验证的不安全因素时,这个 trait 就被认为是不安全的。
声明不安全 trait 的方式是在 trait 定义前加上 unsafe 关键字。这样的 trait 只能在 unsafe 块中实现。
例如:
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
fn main() {}
unsafe trait Foo声明了一个名为Foo的不安全 trait。- 为
i32实现Foo必须发生在unsafe块中,因此需要unsafe impl。
特性 5:访问 union 字段
union 类似于 struct,但在给定实例中,一次只使用一个已声明的字段。union 主要用于与 C 代码中的 union 互操作。访问 union 字段是不安全的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。详情见 Rust Reference。
19.1.3 何时使用 unsafe 代码
确保 unsafe 代码正确是棘手的,因为编译器无法帮助维护内存安全,而开发者自己也很难保证正确性。
当你有充分理由时再使用 unsafe 代码。显式的 unsafe 标注会让问题发生时更容易追踪根源。
19.2 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype
19.2.1 在 trait 定义中使用关联类型来指定占位类型
我们首先在 10.3. trait Pt.1:trait的定义、约束与实现 介绍了 trait,但没有讨论更高级的细节。现在来深入了解。
关联类型是 trait 内部的类型占位符。它可以用于 trait 方法签名中。它用来为某些类型定义 trait,而无需事先知道这些类型是什么。
例如:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
}
标准库的 Iterator trait 就是一个带有关联类型的 trait,其定义如上所示。
Item 就是关联类型。在迭代过程中,用 Item 代替实际值的类型,从而把逻辑和具体数据类型分开。你可以在 next 的返回类型 Option<Self::Item> 中看到 Item。
Item 是一个类型占位符。它的核心思想与泛型类似,但也有区别:
| 泛型 | 关联类型 |
|---|---|
| 每次实现 trait 时都要指定类型 | 无需指定类型 |
| 同一个类型可以用不同的泛型参数多次实现同一个 trait | 同一个类型不能多次实现同一个 trait |
19.2.2 默认泛型类型参数和运算符重载
使用泛型参数时,我们可以给泛型一个默认的具体类型。语法是 <PlaceholderType=ConcreteType>。这项技术常用于运算符重载。
虽然 Rust 不允许你创建自己的运算符,也不能重载任意运算符,但你可以通过实现 std::ops 中列出的那些 trait 来重载某些运算符。
看一个例子:
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
- 在这个例子中,我们为
Point结构体实现了Addtrait,从而重载了+运算符。具体来说,Add中的add函数逐字段相加。 - 在
main中,我们可以直接用+把两个Point值相加。
Add trait 的定义如下:
#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
}
它使用了默认泛型参数 Rhs=Self。这意味着当我们实现 Add 时,如果不给 Rhs 指定具体类型,默认类型就是 Self。所以上面例子中的 Rhs 是 Point。
现在再看另一个例子,这次是毫米和米相加:
#![allow(unused)]
fn main() {
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
}
- 这里把
Millimeters和Meters声明为元组结构体,分别表示毫米和米。 - 我们为
Millimeters实现Add,并显式指定另一个类型是Meters。在add中,我们把存储的毫米值与换算成毫米后的米值相加。
19.2.3 默认泛型参数的主要用例
- 在不破坏现有代码的前提下扩展类型
- 允许在大多数用户不需要的特殊情况下进行自定义
19.2.4 使用完全限定语法调用同名方法
直接看例子:
#![allow(unused)]
fn main() {
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
}
- 我们定义了两个 trait:
Pilot和Wizard,各自都有一个fly方法,但没有具体实现。 - 我们有一个
Human结构体。接下来分别为它实现两个 trait,也就是为每个 trait 提供一个fly方法。此外,我们还在结构体自己的impl块中实现了一个fly方法。
此时一共有三个 fly 方法。如果在 main 中这样调用:
fn main() {
let person = Human;
person.fly();
}
运行这段代码会打印 *waving arms furiously*,说明 Rust 直接调用了 Human 上实现的 fly 方法。
要调用 Pilot trait 或 Wizard trait 中的 fly,需要用更明确的语法指出我们指的是哪一个 fly:
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
在方法名前指定 trait 名,可以告诉 Rust 我们想要哪一个 fly 实现。person.fly() 也可以写成 Human::fly(&person)。
输出:
This is your captain speaking.
Up!
*waving arms furiously*
然而,不是方法的关联函数没有 self 参数。当来自不同类型或 trait 的多个方法或关联函数同名时,除非使用完全限定语法,Rust 并不总是知道你指的是哪一个:
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
Animaltrait 有一个baby_name函数。Dog是一个结构体,实现了Animaltrait,同时也在自己的impl块中实现了baby_name。所以现在有两个baby_name函数。- 在
main中使用了Dog::baby_name(),因此按上面的逻辑,会运行Dog自己的impl块中的baby_name实现,得到Spot。
输出:
A baby dog is called a Spot
那么如何调用 Dog 对 Animal trait 的 baby_name 实现呢?我们试试上一个例子的逻辑:
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
输出:
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
--> src/main.rs:20:43
|
2 | fn baby_name() -> String;
| ------------------------- `Animal::baby_name` defined here
...
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
20 | println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
| +++++++ +
For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error
Animal trait 上的 baby_name 函数需要知道使用哪个类型的实现,但 baby_name 本身没有参数,所以 Rust 无法推断指的是哪个类型的实现。
这时就需要完全限定语法。它的形式是:
#![allow(unused)]
fn main() {
<Type as Trait>::function(receiver_if_method, next_arg, ...);
}
这种语法可以在任何调用函数或方法的地方使用,并且可以忽略那些能从其它上下文推断出来的部分。
但只有在 Rust 无法区分你想要哪个具体实现时,才需要这种语法,因为它写起来很麻烦。所以一般来说,除非必要,否则不要使用它。
按这个语法,上面的代码应改为:
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
输出:
A baby dog is called a puppy
19.2.5 使用 supertrait 要求额外的 trait 功能
有时我们需要在一个 trait 中使用另一个 trait 的功能,这意味着那个被间接要求的 trait 也必须被实现。那个被间接要求的 trait 就是当前 trait 的 supertrait。
例如:
#![allow(unused)]
fn main() {
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
}
OutlinePrint 实际上用来在终端用字符打印一个形状。但在打印时,self 必须实现 to_string,这意味着 self 必须实现 Display trait(to_string 来自 ToString trait,而任何实现了 Display 的类型都会自动实现 ToString)。写法是 trait 关键字 + trait 名 + : + supertrait。
假设我们有一个 Point 结构体,想用 OutlinePrint 的 outline_print 方法在终端打印它。因为 OutlinePrint 要求 Display,我们必须同时实现 OutlinePrint 和 Display,否则会失败:
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl OutlinePrint for Point {}
}
19.2.6 使用 newtype 模式在外部类型上实现外部 trait
我们已经讨论过孤儿规则:只有当 trait 或类型定义在本地 crate 中时,才能为该类型实现这个 trait。我们可以使用 newtype 模式绕过这条规则,具体做法是用元组结构体在本地构建一个新类型。
例如:
假设我们想为 Vec<String> 实现 Display,但 Vec 和 Display 都定义在我们的 crate 之外,所以不能直接为 Vec<String> 实现。于是我们把这个向量包进自己的元组结构体 Wrapper,再为 Wrapper 实现 Display:
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {w}");
}
19.3 高级函数和闭包
19.3.1 函数指针
我们已经讲过把闭包传进函数。实际上,我们也可以把函数传进函数。
在传递时,函数会被强制转换成 fn 类型,这就是函数指针。
例如:
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {answer}");
}
do_twice 的第一个参数 f 的类型是 fn,也就是函数指针。它期望一个参数类型为 i32、返回类型也为 i32 的函数。函数体中调用了两次 f。
输出:
The answer is: 12
函数指针与闭包的区别
闭包至少实现了 Fn、FnOnce 和 FnMut 这三个 trait 之一。 函数指针 fn 是一个类型,不是 trait。我们可以直接把 fn 指定为参数类型,而不必声明一个以 Fn trait 为约束的泛型参数。
函数指针实现了全部三种闭包 trait,也就是 Fn、FnOnce 和 FnMut。 所以你总是可以把函数指针作为参数传给接受闭包的函数。正因为如此,我们编写函数时通常更倾向于使用带闭包 trait 的泛型参数,因为这样函数既能接受闭包,也能接受普通函数。
在某些情况下,我们可能想接受 fn 类型而不是闭包,例如与不支持闭包的代码交互时,比如 C 函数。该怎么写呢?
看一个例子:
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> = list_of_numbers
.iter()
.map(|i| i.to_string())
.collect();
// 分行只是为了可读性,并不是必须的
}
list_of_numbers 中的元素是 i32,我们想把它们转换成 String 赋给 list_of_strings。步骤是:
- 先用
iter产生一个迭代器 - 再用
map中的闭包|i| i.to_string()转换每个元素 - 最后用
collect把所有转换后的元素收集成一个集合
这段代码也可以这样写:
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> = list_of_numbers
.iter()
.map(ToString::to_string)
.collect();
}
区别在于 .map(ToString::to_string),这里直接传入了 to_string 函数。效果与上一版相同。顺便一提,ToString::to_string 使用了 19.2. 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype 讨论过的完全限定语法。
来看一下 map 的定义:
#![allow(unused)]
fn main() {
fn map<B, F>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnMut(Self::Item) -> B
}
map 要求 f 实现 FnMut trait,而闭包和函数指针都满足这个要求,所以两者都可以传入。
再看另一个例子:
fn main() {
enum Status {
Value(u32),
Stop,
}
let list_of_statuses: Vec<Status> = (0u32..20)
.map(Status::Value)
.collect();
}
注意 map 的参数。我们使用构造函数 Status::Value,对范围内的每个 u32 调用 map,并创建 Status::Value 实例。
有人可能会问:Status::Value 不是枚举变体吗?怎么变成函数了?这是因为在 Rust 中,这样的构造函数被实现为接收一个参数并返回新实例的函数。换句话说:
#![allow(unused)]
fn main() {
let v = Status::Value(3);
}
这只是一个例子。这里初始化了 v,而 Status::Value(3) 可以看作一次构造函数调用:3 是构造函数的参数。由于构造函数被实现为函数,我们可以把它们当作函数来用,3 就是它们的参数。
所以我们也可以把这类构造函数用作实现了闭包 trait 的函数指针。
19.3.2 返回闭包
闭包通过 trait 来表达,因此不能直接从函数返回闭包。相反,你可以返回一个实现了该 trait 的具体类型。
例如:
#![allow(unused)]
fn main() {
fn returns_closure() -> dyn Fn(i32) -> i32 {
|x| x + 1
}
}
这个函数试图直接返回一个闭包。
输出:
error[E0746]: return type cannot be a trait object without pointer indirection
--> src/lib.rs:1:25
|
1 | fn returns_closure() -> dyn Fn(i32) -> i32 {
| ^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
help: consider returning an `impl Trait` instead of a `dyn Trait`
|
1 - fn returns_closure() -> dyn Fn(i32) -> i32 {
1 + fn returns_closure() -> impl Fn(i32) -> i32 {
|
help: alternatively, box the return type, and wrap all of the returned values in `Box::new`
|
1 ~ fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
2 ~ Box::new(|x| x + 1)
|
For more information about this error, try `rustc --explain E0746`.
error: could not compile `functions-example` (lib) due to 1 previous error
Rust 不知道需要多少空间来存储这个闭包,所以会报错。
还记得我们以前在哪里遇到过同样的“Rust 不知道该分配多少空间”的错误吗?没错——在学习链表时。当时的解决办法是用 Box<T> 包裹链表,这里也可以这样做:
#![allow(unused)]
fn main() {
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
}
因为返回值位于指针之后,返回类型现在在编译时就有了已知大小。
19.4 宏(macro)
19.4.1 什么是宏
在 Rust 中,宏是一组相关特性的统称:
- 用
macro_rules!构建的声明宏 - 三种过程宏:
- 派生宏,用于
struct或enum,让你指定通过derive属性添加的代码 - 类属性宏,可为任意条目添加自定义属性
- 类函数宏,看起来像函数调用,并对作为参数传入的 token 进行操作
- 派生宏,用于
19.4.2 函数与宏的区别
- 从本质上说,宏是生成其它代码的代码,这称为元编程。
- 函数必须在签名中声明参数的个数和类型;宏可以处理可变数量的参数。
- 编译器会在解释代码之前展开宏。
- 宏的定义比函数定义复杂得多,更难阅读、理解和维护。
- 在某个文件中调用宏时,宏必须已经定义好,或已导入当前作用域。函数可以在任何位置定义并在任何位置使用。
19.4.3 使用 macro_rules! 的声明宏
声明宏有时叫做宏模板,有时叫做 macro_rules 宏,有时就直接叫做宏。
它们是 Rust 中最常见的宏形式。它们有点类似于 match 表达式的模式匹配,定义声明宏时我们使用 macro_rules!。
例如:
#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
}
这是用于创建 Vec 的 vec! 宏的简化定义。我们逐行看一下:
-
#[macro_export]表示只有在所属 crate 被引入作用域后,这个宏才能使用。没有这个属性,宏就不能被导入作用域。 -
macro_rules!是声明宏的关键字。宏的名字是vec,后面{}里的内容是宏体。 -
宏体有点像
match模式匹配,看起来像分支。实际上这里只有一个分支。虽然我们说宏体类似于match模式匹配,但它与match有本质区别:match匹配的是模式,而宏匹配的是 Rust 代码结构。 -
( $( $x:expr ),* )是它的模式,后面是代码。因为这里只有一个模式,其它任何模式都会导致编译时错误。更复杂的宏可能包含多个分支。首先,我们用括号包住整个模式。在宏系统中,我们用美元符号(
$)声明一个变量,它会包含与模式匹配的 Rust 代码。美元符号清楚地表明这是宏变量,而不是普通 Rust 变量。接下来是另一组括号,它们捕获与括号内模式匹配的值,以便在替换代码中使用。$()里面是$x:expr,它匹配任意 Rust 表达式,并把名字设为$x。*表示该模式可以匹配零个或多个前面的项。假设我们写
let v: Vec<u32> = vec![1, 2, 3];——那么$x会分别匹配1、2和3。现在看与该分支关联的代码体:
$()*中的temp_vec.push()会根据模式中$()部分的匹配次数生成零次或多次。$x会被替换成每个匹配到的表达式。当我们用vec![1, 2, 3];调用这个宏时,替换宏调用所生成的代码是:
#![allow(unused)]
fn main() {
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
}
关于如何编写宏的更多内容,见 Daniel Keep 撰写、Lukas Wirth 续写的 The Little Book of Rust Macros。
大多数程序员只使用宏,从不编写宏,所以这里不再深入。
19.4.4 基于属性生成代码的过程宏
第二种宏是过程宏。它更像函数,或者说某种过程。过程宏把代码作为输入,处理后生成代码作为输出,而不像声明宏那样匹配模式并用其它代码替换。
过程宏有三种:
- 派生宏
- 类属性宏
- 类函数宏
创建过程宏时,定义必须放在它自己的 crate 中,而且该 crate 必须使用特殊的 crate 类型。这是出于复杂的技术原因。Rust 正在努力取消这一要求,但目前仍然存在。
例如:
#![allow(unused)]
fn main() {
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
}
some_attribute是用来指定过程宏类型的占位符。- 下面是过程宏函数,它以
TokenStream为输入,并以TokenStream为输出。TokenStream定义在proc_macrocrate 中。它表示一串 token,而这正是过程宏操作的对象:需要处理的源代码成为输入TokenStream,宏生成的代码成为输出TokenStream。 附加在函数上的属性决定了我们创建的是哪一种过程宏。一个 crate 可以包含多种过程宏。
派生宏
来看一个例子:
创建一个名为 hello_macro 的 crate,定义一个带有关联函数 hello_macro 的 HelloMacro trait,并提供一个过程宏来自动实现该 trait,这样用户就可以在类型上写 #[derive(HelloMacro)],并得到默认的 hello_macro 实现。
首先,我们需要创建一个新的工作空间,并把其它项目放在这个工作空间下。创建并打开 Cargo.toml:
touch Cargo.toml
在里面写入:
[workspace]
members = [
"hello_macro",
"hello_macro_derive",
"pancakes",
]
先创建一个库 crate:
cargo new hello_macro --lib
在 hello_macro/src/lib.rs 中写入:
#![allow(unused)]
fn main() {
pub trait HelloMacro {
fn hello_macro();
}
}
这样我们就有了一个 hello_macro trait 和一个 hello_macro 方法,但还没有任何具体实现。
然后我们可以在 main.rs 中实现该 trait,并提供实际的方法体:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
这样做可以工作,但有一个缺点:如果用户希望很多类型都使用 hello_macro,就必须为每个类型编写相似的代码。这非常繁琐。
所以我们想用过程宏来生成相关代码。另外,因为宏需要打印类型名,而这部分是可变的。例如,如果类型是 Pancakes,应打印 "Hello, Macro! My name is Pancakes!";如果是 Apple,应打印 "Hello, Macro! My name is Apple!"。因为 Rust 没有反射,所以这里只能用宏。
过程宏需要自己的库,所以我们在工作空间中再创建一个库 crate:
cargo new hello_macro_derive --lib
hello_macro_derive 是存放过程宏的 crate。把 hello_macro 宏代码放在 hello_macro_derive 中是命名惯例。
在这个 crate 的 Cargo.toml 中添加以下内容,不要覆盖现有内容:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
我们会用到 syn 和 quote crate,所以把它们加为依赖。
然后看这个 crate 的 lib.rs 该怎么写:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Build a syntax tree representation of Rust code
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
}
- 通过
proc_macro提供的编译器接口,我们可以读取并操作 Rust 代码。因为它内置于 Rust,所以不必把它加为依赖。 syncrate 用于把 Rust 代码从文本转换成我们可以进一步操作的数据结构。quotecrate 把syn产生的数据结构再转回 Rust 代码。
这三个 crate 让解析 Rust 代码容易得多。编写完整的 Rust 解析器并不简单。
简而言之,这里的逻辑是:
hello_macro_derive函数解析TokenStreamimpl_hello_macro转换语法树(ast)
hello_macro_derive 中的代码对每个派生宏来说大体相同;不同的是 impl_hello_macro 部分。效果是:当用户在类型上写 #[derive(HelloMacro)] 时,hello_macro_derive 函数会被自动调用。
它之所以会自动调用,是因为我们在定义宏时使用了 #[proc_macro_derive(HelloMacro)],这个属性告诉 Rust 它作用于 HelloMacro trait。
这个函数首先把输入的 TokenStream 转换成我们可以解释和操作的数据结构。它把 TokenStream 传给 syn::parse,输出一个表示解析后 Rust 代码的 DeriveInput 结构体。以上面的 Pancakes 类型为例,输出大致如下:
#![allow(unused)]
fn main() {
DeriveInput {
// ...
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
}
它的 ident(标识符,也就是名称)是 Pancakes。其余部分不详细解释;见官方 DeriveInput 文档。
impl_hello_macro 是最终生成 Rust 代码并以 TokenStream 返回的地方。
#![allow(unused)]
fn main() {
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
}
我们用 ast.ident 获得一个包含被注解类型名称的 Ident 结构体实例。以 Pancakes 为例,当我们在清单中的代码上运行 impl_hello_macro 时,得到的 ident 有一个值为 "Pancakes" 的 ident 字段。因此 name 变量包含一个 Ident 结构体实例,打印时会是字符串 "Pancakes"。
quote! 宏让我们定义想要返回的 Rust 代码。因为 quote! 的结果不能直接被编译器理解,所以我们需要把它转换成 TokenStream。做法是调用 into,它接收这个中间表示并返回所需的 TokenStream 值。
quote! 宏还提供模板机制:我们可以写 #name,然后 quote! 会用 name 中的值替换它。你甚至可以用类似普通宏的方式做重复。见官方 quote 文档。
stringify! 宏内置于 Rust。它接受一个 Rust 表达式,例如 1 + 2,但不会求值。相反,1 + 2 会直接被转换成字符串 "1 + 2"。这与 format! 或 println! 不同,后两者会先求值表达式,再把结果转换成 String。#name 输入可能是一个按字面打印的表达式,所以我们使用 stringify!。使用 stringify! 还可以在编译时把 #name 变成字符串字面量,从而节省分配。
写完这些后,编译这两个 crate(带包名的 cargo build 即可;注意路径,否则 Cargo 找不到 crate)。然后在同一个工作空间中创建一个二进制 crate:
cargo new pancakes
在 pancakes crate 的 Cargo.toml 中添加以下内容,不要覆盖其它内容:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro_derive" }
添加 hello_macro 和 hello_macro_derive 依赖。
在 pancakes/src/main.rs 中写入:
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
这样就完成了。运行后可以看到:
Hello, Macro! My name is Pancakes!
类属性宏
类属性宏也叫属性宏。它们类似于自定义派生宏,但不是为 derive 属性生成代码,而是让你创建新属性。它们也更灵活:derive 只适用于结构体和枚举,而属性还可以应用到其它条目上,例如函数。
下面是一个类属性宏的例子:
有一个名为 route 的属性(表示路由),在使用 Web 应用框架时,它会注解一个函数。
#![allow(unused)]
fn main() {
#[route(GET, "/")]
fn index() {
}
这段代码只是片段,并不完整。它表示如果路径是 / 且方法是 Get,就会执行 index 函数。route 属性由过程宏定义,宏的函数签名如下:
#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
}
这里有两个 TokenStream 参数:attr 对应 (GET, "/"),item 对应函数体,也就是 index 函数。
除此之外,属性宏的工作方式几乎与派生宏完全一样。它们也需要一个 proc_macro crate,以及一个生成相应代码的函数。
类函数宏
类函数宏也叫函数宏。它们用 macro_name!(...) 这种调用形式,类似于 macro_rules! 宏,但比 macro_rules! 宏更灵活:它们接收 TokenStream 作为输入,并且像另外两种过程宏一样,在定义中使用 Rust 代码来操作这个 TokenStream。
例如:
#![allow(unused)]
fn main() {
let sql = sql!(SELECT * FROM posts WHERE id=1);
}
这只是片段,并不完整。假设我们想定义一个解析 SQL 语句的宏,具体是 SELECT * FROM posts WHERE id=1。宏定义可以是:
#![allow(unused)]
fn main() {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
}
它的签名也类似于派生宏:接收一个 TokenStream,并返回带有所需行为的 TokenStream。
19.5 高级类型
19.5.1 使用 newtype 模式实现类型安全和抽象
在 19.2. 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype 中,我们已经使用 newtype 模式为 Vec 实现了 Display(更具体地说,是在 19.2.6,使用 newtype 模式在外部类型上实现外部 trait)。
在 19.2. 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype(19.2.2「默认泛型参数和运算符重载」)中,我们还写过 Millimeters 和 Meters 结构体,分别存储毫米和米的值。因为这两个值不能直接加减,这就避免了单位被误混用。
我们还可以用 newtype 模式抽象其它特性:
- 新类型可以暴露与其私有内部类型不同的公共 API
- 新类型可以隐藏内部实现(如 17.1. Rust的面向对象的编程特性「封装」中提到的)
19.5.2 类型别名
Rust 提供了声明类型别名的能力,从而给现有类型另一个名字,这有点像泛型。
要使用类型别名,使用 type 关键字。例如:
#![allow(unused)]
fn main() {
type Kilometers = i32;
}
我们把 Kilometers 称为 i32 的同义词。你可以像使用 i32 一样使用 Kilometers:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
- 因为
Kilometers和i32是相同类型,所以我们可以把这两种类型的值相加。
类型同义词的主要用例是减少重复。例如,我们可能有这样一个很长的类型:
#![allow(unused)]
fn main() {
Box<dyn Fn() + Send + 'static>
}
在整个代码库的函数签名和类型标注中反复写这么长的类型,既繁琐又容易出错。例如:
#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// ...
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// ...
}
}
类型别名通过减少重复,让这段代码更容易管理,而且有意义的名字更能表达意图。我们可以把上面的代码改写成:
#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// ...
}
fn returns_long_type() -> Thunk {
// ...
}
}
类型别名也经常与 Result<T, E> 一起使用,以减少重复。例如:
#![allow(unused)]
fn main() {
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
}
I/O 操作通常返回 Result<T, E> 来处理失败。std::io::Error 表示所有可能的 I/O 错误。std::io 中的许多函数返回 Result<T, E>,其中 E 是 std::io::Error。
Result<..., Error> 重复了很多次,所以 std::io 使用了类型别名:
#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, std::io::Error>;
}
于是 Write trait 的方法签名看起来像这样:
#![allow(unused)]
fn main() {
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
}
类型别名在这里有两个作用:
- 它们让代码更容易写,并在整个
std::io中给出一致的接口。 - 因为它只是一个别名,本质上仍然是另一个
Result<T, E>,这意味着我们仍然可以使用适用于Result<T, E>的任何方法,以及像?运算符这样的特殊语法(在 9.3. Result枚举与可恢复的错误 Pt.2:传播错误、问号运算符与链式调用「?运算符」中讨论过)。
19.5.3 Never 类型
Rust 有一个特殊类型叫 !。在类型理论中,它被称为空类型,因为它没有值。我们更喜欢称它为 never 类型,因为它出现在函数的返回类型位置。
例如:
#![allow(unused)]
fn main() {
fn bar() -> ! {
}
}
这段代码的含义是:“函数 bar 永不返回。”永不返回的函数叫做发散函数。
那么 never 类型有什么用?我们用 2.4. 猜数游戏Pt.4 循环询问 中的一段代码:
#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
}
这样写没问题。但如果我们写成这样呢?
#![allow(unused)]
fn main() {
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
这段代码会失败,因为 match 的两个分支返回了不同类型。Rust 是强类型语言,所以必须知道值的确切类型。guess 可能是 i32 或 &str,但 Rust 要求 guess 只能有一种类型。
换句话说,在这种形式下,match 的所有分支都必须返回相同类型。
再回看正确的代码:Ok 分支返回 u32,那 Err 分支中的 continue 返回什么类型?如果它是单元类型 (),也就是没有返回值,Rust 就无法判断 guess 究竟是 u32 还是 ()。
这就是 never 类型发挥作用的地方:continue 的返回类型是 !。换句话说,当 Rust 检查 guess 的类型时,它会查看两个 match 分支。第一个分支返回 u32,第二个分支返回 !。因为 ! 永远不会产生值,Rust 就知道 guess 是 u32。
never 类型对 panic! 宏的作用方式相同。看看 unwrap 的定义:
#![allow(unused)]
fn main() {
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
}
Rust 看到 val 的类型是 T,而 panic! 的类型是 !,所以整个 match 表达式的返回值是 T。之所以可行,是因为 panic! 不返回值;它会结束程序。
实际上,loop 也是 !,因为无限循环永不结束,所以永远不会产生返回值。然而,如果包含 break,情况就不再如此,因为循环到达 break 时会终止。
19.5.4 动态大小类型与 Sized trait
Rust 需要了解其类型的某些细节,例如为特定类型的值分配多少空间。这使得动态大小类型这个概念有点令人困惑。它们有时被称为 DST 或 unsized types。这些类型让我们可以编写处理大小仅在运行时才知道的值的代码。
我们可以用 str(不是 &str,也不是 String)作为动态大小类型的例子:
#![allow(unused)]
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
在运行时之前我们无法知道字符串有多长,这意味着我们无法创建类型为 str 的变量,所以上面的代码无法工作。
Rust 需要知道为特定类型的任何值分配多少内存,并且同一类型的所有值必须使用相同数量的内存。如果 Rust 允许我们写上面的代码,那么这两个 str 值就必须占用相同空间。但它们长度不同:s1 需要 12 字节存储,而 s2 需要 15 字节。这就是我们无法创建存储动态大小类型的变量的原因。
那该怎么办?一般来说,把 s1 和 s2 的类型改成 &str 而不是 str,就能解决问题:
#![allow(unused)]
fn main() {
let s1: &str = "Hello there!";
let s2: &str = "How's it going?";
}
切片数据结构只存储切片的起始位置和长度。所以虽然 &T 是包含一个内存地址的单个值,但 &str 是两个值(如 4.5. 切片(Slice) 中讨论的):
str的地址(usize)str的长度(usize)
因此,我们可以在编译时知道 &str 值的大小:它是 usize 大小的两倍。换句话说,无论它引用的字符串有多长,我们总是知道 &str 的大小。
一般来说,在 Rust 中使用动态大小类型的最佳方式,是给它们额外的元数据来存储动态大小信息。动态大小类型的黄金法则是:我们必须始终把它们放在某种指针后面。
我们可以把 str 与各种指针组合,例如 Box<str> 或 Rc<str>。Trait 实际上也是动态大小类型。为了处理动态大小类型,Rust 提供了 Sized trait 来判断类型的大小在编译时是否已知。所有在编译时大小已知的类型都会自动实现这个 trait。此外,Rust 会隐式给每个泛型函数加上 Sized trait。
这意味着像这样的泛型函数:
#![allow(unused)]
fn main() {
fn generic<T>(t: T) {
// ...
}
}
实际上被写成:
#![allow(unused)]
fn main() {
fn generic<T: Sized>(t: T) {
// ...
}
}
默认情况下,泛型函数只适用于编译时大小已知的类型。 但我们可以用特殊的 ?Sized 语法放宽这个限制:
#![allow(unused)]
fn main() {
fn generic<T: ?Sized>(t: &T) {
// ...
}
}
?Sized表示“T可能实现也可能不实现Sizedtrait”,也就是说T可能是也可能不是动态大小类型。这个记号移除了“泛型类型在编译时必须有已知大小”这一默认要求。?Trait语法只适用于Sizedtrait,不适用于其它 trait。- 我们把参数
t的类型从泛型T改成了&T。因为该类型可能没有实现Sized,也就是可能是动态大小类型,所以我们需要用指针包裹动态大小类型。
使用动态大小类型的最佳场景是与 trait 配合。有时我们希望某些数据实现某些 trait 或某个生命周期,但不知道具体类型,这时就可以使用指针包裹的动态类型。例如:
#![allow(unused)]
fn main() {
type Job = Box<dyn FnOnce() + Send + 'static>;
}
这个例子同时使用了类型别名和指针包裹的动态类型。Job 可以是任何实现了 FnOnce()、Send 以及 'static 生命周期的类型。
20.1 最后的项目:单线程Web服务器
20.1.1. 什么是TCP和HTTP
Web 服务器涉及的两个主要协议是超文本传输协议(Hypertext Transfer Protocol,HTTP)和传输控制协议(Transmission Control Protocol,TCP)。这两种协议都是请求-响应协议:客户端发送请求,服务器监听请求并向客户端发送响应。这些请求和响应的内容由协议定义。
TCP 是较低级别的协议。它描述信息如何从一台服务器传输到另一台服务器的细节,但不指定该信息是什么。HTTP 通过定义请求和响应的内容构建在 TCP 之上。从技术上讲,可以将 HTTP 与其他协议结合使用,但在大多数情况下,HTTP 通过 TCP 发送数据。我们将使用 TCP 和 HTTP 请求与响应的原始字节。
20.1.2. 监听TCP
了解了这些基础之后,我们就开始实践吧!首先创建这个项目:
cargo new web_server
打开 main.rs,初始代码如下:
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
std::net::TcpListener是标准库提供的用于监听 TCP 连接的类型。TcpListener::bind函数会监听你传入的地址。这里我们传入"127.0.0.1:7878",也就是本地的 7878 端口。它的返回类型是Result<T, E>,因此我们使用unwrap进行错误处理。如果绑定成功,就会返回一个TcpListener,并赋给listener变量。TcpListener有一个incoming方法,它会返回一个产生流序列的迭代器,也就是TcpStream。单个流表示客户端和服务器之间打开的一个连接,而for循环会依次处理每一个连接,为我们生成可供处理的流。
让我们尝试运行这段代码。在终端运行 cargo run,然后在浏览器中加载 127.0.0.1:7878。浏览器应该会显示错误(例如“连接重置”或 ERR_SOCKET_NOT_CONNECTED),因为服务器当前还没有发回任何数据。但当你查看终端时,应该会看到浏览器连接到服务器时打印出的一条或多条消息——浏览器加载单个页面时往往会打开多个连接:
控制台输出如下(某次可能的运行结果;具体条数可能不同):
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
20.1.3. 读取请求
我们已经实现了 TCP 监听,接下来尝试读取请求。我们直接在上文的代码上修改:
use std::{
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {:#?}", http_request);
}
- 我们定义了一个名为
handle_connection的函数来处理客户端连接。参数stream是一个可变的TcpStream值,用于与客户端通信。TcpStream的内部状态可能会随着数据读取和写入发生变化,因此必须声明为mut。 - 我们用
BufReader包裹stream,创建一个名为buf_reader的缓冲读取器。 - 我们使用
map(|result| result.unwrap())解包Result值并提取其中的字符串。如果读取失败,程序会因unwrap而恐慌。 take_while(|line| !line.is_empty())会过滤迭代器中的元素,直到遇到空行为止。HTTP 请求使用空行("")标记请求头结束,因此我们只收集非空行。- 我们将所有非空行收集到一个
Vec<_>中,并存储为http_request。 - 我们用
println!打印http_request。
试一下:
终端输出如下(请求头因客户端而异;本例来自 Chrome):
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"Connection: keep-alive",
"sec-ch-ua: \"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
"sec-ch-ua-mobile: ?0",
"sec-ch-ua-platform: \"macOS\"",
"Upgrade-Insecure-Requests: 1",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Sec-Fetch-Site: none",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-User: ?1",
"Sec-Fetch-Dest: document",
"Accept-Encoding: gzip, deflate, br, zstd",
"Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6",
]
HTTP 是基于文本的协议,它的请求采用以下格式:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
第一行是请求行,保存有关客户端请求的信息。请求行的第一部分指示正在使用的方法,例如 GET 或 POST。它描述客户端如何发出此请求。我们的客户端使用了 GET 请求,这意味着它正在询问信息。
请求行的下一部分是 /,它表示客户端请求的统一资源标识符(URI)。URI 几乎与统一资源定位符(URL)相同,但不完全相同。URI 和 URL 之间的区别对于本章的目的并不重要,但 HTTP 规范使用了术语 URI,因此我们可以在这里用 URL 代替 URI。
最后一部分是客户端使用的 HTTP 版本,然后请求行以CRLF 序列(\r\n)结束,其中 \r 是回车,\n 是换行。CRLF 序列将请求行与其余请求数据分开。请注意,当打印 CRLF 时,我们会看到一个新行,而不是 \r\n。
20.1.4. 编写响应
现在我们已经能读取请求,接下来写响应。响应的格式与请求非常相似:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行是状态行,其中包含响应中使用的 HTTP 版本、一个数字状态码,以及该状态码对应的文本描述,后面跟着一个 CRLF 序列。
有了格式就好写代码:
#![allow(unused)]
fn main() {
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
HTTP/1.1是 HTTP 版本,200是数字状态码,OK是文本描述,\r\n\r\n是 CRLF 序列。- 我们在
response上调用as_bytes,将字符串数据转换为字节。stream上的write_all方法接受&[u8],并直接通过连接发送这些字节。由于write_all可能失败,我们使用unwrap。在实际应用中,你也可以在这里添加其他错误处理逻辑。
接下来返回一个真正的 HTML 文档。在项目根目录创建 hello.html:
然后这样写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
这是一个最小的 HTML5 文档,带有标题和一些文本。
为了返回 HTML,我们需要修改 main.rs。首先把 std::fs 引入作用域:
#![allow(unused)]
fn main() {
use std::fs;
}
fs 是文件系统模块。
然后在 handle_connection 中稍微修改一下 response 变量:
#![allow(unused)]
fn main() {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
- 使用
fs::read_to_string把文件内容转换为字符串。 - 然后使用
format!宏,按刚才写的格式把字符串放入响应中。
完整代码:
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
试一下:

20.1.5. 有选择地响应
现在,无论客户端请求什么,我们的 Web 服务器都会返回这个 HTML 文件。让我们添加功能,检查浏览器是否在访问正常路由。正常访问指的是访问 127.0.0.1:7878/ 或 127.0.0.1:7878。在返回 HTML 文件之前,如果浏览器请求了其他任何内容,则返回错误。
在项目根目录创建一个 404.html 文件,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
把之前返回 HTML 的代码放进 if 分支。如果请求是正常访问,就返回正常内容;否则返回 404.html:
#![allow(unused)]
fn main() {
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
}
}
- 我们删去了打印请求的部分,因为不再需要它。
- 我们通过请求行判断用户是否在正常访问。正常路径仍然返回正常内容;其他任何请求则返回
404.html的内容。
当前代码有很多重复的地方,我们来稍微重构一下:
#![allow(unused)]
fn main() {
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
}
我们使用元组模式匹配和 if 表达式来确定 status_line 和 filename 的值。
试一下:
正常访问:

非正常访问:

20.1.6. 总结
以下是源代码:
main.rs:
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
20.2 最后的项目:多线程Web服务器
20.2.1. 回顾
在上一篇文章中,我们写了一个简单的本地服务器。不过,这个服务器是单线程的,也就是说请求会一个一个被处理。我们得逐个处理每个请求;如果某个请求处理得很慢,后面的就得排队等待。这种单线程外部服务器的性能非常差。
20.2.2. 慢速请求
我们可以用代码来模拟慢速请求:
#![allow(unused)]
fn main() {
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
// ...
fn handle_connection(mut stream: TcpStream) {
// ...
let (status_line, filename) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
// ...
}
}
一些原代码被省略了,但不影响说明。我们增加的语句会在用户访问 127.0.0.1:7878/sleep 时让代码休眠 5 秒,以此模拟慢速请求。
现在打开两个浏览器窗口:一个访问 http://127.0.0.1:7878/,另一个访问 http://127.0.0.1:7878/sleep。和以前一样,你会看到正常路由快速响应。但如果你输入 /sleep 并加载页面,就会看到浏览器在完成加载前会完整等待 5 秒。
如何改善这种情况呢?这里我们使用线程池技术。其他可选方案还包括fork/join 模型、单线程异步 I/O 模型或多线程异步 I/O 模型。
20.2.3. 使用线程池提高吞吐量
线程池是一组已分配好的线程,它们会等待任务,并在任务到来时随时可用。当程序接收到一个新任务时,它会把任务分配给池中的某个线程,同时其余线程还可以继续接收其他任务。任务完成后,该线程会被放回线程池。
线程池通过允许并发处理连接的方式提高了服务器吞吐量。
如何为每个连接创建一个线程呢?看这里:
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(|| {
handle_connection(stream);
});
}
}
迭代器每迭代一次,就会创建一个新线程来处理连接。
缺点是线程数量没有限制:每个请求都会创建一个新线程。如果黑客发起 DoS(Denial of Service,拒绝服务)攻击,我们的服务器很快就会崩溃。
所以在上面代码的基础上,我们进行修改。我们将使用编译驱动开发来编写代码(这不是一种标准的开发方法论,而是开发者之间的一种戏称,不同于 TDD 测试驱动开发):先写出期望调用的函数或类型,再根据编译器错误一步步修复。
使用编译驱动开发
我们先直接写出想要的代码,先不管它对不对:
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming() {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
}
虽然还没有 ThreadPool 类型,但按照编译驱动开发的逻辑,我们先写上,稍后再关心正确性。
运行 cargo check:
error[E0433]: cannot find type `ThreadPool` in this scope
--> src/main.rs:11:16
|
11 | let pool = ThreadPool::new(4);
| ^^^^^^^^^^ use of undeclared type `ThreadPool`
For more information about this error, try `rustc --explain E0433`.
error: could not compile `web_server` (bin "web_server") due to 1 previous error
这个错误告诉我们需要一个 ThreadPool 类型或模块,所以现在就来构建一个。
我们将把 ThreadPool 相关代码写在 lib.rs 中。一方面,这能让 main.rs 足够简洁;另一方面,也让 ThreadPool 代码可以独立存在。
打开 lib.rs,写下 ThreadPool 的简单定义:
#![allow(unused)]
fn main() {
pub struct ThreadPool;
}
在 main.rs 中把 ThreadPool 引入作用域:
#![allow(unused)]
fn main() {
use web_server::ThreadPool;
}
运行 cargo check:
error[E0599]: no associated function or constant named `new` found for struct `ThreadPool` in the current scope
--> src/main.rs:12:28
|
12 | let pool = ThreadPool::new(4);
| ^^^ associated function or constant not found in `ThreadPool`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `web_server` (bin "web_server") due to 1 previous error
这个错误表明,我们现在需要在 ThreadPool 上有一个名为 new 的关联函数。我们还知道 new 需要接受一个可以传入 4 的参数,并且应该返回一个 ThreadPool 实例。让我们实现具备这些特征的最简单 new 函数:
#![allow(unused)]
fn main() {
pub struct ThreadPool;
impl ThreadPool {
pub fn new(size: usize) -> ThreadPool {
ThreadPool
}
}
}
运行 cargo check:
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
--> src/main.rs:17:14
|
17 | pool.execute(|| {
| -----^^^^^^^ method not found in `ThreadPool`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `web_server` (bin "web_server") due to 1 previous error
现在报错是因为 ThreadPool 没有 execute 方法。那就补上一个:
#![allow(unused)]
fn main() {
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
- 除了
self,execute函数还接受一个闭包参数。处理请求的线程只会调用该闭包一次,所以我们使用FnOnce()。()表示它是返回单元类型()的闭包。我们还需要Sendtrait,以便把闭包从一个线程传递到另一个线程;以及'static,因为我们不知道线程会运行多久。 - 另一种理解方式是:我们是在用它替换原来的
thread::spawn函数,所以修改时可以借鉴它的函数签名。其签名如下。我们主要借鉴的是泛型F及其约束,因此execute的泛型约束可以按同样风格来写。
#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
}
现在 cargo check 已经没有错误了,但 cargo run 仍然无法正确处理请求,因为 execute 和 new 实际上还什么都没做,只是满足了编译器。
你可能听说过关于具有严格编译器的语言(例如 Haskell 和 Rust)的一句话:“如果代码能编译,它就能工作。”但这并不普遍正确。我们的项目可以编译,但它什么也没做。如果我们正在构建一个真实、完整的项目,那么这会是开始编写单元测试、检查代码是否编译并具有我们期望行为的好时机,也就是 TDD 测试驱动开发。
修改 new 函数,第 1 部分
我们先修改 new,让它具有实际意义:
#![allow(unused)]
fn main() {
impl ThreadPool {
/// Create a new ThreadPool.
///
/// The size is the number of threads in the pool.
///
/// # Panics
///
/// The `new` function will panic if the size is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
ThreadPool
}
// ...
}
}
- 我们使用
assert!宏检查new函数的参数大于 0,因为 0 没有任何意义。 - 我们添加了一些文档注释,这样在运行
cargo doc --open时就能看到:
修改 ThreadPool 类型
new 函数的修改遇到了瓶颈:ThreadPool 还没有具体字段,因此无法实现创建指定数量线程的目标。接下来我们研究如何在 ThreadPool 中存储线程:
#![allow(unused)]
fn main() {
use std::thread;
pub struct ThreadPool {
threads: Vec<thread::JoinHandle<()>>,
}
}
ThreadPool 有一个类型为 Vec<thread::JoinHandle<()>> 的 threads 字段:
- 我们使用
Vec<>,因为要存储多个线程,但确切数量未知,所以用Vector。 - 之前我们看过
thread::spawn的签名,其返回值是JoinHandle<T>,依此我们也用thread::JoinHandle<>来存储线程。JoinHandle<T>有一个T,是因为thread::spawn创建的线程可能有返回值,而我们不知道具体类型,所以用泛型表示。我们的代码确定没有返回值,所以写成thread::JoinHandle<()>,其中()是单元类型。
修改 new 函数,第 2 部分
改完 ThreadPool 的定义后,我们再回来修改 new:
#![allow(unused)]
fn main() {
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let mut threads = Vec::with_capacity(size);
for _ in 0..size {
// create some threads and store them in the vector
}
ThreadPool { threads }
}
}
Vec::with_capacity以size为参数,创建一个预分配容量的Vector。- 我们写了一个从
0到size(不含size)的循环。里面的逻辑还没写,但这个循环是用来创建线程并把它们存进Vector的。 - 最后返回一个
ThreadPool值,其threads字段赋值为本函数中的threads变量。
接下来我们研究 thread::spawn 函数,以便更容易写出 new 里的循环。thread::spawn 在线程创建后会立即开始执行线程应运行的代码。然而在我们的例子中,我们想创建线程并让它们等待我们稍后发送的代码。标准库的线程实现没有提供这样做的方法,所以我们必须自己实现。
使用 Worker 数据结构
我们使用一种新的数据结构来实现这种行为,叫做 Worker,这是池实现中的常用术语。Worker 会拾取需要运行的代码,并在 Worker 的线程中运行它。想象一下在餐厅厨房工作的人:工人们等待顾客下单,然后接受并完成这些订单。我们用 Worker 来管理和实现想要的行为。
让我们创建 Worker 结构体及必要的方法:
#![allow(unused)]
fn main() {
struct Worker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl Worker {
fn new(id: usize) -> Worker {
let thread = thread::spawn(|| {});
Worker { id, thread }
}
}
}
Worker有两个字段:id,类型为usize,用于标识 worker;以及thread,类型为thread::JoinHandle<()>,用于存储一个线程。new函数创建一个Worker实例,id字段的值就是传入的参数。
PS:外部代码(例如 main.rs 中的服务器)不需要知道 ThreadPool 内部如何使用 Worker 的实现细节,因此我们将 Worker 结构体及其 new 函数设为私有。
接下来在 ThreadPool 中使用 Worker:
#![allow(unused)]
fn main() {
pub struct ThreadPool {
workers: Vec<Worker>,
}
}
ThreadPool 上的 new 和 execute 函数也需要修改。我们先修改 new,execute 稍后再改:
#![allow(unused)]
fn main() {
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id));
}
ThreadPool { workers }
}
}
- 把与
threads相关的代码改为workers。 - 由于
ThreadPool中的Worker字段被包在Vector里,我们可以用Vector的push方法添加新元素。 - 在循环中,我们调用
Worker::new创建Worker实例,id字段就是作为参数传入的值。
PS:如果操作系统因为系统资源不足而无法创建线程,thread::spawn 会恐慌。我们在这个例子中不考虑这种情况,但在真实代码中最好用 std::thread::Builder 来处理,它会返回 Result<JoinHandle<T>>。
通过通道向线程发送请求
现在线程创建完成了,接下来的问题是如何接收任务。这时就需要用到通道。像这样重构代码:
#![allow(unused)]
fn main() {
use std::thread;
use std::sync::mpsc;
pub struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
struct Job;
}
- 用
use std::sync::mpsc;把mpsc引入作用域,以便后文使用。 - 给
ThreadPool新增一个名为sender的字段。其类型是mpsc::Sender<Job>(Job是表示待执行工作的结构体),用于存储通道的发送端。
在 ThreadPool::new 中创建通道:
#![allow(unused)]
fn main() {
impl ThreadPool {
// ...
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, receiver));
}
ThreadPool { workers, sender }
}
// ...
}
// ...
impl Worker {
fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
let thread = thread::spawn(|| {
receiver;
});
Worker { id, thread }
}
}
}
- 使用
mpsc::channel()创建通道。发送端和接收端分别命名为sender和receiver。 - 把
sender赋给返回值的sender字段;换句话说,线程池拥有通道的发送端。 - 接收端应该属于
Worker,所以我们也修改Worker::new,增加receiver参数。
现在试一下 cargo check:
error[E0382]: use of moved value: `receiver`
--> src/lib.rs:18:42
|
14 | let (sender, receiver) = mpsc::channel();
| -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
17 | for id in 0..size {
| ----------------- inside of this loop
18 | workers.push(Worker::new(id, receiver));
| ^^^^^^^^ value moved here, in previous iteration of loop
|
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
--> src/lib.rs:37:33
|
37 | fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
| --- in this method ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `web_server` (lib) due to 1 previous error
报错是因为代码试图把同一个 receiver 传给多个 Worker 实例,这行不通,因为接收端只能有一个。
我们希望所有线程共享同一个 receiver,从而能在线程间分发任务。此外,从通道队列中取出内容需要修改 receiver,因此线程需要一种安全的方式来共享并改变 receiver。否则,我们可能会遇到竞争条件。
针对多线程中的多重所有权,我们可以使用 Arc<T>(Rc<T> 只适用于单线程代码)。针对多线程中避免数据竞争,我们可以使用互斥锁 Mutex<T>。
所以只需用 Arc<T> 和 Mutex<T> 包裹原来的 receiver:
#![allow(unused)]
fn main() {
impl ThreadPool {
/// ...
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::with_capacity(size);
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
//...
}
//...
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(|| {
receiver;
});
Worker { id, thread }
}
}
}
- 重新绑定
receiver,让它被Arc<T>和Mutex<T>包裹。 - 在循环中,把
Arc::clone(&receiver)传给每个Worker。 Worker::new中的receiver参数必须改为Arc<Mutex<mpsc::Receiver<Job>>>。
修改 Job
我们的 Job 仍然是一个空结构体,没有任何实际效果,所以我们把它改成类型别名(详见 19.5. 高级类型):
#![allow(unused)]
fn main() {
type Job = Box<dyn FnOnce() + Send + 'static>;
}
Job 是一个在单个线程中只被调用一次、没有返回值(或者说返回单元类型 ())的闭包,因此必须满足 FnOnce()。它还需要能在线程间传递,因此必须满足 Send trait。'static 是因为我们不知道线程会运行多久,所以声明为静态生命周期。
修改 execute 函数
接下来修改 execute:
#![allow(unused)]
fn main() {
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.send(job).unwrap();
}
}
- 因为
Job被包在Box<T>中,所以闭包f必须先用Box::new包裹,然后才能发送出去。 - 使用
self上的sender字段作为发送端,把job发送出去。
修改 Worker::new
现在 execute 已经改完,作为接收端的 Worker::new 也必须改:
#![allow(unused)]
fn main() {
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv().unwrap();
println!("Worker {} got a job; executing.", id);
job();
});
Worker { id, thread }
}
}
}
- 使用
lock锁定被包在Mutex<T>中的receiver,获取互斥守卫,并用unwrap做错误处理。 - 然后使用
recv接收通过通道发送过来的值,再次用unwrap做错误处理。 - 打印是哪个
Worker在工作。 - 当调用
job();时,编译器会自动把job解引用为其内部的闭包类型,然后调用FnOnce或相关 trait 实现中合适的call方法。这是因为Box<dyn FnOnce()>实现了FnOnce。换句话说,job();是(*job)();的语法糖。
版本差异
我使用的是 Rust 1.84.0。在较旧的 Rust 版本中,你不能直接调用 job();,也不能使用 (*job)();,因为编译器当时并不直接知道如何处理装箱的 trait 对象。在较新的 Rust 版本中,编译器的解引用并调用调度逻辑已经支持直接调用 Box<dyn Trait>。
如果你的 Rust 版本拒绝上面的代码,那么要么升级 Rust,要么使用一个小变通方案:
#![allow(unused)]
fn main() {
trait FnBox {
fn call_box(self: Box<Self>);
}
impl<F: FnOnce()> FnBox for F {
fn call_box(self: Box<F>) {
(*self)();
}
}
type Job = Box<dyn FnBox + Send + 'static>;
}
FnBoxtrait 让我们可以在装箱类型上调用方法。- 我们为
FnOnce()实现call_box(因为Job实现了FnOnce()),这样就能获得Box内部值的所有权并调用它。 - 我们把
Job的类型从FnOnce()改成FnBox,这样其余代码就不需要改动。任何实现了FnBox的类型都可以在这个变通方案中作为任务使用。
20.2.4. 试运行
终于改完了,让我们试运行一下:

终端输出(哪个 Worker 接到任务是不确定的):
Worker 1 got a job; executing.
Worker 0 got a job; executing.
如果你在浏览器里多刷新几次页面,就能看到其他不同 id 的 Worker 在工作。
20.2.5. 总结
main.rs:
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
use web_server::ThreadPool;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming() {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
lib.rs:
#![allow(unused)]
fn main() {
use std::{
sync::{mpsc, Arc, Mutex},
thread,
};
pub struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
/// Create a new ThreadPool.
///
/// The size is the number of threads in the pool.
///
/// # Panics
///
/// The `new` function will panic if the size is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::with_capacity(size);
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.send(job).unwrap();
}
}
struct Worker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv().unwrap();
println!("Worker {} got a job; executing.", id);
job();
});
Worker { id, thread }
}
}
}
hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
20.3 最后的项目:Web服务器的优雅停机与清理
20.3.0. 回顾
在上一篇文章中,我们完成了多线程 Web 服务器,但仍然有一些可以改进之处。这篇文章我们就来完善代码。
注意:本文衔接于 20.2. 最后的项目:多线程Web服务器。如果你想详细了解从零开始构建 Web 服务器的过程,请阅读完第 20 章的所有文章。
20.3.1. 为 ThreadPool 实现 Drop trait
当我们想要关闭服务器(使用不太优雅的 Ctrl + C 方法停止主线程)时,所有其他线程也会立即停止,即使它们仍在处理请求。
用于管理清理的 trait 是 Drop trait。我们只需要在本地编写 drop 函数来覆盖默认实现,让线程能够在关闭之前完成当前正在处理的工作。我们还需要某种方式来阻止线程接收新请求,并为停机做好准备。
让我们为 ThreadPool 实现 Drop trait:
#![allow(unused)]
fn main() {
impl Drop for ThreadPool {
fn drop(&mut self) {
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
worker.thread.join().unwrap();
}
}
}
}
逻辑就是遍历每一个 worker,然后调用 worker 里 thread 字段的 join 方法(详见 16.1. 使用多线程同时运行代码)。
运行 cargo check:
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
--> src/lib.rs:42:13
|
42 | worker.thread.join().unwrap();
| ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
| |
| move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
|
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
--> /Users/stanyin/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/src/thread/join_handle.rs:149:17
|
149 | pub fn join(self) -> Result<T> {
| ^^^^
For more information about this error, try `rustc --explain E0507`.
error: could not compile `web_server` (lib) due to 1 previous error
报错信息显示我们无法把 worker 的 thread 字段移出来,因为我们只有每个 worker 的可变引用,但 join 需要 JoinHandle 的所有权(也就是 worker.thread 的所有权)。
为了满足所有权要求,我们需要修改 Worker 中 thread 字段的类型,用 Option<T> 包裹 thread::JoinHandle<()>。这样我们就可以调用 Option<T>::take 来获得所有权:
#![allow(unused)]
fn main() {
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
}
凡是使用了 thread 字段的地方,也都必须因 Option<T> 而更新:
#![allow(unused)]
fn main() {
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv().unwrap();
println!("Worker {} got a job; executing.", id);
job();
});
Worker {
id,
thread: Some(thread),
}
}
}
}
把 thread 字段的值从 thread 改为 Some(thread)。
#![allow(unused)]
fn main() {
impl Drop for ThreadPool {
fn drop(&mut self) {
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
}
使用 if let 模式匹配,在 worker.thread 为 Some 时取出其中的值(使用 take 可以获得所有权,而不是可变引用)。
20.3.2. 向线程发出信号以退出
这样修改后可以编译通过,但仍未达到预期效果。调用 drop 并不会真正关停线程,因为线程仍然卡在 loop 中等待工作。
如果用这个 drop 方法丢弃 ThreadPool,主线程会永远阻塞,等待第一个线程结束(因为每个线程都一直在循环寻找工作,不会跳出循环)。
我们需要 ThreadPool 的 sender 字段有两种状态:一种是附带任务的存活状态,另一种是终止状态:
#![allow(unused)]
fn main() {
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
}
使用 Option<T> 可以让它表示这两种状态。
凡是使用了 sender 字段的地方也都必须修改:
#![allow(unused)]
fn main() {
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
}
添加 drop(self.sender.take()); 来显式丢弃发送端,这样就会关闭通道。发生这种情况时,worker 无限循环中执行的所有 recv 调用都会返回错误,worker 也会停止运行。
#![allow(unused)]
fn main() {
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::with_capacity(size);
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool {
workers,
sender: Some(sender),
}
}
}
用 Some 包裹返回值中的 sender 字段。
#![allow(unused)]
fn main() {
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
因为 sender 现在是 Option,我们调用 as_ref 得到 Option<&Sender>(对内部发送端的引用),而不会把它从 self 中移出。随后再 unwrap 并调用 send:send 在发送端上接受 &self,并把任务移入通道。
这样改仍然不够优雅,因为 worker 无限循环中执行的所有 recv 调用都会返回错误。最好不要因为错误而退出,所以还需要再改一处:
#![allow(unused)]
fn main() {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => {
println!("Worker {} got a job; executing.", id);
job();
}
Err(_) => {
println!("Worker {} disconnected; shutting down.", id);
break;
}
}
});
}
去掉 job 上最后一个 unwrap,转而使用 match 分支:Ok 变体就执行 job,Err 变体则打印 worker 正在断开连接,然后跳出循环。
20.3.3. 试运行
为了测试修改后的行为,我们修改 main.rs,让服务器只接受两个请求(通过 take 限制迭代次数):
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
println!("Shutting down.");
}
控制台输出如下(某次可能的运行结果;任务分配与交错顺序是不确定的):
Shutting down.
Shutting down worker 0
Worker 0 got a job; executing.
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3
你可能会看到不同的 Worker id,以及 “got a job”“disconnected”“Shutting down worker” 行的不同交错顺序,但整体模式应该类似。
20.3.4. 总结
main.rs:
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
use web_server::ThreadPool;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
println!("Shutting down.");
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
lib.rs:
#![allow(unused)]
fn main() {
use std::{
sync::{mpsc, Arc, Mutex},
thread,
};
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
/// Create a new ThreadPool.
///
/// The size is the number of threads in the pool.
///
/// # Panics
///
/// The `new` function will panic if the size is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::with_capacity(size);
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool {
workers,
sender: Some(sender),
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => {
println!("Worker {} got a job; executing.", id);
job();
}
Err(_) => {
println!("Worker {} disconnected; shutting down.", id);
break;
}
}
});
Worker {
id,
thread: Some(thread),
}
}
}
}
hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>