1.0 简介
1.0. 简介
1.0.1. 题外话
这个项目(代码和笔记)都是我在自学Rust时所记录下的,或许会有记述不严密,表达不清晰之处,还请你谅解。如果你能从中受益,那再好不过。在这里推荐原视频 Rust编程语言入门教程(Rust语言/Rust权威指南配套)【已完结】
1.0.2. 为什么要使用Rust
-
Rust代码可靠且高效
-
Rust可以代替C和C++, Rust在具有相同性能的前提下比他们更安全,在编程中最明显的感受就是Rust不需要像前两者那样写几行就编译一下查看是否报错。具体如下: - 内存安全(空指针解引用、悬空指针和数据竞争) - 线程安全 (在运行程序之前就可以保证多线程代码是安全的) - 避免未定义行为 (数组越界、未初始化的变量和使用释放的内存)
-
Rust提供了现代语言特性(如泛型、trait、模式匹配等)
-
Rust 提供了更现代化的工具链:Rust 的 Cargo 和 Python 的库管理工具(如 pip)有着同一理念。用过C/C++都知道这两个语言的依赖项配置比较麻烦,而python的库管理工具十分自由且简单,Rust的Cargo保证了用户能在拥有C和C++的性能下有着使用python般的令人舒适的依赖项管理体验。
1.0.3 适用场景
-
需要运行速度:Rust 既可以像 C 一样精细控制内存(通过 unsafe),也可以提供现代高级语言的便利性(如所有权系统和模式匹配)。Python 是一种非常高级的语言,开发效率高,但牺牲了性能和控制。
-
需要内存安全:Rust 通过编译时的静态检查,提供了强大的内存安全保证,这使其在需要避免内存错误的场景(如操作系统、嵌入式开发、网络服务器等)中极为适用。
-
需要高效利用多处理器:Rust 为高效并发和多处理器编程提供了原生支持,而不牺牲安全性。这对于需要处理高吞吐量和并发任务的场景(如 Web 服务器、分布式系统、实时计算)尤其关键。
擅长的领域:
- Web Service
- WebAssembly (C#和Java的性能对比Rust和C/C++相形见绌)
- 命令行工具
- 网络编程
- 嵌入式设备
- 系统编程
1.0.4 与其他语言的对比
| 类别 | 语言 | 特点 |
|---|---|---|
| 机器语言 | 二进制指令 | 最接近硬件,由CPU直接执行 |
| 汇编语言 | Assembly | 使用助记符代替机器指令,如 MOV AX, BX。 |
| 低级语言 | C、C++ | 更贴近硬件,提供有限的抽象。 |
| 中级语言 | Rust、Go | 性能接近低级语言,但提供了更高的抽象。 |
| 高级语言 | Python、Java | 更高层次的抽象,易读易用。 |
高级语言与低级语言并不是绝对对立的,而是一个连续的光谱:
- 更低级的语言提供更多的硬件控制能力,但编写代码复杂,开发效率低。
- 更高级的语言提供更多的抽象和自动化功能,但可能会引入运行时开销,失去对硬件的精细控制。
Rust的优点:
- 性能好
- 安全性高
- 极好的并发支持
Rust作为一种中级语言相比于其他语言有这写优势:
- C / C++性能非常好,但是不够安全;Rust能够做到在维持基本相同性能前提下保证安全。
- Java / C#,能保证内存安全(有GC垃圾回收程序),也有很多特性,但是性能不行;Rust不但拥有与之相媲美的安全性,而且性能还更强。
1.0.5. 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.6. Rust的用户与案例
- Google: 操作系统Fuschia, Rust代码量占30%
- Amazon: 基于Linux开发的直接可以在裸机、虚拟机上运行容器的操作系统
- System76: 纯Rust开发了下一代安全操作系统Redox
- 斯坦福和密歇根大学: 嵌入式实时操作系统,应用于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
打开下载好的安装程序,会有如下界面:

这里有三个选项:
- 选项一(默认选项):标准安装
- 选项二:自定义安装,可以自定义安装路径、安装的组件、安装的工具链版本等
- 选项三:取消安装
对于大多数人来说用选项一即可(先输入1再回车或是直接回车都可以)
如果你看到如下界面,那么Rust就已经成功安装了:
安装程序在这里提示需要重启Shell,按下回车键即可。这时候程序就会退出,Rust也就安装完毕了。
1.1.2. Rust各项命令行操作
Rust的各项命令在Windows环境下可以在terminal中执行(Win11自带,如果没有去微软商城搜Windows terminnal下载即可)
-
更新Rust:
rustup updateRust作为新兴的语言,目前的更新非常频繁,建议不时地执行此操作来获得最新版本 -
卸载Rsut:
rustup self uninstall -
安装验证:
rustc --version或是rustc -V结果格式:rustc x.y.z (xxxxxxxxx yyyy-mm-dd):x.y.z表示版本号xxxxxxxxx表示当前版本的哈希值yyyy-mm-dd表示当年前版本提交日期
-
打开本地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,主页有安装的教程。
Step 2:写代码
因为RustRover会对新项目自动配置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左上角的运行按钮(或是Ctrl + F5),就能看到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换成你的程序名字就行。
注意:编译和运行是单独的两步
- 运行Rust程序之前必须先编译,命令为
rustc 你的程序名.rs - 编译成功后会生成一个二进制文件(Windows平台上还会生成
.pdb文件) - Rust是ahead-of-time编译的语言,意味着可以先编译程序,然后把可执行文件交给别人运行(无需安装Rust)
- rustc只适合简单Rust程序,复杂的rust程序需要Cargo(下章会讲)
1.3 Rust Cargo
1.3.0. 回顾
1.2. Rust的基本认识与打印“Hello World”文章的末尾提到了只有小型简单的Rust项目适合使用rustc来编译,大型项目需要Cargo,本篇就对Cargo进行详细的介绍
1.3.1. 什么是Cargo
Cargo是Rust的构建系统和包管理工具,它的功能有构建代码、下载依赖的库、构建这些库…
在安装Rust时就顺带安装了Cargo。判断Cargo是否正确地安装:在终端中输入命令cargo --version

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的配置文件格式
其内容如下:

内容解析:
-
[pakage]是一个区域标题,表示下方的内容是用来配置包(package)的name指项目名version指项目版本authors指项目作者,可有可无这里没有,有的话这一行格式应为:authors = ["your_name <your_email@xxx.com>"]edition是指使用的Rust版本
-
[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() -
::用于访问关联函数 -
stdin()是一个函数,这个函数用于获取标准输入流(standard input),返回Stdin类型的实例,它会被用作句柄(handle) 来处理终端中的标准输入。 -
.read_line()是Stdin类型提供的方法,用于从标准输入中读取一行内容给放到字符串中,传递给可变的字符串类型变量。read_line()还有一个返回值,类型是Result,一种枚举类型,它有两个值:Ok和Err。如果read_line()能成功读取,那这个枚举类型就会返回Ok和读取到的内容;如果不能读取,就会返回Err和读取失败的原因。 -
&mut guess指的是把.read_line()所读取到的内容传入到这个可变变量guess里。这里的&是取地址符,表示这个参数是一个引用,通过引用就可以在代码的不同地方访问同一块数据(内存地址)。mut指传递给的是可变变量。 -
在读取时可能发生错误,这里就需要调用
.expect(),它是Result类型(与read_line()的返回值同类型)的一个方法。如果读取失败,按上文所述,read_line()就会返回Err,.expect()接收到后会直接触发panic!终止当前程序并打印expect中的错误信息。反之,read_line()就会返回Ok,.expect()接收到后就会把附加的值返回给用户。
PS:也可以不写.expect(),但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);
}
效果:

2.2 猜数游戏Pt.2 生成随机数
2.2.0. 本篇知识点
在本篇中,你将学到:
- 外部Crate的搜索与下载
- Cargo依赖项管理
- 基于语义化版本的升级规则
rand随机数生成器- …
2.2.1. 游戏目标
- 生成一个1到100间的随机数(本篇会涉及)
- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了
- 如果猜测正确,那么打印一个庆祝信息,程序退出
2.2.2. 代码实现
Step 1:寻找外部库
虽然Rust标准库内并没有提供与生成随机数相关的函数,但rust团队开发了具有这个功能的外部库,在Rust官方crates管理平台(Rust 编程语言的官方包管理平台和生态系统)中搜索rand即可以找到这个外部库,这个网页提供了非常详细的对这个库的介绍。

Rust的crate一共分为两种:
-
库 Crate(Library Crate):库 crate 是一个提供功能、逻辑模块的库,没有
main函数,不能单独运行。它通常用于与其他代码共享功能,其用途是提供可重用的功能模块。rand这个crate就属于库crate。 -
二进制 Crate(Binary Crate):二进制 crate 是一个可执行的程序,包含一个
main函数,编译后生成可运行的二进制文件。其用途是构建独立的、可运行的 Rust 应用程序。
Step 2:把这个外部Crate写入Cargo依赖项
接下来就需要把这个外部库写入Cargo依赖项(有关Cargo的介绍在1.3. Rust Cargo一文中就已提及,这里不再重复)以供程序调用。
打开项目中的Cargo.toml文件,在dependencies下面添加依赖项,格式为依赖项名 = "依赖项版本"(Crates网页的右上角Install这一栏下就有这一行的写法)。这个程序需要rand这个依赖项,并且使用0.8.5这个版本,就应该写rand = "0.8.5"。如果这个依赖项有其他的依赖项,那Cargo就会自动在编译时下载它们。
实际上0.8.5这种版本号写法是一种简写,其完整写法为^0.8.5,表示任何与0.8.5版本公共API所兼容的版本都可以。比如有一个依赖项的版本是1.2,那么就意味着允许升级到 1.2.0以上的任意版本,但不会升级到 2.0.0 或更高版本。
Cargo会一直使用你指定的依赖项版本,直到你手动指定其他版本。
如果某个项目的依赖项的更新会破坏基于老版本依赖项的代码,那么在重新build后会发生什么呢?答案在Cargo.lock文件中,在build时,Cargo会查看时候已经生成了Cargo.lock文件,如果有,那就使用这个文件里指定的版本,这样就避免了兼容问题。
如果想在当前标准下更新版本,只需要在控制台中使用cargo update指令,具体步骤如下:
- 复制Cargo项目所在的文件夹路径,打开终端,输入命令
cd Cargo项目路径 - 输入命令
cargo update
这个命令会忽略cargo.lock,通过更新的注册表来找到符合在Cargo.toml中要求的最新版本依赖项,但Cargo.toml中所写下的版本是不会动的。举个例子,假如说有一个依赖项在Cargo.toml中声明的版本是1.2,它就可以通过cago update升级到最新的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 rang_number:声明了一个叫做range_number的不可变变量=:赋值rand::thread_rng:返回ThreadRng类型,这个类型就是一个随机数生成器。这个随机数生成器位于本地线程空间,通过操作系统获得这个随机数的种子。.gen_range(1,101):rand::thread_rng上的一个方法,它有两个参数:最小值和最大值,在这里就是1和101,它就会在1到101间生成随机数,这个范围包括1但不包括101。
最后再打印出这个随机数(println!的使用在上一篇文章已作介绍,不再阐述):
#![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);
}
运行效果如下:

2.3 猜数游戏Pt.3 输入数与随机数的对比
2.3.0. 本篇知识点
在本篇中,你将学到:
match的用法- 类型遮蔽
- 类型强制转换
Odering类型
2.3.1. 游戏目标
- 生成一个1到100间的随机数
- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了(本篇会涉及)
- 如果猜测正确,那么打印一个庆祝信息,程序退出
2.3.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是i32类型(有符号的 32 位整数类型,gen_range 方法返回的类型与范围中的数值类型一致。在这里,1 和 101 是 i32 类型,因此返回值类型也是 i32,数据类型将会在下一章提到)。这两个变量类型不同,不能直接比较。需要强制把字符串类型转换为整数类型。
#![allow(unused)]
fn main() {
let guess:u32 = guess.trim().parse().expect("请输入一个数字 Please enter a number")
}
-
let guess:u32:这里声明了一个类型为u32(没有符号的32位整数类型,换句话来说就是不能表示负数),名字叫做guess的变量。 但这里有一个问题:在之前的代码中(let mut guess = String::new();)已经声明了一个叫做guess的变量,不会报错吗?答案是不会,因为Rust允许使用同名新变量来隐藏原来同名的新变量,学名叫做类型遮蔽(当一个变量、函数或类型的名称在当前作用域中被重新定义时,隐藏了外部作用域中同名的变量、函数或类型),它允许代码复用这个变量名而无需声明新的变量,这个特性会在下一章仔细介绍。这里可以举一个例子:
fn main(){
let a = 1;
println!("{}",a);
let a = "one";
println!("{}",a);
}
这么做程序不会报错,并且打印出了:
1
one
当程序执行到第二行时,a被赋值为1,所以打印出的是1;在第四行,程序注意到a被复用了,就会抛弃原来的值1,把a赋值为“one“,所以下一行打印的就是one。这就是类型遮蔽。
-
=:赋值 -
guess.trim():这里的guess指的是老的guess,类型为字符串,代表用户输入。因为read_line()这个方法会把用户的回车也记录进去,所以需要使用.trim()。.trim()的作用是除掉字符串中前后的空格和回车都去掉(类似于python中的.strip())。 -
.parse():它可以把字符串解析为某种数值类型,用户的正常输入肯定是1到100间的数,这个数i32、u32和i64等等数据类型都可以容纳,这种情况下解析后到底是那种类型呢?你就得告诉Rust你要那种类型,所以在声明变量时才要显式声明(声明变量时在后面加上:想要的类型,类似于python中静态检查写法)为u32类型。 当然转换有可能会失败,比如说输入xyz,这个时候就没法解析成整数,Rust非常聪明地把.parse()的返回值设成了Result类型(在Pt.1中讲到过),这种枚举类型有两个值:Ok和Err。如果能成功转换,那这个枚举类型就会返回Ok和转换后的结果;如果不能读取,就会返回Err和转换失败的原因。 -
.expect():它是Result类型(与.parse()的返回值同类型)的一个方法。如果读取失败,按上文所述,.parse()就会返回Err,.expect()接收到后会直接触发panic!终止当前程序并打印expect中的错误信息。反之,.parse()就会返回Ok,.expect()接收到后就会把附加的值返回给用户,也就是把转换好的值赋给变量。
Step 2: 数字的比较
在成功转换数据类型后,我们就可以比较两个数字: 先在代码开头声明引用库:
#![allow(unused)]
fn main() {
use std::cmp::Ordering
}
这段代码表示从std标准库里引入一个叫做Ordering的类型,Ordering是一个枚举类型,它有三个变体(可以把它理解为有三个可能的值):Ordering:Less、Ordering::Greater和Ordering::Equal,分别表示小于、大于和等于。
再在主函数里写下对比代码:
#![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);这一行(第五行):
可以看到,没有写到match时,IDE提示range_number的数据类型是i32,写了match这段之后,IDE提示range_number的数据类型是u32,这是为什么呢?这是因为match这段中的guess.cmp(&range_number),这里进行了比较大小,虽然range_number的类型没有被显式定义,但是guess已经被显式定义为了u32,得益于Rust编译器强大的上下文类型推断功能,range_number的类型被guess.cmp(&range_number)的需求推导为u32。而没有match时,因为Rust 默认的整数类型是i32且没有任何其他约束需要将range_number设置为其他类型,所以,Rust编译器会把range_number定义为i32。 -
match:它是Rust的中的模式匹配表达式,它让我们可以根据.cmp()方法返回的Odering这个枚举类型的值来决定下一步的操作。一个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。在下一章会详细地讲
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);
}
效果如下:

2.4 猜数游戏Pt.4 循环询问
2.4.0. 本篇知识点
这是猜数游戏的最后一个部分,本期知识点为:
loop循环breakcontinuematch的灵活使用- 枚举类型的处理方式
2.4.1. 游戏目标
- 生成一个1到100间的随机数- 提示玩家输入一个猜测
- 猜完之后,程序会提示猜测是太大了还是太小了
- 循环询问,如果猜测正确,那么打印一个庆祝信息,程序退出(本篇会涉及)
2.4.2. 代码实现
Step 1:实现循环
之前代码中,我们实现了一次输入的比较,接下来我们要实现多次询问和多次比较,直到用户猜到正确的数。
以下是截止到本篇之前的代码:
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这个分支(分支的概念在上一篇文章中就有解释,这里不再重复)后即可,记得一个分支下如果要执行多行代码要把代码块部分用{}框住。
#![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 一次猜测* 中提到过.parse()的返回值是一个枚举类型,如果成功转换,返回值会是:Ok+转换好的内容;如果失败,返回值是:Err+失败的原因;那在哪里提到过这个枚举类型呢?没错,在上一篇文章中我们提到了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(_) => continue,
};
}
-
Ok(num) => num:这个分支负责处理成功转换,返回值是Ok+转换好的内容的情况。Ok是这个枚举类型的一个变体,OK后的()里是这个枚举类型中附带的转换好的内容(u32),这里()里写了num意思就是把转换好的内容绑定到num上,这个num的值会被传递给match表达式的结果,最终赋值给guess。 -
Err(_) => continue:这个分支用于处理转换失败,返回值是Err+失败的原因的情况。Err是枚举类型的一个变体,Err后的()里是这个枚举类型中附带的失败的原因(&str),()里是_表示不关心错误信息,只需要知道是Err即可。
这里使用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(_) => 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);
}
效果:

3.1 变量与可变性
3.1.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性(本文)
- 数据类型:标量类型
- 数据类型:复合类型
- 函数和注释
- 控制流:
if else - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.1.1. 可变/不可变变量的声明
-
声明变量使用
let关键字 -
默认情况下,变量是不可变的。以下是错误例,报错内容在注释里:
fn main(){
let machine = 6657;
machine = 0721; //Error: cannot assign twice to immutable varible
println!("machine is {}", machine);
}
- 在
let后加上mut才能声明可变变量。以下是成功例,输出内容在注释里:
fn main(){
let mut machine = 6657;
machine = 721;
println!("machine is {}", machine);//Output: machine is 721
}
3.1.2 变量与常量
有很多人在刚开始学Rust的时候都会搞不清不可变变量与常量(constant)的区别在哪里。常量在绑定值后也是不可变的,但它与不可变变量的区别很大:
- 常量不能使用
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)
在之前的小游戏程序中声明变量时就提过一嘴,Rust允许使用同名新变量来隐藏原来同名的新变量,学名叫做类型遮蔽(当一个变量、函数或类型的名称在当前作用域中被重新定义时,隐藏了外部作用域中同名的变量、函数或类型),每次遮蔽时,原变量的值和类型都会被新变量替代。它允许代码复用这个变量名而无需声明新的变量。
这里可以举一个例子:
fn main(){
let a = 1;
println!("{}",a);
let a = "one";
println!("{}",a);
}
这么做程序不会报错,并且打印出了:
1
one
当程序执行到第二行时,a被赋值为1,所以打印出的是1;在第四行,程序注意到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);//Error: 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中,你就会发现IDE提示type error: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^31次方 - Rust的整数类型列表如图:
- 每种都分i和u,以及固定的位数
- 有符号范围:-(2^n - 1) 到 2^n - 1
- wu无符号范围: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. 整数字面值
整数不一定是10进制的,也有其他的进制,使用固定的格式能让程序理解你使用的进制也使别人能看得懂你的代码。
| Number literals | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
| Byte (u8 only) | b’A’ |
- 十进制中可以加上
_来增加可读性 - 16进制以
0x开头 - 8进制以
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提供了两种基础的复合类型:元组(Tuple)、数组
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 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灵活(以后会讲)。
- Vector和数组类似,它由标准库;数组由prelude模块(也是标准库的一部分)提供。
- Vector的长度可以改变
- 不确定应该使用数组还是Vector时,大概率应该使用Vector。
数组的类型:
- 数组的类型以
[类型;长度]的形式表示
#![allow(unused)]
fn main() {
let machine:[u32,4] = [6, 6, 5, 7];
}
声明数组的其他方法:
- 如果数组的每个元素值都相同,那么可以:
- 在中括号里指定初始值
- 然后跟着一个
; - 最后加上数组的长度
#![allow(unused)]
fn main() {
let a = [3;2];
let b = [3, 3, 3];
}
这个例子中a和b的写法是等价的。
访问数组的元素:
- 数组是Stack上分配的的单个块的内存
- 可以使用索引来访问数组的元素
#![allow(unused)]
fn main() {
let machine = [6, 6, 5, 7];
let wjq = machine[0];
}
- 如果访问的索引超出了数组的范围:
cargo build时会报错,cargo check时不会- 运行时会报错,因为Rust不会允许其继续访问相应地址的内存。
数组的原理是一块连续的内存,假设数组的第一个元素在内存上的x位置,那么第二个元素的位置就是x加第一个元素的长度,之后的以此类推。
如果索引值超过了数组的实际长度,那么程序就会读取不在数组位置的其他内存位置,而这个地方的值不一定是什么。在C中完全没有边界检查。在C++中普通数组没有,只有std::array有;在Rust里强制边界检查
| 特性 | C | C++ | Rust |
|---|---|---|---|
| 内存模型 | 连续 | 连续 | 连续 |
| 安全性 | 无边界检查 | std::array有边界检查,普通数组无 | 强制边界检查 |
| 动态数组支持 | 需要手动管理内存 | std::vector | Vec |
| 多维数组支持 | 是 | 是 | 是 |
| 特殊能力 | 简单高效 | STL容器丰富 | 所有权和借用检查 |
但Rust只会对数组进行简单的边界检查,如果将代码写的稍微复杂一点,Rust就无法在编译时检查,只能在运行时进行检查。
#![allow(unused)]
fn main() {
let a = 5;
let machine = [6, 6, 5, 7];
let wjq = machine[a];
}
这个代码Rust会在编译时报错
#![allow(unused)]
fn main() {
let a = [1, 9, 10, 4, 5];
let machine = [6, 6, 5, 7];
let wjq = machine[a[4]];
}
这个代码Rust就不会在编译时报错,但在运行时会报错
3.3 数据类型:复合类型
3.3.0. 写在正文之前
3.3.1. 复合类型的简介
- 复合类型可以将多个值放在一个类型里
- Rust提供了两种基础的复合类型:元组(Tuple)、数组
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 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灵活(以后会讲)。
- Vector和数组类似,它由标准库;数组由prelude模块(也是标准库的一部分)提供。
- Vector的长度可以改变
- 不确定应该使用数组还是Vector时,大概率应该使用Vector。
数组的类型:
- 数组的类型以
[类型;长度]的形式表示
#![allow(unused)]
fn main() {
let machine:[u32,4] = [6, 6, 5, 7];
}
声明数组的其他方法:
- 如果数组的每个元素值都相同,那么可以:
- 在中括号里指定初始值
- 然后跟着一个
; - 最后加上数组的长度
#![allow(unused)]
fn main() {
let a = [3;2];
let b = [3, 3, 3];
}
这个例子中a和b的写法是等价的。
访问数组的元素:
- 数组是Stack上分配的的单个块的内存
- 可以使用索引来访问数组的元素
#![allow(unused)]
fn main() {
let machine = [6, 6, 5, 7];
let wjq = machine[0];
}
- 如果访问的索引超出了数组的范围:
cargo build时会报错,cargo check时不会- 运行时会报错,因为Rust不会允许其继续访问相应地址的内存。
数组的原理是一块连续的内存,假设数组的第一个元素在内存上的x位置,那么第二个元素的位置就是x加第一个元素的长度,之后的以此类推。
如果索引值超过了数组的实际长度,那么程序就会读取不在数组位置的其他内存位置,而这个地方的值不一定是什么。在C中完全没有边界检查。在C++中普通数组没有,只有std::array有;在Rust里强制边界检查
| 特性 | C | C++ | Rust |
|---|---|---|---|
| 内存模型 | 连续 | 连续 | 连续 |
| 安全性 | 无边界检查 | std::array有边界检查,普通数组无 | 强制边界检查 |
| 动态数组支持 | 需要手动管理内存 | std::vector | Vec |
| 多维数组支持 | 是 | 是 | 是 |
| 特殊能力 | 简单高效 | STL容器丰富 | 所有权和借用检查 |
但Rust只会对数组进行简单的边界检查,如果将代码写的稍微复杂一点,Rust就无法在编译时检查,只能在运行时进行检查。
#![allow(unused)]
fn main() {
let a = 5;
let machine = [6, 6, 5, 7];
let wjq = machine[a];
}
这个代码Rust会在编译时报错
#![allow(unused)]
fn main() {
let a = [1, 9, 10, 4, 5];
let machine = [6, 6, 5, 7];
let wjq = machine[a[4]];
}
这个代码Rust就不会在编译时报错,但在运行时会报错
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是一个基于表达式的语言,下面所讲的语法大部分都跟Scale很像,因为两者都是以表达式为核心的编程模型。
- 语句是执行一些动作的指令
- 表达式会计算产生一个值,表达式本身就是一个值
- 函数的定义也是语句
- 语句不返回值,所以不可以使用
let把一个语句付给一个变量
fn main(){
let x = (let y = 6);//Error: expected expression, found in statement(`let`)
}
在这个例子中,Rust编译器期待右边是一个表达式,但它发现了右边实际上是一个语句,所以就会报错。有些语言中可以实现类似的写法,但在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 是一个表达式,而因为 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 up up!
println!("The value of wjq is:{}", wjq);
}
Rust还有一种很重要的文档注释,以后单独讲。
3.5 控制流:if else
3.5.0. 写在正文之前
欢迎来到Rust自学的第三章,一共有6个小节,分别是:
- 变量与可变性
- 数据类型:标量类型
- 数据类型:复合类型
- 函数和注释
- 控制流:
if else(本文) - 控制流:循环
通过第二章的小游戏(没看的初学者强烈建议看一下),相信你已经学会了基本的Rust语法,而在第三章我们将更深一层,了解Rust中的通用的编程概念。
3.5.1. if表达式的基本认识
- if表达式允许根据条件来执行不同的代码分支操作
- 这个条件必须是布尔类型。这点不同于Ruby、JS和C++,它们会把if后的非布尔类型转换为布尔类型
- 条件可以是一个字面值、一个表达式或是一个变量
- if表达式中,与条件相关联的代码就叫做分支(在讲
match时就有提到过这个概念) - 可选的,在后面可以加上一个else表达式
fn main(){
let machine = 6657;
if machine < 11451 {
println!("condition is true");
} else {
println!("condition is false");
}
}
这个例子中,machine这个变量的值是小于11451 的,所以程序会执行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 nor 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);
}
这个例子在编译时会报错:if` and `else` habe incompatible types,就是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!("Huston, 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!
Huston, 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循环简洁紧凑,它可以针对集合中的每个元素来执行代码。由于for循环的安全性、简洁性,所以它在Rust中使用最多
2. .enumerate()
• 为迭代器的每个元素附加一个索引。索引从 0 开始,是一个usize类型的值。.enumerate() 将迭代器的每个元素包装成 (index, value) 的形式,其中:index 是元素在集合中的索引。value 是当前迭代器指向的元素。.enumerate() 返回一个新的迭代器,迭代出的元素类型是 (usize, &T),其中 T 是集合中元素的类型。在这里,numbers 是一个Vec<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由标准库提供。用户可以通过Range生成它们之间的数字(不含结束)。使用rev方法可以反转Range。
fn main() {
println!("Rocket Launch Countdown:");
for countdown in (1..=10).rev() {
println!("T-minus {}...", countdown);
}
println!("🚀 Liftoff!");
println!("Huston, we have a problem.");
}
这个例子使用for循环、Range和rev实现了上文while函数所实现的火箭倒数
代码解析
- (1..=10):
- 这是一个
Range,表示从 1 到 10(包含 10)。 - ..= 是包含上限的范围操作符。
- 这是一个
- .rev():
- 反转迭代器,生成一个从 10 到 1 的递减序列。
4.1 所有权:栈内存 vs. 堆内存
4.1.0 写在正文之前
在学习了Rust的通用编程概念后,就来到了整个Rust的重中之重——所有权,它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有三小节:
- 所有权:栈内存 vs. 堆内存(本文)
- 所有权规则、内存与分配
- 所有权与函数
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)。
添加数据叫压入栈(压栈),移除数据叫弹出栈(出栈)。
所有存储在栈内存上的数据必须拥有已知的固定的大小。 相反的,编译时大小未知的数据或是运行时大小可能发生变化的数据必须存放在堆内存上。
2. 堆内存
堆内存的的内存组织性差一些。当把数据放入堆内存时,会请求一定的空间。操作系统会在堆内存中找到一块足够大的空间,把它标记为在用,并返回一个指针,也就是这个空间的地址。这个过程叫做在Heap上进行内存分配,有时简称为“分配“。
3. 指针与内存
因为指针是固定大小的,可以把指针放在栈内存上。但如果想要指针所指向的具体数据时,就必须得使用指针所指向的地址来访问它。、
把数据压到栈内存上比在堆内存上分配要快得多:
-
在栈内存上,操作系统不需要寻找用来存储新数据的空间,那个位置永远都在栈内存的顶端(栈内存的末尾位置,也就是当前可用的栈内存的起始位置)。
-
在堆内存上分配空间则需要做更多的工作:操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录方便下一次的分配。
4.1.4. 访问数据
访问栈内存中的数据要比访问堆内存中的数据快,因为需要通过指针才能找到堆内存中的数据,多了指针跳转这么一个环节,它属于间接的访问。而对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的数据越少,那么速度就越快。
如果数据存放的距离比较近,那么处理器的处理速度就会更快一些,例如放在栈内存上;反之,如果数据之间距离较远,那么处理速度就会慢一些,例如放在堆内存上(在堆内存上分配大量的空间也是需要时间的)。
4.1.5. 函数调用
当代码调用函数时,值被传入函数(也包括指向堆内存的指针)。函数本地的变量被压在栈内存上。当函数结束后,这些值会从栈内存上弹出。
4.1.6. 所有权存在的原因
所有权解决的问题:
- 跟踪代码中分配的堆内存空间,换句话说就是跟踪代码的哪些部分正在使用堆内存的哪些数据
- 最小化堆内存上的重复数据量
- 清理堆内存上未使用的数据以避免空间不足
一旦懂了所有权,就不用经常地去想堆内存和栈内存了。但是知道管理堆内存数据是所有权存在的原因有助于解释它为什么会这样工作。
4.2 所有权规则、内存与分配
4.2.0 写在正文之前
在学习了Rust的通用编程概念后,就来到了整个Rust的重中之重——所有权,它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有三小节:
- 所有权:栈内存 vs. 堆内存
- 所有权规则、内存与分配(本文)
- 所有权与函数
4.2.1. 所有权规则
所有权有三条规则:
- 每个值都有一个变量,这个变量是该值的所有者
- 每个值同时只能有一个所有者
- 当所有者超出作用域(scope)后,这个值将会被删除
4.2.2. 变量作用域
作用域(scope)就是程序中一个项目的有效范围
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的副本,把x的副本绑定到y上。由于整数是已知且固定大小的简单的值,所以这两个5被压到了栈内存中。
但如果情况更加复杂,比如说是String类型时,情况又会有所不同。
#![allow(unused)]
fn main() {
let machine = String::from("Niko");
let wjq = machine;
}
在这个例子中,第一行通过String下的from函数从字符串字面值得到一个String类型的值叫machine。然后第二行把machine绑到wjq上。
虽然代码很相似,但两者的运行方式是完全不一样的。
首先我们得了解,一个String类型由三个部分组成(如下图所示):

- 一个指向存放字符串内容的内存的指针(pointer)
- 一个长度
- 一个容量
这部分数据被压到了栈内存中,而存放字符串内容的部分在堆内存中,长度(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'
学习过其他语言的人可能接触过浅拷贝(shallow copy)和深拷贝(deep copy)。有些人会把这种复制指针、长度和容量视为浅拷贝,但由于Rust让machine失效了,所以这里使用新的术语:移动(Move)
这里隐藏了一个设计原则:Rust不会自动创建数据的深拷贝。也就是说,就运行时的性能而言,任何自动赋值的操作都是廉价的。
2. 克隆(Clone)
如果真想对堆内存上的String数据进行深度拷贝,而不仅仅是栈内存上的数据,那么可以使用clone方法。
#![allow(unused)]
fn main() {
let machine = String::from("Niko");
let wjq = machine.clone();
}
通过这种方法,无论是栈内存还是堆内存都被完整的复制了一份

但是克隆这种操作是比较消耗资源的,所以要谨慎使用。
3. Stack上的数据:复制
对于Stack上的数据,克隆是不需要的,复制就可以。
#![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
对于元组(Tuple),如果其中所有的元素都是能实现Copy trait的,那么这个元组就可以的;如果其中但凡有一个不能实现Copy trait,那整个元组就不能。
(i32, u32)可以实现Copy trait(i32, String)不能实现Copy trait,因为String不能实现Copy trait
4.3 所有权与函数
4.3.0 写在正文之前
在学习了Rust的通用编程概念后,就来到了整个Rust的重中之重——所有权,它跟其他语言都不太一样,很多初学者觉得学起来很难。这个章节就旨在让初学者能够完全掌握这个特性。
本章有三小节:
- 所有权:栈内存 vs. 堆内存
- 所有权规则、内存与分配
- 所有权与函数(本文)
4.3.1. 把值传递给函数
在语义上,把值传递给函数和把值赋给变量是类似的,所以一句话总结:函数参数传递跟赋值操作是一样的
接下来详细解释一下:把值传递给函数将会发生移动(Move)或者复制(Copy)
- 对于实现了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函数时,发生了移动(Move),即所有权从变量machine转移到了函数参数some_string。 - 此时,
machine的所有权被转移,函数wjq可以正常使用它,但原来的变量machine不再可用。如果尝试在之后使用machine,编译器会报错。
-
对于变量
x:i32是一种基本数据类型,大小固定,分配在栈上,并且实现了 Copy trait。- 当
x被传递给wjq_copy函数时,发生了复制(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的作用域后不会被释放,因为它的所有权已交给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, uszie) {
let length = s.len();
(s, length)
}
在这个例子中,s1不得不把所有权交给s,但这个函数在返回时把s也原封不动地返回,把数据所有权交给了s2,这样做就把数据所有权又交给了main函数里的变量,使得s1下的数据又能够在main函数中使用(虽然换了个变量名)。
这种做法太麻烦,也太笨了。 Rust针对这种场景有一个特性叫引用(Reference),让函数使用某个值而不获得其所有权。 这个特性将会在下篇文章中讲。
4.4 引用与借用
4.4.0 写在正文之前
这一节的内容其实就相当于C++的智能指针移动语义在编译器层面做了一些约束。Rust中引用的写法通过编译器的约束写成了C++中最理想、最规范的指针写法。所以学过C++的人对这一章肯定会非常熟悉。
4.4.1. 引用
引用让函数使用某个值而不获得其所有权,声明时在类型前加上&即代表引用。例如String的引用就是&String。如果学过C++的话,C++中的解引用符号是*,Rust中也是一样的。
学了引用之后,就可以把上一篇文章最后的示例代码给简化
这是先前的代码:
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, uszie) {
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实际上是一个指针,指向s所在栈内存位置(不会直接指向堆内存中的数据)。这个指针在走出作用域时,Rust并不会消除其指向的数据(因为s没有所有权),只会弹出栈上所存储的指针信息,也就是释放下图中的最左侧的部分所占的内存。

这种以引用作为函数的参数叫做借用
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
报错的原因在于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时把s1声明为可变变量)
这种可以修改数据内容的引用就叫做可变引用
4.4.3. 可变引用的限制
可变引用有两个非常重要的限制,其一是:在特定作用域内,对某一块数据,只能有一个可变的引用。
以这个代码为例:
fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &mut s;
}
因为s1和s2都是指向s的可变引用,且在同一个作用域内,所以在编译时会报错:
error[E0499]: cannot borrow `s` as mutable more than once at a time
这么做的目的是防止数据竞争,以下三种条件同时满足时会发生数据竞争:
- 两个或多个指针同时访问同一个数据
- 至少有一个指针用于写入数据
- 没有使用任何机制来同步对数据的访问
在报错信息中提及了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;
}
因为s1是可变引用,s2是不可变引用,两者出现在同一个作用域指向同一块数据,所以编译器会报错:
error[E0502]: cannot borrow `s` as mutable because it also borrowed as immutable
当然,多个不可变的引用是可以同时出现的。
总结:多个读(不可变引用)是可以同时存在的,多个写(可变引用)可以存在但不能同时,多个写和同时读写是不允许的。
4.4.4. 悬空引用(Dangling References)
在使用指针时非常容易引起叫做悬空指针(Dangling Pointer) 的错误,其定义为:一个指针引用了内存中的某个地址,而这块内存可能已经释放并分配给其他人使用了。
如果你引用了某些数据,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的数据,返回的引用指向的是已经被释放的内存地址,变成了悬空引用(Dangling Pointer)。
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.clean();这行来清空s,此时的word_index这个变量就没有意义了;也可以说,Rust编译器发现不了代码使用了s.clean()但word_index仍然存在的错误,如果你在之后的代码中还使用了word_index去打印字符,那显然就会发生错误。
这类的API(或者叫函数设计)要求随时关注word_index的有效性,确保这个索引和这个String变量s它们之间的同步性。偏偏这类工作往往相当繁琐而且特别容易出错,所以针对这类问题Rust提供了字符串切片。
字符串切片是指向字符串中一部分内容的引用。
在原字符串名前加上&代表对它的引用,在后加上[开始索引..结束索引],表示引用这个字符串的一部分。注意,[]内的区间是左闭右开,所以结束索引是切片终止位的下一个索引值。顺口溜:包左不包右。
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边界内 - 如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出
重写代码
学了切片之后,就可以修改文章开头的代码来进一步优化了:
fn main() {
let 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表示字符串切片
这个时候如果在word = first_word(&s);这一行之后加上s.clean();,Rust就能够发现错误并报错:
error[E0502]:cannot borrow `s` as mutable because it is also borrowed as immutable
因为在同一个作用域中出现了可变引用s.clean()和不可变引用&s,违反了借用规则
PS:s.clean()等价于clean(&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。
定义函数时使用字符串切片来代替字符串引用会使APU更加通用,且不会损失任何功能。
根据它,还可以再进一步地优化之前的代码:
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命名(驼峰命名法) - 在花括号内,为所有字段(Field) 定义 名称 和 类型
例子:
为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,需要创建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
其中文名叫做元组结构体,指的是类似元组的结构体。元组结构体整体有名字,但里面的元素没有。适用于想给整个tuple起名,并让它不同于其他tuple,而且又不需要给每个元素起名。
定义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);
}
有的人戏谑地说:tuple struct在传统编程语言没有类似物,这是来自Haskell的高贵血统。这是因为在许多传统的面向对象语言(如 Java、C++)中,结构体或类是具名且字段命名的,而元组则是匿名且仅基于顺序的。没有中间形式来融合两者的优点。Rust的 tuple struct 概念与Haskell的新类型(Newtype Pattern) 有直接关系,Haskell中可以通过newtype来定义类似的模式。
需要注意的是,即使两个元组结构体有相同数量的元素并且对应元素的数据类型都一样,它们也不该被称为相同的类型,因为它们是不同的struct。
5.1.9. 类单元结构体Unit-Like Struct
unit-like struct被称为类单元结构体,因为它们的行为类似于单元类型()。当需要类型标记或是在某种类型上实现trait(可以理解为接口)但不想要在类型本身中存储任何数据时。类似于Go语言中的interface{}。
struct ReadOnly;
struct WriteOnly;
fn process_data<T>(_mode: T) {
// 仅用于类型标记
}
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使用例(加打印调试信息)
对不起我都写到第8章了才发现我忘记发这一篇了,现在补上,不过这可能导致专栏的文章顺序有一点问题,但也只能将就着了。
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解法
元组解法虽然提升了可维护性,但代码的可读性变差了,因为如果不加注释没人知道元组的第一个数据是代表长还是代表宽(虽然对于计算面积来说无所谓,但是对于较大的项目来说很重要)。元组的元素是没有名字的,即使是元组结构体(上一篇文章中有讲),它里面的元素也是没有名字的。
那么那种数据结构可以把两个数据整合到一起并且分别赋名呢?没错,就是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); //直接打印实例
}
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
|
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
先解释一下报错:println!这个宏它可以执行很多格式化的打印。占位符{}就是告诉println!来使用std::fmt::Display这个trait(理解成接口),类似于Python的toString,而在报错信息中提到的就是Rectangle并没有实现std::fmt::Display这个trait,也就不能打印。
实际上,目前所讲的基础数据类型,默认都实现了std::fmt::Display这个trait,因为它们的展示方式都比较单一,比如说把1打印出来,那程序只可能打印出阿拉伯数字1。但是对于Rectangle,它里面有2个字段,是要都打印,还是打印width,还是打印length呢?可能性太多了,所以Rust并没有为struct默认实现std::fmt::Display这个trait。
但如果我们继续往下看到这一行:
= 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); //把`{}`改为`{:?}`
}
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 `{:?}`
|
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
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。这里使用前一种(后一种下一篇文章会讲):
#[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本身包含了打印调试信息的功能(也就是debug信息的功能),但必须为自己代码中的结构体显式地选择这一功能,所以要在定义结构体前加上#[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); // 使用 Debug 特质打印 Point
assert_eq!(point1, point2); // 使用 PartialEq 特质比较两个 Point
}
在这个例子中:
#[derive(Debug)]允许你使用{:?}格式化规范来打印Point结构体的实例。#[derive(Clone)]允许你创建一个Point实例的副本。#[derive(PartialEq)]允许你比较两个Point实例是否相等。
5.3 struct的方法(Method)
5.3.1. 什么是方法(Method)
方法和函数类似,也是用fn关键字进行声明,方法也有名称,也有参数,也有返回值。但方法和函数也有不同之处:
- 方法在struct(或枚举或trait对象)的上下文中定义
- 方法的第一个参数总是
self,表示方法所在的(被调用的)struct实例,类似于Python中的self和JS中的this。
5.3.2. 方法的实际应用
接下来还是看例子,以上一篇文章的代码为例:
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这个名字就要混淆。如果改名成ractangle_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地址无非就只有2种——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.3. 将数据附加到枚举的变体中
枚举类型是一种自定义的数据类型,所以它可以作为结构体里面字段的类型,例如:
#![allow(unused)]
fn main() {
struct IpAddr {
kind: IpAddrKind,
address: String,
}
}
IpAddr下的Kind的类型是IpAddrKind,存储网络协议;它的另一个字段address是字符串类型,存储具体的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类型。
这种做法的优点是:
- 不需要额外使用struct
- 每个变体可以拥有不同的类型以及相关联的数据量
比如说:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
IPv4实际上是由4个32位的数字(也就是u8的容量)组成的,而IPv6是字符串,所以就应该使用String。如果我们想要将V4地址存储为四个u8值,但仍将V6地址表示为一个String值,我们将无法使用结构体。枚举可以轻松处理这种情况。
我们来重写一下6.1.3中的代码:
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.4. 标准库中的IpAddr
事实上,标准库中就提供了关于IP地址的枚举类型,看一下官方是怎么写的
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Ipv4Addr和Ipv6Addr的内容这里没有写出来,但这不是重点。重点是此代码说明任何类型的数据放入枚举变体中都是可以的:例如字符串、数字类型或结构。甚至可以包含另一个枚举。
6.1.5. 在枚举类型使用方法(Method)
方法(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值。
在主函数里声明了变量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试图表达的概念(Null是当前无效或由于某种原因不存在的值)Rust提供了类似的枚举叫Option<T>。
6.2.3. Option<T>
它在标准库中的定义是这样的:
#![allow(unused)]
fn main() {
enum Option<T>{
Some(T),
None,
}
}
Some这个变体可以关联某些数据,其数据类型就是T。<T>实际上是泛型参数(以后会讲)None是其另外一个变体,但不会关联任何数据,因为它代表的是值不存在的情况
因为它包含在预导入模块,所以可以直接使用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<&str>,当然你也可以显式声明,但没必要,除非你想指定某个类型。 - 对于最后语句,由于赋的值是
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>`:
`&i8` implements `Add<i8>`
`&i8` implements `Add`
`i8` implements `Add<&i8>`
`i8` implements `Add`
报错内容的意思就是无法把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枚举。 -
然后是花括号,花括号里有4个分支(英文叫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. 绑定值的模式
匹配的分支可以绑定到被匹配对象的部分值,通过这个功能,就可以从枚举类型的变体中提取值。
看个例子:一位朋友正在尝试收集全部 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美分硬币(以下都叫Quarter)所对应的Coin里的变体,也就是
Coin::Quarter给关联一个数据,它关联的数据就是上面的这个枚举类型UsState。 -
在
value_in_cents函数里也需要对Quarter所在的分支稍微修改一下,匹配模式从Coin::Quarter修改到Coin::Quarter(state),意思就是把Coin::Quarter所关联的值绑定到state这个变量上,在后面的代码块里面就可以使用state这个变量,把Coin::Quarter所关联的值取出来进行使用。 在某些情境下,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>
就以上一篇文章最后的代码例来做分析:
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:571:1
|
571 | pub enum Option<T> {
| ^^^^^^^^^^^^^^^^^^
...
575 | 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; // 使用外部库
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
match v {
0 => println!("zero"),
_ => println!("not zero"),
}
}
u8有256个数256种可能,使用match自然是不可能每个数都写一个分支,所以,就可以为0写一个分支,其他的使用通配符_来代替。
输出:
136
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,是0就打印zero
use rand::Rng; // 使用外部库
fn main(){
let v: u8 = rand::thread_rng().gen_range(0..=255); // 生成随机数
println!("{}", v);
match v {
0 => println!("zero"),
_ => (),
}
}
这里只用分辨0和非0两种情况,在这种情况下使用if let就会更简单:
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,是0就打印zero,不是就打印not zero
use rand::Rng; // 使用外部库
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就行:
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的取舍
if let相比match有更少的代码,更少的缩进和更少的模版代码。但if let放弃了穷举的可能。
所以说使用if let还是match需要根据实际需求来决定,这之间存在简洁性与穷尽性的取舍问题。
6.4.5. if let 与 if 的区别
很多初学者搞不清if let与if的区别,因为好像if let能做的if也能做,但它们两个有本质上的区别:if let是模式匹配,if是判断语句。
if后面的条件只能是布尔类型,而if else是匹配是否符合某个具体的模式,适合从枚举、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_the_house的子module,front_of_the_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_the_house的子module,hosting下还定义了两个函数add_to_waitlist和seat_at_table。
在front_of_the_house的同一级中还声明了一个函数eat_at_restaurant,这个函数下就分别用绝对路径和相对路径调用了add_to_waitlist这个函数。
对于绝对路径,函数eat_at_restaurant与add_to_waitlist所在的front_of_the_house模块在同一个文件lib.rs里,也就是在同一个crate里(lib.rs的内容已经隐式地组成了crate这个模块,具体可以参考上一篇文章最后一部分)。所以说绝对路径就是从crate开始写起,逐级地写,用::分开每级的标识符:
#![allow(unused)]
fn main() {
crate::front_of_house::hosting::add_to_waitlist();
}
对于相对路径,由于函数eat_at_restaurant与add_to_waitlist所在的front_of_the_house模块在同一级,所以就可以直接从模块名起手写,依然是逐级地写,用::分开每级的标识符:
#![allow(unused)]
fn main() {
front_of_house::hosting::add_to_waitlist();
}
在实际项目中,使用绝对路径还是相对路径主要取决于你定义条目的代码(例子中的add_to_waitlist)和使用条目的代码(例子中的eat_at_restaurant)会不会一起移动而决定。如果这两部分一起移动,也就是两者的相对路径不会变,那么就使用相对路径;反之则需要用绝对路径。但大部分情况还是使用绝对路径,因为这样定义条目的代码和使用条目的代码就可以彼此独立地进行移动。
接下来我们运行一下代码:
error[E0603]:module `hosting` is private
不管是绝对路径调用还是相对路径调用都报了这个错误。这个错误的意思是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关键字加在stuct前就可以把结构体声明为公共的,如下例:
#![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(构造器中写的)。 下面一行中,因为Breakfast这个结构体是公共的所以meal.toast可以直接被更改,这里是改为了Wheat。
在eat_at_restaurant这个函数中写下meal.seasonal_fruit = String::from("buleberries");这一行会不会报错呢?答案是会的,因为结构体中的字段默认是私有的,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的使用惯例
在上面的例子中,我们导入模块只到了use这个层级,但调用的函数只有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);
}
使用标准库的collection模块下的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将路径导入作用域内后。该名称在词作用域内是私有的。
以上一篇文章的代码为例:
#![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_restuarant下的add_to_wait list外部代码是看不见的,因为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将特定条目引入到作用域。
还记得第二章的猜数游戏吗?那时候我们需要rand包来生成随机数,现在我们还是以引入rand包来举例:
Step 1:修改Cargo.toml
打开项目的Cargo.toml文件,在[dependencies]下写上rand这个包名和指定的版本,中间用=连接,如下:
[package]
name = "RustStudy"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8.5"
Step 2:在源代码中引入包
你想用包下的什么东西就用use指定对应的路径来引入即可。这里我需要生成随机数的函数,所以就引入这个函数的父级模块Rng,引入这行的代码如下:
#![allow(unused)]
fn main() {
use rand::Rng;
}
Rust语言的标准库std也被当作是外部的包,但是它已经内置在Rust语言内了,所以就不需要在Cargo.toml里增加依赖项了,直接在源代码中用use引入就行,这有点类似于Python中的re、os、ctype这类库。
比如说我们想要引入std下的collectiond模块的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::Odering;
use std::io;
}
它们有公共的部分std,所以就可以用嵌套路径写为:
#![allow(unused)]
fn main() {
use std::{cmp::Odering, 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的网站在国外,所以国内下载很慢,可以换成清华大学镜像。
打开Windows Terminal(Win11自带,Win10需要去微软商店里下载,不花钱),先找到你的项目所在的文件夹路径,然后输入指令,回车:
cd 你的文件夹路径
然后在下面建立一个config配置文件,输入如下指令,回车:
touch config
编辑它:输入如下指令,回车:
vim config
把这段贴进去:
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'tuna'
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
[net]
git-fetch-with-cli = true
把光标(不是鼠标指针!)下移,从
移到
然后输入
:wq
再按回车就会保存。
然后再重新build你的项目就可以。
7.6 将模块拆分为不同文件
7.6.1. 将模块的内容移动到其他文件
如果在模块定义时模块名后边跟的是;而不是代码块,Rust就会在src目录下找与模块同名的.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所在的原处,这个时候就不用后面的代码块了,把它连着{}都删去,加上;即可(其它的无关代码不要动),原本代码是:
#![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();
}
}
改成:
#![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. 子模块的拆分
需要注意的是,如果想把模块下的子模块拆出来,就需要新建父模块的同名文件夹,在这个文件下方.rs文件用于存储子模块或是条目。
举个例子,如果我要把hosting独立出去成一个单独的文件,操作不仅仅是创建一个同名.rs文件,而是需要先新建一个父模块的同名文件夹,在这个例子中父模块的名字是front_of_house,所以就要创建名字为front_of_house的文件夹。
然后再在这个文件夹下创建与条目名/模块名相同的.rs文件,在这个例子中是要把hosting独立出去,所以这个文件应该叫做hosting.rs。

在hosting.rs里存储hosting的内容,也就是:
#![allow(unused)]
fn main() {
pub mod hosting {
pub fn add_to_waitlist() { }
}
}

如果不想要同名的文件和文件夹,可以把文件放进文件夹并把文件改名为mod.rs!!!
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这个函数声明了一个Vector里边元素是i32的变量。 (常用)let v = vec![1, 2, 3]: 指定初始值,创建Vector。这样的情况就可以使用vec!这个宏,这里在声明变量时已经把1, 2, 3填进了向量。当然只使用vec![]不填内容也是可以的。 (常用)let v = Vec::with_capacity(10): 创建空 Vector,但提前分配至少 n 个元素的容量。适合你已经知道大概要放多少元素,能减少扩容次数,提高性能。
第一种方法(Vec::new())需要显式声明类型为Vec<i32>,是因为Vec::new()它创建的是一个空的Vector,里面没有元素,又因为这个例子里没有前后文供Rust推断,所以Rust就推断不出来这个变量里的元素是什么类型的,就会报错。如果有前后文供Rust推断,Rust就能够自行判断Vector里的元素类型。
第二种方法(vec![])就不需要显式声明Vector里的元素类型了,因为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);
let a = vec![1, 2, 3];
let b = vec![4, 5, 6];
a.append(&mut b);
}
v.extend([1, 2, 3]): 批量添加元素v.insert(1, 99): 在指定位置插入,后面的元素整体后移。如果下标越界会panic。a.append(&mut b): 把另一个 Vector (b) 的所有元素移动到当前 Vector (a) 尾部,b用完后会变空。
2. 删除Vector / Vector里的元素
pop方法: 删除尾部元素,并返回它(被Option包裹, 在6.2. Option枚举中讲过,这里不再赘述)。如果空了,返回 None。
fn main() {
let mut v = vec![1, 2, 3];
let x = v.pop();
}
remove(index)方法: 删除指定位置的元素,并返回它。后面的元素会前移。index越界会 panic。
fn main() {
let mut v = vec![1, 2, 3];
let x = v.remove(1);
}
clear方法: 清空所有元素。长度变成 0,但容量通常还在。
fn main() {
let mut v = vec![1, 2, 3];
v.clear();
}
与任何其他的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的位置,所以[]内写2。而在变量v前加上&表示是引用。v.get(2)就是使用get方法来实现读取的,但由于get的返回值是Option枚举类型,所以要使用match表达式(在6.3. 控制流运算符-match中讲过match)来解包。如果能从这个索引取到值,那么就会把这个索引下的值绑定给third这个变量,然后在后面的代码块中输出。如果不能,返回的是None这个变体,就会打印“There is no third element.“。
这两者的实现方法比较不同,效果是一样的。但如果是非法的访问(比如访问的索引越界了,超过了实际Vector的长度),两种将会有一些区别。
先试试使用索引:
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third = &v[100]; //索引100越界了
println!("The third element is {}", third);
}
输出:
index out of bounds: the len is 5 but the index is 100
程序触发了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.3. 遍历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变为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)和字符串(String)。
在main函数中,声明了变量row用于存储一行的单元格,因为一行的单元格数量不确定,所以需要Vector来存储,在这里初始化时有三个单元格,第一个存储整数6657,第二个放了字符串“up up“,第三个放了浮点数114.514。
通过这个例子,我们可以看到通过使用可附加数据的枚举类型,就可以变相地在Vector里存放不同类型的数据。
那么Rust为什么在编译的时候就需要知道Vector里的元素的类型呢?因为这样Rust才能确定堆内存上到底需要多少内存来容纳这个Vector。除此之外,如果允许在Vector上存储不同类型的元素,那么在对Vector上的元素进行批量操作时,有些操作可能在某些类型上是合法的而在某些类型上不是,程序就会出错。而这种枚举类型配合match表达式的方式使得Rust能在编译时提前知晓所有的可能情况,在运行时就可以正确处理了。
在这个例子上Vector实现了存储不同的数据类型,但前提条件是我们必须知道到底有哪些数据类型(或者叫知道详尽的数据类型),否则的话,如果这个类型有无限种可能(或者叫不详尽),那么使用枚举也没有办法,连枚举都定义不出来。针对这种情况,Rust提供了trait,但这个得等到后面讲了。
8.3 String类型 Pt.1:字符串的创建、更新与拼接
8.3.0. 本章内容
第八章主要讲的是Rust中常见的集合。Rust中提供了很多集合类型的数据结构,这些集合可以包含很多值。但是第八章所讲的集合与数组和元组有所不同。
第八章中的集合是存储在堆内存上而非栈内存上的,这也意味着这些集合的数据大小无需在编译时就确定,在运行时它们可以动态地变大或变小。
本章主要会讲三种集合:Vector、String(本文) 和HashMap
8.3.1. String对开发者造成的困扰
Rust开发者(尤其是新手)会经常被字符串困扰,原因如下:
- Rust倾向于暴露可能的错误
- 字符串数据结构复杂
- Rust字符串使用了
UTF-8编码
8.3.2. 字符串是什么
字符串是基于字节(Byte)的集合,并且它提供了一些方法,这些方法能将字节解析为文本。
在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)
由于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而不是&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}`, which is required by `String: Index<_>`
= 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>
= help: the trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
报的错是类型String无法使用整数来进行索引,继续往下看到=help这一行,这里提示了String这个类型没有实现index<{integer}>(index是索引的意思,integer是整数的意思)这个trait。
8.4.2. String类型的内部表示
String类型是对Vec<u8>的包装,u8也就是byte字节。我们可以通过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标量值,而西里尔字母每个Unicode标量值都对应两个字节。
通过这个例子你可以发现,String的数字索引并不能总是对应道一个完整的Unicode标量值,因为有的Unicode标量值会占不止一个字节,而数字索引注定只能读取到一个字节的值。
再举个例子,西里尔语里的З(不是数字)这个字母对应的是两个字节,而这两个字节的值分别是是208和151。假如说数字索引是允许的,那么我取Здравствуйте的索引0的值就会是208,而208本身又是无意义的字符(因为缺少第二个字节组不成一个Unnicode标量值)。所以为了避免这种无法立即发现的bug,Rust封杀了数字索引String,也就是在开发的早期阶段杜绝可能的误解。
8.4.3. 字节、标量值、字形簇
Rust中有三种看待字符串的方式:字节(Bytes)、标量值(Scalar Values)和字形簇(Grapheme Clusters)。其中字形簇是最接近我们说说的字母的概念的。
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()方法能够获得这段字符串所对应的标量值,输出如下:
न म स ् त े
它有4个实际的字母,而第四个和第六个标量值代表的是音标,单独存在没有任何意义,得于前面的东西放在一起算是一个字母。
这里也解释了为什么这个梵文实际上有18个字节,因为一个梵文占3个字节,这段字符串加上隐藏着的音标一共6个字符,把这两个数字相乘可以得到18这个数字,也就是18个字节。
3. 字形簇
因为从String里获得字形簇很复杂,所以Rust标准库没有提供这个功能,这里也就不做演示,但是可以去crate.io找第三方的库来实现这个功能。
总之,这串梵文如果以字形簇的格式打印出来会是:
这个样子。
8.4.4. 不能使用索引来访问String的原因
- 数字索引取出来值的可能并不完整,无法组成一个Unicode标量值,导致无法第一时间察觉的错误
- 索引操作会消耗一个常量时间,也就是O(1),而
String无法保证这个时间,因为它需要从头到尾遍历所有内容从而确定有多少个合法的字符。
8.4.5. 切割String
可以使用[],在里面填上范围来创建字符串切片(关于字符串切片的详细内容在4.5. 切片(Slice),这里不再赘述)。如下例:
fn main() {
let hello = String::from("Здравствуйте");
let s = &hello[0..4];
println!("{}", s);
}
刚才也说了一个西里尔字母占两个字节,这里的字符串切片切的是字符串的前4个字节,也就是前两个字母,看一下输出:
Зд
那如果字符串切片切的是2前三个字节呢?也就意味着切片的内容会是第一个字母加上半个第二个字母,这种情况会怎么样呢?看下面的例子:
fn main() {
let hello = String::from("Здравствуйте");
let s = &hello[0..3];
println!("{}", s);
}
输出:
byte index 3 is not a char boundary; it is inside 'д' (bytes 2..4) of `Здравствуйте`
程序触发了panic!,错误信息是:索引3不是一个char边界。也就是说在切割的时候必须沿着char的边界来切割,对于这个西里尔语言来说就是2个2个字节地切割。
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);
}
}
- 对于字形簇,标准库未提供方法,但是可以找第三方库。
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的内部实现使用了Hash函数,中文叫哈希函数,这个函数决定了如何在内存中存储键与值。
在Vector中我们使用索引来访问数据,但有的时候你想要的是通过键(键可以是任何数据类型)来寻找数据,而不是通过索引(或者说你不清楚这个数据在哪个索引)。这种情况就可以使用HashMap。
需要注意的是,HashMap是同构的,也就是说在一个HashMap中,所有的键必须是同一类型,所有的值必须是同一类型。
8.5.2. 创建HashMap
- 由于HashMap不常用,所以Rust并没有把它集成到预导入模块(Prelude),使用前需要引入HashMap,在代码开头写上:
use std::collections::HashMap; - 创建空的HashMap使用
Hash::new()函数 - 添加数据使用
insert()方法
看个例子:
use std::collections::HashMap;
fn main() {
let mut scores:HashMap<String, i32> = HashMap::new();
}
在这里创建了一个名为scores的变量来存储HashMap,由于Rust是强语言类型,它必须知道你在HashMap里存储什么数据类型。又因为没有前后文可供编译器推断,所以在声明时就必须把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>类型的HashMap,因此第四行在声明时就不需要显式声明了。
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.iter().zip(initial_scores.iter()).collect();
}
player这个Vector是用来存储选手名字的,里面的元素是String类型initial_scores这个Vector是用来存储每个选手对应的得分的player.iter()和initial_scores.iter()是两个Vector的遍历器,使用.zip()方法就可以创建一个元组的数组,player.iter().zip(initial_scores.iter())就是创建一个player中的元素在前,initial_scores中的元素在后的元组数组,想换元素位置就可以把代码中的两个迭代器呼唤位置即可。然后再使用.collect()方法来把元组转化为HashMap。- 最后要注意的一点是
.collect()它支持转换为很多数据结构,如果声明时不显式声明其类型程序就会报错,这里就指明了类型是HashMap<_, _>,<>中的两个数据类型编译器可以根据代码(也就是找两个的Vector的数据类型)来推断,所以这里可以写_占位符让它自行推断。
8.5.4. HashMap和所有权
对于实现了Copy trait的数据类型(例如i32在内的绝大多数简单数据类型),值会被复制到HashMap中,原先的变量仍然可用。对于没有实现的(例如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。
输出:
Zywoo: 100
dev1ce: 0
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提供了or_insert方法,其参数是想要添加的值,它可以接收Entry枚举类型,根据这个类型的两个变体来实现是否插入的功能。如果它接收到了OccupiedEntry(已经存在的情况)这个变体,它就会不插入值;反之,如果接收到了VacantEntry(不存在的情况)变体,它就不会执行插入操作。最重要的一点是,它是有返回值的,它的返回值是这个键对应值的可变引用(存在就返回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“,发现没有,就返回VacantEntry,or_insert接收到了,就根据VacantEntry所关联的键和传入的参数100创建了(“Zywoo”, 100)这个键值对。 - 第二个
entry语句查找了“dev1ce“,发现有了,就返回OccupiedEntry,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使用加密功能强大的Hash函数,可以抵抗拒绝服务(DoS)攻击。但这种函数它不是可用的最快的Hash算法,它的优势是具有较好的安全性。如果觉得它性能不好,也可以指定不同的hasher来切换到另一个函数。hasher指的是实现BuildHasher trait的类型。
9.1 不可恢复的错误以及panic!
9.1.1. Rust错误处理概述
Rust拥有极高的可靠性,这也延伸到了错误处理的领域。比如说在大部分情况下,Rust会迫使你意识到可能会出现错误的地方,然后在编译阶段就确保它们获得妥善的处理。
在Rust里,错误被分为两大类:
- 可恢复的错误:比如说文件未找到,就可以把错误信息传递给用户,并让用户再次尝试寻找这个文件。
- 不可恢复的错误:bug的另外一种说法,比如说索引越界
其他大部分编程语言都没有刻意地区分这两种错误,它们通常通过异常这种机制来统一处理。但Rust里没有类似异常的机制。
- 针对可恢复的错误,Rust提供了
Result<T, E>这个类型(下一篇文章会讲)。 - 针对不可恢复的错误,Rust提供了
panic!这个宏,当执行这个宏时,程序会立即中止执行。
9.1.2. panic!
有时候在代码里会发生糟糕的问题,而针对这些问题开发者是束手无策的。为了应对这种情况,Rust提供了panic!这个宏。
当这个宏执行时,会发生这些动作:
- 它会打印出错误信息
- 然后展开(unwind)和清理调用栈(Stack)
- 退出程序
9.1.3. 当panic!时,展开还是终止(abort)调用栈
程序在展开调用栈时工作量极大,因为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.realse的意思是在发布模式下运行时
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: rust_begin_unwind
at /rustc/90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf/library/std/src/panicking.rs:665:5
1: core::panicking::panic_fmt
at /rustc/90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf/library/core/src/panicking.rs:74: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来获得所有的详细信息,具体操作就是在终端输入set RUST_BACKTRACE=full && cargo run(对于Windows,对于MacOS/Linux:export RUST_BACKTRACE=full && cargo run`)。
为了获得像刚才这样的调试信息,还有一点前提条件,就是程序不能运行在发布模式(--release)下而是调试模式(debug)。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。泛型在第十章会讨论,现在只需要知道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,也就是io错误(失败的时候返回)
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关联的值,也就是这个新创建的文件呢的内容(内容肯定是空的,因为是新创建的)绑定在fc这个变量上返回赋给f;如果是Err(创建失败),就把Err所关联的错误信息绑定在e上用panic!打印出来并停止程序。
match确实用的比较多,但也比较原始,这里的套娃是其可读性大大降低(虽然对比其他语言可能可读性还要高一些)。而在第13章会讲一个概念叫做闭包(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方法,如果你有印象的话,在第一章的猜数游戏中就用过这种方法。
试试用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 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))。也就是说,第五行代码的效果等同于:
#![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::connvert::From这个trait,而它的作用是在错误之间进行转换,将一个错误类型转化为另外一个错误类型,而被?所接收的错误,会隐式地被from函数处理,from会看当前代码所在的函数的返回值的错误类型是什么,然后转换为什么。
就以刚才的代码为例,read_username_from_file函数的返回值是Result<String, io::Error>,from函数就看得出来函数需要io::Error作为发生错误时的返回值,就会把不同的错误类型转化为io::Error,这里只是碰巧所有的函数体内的错误类型都是io::Error,就不需要转化这一步。
这个特点用于针对不同的错误原因,返回同一种错误类型的情况非常有用。但前提条件是涉及到的错误类型实现了转换为所返回的错误类型的from函数就可以。
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类型的函数看个例子:
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: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
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. 你比编译器更了解情况
有时候你可以确定一个函数的调用返回的Result一定是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. 为验证创建自定义类型
以第二章讲的猜数游戏为例,有些代码不重要就跳过了:
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里的所有元素,如果当前元素大于最大值存储的值,那就把当前元素的值赋给最大值。
输出:
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(),但不影响代码功能,因为Vector实现了IntoIterator,所以它会隐式调用list.iter()。这两种写法的行为在语义上是完全等价的,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. 函数定义中的泛型
当使用泛型来定义一个函数的时候,需要将泛型的类型参数放在函数的签名里。而泛型的类型参数通常是用于指定参数和返回的类型。
以上一篇文章的代码为例,使用泛型稍作修改:
#![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`
|
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)也就是Some这个变体持有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,而针对Poinnt<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,mixup有两个泛型类型参数,一个叫V,一个叫W,方法的两个类型参数和Point的两个类型参数是不一样的,当然具类型也有可能是一样的。mixup的第二个参数是other,它的类型也是Point,但这个Point不一定和self所指向的Point的数据类型是一样的,所以需要另起2个新的泛型类型参数。再看看返回类型,是Point<T, W>,T来自Point<T, U>,W来自Point<V, W>。
看下main函数,首先声明了p1,它的两个字段都是i32;然后又声明了p2,它的两个字段分别是&str(字符串切片)和char(用''代表是单个字符)。接着使用了mixup这个函数,p1对应的是Point<T, U>,p2对应的是Point<V, W>,又根据各自的字段的类型可以推断出T是i32,U是i32,V是String,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相关的还有trait bounds(约束)的概念,它可以将泛型类型参数指定为实现了特定行为的类型。换句话说就是要求泛型的类型参数实现了某些triat。
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,里面有一个方法的签名叫summerize,除了&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表示新闻文章,它有4个字段:headline标题、location地点、author作者、content内容 - 结构体
Tweet表示推特(现在是X了)推文,它有四个字段:username用户名、content内容、reply是否有回复、retweet是否是转发
这两个结构体类型肯定不同,里面的字段大部分也不同。但是它们都可以有一个同样的行为——提取摘要Summary,所以就分别在这两个类型上实现Summary这个trait。
#![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());
}
记住在main函数里使用之前要先引入作用域,写法是:
#![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。比如说在本地库里为标准库的
Vector实现标准库的Displaytrait。 这个限制是程序属性的一部分(也就是一致性)。更具体的说是孤儿原则,之所以这么命名是因为它的父类型并没有定义在当前库中。这个规则确保了其他人的代码不能随意破坏你的代码,反之亦然。如果没有这个规则,两个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 summerize_author(&self) -> String
fn summarize(&self) -> String {
String::from("(Read more from {}...)", self.summerize_author())
}
}
}
在summarize的默认实现里调用了summerize_author,即使它只是一个签名,没有具体实现。但如果想要在类型上实现summerize的话就需要先写summerize_author的实现:
#![allow(unused)]
fn main() {
impl Summary for NewsArticle {
fn summerize_author(&self) -> String {
format!("@{}", self.author)
}
}
}
PS:由于NewsArticle的summarize使用的是默认实现,所以就不需要在这里写summerize的默认实现了
这个写法有一点注意:无法从方法的重写实现里面调用默认的实现
10.4 trait Pt.2:trait作为参数和返回类型、trait bound
说句题外话,写这篇的时间比写所有权还还花的久,trait是真的比较难理解的概念。
10.4.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上的summerize方法的返回值。
但这里有一个问题,它接收的参数是两个结构体,怎么样实现让参数可以是两个类型呢?
我们细想一下,这两个结构体的共同点是什么?没错,它们都实现了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());
}
}
这两种写法等价。
但是这种简单的写法看不出来trait bound的优势,再换一个例子。比如说,我要设计一个新的nnotify函数,叫它notify1吧,它接收两个参数,输出“Breaking news:“后面的内容是两个参数分别调用Summary上的summerize方法的返回值。
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());
}
}
前一种的函数签名显然比后一种的要跟好写也更直观。
而实际上,impl trait写法不过是trait bound写法的语法糖,所以impl trait写法不适合复杂情况也确实可以理解。
那么如果这个notify函数我需要它的参数是同时实现Display这个trait和Summary这个trait呢?也就是如果我有两个甚至两个以上的trait bounds该怎么写呢?
看例子:
#![allow(unused)]
fn main() {
pub fn notify_with_display<T: Summary + std::fmt::Display>(item: &T) {
println!("Breaking news! {}", item);
}
}
使用+号连接各个trait bound即可。
还有一点,由于Display不在预导入模块,所以写它的时候需要把路径写出来,也可以在代码开头先引入Display这个trait,也就是写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 bounds:
#![allow(unused)]
fn main() {
use std::fmt::Display
pub fn notify_with_display(item: &impl Summary + Display) {
println!("Breaking news! {}", item);
}
}
这种写法有一个缺点,如果trait bounds过多,那么写的大量约束信息就会降低这个函数签名的可读性。为了解决这个问题,Rust提供了替代语法,就是在函数签名之后使用where字句来写trait bounds。
看个使用普通写法的写多个trait bounds:
#![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) {
format!("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,
{
format!("Breaking news! {} and {}", item1.summarize(), item2.summarize());
}
}
这种写法跟C#很相似。
10.4.2. 把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类型和NewArticle,这时候编译器就会报错:
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) -> 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`
|
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: std::cmp::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];
| +
但报的错不一样了:无法从list里移动元素,因为list里的T没有实现Copy这个trait,下边的help说如果T实现了Clone这个trait,考虑克隆这个值。再下面还有一个help,说考虑使用借用的形式。
根据以上信息,有三种解决方案:
- 为泛型添加上
Copy这个trait - 使用克隆(得为泛型加上
Clone这个trait) - 使用借用
该选择哪个解决方案呢?这取决于你的需求。我想要这个函数能够处理数字和字符的集合,由于数字和字符都是存储在栈内存上的,所以都实现了Copy这个trait,那么只需要为泛型添加上Copy这个trait就可以:
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这个trait的思路就行不通。
那就试试克隆(得为泛型加上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
| move occurs 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这个trait,但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加&。
10.4.3. 使用trait bound有条件的实现方法
在使用泛型类型参数的impl块上使用trait boud,就可以有条件地为实现了特定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具体是什么类型,在Pair上都会有new函数,但只有T实现了Display和PartialOrd的时候才会有cmd_display这个方法。
也可以为实现了其它trait的任意类型有条件的实现某个trait。为满足trait bound的所有类型上实现trait叫做覆盖实现(blanket implementations)
以标准库中的to_string函数为例:
#![allow(unused)]
fn main() {
impl<T: Display> ToString for T {
// ......
}
}
它的意思就是对所满足display trait的类型都实现了ToString这个trait,这就是所谓的覆盖实现,也就是可以为任何实现了display trait的类型调用ToString这个trait上的方法。
以整数为例:
#![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. 生命周期的存在意义
生命周期存在的主要目的是为了避免悬空引用(Dangling reference,又叫悬垂引用),这个概念在4.4. 引用与借用 中有讲过,我把当时对悬空引用的解释粘到这来:
在使用指针时非常容易引起叫做悬空指针(Dangling Pointer) 的错误,其定义为:一个指针引用了内存中的某个地址,而这块内存可能已经释放并分配给其他人使用了。如果你引用了某些数据,Rust编译器保证在引用离开作用域前数据不会离开作用域。 这是Rust保证悬空引用永远不会出现的做法。
看个例子:
fn main() {
let r;
{ //小花括号
let x = 5;
r = &x;
}
println!("{}", r);
}
- 在这个例子中先声明了
r,但是没有初始化,这么做的目的是让r存在于小花括号外(看代码的注释的位置)的作用域。当然,Rust没有Null值,所以在r初始化前不能使用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. 借用检查器(borrow tracker)
借用检查器会比较作用域来判断所有的借用是否合法。对于刚才那个代码例,借用检查器发现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 {
| ++++ ++ ++ ++
编译器还是不清楚,因为函数类型体现不出来返回类型借用的值是来自x还是来自y。
所以这个事跟函数体里的逻辑没有关系,就是跟函数签名有关系,那该怎么改呢?我们其实可以按照报错信息里的帮助提示来改:
= 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和返回类型的生命周期是一样的。
“一样的”这个说法不太准确,因为x和y在main函数对应的实例的生命周期其实有一点差别,但这个内容下篇文章再讲。
先看看代码整体:
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描述多个泛型生命周期之间的参数的关系。
以上一篇文章的代码为例:
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的作用域所重叠的部分都是有效的。
这就是为什么在前一篇文章和本文的前面对于“相同的”这个词都使用了引号标注,因为它并不是字面意义上的相同,而是指重叠的部分。
下面来看一下生命周期标注是如何对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行。函数会寻找重叠的部分(或者说较短的那个生命周期),也就是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行,那么返回值的作用域也该是第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.指定生命周期参数的方式依赖于函数所做的事情
以上一篇文章的代码为例子:
#![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()
}
}
在这个函数里创建了一个String类型的result,然后调用result上的as_str方法返回字符串切片(&str),其实就是一个引用,然后就报错了:
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:13:5
|
13 | 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函数,里面先创建了一个String类型的novel然后通过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. 生命周期省略的三个规则
编译器使用3个规则在没有显式标注生命周期的情况下来确定引用的生命周期
- 规则1用于输入生命周期
- 规则2、3用于输出生周期
- 如果编译器在应用完3个规则后仍然有无法确定生命周期的引用,就会报错
- 这3个规则不但适用于函数或是方法的定义,也适用于
impl块
规则1: 每个引用类型的参数都有自己的生命周期。 单参数的函数就有1个生命周期,双参数的函数就有两个,以此类推。
规则2: 如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数。 就是单参数的生命周期只有1个,这个生命周期就是这个函数所有可能返回值的生命周期。
规则3: 如果有多个输入生命周期参数,但其中一个是&self或&mut self(也就是说是这个函数是方法),那么self的生命周期会被赋给所有输出的生命周期参数。
1. 成功例
规则讲完,看看例子:
#![allow(unused)]
fn main() {
fn first_word(s:&str) -> &str {
//...
}
}
把自己带入一下编译器,想想对于这个函数签名如何根据3条规则来找到省略的生命周期。
首先应用第一条规则——每个引用类型的参数都有自己的生命周期。这里只有一个参数,所以就只有一个生命周期。所以到这一步编译器推断出了:
#![allow(unused)]
fn main() {
fn first_word<'a>(s:&'a str) -> &str {
//...
}
}
由于只有一个输入生命周期,所以第2条规则在这里也适用——如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数。 所以输入生命周期就被赋予给了输出生命周期。到这一步编译器推断出了:
#![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 {
//...
}
}
这个函数签名有两个引用输入,返回类型也是引用。尝试用这3条规则:
首先应用第一条规则——每个引用类型的参数都有自己的生命周期。这里有两个参数,就有两个生命周期:
#![allow(unused)]
fn main() {
fn longest<'a, 'b>(x:&'a str, y:&'b str) -> &str {
//...
}
}
由于有两个引用参数,所以规则2不适用。
由于这个函数不是方法,所以规则3不适用。
应用完这3条规则后发现返回值的生命周期仍然无法确定,所以编译器就会报错。也就是说你必须显式声明生命周期。
10.8 生命周期 Pt.4:方法定义中的生命周期标注与静态生命周期
10.8.1. 方法定义中的生命周期标注
还记得在上一篇文章 10.7. 生命周期 Pt.3 中所提到的省略生命周期的三条规则吗:
规则1: 每个引用类型的参数都有自己的生命周期。 单参数的函数就有1个生命周期,双参数的函数就有两个,以此类推。
规则2: 如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数。 就是单参数的生命周期只有1个,这个生命周期就是这个函数所有可能返回值的生命周期。
规则3: 如果有多个输入生命周期参数,但其中一个是&self或&mut self(也就是说是这个函数是方法),那么self的生命周期会被赋给所有输出的生命周期参数。
在上一篇文章的代码例中我们应用了规则1和2,但是规则3没有,因为规则3只适用于方法。所以这里就来讲一下规则3,也就是方法定义中的生命周期标注。
方法需要一个结构体,而在结构体上使用生命周期实现方法,它的语法和泛型参数的语法一样(详见文章 10.7. 生命周期 Pt.3)。
在哪里声明和使用生命周期参数,取决于生命周期参数是否和字段、方法的参数或返回值有关。
结构体字段的生命周期名总是声明在impl关键字后面,然后在结构体名的后面进行使用。因为这些生命周期是结构体类型的一部分。
而在impl块内的方法签名中,引用必须绑定于struct字段引用的生命周期,或者引用是独立的也可以。此外,生命周期省略规则经常使得方法中的生命周期标注不是必须的。
多说无益,看个例子:
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>。
要注意的是第四行的两个<'a>一个都不能省略,但是level这个函数由于应用了省略生命周期标注的规则1和2,所以&self不需要加上生命周期标注。
然后再添加一个方法:
#![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. 静态生命周期
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里一个测试就是一个函数,它被用于验证非测试代码的功能是否和预期一致。
在一个测试的函数体里通常执行3个操作:
- 准备(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
Created library `adder` project
$ 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这个Attribute进行修饰,而并不是因为它是一个test模块(test module),因为test模块里也可以有普通的函数。
使用cargo test命令来运行测试:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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个通过(这里本来就只有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.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' 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!,也就是源代码中写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.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
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.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
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.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' 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这两个traits。所有基本类型和大部分标准库类型都实现了,只不过对于自定义的结构体和枚举来说就得自行实现这两个trait。
自行实现Display trait的例子:
use std::fmt;
// 定义一个结构体
struct Point {
x: i32,
y: i32,
}
// 为 Point 实现 Display trait
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// 定义格式化的输出
write!(f, "Point(x: {}, y: {})", self.x, self.y)
}
}
fn main() {
let p = Point { x: 10, y: 20 };
println!("{}", p); // 使用 Display 格式化输出
}
看个使用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.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' 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`
失败的时候它把两个值打印出来了,左值是4,右值是5。
现在4和5不相等,那么这时候把测试函数的assert_eq!改成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.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' 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.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' 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. 验证错误处理的情况
测试函数出了验证代码的返回值是否正确,还需要验证代码是否如预期的去处理了发生错误的情况。比如说可以编写一个测试来验证代码是否在特定情况下发生了panic!。
这种测试需要为函数额外增加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有一个存储u32类型数据的字段value,它有一个关联函数new用于创建一个Guess实例,但前提是传进new的参数大于1小于100,否则就要恐慌。 greater_than_100这个测试函数测试给new函数传入大于100的值,这时候应该发生恐慌,所以为这个测试函数添加了一个should_panic的attribute(属性),也就是写#[should_panic]。
测试结果:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
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 || 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::new(200);就不会恐慌,但是因为它添加了should_panic这个attribute,所以本应该恐慌的函数没有恐慌就会导致测试失败:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected
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.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' 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.。根据这个就可以纠错。
11.5 在测试中使用Result<T, E>
11.5.1. 测试函数返回值为Result枚举
到目前为止,测试运行失败的原因都是因为触发了panic,但可以导致测试失败的方式也不止它。
使用Result枚举的测试函数也比较好写,只需要接收被测试函数的返回值,符合期待的就返回Ok变体,反之就返回Err变体,又因为枚举类型允许变体附带数据,所以还可以在Err上附带一些错误信息来更好的词帮助纠错。
如果是Ok,就代表测试通过;反之就是失败。
看个例子:
#![allow(unused)]
fn main() {
#[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(()) ,当测试失败时为Err ,其中包含一个带报错信息的String类型 。
这个测试肯定是通过的。
使用Result枚举来测试有一个注意,就是不要在使用Result<T, E>编写的测试上使用should_panic属性(这个属性在上一篇文章中有讲),因为使用Result<T, E>编写的测试会返回Err而不是直接触发恐慌。
11.6 控制测试运行:并行和串行(连续执行)测试
11.6.1. 控制测试的运行方式
cargo test和cargo run一样,cargo test也会编译代码并生成一个二进制文件用于测试,只不过cargo test是在测试模式下。
为cargo test添加参数可以改变cargo test的行为,如果不添加任何参数,那么就会执行默认行为:
- 并行运行所有测试
- 在测试通过的情况下,捕获(不显示)所有输出,使读取与测试结果相关的输出更容易。如果测试不通过,输出是会显示的,以便于程序员纠错。
命令行参数分为两类:
- 针对
cargo test的参数,紧跟cargo test后 - 针对生成的可执行文件:放在
--之后。例如:cargo test --help,这个参数会显示cargo test所有可用的参数。cargo test -- --help会显示所有能放在--之后的参数,也就是所有针对可执行文件的参数。
11.6.2. 并行运行测试
在运行多个测试时默认会使用多个线程来并行地运行测试,这样运行得更快,但代价是这些测试之间不能有相互依赖,而且它们不依赖于某个共享状态(环境、工作目录、环境变量…)。
如果两个测试都依赖于某个共享的状态,其中一个测试运行完时把状态改了,那么其他共享了相同状态的测试就会受到影响。
如果不想并行地运行测试,或是希望精确地控制测试时所启用的线程数量,那就可以使用--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调用了println!输出传入的值,然后返回10 - 测试函数
this_test_will_pass传了4给被输出函数,所以被测试函数会打印4,然后又把函数固定的返回值与10比较。这个测试会成功。 - 测试函数
this_test_will_fail传了8给被输出函数,所以被测试函数会打印8,然后又把函数固定的返回值与5比较。这个测试会失败。
测试结果:
$ cargo test
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' 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.60s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok
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' 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 onne_hundred:
$ cargo test one_hundred
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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这个attribute(属性),将这些测试设为默认不执行。
看个例子:
#![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这个attribute,所以在cargo test不会执行,除非手动指定执行它们。
看一下测试结果:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test 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把测试分为两类,一个是单元测试,一个是集成测试。
- 单元测试比较小也比较专注,每次只对一个模块进行隔离的测试,还可以测试私有的(private)接口。
- 集成测试完全位于代码库之外,它和其他外部代码一样地使用你的代码,集成测试只能访问公共的(public)接口,并且在每个测试中可能使用到多个模块。
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,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。
集成测试的目的是验证被测试库的多个部分能否正确地一起工作,这一点有别于单元测试,单元测试比较小也比较专注,每次只对一个模块进行隔离的测试,还可以测试私有的(private)接口。
有时候能够独立运行的一些单元代码在合在一起运用时也会发生问题,集成测试正是为了今早发现和解决这种问题存在的。所以说,集成测试的覆盖率很重要。
11.10.2. tests目录
创建集成测试需要先创建tests目录。
这个目录是与src目录并列,cargo会自动在这个目录下寻找集成测试文件。在这个目录下可以创建任意多的集成测试文件,cargo会在编译时把每个测试文件都处理为一个单独的包,也就是一个单独的crate。
下面来演示一下创建集成测试文件:
1. 创建tests目录
在与src同级的目录下创建名为tests的文件夹:

2. 创建测试文件
在tests目录下创建.rs的测试文件,给它取个名字,这里我起的是integration_test.rs:

3. 把测试代码移到测试文件里
以上一篇文章的代码为例(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)想要测试lib.rs这个crate的内容就得先导入作用域。
在这个例子中,由于我给这个项目命名为RustStudy,所以这个package(包)的名字就是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 adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)
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-1082c4b063a8fbe6)
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 adder
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 指定的函数名;运行某个测试文件内的所有测试函数,可以使用cargo test --test 文件名。
看个例子:

现在tests下有两个文件,如果我只希望运行integration_test.rs里的测试函数,那么就输入指令:
cargo test --test integration_test
11.10.4. 集成测试中的子模块
由于tests目录下的每个文件被编译成单独的crate,所以这些文件互不共享行为(与src目录下的文件规则不同)。
那如果我想将测试函数中重复出现的逻辑提取到一个helper函数中,避免代码重复,该怎么写呢?
举个例子,我在tests目录下写了common.rs这个文件用于存储helper函数:

试试执行测试:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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-92948b65e88960b4)
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-92948b65e88960b4)
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 adder
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 文件夹名;即可,在这个例子中就是mod common;。使用时写common::你想要的函数,在这个例子中就是common::setup()
11.10.5. 针对binary crate的集成测试
如果项目是二进制包(binary crate),也就是只含有src/main.rs没有src/lib.rs,就不可以在tests目录下创建集成测试,即使有,也无法把main.rs的函数导入作用域。因为只有library crate(也就是有lib.rs)才能暴露函数给其它crate用。
binary crate意味着独立运行。因此,Rust的binary项目通常会把这些逻辑都放在lib.rs里,而在main.rs里只有简单的调用。这样做项目就会被视为library 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章会讲),产生一系列的值。对于迭代器可以使用collect这个方法把这一系列的值转化为一个集合,比如说一个Vector。
在 7.4. use关键字 Pt.1 讲过,当函数被嵌套着不止一层的模块时,通常将其父模块引入作用域。
代码如下:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
}
由于collect会产生集合,但是集合内的元素类型Rust无法推断,所以在声明时需要显式声明args的类型是Vec<String>。
使用println!来看看效果如何吧:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
println!("{:#?}", args);
}
输出1(没有带任何参数):
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
"target/debug/minigrep",
]
输出2(带了参数):
$ cargo run -- needle haystack
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
"target/debug/minigrep",
"needle",
"haystack",
]
在第二个例子中的--是用来区分Cargo 命令的参数和传递给程序的参数的。它的作用是告诉 Cargo,接下来的内容不是 Cargo 的选项或参数,而是运行程序时需要传递给程序的参数。env::agrs并不会读取并存储它。
可以看到,即使不带参数,这个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!("search 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!("search for {}", query);
println!("In file {}", filename);
}
12.2 读取文件
12.2.0. 写在正文之前
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件(本文)
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
12.2.2. 回顾
这是截止到上文所写出的所有代码:
use std::env;
fn main() {
let args:Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("search 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("Somthing 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!("search for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Somthing 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 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
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!("search for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Somthing went wrong while reading the file");
println!("With text:\n{}", contents);
}
这个代码存在4个问题:
-
main函数负责的功能太多,它既负责命令行的功能解析,又负责读取文件。程序代码的编写原则是每一个函数只负责一个功能,所以说最好把函数拆开。 -
query和filename这两个变量是用来存储程序配置的,contents是用来存储文件内容的。随着代码和变量在编写时越来越多,每个变量的实际意义就变得难以追踪。所以最好把这些变量存在结构体里。 -
读取文件时使用
expect来处理错误,不论读取时出现了什么错误都只会打印出错误信息并恐慌,这并不是最好的处理方式。因为文件读取失败可能是文件找不到,也有可能是权限问题,现在指定的这个恐慌信息“Somthing went wrong while reading the file“并不能帮助用户排查错误。 -
如果程序里到处都使用
expect方法那么用户得到的报错信息是来自于Rust语言内部的,比如“Index out of bound“,不是程序员根本不明白到底是什么引发了错误。最好是将错误的代码集中放置,从而使将来的维护者在需要修改错误处理相关的逻辑时只考虑这一处代码,也能确保向用户打印的错误信息是易于理解的。
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!("search for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Somthing 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("Somthing 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("Somthing 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中必须注意query和filename的格式:形参args的类型是&[String]是一个引用,没有所有权,所以query和filename也是引用,但是Config这个结构体接收的是String而不是&String,所以需要通过克隆来获得所有权,把&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.5. 整体代码
以下是截止到本篇文章所写出的所有代码:
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("Somthing 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("Somthing 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 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27: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 bound“索引越界,作为程序编写者的我们明白这是因为参数不够导师程序在使用索引获取参数时越界触发恐慌。但是作为用户就不可能看懂这个报错信息,无法纠正错误。
这一篇文章要做的就是让程序产生的错误信息易于理解。
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 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这一次的错误信息比上一次就好很多了。
但是它仍然残留了一些其他的信息,比如“thread ‘main’ panicked at src/main.rs:26: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’ panicked at src/main.rs:26:13:“和“note: run with RUST_BACKTRACE=1 environment variable to display a backtrace“这些内容。
试一下:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
闭包这个概念在下一章才会讲到,这里没看懂也没关系,只要了解个大概即可。
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("Somthing 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("Somthing 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章)。
对于这个截止到目前的代码,run函数应该是:
#![allow(unused)]
fn main() {
fn run(config: Config) {
let contents = fs::read_to_string(config.filename)
.expect("Somthing 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),
};
}
这么改之后就会把错误传播给调用者,也就是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.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.“这一行,所以返回值会是元素为String的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 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44: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章会细讲),会把字符串的内容一行一行地返回。 -
对于查找是否有符合
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 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
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-9cd200e5fac0fc94)
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 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
这个例子只有单行,试试有多行的字符:
$ cargo run -- body poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
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
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
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. 编写不区分大小写的search函数
这里不区分大小写的功能是通过环境变量来实现的,当然也可使用参数来实现,但使用环境变量的好处是只需要配置一次就可以在整个终端的会话中一直保持有效。
对于这个功能,我们也使用TDD(测试驱动开发)流程来开发:
- 编写一个会失败的测试,运行该测试,并确保它是按照预期的原因失败
- 编写或修改刚好足够的代码,让新测试通过
- 重构刚刚添加或修改的代码,确保测试会通过
- 返回步骤1,继续
1. 编写一个会失败的测试
这个对大小写不敏感的函数先给它起个名叫做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第一步想要的
2. 编写或修改刚好足够的代码,让新测试通过
其实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.to_lowercase().lines() {
if line.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 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... 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-9cd200e5fac0fc94)
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函数中使用此函数
这个函数没问题了,就可以在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("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive})
}
}
}
这里使用了std::env::var这个函数(当然也可以先把std::env导入作用域,再使用env::var),它的参数是这个环境变量的名称(按惯例全大写),这里我写的是CASE_INSENSITIVE,中文翻译过来就是大小写不敏感。这种环境变量只要出现就认为不区分大小写,不出现就认为区分大小写。
std::env::var的返回值是Result类型的,如果这个CASE_INSENSITIVE环境变量被设置了,返回包含环境变量值的Ok(String),反之返回Err(std::env::VarError)。
这里std::env::var后面还跟了is_err这个方法,如果is_err是Err变体,就会返回赋true给变量case_sensitive,反之就是赋false。
PS:说实话,这个小程序写成这个B样也是为了教学的无奈之举,我看到一半我都被这个代码量气笑了,真正写的时候没必要写得这么一板一眼的
12.7.3. 整体代码与试运行
写了这么多,看看截止到目前的所有代码。
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.to_lowercase().lines() {
if line.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 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
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("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.to_lowercase().lines() {
if line.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);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | 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;
| ^
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次闭包。
对于这个问题,一个解决方案是把闭包的值赋给某个本地变量,让这个本地变量被输出语句重复调用。这么写问题的是会造成一些代码的重复。
所以这里更适合使用另一种解决方法:创建一个结构体,它持有闭包及其调用结果。也就是说,在第一次调用闭包后把结果存到闭包里,如果以后还要调用闭包就直接使用存在里面的结果。它的效果是只会在需要结果时才执行该包,而且可缓存结果。
这种模式通常叫 记忆化(memorization) 或 延迟计算(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。
这里闭包直接访问了同在一个作用域的变量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,所以返回的结果会被包裹在Option下的Some变体里。如果迭代结束,就会返回Option下的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)]` 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.47s
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章会细讲,现在知道这个迭代器会返回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 使13.9. 使用闭包和迭代器改进IO项目
13.9.0. 写在正文之前
Rust语言在设计过程中收到了很多语言的启发,而函数式编程对Rust产生了非常显著的影响。函数式编程通常包括通过将函数作为值传递给参数、从其他函数返回它们、将它们分配给变量以供以后执行等等。
在本章中,我们会讨论 Rust 的一些特性,这些特性与许多语言中通常称为函数式的特性相似:
- 闭包
- 迭代器
- 使用闭包和迭代器改进I/O项目(本文)
- 闭包和迭代器的性能
13.9.1. 回顾
本篇文章会以第12章中的grep项目为例演示使用闭包和迭代器改进I/O项目,在此之前我们先回顾一下。
第12章要做一个实例的项目——一个命令行程序。这个程序是一个grep(Global Regular Expression Print),是一个全局正则搜索和输出的工具。它的功能是在指定的文件中搜索出指定的文字。
这个项目分为这么几步:
- 接收命令行参数
- 读取文件
- 重构:改进模块和错误处理
- 使用TDD(测试驱动开发)开发库功能
- 使用环境变量
- 将错误信息写入标准错误而不是标准输出
lib.rs:
#![allow(unused)]
fn main() {
```rust
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.to_lowercase().lines() {
if line.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> {
contents.to_lowercase()
.lines()
.filter(|line| line.contains(&query.to_lowercase()))
.collect()
}
}
注意,query.to_lowercase()得加&,因为query.to_lowercase()会生成String类型,而contains方法接收&str,所以不能直接传query.to_lowercase(),只有传引用进去,也就是&query.to_lowercase()才能正确执行。
转换为&str不仅可以加&,当然也可以用as_str方法:
#![allow(unused)]
fn main() {
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.to_lowercase()
.lines()
.filter(|line| line.contains(query.to_lowercase().as_str()))
.collect()
}
}
不管从代码量还是可读性上比,使用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的迭代器大量使用泛型(generics)来定义操作,比如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 --realse
使用cargo build和cargo build --realse指令会使用两个不同的配置文件:
$ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
$ cargo build --release
Finished release [optimized] target(s) in 0.0s
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到crates.io供他人使用。
Rust 和 Cargo 具有使您发布的包更容易被人们找到和使用的功能。接下来我们将讨论其中一些功能,然后解释如何发布包。
14.2.2. 文档注释
使用///来写文档注释,文档注释用于生产项目的文档,它不同于//,//用于代码的标注,而文档注释是整个项目的注解。
这种文档是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会使用rustdoc工具(Rust安装自带),可以生成文档。它会把生成的文档放在target/doc目录下。
cargo doc --open会生成文档并在网页浏览器中打开结果:

14.2.4. 常用章节(部分)
以下是 crate 作者在其文档中常用的一些部分:
-
# Example是示例部分,下面的代码块放示例代码 -
Panic:正在记录的函数可能会出现恐慌的情况。不希望程序出现恐慌的函数调用者应确保在这些情况下不会调用该函数。 -
Error:如果函数返回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.27s
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用途的文档,也就是对add_one的外层条目的注释。
这时候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下,把函数放在untils下对于开发者来说非常友好
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 。要注意的是,此令牌不能与其他任何人共享。如果你泄露了,应该撤销它并在crates.io上生成一个新令牌。
14.4.2. 将元数据添加到crate
在发布crate之前还需要在Cargo.toml文件里的[package]这个区域添加一些元数据:
- 首先你要确保项目名称在网站上是独一无二。
- 其次还需要写
description,也就是介绍,但是不需要太多,一两句话就可以。description的内容会出现在crate搜索的结果里。 - 你需要提供这个crate遵循的许可证标识值(可以到spdx.org/licenses/中查找),可以指定多个,用
OR隔开,写在license。 - 语义版本信息(写在
version) - 作者(写在
author)
当然可以写的信息不止这些,具体可以参阅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,但是前提是元数据完整且项目名不重复。
cargo publish正常运行时会向你在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. 撤回版本
撤回版本会使新的要使用这个crate的项目不能依赖于这个版本,但是已经基于这个版本写出的项目仍然可以使用并可下载。
其指令是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.4.1. 为什么需要cargo workspace
假如说我们构建了一个二进制crate,里面既有library又有库。随着项目规模不断增长,库crate可能不断变大。在这种情况下通常会把它拆为多个包,针对这种需求,Rust提供了cargo工作空间,也就是cargo workspace。
cargo workspace会帮助管理多个相互关联且需要协同开发的crate。其本质是一套共享同一个Cargo.lock和输出文件的包。
14.4.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]
members = [
"adder",
]
adder就是我给二进制crate取的名,这个列表可以继续添加。
3. 添加库
$ cargo new adder
Created binary (application) `adder` package
通过这个命令创建了adder crate,在目录add/adder下
此时整个项目的结构如下:
├── Cargo.lock
├── Cargo.toml
├── adder
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
需要注意的是,这时候我们既可以对add这个项目使用cargo build,也可以对add下的adder库使用cargo bulid。但是生成的target目录和Cargo.lock文件只会有一个,在add目录下,而adder库使用cargo bulid的产出物也会存放在这里。因为各个crate往往是相互依赖的,每个目录都有自己的target就会导致开发者不得不反复编译工作空间里的其余crate。
接下来添加其它crate:
另一个crate叫add_one,修改工作空间信息:
[workspace]
members = [
"adder",
"add_one",
]
使用cargo new添加库,记得使用--lib旗帜来把它声明为library crate:
$ cargo new add_one --lib
Created 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
}
}
现在我们可以让adder包和我们的二进制文件依赖于add_one。首先,我们需要添加路径依赖add_one到adder/Cargo.toml,因为Cargo并不假设工作区中的crate会相互依赖,因此我们需要明确依赖关系。在adder/Cargo.toml中这么写:
#![allow(unused)]
fn main() {
[dependencies]
add_one = { path = "../add_one" }
}
接下来,让我们使用add_one函数(来自add_one crate)。打开adder/src/main.rs文件并在顶部添加use来把add_one引入作用域,将新的add_one库crate纳入范围。然后更改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 [unoptimized + debuginfo] target(s) in 0.68s
没有报错,正常运行。
6. 测试
我们还可以通过使用-p标志并指定我们要测试的包的名称,从顶级目录中对工作区中的一个特定包运行测试。比如说仅测试add_one函数:
$ cargo test -p add_one
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src/lib.rs (target/debug/deps/add_one-b3235fea9a156f74)
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. 从cratea.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%(Linux/MacOS使用$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 智能指针的常见类型
引用计数 (Reference Counting) 类型
引用计数智能指针通过记录所有者的数量,实现数据的多重持有。它会在没有使用者时自动清理数据。
标准库中的智能指针
在前文中,我们已经接触过一些智能指针,例如:
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定义为具有指向值5的Box的值,该值分配在堆上。该程序将打印b = 5 。
和其他任何拥有所有权的值一样,b这个变量离开作用域的时候(也就是第4行花括号结束的时候),会和其他任何拥有所有权的变量一样释放内存(堆上的和栈上的都会被释放)。
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>是更好的选择。
下面用Vec<T>创建一个同上图结构相同的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 (file:///projects/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>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing when `List` needs drop again
= note: cycle used when computing whether `List` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
因为Rust需要知道类型所占的空间大小,但递归类型的大小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值所需的最大空间就是存储其最大变体所需的空间,也就是ChangeColor这个变体。
15.1.8. 使用Box来获得确定大小的递归类型
刚才讲了,Rust需要知道类型所占的空间大小,但递归类型的大小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,就是引用的英文reference加上“de“这个反义前缀,意思为解引用。
如果一个类型实现了Deref trait,那么它就使我们可以自定义解引用运算符*的行为。通过实现Deref trait,智能指针可像常规引用一样来处理。
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这里报错了,报错信息是:
Type `MyBox<{integer}>` cannot be dereferenced
类型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实现Dereftrait就得写impl<T> Deref for MyBox<T>这个impl块。在这个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);
}
能够通过编译,没有问题。
而实际上主函数里的*y的写法Rust编译器会隐式地展开为:
#![allow(unused)]
fn main() {
*(y.deref())
}
先调用了MyBox类型上的deref方法返回一个引用,然后再使用解引用符号*进行普通的解引用操作。
15.3 Deref trait Pt.2:隐式解引用转化与可变性
15.3.1. 函数和方法的隐式解引用转化(Deref Coercion)
隐式解引用转化(Deref Coercion)是为函数和方法提供的一种便捷特性。
它的原理是:*假如类型T实现了Deref trait,那么Deref Coercion可以把T的引用转化为T经过Deref操作后生成的引用。
当某类型的引用传递给函数或者是方法时,但它的类型与定义的参数类型不匹配,Deref Coercion就会自动发生。编译器会对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没有Deref Coercion,那么写法会是:
#![allow(unused)]
fn main() {
hello(&(*m)[..]);
}
- 先使用解引用符号
*把m从MyBox<String>转化为String - 加上引用符号
&把m从String转化为&String - 通过切片操作
[..],可以获得 String 中的完整内容的引用,并且把其值从&String转化为&str
15.3.2. 解引用于可变性
可以使用DerefMut trait重载可变引用的*运算符。DerefMut相比Deref多了Mut,这是指DerefMut返回的是可变引用&mut T,而Deref返回的是不可变引用&T。
在类型和trait满足下列三种情况时,Rust会执行Deref Coercion:
-
当
T:Deref<Target=U>,允许&T转换为&U:T实现了Dereftrait,而Dereftrait下的deref方法的返回类型是&U,那么&T就可以被转化为&U。 举个例子,上文代码例的MyBox类型就实现了Dereftrait,其deref方法的返回值是泛型参数&T,所以&MyBox就可以转换为&T。 -
当
T:DerefMut<Target=U>,允许&mut T转换为&mut U。T实现了DerefMuttrait(DerefMut返回的是可变引用&mut T),而DerefMuttrait下的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 trait只要求实现drop方法,其参数是对self的可变引用。Drop trait在预导入模块(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走出作用域都在第19行花括号后,所以程序接着会分别对c和d调用drop函数。在实现Drop trait时定义的drop函数是打印一句话,所以这里c和d就会分别打印一句话。
15.4.2. 使用std::mem::drop来提前drop值
比较遗憾的是,我们很难直接禁用自动的drop功能,也没必要。因为Drop trait的目的就是进行自动的释放处理逻辑。
此外,Rust不允许手动调用Drop trait的drop方法。但是可以调用标准库的std::mem::drop函数来提前drop值,相当于提前调用了Drop trait的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也只会在确定不再使用这个值时被调用1次。
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>的实际应用:
一共有3个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
报错内容是使用了已移动的值。这是因为在写b时写道了a所以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的引用&a作为参数传进去,这样b和c就不会获得a的所有权,同时每使用一次Rc::clone就会把智能指针内的引用计数加1。
创建a时使用Rc::new算第一次引用,此时计数器为1;在b和c中各使用了Rc::clone一次,引用计数就会各加1,最终引用计数就是3。a这个智能指针中的数据只有在引用计数为0时才会被清理掉。
其实在Rc<T>上也有clone方法(不是Clone trait的上的clone方法),其源码与Rc::clone完全一样,所以在给b和c赋值时写a.clone()也是可以的。但因为这么写可能会被误解为深拷贝(尤其是对新手来说),而实际它只是增加了引用计数,所以不推荐这么写,更多还是使用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>允许程序员持有多个可变引用的话就会违反借用规则(详见 4.4. 引用与借用)——多个指向同一区域的可变引用会导致数据竞争以及数据的不一致。
而在实际开发中肯定会遇到需要数据可变的情况,针对它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>并没有完全地绕开借用规则,编译阶段的检查虽然能够通过,但是在运行阶段如果违反了借用规则就会造成程序恐慌。
下面看一个例子(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并实现Messenger这个在程序开头定义的trait。LimitTracker里面有三个字段:messenger:类型为&str字符串切片类型,生命周期为'avalue:类型为usizemax:类型为usize
-
往下看,通过
impl块在LimitTracker上写了关联函数new,其参数是类型为泛型引用&T的形参messenger和类型为usize的形参max,返回值是LimitTracker类型。这个函数用于创建LimitTracker实例,这个实例:messenger字段是形参menssenger的值value字段值为0max字段值为形参max的值
-
LimitTracker还有一个方法叫做set_value,其第一个参数是self的可变引用&mut self,第二个参数是value,类型为usize。 方法内部的代码逻辑很简单。把self的value字段值和参数value的值相除(还要转换成f64避免丢失精度)得到一个百分比,存储在percentage_of_max内。根据percentage_of_max的大小使用Messengertrait下的send方法发送不同的警告。
使用测试替代(test double)进行测试
这里有一个问题,如果我们要对这个set_value方法进行测试,就需要这个方法得输出些什么东西以供断言。但是set_value方法实际上并没有返回任何的值,所以说它不会提供任何的结果来进行断言。
我们要测试的是当某一个实现了Messenger trait的值和一个max值来创建LimitTracker实例时,传入不同的value就能够触发Messenger发送不同的消息。
为了解决这个问题,这里要介绍test double,它的中文叫测试替代,是一个通用的变成概念,代表了测试工作中被用作其他类型的替代品。test double中有一个特定的类型,叫模拟对象(Mock Object),它会承担记录测试过程中的工作。我们就可以利用这些记录来断言这个测试工作运行是否正确。
Rust里没有类似的概念,在标准库里也没有模拟对象(Mock Object),但是我们可以自定义一个结构体来实现和Mock Object相同的功能。
接着上文的代码来写:
#![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结构体,里面有1个字段sent_message,表示发送的消息,其类型是Vec<String> -
MockMessenger在下文又通过impl块创建了了new函数用于创建MockMessenger的实例,实例的sent_messages字段的值是一个空的Vector。 -
后面又为
MockMessenger结构体实现了整个代码一开头的Messengertrait。实现了这个trait之后MockMessenger就可以用来创建LimitTracker(因为LimitTracker要求泛型类型实现Messengertrait)。 使用send方法时这个消息会存储在MockMessenger下字段sent_message这个Vector里。 -
最后是
it_sends_an_over_75_percent_warning_message这个测试函数,它测试的是超过75%的这部分。 首先创建了MockMessenger的实例叫mock_messenger,然后创建了一个LimitTracker的实例叫limit_tracker,接着在LimitTracker的实例上(就是limit_tracker)调用。 最后通过mock_messenger下sent_message这个Vector里元素的数量来断言。
此时的代码逻辑有问题,但是运行会报错:
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 the data it refers to 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实现Messenger trait时定义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实现Messenger trait时定义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>,实现了Dereftrait
RefCell<T>会记录当前存在多少活跃的Ref<T>和RefMut<T>:
- 每次调用
borrow:不可变借用计数加1。 任何一个Ref<T>的值离开作用域被释放:不可变借用计数减1 - 每次调用
borrow_mut:可变借用计数加1 任何一个RefMut<T>的值离开作用域被释放:可变借用计数减1
与编译时借用规则(详见 4.4. 引用与借用)一样, RefCell<T>允许我们在任何时间点拥有许多不可变借用或一个可变借用。
如果我们尝试违反这些规则, RefCell<T>的实现将在运行时出现恐慌(因为RefCell<T>在运行时才会进行借用规则检查)。出现恐慌 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<>包裹,由于Rust编译器无法确定RefCell<T>大小,得用Rc<>包裹在外,其余保持不变 - 记得引入
Rc和RefCell到当前作用域 - 下面通过
Rc::new()和RefCell::new()来创建实例,a通过Rc::clone()来共享value的值,b和c通过Rc::clone()来共享a的值(前提是a被Rc<>包裹)。 - 最后通过
RefCell<T>上的borrow_mut获得value的可变引用,其类型时&i32,然后通过解引用符号*变为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的方法,用于获取List下Cons变体附带的第二个元素,如果有就返回其值,用Some封装,是Nil就返回None。 - 然后在
main函数创建了a、b两个List的实例,b内部共享了a的值。这种链表的代码看着就犯恶心,所以我把其结构图放在这里:
main函数里还通过Rc::strong_count获取了a和b的强引用数量,使用自定义的tail方法获了Cons附带的第二个元素,用println!打印出来。- 下面使用
if let语句把a的Cons的第二个值绑在link上,通过borrow_mut方法获得其可变引用&Cons,使用解引用符号*把它转为Cons,最后把b的值通过Rc::clone共享赋给了link,也就改变了a内部的结构,变为了:
PS:我觉得自己画的太烂了,所以这里就换成Rust圣经的图片了
输出:
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。
第六行到第7行:if let语句把a的内部结构改变了,使a的第二个元素指向b,b的引用数量加1变为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指向a等等,直到溢出堆栈。最终的结果会是栈溢出错误。
15.7.2. 防止内存泄漏的方法
那有什么方法来防止内存泄漏吗?这只能依靠开发者,不能依靠Rust。
不然就只能重新组织数据结构,把引用拆分成持有和不持有所有权的两种情况,一些引用用来表达所有权,一些引用不表达所有权。循环引用的一部分具有所有权关系,另一部分不涉及所有权关系。这样写只有所有权的指向关系才会影响到值的清理。
15.7.3. 把Rc<T>换成Weak<T>以防止循环引用
我们知道Rc::clone会生成数据的强引用,使Rc<T>内部的引用计数加1,而Rc<T>只有在strong_count为0时才会被清理。
然而,Rc<T>实例通过调用Rc::downgrade方法创建值的弱引用(Weak Reference)。这个方法的返回类型是weak<T>(也是智能指针),每次调用Rc::downgrade会为weak_count加1而不是strong_count,所以弱引用并不影响Rc<T>的清理。
15.7.4. Strong vs. Weak
强引用(Strong Reference)是关于如何分析Rc<T>实例的所有权。弱引用(Weak Reference)并不表达上述意思,使用它不会创建循环引用:当强引用数量为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的内容从Rc<Node>变为Weak<Node>,指向了leaf的parent字段:
leaf.parent是表示leaf父节点的字段,其类型是RefCell<Weak<Node>>,所以可以使用borrow_mut来获得其可变引用&mut RefMut<Weak<Node>>- 使用解引用符号
*把可变引用&mut RefMut<Weak<Node>>变为RefMut<Weak<Node>> - 通过
downgrade方法把branch的Rc<Node>变为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,由于branch使用强引用对leaf进行了关联,其parent字段使用了Weak::new()创建,所以branch有1个强引用,一个弱引用 - 第3行:
branch使用了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后就要结束了,这时候还有些时间。另外的线程趁程序关闭前又输出了两个数。
这样写不能保证另外一个线程能够完成它的执行,这时候就需要joinHandle。
16.1.6. 通过join Handle来等待所有线程完成
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 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main 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 4 from the main thread!“后就结束了,然后分线程在这之后依然能输出直到执行完。
让我们看看当将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也提供了机遇消息传递的一种并发方式,具体的就是在Rust里实现就是使用Channel(标准库提供)。Go语言里也有Channel,思路差不多。
16.2.2. 理解Channel
可以将编程中的Channle想象为定向水道,例如小溪或河流。如果你把橡皮鸭之类的东西放入河中,它会顺流而下,到达水道的尽头。
通道有两部分:发送端和接收端。发射端是将橡皮鸭放入河中的上游位置,接收端是橡皮鸭最终到达下游的位置。代码的一部分使用要发送的数据调用发送端上的方法,另一部分检查接收端是否有到达的消息。如果发送端或接收端其一掉线,则称通道已关闭。
具体的步骤:
- 调用发送端的方法,发送数据
- 接收端会检查和接收到达的数据
- 如果发送端、接收端中的任意一端被丢弃了,那么
Channel就关闭了。
16.2.3. 创建channel
使用mpsc::channel函数来创建Channel。mpsc表示multiple producer, single consumer(多个生产者、一个消费者),表示可以有多个发送端,但是只能有一个接收端。
调用这个函数返回一个元组(tuple,详见 3.3. 数据类型:复合类型),有两个元素,分别是发送端和接收端。
看个例子:
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方法(recieve的简写)。它会一直阻塞这个线程,直到有消息被传入为止。 消息被包裹在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 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
--> src/main.rs:10:26
|
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
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
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: for
Got: the
Got: thread
Got: you
接收端收到的数据是交替出现的。
16.3 共享状态的并发
16.3.1. 使用共享来实现并发
还记得Go语言有一句名言是这么说的:Do not communicate by sharing memory; instead, share memory by communicating.(不要用共享内存来通信,要用通信来共享内存)
上一篇文章就是使用通信的方式来实现并发的。这一篇文章讲一下如何使用共享内存的方式来实现并发。Go语言不建议使用这种方式,Rust支持通过共享状态来实现并发。
上一篇文章讲的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的类型是MutexGuard<i32>。 - 后面使用
{}创建了新的小作用域,在小作用域里使用lock方法获取值,使用unwrap进行错误处理。由于MutexGuard实现了Dereftrait,我们就可以获得内部数据的引用。所以num是一个可变引用。 - 在小作用域内还使用了解引用
*来修改数据的值为6。 - 由于
MutexGuard实现了Droptrait,所以在小作用域结束后会自动解锁。 - 最后打印了修改后的互斥锁内的内容。
输出:
m = Mutex { data: 6 }
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包裹以更好地在多线程中调用,刚开始的值是0handle目前是一个空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 (file:///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 `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
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = counter.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
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章讲了一个多重所有权的智能指针叫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<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<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<Mutex<i32>>`, which is required by `{closure@src/main.rs:11:36: 11:43}: Send`
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`
--> /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/std/src/thread/mod.rs:688:1
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<Mutex<i32>>` cannot be sent between threads safely,Rc<Mutex<i32>>不能在线程间安全地传递。编译器也告诉我们了原因:the trait `Send` is not implemented for `Rc<Mutex<i32>>`,Rc<Mutex<i32>>没有实现send trait(下一篇文章会讲到)。只有实现send的类型才能在线程间安全地传递。
其实在第15章讲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>的功能需要以性能作为代价Arc<T>和Rc<T>的API都是相同的
既然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家族一样。我们一般使用RefCell<T>包裹Rc<T>以获得一个有内部可变性的共享所有权数据类型。同样的,使用Mutex<T>可以改变Arc<T>里面的内容。
当使用Mutex<T>时,Rust 无法保护您免受各种逻辑错误的影响。使用Rc<T>会带来创建引用循环的风险,其中两个Rc<T>值相互引用,从而导致内存泄漏。同样, Mutex<T>也存在产生死锁(deadlock) 的风险。当一个操作需要锁定两个资源并且两个线程各自获取其中一个锁,导致它们永远等待对方时,就会发生这种情况。Mutex<T>和MutexGuard的标准库API文档提供了有用的信息。详见:Mutex<T>API文档和MutexGuardAPI文档。
16.4 通过Send和Sync trait来扩展并发
16.4.1. Send和Sync trait
Rust语言本身的并发特性较少,目前所提及的并发特性都来自于标准库,而不是语言本身。其实无需局限于标准库的开发,可以自己实现并发。
但在Rust语言中有两个并发概念:
std::marker::Synctraitstd::marker::Sendtrait
这两个trait叫标签trait,因为他们没有定义任何方法,只供标记特性。
16.4.2. Send:允许线程间转移所有权
在上一篇文章我们曾尝试在跨线程的情况下传递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特征,因此我们不必手动实现这些特征。作为标记特征,它们甚至没有任何方法可以实现。它们只是用于强制执行与并发相关的不变量。
手动实现这些特征涉及实现不安全的 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函数式编程的特性,但它既不是传统的面向对象编程语言,也不是纯函数式编程语言,而是一个多范式编程语言,它结合了函数式编程和面向对象编程的一些特点。
对象包含数据和行为
Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides(这四人通俗地被称为“设计模式四人帮”)所著的“Design Patterns: Elements of Reusable Object-Oriented Software“(《设计模式:可重用面向对象软件的元素》)一书是一本面向对象的目录面向设计模式。它这样定义 OOP:
*Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations. * 面向对象的程序是由对象组成的。对象封装了数据和操作该数据的过程。这些过程通常称为方法或操作。
基于这个定义,Rust是面向对象的:struct、enum包含数据,impl块为之提供了方法。但在Rust里带有方法的struct和enum并没有称之为对象。
17.1.2. 封装
封装指的是调用对象外部的代码无法直接访问对象内部的实现细节,唯一可以与对象进行交互的方法就是通过它公开的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方法来实现。
17.1.3. 继承
继承是指使对象可以沿用另外一个对象的数据与行为,且无需重复定义相关的代码。Rust并不支持这个特性。
通常使用继承的原因是代码复用和多态。
-
针对代码复用,Rust提供了默认trait方法来进行代码共享。在trait中某个方法有默认实现,那么任何实现了这个trait的类型就会自动拥有这个方法。这和面向对象很类似,在面向对象语言中,父类中实现的方法就可以被继承它的子类拥有。当实现某个trait时,还可以覆盖trait的默认实现,这类似于子类覆盖从父类继承的方法的实现。
-
多态指期望某个类型能够被应用在需要父类型的地方,换句话说,就是如果一些对象拥有某些共同的特征,那么这些对象就可以在运行时相互替换。Rust通过泛型和trait bound(trait约束)实现了这一点:泛型使得逻辑可以更好的脱离于实际的数据类型,并使用trait bound来决定能使用此逻辑的类型必须提供的某些具体特性,这一技术也称为限定参数多态化(bounded parametric)
现在其实很多语言都不使用继承作为内置的程序设计方案了。因为它经常面临共享过多代码的风险。子类不应该总是共享其父类的所有特征,但可以通过继承来实现。这会降低程序设计的灵活性。它还引入了在子类上调用没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,有些语言只允许单继承(即子类只能从一个类继承),这进一步限制了程序设计的灵活性。
17.2 使用trait对象来存储不同值的类型
17.2.1. 需求
这篇文章以一个例子来介绍如何在Rust中使用trait对象来存储不同值的类型。
在第 8 章中,我们提到Vector的一个限制是它们只能存储一种类型的元素。我们在 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这个父类。
上一篇文章中说了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。它的类型是Vector,里面的元素是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一固定下来这个Vector里就只能存储这个类型了。举个例子,假如第一个放进这个Vector的元素是Button类型,那么这个Vector的其他元素就只能是Button了(因为Vector里的所有元素类型必须相同)。
而如果是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的内容,接下来到mian.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()封装)。这个Vector能放不同类型的元素正是归功于定义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使用了一系列规则来判定某个对象是否安全,只需要记住两条:
- 方法的返回类型不是
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> {
Box::new(Published {})
}
}
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字段使用pusth_str方法来添加内容 -
即使我们调用了
add_text并向帖子添加了一些内容,我们仍然希望content方法返回一个空字符串切片,因为帖子仍处于草稿状态。 -
request_review会提取出state字段下的状态,取出来之后,State就会暂时变为None,因为所有权被移动出来了。这个时候调用state上的request_review方法来请求审批。 当state是Draft状态时,就会调用Draft结构体上的request_review方法(下文有讲),把state字段的值从Draft变为了PendingReview,把状态更新回state上。
-
-
approve表示审批通过,其写法跟request_review差不多,把状态取出来,调用self上的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。
-
PendingReviewing用于表示等待审批,不需要实际的内容,所以只要声明一个没有字段的结构体即可 -
通过
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里的一种特殊的语法,用于匹配复杂和简单类型的结构。
将模式与匹配表达式和其他构造结合使用,可以更好地控制程序的控制流。
模式由以下元素(的一些组合)组成:
- 字面值
- 解构的数组、
enum、struct和tuple - 变量
- 通配符
- 占位符
想要使用模式,需要将其与某个值进行比较:如果模式匹配,就可以在代码中使用这个值的相应部分。
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
if let相比于match的缺点在于不会检查穷尽性,如果我们省略了最后一个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条件放置在该块中:我们不能将这两个条件组合成 if let Ok(age) = age && age > 30 。我们想要与 30 进行比较的阴影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中Rust中最常见的循环。for循环中,模式就是紧随for关键字后的值。
在for循环中,直接跟在关键字for后面的值是一个模式。例如,在for x in y中, x是模式。下面的例子演示了如何在for循环中使用模式来解构或分解元组作为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)并看到该值与模式匹配,因此 Rust 将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. 模式的两种形式
模式有两种形式:
- 可辩驳的(可失败的)
- 无可辩驳的(不可失败的),可以把它理解为不会失败的,怎么写都会成功
其中能匹配任何可能传递的值的模式:无可辩驳的。看个例子:
#![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即可:
#![allow(unused)]
fn main() {
let a:Option<i32> = Some(5);
if let Some(x) = a {
// ...
}
}
if let和while let支持可辩驳和无可辩驳模式。实际上,如果在if let和while let中使用无可辩驳模式编译器会发出警告,因为存在可能的失败。看个例子:
#![allow(unused)]
fn main() {
if let x = 5 {
println!("{x}");
};
}
输出:
$ cargo run
Compiling patterns v0.1.0 (file:///projects/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.39s
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。这两个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和tuple,从而引用这些类型值的不同部分。
解构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类型- 有一个
Point的实例叫p,其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和tuple
看例子:
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 (file:///projects/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守卫)来提供额外的条件
match guards是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 guards,要求Some附带的数据要能被2整除。
无法在模式中表达if x % 2 == 0条件,因此match guards使我们能够表达此逻辑。这种额外表达能力的缺点是,当涉及匹配保护表达式时,编译器不会尝试检查是否详尽。
看第二个例子:
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到y 。
看第三个例子:
#![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 ,但匹配守卫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_varible上又判断了其值应该在3到7的闭区间内。
19.1 摆脱安全性限制的unsafe Rust
19.1.1. 匹配命名变量
到目前为止我们讨论的所有代码都在编译时强制执行Rust的内存安全保证。然而,Rust内部隐藏着第二种语言,它不强制执行这些内存安全保证,被称为unsafe Rust。它和普通Rust一样,但给了我们额外的“超能力”。
unsafe Rust之所以存在是因为:
- 静态分析是非常保守的。编译器在判断一段代码是否拥有安全的保证时,宁可错杀执行起来没问题的程序,也不会放过任何有可能出错的代码。
- 计算机硬件本身就是不安全的,Rust想要达到C那样的底层程度就需要Unsafe Rust。换句话说,unsafe Rust可以进行底层系统编程。
使用unsafe Rust就是在告诉编译器:我知道自己在干嘛,并承担相应风险。
19.1.2. Unsafe Rust的超能力
使用unsafe关键字来切换到unsafe Rust。它会开启一个代码块,写在里面的就是unsafe代码。
unsafe Rust可以执行的四个操作(也就是所谓的超能力):
- 解引用原始指针
- 调用不安全的函数或方法
- 访问或修改可变的静态变量
- 实现不安全的trait
- 访问
union的字段
注意:
- unsafe Rust并没有关闭借用检查或停用其它安全检查。如果你在代码里使用引用的话,这个引用依然会被检查。
unsafe关键字仅仅是为了让你可以执行上文所说的那4个不会被编译器进行内存安全检查的操作。所以说,即便是处于unsafe代码块中你仍然可以获得一定程度上的安全性。 - 任何内存安全相关的错误必须留在
unsafe块里 - 尽可能隔离
unsafe代码,最好将其封装在安全的抽象里,提供安全的API。实际上某些标准库中的代码使用了unsafe代码块,但在此之上提供了了安全的抽象接口,这就可以有效地防止不安全代码泄露到任何调用它的地方,因为使用安全的抽象就是安全的,不管里面有没有使用unsafe Rust。
特性1:解引用原始指针
unsafe Rust提供了两种类似于引用的新型指针,它们叫做原始指针或者裸指针,英文是raw pointer。只有使用原始指针时才需要放到usnafe块里,因为可能会出现问题,单创建一个原始指针并不会产生问题,故不需要放到unsafe块里。
和引用类似,这种原始指针要么是可变的要么是不可变的:
- 可变的:
*mut T - 不可变的:
*const T,意味着指针在解引用之后直接对其进行赋值。 注意:这里面的*是类型的一部分不代表解引用,*const T这三个标记放在一起才是一个类型,比如*const String
*const T和*mut T的差别很小,可以相互自由的转换。Rust的引用(不论是&mut T还是&T)在编译阶段都会被编译器转为原始指针,这意味着无需进入unsafe块就能获得原始指针的性能。
引用和原始指针的不同之处在于:
- 允许通过同时具有不可变和可变指针或多个指向同一位置的可变指针来忽略借用规则(借用规则详见 4.4. 引用与借用)
- 原始指针无法保证能指向合理的内存,而引用可以。
- 原始指针允许为
null - 原始指针不实现任何自动清理功能
放弃保证的安全就可以换取更好的性能/与其它语言或硬件接口的能力。
看个例子:
fn main(){
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
}
这是创建原始指针的一个例子,我们在主函数里分别创建了一个不可变和可变的原始指针。
这段代码虽然没有放在unsafe代码块里但并没有报错,所以说我们可以在不安全代码之外创建原始指针,但对原始指针的解引用只能在unsafe代码块里进行。
这个代码在一个作用域里存在了指同一片内存区域的可变指针和不可变指针,而且被允许了,这意味着我们可以通过可变引用修改值。但是这么写一定要小心。
在创建原始指针的过程中先是按照引用的写法来写,然后通过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);
}
}
输出:
没错在我的电脑上它什么也没有输出(但也没报错),你可以在你的电脑上试试,有的会报错表示是非法的访问,有的甚至会输出个数字出来。
既然原始指针这么危险,那为什么要使用它呢?原因如下:
- 与C语言进行接口交互
- 构建借用检查器无法理解的安全抽象
特性2:调用不安全的函数/方法
不安全的函数/方法就是指在定义时加了unsafe关键字的函数/方法。除此之外与一般的函数/方法没啥区别。
调用这种函数/方法之前需要手动满足一些条件(主要靠看文档),因为Rust无法对这些条件进行验证。另外,想要调用不安全的函数/方法需要在unsafe块里调用。
看个例子:
unsafe fn dangerous() {}
fn main(){
unsafe {
dangerous();
}
}
使用unsafe关键字声明了一个dangerous函数,它属于不安全的函数,所以主函数里得在unsafe块下调用它。
函数包含不安全的代码不意味着需要将整个函数标记为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这个Vector,r是它的完整可变切片,接着对r使用了split_as_mut方法 split_as_mut方法接收切片内元素是i32的self和一个unsize值作为参数,方法会从这个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 的借用检查器无法理解我们正在借用切片的不同部分,这两部分并没有重叠,但它只知道我们从同一个切片借用了两次。所以我们得使用不安全函数:
#![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_str会返回原始指针,具体来说是*mut i32- 在返回元组时使用了
unsafe块(使用了原始指针和偏移量),通过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)(以mid作为偏移量,得到ptr里mid个元素的位置)开始创建了一个有len - mid个元素的切片(也就是到ptr到结尾)
这个函数使用了unsafe块,但它本身并没有被标注为unsafe,这就是不安全代码的安全抽象。
如果我们不适用安全的抽象呢:
use std::slice;
fn main(){
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
我们不一定拥有这个任意位置的内存,并且不能保证此代码创建的切片包含有效的i32值。尝试使用 将values视为有效切片会导致未定义的行为。
使用extern函数来调用外部代码或是被外部代码调用
extern关键字可以简化创建和使用外部函数接口(Foreign Function Interface,简称FFI) 的过程。
外部函数接口允许一种编程语言定义函数,并让其它编程语言能调用这些函数。
看个例子:
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
- 任何在
extern块里声明的函数都是不安全的,因为其它语言不会强制执行Rust所遵守的规则,而Rust又无法对它们进行检查。所以调用外部函数被隐性地加了unsafe标注,保证安全的责任交给了开发者。 - 在
extern "C"块中,我们列出了我们想要调用的另一种语言的外部函数的名称和签名。"C"部分定义了外部函数使用的应用程序二进制接口 (application binary interface,简称ABI) ——ABI定义如何在汇编层面调用该函数。"C"ABI是最常见的,并且遵循C编程语言的ABI。
既然Rust可以调用其他编程语言的函数,那其他编程语言能调用Rust的代码吗?答案是肯定的。
我们可以使用extern创建接口,其他语言可以通过它们调用Rust的函数。具体做法是在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关键字声明,命名规范是大写的蛇形命名,而且在声明时必须标注出它的类型。它的生命周期是且只能是'static,表示在程序运行时一直保持有效,这个不需要显式标注,Rust会自行推断。访问不可变的静态变量是安全的。
看个例子:
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {HELLO_WORLD}");
}
HELLO_WORLD就是声明的全局变量,其值为“Hello, world!“,类型是字符串切片&str- 在主函数里打印了这个全局变量
常量(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实现Footrait就得在unsafe块里,所以得使用unsafe impl
特性5:访问union的字段
union类似于struct ,但在特定实例中一次仅使用一个声明的字段,union主要用于与C代码中的union进行交互。访问union字段是不安全的,因为Rust无法保证当前存储在union实例中的数据的类型。详细内容见Rust 参考。
19.1.3. 何时使用unsafe代码
保证unsafe代码正确是比较棘手的,因为编译器无法帮助维护内存安全,而让开发者来保证正确并不简单。
当有充足理由使用unsafe代码时,就得使用。通过显式标注的unsafe 注释使得在问题发生时更容易追踪问题的根源。
19.2 高级trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait和newtype
19.2.1. 在trait定义中使用关联类型来指定占位类型
我们首先在第10章的10.3. trait Pt.1:trait的定义、约束与实现 和 10.4. trait Pt.2:trait作为参数和返回类型、trait bound 中介绍了trait,但我们没有讨论更高级的细节。现在我们来深入了解
关联类型(associated type)是trait中的类型占位符,它可以用于trait方法的签名中。它用于定义出包某些类型的trait,而在实现前无需知道这些类型是什么。
看个例子:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
}
标准库中的负责迭代器部分的iterator trait(详见 13.8. 迭代器 Pt.4:创建自定义迭代器)就是一个带有关联类型的trait,其定义如上。
Item就是关联类型。在迭代过程中使用Item类型来替代实际出现的值以完成逻辑和实际数据类型分离的目的。可以看到next方法的返回值Option<Self:Item>就出现了Item。
Item就是所谓的类型占位符,其核心思想与泛型有点像,但区别也是有的:
| 泛型 | 关联类型 |
|---|---|
| 每次实现 Trait 时标注类型 | 无需标注类型 |
| 可以为一个类型多次实现某个 Trait(不同的泛型参数) | 无法为单个类型多次实现某个 Trait |
19.2.2. 默认泛型参数和运算符重载
我们可以在使用泛型参数时为泛型指定一个默认的具体类型。它的语法是<PlaceholderType=ConcreteType>。这种技术常用于运算符重载(operator overloading)。
虽然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,也就是重载了+这个运算符,具体来说就是Addtrait下的add函数把每个字段分别相加 - 主函数里就可以直接使用
+来把两个Point类型相加
Add trait的定义如下:
#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
}
它就使用了默认的泛型参数类型Rhs=Self。也就是说当我们实现Add trait时如果没有为Rhs指定一个具体的类型,那么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实现了Addtrait,其中又通过<Meters>显式地指明了类型被设定为Meters了。add函数中通过本身的毫米和传进来的米乘1000相加得出来以毫米计数的值。
19.2.3. 默认泛型参数的主要应用场景
- 扩展一个类型而不破坏现有代码
- 允许在大部分用户都不需要的特定场景下进行自定义
19.2.4. 完全限定语法(Fully Qualified Syntax)如何调用同名方法
直接看例子:
#![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方法,如果我们在主函数中调用:
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();
}
在方法名称之前指定特征名称可以向 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方法。- 在主函数里使用了
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这个方法又没有参数,所以不知道是哪个类型上的实现。
针对这种情况就得使用完全限定语法。其形式为:
#![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就是当前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是Display trait下的方法)。其写法就是trait关键字 + trait名字 + : + supertrait。
假如我们有一个结构体Point,想要通过OutlinePrint trait的outline_print函数在终端打印出来。又因为OutlinePrint trait需要Display trait的函数,所以得为它同时实现OutlinePrint trait和Display trait,不然就会报错:
#![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或类型定义在本地包时,才能为该类型实现这个trait。而我们可以使用newtype模式来绕过这一规则,具体来说就是利用元组结构体来构建一个新的类型放在本地。
看个例子:
我们想为Vector实现Display trait,但是Vector和Display trait都定义在在外部包中,所以无法直接为Vector实现。所以把Vector包裹在自己创建的元组结构体Wrapper里,然后用Wrapper来实现Display trait:
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. 函数指针(function pointer)
我们之前讲过如何把闭包传给函数,而实际上我们还可以把函数传递给函数。
在传递过程中,函数会被强制转换为fn类型,也就是函数指针(function pointer)。
看个例子:
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为约束的泛型参数。
函数指针实现了全部3种闭包trait,也就是Fn、FnOnce和FnMut这三个trait。所以总是可以把函数指针用作参数传递给一个接收闭包的函数。正是因为这个原因,我们倾向于搭配闭包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这个Vector里的元素是i32类型,而后文我们想要把list_of_numbers的元素转换为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使用了前一篇文章所说的完全限定语法的知识。
我们看看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的初始化函数调用map范围内的每个u32值来创建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 have an unboxed trait object
--> 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() -> 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)
}
}
由于返回值是一个指针,所以这个时候返回类型就有固定的大小(usize)了。
19.4 宏(macro)
必须要说,这篇文章的内容你一定不可能一次性完全消化,除非你是身经百战的程序员。当然也没多大的必要完全掌握,实战中真的很少遇到要必须写宏才能解决的问题。宏的概念非常复杂,不可能是一篇文章讲得完的,这里只做了一个广却不精的介绍。如果真要系统性了解必须得依靠一个专栏的文章量。如果你真想深入了解,不妨看 The Little Book of Rust Macros。
19.4.1. 什么是宏
宏(macro)在Rust里指的是一组相关特性的集合称谓:
- 使用
macro_rules!构建的声明宏(declarative macro) - 3种过程宏:
- 自定义派生宏,用于
struct或enum,可以为其指定随derive属性添加的代码 - 类似属性的宏,在任何条目上添加自定义属性
- 类似函数的宏,看起来像函数调用,对其指定为参数的token进行操作
- 自定义派生宏,用于
19.4.2. 函数与宏的差别
- 从本质上来讲,宏是用来编写可以生成其它代码的代码,也就是所谓的元编程(metaprogramming)。
- 函数在定义签名时,必须声明参数的个数和类型;宏可以处理可变的参数。
- 编译器会在解释代码前展开宏
- 宏的定义比函数复杂很多,难以阅读、理解和维护
- 在某个文件调用宏时,必须提前定义宏或将宏引入当前作用域;函数可以在任何位置定义并在任何位置使用。
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!(用于创建Vector)这个宏的简化定义版本,我们一行一行地看:
#[macro_export]这个标注意味着这个宏会在它所处的包被引入作用域后才可以使用,缺少了这个标注的宏就不能被引入作用域macro_rules!是声明宏的关键字,这个宏的名称叫vec,后边的{}内的东西就是宏的定义体。- 定义体里的东西有点类似于
match的模式匹配,有点像match的分支,而这里实际上只有一个分支。虽然我们一直说定义体里的东西有点类似于match的模式匹配,但它和match有本质区别:match匹配的是模式,而它匹配的是Rust的代码结构。 ( $( $x:expr ),* )是它的模式,后面是代码。由于这里只有一个模式,所以任何其它的模式都会导致编译时的错误。某些比较复杂的宏就可能包含多个分支。 首先,我们使用一组括号来包含整个模式。我们使用美元符号 ($) 在宏系统中声明一个变量,该变量将包含与模式匹配的Rust代码。美元符号清楚地表明这是一个宏变量,而不是常规的Rust变量。接下来是一组括号,它们捕获与括号内的模式匹配的值,以便在替换代码中使用。$()中是$x:expr,它匹配任何 Rust 表达式,并为表达式指定名称$x。*意味着这个模式能够匹配0个或是多个*之前的东西。 假入我们写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. 基于属性来生成代码的过程宏
宏的第二种形式是过程宏,它的作用更像是一个函数(或者叫某种形式的过程)。过程宏接受一些代码作为输入,对该代码进行操作,并生成一些代码作为输出,而不是像声明性宏那样匹配模式并用其他代码替换代码。
一共有三种过程宏:
- 自定义派生宏
- 类属性宏
- 类函数宏
创建过程宏时,定义必须单独放在其自己的包中,并且使用特殊的包类型。这是出于复杂的技术原因。Rust也在致力于消除这个要求,但起码目前还没做到。
看个例子:
#![allow(unused)]
fn main() {
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
}
some_attribute是一个用来指定过程宏类型的占位符- 下面定义了过程宏的函数,接收一个
TokenStream的值作为参数,产生一个TokenStream的值作为输出。TokenStream是在pro_macro包中定义的,它表示一段标记序列,而这也是过程宏的核心所在:需要被宏处理的源代码就组成了输入的TokenStream,而宏生成的代码则组成了输出的TokenStream。 函数附带的属性决定了我们究竟创建的是哪一种过程宏,同一个包装可以拥有多种不同类型的过程宏。
自定义派生(derive)宏
我们通过一个例子来看:
创建一个名为hello_macro的包,定义一个拥有关联函数hello_macro的HelloMacro trait。我们要提供一个能自动实现trait的过程,使得用户在类型上标注#[derive(HelloMacro)]就能得到hello_macro的默认实现
首先我们需要创建一个新的工作空间(workspace),其它的项目都在工作空间之下。创建并打开Cargo.toml:
touch Cargo.toml
在里面这么写:
[workspace]
members = [
"hello_macro",
"hello_macro_derive",
"pancakes",
]
首先创建库crate,输入指令(注意指令应该执行在工作空间的路径下):
cargo new hellow_macro --lib
在hello_macro的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功能,所以他们就必须为每一个希望使用到hello_macro功能的函数编写出类似的代码,这就非常的繁琐。
所以我们就会想使用过程宏来生成相关的代码。而且在这里面打印的话需要把类型名打印进去,它是可变的。比如说类型是Pancakes就打印“Hello, Macro! My name is Pancakes!“,如果是Apple就打印“Hello, Macro! My name is Apple!”。由于Rust没有反射,所以只能使用宏。
过程宏需要自己的库,所以在工作空间的目录下要再创建一个库crate,输入指令:
cargo new hellow_macro_derive --lib
hellow_macro_derive就是过程宏所在的crate。hellow_macro的宏写在hellow_macro_derive里是命名的惯例。
在这个crate的Cargo.toml里添加(不要覆盖原本的内容!!!)这部分内容:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
会用到syn和quote这两个包,所以把它们添加为依赖。
然后看一下这个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 {
// Construct a representation of Rust code as a syntax tree
// 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 gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
}
- 通过
pro_macro提供的编译器接口从而在代码中读取和执行Rust代码。由于它被内置在Rust里,所以不需要把它添加为依赖项。 syn包是用来把Rust代码从字符转化为可供我们进一步操作的数据结构quote包将syn产生的数据结构重新转化为Rust代码
这三个包使得解析Rust代码变得相当轻松。得知道,要编写一个完整的Rust代码解析器可不是一件简单的事。
简单地讲一下这里的逻辑:
- 函数
hello_macro_derive负责解析TokenStream impl_hello_macro负责转换语法树(ast)
hello_macro_derive的代码在每一个过程宏的创建中都是大差不差的,不同的就是里面的impl_hello_macro。它实现的效果是用户在某个类型标注#[derive(HelloMacro)]的时候,下边的hello_macro_derive函数就会被自动地调用。
能够实现自动调用的原因是我们在定义宏时使用了#[proc_macro_derive(HelloMacro)],而且属性我们指明了是HelloMacro trait。
这个函数首先会把输入的TokenStream转化为一个可供我们解释和操作的数据结构,通过syn::parse函数把TokenStream作为输入,输出DeriveInput结构体,表示解析后的Rust代码。以上文的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 gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.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转换为字符串文字来保存分配。
写完这些以后来编译这两个包(使用cargo build 包名即可,注意路径哦,否则会找不到crate),然后创建一个二进制crate(一样在工作空间的目录下):
cargo new pancakes
在pancakes这个crate的Cargo.toml里添加(不要覆盖其他内容!!!)这些:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
添加上hello_macro和hello_macro_derive这两个依赖项。
在pancakes这个crate的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函数。
除此之外,属性宏和派生宏的工作方式几乎一样,都需要建立一个pro_macro的包并提供生成相应代码的函数。
类似函数的宏
类似函数的宏又叫做函数宏。它的定义看起来像函数调用的宏。类似于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 中(具体来说是19.2.6. 使用newtype模式在外部类型上实现外部trait)我们就使用了newtype模式为Vector实现了Display trait。
在19.2.2. 默认泛型参数和运算符重载中我们还写过一个 Millimeters和Meters结构体用来分别存储毫米和米的数据,由于两个数据并不能直接相加减也就避免了单位混用的问题。
我们还可以使用newtype模式来抽象出类型还有其他一些特性:
- 新类型可以公开与私有内部类型的API不同的公共API
- 新类型还可以隐藏内部实现(在 17.1.2. 封装 中提到过)
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特征函数签名最终看起来像这样:
#![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.2.?运算符 中有讲)。
19.5.3. never类型
Rust 有一个特殊类型叫!,这在类型理论术语中被称为空类型,因为它没有值。我们更喜欢称其为never类型,因为它写在函数返回值类型的位置。
举个例子:
#![allow(unused)]
fn main() {
fn bar() -> ! {
}
}
这段代码被解读为“函数bar永不会返回”。从不返回的函数称为发散函数。
那么never类型有什么作用呢?让我们以第二章猜数游戏的一段代码为例:
#![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返回的num类型是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也是!,因为loop执行的无尽循环不会结束,所以就不可能有返回值。然而,如果我们包含一个break ,情况就不是这样了,因为循环在到达break时就会终止。
19.5.4. 动态大小和和Sized trait
Rust 需要了解有关其类型的某些详细信息,例如为特定类型的值分配多少空间。这使得动态大小类型(dynamically sized types) 这个概念有些迷惑人。它有时被称为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是一个存储了内存地址的单个值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。因为类型可能没实现Sizedtrait,就是动态大小类型,所以我们需要用指针包裹动态大小类型。
使用动态大小类型的最好场景是与trait配合时:有时候我们会要求某些数据必须实现某些trait或是指定的生命周期,但不知道具体是什么类型,所以就可以使用指针包裹动态类型的写法。如下例:
#![allow(unused)]
fn main() {
type Job = Box<dyn FnOnce() + Send + 'static>;
}
这个例子就使用了类型别名和指针包裹动态类型的写法,Job可以是任何同时能实现FnOnce() trait、Send trait和'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然后加载 Web 浏览器中的127.0.0.1:7878。浏览器应该显示一条错误消息,例如“连接重置”,因为服务器当前没有发回任何数据。但是当你查看终端时,你应该会看到浏览器连接到服务器时打印的几条消息。

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。
试一下:
终端输出的信息如下:
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-US,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序列结束。(CRLF代表回车和换行) 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::{
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
fs,
};
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\r\n" {
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.5. 总结
以下是源代码:
main.rs:
use std::{
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
fs,
};
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时会调用thread::sleep(Duration::from_secs(5));,这句话使代码的执行休眠5秒,也就是模拟的慢速请求。
然后打开两个浏览器窗口:一个用于http://127.0.0.1:7878/另一个为http://127.0.0.1:7878/sleep。如果像以前一样,您会看到它快速响应。但是如果你输入/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]: failed to resolve: use of undeclared type `ThreadPool`
--> src/main.rs:11:16
|
9 | let pool = ThreadPool::new(4);
| ^^^^^^^^^^ use of undeclared type `ThreadPool`
For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error
这个错误告诉我们我们需要一个ThreadPool类型或模块,所以我们现在就构建一个。
我们在lib.rs中写ThreadPool的相关代码,一方面保持了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 function or associated item named `new` found for struct `ThreadPool` in the current scope
--> src/main.rs:10:28
|
10 | let pool = ThreadPool::new(4);
| ^^^ function or associated item not found in `ThreadPool`
这个错误表明接下来我们需要创建一个名为的关联函数 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
|
15 | pool.execute(|| {
| -----^^^^^^^ method not found in `ThreadPool`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error
现在发生错误是因为我们在ThreadPool上没有execute方法。那就补充一个方法:
#![allow(unused)]
fn main() {
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
-
execute函数的参数除了self的应用还有一个闭包参数,运行请求的线程只会调用闭包一次,所以使用FnOnce(),()表示它是返回单位类型()的闭包。同时我们需要Sendtrait将闭包从一个线程传输到另一个线程,而'static是因为我们不知道线程执行需要多长时间。 -
也可以这么想:我们使用它替代的是原代码的
thread::spawn函数,所以修改时就可以借鉴它的函数签名,它的签名如下。我们主要借鉴的是泛型F和它的约束,所以excute函数的泛型约束就可以按照F来写。
#![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)的一句话是“if the code compiles, it works.如果代码可以编译,它就可以工作”。但这句话并不普遍正确。我们的项目可以编译,但它什么也没做。如果我们正在构建一个真实的、完整的项目,那么这是开始编写单元测试以检查代码是否编译并具有我们想要的行为的好时机(也就是TDD测试驱动开发)。
修改new函数 Pt.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下有threads字段,类型是Vec<thread::JoinHandle<()>>:
Vec<>是因为我们要存储多个线程,但是具体数量又未知,所以使用Vector- 之前我们看过
thread::spawn函数的函数签名,其返回值是JoinHandle<T>,依葫芦画瓢,我们就也使用thread::JoinHandle<>来存储线程。JoinHandle<T>有T是因为thread::spawn的线程有可能会有返回值,不知道具体什么类型,所以用泛型来表示。而我们的代码是确定没有返回值的,所以就写thread::JoinHandle<()>,()是单元类型。
修改new函数 Pt.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,表示标识;还有一个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函数和excute函数也需要修改,这里先修改new函数,excute等一下修改:
#![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方法即可以往Vector里添加新元素 - 在循环中使用到了
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: 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:26:42
|
22 | let (sender, receiver) = mpsc::channel();
| -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 | for id in 0..size {
| ----------------- inside of this loop
26 | 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:45:33
|
45 | fn new(id: usize, receiver: Receiver<Job>) -> Worker {
| --- in this method ^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
|
25 ~ let mut value = Worker::new(id, receiver);
26 ~ for id in 0..size {
27 ~ workers.push(value);
|
报错是因为该代码尝试将一个receiver传递给多个Worker实例,这是行不通的,因为接收端只能有一个。
我们希望所有的线程都共享一个receiver,从而能在线程间分发任务。此外,从通道队列中取出receiver涉及改变 receiver ,因此线程需要一种安全的方式来共享和修改receiver 。否则,我们可能会遇到竞争条件。
针对多线程多重所有权的要求,可以使用Arc<T>(Rc<T>只能用于单线程);针对多线程避免数据竞争的要求,可以使用互斥锁Mutex<T>。
这下只需要在原本的receiver上套Arc<T>和Mutex<T>就行了:
#![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函数
excute方法这么改了,那么作为接收端的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锁定了receiver(receiver被封装在互斥锁Mutex<T>里),获取互斥体,unwrap错误处理 - 再使用
recv方法从通道接收传过来的内容,再使用unwrap错误处理 - 打印一下是哪个
Worker在工作 - 当调用
job();时,编译器会自动将job解引用为其内部的闭包类型,然后调用FnOnce或其他相应的trait实现的call方法。这是因为Box<dyn FnOnce()>实现了FnOnce。也就是说,job();是(*job)();的语法糖。
版本差异
我使用的是1.84.0的Rust,在早期(大概是1.0版本附近)时不能直接使用job();,也不能使用(*job)();,因为当时编译器不直接知道动态大小类型所占用的内存大小,所以不能直接解码。在后来的 Rust RFC 127(实现于 Rust 1.20,发布于 2017 年) 之后,Rust 为Box<dyn Trait>等类型添加了直接调用trait方法的能力,这背后利用了自动解引用及调用调度逻辑。
总而言之,如果你写成上文代码那样要报错的话要么就升级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<FnBox + Send + 'static>
}
FnBoxtrait这个方法使得我们可以在类型的Box上调用了- 为
FnOnce()写了call_box的具体实现(因为Job实现了FnOnce()),这样就可以获得Box里边东西的所有权,从而调用 - 把
Job的类型从FnOnce()改成FnBox,这样其它代码就可以不用修改,所有实现了FnBox的类型肯定同时实现了FnBox
20.2.4. 试运行
终于改完了,让我们试运行一下:
如果你在浏览器里多刷新几次界面就能看到其它不同id的Worker在工作。
20.2.5. 总结
main.rs:
use std::{
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
fs,
};
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) = 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();
}
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:49:13
|
49 | 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/mod.rs:1863:17
|
1863 | pub fn join(self) -> Result<T> {
| ^^^^
报错信息显示我们无法把worker的thread字段移出来,因为我们只有每个worker的可变引用但join方法要求我们获得worker的所有权。
为了实现取得所有权的要求,我们需要修改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为Some变体时里的值(使用take方法可以获得所有权而不是可变引用)。
20.3.2. 向线程发出信号以退出
这么修改编译能够通过了,但是还没到达效果。调用drop方法并不会真正地关停线程,因为线程还在loop循环中持续地等待任务。
如果我们用这个drop方法丢弃ThreadPool主线程就会永远阻塞,以等待第一个线程的结束(每个线程里都一直在loop寻找作业,不会跳出循环)。
我们需要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的调用都将返回错误,也就停止了运行。
#![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),
}
}
}
返回值的sender字段使用Some变体包裹。
#![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();
}
}
}
使用as_ref就可以避免所有权问题:send需要所有权,但是不能给(excute函数的参数是引用&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(_) => break,
}
});
}
取消掉了job的最后一个unwrap,转而使用match分支来操作:Ok变体就执行job,Err变体就退出。
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.");
}
输出:
你可能会看到不同的信息,因为线程池中哪个线程得到工作是随机的,但是应该是大致类似的。
20.3.4. 总结
main.rs:
use std::{
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
fs,
};
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) = 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();
}
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(_) => 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>