Rust by Example
Rust 是一门现代系统编程语言,专注于安全性、速度和并发性。通过内存安全而不使用垃圾回收来实现这些目标。
《通过例子学 Rust》(Rust By Example, RBE)是一系列可运行的示例,它们展示了各种 Rust 概念和标准库。为了更好地利用这些示例,请不要忘记本地安装 Rust并查看官方文档。此外,好奇的话,你也可以查看这个网站的源代码。
现在让我们开始吧!
-
Hello World - 从一个经典的 Hello World 程序开始。
-
Primitives - 学习有符号整数、无符号整数和其他原生类型。
-
自定义类型 - 结构体
struct
和枚举enum
。 -
变量绑定 - 可变绑定、作用域、遮蔽。
-
类型 - 学习如何改变和定义类型。
-
转换 - 在不同类型的数据之间进行转换,如字符串、整数和浮点数。
-
表达式 - 学习表达式及其使用方法。
-
控制流 -
if
/else
、for
等。 -
函数 - 学习方法、闭包和高阶函数。
-
模块 - 使用模块组织代码
-
Crates - Crate 是 Rust 中的编译单元。学习如何创建库。
-
Cargo - 了解官方 Rust 包管理工具的一些基本功能。
-
属性 - 属性是应用于某些模块、crate 或项的元数据。
-
泛型 - 学习编写可以适用于多种类型参数的函数或数据类型。
-
作用域规则 - 作用域在所有权(ownership)、借用(borrowing)和生命周期(lifetime)中扮演重要角色。
-
特质 - 特质(trait)是为未知类型
Self
定义的一组方法。 -
宏 - 宏是一种编写代码以生成其他代码的方式,也被称为元编程。
-
错误处理 - 学习 Rust 处理失败的方式。
-
标准库类型 - 学习
std
标准库提供的一些自定义类型。 -
标准库中的其他内容 - 更多关于文件处理、线程的自定义类型。
-
测试 - Rust 中的各种测试方法。
-
不安全操作 - 学习如何编写和使用不安全代码块。
-
兼容性 - 应对 Rust 语言的演进及可能出现的兼容性问题。
-
补充 - 文档,基准测试。
Hello World
这是经典的 Hello World 程序的源代码。
println!
是一个用于在控制台打印文本的宏。
可以使用 Rust 编译器 rustc
来生成可执行文件。
$ rustc hello.rs
rustc
将生成一个名为 hello
的可执行文件。
$ ./hello
Hello World!
练习
点击上方的"运行"按钮查看预期输出。接下来,添加一行新代码,再次使用 println!
宏,输出显示如下内容:
Hello World!
I'm a Rustacean!
注释
每个程序都需要注释,Rust 支持几种不同类型的注释:
- 普通注释:编译器会忽略这些注释
// 行注释,从双斜杠开始到行尾。
/* 块注释,从开始符号到结束符号。 */
- 文档注释:这些注释会被解析成 HTML 格式的库文档
/// 为接下来的项生成库文档。
//! 为当前项(如 crate、模块或函数)生成库文档。
另请参阅:
格式化打印
打印功能由 std::fmt
中定义的一系列宏
处理,其中包括:
format!
:将格式化文本写入String
print!
:与format!
类似,但文本会打印到控制台(io::stdout)println!
:与print!
类似,但会在末尾添加换行符eprint!
:与print!
类似,但文本会打印到标准错误输出(io::stderr)eprintln!
:与eprint!
类似,但会在末尾添加换行符
所有这些宏都以相同的方式解析文本。此外,Rust 会在编译时检查格式化的正确性。
std::fmt
包含许多控制文本显示的 traits
。下面列出了两个重要的基本形式:
fmt::Debug
: 使用{:?}
标记。用于调试目的的文本格式化。fmt::Display
: 使用{}
标记。以更优雅、用户友好的方式格式化文本。
这里我们使用 fmt::Display
,因为标准库为这些类型提供了实现。要打印自定义类型的文本,需要额外的步骤。
实现 fmt::Display
特性会自动实现 ToString
特性,这允许我们将该类型转换为String
。
在 43 行,#[allow(dead_code)]
是一个 属性(attribute),它只适用于它之后的模块。
练习
- 修复上述代码中的问题(参见 FIXME 注释),使其能够正常运行。
- 尝试取消注释那行尝试格式化
Structure
结构体的代码(参见 TODO 注释) - 添加一个
println!
宏调用,打印:Pi 约等于 3.142
,通过控制显示的小数位数来实现。 在本练习中,使用let pi = 3.141592
作为 pi 的近似值。(提示:你可能需要查阅std::fmt
文档来了解如何设置显示的小数位数)
另请参阅:
std::fmt
、macros
、 struct
、traits
和 dead_code
调试 Debug
所有想要使用 std::fmt
格式化 traits
的类型都需要实现才能打印。 自动实现仅为 std
库中的类型提供。所有其他类型都必须以某种方式手动实现。
fmt::Debug
trait 使这变得非常简单。所有类型都可以 derive
(自动创建) fmt::Debug
实现。但这对 fmt::Display
不适用,后者必须手动实现。
所有 std
库类型也可以自动使用 {:?}
打印:
所以 fmt::Debug
确实使其可打印,但牺牲了一些优雅。 Rust 还提供了使用 {:#?}
进行"美化打印"的功能。
可以手动实现 fmt::Display
来控制显示方式。
另请参阅:
attributes
、 derive
、std::fmt
和 struct
显示 Display
fmt::Debug
的输出往往不够简洁清晰,因此自定义输出外观通常更有优势。 这可以通过手动实现 fmt::Display
来完成, 它使用 {}
打印标记。实现方式如下:
fmt::Display
可能比 fmt::Debug
更简洁,但这给 std
库带来了一个问题:如何显示歧义类型?例如,如果 std
库为所有 Vec<T>
实现统一的样式,应该采用哪种样式?是以下两种之一吗?
Vec<path>
:/:/etc:/home/username:/bin
(以:
分隔)Vec<number>
:1,2,3
(以,
分隔)
答案是否定的。因为不存在适用于所有类型的理想样式,std
库也不应擅自规定一种。因此,fmt::Display
并未为 Vec<T>
或其他泛型容器实现。在这些泛型情况下,必须使用 fmt::Debug
。
不过,这不是问题。对于任何新的非泛型容器类型,都可以实现 fmt::Display
。
因此,虽然实现了 fmt::Display
,但未实现 fmt::Binary
,所以无法使用。std::fmt
包含许多这样的traits
,每个都需要单独实现。更多详情请参阅 std::fmt
。
练习
查看上述示例的输出后,参考 Point2D
结构体,向示例中添加一个 Complex
结构体。以相同方式打印时,输出应为:
Display: 3.3 + 7.2i
Debug: Complex { real: 3.3, imag: 7.2 }
另请参阅:
derive
、std::fmt
、macros
、struct
、trait
和 use
测试实例:列表
为一个需要顺序处理元素的结构体实现 fmt::Display
是棘手的。问题在于每个 write!
都会生成一个 fmt::Result
。正确处理这种情况需要处理所有的结果。Rust 提供了 ?
运算符专门用于此目的。
在 write!
上使用 ?
的示例如下:
// 尝试执行 `write!`,检查是否出错。如果出错,返回错误。
// 否则继续执行。
write!(f, "{}", value)?;
有了 ?
运算符,为 Vec
实现 fmt::Display
就变得简单明了:
练习
尝试修改程序,使向量中每个元素的索引也被打印出来。新的输出应该如下所示:
[0: 1, 1: 2, 2: 3]
另请参阅:
for
、ref
、Result
、struct
、?
和 vec!
格式化
我们已经看到,格式化是通过一个_格式字符串_来指定的:
format!("{}", foo)
->"3735928559"
format!("0x{:X}", foo)
->"0xDEADBEEF"
format!("0o{:o}", foo)
->"0o33653337357"
同一个变量(foo
)可以根据使用的参数类型而有不同的格式化方式:X
、o
或未指定。
这种格式化功能是通过 trait 实现的,每种参数类型都对应一个 trait。最常用的格式化 trait 是 Display
,它处理参数类型未指定的情况,例如 {}
。
你可以在 std::fmt
文档中查看格式化 trait 的完整列表及其参数类型。
练习
为上面的 Color
结构体实现 fmt::Display
trait,使输出显示如下:
RGB (128, 255, 90) 0x80FF5A
RGB (0, 3, 254) 0x0003FE
RGB (0, 0, 0) 0x000000
如果你遇到困难,这里有三个提示:
- RGB 颜色空间中颜色的计算公式是:
RGB = (R*65536)+(G*256)+B,(其中 R 是红色,G 是绿色,B 是蓝色)
。更多信息请参见 RGB 颜色格式和计算。 - 你可能需要多次列出每种颜色。
- 你可以使用
:0>2
用零填充到宽度为 2。
另请参阅:
原生类型
Rust 提供了多种原生类型
。以下是一些示例:
标量类型
- 有符号整数:
i8
、i16
、i32
、i64
、i128
和isize
(指针大小) - 无符号整数:
u8
、u16
、u32
、u64
、u128
和usize
(指针大小) - 浮点数:
f32
、f64
char
Unicode 标量值,如'a'
、'α'
和'∞'
(每个都是 4 字节)bool
值为true
或false
- 单元类型
()
,其唯一可能的值是空元组:()
尽管单元类型的值是一个元组,但它不被视为复合类型,因为它不包含多个值。
复合类型
- 数组,如
[1, 2, 3]
- 元组,如
(1, true)
变量总是可以进行类型标注。数字还可以通过后缀或默认方式来标注。整数默认为 i32
类型,浮点数默认为 f64
类型。请注意,Rust 也可以从上下文中推断类型。
另请参阅:
std
库、mut
、inference
和 shadowing
字面量和运算符
整数 1
、浮点数 1.2
、字符 'a'
、字符串 "abc"
、布尔值 true
和单元类型 ()
可以用字面值表示。
整数也可以使用十六进制、八进制或二进制表示法,分别使用这些前缀:0x
、0o
或 0b
。
可以在数字字面值中插入下划线以提高可读性,例如 1_000
与 1000
相同,0.000_001
与 0.000001
相同。
Rust 还支持科学计数法 E-notation,例如 1e6
、7.6e-4
。相关类型是 f64
。
我们需要告诉编译器我们使用的字面值的类型。现在,我们将使用 u32
后缀来表示该字面值是一个无符号 32 位整数,使用 i32
后缀来表示它是一个有符号 32 位整数。
Rust 中可用的运算符及其优先级与其他 类 C 语言 类似。
元组
元组是一个可以包含各种类型的值的集合。元组使用圆括号 ()
来构造,而且每个元组本身是一个类型标记为 (T1, T2, ...)
的值,其中 T1
、T2
是其成员的类型。函数可以使用元组来返回多个值,因为元组可以存储任意数量的值。
练习
-
复习:为上面示例中的
Matrix
结构体添加fmt::Display
trait, 这样当你从打印调试格式{:?}
切换到显示格式{}
时, 你会看到以下输出:( 1.1 1.2 ) ( 2.1 2.2 )
你可能需要回顾一下 打印显示 的示例。
-
参考
reverse
函数的模板,添加一个transpose
函数。 该函数接受一个矩阵作为参数,并返回一个交换了两个元素的矩阵。例如:println!("矩阵:\n{}", matrix); println!("转置:\n{}", transpose(matrix));
输出结果为:
Matrix: ( 1.1 1.2 ) ( 2.1 2.2 ) Transpose: ( 1.1 2.1 ) ( 1.2 2.2 )
数组和切片
数组是一种存储在连续内存中的相同类型 T
的对象集合。 数组使用方括号 []
创建,其长度在编译时已知, 是其类型签名 [T; length]
的一部分。
切片类似于数组,但其长度在编译时未知。切片是一个双字对象: 第一个字是指向数据的指针,第二个字是切片的长度。字的大小与 usize 相同, 由处理器架构决定,例如在 x86-64 上是 64 位。切片可用于借用数组的一部分, 其类型签名为 &[T]
。
自定义类型
Rust 的自定义数据类型主要通过以下两个关键字来创建:
struct
:定义结构体enum
:定义枚举
常量也可以通过 const
和 static
关键字来创建。
结构体
使用 struct
关键字可以创建三种类型的结构体("structs"):
- 元组结构体:本质上是具名元组。
- 经典的 C 语言风格结构体
- 单元结构体:没有字段,在泛型中很有用。
练习
- 添加一个
rect_area
函数来计算Rectangle
的面积(尝试使用嵌套解构)。 - 添加一个
square
函数,它接受一个Point
和一个f32
作为参数,返回一个Rectangle
,其左上角在该点上,宽度和高度都等于f32
参数。
另请参阅
枚举
enum
关键字允许创建一个可能是几种不同变体之一的类型。任何作为 struct
有效的变体在 enum
中也是有效的。
类型别名
使用类型别名可以通过别名引用每个枚举变体。当枚举名称过长或过于泛化,而你想重命名它时,这会很有用。
你最常见到这种用法的地方是在使用 Self
别名的 impl
块中。
要了解更多关于枚举和类型别名的信息,你可以阅读这个特性被稳定到 Rust 时的稳定化报告。
另请参阅:
match
、fn
、String
和 "类型别名枚举变体" RFC
use
use
声明可以用来避免手动作用域限定:
另请参阅:
C 风格用法
enum
也可以像 C 语言那样使用。
另请参阅:
测试实例:链表
使用 enum
是实现链表的常见方法:
另请参阅:
常量
Rust 有两种常量类型,可以在任何作用域(包括全局作用域)中声明。两者都需要显式类型标注:
另请参阅:
变量绑定
Rust 通过静态类型提供类型安全。变量绑定在声明时可以添加类型注解。然而,在大多数情况下,编译器能够从上下文推断出变量类型,大大减少了类型注解的负担。
可以使用 let
关键字将值(如字面量)绑定到变量。
可变性
变量绑定默认是不可变的,但可以使用 mut
修饰符来改变这一行为。
编译器会对可变性错误给出详细的诊断信息。
作用域和遮蔽
变量绑定有作用域,它们被限制在一个代码块中生存。代码块是由花括号 {}
包围的一系列语句。
此外,Rust 允许变量遮蔽。
先声明
It is possible to declare variable bindings first and initialize them later, but all variable bindings must be initialized before they are used: the compiler forbids use of uninitialized variable bindings, as it would lead to undefined behavior.
It is not common to declare a variable binding and initialize it later in the function. It is more difficult for a reader to find the initialization when initialization is separated from declaration. It is common to declare and initialize a variable binding near where the variable will be used.
冻结
当数据以相同名称被不可变地绑定时,它也会冻结。被冻结的数据在不可变绑定离开作用域之前不能被修改:
类型
Rust 提供了几种机制来更改或定义原生类型和用户定义类型。以下部分将介绍:
类型转换
Rust 不支持原始类型之间的隐式类型转换(强制转换)。但可以使用 as
关键字进行显式类型转换(转型)。
整数类型之间的转换规则通常遵循 C 语言惯例,但 C 中存在未定义行为的情况除外。在 Rust 中,所有整数类型之间的转换行为都有明确定义。
字面量
数字字面值可以通过添加类型后缀进行类型标注。例如,要指定字面值 42
的类型为 i32
,可以写成 42i32
。
无后缀数字字面值的类型取决于其使用方式。如果没有约束,编译器将对整数使用 i32
,对浮点数使用 f64
。
前面的代码中使用了一些尚未解释的概念。为了满足迫不及待的读者,这里简要说明如下:
std::mem::size_of_val
是一个函数,这里使用了它的"完整路径"来调用。代码可以被划分为称为"模块"的逻辑单元。在这个例子中,size_of_val
函数定义在mem
模块中,而mem
模块则定义在std
crate 中。更多详情请参阅模块和crate。
类型推断
类型推断引擎相当智能。它不仅在初始化时分析值表达式的类型,还会根据变量后续的使用方式来推断其类型。下面是一个类型推断的高级示例:
无需为变量添加类型注解,编译器和程序员都很满意!
别名
type
语句用于为现有类型创建新名称。类型名必须使用 UpperCamelCase
(大驼峰)命名,否则编译器会发出警告。此规则的例外是原始类型,如 usize
、f32
等。
别名的主要用途是减少重复代码。例如,io::Result<T>
类型是 Result<T, io::Error>
类型的别名。
另请参阅:
转换
原始类型可以通过类型转换相互转换。
Rust 通过使用特质来处理自定义类型(如 struct
和 enum
)之间的转换。通用转换使用 From
和 Into
特质。然而,对于更常见的情况,特别是与 String
相互转换时,还有一些更具体的特质。
From
和 Into
From
和 Into
特质本质上是相互关联的,这实际上是其实现的一部分。如果你能将类型 A 从类型 B 转换,那么我们也应该能够将类型 B 转换为类型 A。
From
From
特质允许一个类型定义如何从另一个类型创建自身,从而提供了一种非常简单的机制来在多种类型之间进行转换。标准库中有许多这个特质的实现,用于原始类型和常见类型的转换。
例如,我们可以轻松地将 str
转换为 String
我们可以为自己的类型定义类似的转换。
Into
Into
特质简单来说就是 From
特质的反向操作。它定义了如何将一个类型转换为另一个类型。
调用 into()
通常需要我们指定结果类型,因为编译器大多数时候无法确定这一点。
From
and Into
are interchangeable
From
和 Into
被设计为互补的。我们不需要为两个特质都提供实现。如果你为你的类型实现了 From
特质,Into
会在必要时调用它。但请注意,反过来并不成立:为你的类型实现 Into
不会自动为它提供 From
的实现。
TryFrom
和 TryInto
与 From
和 Into
类似,TryFrom
和 TryInto
是用于类型转换的泛型特质。与 From
/Into
不同,TryFrom
/TryInto
特质用于可能失败的转换,因此返回 Result
。
String 类型转换
转换为字符串
To convert any type to a String
is as simple as implementing the ToString
trait for the type. Rather than doing so directly, you should implement the fmt::Display
trait which automatically provides ToString
and also allows printing the type as discussed in the section on print!
.
解析字符串
将字符串转换为其他类型很有用,其中最常见的操作之一是将字符串转换为数字。惯用的方法是使用 parse
函数,可以通过类型推断或使用"涡轮鱼"语法指定要解析的类型。以下示例展示了这两种方法。
只要为目标类型实现了 FromStr
特质,就可以将字符串转换为指定的类型。标准库中为许多类型实现了这个特质。
要在自定义类型上获得这个功能,只需为该类型实现 FromStr
特质。
表达式
Rust 程序(主要)由一系列语句组成:
Rust 中有几种语句。最常见的两种是声明变量绑定,以及在表达式后使用分号 ;
:
代码块也是表达式,因此可以在赋值中作为值使用。代码块中的最后一个表达式会被赋值给左值表达式(如局部变量)。但是,如果代码块的最后一个表达式以分号结尾,返回值将是 ()
。
控制流
控制流是任何编程语言的重要组成部分,如 if
/else
、for
等。让我们来讨论 Rust 中的这些内容。
if/else
if
-else
分支结构与其他语言类似。不同之处在于,布尔条件不需要用括号括起来,每个条件后面都跟着一个代码块。if
-else
条件是表达式,所有分支必须返回相同的类型。
loop
Rust 提供 loop
关键字来表示无限循环。
break
语句可以随时退出循环,而 continue
语句可以跳过当前迭代的剩余部分并开始下一次迭代。
嵌套和标签
在处理嵌套循环时,可以 break
或 continue
外层循环。这种情况下,循环必须用 'label
标记,并且必须将标签传递给 break
/continue
语句。
在 loop 中返回值
loop
的一个用途是重试操作直到成功。如果操作返回一个值,你可能需要将它传递给代码的其余部分:将它放在 break
之后,它将被 loop
表达式返回。
while
while
关键字用于在条件为真时运行循环。
让我们用 while
循环来编写著名的 FizzBuzz 程序。
for 循环
for 和 range
for in
结构可用于遍历 Iterator
。创建迭代器最简单的方法之一是使用区间表示法 a..b
。这会生成从 a
(包含)到 b
(不包含)的值,步长为 1。
让我们用 for
而不是 while
来编写 FizzBuzz。
另外,可以使用 a..=b
表示两端都包含的范围。上面的代码可以改写为:
for 与迭代器
for in
结构能以多种方式与 Iterator
交互。正如在 Iterator 特质一节中讨论的那样,默认情况下 for
循环会对集合应用 into_iter
函数。然而,这并不是将集合转换为迭代器的唯一方法。
into_iter
、iter
和 iter_mut
都以不同的方式处理集合到迭代器的转换,通过提供对数据的不同视图。
iter
- 在每次迭代中借用集合的每个元素。因此,集合保持不变,并且在循环之后可以重复使用。
into_iter
- 这会消耗集合,使得在每次迭代中提供确切的数据。一旦集合被消耗,它就不再可用于重复使用,因为它已经在循环中被"移动"了。
iter_mut
- 这会可变地借用集合的每个元素,允许在原地修改集合。
在上面的代码片段中,注意 match
分支的类型,这是迭代类型的关键区别。类型的差异意味着可以执行不同的操作。
另请参阅:
match
Rust 通过 match
关键字提供模式匹配,类似于 C 语言的 switch
。第一个匹配的分支会被求值,并且必须覆盖所有可能的值。
解构
match
块可以以多种方式解构项。
元组
元组可以在 match
中按如下方式解构:
另请参阅:
数组/切片
与元组类似,数组和切片也可以用这种方式解构:
另请参阅:
枚举
enum
的解构方式类似:
另请参阅:
指针/引用
对于指针,需要区分解构和解引用,因为它们是不同的概念,其用法与 C/C++ 等语言不同。
- 解引用使用
*
- 解构使用
&
、ref
和ref mut
另请参阅:
结构体
同样,struct
可以按如下方式解构:
另请参阅:
守卫
match
分支可以使用守卫进行额外的筛选。
注意,编译器在检查 match 表达式是否涵盖了所有模式时,不会考虑守卫条件。
另请参阅:
绑定
间接访问变量时,无法在分支中使用该变量而不重新绑定。match
提供了 @
符号,用于将值绑定到名称:
你也可以使用绑定来"解构" enum
变体,例如 Option
:
另请参阅:
if let
在某些情况下,使用 match
匹配枚举可能会显得繁琐。例如:
对于这种情况,if let
更加简洁,而且还允许指定各种失败时的处理选项:
同样地,if let
可以用来匹配任何枚举值:
if let
的另一个优点是它允许我们匹配非参数化的枚举变体。即使在枚举没有实现或派生 PartialEq
的情况下也是如此。在这种情况下,if Foo::Bar == a
将无法编译,因为枚举的实例无法进行相等比较,但 if let
仍然可以正常工作。
想要挑战一下吗?请修改以下示例,使用 if let
:
另请参阅:
let-else
🛈 自 Rust 1.65 版本起稳定
🛈 你可以通过这种方式编译来指定特定版本:
rustc --edition=2021 main.rs
let
-else
语法允许可能失败的模式匹配像普通 let
一样绑定变量到当前作用域,或在匹配失败时执行中断操作(如 break
、return
、panic!
)。
use std::str::FromStr;
fn get_count_item(s: &str) -> (u64, &str) {
let mut it = s.split(' ');
let (Some(count_str), Some(item)) = (it.next(), it.next()) else {
panic!("无法分割计数项对:'{s}'");
};
let Ok(count) = u64::from_str(count_str) else {
panic!("无法解析整数:'{count_str}'");
};
(count, item)
}
fn main() {
assert_eq!(get_count_item("3 chairs"), (3, "chairs"));
}
名称绑定的作用域是使其区别于 match
或 if let
-else
表达式的主要特点。在此之前,你可能需要通过一些冗余的重复和外部 let
来近似实现这些模式:
另请参阅:
Option、match、if let 和 let-else RFC。
while let
与 if let
类似,while let
可以简化繁琐的 match
序列。让我们来看一个递增 i
的例子:
使用 while let
可以让这个序列更加简洁:
另请参阅:
函数
函数使用 fn
关键字声明。函数参数需要标注类型,就像变量一样。如果函数返回值,则必须在箭头 ->
后指定返回类型。
函数的最后一个表达式将作为返回值。另外,可以使用 return
语句在函数内部提前返回值,甚至可以在循环或 if
语句内部使用。
让我们用函数重写 FizzBuzz 吧!
关联函数和方法
某些函数与特定类型相关联。这些函数有两种形式:关联函数和方法。关联函数是在类型上定义的函数,而方法是在类型的特定实例上调用的关联函数。
闭包
闭包是可以捕获周围环境的函数。例如,下面是一个捕获变量 x
的闭包:
|val| val + x
闭包的语法和功能使其非常适合即时使用。调用闭包与调用函数完全相同。不过,闭包的输入和返回类型可以被推断,而输入变量名必须指定。
闭包的其他特点包括:
- 使用
||
而不是()
来包围输入变量。 - 单行表达式可省略函数体定界符(
{}
),其他情况则必须使用 - 能够捕获外部环境的变量
捕获
闭包本质上很灵活,无需注解就能根据功能需求自动适应。这使得捕获可以灵活地适应不同场景,有时移动,有时借用。闭包可以通过以下方式捕获变量:
- 通过引用:
&T
- 通过可变引用:
&mut T
- 通过值:
T
闭包优先通过引用捕获变量,仅在必要时才使用更底部的的捕获方式。
在竖线前使用 move
强制闭包获取捕获变量的所有权:
另请参阅:
作为输入参数
Rust 通常能自动选择如何捕获变量,无需类型标注。但在编写函数时,这种模糊性是不允许的。当将闭包作为输入参数时,必须使用特定的 trait
来注解闭包的完整类型。这些 trait 由闭包对捕获值的处理方式决定。按限制程度从高到低排列如下:
Fn
:闭包通过引用使用捕获的值(&T
)FnMut
:闭包通过可变引用使用捕获的值(&mut T
)FnOnce
:闭包通过值使用捕获的值(T
)
编译器会以尽可能最少限制的方式逐个捕获变量。
例如,考虑一个注解为 FnOnce
的参数。这表示闭包可能通过 &T
、&mut T
或 T
进行捕获,但编译器最终会根据捕获变量在闭包中的使用方式来决定。
这是因为如果可以移动,那么任何类型的借用也应该是可能的。注意反过来并不成立。如果参数被注解为 Fn
,那么通过 &mut T
或 T
捕获变量是不允许的。但 &T
是允许的。
在下面的例子中,尝试交换 Fn
、FnMut
和 FnOnce
的用法,看看会发生什么:
另请参阅:
std::mem::drop
、Fn
、FnMut
、泛型、where 和 FnOnce
类型匿名
闭包能简洁地从外部作用域捕获变量。这会有什么影响吗?当然会有。注意观察如何将闭包作为函数参数使用时需要泛型,这是由于闭包的定义方式所决定的:
当定义一个闭包时,编译器会隐式创建一个新的匿名结构来存储内部捕获的变量,同时通过 Fn
、FnMut
或 FnOnce
这些 trait
之一为这个未知类型实现功能。这个类型被赋给变量并存储,直到被调用。
由于这个新类型是未知类型,在函数中使用时就需要泛型。然而,一个无界的类型参数 <T>
仍然会是模糊的,不被允许。因此,通过 Fn
、FnMut
或 FnOnce
这些 trait
之一(它实现的)来约束就足以指定其类型。
另请参阅:
输入函数
既然闭包可以作为参数使用,你可能会想知道函数是否也可以这样。确实可以!如果你声明一个函数,它接受一个闭包作为参数,那么任何满足该闭包 trait 约束的函数都可以作为参数传递。
另外需要注意的是,Fn
、FnMut
和 FnOnce
这些 trait 决定了闭包如何从外部作用域捕获变量。
另请参阅:
作为输出参数
既然闭包可以作为输入参数,那么将闭包作为输出参数返回也应该是可行的。然而,匿名闭包类型本质上是未知的,因此我们必须使用 impl Trait
来返回它们。
可用于返回闭包的有效 trait 包括:
Fn
FnMut
FnOnce
此外,必须使用 move
关键字,它表示所有捕获都是按值进行的。这是必要的,因为任何通过引用捕获的变量都会在函数退出时被丢弃,从而在闭包中留下无效的引用。
另请参阅:
std
中的例子
本节包含一些使用 std
库中闭包的示例。
Iterator::any
Iterator::any
是一个函数,它接受一个迭代器作为参数。如果任何元素满足给定的条件,则返回 true
,否则返回 false
。其签名如下:
pub trait Iterator {
// 被迭代的类型
type Item;
// `any` 接受 `&mut self`,意味着调用者可能被借用
// 和修改,但不会被消耗
fn any<F>(&mut self, f: F) -> bool where
// `FnMut` 表示任何捕获的变量最多只能被修改,不能被消耗
// `Self::Item` 表示它通过值将参数传递给闭包
F: FnMut(Self::Item) -> bool;
}
另请参阅:
通过迭代器搜索
Iterator::find
是一个函数,它遍历迭代器并搜索满足特定条件的第一个值。如果没有值满足条件,则返回 None
。其签名如下:
pub trait Iterator {
// 被迭代的类型
type Item;
// `find` 接受 `&mut self`,这意味着调用者可能被借用
// 和修改,但不会被消耗。
fn find<P>(&mut self, predicate: P) -> Option<Self::Item> where
// `FnMut` 表示任何捕获的变量最多只能被修改,不能被消耗。
// `&Self::Item` 表示它通过引用将参数传递给闭包。
P: FnMut(&Self::Item) -> bool;
}
Iterator::find
返回元素的引用。如果需获取元素的索引,则使用 Iterator::position
。
另请参阅:
std::iter::Iterator::rposition
高阶函数
Rust 提供了高阶函数(Higher Order Functions,HOF)。这些函数接受一个或多个函数作为参数,并/或产生一个更有用的函数。HOF 和惰性迭代器赋予了 Rust 函数式编程的特性。
Option 和 Iterator 实现了相当多的高阶函数。
发散函数
发散函数永不返回。它们使用 !
标记,这是一个空类型。
与所有其他类型相反,这个类型不能被实例化,因为这个类型可能拥有的所有可能值的集合是空的。注意,它与 ()
类型不同,后者恰好有一个可能的值。
例如,这个函数像往常一样返回,尽管返回值中没有任何信息。
fn some_fn() {
()
}
fn main() {
let _a: () = some_fn();
println!("这个函数返回了,你可以看到这一行。");
}
与之相对的是这个函数,它永远不会将控制权返回给调用者。
#![feature(never_type)]
fn main() {
let x: ! = panic!("此调用永不返回。");
println!("你永远不会看到这一行!");
}
虽然这看起来像是一个抽象概念,但它实际上非常有用且经常派上用场。这种类型的主要优势是它可以被转换为任何其他类型,这使得它在需要精确类型的情况下非常灵活,比如在 match 分支中。这种灵活性允许我们编写如下代码:
fn main() {
fn sum_odd_numbers(up_to: u32) -> u32 {
let mut acc = 0;
for i in 0..up_to {
// 注意这个 match 表达式的返回类型必须是 u32,
// 因为 "addition" 变量的类型是 u32。
let addition: u32 = match i%2 == 1 {
// "i" 变量的类型是 u32,这完全没问题。
true => i,
// 另一方面,"continue" 表达式不返回 u32,
// 但这仍然可以,因为它永远不会返回,
// 因此不违反 match 表达式的类型要求。
false => continue,
};
acc += addition;
}
acc
}
println!("9 以下(不包括 9)的奇数之和:{}", sum_odd_numbers(9));
}
它也是永远循环的函数(例如 loop {}
)的返回类型,比如网络服务器,或终止进程的函数(例如 exit()
)。
模块
Rust 提供了一个强大的模块系统,可以用来将代码分层地拆分成逻辑单元(模块),并管理它们之间的可见性(公有/私有)。
一个模块是一系列项的集合:函数、结构体、trait、impl
块,甚至其他模块。
可见性
默认情况下,模块中的项具有私有可见性,但可以使用 pub
修饰符来覆盖这一默认行为。只有模块中的公有项可以从模块作用域外部访问。
结构体可见性
结构体的字段具有额外的可见性级别。字段默认为私有,可以使用 pub
修饰符来覆盖。这种可见性只在从结构体定义模块外部访问时才有意义,其目的是实现信息隐藏(封装)。
另请参阅:
use
声明
use
声明可以将完整路径绑定到新名称,以便更轻松地访问。它通常这样使用:
你可以使用 as
关键字将导入绑定到不同的名称:
super
和 self
super
和 self
关键字可以在路径中使用,以消除访问项目时的歧义,并避免不必要的路径硬编码。
文件分层
模块可以映射到文件/目录层次结构。让我们将可见性示例拆分成多个文件:
$ tree .
.
├── my
│ ├── inaccessible.rs
│ └── nested.rs
├── my.rs
└── split.rs
在 split.rs
文件中:
// 这个声明会查找名为 `my.rs` 的文件,并将其内容
// 插入到当前作用域下名为 `my` 的模块中
mod my;
fn function() {
println!("调用了 `function()`");
}
fn main() {
my::function();
function();
my::indirect_access();
my::nested::function();
}
在 my.rs
文件中:
// 同样,`mod inaccessible` 和 `mod nested` 会分别定位到
// `nested.rs` 和 `inaccessible.rs` 文件,
// 并将它们的内容插入到这里对应的模块中
mod inaccessible;
pub mod nested;
pub fn function() {
println!("调用了 `my::function()`");
}
fn private_function() {
println!("调用了 `my::private_function()`");
}
pub fn indirect_access() {
print!("调用了 `my::indirect_access()`,它\n> ");
private_function();
}
在 my/nested.rs
文件中:
pub fn function() {
println!("调用了 `my::nested::function()`");
}
#[allow(dead_code)]
fn private_function() {
println!("调用了 `my::nested::private_function()`");
}
在 my/inaccessible.rs
文件中:
#[allow(dead_code)]
pub fn public_function() {
println!("调用了 `my::inaccessible::public_function()`");
}
让我们检查一下是否一切仍然如之前一样正常运行:
$ rustc split.rs && ./split
called `my::function()`
called `function()`
called `my::indirect_access()`, that
> called `my::private_function()`
called `my::nested::function()`
Crates
在 Rust 中,crate 是一个编译单元。每当执行 rustc some_file.rs
时,some_file.rs
就被视为crate 文件。如果 some_file.rs
中包含 mod
声明,那么模块文件的内容会在编译器处理之前被插入到 crate 文件中 mod
声明的位置。换句话说,模块不会被单独编译,只有 crate 才会被编译。
一个 crate 可以被编译成二进制文件或库。默认情况下,rustc
会将 crate 编译成二进制文件。通过向 rustc
传递 --crate-type
标志并指定 lib
,可以改变这一默认行为。
创建库
让我们创建一个库,然后看看如何将它链接到另一个 crate。
在 rary.rs
文件中:
pub fn public_function() {
println!("调用了 rary 的 `public_function()`");
}
fn private_function() {
println!("调用了 rary 的 `private_function()`");
}
pub fn indirect_access() {
print!("调用了 rary 的 `indirect_access()`,它\n> ");
private_function();
}
$ rustc --crate-type=lib rary.rs
$ ls lib*
library.rlib
库文件名会自动添加 "lib" 前缀,默认使用 crate 文件的名称。但可以通过向 rustc
传递 --crate-name
选项或使用 crate_name
属性 来覆盖这个默认名称。
使用库
要将 crate 链接到这个新库,可以使用 rustc
的 --extern
标志。库中的所有项目都会被导入到一个与库同名的模块下。这个模块的行为通常与其他模块相同。
// extern crate rary; // Rust 2015 版本或更早版本可能需要此声明
fn main() {
rary::public_function();
// 错误!`private_function` 是私有的
//rary::private_function();
rary::indirect_access();
}
# Where library.rlib is the path to the compiled library, assumed that it's
# in the same directory here:
$ rustc executable.rs --extern rary=library.rlib && ./executable
called rary's `public_function()`
called rary's `indirect_access()`, that
> called rary's `private_function()`
Cargo
cargo
是 Rust 的官方包管理工具。它提供了许多非常有用的功能,可以提高代码质量和开发效率!这些功能包括:
- 依赖管理和与 crates.io(Rust 官方包注册中心)的集成
- 支持单元测试
- 支持基准测试
本章将快速介绍一些基础知识,更全面的文档可以在 Cargo 手册 中找到。
依赖
大多数程序都依赖于一些库。如果你曾经手动管理过依赖,你就知道这有多么痛苦。幸运的是,Rust 生态系统标配了 cargo
!cargo
可以为项目管理依赖。
要创建一个新的 Rust 项目,可以使用以下命令:
# 创建二进制项目
cargo new foo
# 创建库项目
cargo new --lib bar
在本章的剩余部分,我们假设我们正在创建一个二进制项目,而不是一个库项目,但所有的概念都是相同的。
执行上述命令后,你应该会看到如下的文件结构:
.
├── bar
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── foo
├── Cargo.toml
└── src
└── main.rs
main.rs
是你新建的 foo
项目的主源文件 —— 这一点没什么新鲜的。Cargo.toml
则是这个项目的 cargo
配置文件。如果你查看它的内容,应该会看到类似这样的内容:
[package]
name = "foo"
version = "0.1.0"
authors = ["mark"]
[dependencies]
[package]
下的 name
字段决定了项目的名称。如果你将 crate 发布到 crates.io
(稍后会详细介绍),这个名称将被使用。同时,它也是编译时生成的二进制文件的名称。
version
字段是使用语义化版本控制的 crate 版本号。
authors
字段是发布 crate 时使用的作者列表。
[dependencies]
部分允许你为项目添加依赖。
举个例子,假设我们想让程序拥有一个出色的命令行界面(CLI)。你可以在 crates.io(Rust 官方包注册中心)上找到许多优秀的包。其中,clap 是一个广受欢迎的选择。在撰写本文时,clap
的最新发布版本是 2.27.1
。要在我们的程序中添加这个依赖,只需在 Cargo.toml
的 [dependencies]
下添加:clap = "2.27.1"
。就这么简单!现在你就可以在程序中使用 clap
了。
cargo
还支持其他类型的依赖。这里给出一个简单的示例:
[package]
name = "foo"
version = "0.1.0"
authors = ["mark"]
[dependencies]
clap = "2.27.1" # 来自 crates.io
rand = { git = "https://github.com/rust-lang-nursery/rand" } # 来自在线仓库
bar = { path = "../bar" } # 来自本地文件系统的路径
cargo
不仅仅是一个依赖管理器。Cargo.toml
的格式规范中列出了所有可用的配置选项。
我们可以在项目目录的任何位置(包括子目录!)执行 cargo build
来构建项目。也可以使用 cargo run
来构建并运行。请注意,这些命令会解析所有依赖,必要时下载 crate,并构建所有内容,包括你的 crate。(值得一提的是,它只会重新构建尚未构建的部分,类似于 make
)。
瞧!就是这么简单!
约定
在上一章中,我们看到了如下目录结构:
foo
├── Cargo.toml
└── src
└── main.rs
那么,如果我们想在同一个项目中包含两个二进制文件,该怎么办呢?
cargo
实际上支持这种需求。如我们之前所见,默认的二进制文件名是 main
,但你可以通过在 bin/
目录中放置额外的文件来添加其他二进制文件:
foo
├── Cargo.toml
└── src
├── main.rs
└── bin
└── my_other_bin.rs
如果要指示 cargo
只编译或运行特定的二进制文件,只需传递 --bin my_other_bin
标志,其中 my_other_bin
是我们想要处理的二进制文件的名称。
除了额外的二进制文件,cargo
还支持更多功能,如基准测试、测试和示例。
在下一章中,我们将更详细地探讨测试。
测试
众所周知,测试是任何软件不可或缺的一部分!Rust 为单元测试和集成测试提供了一流的支持(参见《Rust 程序设计语言》中的这一章)。
从上面链接的测试章节中,我们了解了如何编写单元测试和集成测试。在组织结构上,我们可以将单元测试放在它们所测试的模块中,而将集成测试放在专门的 tests/
目录中:
foo
├── Cargo.toml
├── src
│ └── main.rs
│ └── lib.rs
└── tests
├── my_test.rs
└── my_other_test.rs
tests
目录中的每个文件都是一个独立的集成测试,即旨在测试你的库,就像它被依赖的 crate 调用一样。
测试章节详细阐述了三种不同的测试风格:单元测试、文档测试和集成测试。
cargo
自然提供了一种简便的方式来运行所有测试!
$ cargo test
你将看到类似这样的输出:
$ cargo test
Compiling blah v0.1.0 (file:///nobackup/blah)
Finished dev [unoptimized + debuginfo] target(s) in 0.89 secs
Running target/debug/deps/blah-d3b32b97275ec472
running 4 tests
test test_bar ... ok
test test_baz ... ok
test test_foo_bar ... ok
test test_foo ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
你还可以运行名称匹配特定模式的测试:
$ cargo test test_foo
$ cargo test test_foo
Compiling blah v0.1.0 (file:///nobackup/blah)
Finished dev [unoptimized + debuginfo] target(s) in 0.35 secs
Running target/debug/deps/blah-d3b32b97275ec472
running 2 tests
test test_foo ... ok
test test_foo_bar ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
需要注意:Cargo 可能会并发运行多个测试,因此请确保它们之间不会产生竞态条件。
并发可能导致问题的一个例子是,如果两个测试同时输出到同一个文件,如下所示:
尽管预期结果应该是:
$ cat ferris.txt
Ferris
Ferris
Ferris
Ferris
Ferris
Corro
Corro
Corro
Corro
Corro
但实际写入 ferris.txt
的内容可能是这样的:
$ cargo test test_file && cat ferris.txt
Corro
Ferris
Corro
Ferris
Corro
Ferris
Corro
Ferris
Corro
Ferris
构建脚本
有时候,cargo
的常规构建可能不足以满足需求。你的 crate 可能在 cargo
成功编译之前需要一些先决条件,比如代码生成,或者需要编译一些本地代码。为了解决这个问题,我们可以使用 Cargo 能够运行的构建脚本。
要为你的包添加构建脚本,可以在 Cargo.toml
中指定,如下所示:
[package]
...
build = "build.rs"
如果没有指定,Cargo 默认会在项目目录中查找 build.rs
文件。
如何使用构建脚本
构建脚本只是另一个 Rust 文件,它会在编译包中的其他内容之前被编译和调用。因此,它可以用来满足你的 crate 的先决条件。
Cargo 通过环境变量为脚本提供输入,这些环境变量可以被使用。具体参见这里的说明。
脚本通过标准输出提供输出。所有打印的行都会被写入 target/debug/build/<pkg>/output
。此外,以 cargo:
为前缀的行会被 Cargo 直接解释,因此可以用来为包的编译定义参数。
如需了解更多详细规范和示例,请参阅 Cargo 构建脚本规范。
属性
属性是应用于模块、crate 或条目的元数据。这些元数据可用于以下目的:
- 代码的条件编译
- 设置 crate 的名称、版本和类型(二进制或库)
- 禁用 代码检查(警告)
- 启用编译器特性(如宏、全局导入等)
- 链接外部库
- 将函数标记为单元测试
- 将函数标记为基准测试的一部分
- 类属性宏
属性的形式为 #[outer_attribute]
(外部属性)或 #![inner_attribute]
(内部属性),它们的区别在于应用的位置。
-
#[outer_attribute]
应用于紧随其后的条目。条目的例子包括:函数、模块声明、常量、结构体、枚举等。以下是一个示例,其中属性#[derive(Debug)]
应用于结构体Rectangle
: -
#![inner_attribute]
应用于包含它的条目(通常是模块或 crate)。换句话说,这种属性被解释为应用于它所在的整个作用域。以下是一个示例,其中#![allow(unused_variables)]
应用于整个 crate(如果放置在main.rs
中):#![allow(unused_variables)] fn main() { let x = 3; // 这通常会警告未使用的变量。 }
属性可以使用不同的语法接受参数:
#[attribute = "value"]
#[attribute(key = "value")]
#[attribute(value)]
属性可以有多个值,也可以跨多行分隔:
#[attribute(value, value2)]
#[attribute(value, value2, value3,
value4, value5)]
dead_code
编译器提供了一个 dead_code
lint,用于警告未使用的函数。可以使用属性来禁用这个 lint。
注意,在实际程序中,你应该消除无用代码。在这些示例中,我们会在某些地方允许存在无用代码,这是因为这些示例具有交互性质。
Crates
crate_type
属性可用于告诉编译器一个 crate 是二进制文件还是库(甚至是哪种类型的库),而 crate_name
属性可用于设置 crate 的名称。
然而,需要注意的是,当使用 Rust 的包管理器 Cargo 时,crate_type
和 crate_name
属性完全不起作用。由于大多数 Rust 项目都使用 Cargo,这意味着 crate_type
和 crate_name
在实际使用中的应用相对有限。
当使用 crate_type
属性时,我们就不再需要向 rustc
传递 --crate-type
标志。
$ rustc lib.rs
$ ls lib*
library.rlib
cfg
配置条件检查可以通过两种不同的操作符实现:
cfg
属性:在属性位置使用#[cfg(...)]
cfg!
宏:在布尔表达式中使用cfg!(...)
前者启用条件编译,后者在运行时条件性地求值为 true
或 false
字面量,允许在运行时进行检查。两者使用相同的参数语法。
cfg!
与 #[cfg]
不同,它不会移除任何代码,只会求值为 true 或 false。例如,当 cfg!
用于条件时,if/else 表达式中的所有代码块都需要是有效的,无论 cfg!
正在评估什么。
另请参阅:
自定义
一些条件(如 target_os
)是由 rustc
隐式提供的,但自定义条件必须通过 --cfg
标志传递给 rustc
。
尝试运行这段代码,看看没有自定义 cfg
标志会发生什么。
使用自定义 cfg
标志:
$ rustc --cfg some_condition custom.rs && ./custom
condition met!
泛型
泛型是一个关于将类型和功能泛化以适用于更广泛情况的主题。这在多方面都非常有用,可以大大减少代码重复,但可能需要相对复杂的语法。具体来说,使用泛型需要非常谨慎地指定泛型类型在哪些类型上是有效的。泛型最简单和最常见的用途是类型参数。
类型参数通过使用尖括号和大写驼峰命名法来指定为泛型:<Aaa, Bbb, ...>
。"泛型类型参数"通常表示为 <T>
。在 Rust 中,"泛型"也用来描述任何接受一个或多个泛型类型参数 <T>
的东西。任何被指定为泛型类型参数的类型都是泛型的,而其他所有类型都是具体的(非泛型)。
例如,定义一个名为 foo
的泛型函数,它接受一个任意类型的参数 T
:
fn foo<T>(arg: T) { ... }
因为 T
已经使用 <T>
指定为泛型类型参数,所以在这里用作 (arg: T)
时被视为泛型。即使 T
之前已被定义为一个 struct
,这种情况也成立。
下面的例子展示了一些泛型语法的实际应用:
另请参阅:
函数
同样的规则也适用于函数:当类型 T
前面加上 <T>
时,它就变成了泛型。
使用泛型函数有时需要明确指定类型参数。这种情况可能出现在函数返回类型是泛型时,或者编译器没有足够信息推断必要的类型参数时。
明确指定类型参数的函数调用看起来像这样:fun::<A, B, ...>()
。
另请参阅:
实现
与函数类似,实现(impl
)在涉及泛型时也需要谨慎处理。
另请参阅:
特质
当然,trait
也可以是泛型的。这里我们定义了一个泛型 trait,它重新实现了 Drop
trait,用于释放自身和一个输入参数。
另请参阅:
约束
在使用泛型时,类型参数通常需要使用 trait 作为约束,以规定类型应实现哪些功能。例如,下面的示例使用 Display
trait 来打印,因此它要求 T
必须受 Display
约束;换句话说,T
必须实现 Display
。
// 定义一个函数 `printer`,它接受一个泛型类型 `T`,
// 该类型必须实现 `Display` trait。
fn printer<T: Display>(t: T) {
println!("{}", t);
}
约束将泛型限制为符合约束条件的类型。也就是说:
struct S<T: Display>(T);
// 错误!`Vec<T>` 没有实现 `Display`。
// 这个特化将会失败。
let s = S(vec![1]);
约束的另一个作用是允许泛型实例访问约束中指定的 trait 的方法。例如:
另外值得注意的是,在某些情况下可以使用 where
子句来应用约束,以使表达更加清晰。
另请参阅:
测试实例:空约束
约束的工作机制导致即使一个 trait
不包含任何功能,你仍然可以将其用作约束。std
库中的 Eq
和 Copy
就是这种 trait
的例子。
另请参阅:
std::cmp::Eq
、std::marker::Copy
和 trait
多重约束
可以使用 +
为单个类型指定多个约束。按照惯例,不同的类型用 ,
分隔。
另请参阅:
Where 分句
约束也可以使用 where
子句来表达,它位于开括号 {
之前,而不是在类型首次提及时。此外,where
子句可以将约束应用于任意类型,而不仅限于类型参数。
where
子句在以下情况下特别有用:
- 当单独指定泛型类型和约束更清晰时:
impl <A: TraitB + TraitC, D: TraitE + TraitF> MyTrait<A, D> for YourType {}
// 使用 `where` 子句表达约束
impl <A, D> MyTrait<A, D> for YourType where
A: TraitB + TraitC,
D: TraitE + TraitF {}
- 当使用
where
子句比使用普通语法更具表现力时。这个例子中的impl
如果不使用where
子句就无法直接表达:
另请参阅:
新类型惯用法
newtype
模式在编译时保证了程序接收到正确类型的值。
例如,一个检查年龄(以年为单位)的年龄验证函数,必须接收 Years
类型的值。
取消最后一个 print 语句的注释,你会发现所提供的类型必须是 Years
。
要获取 newtype
的基本类型值,你可以使用元组语法或解构语法,如下所示:
另请参阅:
关联项
"关联项"是指与各种类型的项
相关的一组规则。它是 trait
泛型的扩展,允许 trait
在内部定义新的项。
其中一种项被称为关联类型,当 trait
对其容器类型是泛型时,它提供了更简洁的使用模式。
另请参阅:
问题
对于容器类型是泛型的 trait
,有类型规范要求 —— trait
的使用者必须指定所有的泛型类型。
在下面的例子中,Contains
trait 允许使用泛型类型 A
和 B
。然后为 Container
类型实现该 trait,将 A
和 B
指定为 i32
,以便与 fn difference()
一起使用。
由于 Contains
是泛型的,我们不得不为 fn difference()
显式声明所有泛型类型。实际上,我们希望有一种方法来表达 A
和 B
是由输入 C
决定的。正如你将在下一节中看到的,关联类型恰好提供了这种能力。
另请参阅:
关联类型
使用"关联类型"通过将内部类型局部移动到 trait 中作为输出类型,提高了代码的整体可读性。trait 定义的语法如下:
注意,使用 Contains
trait 的函数不再需要显式指定 A
或 B
:
// 不使用关联类型
fn difference<A, B, C>(container: &C) -> i32 where
C: Contains<A, B> { ... }
// 使用关联类型
fn difference<C: Contains>(container: &C) -> i32 { ... }
让我们使用关联类型重写上一节的示例:
虚类型参数
虚类型参数是一种在运行时不会出现,但在编译时会进行静态检查的类型参数。
数据类型可以使用额外的泛型类型参数作为标记,或在编译时进行类型检查。这些额外的参数不占用存储空间,也没有运行时行为。
在下面的示例中,我们将 std::marker::PhantomData
与虚类型参数的概念结合,创建包含不同数据类型的元组。
另请参阅:
派生(Derive)、结构体(struct)和元组结构体(TupleStructs)
测试实例:单位澄清
通过使用虚类型参数实现 Add
trait,我们可以探索一种有用的单位转换方法。下面我们来看看 Add
trait:
// 这个结构会强制要求:`Self + RHS = Output`
// 其中,如果在实现中未指定 RHS,它将默认为 Self。
pub trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
// `Output` 必须是 `T<U>`,以确保 `T<U> + T<U> = T<U>`。
impl<U> Add for T<U> {
type Output = T<U>;
...
}
完整实现如下:
另请参阅:
借用(&
)、约束(X: Y
)、枚举、impl 和 self、运算符重载、ref、trait(X for Y
)以及元组结构体。
作用域规则
作用域在所有权、借用和生命周期中扮演着重要角色。它们向编译器指示借用何时有效、资源何时可以被释放,以及变量何时被创建或销毁。
RAII
Rust 中的变量不仅仅是在栈上保存数据:它们还拥有资源,例如 Box<T>
拥有堆上的内存。Rust 强制执行 RAII(资源获取即初始化),因此每当一个对象离开作用域时,它的析构函数就会被调用,它拥有的资源也会被释放。
这种行为可以防止资源泄漏错误,因此你再也不用手动释放内存或担心内存泄漏了!以下是一个简单示例:
当然,我们可以使用 valgrind
来再次检查内存错误:
$ rustc raii.rs && valgrind ./raii
==26873== Memcheck, a memory error detector
==26873== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==26873== Using Valgrind-3.9.0 and LibVEX; rerun with -h for copyright info
==26873== Command: ./raii
==26873==
==26873==
==26873== HEAP SUMMARY:
==26873== in use at exit: 0 bytes in 0 blocks
==26873== total heap usage: 1,013 allocs, 1,013 frees, 8,696 bytes allocated
==26873==
==26873== All heap blocks were freed -- no leaks are possible
==26873==
==26873== For counts of detected and suppressed errors, rerun with: -v
==26873== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 2 from 2)
这里没有内存泄漏!
析构函数
Rust 中的析构函数概念是通过 Drop
trait 提供的。当资源离开作用域时,析构函数会被调用。并非每种类型都需要实现这个 trait,只有当你需要为自己的类型实现特定的析构逻辑时才需要实现它。
运行下面的示例来了解 Drop
trait 是如何工作的。当 main
函数中的变量离开作用域时,自定义的析构函数将被调用。
另请参阅:
所有权和移动
由于变量负责释放它们自己的资源,资源只能有一个所有者。这可以防止资源被多次释放。请注意,并非所有变量都拥有资源(例如引用)。
当进行赋值(let x = y
)或按值传递函数参数(foo(x)
)时,资源的所有权会被转移。在 Rust 中,这被称为移动(move)。
资源移动后,原所有者将无法再被使用。这避免了悬垂指针的产生。
可变性
当所有权转移时,数据的可变性可以改变。
部分移动
Within the destructuring of a single variable, both by-move
and by-reference
pattern bindings can be used at the same time. Doing this will result in a partial move of the variable, which means that parts of the variable will be moved while other parts stay. In such a case, the parent variable cannot be used afterwards as a whole, however the parts that are only referenced (and not moved) can still be used. Note that types that implement the Drop
trait cannot be partially moved from, because its drop
method would use it afterwards as a whole.
(在这个例子中,我们将 age
变量存储在堆上以说明部分移动:删除上面代码中的 ref
会导致错误,因为 person.age
的所有权会被移动到变量 age
。如果 Person.age
存储在栈上,就不需要 ref
,因为 age
的定义会从 person.age
复制数据而不是移动它。)
另请参阅:
借用
大多数情况下,我们希望访问数据而不获取其所有权。为了实现这一点,Rust 使用了借用机制。对象可以通过引用(&T
)传递,而不是按值(T
)传递。
编译器通过其借用检查器静态地保证引用始终指向有效的对象。也就是说,当存在指向一个对象的引用时,该对象就不能被销毁。
可变性
可变数据可以使用 &mut T
进行可变借用。这被称为可变引用,并给予借用者读写访问权。相比之下,&T
通过不可变引用借用数据,借用者可以读取数据但不能修改它:
另请参阅:
别名
数据可以被不可变借用任意次数,但在不可变借用期间,原始数据不能被可变借用。另一方面,同一时间只允许一个可变借用。只有在可变引用最后一次使用之后,原始数据才能再次被借用。
ref 模式
在使用 let
绑定进行模式匹配或解构时,可以使用 ref
关键字来获取结构体或元组字段的引用。以下示例展示了几个这种用法有用的场景:
生命周期
生命周期是编译器(更具体地说是其借用检查器)用来确保所有借用都有效的一种机制。具体来说,变量的生命周期从创建时开始,到销毁时结束。尽管生命周期和作用域经常被一同提及,但它们并不完全相同。
举个例子,当我们通过 &
借用一个变量时,这个借用的生命周期由其声明位置决定。因此,只要借用在出借者被销毁之前结束,它就是有效的。然而,借用的作用域则由引用使用的位置决定。
在接下来的例子和本节的其余部分中,我们将看到生命周期如何与作用域相关联,以及两者之间的区别。
请注意,生命周期标签没有被赋予名称或类型。这限制了生命周期的使用方式,我们将在后面看到这一点。
显式注解
借用检查器使用显式生命周期注解来确定引用应该有效多长时间。在生命周期没有被省略1的情况下,Rust 需要显式注解来确定引用的生命周期。显式注解生命周期的语法使用撇号字符,如下所示:
foo<'a>
// `foo` 有一个生命周期参数 `'a`
类似于闭包,使用生命周期需要泛型。此外,这种生命周期语法表示 foo
的生命周期不能超过 'a
的生命周期。类型的显式注解形式为 &'a T
,其中 'a
已经被引入。
在有多个生命周期的情况下,语法类似:
foo<'a, 'b>
// `foo` 有生命周期参数 `'a` 和 `'b`
在这种情况下,foo
的生命周期不能超过 'a
或 'b
的生命周期。
请看下面的例子,展示了显式生命周期注解的使用:
省略(elision)隐式地注解生命周期,因此与显式注解不同。
另请参阅:
函数
排除省略(elision)的情况,带有生命周期的函数签名有以下几个约束:
- 任何引用必须有一个标注的生命周期。
- 任何被返回的引用必须具有与输入相同的生命周期或者是
static
。
此外,请注意,如果返回引用而没有输入会导致返回指向无效数据的引用,这是被禁止的。以下示例展示了一些带有生命周期的有效函数形式:
另请参阅:
方法
方法的生命周期注解与函数类似:
另请参阅:
结构体
结构体中生命周期的注解也与函数类似:
另请参阅:
特质
trait 方法中的生命周期注解基本上与函数类似。注意,impl
也可能需要生命周期注解。
另请参阅:
约束
正如泛型类型可以被约束一样,生命周期(本身也是泛型)也可以使用约束。这里的 :
符号含义略有不同,但 +
的用法相同。请注意以下表达的含义:
T: 'a
:T
中的所有引用必须比生命周期'a
存活更久。T: Trait + 'a
:类型T
必须实现 traitTrait
,并且T
中的所有引用必须比'a
存活更久。
下面的例子展示了上述语法在 where
关键字之后的实际应用:
另请参阅:
强制转换
一个较长的生命周期可以被强制转换为一个较短的生命周期,使其能在通常无法工作的作用域内工作。这种转换可以通过 Rust 编译器的推断自动完成,也可以通过声明不同的生命周期的形式实现:
静态
Rust 有几个保留的生命周期名称。其中之一是 'static
。你可能在两种情况下遇到它:
这两种情况虽然相关但有微妙的区别,这也是学习 Rust 时常见的困惑来源。以下是每种情况的一些例子:
引用生命周期
作为引用生命周期,'static
表示该引用指向的数据在程序的整个剩余运行期间都有效。它仍然可以被强制转换为更短的生命周期。
有两种常见的方法可以创建具有 'static
生命周期的变量,它们都存储在二进制文件的只读内存中:
- 使用
static
声明创建一个常量。 - 创建一个字符串字面量,其类型为:
&'static str
。
请看下面的例子,展示了这些方法:
Since 'static
references only need to be valid for the remainder of a program's life, they can be created while the program is executed. Just to demonstrate, the below example uses Box::leak
to dynamically create 'static
references. In that case it definitely doesn't live for the entire duration, but only from the leaking point onward.
Trait 约束
作为 trait 约束时,它表示该类型不包含任何非静态引用。例如,接收者可以随意持有该类型,直到主动丢弃之前,它都不会变为无效。
理解这一点很重要:任何拥有所有权的数据总是满足 'static
生命周期约束,但对该数据的引用通常不满足:
编译器会提示你:
error[E0597]: `i` does not live long enough
--> src/lib.rs:15:15
|
15 | print_it(&i);
| ---------^^--
| | |
| | borrowed value does not live long enough
| argument requires that `i` is borrowed for `'static`
16 | }
| - `i` dropped here while still borrowed
另请参阅:
省略
有些生命周期模式非常常见,因此借用检查器允许省略它们以减少代码量并提高可读性。这被称为省略。Rust 中的省略存在的唯一原因是这些模式很常见。
以下代码展示了一些省略的例子。要更全面地了解省略,请参阅 Rust 程序设计语言中的生命周期省略章节。
另请参阅:
特质
trait
是为未知类型 Self
定义的一组方法集合。这些方法可以访问同一 trait 中声明的其他方法。
trait 可以为任何数据类型实现。在下面的例子中,我们定义了 Animal
,一组方法的集合。然后为 Sheep
数据类型实现 Animal
trait,这样就可以对 Sheep
使用 Animal
中的方法。
派生
编译器可以通过 #[derive]
属性为某些 trait 提供基本实现。如果需要更复杂的行为,这些 trait 仍然可以手动实现。
以下是可派生的 trait 列表:
- 比较 trait:
Eq
、PartialEq
、Ord
、PartialOrd
。 Clone
,通过复制&T
创建T
。Copy
,使类型具备"复制语义"而不是"移动语义"。Hash
,从&T
计算哈希值。Default
,创建一个数据类型的空实例。Debug
,使用{:?}
格式化器来格式化一个值。
另请参阅:
使用 dyn
返回 trait
Rust 编译器需要知道每个函数的返回类型所需的内存空间。这意味着所有函数都必须返回一个具体类型。与其他语言不同,如果你有一个像 Animal
这样的 trait,你不能编写一个直接返回 Animal
的函数,因为它的不同实现可能需要不同大小的内存。
然而,有一个简单的解决方法。我们可以让函数返回一个包含 Animal
的 Box
,而不是直接返回 trait 对象。Box
本质上是一个指向堆内存的引用。由于引用的大小是静态已知的,且编译器可以保证它指向堆上分配的 Animal
,这样我们就能从函数中返回一个 trait 了!
Rust 在堆上分配内存时力求明确。因此,如果你的函数以这种方式返回一个指向堆上 trait 的指针,你需要在返回类型中使用 dyn
关键字,例如 Box<dyn Animal>
。
运算符重载
在 Rust 中,许多运算符可以通过 trait 进行重载。这意味着某些运算符可以根据输入参数执行不同的任务。之所以可能,是因为运算符实际上是方法调用的语法糖。例如,a + b
中的 +
运算符会调用 add
方法(相当于 a.add(b)
)。这个 add
方法是 Add
trait 的一部分。因此,任何实现了 Add
trait 的类型都可以使用 +
运算符。
可以在 core::ops
模块中找到用于重载运算符的 trait 列表,如 Add
等。
参见
Drop
Drop
trait 只有一个方法:drop
,它会在对象离开作用域时自动调用。Drop
trait 的主要用途是释放实现该 trait 的实例所拥有的资源。
Box
、Vec
、String
、File
和 Process
是一些实现了 Drop
trait 以释放资源的类型示例。你也可以为任何自定义数据类型手动实现 Drop
trait。
下面的例子在 drop
函数中添加了一个控制台打印,用于宣告它被调用的时机。
For a more practical example, here's how the Drop
trait can be used to automatically clean up temporary files when they're no longer needed:
迭代器
Iterator
trait 用于实现对数组等集合的迭代器。
该 trait 只要求为 next
元素定义一个方法,这个方法可以在 impl
块中手动定义,也可以自动定义(如在数组和区间中)。
为了在常见情况下提供便利,for
结构使用 .into_iter()
方法将某些集合转换为迭代器。
impl Trait
impl Trait
可以在两个位置使用:
- 作为参数类型
- 作为返回类型
作为参数类型
如果你的函数对某个 trait 是泛型的,但不关心具体类型,你可以使用 impl Trait
作为参数类型来简化函数声明。
例如,考虑以下代码:
parse_csv_document
是泛型函数,可以接受任何实现了 BufRead
的类型,如 BufReader<File>
或 [u8]
。但具体的 R
类型并不重要,R
仅用于声明 src
的类型。因此,这个函数也可以写成:
注意,使用 impl Trait
作为参数类型意味着你无法显式指定使用的函数形式。例如,parse_csv_document::<std::io::Empty>(std::io::empty())
在第二个例子中将无法工作。
作为返回类型
如果函数返回一个实现了 MyTrait
的类型,你可以将其返回类型写为 -> impl MyTrait
。这可以大大简化类型签名!
更重要的是,某些 Rust 类型无法直接写出。例如,每个闭包都有自己的未命名具体类型。在 impl Trait
语法出现之前,你必须在堆上分配内存才能返回闭包。但现在你可以完全静态地做到这一点,像这样:
你还可以使用 impl Trait
返回一个使用 map
或 filter
闭包的迭代器!这使得使用 map
和 filter
更加容易。由于闭包类型没有名称,如果你的函数返回带有闭包的迭代器,你无法写出显式的返回类型。但使用 impl Trait
,你可以轻松实现:
克隆
在处理资源时,默认行为是在赋值或函数调用期间转移它们。然而,有时我们也需要复制资源。
Clone
trait 帮助我们实现这一点。最常见的是,我们可以使用 Clone
trait 定义的 .clone()
方法。
父特质
Rust 没有“继承”,但你可以将一个 trait 定义为另一个 trait 的超集。例如:
另请参阅:
消除重叠特质的歧义
一个类型可以实现多个不同的 trait。如果两个 trait 都要求函数使用相同的名称,该怎么办?例如,许多 trait 可能都有一个名为 get()
的方法,它们甚至可能有不同的返回类型!
好消息是:由于每个 trait 实现都有自己的 impl
块,因此很容易分清楚你正在实现哪个 trait 的 get
方法。
那么在调用这些方法时又该如何处理呢?为了消除它们之间的歧义,我们必须使用完全限定语法。
另请参阅:
macro_rules!
Rust 提供了一个强大的宏系统,支持元编程。正如你在前面章节中所看到的,宏看起来像函数,只是它们的名字以感叹号 !
结尾。但与生成函数调用不同,宏会展开成源代码,然后与程序的其余部分一起编译。与 C 和其他语言中的宏不同,Rust 宏展开成抽象语法树,而不是进行字符串预处理,因此你不会遇到意外的优先级错误。
宏是使用 macro_rules!
宏来创建的。
那么,为什么宏是有用的呢?
-
不要重复自己。在许多情况下,你可能需要在多个地方使用相似的功能,但类型不同。通常,编写宏是避免代码重复的有效方法。(稍后会详细介绍)
-
领域特定语言。宏允许你为特定目的定义专门的语法。(稍后会详细介绍)
-
可变参数接口。有时你可能想定义一个接受可变数量参数的接口。例如
println!
,它可以根据格式字符串接受任意数量的参数。(稍后会详细介绍)
语法
在接下来的小节中,我们将展示如何在 Rust 中定义宏。有三个基本概念:
指示符
宏的参数以美元符号 $
为前缀,并用指示符来标注类型:
以下是一些可用的指示符:
block
expr
用于表达式ident
用于变量/函数名item
literal
用于字面常量pat
(模式 pattern)path
stmt
(语句 statement)tt
(标记树 token tree)ty
(类型 type)vis
(可见性限定符 visibility qualifier)
完整列表请参阅 Rust 参考手册。
重载
宏可以被重载以接受不同的参数组合。在这方面,macro_rules!
的工作方式类似于 match 块:
重复
宏可以在参数列表中使用 +
来表示一个参数可能重复至少一次,或使用 *
来表示一个参数可能重复零次或多次。
在下面的例子中,用 $(...),+
包围匹配器将匹配一个或多个由逗号分隔的表达式。另外请注意,最后一个情况的分号是可选的。
DRY(不要重复自己)
宏通过提取函数和/或测试套件的共同部分,使我们能够编写符合 DRY(Don't Repeat Yourself)原则的代码。下面是一个在 Vec<T>
上实现并测试 +=
、*=
和 -=
运算符的示例:
$ rustc --test dry.rs && ./dry
running 3 tests
test test::mul_assign ... ok
test test::add_assign ... ok
test test::sub_assign ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured
领域特定语言(DSL)
DSL 是嵌入在 Rust 宏中的一种微型"语言"。它是完全有效的 Rust 代码,因为宏系统会将其展开为普通的 Rust 结构,但它看起来像一种小型语言。这使你能够为某些特定功能定义简洁或直观的语法(在一定范围内)。
假设我想定义一个简单的计算器 API。我希望提供一个表达式,并将计算结果打印到控制台。
输出:
1 + 2 = 3
(1 + 2) * (3 / 4) = 0
这个例子非常简单,但已经有很多利用宏开发的复杂接口,比如 lazy_static
或 clap
。
另外,注意宏中的两对大括号。外层的大括号是 macro_rules!
语法的一部分,除此之外还可以使用 ()
或 []
。
可变参数接口
可变参数接口可以接受任意数量的参数。例如,println!
可以接受任意数量的参数,这由格式字符串决定。
我们可以将上一节的 calculate!
宏扩展为可变参数的形式:
输出:
1 + 2 = 3
3 + 4 = 7
(2 * 3) + 1 = 7
错误处理
错误处理是处理可能出现失败情况的过程。例如,读取文件失败后继续使用那个错误的输入显然会导致问题。注意并明确管理这些错误可以使程序的其他部分避免各种陷阱。
Rust 中有多种处理错误的方法,这些方法将在接下来的小节中详细介绍。它们或多或少都有一些细微的差别和不同的使用场景。一般来说:
显式的 panic
主要用于测试和处理不可恢复的错误。在原型开发中,它可能会有用,例如在处理尚未实现的函数时。但在这些情况下,使用更具描述性的 unimplemented
会更好。在测试中,panic
是一种合理的显式失败方式。
Option
类型用于值是可选的情况,或者缺少值不构成错误条件的情况。例如目录的父目录 - /
和 C:
就没有父目录。在处理 Option
时,对于原型设计和绝对确定有值的情况,使用 unwrap
是可以的。然而,expect
更有用,因为它允许你指定一个错误消息,以防万一出错。
当有可能出错且调用者必须处理问题时,请使用 Result
。你也可以对它们使用 unwrap
和 expect
(除非是测试或快速原型,否则请不要这样做)。
关于错误处理更详尽的讨论,请参阅官方文档中的错误处理章节。
panic
我们将看到的最简单的错误处理机制是 panic
。它会打印一条错误消息,开始展开栈,并通常会退出程序。在这里,我们在错误条件下显式调用 panic
:
第一次调用 drink
正常执行。第二次调用会引发 panic,因此第三次调用永远不会被执行。
abort
和 unwind
上一节介绍了错误处理机制 panic
。可以根据 panic 设置有条件地编译不同的代码路径。当前可用的值有 unwind
和 abort
。
基于之前的柠檬水示例,我们明确使用 panic 策略来执行不同的代码行。
这里是另一个示例,重点是重写 drink()
函数并明确使用 unwind
关键字。
可以通过命令行使用 abort
或 unwind
来设置 panic 策略。
rustc lemonade.rs -C panic=abort
Option
和 unwrap
在上一个例子中,我们展示了如何主动引发程序失败。我们让程序在喝含糖柠檬水时触发 panic
。但如果我们期望得到某种饮料却没有收到呢?这种情况同样糟糕,所以需要处理!
我们可以像处理柠檬水那样对空字符串(""
)进行测试。但既然我们使用的是 Rust,不如让编译器指出没有饮料的情况。
std
库中的 Option<T>
枚举用于处理可能存在缺失的情况。它表现为两个"选项"之一:
Some(T)
:找到了一个T
类型的元素None
:没有找到元素
这些情况可以通过 match
显式处理,也可以用 unwrap
隐式处理。隐式处理要么返回内部元素,要么触发 panic
。
注意,可以使用 expect
手动自定义 panic
,但 unwrap
相比显式处理会产生一个不太有意义的输出。在下面的例子中,显式处理产生了一个更可控的结果,同时保留了在需要时触发 panic
的选项。
使用 ?
解包 Option
你可以使用 match
语句来解包 Option
,但使用 ?
运算符通常更简便。如果 x
是一个 Option
,那么求值 x?
将在 x
是 Some
时返回其内部值,否则它将终止当前执行的函数并返回 None
。
你可以将多个 ?
链接在一起,使你的代码更易读。
组合器:map
match
是处理 Option
的有效方法。然而,频繁使用可能会让人感到繁琐,尤其是在只有输入时才有效的操作中。在这些情况下,可以使用组合器以模块化的方式管理控制流。
Option
有一个内置方法 map()
,这是一个用于简单映射 Some -> Some
和 None -> None
的组合器。多个 map()
调用可以链式使用,从而提供更大的灵活性。
在下面的例子中,process()
函数以简洁的方式替代了之前的所有函数。
另请参阅:
组合器:and_then
map()
被描述为一种可链式调用的方式来简化 match
语句。然而,在返回 Option<T>
的函数上使用 map()
会导致嵌套的 Option<Option<T>>
。链式调用多个这样的函数可能会变得令人困惑。这时,另一个称为 and_then()
的组合器(在某些语言中称为 flatmap)就派上用场了。
and_then()
使用包装的值调用其函数输入并返回结果。如果 Option
是 None
,则直接返回 None
。
在下面的例子中,cookable_v3()
返回一个 Option<Food>
。如果使用 map()
而不是 and_then()
,将会得到一个 Option<Option<Food>>
,这对于 eat()
函数来说是一个无效的类型。
另请参阅:
闭包、Option
、Option::and_then()
和 Option::flatten()
解包 Option 和设置默认值
有多种方法可以解包 Option
并在其为 None
时使用默认值。为了选择满足我们需求的方法,我们需要考虑以下几点:
- 我们需要立即求值还是惰性求值?
- 我们需要保持原始的空值不变,还是就地修改它?
or()
可链式调用,立即求值,保持空值不变
or()
可以链式调用,并且会立即求值其参数,如下例所示。注意,由于 or
的参数是立即求值的,传递给 or
的变量会被移动。
or_else()
可以链式调用,惰性求值,保持空值不变
另一种选择是使用 or_else
,它同样支持链式调用,并且采用惰性求值。以下是一个示例:
get_or_insert()
立即求值,原地修改空值
为确保 Option
包含一个值,我们可以使用 get_or_insert
来原地修改它,提供一个备选值。下面的例子展示了这一点。请注意,get_or_insert
会立即求值其参数,因此变量 apple
会被移动:
get_or_insert_with()
惰性求值,原地修改空值
我们可以向 get_or_insert_with
传递一个闭包,而不是显式提供一个备选值。示例如下:
另请参阅:
闭包
、get_or_insert
、get_or_insert_with
、变量移动
、or
、or_else
Result
Result
是 Option
类型的增强版,它描述可能的错误而非可能的缺失。
也就是说,Result<T, E>
可能有两种结果之一:
Ok(T)
:找到了一个T
类型的元素Err(E)
:发生了一个E
类型的错误
按照惯例,预期的结果是 Ok
,而意外的结果是 Err
。
与 Option
类似,Result
也有许多关联方法。例如,unwrap()
要么返回元素 T
,要么触发 panic
。对于情况处理,Result
和 Option
之间有许多重叠的组合子。
在使用 Rust 时,你可能会遇到返回 Result
类型的方法,比如 parse()
方法。将字符串解析为其他类型并非总是可行,因此 parse()
返回一个 Result
来表示可能的失败。
让我们看看成功和失败地 parse()
一个字符串会发生什么:
在解析失败的情况下,parse()
会返回一个错误,导致 unwrap()
触发 panic。此外,panic 会终止程序并输出一条不友好的错误信息。
为了提高错误信息的质量,我们应该更明确地指定返回类型,并考虑显式地处理错误。
在 main
函数中使用 Result
如果显式指定,Result
类型也可以作为 main
函数的返回类型。通常,main
函数的形式如下:
fn main() {
println!("Hello World!");
}
然而,main
函数也可以返回 Result
类型。如果 main
函数内发生错误,它将返回一个错误代码并打印该错误的调试表示(使用 Debug
trait)。以下示例展示了这种情况,并涉及了下一节中讨论的内容。
Result
的 map
在前面示例的 multiply
函数中使用 panic 并不能产生健壮的代码。通常,我们希望将错误返回给调用者,让它决定如何正确地处理错误。
首先,我们需要知道我们正在处理的错误类型。要确定 Err
类型,我们可以查看 parse()
方法,它是通过 FromStr
trait 为 i32
实现的。因此,Err
类型被指定为 ParseIntError
。
在下面的示例中,直接使用 match
语句会导致整体代码更加繁琐。
幸运的是,Option
的 map
、and_then
以及许多其他组合器也为 Result
实现了。Result
文档中包含了完整的列表。
Result
的别名
如果我们想多次重用特定的 Result
类型,该怎么办?回想一下,Rust 允许我们创建别名。方便的是,我们可以为特定的 Result
定义一个别名。
在模块级别,创建别名特别有用。在特定模块中发现的错误通常具有相同的 Err
类型,因此单个别名可以简洁地定义所有相关的 Result
。这非常实用,以至于 std
库甚至提供了一个:io::Result
!
这里有一个简单的例子来展示语法:
另请参阅:
提前返回
在前面的例子中,我们使用组合器显式地处理了错误。处理这种情况分析的另一种方法是使用 match
语句和提前返回的组合。
也就是说,如果发生错误,我们可以简单地停止执行函数并返回错误。对某些人来说,这种形式的代码可能更容易阅读和编写。考虑使用提前返回重写的前面示例的这个版本:
到目前为止,我们已经学会了使用组合器和提前返回来显式处理错误。虽然我们通常想避免 panic,但显式处理所有错误是很繁琐的。
在下一节中,我们将介绍 ?
运算符,用于我们只需要 unwrap
而不可能引发 panic
的情况。
引入 ?
有时我们只想要 unwrap
的简单性,而不希望有 panic
的可能。到目前为止,当我们真正想要的是获取变量值时,unwrap
迫使我们不断地增加嵌套。这正是 ?
运算符的目的。
当遇到 Err
时,有两种可行的处理方式:
-
- 使用
panic!
(我们已经决定尽可能避免这种方式)
- 使用
-
- 使用
return
(因为Err
表示无法处理该错误)
- 使用
?
运算符几乎1等同于在遇到 Err
时执行 return
而非 panic
的 unwrap
。让我们看看如何简化之前使用组合器的例子:
try!
宏
在 ?
运算符出现之前,相同的功能是通过 try!
宏实现的。现在推荐使用 ?
运算符,但在查看旧代码时,你可能仍会遇到 try!
。使用 try!
宏,前面例子中的 multiply
函数会是这样的:
更多详情请参阅重新认识 ?。
多种错误类型
前面的例子一直都很方便:Result
与 Result
交互,Option
与 Option
交互。
有时,Option
需要与 Result
交互,或者 Result<T, Error1>
需要与 Result<T, Error2>
交互。在这些情况下,我们希望以一种使不同错误类型可组合且易于交互的方式来管理它们。
在下面的代码中,两个 unwrap
实例生成了不同的错误类型。Vec::first
返回一个 Option
,而 parse::<i32>
返回一个 Result<i32, ParseIntError>
:
在接下来的章节中,我们将探讨几种处理此类问题的策略。
从 Option
中提取 Result
处理混合错误类型最基本的方法是将它们相互嵌套。
有时我们希望在遇到错误时停止处理(例如使用 ?
),但在 Option
为 None
时继续执行。这时 transpose
函数就派上用场了,它可以方便地交换 Result
和 Option
。
定义错误类型
有时,用单一类型的错误来掩盖所有不同的错误可以简化代码。我们将通过自定义错误来演示这一点。
Rust 允许我们定义自己的错误类型。通常,一个"好的"错误类型应该:
- 用同一类型表示不同的错误
- 向用户展示友好的错误消息
- 易于与其他类型进行比较
- 好的示例:
Err(EmptyVec)
- 不好的示例:
Err("请使用至少包含一个元素的向量".to_owned())
- 好的示例:
- 能够保存错误的相关信息
- 好的示例:
Err(BadChar(c, position))
- 不好的示例:
Err("此处不能使用 +".to_owned())
- 好的示例:
- 能够与其他错误很好地组合
使用 Box
将错误装箱
一种既能编写简洁代码又能保留原始错误信息的方法是使用 Box
将它们装箱。这种方法的缺点是底层错误类型只能在运行时确定,而不是静态确定的。
标准库通过让 Box
实现从任何实现了 Error
trait 的类型到 trait 对象 Box<Error>
的转换来帮助我们装箱错误,这是通过 From
实现的。
另请参阅:
?
的其他用途
注意在前面的例子中,我们对调用 parse
的直接反应是将库错误通过 map
转换为一个装箱的错误:
.and_then(|s| s.parse::<i32>())
.map_err(|e| e.into())
由于这是一个简单且常见的操作,如果能够省略就会很方便。可惜的是,由于 and_then
不够灵活,所以无法实现这一点。不过,我们可以使用 ?
来替代。
之前我们将 ?
解释为 unwrap
或 return Err(err)
。这只是大致正确。实际上,它的含义是 unwrap
或 return Err(From::from(err))
。由于 From::from
是不同类型之间的转换工具,这意味着如果你在错误可转换为返回类型的地方使用 ?
,它将自动进行转换。
在这里,我们使用 ?
重写了前面的例子。当为我们的错误类型实现 From::from
后,map_err
就不再需要了:
现在代码变得相当简洁了。与原来使用 panic
的版本相比,这种方法非常类似于用 ?
替换 unwrap
调用,只是返回类型变成了 Result
。因此,需要在顶层对结果进行解构。
另请参阅:
包装错误
除了将错误装箱外,另一种方法是将它们包装在你自定义的错误类型中。
这种方法增加了一些处理错误的样板代码,可能并非所有应用程序都需要。有一些库可以帮你处理这些样板代码。
另请参阅:
遍历 Result
Iter::map
操作可能会失败,例如:
让我们逐步介绍处理这种情况的策略。
使用 filter_map()
忽略失败的项
filter_map
调用一个函数并过滤掉结果为 None
的项。
使用 map_err()
和 filter_map()
收集失败的项
map_err
会对错误调用一个函数,因此将其添加到之前的 filter_map
解决方案中,我们可以在迭代时将错误项保存到一旁。
使用 collect()
使整个操作失败
Result
实现了 FromIterator
trait,因此结果的向量(Vec<Result<T, E>>
)可以转换为包含向量的结果(Result<Vec<T>, E>
)。一旦遇到 Result::Err
,迭代就会终止。
这种技巧同样适用于 Option
。
使用 partition()
收集所有有效值和失败项
当你查看结果时,你会注意到所有内容仍然被包装在 Result
中。这需要一些额外的样板代码。
标准库类型
std
库提供了许多自定义类型,大大扩展了"原生类型"的功能。其中包括:
- 可增长的
String
,例如:"hello world"
- 可增长的向量(vector):
[1, 2, 3]
- 可选类型:
Option<i32>
- 错误处理类型:
Result<i32, i32>
- 堆分配的指针:
Box<i32>
另请参阅:
Box、栈和堆
在 Rust 中,所有值默认都是栈分配的。通过创建 Box<T>
,可以将值装箱(在堆上分配)。Box 是指向堆分配的 T
类型值的智能指针。当 Box 离开作用域时,会调用其析构函数,内部对象被销毁,堆上的内存被释放。
可以使用 *
运算符解引用装箱的值,这会移除一层间接引用。
Vectors
向量(Vector)是可调整大小的数组。与切片(Slice)类似,它们的大小在编译时是未知的,但可以随时增长或缩小。向量由 3 个参数表示:
- 指向数据的指针
- 长度
- 容量
容量表示为向量预留的内存量。只要长度小于容量,向量就可以增长。当需要超过这个阈值时,向量会被重新分配更大的容量。
更多 Vec
方法可以在 std::vec 模块中找到
字符串
Rust 中最常用的两种字符串类型是 String
和 &str
。
String
存储为字节向量(Vec<u8>
),但保证始终是有效的 UTF-8 序列。String
在堆上分配,可增长,且不以 null 结尾。
&str
是一个切片(&[u8]
),它始终指向一个有效的 UTF-8 序列,可以用来查看 String
,就像 &[T]
是 Vec<T>
的一个视图一样。
更多 str
和 String
的方法可以在 std::str 和 std::string 模块中找到
字面值和转义字符
有多种方式可以编写包含特殊字符的字符串字面值。所有方式都会产生类似的 &str
,因此最好使用最方便编写的形式。同样,也有多种方式可以编写字节字符串字面值,它们都会产生 &[u8; N]
类型。
通常,特殊字符用反斜杠(\
)进行转义。这样你可以在字符串中添加任何字符,包括不可打印的字符和你不知道如何输入的字符。如果你想要一个字面的反斜杠,用另一个反斜杠转义它:\\
出现在字面值内的字符串或字符字面值分隔符必须被转义:"\""
和 '\''
。
有时需要转义的字符太多,或者直接按原样写出字符串会更方便。这时就可以使用原始字符串字面值。
想要一个非 UTF-8 的字符串吗?(请记住,str
和 String
必须是有效的 UTF-8)。或者你想要一个主要是文本的字节数组?字节字符串来帮忙!
对于字符编码之间的转换,请查看 encoding crate。
关于编写字符串字面值和转义字符的更详细说明,请参阅 Rust 参考手册的「标记」章节。
Option
有时我们希望捕获程序某些部分的失败,而不是调用 panic!
。这可以通过使用 Option
枚举来实现。
Option<T>
枚举有两个变体:
None
:表示失败或缺少值,以及Some(value)
:一个元组结构体,包装了类型为T
的value
。
Result
我们已经看到 Option
枚举可以用作可能失败的函数的返回值,其中 None
用于表示失败。 然而,有时表达操作失败的原因很重要。为此,我们有 Result
枚举。
Result<T, E>
枚举有两个变体:
Ok(value)
:表示操作成功,并包装了操作返回的value
。(value
的类型为T
)Err(why)
:表示操作失败,并包装了why
,它(希望)解释了失败的原因。(why
的类型为E
)
?
使用 match 链式处理结果可能会变得相当混乱;幸运的是,我们可以使用 ?
运算符来让代码变得整洁。?
运算符用在返回 Result
的表达式末尾,等效于一个 match 表达式。在这个表达式中,Err(err)
分支会展开为提前返回的 return Err(From::from(err))
,而 Ok(ok)
分支则展开为 ok
表达式。
请务必查阅 文档,其中包含了许多用于映射和组合 Result
的方法。
panic!
panic!
宏可用于生成一个 panic 并开始展开其栈。在展开过程中,运行时会通过调用该线程所有对象的析构函数来释放线程拥有的所有资源。
由于我们处理的是只有一个线程的程序,panic!
会导致程序报告 panic 消息并退出。
让我们验证 panic!
不会导致内存泄漏。
$ rustc panic.rs && valgrind ./panic
==4401== Memcheck, a memory error detector
==4401== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==4401== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==4401== Command: ./panic
==4401==
thread '<main>' panicked at 'division by zero', panic.rs:5
==4401==
==4401== HEAP SUMMARY:
==4401== in use at exit: 0 bytes in 0 blocks
==4401== total heap usage: 18 allocs, 18 frees, 1,648 bytes allocated
==4401==
==4401== All heap blocks were freed -- no leaks are possible
==4401==
==4401== For counts of detected and suppressed errors, rerun with: -v
==4401== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
HashMap
向量(Vector)通过整数索引存储值,而 HashMap
则通过键存储值。HashMap
的键可以是布尔值、整数、字符串,或任何其他实现了 Eq
和 Hash
trait 的类型。下一节将详细介绍这一点。
与向量类似,HashMap
也可以增长,但当有多余空间时,HashMap 还能自动收缩。你可以使用 HashMap::with_capacity(uint)
创建一个具有指定初始容量的 HashMap,或使用 HashMap::new()
来获得一个具有默认初始容量的 HashMap(推荐)。
要了解更多关于哈希和哈希映射(有时称为哈希表)的工作原理,请参阅哈希表的维基百科页面
更改或自定义键类型
任何实现了 Eq
和 Hash
trait 的类型都可以作为 HashMap
的键。这包括:
bool
(虽然用处不大,因为只有两个可能的键值)int
、uint
及其所有变体String
和&str
(专业提示:你可以使用String
作为HashMap
的键,并用&str
调用.get()
方法)
注意,f32
和 f64
并未实现 Hash
trait,这很可能是因为浮点数精度误差会导致将它们用作哈希映射的键时极易出错。
如果集合类中包含的类型分别实现了 Eq
和 Hash
,那么这些集合类也会实现 Eq
和 Hash
。例如,如果 T
实现了 Hash
,那么 Vec<T>
也会实现 Hash
。
你可以通过一行代码轻松地为自定义类型实现 Eq
和 Hash
:#[derive(PartialEq, Eq, Hash)]
编译器会完成剩余的工作。如果你想对细节有更多控制,可以自己实现 Eq
和/或 Hash
。本指南不会涉及实现 Hash
的具体细节。
为了尝试在 HashMap
中使用 struct
,让我们来创建一个非常简单的用户登录系统:
HashSet
可以将 HashSet
视为一个只关心键的 HashMap
(实际上,HashSet<T>
只是 HashMap<T, ()>
的封装)。
你可能会问:"这有什么意义?我可以直接把键存储在 Vec
中啊。"
HashSet
的独特之处在于它保证不会有重复元素。这是所有集合类型都应满足的约定。HashSet
只是其中一种实现。(另请参阅:BTreeSet
)
如果你插入一个已存在于 HashSet
中的值(即新值等于现有值,且它们的哈希值相同),那么新值将替换旧值。
当你不希望某个元素出现多次,或者想知道是否已经拥有某个元素时,这非常有用。
但是集合的功能不仅限于此。
集合有 4 种主要操作(以下所有调用都返回一个迭代器):
-
并集(
union
):获取两个集合中的所有唯一元素。 -
差集(
difference
):获取存在于第一个集合但不在第二个集合中的所有元素。 -
交集(
intersection
):获取同时存在于两个集合中的所有元素。 -
对称差(
symmetric_difference
):获取存在于其中一个集合中,但不同时存在于两个集合中的所有元素。
在下面的例子中尝试这些操作:
(示例改编自官方文档)
Rc
当需要多重所有权时,可以使用 Rc
(引用计数,Reference Counting)。Rc
会跟踪引用的数量,即包裹在 Rc
内部的值的所有者数量。
每当克隆一个 Rc
时,其引用计数就会增加 1;每当一个克隆的 Rc
离开作用域时,引用计数就会减少 1。当 Rc
的引用计数变为零(意味着没有剩余的所有者)时,Rc
及其包含的值都会被丢弃。
克隆 Rc
从不执行深拷贝。克隆只是创建另一个指向被包裹值的指针,并增加计数。
另请参阅:
Arc
当需要在线程间共享所有权时,可以使用 Arc
(原子引用计数,Atomically Reference Counted)。通过 Clone
实现,这个结构体可以为堆内存中值的位置创建一个引用指针,同时增加引用计数。由于它在线程间共享所有权,当指向某个值的最后一个引用指针超出作用域时,该变量就会被释放。
标准库中的其他内容
标准库提供了许多其他类型来支持各种功能,例如:
- 线程
- 信道
- 文件 I/O
这些类型扩展了基本类型所提供的功能。
另请参阅:
线程
Rust 通过 spawn
函数提供了一种生成原生操作系统线程的机制,该函数的参数是一个移动闭包。
这些线程将由操作系统进行调度。
测试实例:map-reduce
Rust makes it very easy to parallelize data processing, without many of the headaches traditionally associated with such an attempt.
标准库提供了开箱即用的优秀线程原语。这些原语结合 Rust 的所有权概念和别名规则,自动防止了数据竞争。
The aliasing rules (one writable reference XOR many readable references) automatically prevent you from manipulating state that is visible to other threads. (Where synchronization is needed, there are synchronization primitives like Mutex
es or Channel
s.)
在这个例子中,我们将计算一个数字块中所有数字的总和。我们通过将数字块分成小块并分配给不同的线程来完成这个任务。每个线程将计算其小块数字的总和,随后我们将汇总每个线程产生的中间结果。
注意,尽管我们在线程间传递引用,但 Rust 理解我们只是传递只读引用,因此不会发生不安全操作或数据竞争。此外,由于我们传递的引用具有 'static
生命周期,Rust 确保这些线程运行时数据不会被销毁。(当需要在线程间共享非 static
数据时,可以使用 Arc
等智能指针来保持数据存活并避免非 static
生命周期。)
练习
让线程数量依赖于用户输入的数据并不明智。如果用户决定插入大量空格,我们真的想要创建 2,000 个线程吗?修改程序,使数据始终被分割成固定数量的块,这个数量应由程序开头定义的静态常量来确定。
另请参阅:
信道
Rust 提供异步通道(channels
)用于线程间通信。通道允许信息在两个端点之间单向流动:发送端(Sender
)和接收端(Receiver
)。
路径
Path
结构体表示底层文件系统中的文件路径。Path
有两种变体:用于类 UNIX 系统的 posix::Path
和用于 Windows 的 windows::Path
。prelude 会导出适合特定平台的 Path
变体。
Path
可以从 OsStr
创建,并提供多种方法来获取路径所指向的文件或目录的信息。
Path
是不可变的。Path
的所有权版本是 PathBuf
。Path
和 PathBuf
之间的关系类似于 str
和 String
:PathBuf
可以原地修改,并且可以解引用为 Path
。
注意,Path
在内部并非表示为 UTF-8 字符串,而是存储为 OsString
。因此,将 Path
转换为 &str
并非零开销操作,且可能失败(返回一个 Option
)。然而,Path
可以自由地转换为 OsString
或 &OsStr
,分别使用 into_os_string
和 as_os_str
方法。
请务必查看其他 Path
方法(posix::Path
或 windows::Path
)以及 Metadata
结构体。
另请参阅:
文件 I/O
File
结构体表示一个已打开的文件(它封装了一个文件描述符),并提供对底层文件的读取和/或写入访问。
由于文件 I/O 操作可能会出现多种错误,所有 File
方法都返回 io::Result<T>
类型,这是 Result<T, io::Error>
的别名。
这使得所有 I/O 操作的失败都变得显式。得益于此,程序员可以看到所有可能的失败路径,并被鼓励主动处理这些情况。
open
open
函数可用于以只读模式打开文件。
File
拥有一个资源(即文件描述符),并在被 drop
时负责关闭文件。
以下是预期的成功输出:
$ echo "Hello World!" > hello.txt
$ rustc open.rs && ./open
hello.txt 的内容:
Hello World!
(建议您在不同的失败情况下测试上述示例:例如 hello.txt
不存在,或 hello.txt
不可读等。)
create
create
函数以只写模式打开文件。如果文件已存在,旧内容会被清除;否则,会创建一个新文件。
static LOREM_IPSUM: &str =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
";
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
fn main() {
let path = Path::new("lorem_ipsum.txt");
let display = path.display();
// 以只写模式打开文件,返回 `io::Result<File>`
let mut file = match File::create(&path) {
Err(why) => panic!("无法创建 {}: {}", display, why),
Ok(file) => file,
};
// 将 `LOREM_IPSUM` 字符串写入 `file`,返回 `io::Result<()>`
match file.write_all(LOREM_IPSUM.as_bytes()) {
Err(why) => panic!("无法写入 {}: {}", display, why),
Ok(_) => println!("成功写入 {}", display),
}
}
以下是预期的成功输出:
$ rustc create.rs && ./create
successfully wrote to lorem_ipsum.txt
$ cat lorem_ipsum.txt
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
(与前面的示例类似,建议您在失败情况下测试此示例。)
OpenOptions
结构体可用于配置文件的打开方式。
read_lines
一种简单的方法
对于初学者来说,这可能是从文件中读取行的一个合理的初步尝试。
由于 lines()
方法返回文件中各行的迭代器,我们可以内联执行 map 并收集结果,从而得到一个更简洁流畅的表达式。
注意,在上述两个示例中,我们都必须将 lines()
返回的 &str
引用转换为拥有所有权的 String
类型,分别使用 .to_string()
和 String::from
。
一种更高效的方法
在这里,我们将打开的 File
的所有权传递给 BufReader
结构体。BufReader
使用内部缓冲区来减少中间分配。
我们还对 read_lines
函数进行了改进,使其返回一个迭代器,而不是为每行内容在内存中分配新的 String
对象。
use std::fs::File;
use std::io::{self, BufRead};
use std::path::Path;
fn main() {
// 文件 hosts.txt 必须存在于当前路径下
if let Ok(lines) = read_lines("./hosts.txt") {
// 消耗迭代器,返回一个(可选的)String
for line in lines.map_while(Result::ok) {
println!("{}", line);
}
}
}
// 输出被包装在 Result 中以便于错误匹配。
// 返回一个指向文件行读取器的迭代器。
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where P: AsRef<Path>, {
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
运行此程序将逐行打印文件内容。
$ echo -e "127.0.0.1\n192.168.0.1\n" > hosts.txt
$ rustc read_lines.rs && ./read_lines
127.0.0.1
192.168.0.1
(注意,由于 File::open
需要一个泛型 AsRef<Path>
作为参数,我们使用 where
关键字为 read_lines()
方法定义了相同的泛型约束。)
这种方法比在内存中创建包含整个文件内容的 String
更加高效。特别是在处理大文件时,后者可能会导致性能问题。
子进程
process::Output
结构体表示已完成子进程的输出,而 process::Command
结构体是一个进程构建器。
(建议您尝试在上述示例中向 rustc
传递一个错误的标志)
管道
The std::process::Child
struct represents a child process, and exposes the stdin
, stdout
and stderr
handles for interaction with the underlying process via pipes.
use std::io::prelude::*;
use std::process::{Command, Stdio};
static PANGRAM: &'static str =
"the quick brown fox jumps over the lazy dog\n";
fn main() {
// 启动 `wc` 命令
let mut cmd = if cfg!(target_family = "windows") {
let mut cmd = Command::new("powershell");
cmd.arg("-Command").arg("$input | Measure-Object -Line -Word -Character");
cmd
} else {
Command::new("wc")
};
let process = match cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn() {
Err(why) => panic!("无法启动 wc:{}", why),
Ok(process) => process,
};
// 向 `wc` 的 `stdin` 写入字符串。
//
// `stdin` 的类型是 `Option<ChildStdin>`,但我们知道这个实例
// 必定存在,所以可以直接 `unwrap` 它。
match process.stdin.unwrap().write_all(PANGRAM.as_bytes()) {
Err(why) => panic!("无法写入 wc 的标准输入:{}", why),
Ok(_) => println!("已将 pangram 发送给 wc"),
}
// 由于 `stdin` 在上述调用后不再存活,它会被 `drop`,
// 管道随之关闭。
//
// 这一点非常重要,否则 `wc` 不会开始处理
// 我们刚刚发送的输入。
// `stdout` 字段的类型也是 `Option<ChildStdout>`,因此必须解包。
let mut s = String::new();
match process.stdout.unwrap().read_to_string(&mut s) {
Err(why) => panic!("无法读取 wc 的标准输出:{}", why),
Ok(_) => print!("wc 的响应为:\n{}", s),
}
}
等待
如果你想等待一个 process::Child
完成,你必须调用 Child::wait
,它会返回一个 process::ExitStatus
。
use std::process::Command;
fn main() {
let mut child = Command::new("sleep").arg("5").spawn().unwrap();
let _result = child.wait().unwrap();
println!("到达 main 函数末尾");
}
$ rustc wait.rs && ./wait
# `wait` 会持续运行 5 秒,直到 `sleep 5` 命令执行完毕
reached end of main
文件系统操作
std::fs
模块包含多个用于处理文件系统的函数。
use std::fs;
use std::fs::{File, OpenOptions};
use std::io;
use std::io::prelude::*;
#[cfg(target_family = "unix")]
use std::os::unix;
#[cfg(target_family = "windows")]
use std::os::windows;
use std::path::Path;
// `% cat path` 命令的简单实现
fn cat(path: &Path) -> io::Result<String> {
let mut f = File::open(path)?;
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
// `% echo s > path` 命令的简单实现
fn echo(s: &str, path: &Path) -> io::Result<()> {
let mut f = File::create(path)?;
f.write_all(s.as_bytes())
}
// `% touch path` 命令的简单实现(忽略已存在的文件)
fn touch(path: &Path) -> io::Result<()> {
match OpenOptions::new().create(true).write(true).open(path) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
fn main() {
println!("`mkdir a`");
// 创建目录,返回 `io::Result<()>`
match fs::create_dir("a") {
Err(why) => println!("! {:?}", why.kind()),
Ok(_) => {},
}
println!("`echo hello > a/b.txt`");
// 可以使用 `unwrap_or_else` 方法简化之前的匹配
echo("hello", &Path::new("a/b.txt")).unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`mkdir -p a/c/d`");
// 递归创建目录,返回 `io::Result<()>`
fs::create_dir_all("a/c/d").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`touch a/c/e.txt`");
touch(&Path::new("a/c/e.txt")).unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`ln -s ../b.txt a/c/b.txt`");
// 创建符号链接,返回 `io::Result<()>`
#[cfg(target_family = "unix")] {
unix::fs::symlink("../b.txt", "a/c/b.txt").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
}
#[cfg(target_family = "windows")] {
windows::fs::symlink_file("../b.txt", "a/c/b.txt").unwrap_or_else(|why| {
println!("! {:?}", why.to_string());
});
}
println!("`cat a/c/b.txt`");
match cat(&Path::new("a/c/b.txt")) {
Err(why) => println!("! {:?}", why.kind()),
Ok(s) => println!("> {}", s),
}
println!("`ls a`");
// 读取目录内容,返回 `io::Result<Vec<Path>>`
match fs::read_dir("a") {
Err(why) => println!("! {:?}", why.kind()),
Ok(paths) => for path in paths {
println!("> {:?}", path.unwrap().path());
},
}
println!("`rm a/c/e.txt`");
// 删除文件,返回 `io::Result<()>`
fs::remove_file("a/c/e.txt").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`rmdir a/c/d`");
// 删除空目录,返回 `io::Result<()>`
fs::remove_dir("a/c/d").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
}
以下是预期的成功输出:
$ rustc fs.rs && ./fs
`mkdir a`
`echo hello > a/b.txt`
`mkdir -p a/c/d`
`touch a/c/e.txt`
`ln -s ../b.txt a/c/b.txt`
`cat a/c/b.txt`
> hello
`ls a`
> "a/b.txt"
> "a/c"
`rm a/c/e.txt`
`rmdir a/c/d`
最终 a
目录的状态如下:
$ tree a
a
|-- b.txt
`-- c
`-- b.txt -> ../b.txt
1 directory, 2 files
另一种定义 cat
函数的方法是使用 ?
运算符:
fn cat(path: &Path) -> io::Result<String> {
let mut f = File::open(path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
另请参阅:
程序参数
标准库
可以使用 std::env::args
访问命令行参数,它返回一个迭代器,为每个参数生成一个 String
:
$ ./args 1 2 3
程序路径:./args
接收到 3 个参数:["1"、"2"、"3"]
Crates
此外,在开发命令行应用程序时,还有许多 crate 可以提供额外的功能。其中,clap
是一个广受欢迎的命令行参数处理 crate。
参数解析
可以使用模式匹配来解析简单的参数:
如果你将程序命名为 match_args.rs
并使用 rustc match_args.rs
编译它,你可以按以下方式执行:
$ ./match_args Rust
This is not the answer.
$ ./match_args 42
This is the answer!
$ ./match_args do something
error: second argument not an integer
usage:
match_args <string>
Check whether given string is the answer.
match_args {increase|decrease} <integer>
Increase or decrease given integer by one.
$ ./match_args do 42
error: invalid command
usage:
match_args <string>
Check whether given string is the answer.
match_args {increase|decrease} <integer>
Increase or decrease given integer by one.
$ ./match_args increase 42
43
外部函数接口
Rust 提供了与 C 库交互的外部函数接口(FFI)。外部函数必须在 extern
块内声明,并使用 #[link]
属性标注外部库的名称。
use std::fmt;
// 此 extern 块链接到 libm 库
#[cfg(target_family = "windows")]
#[link(name = "msvcrt")]
extern {
// 这是一个外部函数
// 用于计算单精度复数的平方根
fn csqrtf(z: Complex) -> Complex;
fn ccosf(z: Complex) -> Complex;
}
#[cfg(target_family = "unix")]
#[link(name = "m")]
extern {
// 这是一个外部函数
// 用于计算单精度复数的平方根
fn csqrtf(z: Complex) -> Complex;
fn ccosf(z: Complex) -> Complex;
}
// 由于调用外部函数被认为是不安全的,
// 通常会为它们编写安全的包装函数。
fn cos(z: Complex) -> Complex {
unsafe { ccosf(z) }
}
fn main() {
// z = -1 + 0i
let z = Complex { re: -1., im: 0. };
// 调用外部函数是一个不安全的操作
let z_sqrt = unsafe { csqrtf(z) };
println!("{:?} 的平方根是 {:?}", z, z_sqrt);
// 调用封装了不安全操作的安全 API
println!("cos({:?}) = {:?}", z, cos(z));
}
// 单精度复数的最小实现
#[repr(C)]
#[derive(Clone, Copy)]
struct Complex {
re: f32,
im: f32,
}
impl fmt::Debug for Complex {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.im < 0. {
write!(f, "{}-{}i", self.re, -self.im)
} else {
write!(f, "{}+{}i", self.re, self.im)
}
}
}
测试
Rust 是一种非常注重正确性的编程语言,它内置了编写软件测试的支持。
测试分为三种类型:
此外,Rust 还支持为测试指定额外的依赖项:
参见
单元测试
测试是 Rust 函数,用于验证非测试代码是否按预期方式运行。测试函数的主体通常包括一些准备工作,运行待测试的代码,然后断言结果是否符合预期。
大多数单元测试都放在带有 #[cfg(test)]
属性的 tests
模块中。测试函数用 #[test]
属性标记。
当测试函数中出现恐慌(panic)时,测试就会失败。以下是一些辅助宏:
assert!(expression)
- 如果表达式求值为false
,则会触发 panic。assert_eq!(left, right)
和assert_ne!(left, right)
- 分别用于测试左右表达式的相等性和不相等性。
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// 这是一个非常糟糕的加法函数,它的目的是在这个例子中失败。
#[allow(dead_code)]
fn bad_add(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
// 注意这个有用的惯用法:从外部作用域(对于 mod tests 而言)导入名称。
use super::*;
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
#[test]
fn test_bad_add() {
// 这个断言会触发,测试将失败。
// 请注意,私有函数也可以被测试!
assert_eq!(bad_add(1, 2), 3);
}
}
可以使用 cargo test
命令运行测试。
$ cargo test
running 2 tests
test tests::test_bad_add ... FAILED
test tests::test_add ... ok
failures:
---- tests::test_bad_add stdout ----
thread 'tests::test_bad_add' panicked at 'assertion failed: `(left == right)`
left: `-1`,
right: `3`', src/lib.rs:21:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::test_bad_add
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
测试与 ?
运算符
之前的单元测试示例都没有返回类型。但在 Rust 2018 版本中,你的单元测试可以返回 Result<()>
,这使得你可以在测试中使用 ?
运算符!这可以使测试代码更加简洁。
更多详情请参阅《版本指南》。
测试 panic
要检查在某些情况下应该触发恐慌的函数,可以使用 #[should_panic]
属性。这个属性接受可选参数 expected =
,用于指定预期的恐慌消息文本。如果你的函数可能以多种方式触发 panic,这有助于确保你的测试正在检查正确的 panic 情况。
pub fn divide_non_zero_result(a: u32, b: u32) -> u32 {
if b == 0 {
panic!("除以零错误");
} else if a < b {
panic!("除法结果为零");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_divide() {
assert_eq!(divide_non_zero_result(10, 2), 5);
}
#[test]
#[should_panic]
fn test_any_panic() {
divide_non_zero_result(1, 0);
}
#[test]
#[should_panic(expected = "除法结果为零")]
fn test_specific_panic() {
divide_non_zero_result(1, 10);
}
}
运行这些测试会得到以下结果:
$ cargo test
running 3 tests
test tests::test_any_panic ... ok
test tests::test_divide ... ok
test tests::test_specific_panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
运行特定测试
要运行特定的测试,可以在 cargo test
命令中指定测试名称。
$ cargo test test_any_panic
running 1 test
test tests::test_any_panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
要运行多个测试,可以指定测试名称的一部分,该部分匹配所有应该运行的测试。
$ cargo test panic
running 2 tests
test tests::test_any_panic ... ok
test tests::test_specific_panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
忽略测试
可以使用 #[ignore]
属性标记测试以排除某些测试。或者使用命令 cargo test -- --ignored
来运行这些被忽略的测试。
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
#[test]
fn test_add_hundred() {
assert_eq!(add(100, 2), 102);
assert_eq!(add(2, 100), 102);
}
#[test]
#[ignore]
fn ignored_test() {
assert_eq!(add(0, 0), 0);
}
}
$ cargo test
running 3 tests
test tests::ignored_test ... ignored
test tests::test_add ... ok
test tests::test_add_hundred ... ok
test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
Doc-tests tmp-ignore
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo test -- --ignored
running 1 test
test tests::ignored_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests tmp-ignore
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
文档测试
Rust 项目的主要文档编写方式是通过在源代码中添加注释。文档注释使用 CommonMark Markdown 规范编写,并支持其中的代码块。Rust 注重正确性,因此这些代码块会被编译并用作文档测试。
/// 第一行是函数的简要描述。
///
/// 接下来的几行是详细文档。代码块以三个反引号开始,
/// 并隐含了 `fn main()` 函数和 `extern crate <cratename>` 声明。
/// 假设我们正在测试 `doccomments` crate:
///
/// ```
/// let result = doccomments::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 文档注释通常包含"示例"、"异常"和"错误"等部分。
///
/// 下面的函数用于两数相除。
///
/// # 示例
///
/// ```
/// let result = doccomments::div(10, 2);
/// assert_eq!(result, 5);
/// ```
///
/// # 异常
///
/// 当第二个参数为零时,函数会触发异常。
///
/// ```rust,should_panic
/// // 除以零会触发异常
/// doccomments::div(10, 0);
/// ```
pub fn div(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("除以零错误");
}
a / b
}
运行常规的 cargo test
命令时,文档中的代码块会自动进行测试:
$ cargo test
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests doccomments
running 3 tests
test src/lib.rs - add (line 7) ... ok
test src/lib.rs - div (line 21) ... ok
test src/lib.rs - div (line 31) ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
文档测试的动机
文档测试的主要目的是提供功能演示的示例,这是最重要的指导原则之一。它允许将文档中的示例作为完整的代码片段使用。但是使用 ?
会导致编译失败,因为 main
函数返回 unit
类型。这时,隐藏文档中的某些源代码行就派上用场了:可以编写 fn try_main() -> Result<(), ErrorType>
,将其隐藏,并在隐藏的 main
函数中 unwrap
它。听起来很复杂?这里有一个例子:
/// 在文档测试中使用隐藏的 `try_main` 函数。
///
/// ```
/// # // 以 `#` 开头的行在文档中是隐藏的,但它们仍然可以编译!
/// # fn try_main() -> Result<(), String> { // 这行包装了文档中显示的函数体
/// let res = doccomments::try_div(10, 2)?;
/// # Ok(()) // 从 try_main 返回
/// # }
/// # fn main() { // 开始会调用 unwrap() 的 main 函数
/// # try_main().unwrap(); // 调用 try_main 并解包
/// # // 这样在出错时测试会触发 panic
/// # }
/// ```
pub fn try_div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("除以零错误"))
} else {
Ok(a / b)
}
}
参见
集成测试
单元测试每次只隔离测试一个模块:它们规模小,可以测试私有代码。集成测试则位于 crate 外部,仅使用其公共接口,就像其他代码一样。集成测试的目的是验证库的多个部分能否正确协同工作。
Cargo 在 src
目录旁的 tests
目录中查找集成测试。
文件 src/lib.rs
:
// 在名为 `adder` 的 crate 中定义此内容。
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
测试文件:tests/integration_test.rs
:
#[test]
fn test_add() {
assert_eq!(adder::add(3, 2), 5);
}
使用 cargo test
命令运行测试:
$ cargo test
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-bcd60824f5fbfe19
running 1 test
test test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
tests
目录中的每个 Rust 源文件都被编译为独立的 crate。为了在集成测试之间共享代码,我们可以创建一个包含公共函数的模块,然后在测试中导入并使用它。
文件 tests/common/mod.rs
:
pub fn setup() {
// 一些设置代码,如创建必要的文件/目录,启动
// 服务器等。
}
测试文件:tests/integration_test.rs
// 导入 common 模块。
mod common;
#[test]
fn test_add() {
// 使用公共代码。
common::setup();
assert_eq!(adder::add(3, 2), 5);
}
将模块创建为 tests/common.rs
也可行,但不推荐,因为测试运行器会将该文件视为测试 crate 并尝试运行其中的测试。
开发依赖
有时我们需要仅用于测试(或示例、基准测试)的依赖项。这些依赖项添加在 Cargo.toml
的 [dev-dependencies]
部分。这些依赖项不会传递给依赖于本包的其他包。
例如 pretty_assertions
,它扩展了标准的 assert_eq!
和 assert_ne!
宏,提供彩色差异对比。
文件 Cargo.toml
:
# 省略标准的 crate 数据
[dev-dependencies]
pretty_assertions = "1"
文件 src/lib.rs
:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq; // 仅用于测试的 crate。不能在非测试代码中使用。
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
参见
Cargo 文档中关于指定依赖项的说明。
不安全操作
作为本节的引言,借用官方文档的话说:"应该尽量减少代码库中不安全代码的数量。"牢记这一点,让我们开始吧!Rust 中的不安全标注用于绕过编译器设置的保护机制。具体来说,不安全主要用于以下四个方面:
- 解引用裸指针
- 调用被标记为
unsafe
的函数或方法(包括通过 FFI 调用函数,参见本书前面的章节) - 访问或修改静态可变变量
- 实现不安全特征
裸指针
裸指针 *
和引用 &T
的功能类似,但引用总是安全的,因为借用检查器保证它们指向有效数据。解引用裸指针只能在 unsafe 块中进行。
调用不安全函数
某些函数可以被声明为 unsafe
,这意味着确保其正确性是程序员的责任,而不是编译器的责任。一个例子是 std::slice::from_raw_parts
,它根据指向第一个元素的指针和长度创建一个切片。
对于 slice::from_raw_parts
,必须遵守的一个假设是:传入的指针指向有效内存,且指向的内存类型正确。如果这些不变量未被遵守,那么程序的行为将是未定义的,无法预知会发生什么。
内联汇编
Rust 通过 asm!
宏提供了内联汇编支持。它可以用于在编译器生成的汇编输出中嵌入手写的汇编代码。通常这不是必需的,但在无法通过其他方式实现所需性能或时序要求时可能会用到。访问底层硬件原语(例如在内核代码中)也可能需要这个功能。
注意:这里的示例使用 x86/x86-64 汇编,但也支持其他架构。
目前支持内联汇编的架构包括:
- x86 和 x86-64
- ARM
- AArch64
- RISC-V
基本用法
让我们从最简单的例子开始:
这将在编译器生成的汇编代码中插入一条 NOP(无操作)指令。请注意,所有 asm!
调用都必须放在 unsafe
块内,因为它们可能插入任意指令并破坏各种不变量。要插入的指令以字符串字面量的形式列在 asm!
宏的第一个参数中。
输入和输出
插入一个什么都不做的指令相当无聊。让我们来做些实际操作数据的事情:
这将把值 5
写入 u64
类型的变量 x
。你可以看到,我们用来指定指令的字符串字面量实际上是一个模板字符串。它遵循与 Rust 格式化字符串相同的规则。然而,插入到模板中的参数看起来可能与你熟悉的有些不同。首先,我们需要指定变量是内联汇编的输入还是输出。在这个例子中,它是一个输出。我们通过写 out
来声明这一点。我们还需要指定汇编期望变量在什么类型的寄存器中。这里我们通过指定 reg
将其放在任意通用寄存器中。编译器将选择一个合适的寄存器插入到模板中,并在内联汇编执行完成后从该寄存器读取变量的值。
让我们再看一个使用输入的例子:
这段代码会将 5
加到变量 i
的值上,然后将结果写入变量 o
。具体的汇编实现是先将 i
的值复制到输出寄存器,然后再加上 5
。
这个例子展示了几个要点:
asm!
宏支持多个模板字符串参数,每个参数都被视为独立的汇编代码行,就像它们之间用换行符连接一样。这使得格式化汇编代码变得简单。
其次,我们可以看到输入参数使用 in
声明,而不是 out
。
第三,我们可以像在任何格式字符串中一样指定参数编号或名称。这在内联汇编模板中特别有用,因为参数通常会被多次使用。对于更复杂的内联汇编,建议使用这种方式,因为它提高了可读性,并且允许在不改变参数顺序的情况下重新排列指令。
我们可以进一步优化上面的例子,避免使用 mov
指令:
我们可以看到 inout
用于指定既作为输入又作为输出的参数。这与分别指定输入和输出不同,它保证将两者分配到同一个寄存器。
也可以为 inout
操作数的输入和输出部分指定不同的变量:
延迟输出操作数
Rust 编译器在分配操作数时采取保守策略。它假设 out
可以在任何时候被写入,因此不能与其他参数共享位置。然而,为了保证最佳性能,使用尽可能少的寄存器很重要,这样就不必在内联汇编块前后保存和重新加载寄存器。为此,Rust 提供了 lateout
说明符。这可以用于任何在所有输入被消耗后才写入的输出。此外还有一个 inlateout
变体。
以下是一个在 release
模式或其他优化情况下 不能 使用 inlateout
的例子:
在未优化的情况下(如 Debug
模式),将上述例子中的 inout(reg) a
替换为 inlateout(reg) a
仍能得到预期结果。但在 release
模式或其他优化情况下,使用 inlateout(reg) a
可能导致最终值 a = 16
,使断言失败。
这是因为在优化情况下,编译器可以为输入 b
和 c
分配相同的寄存器,因为它知道它们具有相同的值。此外,当使用 inlateout
时,a
和 c
可能被分配到同一个寄存器,这种情况下,第一条 add
指令会覆盖从变量 c
初始加载的值。相比之下,使用 inout(reg) a
可以确保为 a
分配一个单独的寄存器。
然而,以下示例可以使用 inlateout
,因为输出仅在读取所有输入寄存器后才被修改:
如你所见,即使 a
和 b
被分配到同一个寄存器,这段汇编代码片段仍能正确运行。
显式寄存器操作数
某些指令要求操作数必须位于特定寄存器中。因此,Rust 内联汇编提供了一些更具体的约束说明符。虽然 reg
通常适用于任何架构,但显式寄存器高度依赖于特定架构。例如,对于 x86 架构,通用寄存器如 eax
、ebx
、ecx
、edx
、ebp
、esi
和 edi
等可以直接通过名称进行寻址。
在这个例子中,我们调用 out
指令将 cmd
变量的内容输出到端口 0x64
。由于 out
指令只接受 eax
(及其子寄存器)作为操作数,我们必须使用 eax
约束说明符。
注意:与其他操作数类型不同,显式寄存器操作数不能在模板字符串中使用。你不能使用
{}
,而应直接写入寄存器名称。此外,它们必须出现在操作数列表的末尾,位于所有其他操作数类型之后。
考虑以下使用 x86 mul
指令的例子:
这里使用 mul
指令将两个 64 位输入相乘,得到一个 128 位的结果。唯一的显式操作数是一个寄存器,我们用变量 a
填充它。第二个操作数是隐式的,必须是 rax
寄存器,我们用变量 b
填充它。结果的低 64 位存储在 rax
中,用于填充变量 lo
。高 64 位存储在 rdx
中,用于填充变量 hi
。
被破坏的寄存器
在许多情况下,内联汇编会修改不需要作为输出的状态。这通常是因为我们必须在汇编中使用临时寄存器,或者因为指令修改了我们不需要进一步检查的状态。这种状态通常被称为"被破坏"。我们需要告知编译器这一点,因为它可能需要在内联汇编块前后保存和恢复这种状态。
在上面的示例中,我们使用 cpuid
指令读取 CPU 制造商 ID。该指令将最大支持的 cpuid
参数写入 eax
,并按顺序将 CPU 制造商 ID 的 ASCII 字节写入 ebx
、edx
和 ecx
。
尽管 eax
从未被读取,我们仍需要告知编译器该寄存器已被修改,这样编译器就可以保存汇编前这些寄存器中的任何值。我们通过将其声明为输出来实现这一点,但使用 _
而非变量名,表示输出值将被丢弃。
这段代码还解决了 LLVM 将 ebx
视为保留寄存器的限制。这意味着 LLVM 假定它对该寄存器拥有完全控制权,并且必须在退出汇编块之前将其恢复到原始状态。因此,ebx
不能用作输入或输出,除非编译器将其用于满足通用寄存器类(如 in(reg)
)。这使得在使用保留寄存器时,reg
操作数变得危险,因为我们可能会在不知情的情况下破坏输入或输出,原因是它们共享同一个寄存器。
为了解决这个问题,我们采用以下策略:使用 rdi
存储输出数组的指针;通过 push
保存 ebx
;在汇编块内从 ebx
读取数据到数组中;然后通过 pop
将 ebx
恢复到原始状态。push
和 pop
操作使用完整的 64 位 rbx
寄存器版本,以确保整个寄存器被保存。在 32 位目标上,代码会在 push
/pop
操作中使用 ebx
。
这种技术还可以与通用寄存器类一起使用,以获得一个临时寄存器在汇编代码内使用:
符号操作数和 ABI 破坏
默认情况下,asm!
假定汇编代码会保留所有未指定为输出的寄存器的内容。asm!
的 clobber_abi
参数告诉编译器根据给定的调用约定 ABI 自动插入必要的破坏操作数:任何在该 ABI 中未完全保留的寄存器都将被视为被破坏。可以提供多个 clobber_abi
参数,所有指定 ABI 的破坏都将被插入。
寄存器模板修饰符
在某些情况下,需要对寄存器名称插入模板字符串时的格式进行精细控制。当一个架构的汇编语言对同一个寄存器有多个名称时,这种控制尤为必要。每个名称通常代表寄存器的一个子集"视图"(例如,64 位寄存器的低 32 位)。
默认情况下,编译器总是会选择引用完整寄存器大小的名称(例如,在 x86-64 上是 rax
,在 x86 上是 eax
等)。
可以通过在模板字符串操作数上使用修饰符来覆盖这个默认设置,类似于格式字符串的用法:
在这个例子中,我们使用 reg_abcd
寄存器类来限制寄存器分配器只使用 4 个传统的 x86 寄存器(ax
、bx
、cx
、dx
)。这些寄存器的前两个字节可以独立寻址。
假设寄存器分配器选择将 x
分配到 ax
寄存器。h
修饰符将生成该寄存器高字节的名称,而 l
修饰符将生成低字节的名称。因此,汇编代码将被展开为 mov ah, al
,这条指令将值的低字节复制到高字节。
如果你对操作数使用较小的数据类型(例如 u16
)并忘记使用模板修饰符,编译器将发出警告并建议使用正确的修饰符。
内存地址操作数
有时汇编指令需要通过内存地址或内存位置传递操作数。你必须手动使用目标架构指定的内存地址语法。例如,在使用 Intel 汇编语法的 x86/x86_64 架构上,你应该用 []
包裹输入/输出,以表明它们是内存操作数:
标签
重复使用命名标签(无论是局部的还是其他类型的)可能导致汇编器或链接器错误,或引起其他异常行为。命名标签的重用可能以多种方式发生,包括:
- 显式重用:在一个
asm!
块中多次使用同一标签,或在多个块之间重复使用。 - 通过内联隐式重用:编译器可能会创建
asm!
块的多个副本,例如当包含该块的函数在多处被内联时。 - 通过 LTO 隐式重用:链接时优化(LTO)可能导致其他 crate 的代码被放置在同一代码生成单元中,从而可能引入任意标签。
因此,你应该只在内联汇编代码中使用 GNU 汇编器的数字局部标签。在汇编代码中定义符号可能会由于重复的符号定义而导致汇编器和/或链接器错误。
此外,在 x86 架构上使用默认的 Intel 语法时,由于一个 LLVM 的 bug,你不应使用仅由 0
和 1
组成的标签,如 0
、11
或 101010
,因为它们可能被误解为二进制值。使用 options(att_syntax)
可以避免这种歧义,但这会影响_整个_ asm!
块的语法。(关于 options
的更多信息,请参见下文的选项。)
这段代码会将 {0}
寄存器的值从 10 递减到 3,然后加 2 并将结果存储在 a
中。
这个例子展示了几个要点:
- 首先,同一个数字可以在同一个内联块中多次用作标签。
- Second, that when a numeric label is used as a reference (as an instruction operand, for example), the suffixes “b” (“backward”) or ”f” (“forward”) should be added to the numeric label. It will then refer to the nearest label defined by this number in this direction.
选项
默认情况下,内联汇编块的处理方式与具有自定义调用约定的外部 FFI 函数调用相同:它可能读写内存,产生可观察的副作用等。然而,在许多情况下,我们希望向编译器提供更多关于汇编代码实际行为的信息,以便编译器能够进行更好的优化。
让我们回顾一下之前 add
指令的例子:
可以将选项作为可选的最后一个参数传递给 asm!
宏。在这个例子中,我们指定了三个选项:
pure
:表示汇编代码没有可观察的副作用,其输出仅依赖于输入。这使得编译器优化器能够减少内联汇编的调用次数,甚至完全消除它。nomem
:表示汇编代码不读取或写入内存。默认情况下,编译器会假设内联汇编可以读写任何它可访问的内存地址(例如通过作为操作数传递的指针或全局变量)。nostack
:表示汇编代码不会向栈中压入任何数据。这允许编译器使用诸如 x86-64 上的栈红区等优化技术,以避免栈指针调整。
这些选项使编译器能够更好地优化使用 asm!
的代码,例如消除那些输出未被使用的纯 asm!
块。
有关可用选项的完整列表及其效果,请参阅参考文档。
兼容性
The Rust language is evolving rapidly, and because of this certain compatibility issues can arise, despite efforts to ensure forwards-compatibility wherever possible.
原始标识符
Rust 和许多编程语言一样,有"关键字"的概念。这些标识符在语言中具有特殊含义,因此你不能在变量名、函数名等地方使用它们。原始标识符允许你在通常不允许使用关键字的地方使用它们。这在 Rust 引入新关键字,而使用旧版本 Rust 的库中有与新版本引入的关键字同名的变量或函数时特别有用。
例如,假设有一个使用 Rust 2015 版编译的 crate foo
,它导出了一个名为 try
的函数。这个关键字在 2018 版中被保留用于新特性,如果没有原始标识符,我们就无法命名这个函数。
extern crate foo;
fn main() {
foo::try();
}
你会得到这个错误:
error: expected identifier, found keyword `try`
--> src/main.rs:4:4
|
4 | foo::try();
| ^^^ expected identifier, found keyword
你可以使用原始标识符这样写:
extern crate foo;
fn main() {
foo::r#try();
}
补充
有些主题虽然与程序如何运行不直接相关,但它们提供了工具或基础设施支持,使得整个开发生态变得更好。这些主题包括:
- 文档:使用内置的
rustdoc
为用户生成库文档。 - Playground:在文档中集成 Rust Playground。
文档
使用 cargo doc
在 target/doc
目录下构建文档。运行 cargo doc --open
将自动在浏览器中打开文档。
使用 cargo test
运行所有测试(包括文档测试)。如果只想运行文档测试,请使用 cargo test --doc
。
这些命令会根据需要适当地调用 rustdoc
(和 rustc
)。
文档注释
文档注释对需要文档的大型项目非常有用。运行 rustdoc
时,这些注释会被编译成文档。文档注释以 ///
开头,并支持 Markdown 语法。
要运行测试,首先将代码构建为库,然后告诉 rustdoc
库的位置,以便它可以将库链接到每个文档测试程序中:
$ rustc doc.rs --crate-type lib
$ rustdoc --test --extern doc="libdoc.rlib" doc.rs
文档属性
以下是几个与 rustdoc
配合使用的最常见 #[doc]
属性示例。
inline
用于内联文档,而非链接到单独的页面。
#[doc(inline)]
pub use bar::Bar;
/// bar 的文档
pub mod bar {
/// Bar 的文档
pub struct Bar;
}
no_inline
用于防止链接到单独页面或其他任何地方。
// libcore/prelude 中的示例
#[doc(no_inline)]
pub use crate::mem::drop;
hidden
使用此属性告诉 rustdoc
不要在文档中包含此内容:
在文档生成方面,rustdoc
被社区广泛使用。它是用来生成 标准库文档 的工具。
另请参阅:
- 《Rust 程序设计语言》:编写有用的文档注释
- rustdoc 手册
- Rust 参考手册:文档注释
- RFC 1574:API 文档约定
- RFC 1946:文档注释中的相对链接(rustdoc 内部链接)
- 有关注释的文档风格指南?(Reddit 讨论)
Playground
Rust Playground 是一个通过网页界面体验 Rust 代码的平台。
在 mdbook
中使用 Playground
在 mdbook
中,你可以让代码示例变得可运行和可编辑。
这不仅允许读者运行你的代码示例,还能修改和调整它。关键是在代码块标记中添加 editable
关键字,用逗号分隔。
```rust,editable
//...place your code here
```
此外,如果你希望 mdbook
在构建和测试时跳过某段代码,可以添加 ignore
关键字。
```rust,editable,ignore
//...place your code here
```
在文档中使用 Playground
你可能注意到在一些官方 Rust 文档中有一个"运行"按钮,点击后会在 Rust Playground 的新标签页中打开代码示例。要启用此功能,需要使用 #[doc]
属性中的 html_playground_url
。
#![doc(html_playground_url = "https://play.rust-lang.org/")]
//! ```
//! println!("Hello World");
//! ```