Effective Rust
35 Specific Ways to Improve Your Rust Code
编写高质量 Rust 代码的 35 个有效方法
原著:David Drysdale
翻译:lispking
译著
“代码更像是指南,而不是实际的规则。” —— Hector Barbossa
在编程语言的江湖中,Rust 如同一颗新星,以其独树一帜的安全特性和严谨的类型系统,赢得了开发者的青睐。
近日,一本备受瞩目的新书《Effective Rust》横空出世,它不仅为我们揭示了 Rust 编程的深层智慧,还提供了实用的实战技巧。
本文将从这本书的精彩引言出发,带你领略 Rust 的优势与挑战,并预告将持续翻译分享,为读者带来 Rust 编程的全面解析。
《Effective Rust》引言简述
全书由六个部分组成:
- 类型:围绕 Rust 核心类型系统的建议。
- 概念:构成 Rust 设计核心思想的建议。
- 依赖关系:关于使用 Rust 包生态系统的建议。
- 工具:关于如何通过超越 Rust 编译器来改进代码库的建议。
- 异步Rust:关于使用 Rust 异步机制的建议。
- 超越标准Rust:关于在超出 Rust 标准、安全环境时,如何工作的建议。
尽管“概念”部分可以说是比“类型”部分更基础,但它故意排在第二位,以便从开始到结束阅读的读者可以首先建立一些信心。
Rust 的优势:安全的护城河与强大的类型系统
- 安全性:Rust 在类型安全和内存安全方面的卓越表现,使其在编译阶段就能抵御大量潜在的错误。这与 C++ 等语言相比,无疑是 Rust 的一大亮点。
- 类型系统:Rust 的类型系统不仅强大,而且一致,这让 Rust 程序在编译时就能捕捉到更多的错误。一个能够顺利编译的 Rust 程序,往往已经具备了较高的稳定性。
Rust 的挑战:攀登学习高峰与编译器的较量
- 学习曲线:Rust 的生命周期、所有权和借用等概念,对于初学者来说,可能是一道难以逾越的门槛。然而,一旦掌握,这些概念将成为开发者手中的强大工具。
- 编译器斗争:尽管 Rust 编译器提供了丰富的错误诊断信息,但在与借用检查器的较量中,开发者仍需付出大量的时间和精力。这也是 Rust 编程的一大考验。
《Effective Rust》的启示:实战的蓝图与 Rust 生态的探索
- 实战蓝图:《Effective Rust》一书如同一位经验丰富的向导,提供了许多关于 Rust 编程的实战技巧,这些技巧将帮助开发者更好地应对编程中的各种挑战。
- Rust 生态探索:书中还介绍了 Rust 的包生态系统和工具链,这将有助于开发者更好地利用社区资源,提升开发效率。
结论
《Effective Rust》的问世,不仅为 Rust 爱好者带来了一场知识的盛宴,也为广大开发者提供了一把开启 Rust 编程之门的钥匙。
Rust 以其安全性和类型系统,在编程语言的世界中独领风骚。虽然面临学习曲线和编译器斗争等挑战,但 Rust 的优势使其成为值得每一位开发者投入时间和精力的语言。
通过持续翻译分享《Effective Rust》,我们将一起深入 Rust 的世界,掌握 Rust 编程的精髓,为未来的软件开发铺就一条坚实的道路。
引言
“代码更像是‘指导原则’,而不是实际规则。” —— 赫克托·巴博萨
在众多现代编程语言的拥挤景观中,Rust 与众不同。Rust 提供了编译语言的速度,非垃圾回收语言的高效,以及函数式语言的类型安全 —— 同时还提供了解决内存安全问题的独特方案。因此,Rust 经常被评为最受欢迎的编程语言。
Rust 的类型系统的强大和一致性意味着,如果一个 Rust 程序能够编译,那么它已经有一个不错的机会可以正常工作 —— 这种现象之前只在更学术、更不亲民的语言中观察到,如 Haskell。如果一个 Rust 程序能够编译,它也将安全地工作。
然而,这种安全 —— 包括类型安全和内存安全 —— 确实是有代价的。尽管基本文档的质量很高,Rust 还是因为入门坡度陡峭而闻名,新来者必须经历与借用检查器的斗争、重新设计数据结构以及被生命周期搞糊涂的入门仪式。一个能够编译的 Rust 程序可能第一次就有很大的机会正常工作,但是为了让它编译的斗争是真实的 —— 即使 Rust 编译器的错误诊断非常有帮助。
本书面向的读者
本书试图为在这些领域挣扎的程序员提供帮助,即使他们已经具有像 C++ 这样的现有编译语言的经验。因此,与其他 Effective
然而,Rust 的安全性导致这里的条目有一些不同的侧重点,特别是与 Scott Meyers 的原始 Effective C++ 系列相比。C++ 语言充满了陷阱,所以 Effective C++ 专注于避免这些陷阱的一组建议,这些建议基于在 C++ 中创建软件的实际经验。重要的是,它包含的是指导原则而不是规则,因为指导原则有例外 —— 提供指导原则形成及使用的理由,允许读者自行决定特定情况下是否值得违反规则。
在这里,提供建议及其原因的风格被保留了下来。但是,由于 Rust 几乎没有陷阱,这里的条目更多地集中在 Rust 引入的概念上。许多条目的标题像“理解…”和“熟悉自己了解…”,并帮助在编写流畅、地道的 Rust 代码的旅程中。
Rust 的安全性也导致完全没有标题为“永远不要…”的条目。如果你真的不应该做某事,编译器通常会阻止你这样做。
Rust 版本
文本是为2018版的 Rust 编写的,使用稳定工具链。Rust 的后向兼容性承诺意味着任何更高版本的 Rust,包括2021版,仍然会支持为2018版编写的代码,即使那个更高版本引入了破坏性更改。Rust 现在也足够稳定,以至于2018版和2021版之间的差异很小;书中没有任何代码需要更改才能符合2021版(但是第19条包括一个例外,其中较晚版本的 Rust 允许以前不可能的新行为)。
这里的条目没有涵盖 Rust 的任何异步功能方面,因为这涉及到更高级的概念和不那么稳定的工具链支持 —— 使用同步 Rust 已经有足够多的内容要介绍了。也许将来会出现一本《有效的异步Rust》…
用于代码片段和错误信息的具体 rustc
版本是 1.70
。代码片段不太可能需要针对更高版本进行更改,但错误消息可能会因你特定的编译器版本而有所不同。包含在文本中的错误消息也已经手动编辑,以适应书的宽度限制,但除此之外都是编译器生成的。
文本中有许多对其他静态类型语言的引用和比较,如 Java、Go 和 C++,以帮助有这些语言经验的读者定位自己。( C++ 可能是最接近等价的语言,特别是当 C++11 的移动语义发挥作用时。)
本书导读
本书的章节构成分为六章:
- 第一章 —— 类型:围绕 Rust 核心类型系统的建议
- 第二章 —— 特征:关于使用 Rust 特征的建议
- 第三章 —— 概念:构成 Rust 设计核心思想的观念
- 第四章 —— 依赖性:关于使用 Rust 包生态系统的建议
- 第五章 —— 工具:通过超越 Rust 编译器来改进代码库的建议
- 第六章 —— 超越标准 Rust:当您需要在 Rust 标准、安全环境之外工作时,给出的建议
虽然“概念”章节可能比“类型”和“特征”章节更为基础,但它故意放在书的后面,以便从头到尾阅读的读者可以首先建立一些信心。
本书中使用的约定
本书使用了以下排版约定:
- 斜体:表示新术语、URL、电子邮件地址、文件名和文件扩展名。
- 固定宽度:用于程序列表,以及在段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
以下标记用于标识以某种方式不正确的代码。
Ferris | 含义 |
---|---|
这段代码无法编译! | |
这段代码没有产生期望的行为。 |
致谢
我要感谢那些帮助使这本书成为可能的人们:
-
技术审阅者们对文本的所有方面提供了专业和详细的反馈:Pietro Albini, Jess Males, Mike Capp,尤其是Carol Nichols。
-
我在O'Reilly的编辑们:Jeff Bleiel, Brian Guerin, 和 Katie Tozer。
-
Tiziano Santoro,我从他那里最初学到了许多关于Rust的知识。
-
Danny Elfanbaum,他提供了处理书籍AsciiDoc格式化的重要技术支持。
-
原始网络版书籍的勤奋读者们,特别是:
- Julian Rosse,他在在线文本中发现了数十个拼写错误和其他错误。
- Martin Disch,他指出了多个条目中可能的改进和不准确之处。
- Chris Fleetwood, Sergey Kaunov, Clifford Matthews, Remo Senekowitsch, Kirill Zaborsky,以及一位匿名Proton Mail用户,他们指出了文本中的错误。
-
我的家人,他们忍受了许多我因写作而分心的周末。
原文点这里查看
类型
这本书的第一部分涵盖了关于 Rust 类型系统的建议。Rust 的类型系统比其他主流语言的表达能力更强;它与“学术性”语言如 OCaml 或 Haskell 有更多共同点。
其中核心的一部分是 Rust 的枚举类型(enum
),它比其他语言中的枚举类型具有更强的表达能力,并且允许使用代数数据类型。
本章的内容涵盖了 Rust 语言的基本类型,以及如何将它们组合成数据结构,来更精确地表达程序的语义。将行为编码到类型系统中的概念有助于减少检查和错误路径代码量。无效的状态会在编译时被编译工具链暴露出来,而不用等到程序运行时。
接下来还介绍了 Rust 标准库中提供的一些随处可见的数据类型: Option、Result 和 Iterator。熟练掌握这些标准工具可以帮助你写出简洁高效、符合Rust习惯的代码——尤其是问号操作符的使用,提供了非常简洁优雅且依然然类型安全的错误处理。
请注意,涉及 Rust 特性的内容将在下一章中介绍,但因为特性描述了类型的行为,因此与本章的内容有一定的重叠。
第 1 条:使用类型系统表达你的数据结构
“谁叫他们是程序员,而不是打字员” —— @thingskatedid
本章从编译器提供的基本类型开始,再到各种将数据组合成数据结构的方式,对 Rust 的类型系统做了一个快速导览。
在 Rust 中,枚举(enum
)类型扮演了重要的角色。虽然基础版本和其他语言相比没有什么区别,但枚举变量与数据字段相结合的能力,提供了更强的灵活性和表达能力。
基础类型
对于熟悉其他静态类型编程语言(如 C++、Go 或 Java)的人来说,Rust 类型系统的基本概念应该不太陌生。它包括一系列具有特定大小的整数类型,包括有符号(i8
,i16
,i32
,i64
, i128
)和无符号(u8
, u16
, u32
, u64
, u128
)类型。
还有两种整数类型:有符号(isize
)和无符号(usize
),其大小与目标系统上的指针大小一致。实际上,在 Rust 中,并不会在指针和整数之间进行大量的转换,所以大小是否相等并不重要。但是,标准集合是用 usize
(来自 .len()
方法)类型来返回它们的大小,所以在处理集合索引的时候, usize
非常常见 —— 从容量的角度来看,这显然是没问题的,因为内存中集合的项数不可能比系统内存的寻址范围更大。
整数类型让我们第一次意识到 Rust 是一个比 C++ 更严格的世界 —— 在 Rust 中,尝试将一个更大范围的整数类型(i32
)的值赋给较小范围的整数类型(i16
)会在编译时产生错误。
#![allow(unused)] fn main() { let x: i32 = 42; let y: i16 = x; }
error[E0308]: mismatched types
--> use-types/src/main.rs:14:22
|
14 | let y: i16 = x;
| --- ^ expected `i16`, found `i32`
| |
| expected due to this
|
help: you can convert an `i32` to an `i16` and panic if the converted value doesn't fit
|
14 | let y: i16 = x.try_into().unwrap();
| ++++++++++++++++++++
这让人感到安心:当程序员进行有风险的操作时,Rust 不会坐视不理,虽然本例中给出的数值没有超出目标类型 i16
能够表达的范围,转换不会有问题,但 Rust 编译器仍然会考虑到转换有不成功的可能性:
#![allow(unused)] fn main() { let x: i32 = 66_000; let y: i16 = x; // `y` 的值是什么? }
从错误提示中,我们也可以获得对 Rust 的初步认识: Rust 虽然拥有严格的规则,但编译失败时也会给出有用的提示信息,指引我们如何遵守这些规则。编译器还给出了解决方案,引导我们如何处理转换过程中可能出现的超出范围的情况。关于错误处理(第 4 条)和使用 panic!
(第 18 条)后续我们将会有更多的讨论。
Rust 也不允许一些可能看起来“安全”的操作,哪怕是从更小的整数类型向更大的转换:
#![allow(unused)] fn main() { let x = 42i32; // 带有类型后缀的整数字面量 let y: i64 = x; }
error[E0308]: mismatched types
--> use-types/src/main.rs:23:22
|
23 | let y: i64 = x;
| --- ^ expected `i64`, found `i32`
| |
| expected due to this
|
help: you can convert an `i32` to an `i64`
|
23 | let y: i64 = x.into();
| +++++++
在这里,转换并不会有引发错误的担忧,但转换仍然需要是显式的。我们将在后面章节更详细地讨论类型转换(第 5 条)。
现在继续探讨其他原始类型,Rust 有布尔类型(bool
)、浮点类型(f32
, f64
)和单元类型 ()
(类似于 C
的 void
)。
更有趣的是 char
字符类型,它持有一个 Unicode
值(类似于 Go 的 rune
类型)。尽管它在内部以 4 字节存储,但与 32 位整数仍然不支持静默转换。
类型系统的这种精确性迫使你明确地表达你想要表达的内容 —— u32
值与 char
不同,后者又与序列 UTF-8 字节不同,这又与任意字节序列不同,最终取决于你的准确意图 1。Joel Spolsky 的著名博客文章可以帮助理解具体需要哪种数据类型。
当然,有一些辅助方法允许在这不同的类型之间进行转换,但它们的签名迫使你处理(或明确忽略)失败的可能性。例如,一个 Unicode
代码点 2 总是可以用 32 位表示,所以 'a' as u32
是允许的,但反向转换就比较复杂了(因为 u32
值不一定是有效的 Unicode 代码点),例如:
- char::from_u32 返回一个
Option<char>
,迫使调用者处理失败的情况。 - char::from_u32_unchecked 假设有效性,但由于结果是未定义的,因此被标记为
unsafe
,迫使调用者也使用unsafe
(第 16 条)。
聚合类型
接下来讨论聚合类型,Rust 有:
- 数组(Arrays),它们持有单个类型的多个实例,实例的数量在编译时已知。例如
[u32; 4]
是四个连续的 4 字节整数。 - 元组(Tuples),它们持有多个异构类型的实例,元素的数量和类型在编译时已知,例如
(WidgetOffset, WidgetSize, WidgetColour)
。如果元组中的类型不够独特 —— 例如(i32, i32, &'static str, bool)
—— 最好给每个元素命名并使用,或者使用结构体。 - 结构体(Structs),它们也持有编译时已知的异构类型实例,但是允许通过名称来引用整个类型和各个字段。
Rust 也支持元组结构体(Tuple structs),它是结构体和元组的杂交体:整个类型有一个名称,但各个字段没有名称 —— 它们通过数字索引来使用:s.0
, s.1
。
#![allow(unused)] fn main() { /// 拥有 2 个未命名字段的结构体 struct TextMatch(usize, String); // 按顺序传入值来构构造实例 let m = TextMatch(12, "needle".to_owned()); // 使用字段下标索引访问其值 assert_eq!(m.0, 12); }
枚举(enum
)
让我们认识一下 Rust 类型系统中皇冠上的宝石:枚举(enum
)。
在其基本形式中,很难看出有什么值得兴奋的。与其他语言一样,枚举允许你指定一组互斥的值,这些值还可能附带一个数字或字符串值。
#![allow(unused)] fn main() { enum HttpResultCode { Ok = 200, NotFound = 404, Teapot = 418, } let code = HttpResultCode::NotFound; assert_eq!(code as i32, 404); }
因为每个枚举定义都创建了一个独特的类型,这可以用来提高那些接受诸如布尔参数的函数的可读性和可维护性。例如:
#![allow(unused)] fn main() { print_page(/* both_sides= */ true, /* colour= */ false); }
可以用 enum
替换:
#![allow(unused)] fn main() { pub enum Sides { Both, Single, } pub enum Output { BlackAndWhite, Colour, } pub fn print_page(sides: Sides, colour: Output) { // ... } }
在调用的时候是类型安全的,而且易于阅读:
#![allow(unused)] fn main() { print_page(Sides::Both, Output::BlackAndWhite); }
不同于使用布尔值的版本,如果使用该库的用户不小心颠倒了参数的顺序,编译器会立即报错:
#![allow(unused)] fn main() { error[E0308]: mismatched types --> use-types/src/main.rs:89:20 | 89 | print_page(Output::BlackAndWhite, Sides::Single); | ^^^^^^^^^^^^^^^^^^^^^ expected enum `enums::Sides`, found enum `enums::Output` error[E0308]: mismatched types --> use-types/src/main.rs:89:43 | 89 | print_page(Output::BlackAndWhite, Sides::Single); | ^^^^^^^^^^^^^ expected enum `enums::Output`, found enum `enums::Sides` }
使用新的类型模式(第 6 条)来包装一个 bool
也可以实现类型安全和可维护性;如果语义始终是布尔型的,通常最好使用这种方式,如果将来可能会出现新的选择(例如 Sides::BothAlternateOrientation
),则应使用枚举类型。
Rust 枚举的类型安全性也延续到 match
表达式中。下面这段代码无法编译:
#![allow(unused)] fn main() { let msg = match code { HttpResultCode::Ok => "Ok", HttpResultCode::NotFound => "Not found", // forgot to deal with the all-important "I'm a teapot" code }; }
error[E0004]: non-exhaustive patterns: `Teapot` not covered
--> use-types/src/main.rs:65:25
|
51 | / enum HttpResultCode {
52 | | Ok = 200,
53 | | NotFound = 404,
54 | | Teapot = 418,
| | ------ not covered
55 | | }
| |_____- `HttpResultCode` defined here
...
65 | let msg = match code {
| ^^^^ pattern `Teapot` not covered
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
= note: the matched value is of type `HttpResultCode`
编译器强制程序员处理枚举类型所有的可能性 3,即使只是添加一个默认分支 _ => {}
来处理其他情况。(现代 C++ 编译器也能够并且会对枚举缺失的 switch
分支发出警告。)
带有字段的枚举
Rust 枚举特性的真正强大之处在于每个变体都可以携带数据,使其成为一个代数数据类型(ADT)。这对于主流语言的程序员来说不太熟悉。在 C/C++ 的术语中,它类似于枚举与联合(union
)的组合,并且在 Rust 是类型安全的。
这意味着程序数据结构的不变性可以被编码到 Rust 的类型系统中;不符合不变性状态的代码甚至无法编译。一个设计良好的枚举使得创建者的意图对于人类以及编译器都是清晰的:
#![allow(unused)] fn main() { pub enum SchedulerState { Inert, Pending(HashSet<Job>), Running(HashMap<CpuId, Vec<Job>>), } }
仅从类型定义来看,可以合理猜测 Job
在 Pending
状态中排队,直到调度器完全激活,此时它们被分配到某个特定 CPU 的池中。
这突出了本方法的中心主题,即使用 Rust 的类型系统来表达与软件设计相关的概念。
如果代码中需要一些注释来解释什么样的值对这个字段是有效的:
#![allow(unused)] fn main() { struct DisplayProps { x: u32, y: u32, monochrome: bool, // `fg_colour` must be (0, 0, 0) if `monochrome` is true. fg_colour: RgbColour, } }
这种情况适合用带有数据的枚举来替换:
#![allow(unused)] fn main() { #[derive(Debug)] enum Colour { Monochrome, Foreground(RgbColour), } struct DisplayProperties { x: u32, y: u32, colour: Colour, } }
这个简单的例子说明了一个关键的建议:让你的类型无法表达无效状态。只支持有效值组合的类型意味着不符合期望的错误会被编译器拒绝,从而使得代码更小、更安全。
常用的枚举类型
鉴于枚举的强大功能,有两个概念非常常见,以至于 Rust 标准库内置了枚举类型来表达它们,这些类型在 Rust 代码中随处可见。
Option<T>
第一个是 Option
:要么存在特定类型的值(Some(T)
),要么不存在(None
)。始终为可能缺失的值使用 Option
,永远不要退回到使用哨兵值(-1
, nullptr
,……)来表达值的缺失。
然而,有一个微妙的点需要考虑。当您在处理一个包含多个事物的集合时,您需要考虑一个情况:集合中没有任何事物(即事物的数量为零)是否等同于没有这个集合。在大多数情况下,这两种情况并无不同,您可以继续使用 Vec<Thing>
:0 长度的集合表示元素的缺失。
然而,确实存在其他罕见的情况,需要用 Option<Vec<Thing>>
来区分这两种情况 —— 例如,加密系统可能需要区分“负载单独传输”和“空负载”。(这与 SQL 中 NULL
标记列的争论有关。)
同样的,一个常见的边缘情况是 String
可能缺失 —— 是用 ""
还是 None
来表示值的缺失更有意义?无论哪种方式都可以,但 Option<String>
清楚地传达了可能缺失该值的可能性。
Result<T, E>
第二个常见的概念源于错误处理:如果一个函数执行失败,应该如何报告这个失败?历史上,使用了特殊的哨兵值(例如,Linux 系统调用 的 -errno
返回值)或全局变量(POSIX 系统的 errno
)。最近,一些支持从函数返回多个值或元组值的语言(例如 Go),通常会采用返回一个 (result, error)
对的惯例。假设在出现错误时,结果存在某种合适的“零”值。
在 Rust 中,有一个枚举专门用于此目的:始终将可能失败的操作的 结果编码为 Result<T, E>
。T
保存成功的结果(在 Ok
变体中),E
在失败时保存错误详情(在 Err
变体中)。
使用标准类型使得设计意图更清晰,并且允许使用标准转换(第 3 条)和错误处理(第 4 条);它还使得使用 ?
运算符来简化错误处理成为可能。
注释
如果涉及到文件系统,情况会更加复杂,因为在流行各种平台上,文件名介于任意字节和 UTF-8 序列之间:请参阅 std::ffi::OsString 文档。
技术上,是一个 Unicode 标量值,而不是代码点。
这也意味着在库中为一个现有枚举添加一个新的变体是一个破坏性的更改(第 21 条):库的用户需要更改他们的代码以适应新的变体。如果一个枚举实际上只是一个旧式的值列表,可以通过将其标记为 non_exhaustive
枚举来避免这种行为;请参阅 第 21 条。
原文点这里查看
第 2 条:使用类型系统表达常见行为
第1条讨论了如何在类型系统中表达数据结构;本节继续讨论在 Rust 的类型系统中行为的编码。
本条目所描述的机制通常会让人感觉熟悉,因为它们在其他语言中都有直接的类似物:
- 函数:将一段代码与一个名称和参数列表相关联的通用机制。
- 方法:与特定数据结构的实例相关联的函数。在面向对象成为一种编程范式之后创建的编程语言中,方法很常见。
- 函数指针:C 语言家族中的大多数语言都支持,包括 C++ 和 Go,作为一种在调用其他代码时允许额外间接层次的机制。
- 闭包:最初在 Lisp 语言家族中最为常见,但已经被移植到许多流行的编程语言中,包括 C++(从 C++11 开始)和 Java(从 Java 8 开始)。
- 特征(Traits):描述适用于同一基础项的相关功能的集合。在许多其他语言中都有大致等效的概念,包括 C++ 中的抽象类以及 Go 和 Java 中的接口。
当然,所有这些机制都有Rust特定的细节,本条将会一一介绍。 在前面列出的内容中,特征(traits)对于本书来说意义最为重大,因为它们描述了 Rust 编译器和标准库提供的很多行为。第2章 重点讨论了关于设计和实现特征的相关内容,但它们的普遍性意味着它们在本章的其他条目中也会频繁出现。
函数和方法( Function and Methods )
跟多数其他编程语言一样,Rust 使用函数将代码组织到一个带名称的代码块,方便重用。代码块的输入用参数的形式表达。跟其他静态类型语言相同,参数和返回值的类型必须明确地被定义:
#![allow(unused)] fn main() { /// Return `x` divided by `y`. fn div(x: f64, y: f64) -> f64 { if y == 0.0 { // Terminate the function and return a value. return f64::NAN; } // The last expression in the function body is implicitly returned. x / y } /// Function called just for its side effects, with no return value. /// Can also write the return value as `-> ()`. fn show(x: f64) { println!("x = {x}"); } }
如果一个函数与特定的数据结构密切相关,它就表现为一个方法。方法通过 self 标识,对该类型实例进行操作,并包含在 impl DataStructure 块中。这以类似于其他语言的面向对象方式将相关数据和代码封装在一起;然而,在 Rust 中,方法不仅可以添加到结构体
类型上,也可以添加到枚举
类型上,这与 Rust 枚举的普遍性质相符(第1条)。
#![allow(unused)] fn main() { enum Shape { Rectangle { width: f64, height: f64 }, Circle { radius: f64 }, } impl Shape { pub fn area(&self) -> f64 { match self { Shape::Rectangle { width, height } => width * height, Shape::Circle { radius } => std::f64::consts::PI * radius * radius, } } } }
方法的名称为其编码的行为提供了一个标签,而方法签名提供了其输入和输出的类型信息。方法的第一个输入是 self
的某种变体,指示该方法可能对数据结构执行的操作:
&self
参数表示可以从数据结构中读取内容,但不会修改它。&mut self
参数表示该方法可能会修改数据结构的内容。self
参数表示该方法会消耗数据结构。
函数指针
前面的章节描述了如何将一个名称(或者一个参数列表)与某些代码块相关联。实际上,调用方法总是会导致相同的代码被执行;从一次调用到下一次调用,所有的改变就只是该方法操作的数据,这涵盖了许多可能的场景,但是,如果在运行时需要代码发生变化呢?
能实现这个功能的最简单的行为抽象就是函数指针:一个仅指向某些代码的指针,其类型反映了函数的签名。
#![allow(unused)] fn main() { fn sum(x: i32, y: i32) -> i32 { x + y } // Explicit coercion to `fn` type is required... let op: fn(i32, i32) -> i32 = sum; }
类型在编译时进行检查,所以到程序运行时,这个值只是指针的大小。函数指针没有与之关联的其他数据,因此,可以以各种方式将它们视为值:
#![allow(unused)] fn main() { // `fn` types implement `Copy` let op1 = op; let op2 = op; // `fn` types implement `Eq` assert!(op1 == op2); // `fn` implements `std::fmt::Pointer`, used by the {:p} format specifier. println!("op = {:p}", op); // Example output: "op = 0x101e9aeb0" }
一个需要注意的技术细节:需要显式地将函数强制转换为 fn
类型,因为仅仅使用函数的名称并不能得到 fn
类型的值;
这段代码无法编译!
#![allow(unused)] fn main() { let op1 = sum; let op2 = sum; // Both op1 and op2 are of a type that cannot be named in user code, // and this internal type does not implement `Eq`. assert!(op1 == op2); }
#![allow(unused)] fn main() { error[E0369]: binary operation `==` cannot be applied to type `fn(i32, i32) -> i32 {main::sum}` --> use-types-behaviour/src/main.rs:117:21 | 117 | assert!(op1 == op2); | --- ^^ --- fn(i32, i32) -> i32 {main::sum} | | | fn(i32, i32) -> i32 {main::sum} | help: you might have forgotten to call this function | 117 | assert!(op1( /* arguments */ ) == op2); | +++++++++++++++++++ help: you might have forgotten to call this function | 117 | assert!(op1 == op2( /* arguments */ )); | +++++++++++++++++++ }
相反,编译器错误表明该类型类似于 fn(i32, i32) -> i32 {main::sum}
,一种完全内部于编译器的类型(即不能在用户代码中编写),它同时标识了特定的函数及其签名。
换句话说,sum
的类型既编码了函数的签名又编码了其位置(出于优化原因);这种类型可以自动强制转换为 fn
类型(第6条)。
闭包
裸函数指针的使用是有限的,因为被调用函数唯一可以使用的输入是那些明确作为参数值传递的内容。
例如,考虑一些使用函数指针修改切片中每个元素的代码。
#![allow(unused)] fn main() { // In real code, an `Iterator` method would be more appropriate. pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) { for value in data { *value = mutator(*value); } } }
这对于对切片进行简单的修改是有效的:
#![allow(unused)] fn main() { fn add2(v: u32) -> u32 { v + 2 } let mut data = vec![1, 2, 3]; modify_all(&mut data, add2); assert_eq!(data, vec![3, 4, 5,]); }
然而,如果修改依赖于任何额外的状态,那么无法隐式地将这些状态传递给函数指针。
这段代码无法编译!
#![allow(unused)] fn main() { let amount_to_add = 3; fn add_n(v: u32) -> u32 { v + amount_to_add } let mut data = vec![1, 2, 3]; modify_all(&mut data, add_n); assert_eq!(data, vec![3, 4, 5,]); }
#![allow(unused)] fn main() { error[E0434]: can't capture dynamic environment in a fn item --> use-types-behaviour/src/main.rs:142:17 | 142 | v + amount_to_add | ^^^^^^^^^^^^^ | = help: use the `|| { ... }` closure form instead }
错误信息指向了正确的工具:闭包。闭包是一段看起来像函数定义体(lambda
表达式)的代码,不同之处在于:
- 它可以作为表达式的一部分构建,因此,不需要与一个名称相关联
- 输入参数以竖线
|param1, param2|
给出(它们的关联类型通常可以由编译器自动推导) - 它可以捕获其周围环境的一部分。
#![allow(unused)] fn main() { let amount_to_add = 3; let add_n = |y| { // a closure capturing `amount_to_add` y + amount_to_add }; let z = add_n(5); assert_eq!(z, 8); }
为了(大致)理解捕获是如何工作的,可以想象编译器创建了一个一次性的、内部的类型,它包含了 lambda
表达式中提到的环境所有部分。当闭包被创建时,这个临时类型的一个实例被创建来保存相关的值,当闭包被调用时,这个实例被用作额外的上下文使用。
#![allow(unused)] fn main() { let amount_to_add = 3; // *Rough* equivalent to a capturing closure. struct InternalContext<'a> { // references to captured variables amount_to_add: &'a u32, } impl<'a> InternalContext<'a> { fn internal_op(&self, y: u32) -> u32 { // body of the lambda expression y + *self.amount_to_add } } let add_n = InternalContext { amount_to_add: &amount_to_add, }; let z = add_n.internal_op(5); assert_eq!(z, 8); }
在这个概念性的上下文中持有的值通常是引用(第9条),就像这里的例子,但它们也可以是环境中事物的可变引用,或者是通过在输入参数前使用 move
关键字而从环境中完全移出的值。
回到 modify_all
的例子,闭包不能用在期望函数指针的地方。
#![allow(unused)] fn main() { error[E0308]: mismatched types --> use-types-behaviour/src/main.rs:165:31 | 165 | modify_all(&mut data, |y| y + amount_to_add); | ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found closure | = note: expected fn pointer `fn(u32) -> u32` found closure `[closure@use-types-behaviour/src/main.rs:165:31: 165:52]` note: closures can only be coerced to `fn` types if they do not capture any variables --> use-types-behaviour/src/main.rs:165:39 | 165 | modify_all(&mut data, |y| y + amount_to_add); | ^^^^^^^^^^^^^ `amount_to_add` captured here }
相反,接收闭包的代码必须接受一个实现了 Fn*
特征的实例。
#![allow(unused)] fn main() { pub fn modify_all<F>(data: &mut [u32], mut mutator: F) where F: FnMut(u32) -> u32, { for value in data { *value = mutator(*value); } } }
Rust 有三种不同的 Fn*
特征,它们之间表达了关于环境捕获行为的一些区别。
FnOnce
描述了一个只能被调用一次的闭包。如果环境的某些部分被移动到闭包的上下文中,并且闭包的主体随后将其从闭包的上下文中移出,那么这种移动只能发生一次 —— 因为源项没有其他副本可以移动 —— 因此,闭包只能被调用一次。FnMut
描述了一个可以被多次调用的闭包,它能够改变其环境,因为它会可变地借用环境。Fn
描述了一个可以被多次调用的闭包,它只从环境中不可变地借用值。
编译器会为代码中的任何 lambda
表达式自动实现这些 Fn*
特征的适当子集;不可能手动实现这些特征中的任何一个1(与 C++
的 operator()
重载不同)。
回到上面关于闭包的粗略心理模型,编译器自动实现的特征大致对应于捕获的环境上下文是否具有:
FnOnce
: 任何被移动的值FnMut
: 任何对值的可变引用(&mut T
)Fn
: 只是对值的普通引用(&T
)。
上面列表中的后两个特征各自具有前一个特征的特征约束,当你考虑使用闭包时,这是有意义的。
- 如果某事物只期望调用一次闭包(通过接收
FnOnce
表示),那么传递给它一个能够被多次调用的闭包(FnMut
)是可以的。 - 如果某事物期望重复调用一个可能改变其环境的闭包(通过接收
FnMut
表示),那么传递给它一个不需要改变其环境的闭包(Fn
)是可以的。
裸函数指针类型 fn
也名义上属于这个列表的末尾;任何(非不安全的)fn
类型自动实现所有 Fn*
特征,因为它不借用任何环境。
因此,在编写接受闭包的代码时,使用最通用的 Fn*
特征,以允许调用者最大的灵活性 —— 例如,对于只使用一次的闭包,接受 FnOnce
。同样的道理:建议优先使用 Fn*
特征约束而不是裸函数指针(fn
)。
特征(Traits
)
Fn*
特征比裸函数指针更灵活,但它们仍然只能描述单个函数的行为,并且只能在函数签名的基础上描述。
然而,它们本身就是 Rust 类型系统中描述行为的另一种机制的例子,即特征。特征定义了一组相关的方法,这些方法由一些底层项公开提供。此外,这些函数通常是(但不一定是)方法,将 self 的某个变体作为它们的第一个参数。
特征中的每个方法也有一个名称,这允许编译器区分具有相同签名的方法,更重要的是,它允许程序员推断方法的目的。
Rust 的特征大致类似于 Go 和 Java 中的“接口”,或者 C++ 中的“抽象类”(所有虚拟方法,没有数据成员)。特征的实现必须提供所有方法(但请注意特征定义可以包括默认的实现,第13条),并且还可以有相关联的数据,那些实现会使用这些数据。这意味着代码和数据在共同的抽象中以某种面向对象的方式一起封装。
接受结构体并调用其方法的代码被限制只能与特定类型一起工作。如果有多个类型实现了公共行为,那么定义一个特征来封装这种行为,并让代码使用特征的方法而不是特定结构体的方法会更加灵活。
这导致了与其他受面向对象2影响的语言相同的建议:如果预期未来需要灵活性,请优先接受特征类型而不是具体类型。
有时,你希望在某些行为中使用类型系统来区分,但这些行为无法表达为特征定义中的特定方法签名。例如,考虑一个用于排序集合的特征;一个实现可能是稳定的(比较相同的元素在排序前后的顺序不变),但没有办法在排序方法参数中表达这一点。
在这种情况下,使用标记特征(marker trait
)在类型系统中跟踪这个要求仍然是值得的。
#![allow(unused)] fn main() { pub trait Sort { /// Re-arrange contents into sorted order. fn sort(&mut self); } /// Marker trait to indicate that a [`Sortable`] sorts stably. pub trait StableSort: Sort {} }
标记特征(marker trait
)没有方法,但实现仍然需要声明它正在实现该特征 —— 这被视为实现者的承诺:“我庄严宣誓,我的实现有稳定排序的能力。”依赖于稳定排序的代码可以指定 StableSort
特征约束,依赖诚信制度来保持其不变性。使用标记特征来区分无法在特征方法签名中表达的行为。
一旦行为被封装到 Rust 的类型系统中作为一个特征,它可以以两种方式被使用:
- 作为特征约束(
trait bound
),它在编译时限制了哪些类型可以接受一个泛型数据类型或方法,或者 - 作为特征对象(
trait object
),它在运行时限制了哪些类型可以存储或传递给一个方法。 第12条 更详细地讨论了这两种方式的权衡。
特征约束
特征约束表明,当某个类型 T
实现了某个特定特征时,参数化为该类型 T
的泛型代码才能被使用。特征约束的存在意味着泛型的实现可以使用来自该特征的方法,确信编译器将确保任何可以编译的 T
确实具有那些方法。这种检查发生在编译时,当泛型被单态化 ———— 即从处理任意 T 类型的泛型代码转换为处理特定某类型的代码(Rust 对 C++ 中所谓的“模板实例化”的术语)。
对目标类型 T
的这种限制是明确的,编码在特征约束中:只有满足特征约束的类型才能实现该特征。这与 C++ 中的等价情况形成对比,在 C++ 中,template<typename T>
中使用的类型 T
的约束是隐式的3:C++ 模板代码仍然只有在所有引用的方法在编译时都可用时才会编译,但检查纯粹基于方法和签名。(这种“鸭子类型”可能导致混淆;一个使用 t.pop()
的 C++ 模板可能为 Stack
或 Balloon
的 T
类型参数编译 —— 这不太可能是期望的行为。)
对显式特征约束的需求也意味着大部分泛型使用特征约束。要了解为什么会这样,反过来考虑一下在没有 T
的特征约束的情况下 struct Thing<T>
可以做什么。没有特征约束,Thing
只能执行适用于任何类型 T
的操作;这允许容器
、集合
和智能指针
,但除此之外并不多。任何使用类型 T
的东西都需要一个特征约束。
#![allow(unused)] fn main() { pub fn dump_sorted<T>(mut collection: T) where T: Sort + IntoIterator, T::Item: Debug, { // Next line requires `T: Sort` trait bound. collection.sort(); // Next line requires `T: IntoIterator` trait bound. for item in collection { // Next line requires `T::Item : Debug` trait bound println!("{:?}", item); } } }
因此,这里的建议是使用特征约束来表达对泛型中使用的类型的要求,这很容易遵循 —— 编译器将迫使你遵守它。
特征对象
特征对象是利用特征定义的封装的另一种方式,但在这里,不同的特征实现是在运行时而不是编译时选择的。这种动态分派类似于 C++ 中虚拟函数的使用,在底层,Rust 有 'vtable
' 对象,它们与 C++ 中的类似。
特征对象的这种动态方面也意味着它们必须始终通过间接方式处理,通过引用(&dyn Trait
)或指针(Box<dyn Trait>
)。这是因为实现特征的对象大小在编译时是未知的 —— 它可能是一个巨大的结构体或一个微小的枚举 —— 因此无法为裸特征对象分配正确数量的空间。
不知道具体对象的大小也意味着用作特征对象的特征不能有返回 Self 类型的方法或使用 Self 的参数(除了接收者 —— 正在调用方法的对象)。原因是使用特征对象的预先编译的代码将不知道 Self 的大小。
具有泛型方法 fn some_fn
#![allow(unused)] fn main() { trait Foo { fn method<T>(&self, t: T); } struct Bar; impl Bar { fn new() -> Self { Self {} } } impl Foo for Bar { fn method<T>(&self, t: T) { println!("Bar impl trait Foo!"); } } #[cfg(test)] mod tests { use super::*; use std::collections::hash_map::VacantEntry; #[test] fn as_trait_bound() { let bar = Bar::new(); bar.method(0u8); } #[test] fn as_trait_obj() { let bar = Bar::new(); let mut v: Vec<&dyn Foo> = vec![]; v.push(&bar); } } }
fn as_trait_bound() 测试可以通过,没有错误。但是as_trait_obj()会报错:
#![allow(unused)] fn main() { error[E0038]: the trait `Foo` cannot be made into an object --> src/lib.rs:33:20 | 33 | let mut v: Vec<&dyn Foo> = vec![]; | ^^^^^^^^^^^^^ `Foo` cannot be made into an object | note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety> --> src/lib.rs:2:8 | 1 | trait Foo { | --- this trait cannot be made into an object... 2 | fn method<T>(&self, t: T); | ^^^^^^ ...because method `method` has generic type parameters = help: consider moving `method` to another trait = help: only type `Bar` implements the trait, consider using it directly instead }
这两个限制 —— 不能返回 Self
和不能有泛型方法 —— 结合成了对象安全的概念。只有对象安全的特征才能用作特征对象。
注释
至少,在撰写本文时的稳定 Rust 中是这样。实验性功能 unboxed_closures
和 fn_traits
可能在未来改变这一点。
例如,Effective Java 第64条:通过它们的接口引用对象
C++20 中添加的概念允许对模板类型上的约束进行显式指定,但检查仍然只在模板实例化时执行,而不是在声明时执行。
原文点这里查看
第 3 条:优先选择Option和Result转换,而非显式匹配表达式
第1条 阐述了枚举(enum
)的优点,并展示了 match
表达式如何强制程序员考虑所有可能性;这个方法探讨了在某些情况下,你应尽量避免使用 match
表达式 —— 至少是显式地。
第1条 还介绍了 Rust 标准库提供的两个无处不在的枚举:
Option<T>
,表示一个值(类型为T
)可能存在也可能不存在。Result<T, E>
,用于当尝试返回一个值(类型为T
)的操作可能失败,并可能返回一个错误(类型为E
)。
对于这些特定的枚举,显式使用 match
通常会导致代码比实际需要的不够紧凑,而且不符合 Rust 的习惯用法。
第一种不需要使用 match
的情况是,当只关心值本身,而值的缺失(以及任何相关的错误)可以被忽略时。
#![allow(unused)] fn main() { struct S { field: Option<i32>, } let s = S { field: Some(42) }; match &s.field { Some(i) => println!("field is {}", i), None => {} } }
对于这种情况,使用 if let
表达式可以缩短一行代码,而且更重要的是,它的表达更清晰:
#![allow(unused)] fn main() { if let Some(i) = &s.field { println!("field is {}", i); } }
然而,大多数时候,程序员通常的处理方式是提供相应的else分支:缺少值( Option::None
),或者返回一个可能会出现相关错误( Result::Err(e)
)。设计能够应对失败路径的软件是困难的,而且其中大部分是本质的复杂性,无论多少语法支持都无法帮助——具体来说,就是决定如果一个操作失败了应该发生什么。
在某些情况下,正确的决定是采取“鸵鸟策略”——把我们的头埋进沙子里,明确地不去处理失败。你不能完全忽略错误分支,因为Rust要求代码必须处理Error
枚举的两种变体,但你可以选择将失败视为致命的错误。在失败时执行一个panic!
意味着程序会终止,但可以在成功的假设下来编写其余的代码。通过显式match
来执行此操作会不必要地冗长:
#![allow(unused)] fn main() { let result = std::fs::File::open("/etc/passwd"); let f = match result { Ok(f) => f, Err(_e) => panic!("Failed to open /etc/passwd!"), }; }
当值不存在时,Option
和Result
都提供了一对方法来提取它们的内部值和panic!
,它们分别是 unwrap和expect 。后者允许个性化失败时的错误消息,但无论哪种情况,将错误处理被委托给.unwrap()
后缀(但仍然存在)生成的代码都更短、更简单:
#![allow(unused)] fn main() { let f = std::fs::File::open("/etc/passwd").unwrap(); }
但要明确的是:这些辅助函数仍然会引发 panic!
,所以选择使用它们与选择直接 panic!
(第18条)是一样的。
然而,在许多情况下,正确的错误处理是将决策推迟给其他人。这在编写库时尤其如此,因为库的代码可能会在库作者无法预见的各种不同环境中使用。为了使库更易用,优先使用 Result
而不是 Option
来表示错误,即使这可能涉及不同错误类型之间的转换(第4条)。
当然,这提出了一个问题:什么算作错误?在此示例中,无法打开文件肯定是一个错误,并且该错误的详细信息(没有此类文件?权限被拒绝?)可以帮助用户决定下一步要做什么。另一方面,由于切片为空而未能检索切片的first()元素并不是真正的错误,因此它在标准库中表示为Option返回类型。 在两种可能性之间进行选择需要判断,但如果错误可能传达任何有用的信息,则倾向于Result 。
Result
也有一个 [#must_use]
属性,用来引导库用户朝着正确的方向前进 —— 如果使用返回的 Result
的代码忽略了它,编译器将生成一个警告:
#![allow(unused)] fn main() { warning: unused `Result` that must be used --> transform/src/main.rs:32:5 | 32 | f.set_len(0); // Truncate the file | ^^^^^^^^^^^^^ | = note: `#[warn(unused_must_use)]` on by default = note: this `Result` may be an `Err` variant, which should be handled }
显式使用 match
可以让错误传播,但代价是增加了一些可见的样板代码(让人联想到 Go 语言
):
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, std::io::Error> { let f = match std::fs::File::open("/etc/passwd") { Ok(f) => f, Err(e) => return Err(e), }; // ... } }
减少样板代码的关键是 Rust 的问号运算符 ?
。这个语法糖可以处理匹配 Err
分支和返回 Err(...)
表达式,只用一个字符就完成了:
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, std::io::Error> { let f = std::fs::File::open("/etc/passwd")?; // ... } }
Rust 新手有时会对此感到困惑:问号运算符在一开始很难被注意到,导致人们怀疑这段代码怎么可能正常工作。然而,即使只有一个字符,类型系统仍然在起作用,确保覆盖了相关类型(第1条)表达的所有可能性——让程序员可以专注于主线代码路径,不受干扰。
更重要的是,这些明显的方法调用通常没有额外的成本:它们都是标记为 #[inline]
的泛型函数,所以生成的代码通常会编译成与手动版本相同的机器代码。
这两个因素结合起来意味着你应该优先使用 Option
和 Result
转换,而不是显式的 match
表达式。
在之前的例子中,错误类型是一致的:内部和外部方法都使用 std::io::Error
表达错误。然而,情况往往并非如此;一个函数可能从各种不同的子库中累积错误,每个子库都使用不同的错误类型。
关于错误映射的讨论一般见第4条;现在,只需知道一个手动映射:
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, String> { let f = match std::fs::File::open("/etc/passwd") { Ok(f) => f, Err(e) => { return Err(format!("Failed to open password file: {:?}", e)) } }; // ... } }
可以使用更简洁、更符合 Rust 语法的 .map_err()
转换来表达:
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, String> { let f = std::fs::File::open("/etc/passwd") .map_err(|e| format!("Failed to open password file: {:?}", e))?; // ... } }
更好的是,甚至这可能也不必要 —— 如果外部错误类型可以通过实现标准特征 From
(第5条)从内部错误类型创建,那么编译器将自动执行转换,无需调用 .map_err()
。
这类转换具有更广泛的通用性。问号运算符是一个强大的工具;使用 Option
和 Result
类型上的转换方法将它们调整到可以顺利处理的形态。
标准库提供了各种各样的转换方法来实现这一点,如下面的地图所示。根据第18条,可能引发 panic!
的方法用红色突出显示。
(此图的在线版本可点击:每个框都会链接到相关文档。)
图中未涵盖的一种常见情况是处理引用。例如,考虑一个可能包含一些数据的结构。
#![allow(unused)] fn main() { struct InputData { payload: Option<Vec<u8>>, } }
这个结构上的一个方法尝试将有效载荷传递给一个加密函数,该函数的签名是 (&[u8]) -> Vec<u8>
,如果简单地尝试获取一个引用,则会失败:
#![allow(unused)] fn main() { impl InputData { pub fn encrypted(&self) -> Vec<u8> { encrypt(&self.payload.unwrap_or(vec![])) } } }
#![allow(unused)] fn main() { error[E0507]: cannot move out of `self.payload` which is behind a shared reference --> transform/src/main.rs:62:22 | 62 | encrypt(&self.payload.unwrap_or(vec![])) | ^^^^^^^^^^^^ move occurs because `self.payload` has type `Option<Vec<u8>>`, which does not implement the `Copy` trait | help: consider borrowing the `Option`'s content | 62 | encrypt(&self.payload.as_ref().unwrap_or(vec![])) | +++++++++ }
错误消息准确地描述了使代码工作所需的内容,即 Option
上的 as_ref()
方法1。这个方法将一个对 Option
的引用转换为对引用的 Option
:
#![allow(unused)] fn main() { pub fn encrypted(&self) -> Vec<u8> { encrypt(self.payload.as_ref().unwrap_or(&vec![])) } }
总结一下:
- 习惯使用
Option
和Result
的转换,并且优先使用Result
而不是Option
。 - 在转换涉及引用时,根据需要使用
.as_ref()
。 - 在可能的情况下,优先使用它们而不是显式的
match
操作。 - 特别是,使用它们将结果类型转换成可以使用
?
运算符的形式。
注释
注意,这个方法与 AsRef
特征是分开的,尽管方法名称相同。
原文点这里查看
第 4 条:优先使用惯用的错误类型
第 3 条描述了如何使用标准库为 Option
和 Result
类型提供的转换,以允许使用 ?
运算符简洁、惯用地处理结果类型。但它没有讨论如何最好地处理作为 Result<T, E>
第二个类型参数出现的各种不同的错误类型 E
;这就是本章节的内容。
只有当有多种不同的错误类型时,这才有相关性。如果函数遇到的所有不同错误已经是同一类型,它可以只返回该类型。当有不同类型的错误时,需要做出一个决定,即是否保留子错误类型信息。
错误特征(Error Trait)
了解标准特征(第 10 条)总是一个好主意,这里相关的特征是 std::error::Error
。Result
的 E
类型参数不必是实现 Error
的类型,但这是一个常见的约定,它允许包装器表达适当的特征约束 —— 因此,最好为您的错误类型实现 Error
。
首先要注意的是,对于错误类型,唯一硬性要求是特征约束:实现 Error
的任何类型也必须实现以下特征:
-
Display
特征,意味着可以使用{}
进行格式化 -
Debug
特征,意味着可以使用{:?}
进行格式化
换句话说,应该能够将错误类型显示给用户和程序员。
特征中唯一的方法是 source()
,1 它允许错误类型公开一个内部的、嵌套的错误。此方法是可选的 —— 它带有一个返回 None
的默认实现(第 13 条),表示内部错误信息不可用。
最后要注意的一点是:如果您正在为 no_std
环境(第 33 条)编写代码,可能无法实现 Error
—— Error
特征目前在 std
中实现,而不是 core
,因此不可用。2
最小错误(Minimal Errors)
如果不需要嵌套错误信息,那么错误类型的实现不必比 String
复杂多少 —— 这是一个“字符串类型”的变量可能合适的罕见情况。但它需要比 String
多一点;虽然可以使用 String
作为 E
类型参数:
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, String> { let f = std::fs::File::open("/etc/passwd") .map_err(|e| format!("Failed to open password file: {:?}", e))?; // ... } }
一个 String
并不实现 Error
,我们希望是这样,以便代码的其他部分可以处理 Errors
。为 String
实现 Error
是不可能的,因为特征(trait
)和类型都不属于我们(所谓的孤儿规则):
#![allow(unused)] fn main() { impl std::error::Error for String {} }
#![allow(unused)] fn main() { error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:18:5 | 18 | impl std::error::Error for String {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^------ | | | | | `String` is not defined in the current crate | impl doesn't use only types from inside the current crate | = note: define and implement a trait or new type instead }
类型别名也无济于事,因为它并没有创建一个新的类型,所以也不会改变错误信息:
#![allow(unused)] fn main() { pub type MyError = String; impl std::error::Error for MyError {} }
#![allow(unused)] fn main() { error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:41:5 | 41 | impl std::error::Error for MyError {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^------- | | | | | `String` is not defined in the current crate | impl doesn't use only types from inside the current crate | = note: define and implement a trait or new type instead }
像往常一样,编译器错误消息为解决问题提供了一个线索。定义一个包装 String
类型的元组结构体("新类型模式",第 6 条)允许实现 Error
特征,前提是也实现了 Debug
和 Display
:
#![allow(unused)] fn main() { #[derive(Debug)] pub struct MyError(String); impl std::fmt::Display for MyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl std::error::Error for MyError {} pub fn find_user(username: &str) -> Result<UserId, MyError> { let f = std::fs::File::open("/etc/passwd").map_err(|e| { MyError(format!("Failed to open password file: {:?}", e)) })?; // ... } }
为了方便起见,实现 From<String>
特征可能是有意义的,以便可以轻松地将字符串值转换为 MyError
实例(第 5 条):
#![allow(unused)] fn main() { impl From<String> for MyError { fn from(msg: String) -> Self { Self(msg) } } }
当编译器遇到问号运算符(?
)时,它会自动应用任何需要的 From
特征实现,以便达到目标错误返回类型。这允许进一步的最小化:
#![allow(unused)] fn main() { pub fn find_user(username: &str) -> Result<UserId, MyError> { let f = std::fs::File::open("/etc/passwd") .map_err(|e| format!("Failed to open password file: {:?}", e))?; // ... } }
这里的错误路径涵盖了以下步骤:
File::open
返回一个类型为std::io::Error
的错误。format!
使用std::io::Error
的Debug
实现将其转换为String
。?
使编译器寻找并使用一个From
实现,该实现可以将它从String
转换为MyError
。
嵌套错误
另一种情况是,嵌套错误的内容重要到足以需要被保留并供调用者使用。
考虑一个库函数,它尝试返回文件的第一行作为字符串,只要这一行不是太长。稍微思考一下就会发现(至少)三种可能发生的不同类型的失败:
- 文件可能不存在或者无法读取。
- 文件可能包含不是有效
UTF-8
的数据,因此无法转换为String
。 - 文件可能有一个过长的一行。
根据 第 1 条,您可以使用类型系统来表达并包含所有这些可能性作为一个枚举
:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum MyError { Io(std::io::Error), Utf8(std::string::FromUtf8Error), General(String), } }
这个枚举
定义包括了 derive(Debug)
,但为了满足 Error
特征,还需要一个 Display
的实现:
#![allow(unused)] fn main() { impl std::fmt::Display for MyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MyError::Io(e) => write!(f, "IO error: {}", e), MyError::Utf8(e) => write!(f, "UTF-8 error: {}", e), MyError::General(s) => write!(f, "General error: {}", s), } } } }
为了方便访问嵌套错误,覆盖默认的 source()
实现也是很有意义的:
#![allow(unused)] fn main() { use std::error::Error; impl Error for MyError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { MyError::Io(e) => Some(e), MyError::Utf8(e) => Some(e), MyError::General(_) => None, } } } }
使用枚举
允许错误处理保持简洁,同时仍然保留不同错误类别的所有类型信息:
#![allow(unused)] fn main() { use std::io::BufRead; // for `.read_until()` /// Maximum supported line length. const MAX_LEN: usize = 1024; /// Return the first line of the given file. pub fn first_line(filename: &str) -> Result<String, MyError> { let file = std::fs::File::open(filename).map_err(MyError::Io)?; let mut reader = std::io::BufReader::new(file); // (A real implementation could just use `reader.read_line()`) let mut buf = vec![]; let len = reader.read_until(b'\n', &mut buf).map_err(MyError::Io)?; let result = String::from_utf8(buf).map_err(MyError::Utf8)?; if result.len() > MAX_LEN { return Err(MyError::General(format!("Line too long: {}", len))); } Ok(result) } }
为所有子错误类型实现 From
特征也是一个好主意(第 5 条):
#![allow(unused)] fn main() { impl From<std::io::Error> for MyError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } impl From<std::string::FromUtf8Error> for MyError { fn from(e: std::string::FromUtf8Error) -> Self { Self::Utf8(e) } } }
这防止了库用户自己受到孤儿规则的影响:他们不允许在 MyError
上实现 From
,因为特征和结构体对他们来说是外部的。
更好的是,实现 From
允许更加简洁,因为问号运算符将自动执行任何必要的 From
转换,从而消除了 .map_err()
的需求:
#![allow(unused)] fn main() { use std::io::BufRead; // for `.read_until()` /// Maximum supported line length. pub const MAX_LEN: usize = 1024; /// Return the first line of the given file. pub fn first_line(filename: &str) -> Result<String, MyError> { let file = std::fs::File::open(filename)?; // `From<std::io::Error>` let mut reader = std::io::BufReader::new(file); let mut buf = vec![]; let len = reader.read_until(b'\n', &mut buf)?; // `From<std::io::Error>` let result = String::from_utf8(buf)?; // `From<string::FromUtf8Error>` if result.len() > MAX_LEN { return Err(MyError::General(format!("Line too long: {}", len))); } Ok(result) } }
编写一个完整的错误类型可能涉及相当多的样板代码,这使得它成为通过派生宏(第 28 条)自动化的好候选。然而,没有必要自己编写这样的宏:考虑使用 David Tolnay
提供的 thiserror crate,它提供了一个高质量、广泛使用的宏实现。thiserror
生成的代码也小心翼翼地避免在生成的 API
中使任何 thiserror
类型可见,这意味着与 第 24 条相关的问题不适用。
特征对象(Trait Objects
)
第一种处理嵌套错误的方法丢弃了所有子错误的细节,只保留了某些字符串输出(format!("{:?}", err
))。
第二种方法保留了所有可能子错误的全类型信息,但需要完整枚举所有可能的子错误类型。
这就引出了一个问题,这两种方法之间有没有中间地带,可以在不需要手动包含每个可能的错误类型的情况下保留子错误信息?
将子错误信息编码为 [特征对象]避免了为每种可能性都有一个枚举
变体的需要,但擦除了特定基础错误类型的细节。接收此类对象的调用者将能够访问 Error
特征及其特征约束的方法 —— source()
、Display::fmt()
和 Debug::fmt()
,依次类推 —— 但不会知道子错误原始的静态类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum WrappedError { Wrapped(Box<dyn Error>), General(String), } impl std::fmt::Display for WrappedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Wrapped(e) => write!(f, "Inner error: {}", e), Self::General(s) => write!(f, "{}", s), } } } }
结果是这是可能的,但出奇地微妙。部分困难来自于特征对象的客观安全性约束(第 12 条),但 Rust
的一致性规则也发挥作用,它们(大致)指出对于一种类型最多只能有一个特征的实现。
一个假设的 WrappedError
类型可能会天真地预期同时实现以下两个:
Error
特征,因为它本身就是一个错误。From<Error>
特征,以便子错误可以被轻松包装。
这意味着可以从一个内部的 WrappedError
创建一个 WrappedError
,因为 WrappedError
实现了 Error
,并且这与 From
的泛反射实现冲突:
#![allow(unused)] fn main() { impl Error for WrappedError {} impl<E: 'static + Error> From<E> for WrappedError { fn from(e: E) -> Self { Self::Wrapped(Box::new(e)) } } }
#![allow(unused)] fn main() { error[E0119]: conflicting implementations of trait `From<WrappedError>` for type `WrappedError` --> src/main.rs:279:5 | 279 | impl<E: 'static + Error> From<E> for WrappedError { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: conflicting implementation in crate `core`: - impl<T> From<T> for T; }
David Tolnay
的 anyhow 是一个已经解决了这些问题(通过添加一个额外的间接层,通过 Box)并增加了其他有用功能(如堆栈跟踪)的 crate
。因此,它迅速成为错误处理的标准化建议 —— 在这里也得到支持:考虑在应用程序中使用 anyhow
crate 进行错误处理。
库与应用程序
上一节的最后建议中包含了这样的限定:“……用于应用程序中的错误处理”。这是因为库中编写的代码和构成顶级应用程序的代码之间通常有一个区别。3
为库编写的代码无法预测代码使用的环境,因此最好发出具体、详细的错误信息,让调用者去弄清楚如何使用这些信息。这倾向于前面描述的枚举风格的嵌套错误(并且在库的公共 API
中避免了依赖 anyhow
,参见第 24 条)。
然而,应用程序代码通常需要更多地关注如何向用户呈现错误。它还可能不得不应对其依赖关系图中所有库发出的所有不同错误类型(第 25 条)。因此,一个更动态的错误类型(如 anyhow::Error
)使得错误处理在应用程序中更简单、更一致。
需要记住的事情
- 标准
Error
特征对您的需求很少,因此最好为您的错误类型实现它。 - 在处理异构的基础错误类型时,决定是否需要保留这些类型。
- 如果不是,考虑在应用程序代码中使用
anyhow
来包装子错误。 - 如果是,将它们编码在一个
枚举
中并提供转换。考虑使用thiserror
来帮助做到这一点。
- 如果不是,考虑在应用程序代码中使用
- 考虑在应用程序代码中使用
anyhow crate
进行便捷、惯用的错误处理。 - 决定权在您手中,但无论您决定什么,都要在类型系统中编码它(第 1 条)。
注释
或者至少是唯一一个非废弃的、稳定的方法。
在撰写本文时,Error
已经被移动到 core
,但在稳定版的 Rust
中尚不可用。
本节灵感来源于 Nick Groenen
的文章《Rust: 2020年在错误处理和结构化》。
原文点这里查看
第 5 条:理解类型转换
Rust 的类型转换分为三个类别:
- 手动:通过实现
From trait
和Into trait
提供的用户定义类型转换 - 半自动:使用
as
关键字在值之间进行显式转换 - 自动:隐式强制转换为新类型
本章节的重点主要是第一种,即手动转换类型,因为后两种大多数情况下不适用于用户定义类型的转换。但也有一些例外,所以本章节最后的部分将讨论转换和强制类型转换 —— 包括它们如何适用于用户定义的类型。
请注意,与许多较旧的语言不同,Rust
在数值类型之间不会执行自动转换。这甚至适用于整数类型的“安全”转换:
#![allow(unused)] fn main() { let x: u32 = 2; let y: u64 = x; }
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:70:18 | 70 | let y: u64 = x; | --- ^ expected `u64`, found `u32` | | | expected due to this | help: you can convert a `u32` to a `u64` | 70 | let y: u64 = x.into(); | +++++++ }
用户定义类型转换
与语言的其他特性(第 10 条)一样,在不同用户定义类型值之间执行转换的能力被封装为标准 trait
—— 或者更确切地说,是一组相关的泛型 trait
。
表达类型值转换能力的四个相关 trait
如下:
From<T>
:这种类型的项可以由类型T
的项构建,并且转换总是成功。TryFrom<T>
:这种类型的项可以由类型T
的项构建,但转换可能不会成功。Into<T>
:这种类型的项可以转换为类型T
的项,并且转换总是成功。TryInto<T>
:这种类型的项可以转换为类型T
的项,但转换可能不会成功。
鉴于第 1 条中关于在类型系统中表达事物的讨论,发现 Try...
变体的区别在于,唯一的 trait
方法返回一个 Result
而不是保证的新项。Try...
trait
定义还要求一个关联类型,它给出了失败情况下发出的错误 E
的类型。
因此,第一条建议是,如果可能转换失败,则**(仅)实现Try... trait
,与第 4 条 一致。另一种方法是忽略错误的可能性(例如,使用 .unwrap()
)**,但这需要是深思熟虑的选择,在大多数情况下,最好将这个选择留给调用者。
类型转换 trait
具有明显的对称性:如果类型 T
可以转换为类型 U
(通过 Into<U>
),难道这不等于可以通过从类型 T
的项转换来创建类型 U
的项(通过 From<T>
)吗?
确实如此,这导致了第二条建议:为转换实现 From trait
。Rust
标准库必须在这两个可能性中选择一个,以防止系统在眩晕的圆圈中旋转,1 它选择了自动提供 From
实现的 Into
。
如果你正在使用这两个 trait
中的一个,作为你自己新的泛型trait
约束,那么建议是相反的:对 trait
约束使用 Into trait
。这样,约束将同时满足直接实现 Into
的内容和仅直接实现 From
的内容。
From
和 Into
的文档强调了这种自动转换,但阅读标准库代码的相关部分也值得一读,这是一个泛型 trait
实现:
#![allow(unused)] fn main() { impl<T, U> Into<U> for T where U: From<T>, { fn into(self) -> U { U::from(self) } } }
将 trait
规范翻译成文字可以帮助理解更复杂的 trait
约束。在这个案例中,它相当简单:"只要 U
已经实现了 From<T>
,我就可以为类型 T
实现 Into<U>
"。
标准库还包括了为标准库类型实现这些转换 trait
的各种情况。正如你所预期的,对于整数转换,当目标类型包括源类型的所有可能值时(例如,u64
的 From<u32>
),会有 From
实现,而当源值可能不适合目标时(例如,u32
的 TryFrom<u64>
),会有 TryFrom
实现。
除了前面显示的 Into
版本的泛型 trait
实现之外,还有各种其他的泛型 trait
实现主要用于智能指针类型,允许智能指针从其持有的类型的实例自动构造。这意味着接受智能指针参数的泛型方法也可以用普通的旧项调用;更多内容将在后续介绍和第 8 条中展开。
TryFrom trait
还有一个泛型实现,适用于任何已经以相反方向实现 Into trait
的类型 —— 这自动包括了(如先前所示)以相同方向实现 From
的任何类型。换句话说,如果你可以无误地将 T
转换为 U
,你也可以尝试从 T
获取 U
;由于这种转换总是成功,所以与之关联的错误类型是Infallible
。2
还有一个非常具体且通用的From
实现方式非常突出,那就是自反实现:
#![allow(unused)] fn main() { impl<T> From<T> for T { fn from(t: T) -> T { t } } }
翻译成文字,这只是说“给定一个T
,我可以获得一个T
”。这是一个如此明显的“嗯,当然了”一样的废话,但是值得停下来理解为什么它有用。
考虑一个简单的新类型结构体(第 6 条)和一个对其操作的函数(忽略这个函数最好表示为一个方法):
#![allow(unused)] fn main() { /// Integer value from an IANA-controlled range. #[derive(Clone, Copy, Debug)] pub struct IanaAllocated(pub u64); /// Indicate whether value is reserved. pub fn is_iana_reserved(s: IanaAllocated) -> bool { s.0 == 0 || s.0 == 65535 } }
这个函数可以使用结构体的实例来调用:
#![allow(unused)] fn main() { let s = IanaAllocated(1); println!("{:?} reserved? {}", s, is_iana_reserved(s)); // output: "IanaAllocated(1) reserved? false" }
但是,即使为新的包装类型实现了 From<u64>
:
#![allow(unused)] fn main() { impl From<u64> for IanaAllocated { fn from(v: u64) -> Self { Self(v) } } }
该函数不能直接为 u64
值调用:
#![allow(unused)] fn main() { if is_iana_reserved(42) { // ... } }
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:77:25 | 77 | if is_iana_reserved(42) { | ---------------- ^^ expected `IanaAllocated`, found integer | | | arguments to this function are incorrect | note: function defined here --> src/main.rs:7:8 | 7 | pub fn is_iana_reserved(s: IanaAllocated) -> bool { | ^^^^^^^^^^^^^^^^ ---------------- help: try wrapping the expression in `IanaAllocated` | 77 | if is_iana_reserved(IanaAllocated(42)) { | ++++++++++++++ + }
但是,该函数的通用版本接受(并显式转换)任何满足Into<IanaAllocated>
内容:
#![allow(unused)] fn main() { pub fn is_iana_reserved<T>(s: T) -> bool where T: Into<IanaAllocated>, { let s = s.into(); s.0 == 0 || s.0 == 65535 } }
允许这种使用:
#![allow(unused)] fn main() { if is_iana_reserved(42) { // ... } }
有了这个特征绑定, From<T>
的自反特征实现就更有意义了:这意味着通用函数可以处理已经是IanaAllocated
实例的项,不需要转换。
这种模式还解释了为什么(以及如何)Rust
代码有时好像会在类型之间进行隐式转换:From<T>
实现与 Into<T>
特征边界的组合,导致了在调用站点看似神奇转换,但在幕后仍然进行安全、显式转换的代码。当与引用类型及其相关转换特征结合时,这种模式变得更为强大;更多内容见第 8 条。
类型转换
Rust
包含 as
关键字以在某些类型对之间执行显式转换。
可以通过这种方式转换的类型对构成了一个相当有限的集合,并且它包括的唯一用户定义类型是“类似 C
”的枚举(那些只有相关联的整数值的枚举)。尽管如此,它还是包括了常规整数转换,为 into()
提供了一个替代方案:
#![allow(unused)] fn main() { let x: u32 = 9; let y = x as u64; let z: u64 = x.into(); }
as
版本还允许进行有损转换:3
#![allow(unused)] fn main() { let x: u32 = 9; let y = x as u16; }
这将会被 from/into
版本拒绝:
#![allow(unused)] fn main() { error[E0277]: the trait bound `u16: From<u32>` is not satisfied --> src/main.rs:136:20 | 136 | let y: u16 = x.into(); | ^^^^ the trait `From<u32>` is not implemented for `u16` | = help: the following other types implement trait `From<T>`: <u16 as From<NonZeroU16>> <u16 as From<bool>> <u16 as From<u8>> = note: required for `u32` to implement `Into<u16>` }
为了保持一致性和安全性,您应该优先使用 from/into
转换而不是 as
强制转换,除非您理解并需要精确的强制转换语义(例如,用于 C
语言互操作性)。这个建议可以通过 Clippy
(第 29 条)得到加强,Clippy
包含了关于 as
转换的几个 lint
;然而,这些 lint
默认是禁用的。
强制转换
上一节描述的显式 as
强制转换是编译器会默默执行的隐式强制转换的超集:任何强制转换都可以用显式的 as
来强制执行,但反之则不成立。特别是,上一节执行的整体转换并不是强制转换,因此将始终需要 as
。
大多数强制转换涉及指针和引用类型的隐式转换,这些转换对程序员来说是有意义且方便的,例如转换以下内容:
- 可变引用到不可变引用(这样您就可以将
&mut T
作为接受&T
的函数的参数) - 引用到原始指针(这并不不安全 —— 不安全性发生在您足够愚蠢地去解引用一个原始指针的时候)
- 恰好没有捕获任何变量的闭包到裸函数指针(第 2 条)
- 数组到切片
- 具体项到特质对象,对于具体项实现的特质
- 项的生命周期到“更短”的一个(第 14 条)4
只有两种强制转换的行为可能受到用户定义类型的影响。第一种情况是用户定义的类型实现了 Deref
或 DerefMut
特质。这些特质表明用户定义的类型充当某种智能指针(第 8 条),在这种情况下,编译器会将智能指针项的引用强制转换为智能指针包含的类型的项的引用(由其 Target
指示)。
用户定义的类型的第二种强制转换发生在具体项转换为特质对象时。这个操作构建了一个指向项的胖指针;这个指针之所以胖,是因为它既包括了指向项在内存中位置的指针,也包括了指向具体类型实现特质的 vtable
的指针 —— 参见第 8 条。
注释
更准确地称为 trait
一致性规则。
暂时如此 —— 这可能会在未来的 Rust
版本中被 !
"never
" 类型所取代。
在 Rust 中允许有损转换可能是错误的,已经有过尝试去除这种行为的讨论。
Rust
将这些转换称为“子类型化”,但它与面向对象语言中“子类型化”的定义大不相同。
原文点这里
第 6 条:拥抱 newtype 模式
第 1 条描述了元组结构体,它的字段没有名字,而是通过数字(self.0
)来引用。本条着重介绍的是,只包含一个类型的元组结构体。它是一个新的类型,可以包含和内置类型一样的值。在 Rust 中,这个模式非常普遍,它叫做:newtype 模式。
newtype 模式的最简单用法,是在类型原有行为的基础上,提供额外的语义。想象有一个将卫星送往火星的项目。1这是一个大项目,不同的团队已经构建了项目的不同部分。其中一个小组负责火箭引擎的代码:
#![allow(unused)] fn main() { /// 点燃推进器。返回产生的脉冲,单位为磅/秒。 pub fn thruster_impulse(direction: Direction) -> f64 { // ... return 42.0; } }
另一个团队负责惯性导航系统:
#![allow(unused)] fn main() { /// 根据提供的推力(单位:牛顿/秒)更新轨迹模型。 pub fn update_trajectory(force: f64) { // ... } }
最终,结合这些不同部分:
#![allow(unused)] fn main() { let thruster_force: f64 = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force); }
糟糕(Ruh-roh )。2
Rust 有类型别名的特性,让不同的团队能够更清楚地表达他们的意图:
#![allow(unused)] fn main() { /// 推力的单位。 pub type PoundForceSeconds = f64; /// 点燃推进器。返回产生的脉冲。 pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds { // ... return 42.0; } }
#![allow(unused)] fn main() { /// 推力的单位。 pub type NewtonSeconds = f64; /// 更新冲力轨迹模型。 pub fn update_trajectory(force: NewtonSeconds) { // ... } }
然而,实际上类型别名只是文档:它们比前面的文档注释有更强的提示,但不能阻止 PoundForceSeconds
值被使用在希望使用 NewtonSeconds
值的地方。
#![allow(unused)] fn main() { let thruster_force: PoundForceSeconds = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force); }
再次出现问题了。
这就是 newtype 模式能带来帮助的地方
#![allow(unused)] fn main() { /// 推力的单位。 pub struct PoundForceSeconds(pub f64); /// 点燃推进器。返回产生的脉冲。 pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds { // ... return PoundForceSeconds(42.0); } /// 推力的单位。 pub struct NewtonSeconds(pub f64); /// 更新冲力轨迹模型。 pub fn update_trajectory(force: NewtonSeconds) { // ... } }
如名称所示,newtype 是一个新类型。因此,当型不匹配时,编译器会报错。在这里,我们尝试将 PoundForceSeconds
值传递给期望使用 NewtonSeconds
值的地方:
#![allow(unused)] fn main() { let thruster_force: PoundForceSeconds = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force); }
#![allow(unused)] fn main() { let new_direction = update_trajectory(thruster_force); error[E0308]: mismatched types --> src/main.rs:76:43 | 76 | let new_direction = update_trajectory(thruster_force); | ----------------- ^^^^^^^^^^^^^^ expected | | `NewtonSeconds`, found `PoundForceSeconds` | | | arguments to this function are incorrect | note: function defined here --> src/main.rs:66:8 | 66 | pub fn update_trajectory(force: NewtonSeconds) { | ^^^^^^^^^^^^^^^^^ -------------------- help: call `Into::into` on this expression to convert `PoundForceSeconds` into `NewtonSeconds` | 76 | let new_direction = update_trajectory(thruster_force.into()); | +++++++ }
如在第 5 条中所述,添加标准库的 From
特性的实现:
#![allow(unused)] fn main() { impl From<PoundForceSeconds> for NewtonSeconds { fn from(val: PoundForceSeconds) -> NewtonSeconds { NewtonSeconds(4.448222 * val.0) } } }
这样就能用 .into()
执行单位和类型转换:
#![allow(unused)] fn main() { let thruster_force: PoundForceSeconds = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force.into()); }
使用 newtype,除了能附加「单位」语义,还可以使布尔参数更清晰。回顾第 1 条的例子,使用newtype可以清晰地说明参数的含义:
#![allow(unused)] fn main() { struct DoubleSided(pub bool); struct ColorOutput(pub bool); fn print_page(sides: DoubleSided, color: ColorOutput) { // ... } }
#![allow(unused)] fn main() { print_page(DoubleSided(true), ColorOutput(false)); }
如果需要考虑大小或二进制兼容性,那么 #repr(transparent)
属性能确保newtype在内存中的表示与内部类型相同。
这个来自第 1 条的例子,是 newtype 的简单用法—将语义编码到类型系统中,以让编译器负责管理这些语义。
绕过特征的孤儿规则
另一个常见但更巧妙的需要 newtype 模式的场景,是 Rust 的孤儿规则。这个规则意味着,在一个包里,以下条件之一满足时,才能为某个类型实现特性:
• 包定义了该特性
• 包定义了该类型
我们来尝试为一个外部类型实现一个外部特性:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for rand::rngs::StdRng { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { write!(f, "<StdRng instance>") } } }
编译器会出错(它指出要使用 newtype):
error[E0117]: only traits defined in the current crate can be implemented for
types defined outside of the crate
--> src/main.rs:146:1
|
146 | impl fmt::Display for rand::rngs::StdRng {
| ^^^^^^^^^^^^^^^^^^^^^^------------------
| | |
| | `StdRng` is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
这种限制的原因是可能发生歧义:如果依赖关系图中的两个不同的包(第 25 条)都要实现 impl std::fmt::Display for rand::rngs::StdRng
,那么编译器/链接器不知道选择哪个。
这经常会带来挫败:例如,如果你试图序列化包含来自其他包的类型的数据,孤儿规则会阻止你写 impl serde::Serialize for somecrate::SomeType
。3
但是 newtype 模式意味着你定义了一个新类型,这是当前包的一部分,所以就满足了孤儿规则的第二点。现在就能够实现一个外部特性:
#![allow(unused)] fn main() { struct MyRng(rand::rngs::StdRng); impl fmt::Display for MyRng { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { write!(f, "<MyRng instance>") } } }
newtype 的限制
newtype 模式解决了两类问题——阻止类型转换和绕过孤儿原则。但它也有一些不足——每个 newtype 的操作都需要转发到内部类型。
这意味着必须在所有地方都使用 thing.0
,而不是使用 thing
。不过这很容易做到,而且编译器会告诉你在哪里需要。
比较麻烦的是,内部类型的任何特征实现都会丢失:因为 newtype 是一个新类型,所以现有的内部实现都不适用。
对于能派生的特征,只需要 newtype 的声明上使用 derive
:
#![allow(unused)] fn main() { #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] pub struct NewType(InnerType); }
然而,对于更复杂的特征,需要一些样板代码来恢复内部类型的实现,例如:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for NewType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { self.0.fmt(f) } } }
注释
具体来说,是火星气候轨道器。
参见维基百科上的“火星气候轨道器”条目,了解更多关于失败原因的信息。译者注:这句话 "Ruh-roh" 通常用来表达轻微的麻烦或问题即将出现。它起源于美国动画片《史酷比》(Scooby-Doo),是其中角色 Shaggy Rogers 的口头禅,用来表达他们遇到了一些麻烦或即将面临挑战。
对于serde来说,这是一个足够常见的问题,因此它包含了一种帮助机制。
原文点这里查看
第 7 条:对于复杂的类型,使用构造器
这条款项描述了构造器模式:对于复杂的数据类型提供对应的构造器类型 builder type
,使得用户可以方便地创造该数据数据类型的实例。
Rust 要求开发者在创建一个新的 struct
实例的时候,必须填入 struct
的所有字段。这样可以保证结构体中永远不会存在未初始化的值,从而保证了代码的安全,然而这会比理想的情况下产生更多的冗余的代码片段。
例如,任何可选的字段都必须显式地使用 None
来标记为缺失:
#![allow(unused)] fn main() { /// Phone number in E164 format. #[derive(Debug, Clone)] pub struct PhoneNumberE164(pub String); #[derive(Debug, Default)] pub struct Details { pub given_name: String, pub preferred_name: Option<String>, pub middle_name: Option<String>, pub family_name: String, pub mobile_phone: Option<PhoneNumberE164>, } // ... let dizzy = Details { given_name: "Dizzy".to_owned(), preferred_name: None, middle_name: None, family_name: "Mixer".to_owned(), mobile_phone: None, }; }
这样的样板式代码也很脆弱,因为将来要向 struct
中添加一个新字段的时候需要更改所有创建这个结构体的地方。
通过使用和实现 Default trait 可以显著地减少这种样板代码,如第 10 条中所述:
#![allow(unused)] fn main() { let dizzy = Details { given_name: "Dizzy".to_owned(), family_name: "Mixer".to_owned(), ..Default::default() }; }
使用 Default
还有助于减少结构体新增字段时候导致的修改,前提是新的字段本身的类型也实现了 Default
。
还有一个更普遍的问题:仅当所有的字段类型都实现了 Default
trait 的时候,结构体才能使用自动派生的 Default
实现。如果有任何一个字段不满足,那么 derive
就会失败了:
#![allow(unused)] fn main() { #[derive(Debug, Default)] pub struct Details { pub given_name: String, pub preferred_name: Option<String>, pub middle_name: Option<String>, pub family_name: String, pub mobile_phone: Option<PhoneNumberE164>, pub date_of_birth: time::Date, pub last_seen: Option<time::OffsetDateTime>, } }
error[E0277]: the trait bound `Date: Default` is not satisfied
--> src/main.rs:48:9
|
41 | #[derive(Debug, Default)]
| ------- in this derive macro expansion
...
48 | pub date_of_birth: time::Date,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Default` is not
| implemented for `Date`
|
= note: this error originates in the derive macro `Default`
由于孤儿规则的存在,代码没办法为 chrono::Utc
实现 Default
;但就算可以,也无济于事 —— 给出生日期赋一个值默认值几乎总是一个错误的选择。
缺少 Default
意味着所有字段都必须手动填写:
#![allow(unused)] fn main() { let bob = Details { given_name: "Robert".to_owned(), preferred_name: Some("Bob".to_owned()), middle_name: Some("the".to_owned()), family_name: "Builder".to_owned(), mobile_phone: None, date_of_birth: time::Date::from_calendar_date( 1998, time::Month::November, 28, ) .unwrap(), last_seen: None, }; }
如果你为复杂的数据结构实现了构造器模式,那么就可以提高这里的效率和体验。
构造器模式最简单的一种实现方式就是用一个额外的 struct
来保存构造原始复杂数据类型所需的数据。简单起见,这里的实例会直接保存一个该类型的实例:
#![allow(unused)] fn main() { pub struct DetailsBuilder(Details); impl DetailsBuilder { /// Start building a new [`Details`] object. /// 开始构造一个新的 [`Details`] 对象 pub fn new( given_name: &str, family_name: &str, date_of_birth: time::Date, ) -> Self { DetailsBuilder(Details { given_name: given_name.to_owned(), preferred_name: None, middle_name: None, family_name: family_name.to_owned(), mobile_phone: None, date_of_birth, last_seen: None, }) } } }
随后,我们可以给构造器类型增添辅助函数来填充新的字段。每一个这种函数都会消费 self
同时产生一个新的 Self
,以允许对不同的构造方法进行链式调用。
这些辅助函数会比简单的 setter
函数有用多了:
#![allow(unused)] fn main() { /// Update the `last_seen` field to the current date/time. /// 把 `last_seen` 字段更新成当前日期/时间 pub fn just_seen(mut self) -> Self { self.0.last_seen = Some(time::OffsetDateTime::now_utc()); self } }
构造器被调用的最后一个函数会消费它自身并输出所构造的对象:
#![allow(unused)] fn main() { /// Consume the builder object and return a fully built [`Details`] /// object. /// 消费构造器对象并返回最后创建的 [`Details`] 对象 pub fn build(self) -> Details { self.0 } }
总而言之,这让构造器的使用者拥有了更符合工程学的体验:
#![allow(unused)] fn main() { let also_bob = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ) .middle_name("the") .preferred_name("Bob") .just_seen() .build(); }
构造器“消费自己”的性质也导致了一些问题。首先,对象的构造过程不能独立完成:
#![allow(unused)] fn main() { let builder = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ); if informal { builder.preferred_name("Bob"); } let bob = builder.build(); }
error[E0382]: use of moved value: `builder`
--> src/main.rs:256:15
|
247 | let builder = DetailsBuilder::new(
| ------- move occurs because `builder` has type `DetailsBuilder`,
| which does not implement the `Copy` trait
...
254 | builder.preferred_name("Bob");
| --------------------- `builder` moved due to this method
| call
255 | }
256 | let bob = builder.build();
| ^^^^^^^ value used here after move
|
note: `DetailsBuilder::preferred_name` takes ownership of the receiver `self`,
which moves `builder`
--> src/main.rs:60:35
|
27 | pub fn preferred_name(mut self, preferred_name: &str) -> Self {
| ^^^^
这个问题可以通过把被消费的构造器重新赋值给同一个变量来解决:
#![allow(unused)] fn main() { let mut builder = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ); if informal { builder = builder.preferred_name("Bob"); } let bob = builder.build(); }
构造器的性质带来的另一个问题是你只能构造一个最终对象,对同一个构造器重复调用 build()
函数来创建多个实例会违反编译器的检查规则,如同你能想到的那样:
#![allow(unused)] fn main() { let smithy = DetailsBuilder::new( "Agent", "Smith", time::Date::from_calendar_date(1999, time::Month::June, 11).unwrap(), ); let clones = vec![smithy.build(), smithy.build(), smithy.build()]; }
error[E0382]: use of moved value: `smithy`
--> src/main.rs:159:39
|
154 | let smithy = DetailsBuilder::new(
| ------ move occurs because `smithy` has type `base::DetailsBuilder`,
| which does not implement the `Copy` trait
...
159 | let clones = vec![smithy.build(), smithy.build(), smithy.build()];
| ------- ^^^^^^ value used here after move
| |
| `smithy` moved due to this method call
另一种实现构造器的途径是让构造器的方法接受 &mut self
并返回一个 &mut Self
:
#![allow(unused)] fn main() { /// Update the `last_seen` field to the current date/time. /// 把 `last_seen` 字段更新成当前日期/时间 pub fn just_seen(&mut self) -> &mut Self { self.0.last_seen = Some(time::OffsetDateTime::now_utc()); self } }
这可以让代码免于分步构造场景下的自赋值:
#![allow(unused)] fn main() { let mut builder = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ); if informal { builder.preferred_name("Bob"); // no `builder = ...` } let bob = builder.build(); }
然而,这个版本的实现使得构造器的构造方法和它的 setter
函数无法被链式调用:
#![allow(unused)] fn main() { let builder = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ) .middle_name("the") .just_seen(); let bob = builder.build(); }
error[E0716]: temporary value dropped while borrowed
--> src/main.rs:265:19
|
265 | let builder = DetailsBuilder::new(
| ___________________^
266 | | "Robert",
267 | | "Builder",
268 | | time::Date::from_calendar_date(1998, time::Month::November, 28)
269 | | .unwrap(),
270 | | )
| |_____^ creates a temporary value which is freed while still in use
271 | .middle_name("the")
272 | .just_seen();
| - temporary value is freed at the end of this statement
273 | let bob = builder.build();
| --------------- borrow later used here
|
= note: consider using a `let` binding to create a longer lived value
如同编译器错误所示,你可以通过 let
为构造器指定一个名字来解决这个问题:
#![allow(unused)] fn main() { let mut builder = DetailsBuilder::new( "Robert", "Builder", time::Date::from_calendar_date(1998, time::Month::November, 28) .unwrap(), ); builder.middle_name("the").just_seen(); if informal { builder.preferred_name("Bob"); } let bob = builder.build(); }
这种修改自身的构造器实现允许你构造多个最终对象。build()
方法的签名不需要消费 self
,因此必须如下所示:
#![allow(unused)] fn main() { /// Construct a fully built [`Details`] object. /// 生成一个构造完毕的 [`Details`] 对象。 pub fn build(&self) -> Details { // ... } }
这个可重复调用的 build()
的实现必须在每次被调用的时候构造一个全新的实例。如果底层类型实现了 Clone
,这就很简单了 —— 构造器可以持有一个模板然后在每一次 build()
的时候执行一次 clone()
。如果底层类型没有实现 Clone
,那么构造器需要保留足够的状态信息,在每一次 build()
的时候手动创建一个实例返回。
不管是哪种构造器模式的实现,样板代码都集中在一个地方 —— 构造器本身 —— 而不是每个需要操作底层类型的地方。
剩下的样板代码或许还可以通过宏(第 28 条)进一步减少,但如果你打算在这条路上走下去,你应该看看是否有现成的包(尤其是 derive_builder)已经提供了你需要的功能——如果你愿意添加一个依赖的话(第 25 条)。
原文点这里查看
第 8 条:熟悉引用和指针类型
在一般的编程中,引用(reference) 是一种间接访问数据结构的方式,它与拥有该数据结构的变量是分开的。在实践中,引用 通常由 指针(pointer) 来实现。指针 是一个数字,它的值是数据结构的变量在内存中的地址。
现代 CPU 通常会对指针施加一些限制:内存地址应该处于有效的内存范围内(虚拟内存或物理内存),并且可能需要对齐(例如,一个4字节的整数值可能只有在其地址是4的倍数时才能访问)。
然而,高级编程语言通常会在其类型系统中编码更多关于指针的信息。在 C 衍生的语言(包括 Rust )中,指针中有一个类型,该类型表示期望在所指向的内存地址存储哪种类型数据结构。这允许通过代码解释在该地址以及随后内存中的内容。
这种基本的指针信息-假定的内存位置和预期的数据结构布局-在 Rust 中被表示为一个裸指针(raw point)。然而,安全的 Rust 代码不使用裸指针,因为 Rust 提供了更丰富的引用和指针类型,这些类型提供了额外的安全保证和约束。这些引用和指针类型是本节的主题;裸指针则留待第16条讨论(该节讨论 unsafe 代码)。
Rust引用
在 Rust 中,最常见的指针类型是 引用,用 &T
表示,其中 T
是任意类型。尽管在底层这是一个指针值,但编译器会确保在使用时遵循一些规则:
这些额外的约束总是隐含在 Rust 中的 引用 中,因此 裸指针 通常很少出现。
Rust 引用必须指向有效、正确对齐的项的约束,与 C++ 的引用类型相同。然而,C++ 没有生命周期的概念,因此允许使用悬空引用而导致错误1:
// C++
const int& dangle() {
int x = 32; // on the stack, overwritten later
return x; // return reference to stack variable!
}
Rust 的借用和生命周期检查会让等价的代码甚至不能编译:
#![allow(unused)] fn main() { fn dangle() -> &'static i64 { let x: i64 = 32; // 在栈上 &x } }
error[E0515]: cannot return reference to local variable `x`
--> src/main.rs:477:5
|
477 | &x
| ^^ returns a reference to data owned by the current function
Rust 的引用 &T
允许只读访问底层元素(大致相当于 C++ 的const T&
)。一个允许修改底层元素的可变引用写为 &mut T
,同样也遵循第 15 项讨论的借用检查规则。这种命名方式反映了 Rust 和 C++ 之间略微不同的思维方式:
- 在 Rust 中,默认情况下变量是只读的,可写类型需要特别标记(用
mut
)。 - 在 C++ 中,默认情况下引用是可写的,只读类型需要特别标记(用
const
)。 编译器会将使用引用的 Rust 代码转换为使用简单指针的机器码,在 64 位平台上这些指针的长度为 8 个字节(本节假设一直如此)。
例如,一对局部变量以及对它们的引用:
#![allow(unused)] fn main() { pub struct Point { pub x: u32, pub y: u32, } let pt = Point { x: 1, y: 2 }; let x = 0u64; let ref_x = &x; let ref_pt = &pt; }
可能最终在栈上布局如图1-2所示。
图1-2.带有指向局部变量的指针的栈布局Rust 引用可以指向位于栈或堆上的元素。Rust 默认情况下会在栈上分配内存,但是 Box<T>
指针类型(大致相当于 C++ 的 std::unique_ptr<T>
) 会强制分配到堆上,这意味着分配的元素可以比当前代码块的作用域更长寿。本质上,Box<T>
也是一个简单的8字节指针值(64 位平台):
注意:
- 栈是一种快速但有限制的内存区域,函数调用时分配,函数结束后释放。
- 堆是一种更大但速度较慢的内存区域,程序可以显式分配和释放内存。
#![allow(unused)] fn main() { let box_pt = Box::new(Point { x: 10, y: 20 }); }
这在图1-3中被描述。
图1-3.栈上的 Box
指针指向堆上的 struct
指针特征
期望一个引用参数,如 &Point
的方法也可以接受一个 &Box<Point>
:
fn show(pt: &Point) {
println!("({}, {})", pt.x, pt.y);
}
show(ref_pt);
show(&box_pt);
(1, 2)
(10, 20)
这之所以可能,因为 Box<T>
实现了 Deref
特征(Trait
),Target = T
。某个类型实现这个特征意味着该特征的 deref()
方法可以用于创建对 Target
类型的引用。还有一个等效的 DerefMut
特征,它会生成对 Target
类型的可变引用。
Deref
/DerefMut
特征有点特别,因为Rust编译器在处理实现它们的类型时有特定的行为。当编译器遇到解引用表达式(例如,*x
),它会根据解引用是否需要可变访问来查找并使用这些特征的实现。这种 Deref
转换允许各种智能指针类型像普通引用一样工作,它是 Rust 中少数允许隐式类型转换的机制之一(如第5条所述)。
作为一个技术细节,理解为什么 Deref
特征不能对目标类型是泛型的(Deref<Target>
)是很值得的。如果它们是,那么某些类型 ConfusedPtr
就可以同时实现 Deref<TypeA>
和 Deref<TypeB>
,这将使编译器无法为 *x
这样的表达式推导出唯一的类型。因此,目标类型被编码为一个名为 Target
的关联类型。
这种技术细节与另两个标准指针特征 AsRef
和 AsMut
形成对比。这些特征不会在编译器中引起特殊行为,但允许通过对其特征函数(as_ref()
和 as_mut()
)的显式调用进行引用或可变引用的转换。转换的目标类型被编码为类型参数(例如,AsRef<Point>
),这意味着一个容器类型可以支持多个目标类型。
例如,标准 String
类型实现了 Deref
特征,Target = str
,这意味着像 &my_string
这样的表达式可以强制转换为类型 &str
。但它也实现了以下特征:
AsRef<[u8]>
,允许转换为字节切片&[u8]
AsRef<OsStr>
,允许转换为OS字符串AsRef<Path>
,允许转换为文件系统路径AsRef<str>
,允许转换为字符串切片&str
(与Deref
相同)
胖指针类型
Rust有两个内置的胖指针类型:切片(Slice
)和特征(Trait
)对象。这些类型的行为像指针,但它们持有关于指向对象的额外信息。
切片
第一种胖指针类型是切片:它引用某个连续值集合的子集。切片由一个(没有所有权的)简单指针和一个长度字段组成,因此大小是简单指针的两倍(在 64 位平台上为 16 字节)。切片的类型写为 &[T]
- 它表示对 [T]
的引用,[T]
是类型 T 的连续值集合的概念类型。
概念类型 [T]
不能被实例化,但是有两种常见的容器实现了它。第一种是数组:一个连续的值集合,其大小在编译时是已知的。
一个有5个值的数组将始终有5个值。因此,切片可以引用数组的一个子集(如图1-4所示):
#![allow(unused)] fn main() { let array: [u64; 5] = [0, 1, 2, 3, 4]; let slice = &array[1..3]; }
图1-4.指向栈数组的栈切片
连续值的另一种常见容器是 Vec<T>
。这像数组一样持有连续的值集合,但与数组不同,Vec
中的值的数量可以增长(例如,用 push(value)
)或缩小(例如,用 pop()
)。
Vec
的内容保存在堆上(这允许其大小发生变化),并且总是连续的,因此切片可以引用 Vec
的子集,如图 1-5 所示:
#![allow(unused)] fn main() { let mut vector = Vec::<u64>::with_capacity(8); for i in 0..5 { vector.push(i); } let vslice = &vector[1..3]; }
图1-5.指向堆上的Vec内容的栈切片
表达式 &vector[1..3]
的底层有很多细节,所以值得将其拆解成多个部分:
1..3
部分是一个范围表达式(range expression);编译器会将其转换为Range<usize>
类型的实例,该类型包含下限(1)但不包含上限(3)。Range
类型实现了特征,该特征描述了对任意类型T
的切片的索引操作(因此Output
类型为[T]
)。vector[]
部分是一个索引表达式(indexing expression);编译器将其转换为在vector
上调用Index
特征的index
方法,并附加一次解引用(即*vector.index()
)。2vector[1..3]
会调用Vec<T>
的Index<I>
[实现],它要求I
是SliceIndex<[u64]>
的一个实例。这是因为Range<usize>
对于任何T
类型来说,包括u64
,都实现了SliceIndex<[T]>
特征。&vector[1..3]
取消了解引用,最终得到的表达式类型为&[u64]
。
特征对象
第二种内置的胖指针类型是特征对象:它引用实现了特定特征的某个元素。特征对象由一个指向该元素的简单指针和一个指向类型 vtable
的内部指针共同构成,大小为 16 字节(在 64 位平台上)。类型的 vtable
存储了该类型所实现特征的方法实现的函数指针,从而允许在运行时进行动态分配(第12条)。3
例如,定义一个简单的特征:
#![allow(unused)] fn main() { trait Calculate { fn add(&self, l: u64, r: u64) -> u64; fn mul(&self, l: u64, r: u64) -> u64; } }
以及一个实现该特征的结构体:
struct Modulo(pub u64);
impl Calculate for Modulo {
fn add(&self, l: u64, r: u64) -> u64 {
(l + r) % self.0
}
fn mul(&self, l: u64, r: u64) -> u64 {
(l * r) % self.0
}
}
let mod3 = Modulo(3);
我们可以将 Modulo
转换为特征对象 &dyn
Calculate。dyn
关键字强调了涉及动态分配的事实:
// Need an explicit type to force dynamic dispatch.
let tobj: &dyn Calculate = &mod3;
let result = tobj.add(2, 2);
assert_eq!(result, 1);
如图 1-6 所示的相应内存布局。
图1-6.指向具体项和 vtable
的 Trait 对象
持有特征对象的代码可以通过 vtable
中的函数指针调用特征的方法,并将元素指针作为 &self
参数传递;有关更多信息和建议,请参考第12条。
更多指针特征
之前的一节描述了两组特征(Deref
/DerefMut
、AsRef
/AsMut
),它们用于处理可以轻松转换为引用的类型。除此之外,还有一些标准特征在处理类似指针的类型时也会发挥作用,这些类型可以来自标准库或用户定义的类型。
其中最简单的是 Pointer
特征,它用于格式化指针值以供输出。这对于底层开发调试很有帮助,编译器在遇到{:p}
格式说明符时会自动使用这个特征。
更有趣的是 Borrow
和 BorrowMut
特征,它们各自只有一个方法(分别为 borrow
和 borrow_mut
)。这些方法的签名与相应的 AsRef
/AsMut
特征方法相同。
通过标准库提供的泛型实现,可以看到这些特征之间的主要意图差异。给定任意Rust引用 &T
,标准库都提供了 AsRef
和 Borrow
的泛型实现;同样地,对于可变引用 &mut T
,也提供了 AsMut
和 BorrowMut
的泛型实现。
然而,Borrow
还有一个针对(非引用)类型的泛型实现:impl<T> Borrow<T> for T
。这意味着,一个接受 Borrow
特征的方法可以同样处理 T
的实例以及对 T
的引用:
#![allow(unused)] fn main() { fn add_four<T: std::borrow::Borrow<i32>>(v: T) -> i32 { v.borrow() + 4 } assert_eq!(add_four(&2), 6); assert_eq!(add_four(2), 6); }
标准库的容器类型有更贴合实际的 Borrow
用法。例如,HashMap::get
使用 Borrow
以便无论通过值还是引用作为键,都可以方便地检索条目。
ToOwned
特征建立在 Borrow
特征的基础上,增加了一个 to_owned()
方法,该方法生成一个新的底层类型的所有权项。这是 Clone
特征的泛化: 其中 Clone
特征特别要求一个Rust引用 &T
,而 ToOwned
则处理实现 Borrow
的内容。
这为以统一的方式处理引用和移动项提供了几种可能性:
- 一个操作某些类型引用的函数可以接受
Borrow
,以便它也可以用移动项以及引用调用。 - 一个操作某些类型拥有项的函数可以接受
ToOwned
,以便它也可以用引用项以及移动项调用;传递给它的任何引用都将被复制到本地拥有项中。
此时值得一提的是 Cow
类型,虽然它不是指针类型,因为它提供了处理相同情况的替代方式。Cow
是一个枚举,可以持有拥有的数据或对借用数据的引用。这个特殊的名称代表“写入时复制”(clone-on-write):一个 Cow
输入可以作为借用数据一直保持,直到它需要被修改,但在数据需要被更改的时,它才会变成一个拥有的副本。
智能指针类型
Rust 标准库包含多种在某种程度上类似于指针的类型,这些类型在一定程度上充当指针的角色,并由之前描述的标准库特质进行调解。每种智能指针类型都具有一些特定的语义和保证,正确组合它们可以对指针的行为进行细粒度控制,但缺点是由此产生的类型乍一看可能会让人不知所措(比如 Rc<RefCell<Vec<T>>>
)。
第一个智能指针类型是 Rc<T>
,它是对某个项的引用计数指针(大致类似于 C++中的 std::shared_ptr<T>
)。它实现了所有与指针相关的特征,因此在许多方面的行为类似于 Box<T>
。
这对于可以通过不同途径访问同一个项的数据结构很有用,但它消除了 Rust 围绕所有权的核心规则之一,每个项只有一个所有者。放宽这条规则意味着现在可能会发生数据泄漏:如果变量 A 有一个指向变量 B 的 Rc
指针,而变量 B 有一个指向 A 的 Rc
指针,那么这对变量将永远不会被释放。4换句话说:你需要 Rc
来支持循环数据结构,但缺点是现在你的数据结构中存在循环。
在某些情况下,可以通过使用相关的 Weak<T>
类型来降低数据泄漏的风险。Weak<T>
持有对底层数据的非所有权引用(弱引用,类似于 C++ 的 std::weak_ptr<T>
)。持有弱引用不会阻止底层数据被删除(当所有强引用都被移除时),所以使用 Weak<T>
需要升级为 Rc<T>
——这可能会失败。
在底层,Rc
(目前)实现为一对引用计数和被引用的项,所有这些都存储在堆上(如图 1-7 所示):
#![allow(unused)] fn main() { use std::rc::Rc; let rc1: Rc<u64> = Rc::new(42); let rc2 = rc1.clone(); let wk = Rc::downgrade(&rc1); }
图1-7. Rc和Weak指针都指向堆上同一个项
当强引用计数降至零时,底层数据将被释放,但只有弱引用计数也降至零时,才会释放 bookkeeping 结构。
RcRc
或 Weak
引用指向同一项),你才能修改它(通过 get_mut
)。这很难协调,因此 Rc
通常与 RefCell
结合使用。
下一个智能指针类型是RefCell<T>
,它放宽了只能由所有者或持有唯一可变引用的代码修改数据的规则(参考第15条)。这种内部可变性带来了更大的灵活性,例如允许特征实现修改内部,即使方法签名只允许 &self
。然而,这也带来了代价:除了额外的存储开销(需要一个额外的 isize
用于跟踪当前的借用,如图 1-8 所示),正常的借用检查也从编译时转移到了运行时:
#![allow(unused)] fn main() { use std::cell::RefCell; let rc: RefCell<u64> = RefCell::new(42); let b1 = rc.borrow(); let b2 = rc.borrow(); }
图1-8. 引用了 RefCell 容器的 Ref 借用
这些运行时的借用检查意味着用户使用 RefCell
时,必须在两个不愉快的选项之间做出选择:
- 接受借用可能失败的操作,并处理来自
try_borrow[_mut]
的 Result 值。 - 使用所谓不会失败的借用方法
borrow[_mut]
,并接受在运行时由于借用规则不合规而引发 panic 的风险(参考第18条)。
无论哪种情况,这种运行时检查意味着 RefCell
不实现任何标准指针特征;相反,它的访问操作返回一个实现了这些特征的 Ref<T>
或 RefMut<T>
智能指针类型。
如果底层类型 T
实现了 Copy
特征(表示按位快速复制生成一个有效的项;参考第10条),那么 Cell<T>
类型允许以更少的开销进行内部修改——get(&self)
方法复制出当前值,set(&self, val)
方法设置一个新值进去。Cell
类型在 Rc
和 RefCell
实现中都被内部使用,用于共享跟踪可以修改的计数器,而无需 &mut self
。
到目前为止描述的智能指针类型仅适用于单线程使用;它们的实现假设对其内部没有并发访问。如果不是这种情况,则需要包含额外同步开销的智能指针。
Arc<T>
线程安全 Rc<T>
等价实现,它使用原子计数器来确保引用计数保持准确。与 Rc
一样,Arc
实现了所有各种与指针相关的特征。
然而,单独的 Arc
不允许对底层项目进行任何形式的可变访问。这是由 Mutex
类型引起的,它确保只有一个线程可以访问(无论是可变的还是不可变的)底层项目。与 RefCell
一样,Mutex
本身不实现任何指针特征,但它的 lock()
操作返回一个实现了 Deref[Mut]
的类型的值:MutexGuard
。
如果读者(读操作线程)可能多于写者(写操作线程),则更推荐使用 RwLock
类型,因为它允许多个读者并行访问底层项目,前提是当前没有(单个)写者。
在任何情况下,Rust 的借用和线程规则都强制在多线程代码中使用这些同步容器中的一个(但这仅能防止共享状态并发的一些问题;参考第17条)。
对于其他智能指针类型,有时也可以应用相同的策略——查看编译器拒绝的内容,并替换为它建议的内容。然而,更快、更少挫折的方法是理解不同智能指针的行为含义。借用来自Rust编程语言第一版的示例
:
Rc<RefCell<Vec<T>>>
持有一个具有共享所有权 (Rc
) 的Vec
,其中Vec
可以作为一个整体被修改。Rc<Vec<RefCell<T>>>
也持有具有共享所有权的Vec
,但这里Vec
中的每个单独的条目都可以独立于其他条目进行修改。
涉及的类型精确描述了这些行为。
注释
尽管有来自现代编译器的警告
可变表达式的等价特征是IndexMut
这是一种简化解释;完整的虚函数表(vtable)
还包含类型的尺寸和对齐信息,以及一个drop()
函数指针,用于安全地释放底层对象
请注意,这并不影响 Rust 的内存安全保证:项目仍然是安全的,只是无法访问
第 9 条:考虑使用迭代器转换代替显式循环
编程语言中简陋朴素的循环经历了一段漫长的发展,逐步提高了使用的便利性和抽象性。B 语言(C 语言的前身)当时仅有 while (condition) { ... }
这种结构,但随着 C 语言的到来,for
循环的加入使通过数组下标进行遍历这种高频行为变得越来越方便:
// C code
int i;
for (i = 0; i < len; i++) {
Item item = collection[i];
// body
}
C++ 的早期版本通过允许将循环变量的声明嵌入到 for
语句中,进一步提高了便利性(这也被 C 语言的 C99 标准所纳入):
// C++98 code
for (int i = 0; i < len; i++) {
Item item = collection[i];
// ...
}
大多数现代编程语言把循环的概念做了更进一步的抽象:循环的核心功能通常是移动到某个容器的下一项进行访问。维护访问容器元素所需要的逻辑(index++
or ++it
)通常是无关紧要的细节。基于这种认识,产生了两个核心的概念:
- 迭代器
Iterators
:一种类型,它存在的目的是重复地释出容器的下一个元素,直到把容器耗尽1。 For-each
循环:一种紧凑的循环表达式,用于迭代容器中的所有元素,表达式会将容器元素绑定到循环变量上,而不是提供一些让你访问到元素的细节信息。
这些概念允许循环代码变得更简短,(更重要的是)更加清晰地表明意图:
// C++11 code
for (Item& item : collection) {
// ...
}
一旦有了这些可行的概念,它们的强大显而易见,因此它们很快地就被移植到了那些本没有这些概念的语言中(例如,for-each 循环在 Java 1.5 和 C++11 中被引入)。
Rust 包括迭代器和 for-each
风格的循环,但它还包括了更进一步的抽象:允许整个循环体通过迭代器转换 iterator transform
(有时候也被称作迭代器适配器 iterator adaptor
)来表达。如我们在 第 3 条 中讨论的 Option
和 Reuslt
一样,这条条款会尝试展示如何使用这些迭代器转换来替换显式的循环,并就应该何时使用给出相应的指导。特别要注意的是,迭代器转换或许会比显式的循环更高效,因为编译器可以跳过那些潜在的边界检查。
在看完这条条款后,一个 C 风格的,用于求向量 vector
前五个偶数项的平方和的循环:
#![allow(unused)] fn main() { let values: Vec<u64> = vec![1, 1, 2, 3, 5 /* ... */]; let mut even_sum_squares = 0; let mut even_count = 0; for i in 0..values.len() { if values[i] % 2 != 0 { continue; } even_sum_squares += values[i] * values[i]; even_count += 1; if even_count == 5 { break; } } }
应该能被很自然地表达为函数式表达式的样子:
#![allow(unused)] fn main() { let even_sum_squares: u64 = values .iter() .filter(|x| *x % 2 == 0) .take(5) .map(|x| x * x) .sum(); }
像这样的迭代器转换表达式可以粗略被分解为三个部分:
- 一个源迭代器,来自于一个实现了 Rust 的任意一种
iterator
traits 的类型实例; - 一个由若干迭代器转换组成的操作序列;
- 一个最终的消费者方法
consumer method
将迭代的结果合并为最后的值;
其中前两部分有效地将循环的内部功能从循环体内移到 for
表达式中,最后一部分则完全消除了对 for
语句的依赖。
译者注:这里的原文表达的意思有点绕。不熟悉这种编程范式的朋友可以尝试这么理解:
for
循环版本代码中的逻辑,通过filter().take().map()
这一系列调用“表达”了。而iter()
和sum()
相当于是这个循环的“启动”和“结束”,对应for
循环版本中的for
和even_sum_squares += ...
。于是我们最终摆脱了对for
这个表达式的使用。
Iterator Traits 迭代器 Traits
标准库中这个核心的 Iterator trait 有一个十分简单的接口:一个 next 方法用于生成 Some
元素,直到没法再继续生成(返回 None
)。迭代过程中生成元素的类型通过 trait 的关联类型 Item
定义。
那些允许用户对其中元素进行遍历的容器——在其他语言中被称为可迭代对象 iterable
)——实现了 IntoIterator trait;trait 中定义的 into_iter 方法会消耗掉 Self
并返回一个 Iterator
。编译器会自动对以下形式的表达式自动应用这个 trait:
#![allow(unused)] fn main() { for item in collection { // body } }
并高效地转换成类似如下的代码:
#![allow(unused)] fn main() { let mut iter = collection.into_iter(); loop { let item: Thing = match iter.next() { Some(item) => item, None => break, }; // body } }
或者更简洁惯用的形式:
#![allow(unused)] fn main() { let mut iter = collection.into_iter(); while let Some(item) = iter.next() { // body } }
为了让不同场景下的使用更流畅,对于任何的 Iterator
都有实现 IntoIterator
,而实现就是返回 self
;毕竟要把 Iterator
转成 Iterator
不费吹灰之力!
这种初始形式生成的是一个消耗型迭代器,在创建的时候会把容器消耗掉。
#![allow(unused)] fn main() { let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)]; for item in collection { println!("Consumed item {item:?}"); } }
任何在迭代后完成后对容器进行的操作都会失败:
#![allow(unused)] fn main() { println!("Collection = {collection:?}"); }
error[E0382]: borrow of moved value: `collection`
--> src/main.rs:171:28
|
163 | let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)];
| ---------- move occurs because `collection` has type `Vec<Thing>`,
| which does not implement the `Copy` trait
164 | for item in collection {
| ---------- `collection` moved due to this implicit call to
| `.into_iter()`
...
171 | println!("Collection = {collection:?}");
| ^^^^^^^^^^^^^^ value borrowed here after move
|
note: `into_iter` takes ownership of the receiver `self`, which moves
`collection`
虽然容易理解,但这种消耗整个容器的行为通常不是我们想要的;我们需要对被迭代的元素进行某种借用。
为了确保展示内容的清晰,这里的例子使用了一个没有实现 Copy
(第 10 条) 的 Thing
类型,因为 Copy
会掩盖掉所有权(第 15 条)的问题——编译器会偷偷四处拷贝:
#![allow(unused)] fn main() { // Deliberately not `Copy` // 特地不实现 `Copy` #[derive(Clone, Debug, Eq, PartialEq)] struct Thing(u64); let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)]; }
如果被迭代的集合以 &
作为前缀:
#![allow(unused)] fn main() { for item in &collection { println!("{}", item.0); } println!("collection still around {collection:?}"); }
那么编译器会寻找 &Collection
类型的 IntoIterator 实现。正确设计的集合都会提供这样的一个实现;这个实现仍然会消耗 Self
,不过此时 Self
是 &Collection
类型而不是 Collection
,并且对应的关联类型 Item
将会是一个引用类型 &Thing
。
这使得在迭代之后容器仍然保持完整,等效的扩展代码如下:
#![allow(unused)] fn main() { let mut iter = (&collection).into_iter(); while let Some(item) = iter.next() { println!("{}", item.0); } }
在可以遍历可变引用的场景下1,for item in &mut collection
也有类似的模式:编译器寻找 &mut Collection
的 IntoIterator
trait,此时关联类型 Item
是 &mut Thing
类型。
按照惯例,标准库容器会提供一个 iter()
方法返回对底层元素的引用,以及如果可以的话,一个等效的 iter_mut()
方法,其行为与上面提到的相同。这些方法可以在 for
循环中使用,但在用作迭代器转换的场景下有更明显的好处:
#![allow(unused)] fn main() { let result: u64 = (&collection).into_iter().map(|thing| thing.0).sum(); }
可以变成:
#![allow(unused)] fn main() { let result: u64 = collection.iter().map(|thing| thing.0).sum(); }
Iterator Transforms 迭代器转换
Iterator trait 只有一个必须的 next 方法,但也提供了大量的在迭代器上执行转换计算的默认方法实现(第 13 条)。
其中一些转换会影响到整个迭代的过程:
- take(n):限制迭代器最多只能产生
n
个元素。 - skip(n):跳过迭代器的前
n
个元素。 - step_by(n):转换迭代器,让它每隔
n
个元素生成一个元素。 - chain(other):将两个迭代器粘合在一起构造一个组合迭代器,它会在遍历完第一个迭代器的内容后开始遍历第二个迭代器。
- cycle():将迭代器转换为一个永久循环的迭代器,当遍历到头后再次从头开始遍历。(迭代器需要实现
Clone
来支持这个方法。) - rev():反转迭代器的方向。(迭代器需要实现 DoubleEndedIterator trait,这个 trait 有一个额外的 next_back 方法。)
其他的转换会影响到 Iterator
对应的 Item
的性质/属性:
- map(|item| {...}):重复应用闭包依次转换迭代的元素。这是最通用的转换,这个列表中的以下若干个方法都可以用
map
等价地实现。 - clone():产生原始迭代器中元素的一个克隆;这个方法在遍历
&Item
这种引用的时候十分有用。(显然这需要底层类型Item
实现Clone
。) - copied():产生原始迭代器中元素的一个拷贝;这个方法在遍历
&Item
这种引用的时候十分有用。(显然这需要底层类型Item
实现Copy
,如果是这样的话,那么有可能会比cloned()
要快一些。) - enumerate():将迭代器转换成迭代
(usize, Item)
值对的迭代器,提供了迭代器中元素的索引。 - zip(it):将一个迭代器和另一个迭代器联结,构建一个组合迭代器,用于产生值对,每一个值对里面的元素分别来自于两个迭代器,组合迭代器会一直产生元素直到元素较少的迭代器迭代完毕。
还有一些转换可以对 Iterator
产生的 Item
进行过滤:
- filter(|item| {...}):对每个元素的引用应用一个返回布尔值的闭包,来判断这个元素是否应该要被迭代器提供。
- take_while():基于谓词提供迭代器初始区间中的元素。是
skip_while
的镜像。 - skip_while():基于谓词提供迭代器末端区间中的元素。是
take_while
的镜像。
flatten 方法用于处理元素类型还是迭代器类型的迭代器,用于展平结果。单就这个方法来看这好像没有什么用,但是我们发现当 Option 和 Result 类型用作迭代器的时候,这就很有用了:这两个类型会产生零(None
,Err(e)
)或者一(Some(v)
,Ok(v)
)。这意味着 flatten
一个 Option
或者 Result
的流是一个提取其中有效值的简单方式。
从整体上看,上面提到的方法允许对迭代器进行转换,以便迭代器精确地生成大多数情况下所需要的元素序列。
Iterator Consumers 迭代器消耗者
前面两节介绍了如何获取迭代器,以及如何对其进行合适的转换来进行精准的迭代。这种目的明确的迭代也可以通过显式循环的方式来实现:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; for value in values.iter().filter(|x| *x % 2 == 0).take(5) { even_sum_squares += value * value; } }
但同时 Iterator 提供的方法集里面还包含了许多可以消费整个迭代以获得结果的方法,从而可以让我们消除显式的 for
循环。
这些方法中最常见的是 for_each(|item| {...}),它会对 Iterator
产生的每个元素应用一个闭包。这可以完成绝大多数显式 for
循环可以完成的工作(除了少量例外,我们会在后面的部分中提到)。但它的普适性也让它用起来有点尴尬——闭包需要捕获对外部状态的可变引用才能“返回”结果:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; values .iter() .filter(|x| *x % 2 == 0) .take(5) .for_each(|value| { // closure needs a mutable reference to state elsewhere even_sum_squares += value * value; }); }
但是如果 for
循环的循环体跟一些常见的模式之一匹配,那么就有更特化的方法来“消费”迭代器,这些方法往往更清晰、简短且符合惯用法。
这些模式包括从一个集合中生成一个值的便捷方法:
- sum():对数值(整型或浮点型)类型的集合求和。
- product():将数值类型集合中的元素相乘。
- min():使用
Item
的Ord
实现,寻找集合中的最小值。 - max():使用
Item
的Ord
实现,寻找集合中的最大值。 - min_by(f):使用用户提供的比较函数
f
,寻找集合中的最小值。 - max_by(f):使用用户提供的比较函数
f
,寻找集合中的最大值。 - reduce(f):通过在每次迭代中执行闭包来计算
Item
的求和值,闭包会接收截止目前的求和值和当前遍历的元素作为参数。这是一个更通用的操作,包含(可以用于实现)前面提到的一些方法。 - fold(f):通过在每次迭代中执行闭包来计算任意类型(而不限于
Iterator::Item
类型)的求和值,闭包会接收截止目前的求和值和当前遍历的元素作为参数。这是reduce
的更泛化的版本。 - scan(init, f):通过在每次迭代中执行闭包来计算某个特定类型的求和值,闭包会接收某种内部状态的可变引用和当前遍历的元素作为参数。这是一个稍特别的
reduce
的泛化版本。
还有一些方法可以用从集合中选择一个值:
- find(p):查找第一个满足谓词的元素。
- position(p):也是查找第一个满足谓词的元素,不过返回元素对应的索引。
- nth(n):如果有,返回迭代的第
n
的元素。
还有一些方法可以针对集合中的每个元素进行测试:
对于上面两个方法之一,如果遍历过程中找到一个反例,迭代都会提前终止。
有一些方法允许闭包在对元素操作的时候返回失败。在这种场景下,如果闭包对某个元素的操作返回失败,迭代将终止,并返回第一个导致失败产生的操作:
- try_for_each(f):行为类似于
for_each
,但闭包操作可能会失败。 - try_fold(f):行为类似于
fold
,但闭包操作可能会返回失败。 - try_find(f):行为类似于
find
,但闭包操作可能会返回失败。
最后,还有一些方法可以把所有迭代的元素累积到新的集合中。当中最重要的就是 collect() 方法,它可以用于创建任意一种实现了 FromIterator 特征的集合类型。
FromIterator
traits 在所有的标准库集合类型上都有实现(Vec,HashMap,BTreeSet 等),但这种普遍性也意味着你总是要显式地指定集合的类型,否则编译器无法推断出你想要组装一个(比如说)Vec<i32>
还是 HashSet<i32>
:
#![allow(unused)] fn main() { use std::collections::HashSet; // Build collections of even numbers. Type must be specified, because // the expression is the same for either type. // 创建一个只有偶数的集合。你必须指定集合的类型,因为对于两个集合来说构造的表达式一模一样。 let myvec: Vec<i32> = (0..10).into_iter().filter(|x| x % 2 == 0).collect(); let h: HashSet<i32> = (0..10).into_iter().filter(|x| x % 2 == 0).collect(); }
这个例子也展示了如何使用范围表达式 range expressions 来生成要迭代的初始数据。
还有一些其他(更加晦涩)的集合生成方法:
- unzip():将一个 pair 的迭代器拆分到两个集合中。
- partition(p):通过应用谓词到元素上,把一个迭代器迭代的内容切分到两个集合中。
本条款涉及了很多 Iterator
相关的方法,但这仅仅是所有可用方法的子集;要想了解更多,可以参考文档 iterator documentation 或者 Programming Rust 第二版(O'Reilly)的 15 章,书里更详细地介绍了各种可能的用法。
丰富的迭代器转换方法集合在日常中就这么触手可及,这些方法可以让代码变得更符合惯用法、更紧凑,同时更好地表达代码的意图。
将循环表达式转换成迭代器转换还有助于生成更高效的代码。为了安全起见,Rust 在访问诸如 vector 和切片这种连续的容器时会对访问执行边界检查;任何尝试访问越界的元素的操作都会导致 panic 而不是访问无效的数据。传统的访问容器值的方法(如 values[i]
)可能会受到这些运行时检查的介入,而一个逐步提供值的迭代器可以被认为是不会越界的。
但是,与迭代器转换等效的传统循环表达式也可能不会受到额外的边界检查的影响。Rust 的编译器和优化器很擅长分析切片访问的上下文代码来决定跳过边界检查是否安全可行的;Sergey "Shnatsel" Davidoff 的文章 2023 article 探讨了其中的细节。
从 Result 值构建集合
上一节我们介绍了如何用 collect()
从迭代器构建集合,但同时 collect()
对于处理 Result
值的场景也有特别有用的特性。
考虑一个例子,将一个 u64
的 vector 转成字节 u8
,并期望它们都满足条件:
#![allow(unused)] fn main() { // In the 2021 edition of Rust, `TryFrom` is in the prelude, so this // `use` statement is no longer needed. use std::convert::TryFrom; let inputs: Vec<i64> = vec![0, 1, 2, 3, 4]; let result: Vec<u8> = inputs .into_iter() .map(|v| <u8>::try_from(v).unwrap()) .collect(); }
这是可以的,直到有一些意外的输入:
#![allow(unused)] fn main() { let inputs: Vec<i64> = vec![0, 1, 2, 3, 4, 512]; }
这将导致运行时的失败:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value:
TryFromIntError(())', iterators/src/main.rs:266:36
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
根据 第 3 条 的建议,我们希望保留 Result
类型并且使用 ?
运算符让任何一个失败的操作作为调用代码的问题返回。修改让代码返回 Result
达不到我们的目的:
#![allow(unused)] fn main() { let result: Vec<Result<u8, _>> = inputs.into_iter().map(|v| <u8>::try_from(v)).collect(); // Now what? Still need to iterate to extract results and detect errors. }
但是还有另一种 collect()
的版本,可以组装一个持有 Vec
的 Result
,而不是一个装着 Result
的 Vec
。
想要强制使用这个版本就要用到 turbofish 语法(::<Result<Vec<_>, _>>
):
#![allow(unused)] fn main() { let result: Vec<u8> = inputs .into_iter() .map(|v| <u8>::try_from(v)) .collect::<Result<Vec<_>, _>>()?; }
将其与问号运算符结合使用实现了一种有用的行为:
- 如果迭代遇到了错误,错误的值会返回到调用方,并且迭代终止。
- 如果没有遇到错误,则余下代码能处理返回一个合理、类型正确的值的集合。
转换显式循环
这个条款的目的是让你相信很多显式循环都可以被当做基于迭代器的转换。这对于不习惯的程序员来说可能会让人觉得不太自然,所以我们来一步步完成一次转换吧。
我们从一个很 C 语言风格的循环开始,对一个 vector 的头五个偶数项求平方和:
let mut even_sum_squares = 0;
let mut even_count = 0;
for i in 0..values.len() {
if values[i] % 2 != 0 {
continue;
}
even_sum_squares += values[i] * values[i];
even_count += 1;
if even_count == 5 {
break;
}
}
第一步是把 vector 的索引换成 for-each 循环的迭代器:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; let mut even_count = 0; for value in values.iter() { if value % 2 != 0 { continue; } even_sum_squares += value * value; even_count += 1; if even_count == 5 { break; } } }
使用 continue
来跳过某些元素的原始代码分支可以用 filter()
来很自然地表达:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; let mut even_count = 0; for value in values.iter().filter(|x| *x % 2 == 0) { even_sum_squares += value * value; even_count += 1; if even_count == 5 { break; } } }
接下来,一旦我们有 5 个元素了,我们就提前退出循环,这里用 take(5)
:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; for value in values.iter().filter(|x| *x % 2 == 0).take(5) { even_sum_squares += value * value; } }
每次迭代我们都只要元素的平方值 value * value
,这就是 map()
理想的使用场景:
#![allow(unused)] fn main() { let mut even_sum_squares = 0; for val_sqr in values.iter().filter(|x| *x % 2 == 0).take(5).map(|x| x * x) { even_sum_squares += val_sqr; } }
对原始循环的重构最后成为了 sum()
方法大锤下一颗完美的钉子:
#![allow(unused)] fn main() { let even_sum_squares: u64 = values .iter() .filter(|x| *x % 2 == 0) .take(5) .map(|x| x * x) .sum(); }
什么时候显式循环更好
这条条款强调了迭代器转换的优势,特别是在简洁性和清晰度方面。那么什么时候迭代器转换不是那么适合或者不符合惯用法呢?
-
如果循环体很大、或者功能很多,那么保留在一个显式的循环体里面会比把逻辑压缩到闭包中更合理。
-
如果循环体包含很多会导致功能提前终止的错误条件,最好还是把它们保留在显式的循环体中——
try...()
之类的方法也不会帮上很多忙。但是,collect()
能把一个值类型为Result
的集合转换成一个持有集合类型的Result
类型的能力,在配合?
运算符的场景下还是可以进行错误条件的处理。 -
如果性能至关重要,包含闭包的迭代器转换理应跟显式的代码一样快 just as fast。但如果代码中一个核心的循环很重要,测量不同的实现方法并进行适当的调优。
- 请确保你的测试能反映实际的性能——编译器的优化可能会对测试数据给出过于乐观的结果(如 第 30 条 所述)。
- Godbolt compiler explorer 是一个了不得的工具,你可以看到编译器都生成了什么。
最重要的,如果转换的过程是强制的或者生搬硬套的,那么就不要把显式循环转成迭代器转换了。这不过是一种编程风格口味的取向而已——但注意到,随着你对函数式的风格越来越熟悉,你的口味也很可能会发生变化。
1:事实上,迭代器可以更通用——在直到结束之前不停地产生下一个元素,这种想法不必跟某种容器强关联。
2:如果对容器元素的修改可能会导致容器内部的一些约束被打破,那么这个方法就不能提供了。比如说:导致元素的 Hash 值发生变化的修改,就可能会导致 HashMap
内部数据结构的失效。
特征(Traits)
Rust 类型系统的第二个核心支柱是特征(traits
)的使用,它允许编码在不同类型之间通用的行为。特征在其他语言中大致等同于接口类型,但它们也与 Rust 的泛型(第 12 条)相关联,允许在不产生运行时开销的情况下重用接口。
本章中的条目描述了 Rust 编译器和 Rust 工具链提供的标准特征,并提供了关于如何设计和使用特征编码行为的建议。
第 10 条:熟悉标准库的 traits
Rust 通过一系列详细的、描述类型行为的标准库 traits,把类型系统自身的关键行为特征编码到了类型系统本身上(参考 第 2 条)。
其中的许多 traits 对于 C++ 程序员来说会感觉很熟悉,类比于拷贝构造函数、析构函数、相等性判断和复制运算符等等。
和在 C++ 中一样,为用户的自定义类型实现标准库当中的多数 traits 是个不错的选择;Rust 编译器会在用户的自定义类型需要某些 traits,而类型又缺少对应实现的时候给出有用的错误信息。
实现这么多的 traits 看起来有点吓人,但当中绝大多数的 traits 都可以通过 derive
macros 自动应用到用户的自定义类型上。 derive
宏会基于类型生成相应的实现(例如:对于 struct
的字段逐一进行 Eq
判断);这通常要求结构体的组成部分也实现了对应的 trait。自动生成的实现通常就是你会需要的,但也有例外,我们会在后面讨论具体 trait 的时候提到。
使用 derive
宏会让类型的定义看着像这样:
#![allow(unused)] fn main() { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] enum MyBooleanOption { Off, On, } }
这里就触发了对于 8 个不同的 trait 实现的自动生成。
这种细粒度的行为规范一开始可能会让人觉得不适应,但熟悉最常见的标准库 traits 非常重要,这样才能快速了解到一个类型的定义中给它赋予的各种可用行为。
常见的标准库 traits
这一节讨论最常遇到的标准库 traits。以下是对这些 traits 粗略的一句话总结:
- Clone:需要时,该类型的实例可以通过执行用户定义的代码来创建自身的一个副本。
- Copy:如果编译器对类型实例的内存表示数据执行按比特拷贝,会得到一个有效的新副本。
- Default:可以使用合理的默认值创建该类型的实例。
- PartialEq:该类型的实例之间存在偏序关系 partial equivalence relation——任意两个实例可以明确地进行比较,但
x == x
并不总为真。 - Eq:该类型的实例之间存在等价关系 equivalence relation,——任意两个实例可以明确地进行比较,且
x == x
总为真。 - PartialOrd:该类型的某些实例之间可以进行比较和排序。
- Ord:该类型的所有实例之间可以进行比较和排序。
- Hash:该类型的实例可以在需要的时候生成一个稳定的散列值(哈希值)。
- Debug:该类型的实例可以对程序员显示(调试信息)。
- Display:该类型的实例可以对用户显示。
除了 Display
(因为与 Debug
功能有重叠)以外,这些 traits 都可以通过 derive
为用户自定义类型派生。然后有些时候手动实现——或者说不实现这些 traits 可能是个更好的选择。
下面的小节会更详细地讨论这些常见的 traits。
Clone
Clone
trait 表示可以通过调用 clone() 函数来创建一个对象的新副本。这跟 C++ 的拷贝函数大致相同,但是表意更加明确:编译器不会默默地调用这个函数(下一节会更详细地说明)。
如果一个类型的所有字段都实现了 Clone
,那么可以通过 derive
为这个类型自动派生 Clone
。derive
派生获得的实现会对类型的每个成员依次执行克隆操作;再说一次,这跟 C++ 的构造函数大致相同。这个 trait 需要显式地启用(通过添加 #[derive(Clone)]
),这与 C++ 中需要显式禁止(MyType(const MyType&) = delete;
)恰恰相反。
派生 Clone
是一个常见且有用的操作,以至于去考虑是否应该实现 Clone
、评估默认派生的实现是否符合实际要求变得更加有意思了。
- 如果一个类型的实例持有某些资源的唯一访问权(例如 RAII 类型 第 11 条),或者有其他原因限制拷贝(例如对象持有了加密密钥),那么你不应该实现
Clone
。 - 如果类型的某些部分不是
Clone
的,那么你也无法实现Clone
:- 字段是可变引用(
&mut T
),因为借用检查器(第 15 条)在同一时刻只允许一个对同一资源的可变引用的存在。 - 属于上述类别的标准库类型,例如 MutexGuard(体现唯一访问权)或者 Mutex(出于线程安全限制拷贝)。
- 字段是可变引用(
- 如果类型的任何部分不能通过(递归的)字段拷贝,或者有生命周期相关的簿记动作需要执行,那么你需要手动实现
Clone
。举个例子,考虑一个在运行时跟踪所有实例数量的类型,你需要手动实现Clone
来保证计数器的准确。
Copy
Copy
trait 有一个简单的声明:
#![allow(unused)] fn main() { pub trait Copy: Clone { } }
这个 trait 里面没有方法,这意味这是一个标记 trait marker trait
(如同第 2 条中所述):这是用来表示对类型的某些约束,而这种约束本身没有在类型系统中表达。
对于 Copy
而言,这个标记表示,对类型实例的内存进行按比特的拷贝,可以得到一个正确的新对象。实际上,这个 trait 是一个标记,表示一个类型是 plain old data(POD)类型。
这也意味着 Clone
特性可能会有点令人困惑:尽管实现了 Copy
的类型需要实现 Clone
,但是当一个实例被拷贝的时候,clone()
方法并没有被调用——编译器在不使用任何用户定义代码的情况下生成了一个新的对象。
跟其他用户自定义的标记 trait(第 2 条)相比,Copy
对编译器有着比作为 trait bound 以外更特殊的意义(和其他 std::marker
中的其他几个 trait 一样)——它使编译器在处理类型的时候从移动语意变成拷贝语义。
在移动语义的场景下,赋值运算符会把运算符右侧的内容,拿走并赋值给左边:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] struct KeyId(u32); let k = KeyId(42); let k2 = k; // value moves out of k into k2 println!("k = {k:?}"); }
error[E0382]: borrow of moved value: `k`
--> src/main.rs:60:23
|
58 | let k = KeyId(42);
| - move occurs because `k` has type `main::KeyId`, which does
| not implement the `Copy` trait
59 | let k2 = k; // value moves out of k into k2
| - value moved here
60 | println!("k = {k:?}");
| ^^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl`
help: consider cloning the value if the performance cost is acceptable
|
59 | let k2 = k.clone(); // value moves out of k into k2
| ++++++++
而使用拷贝语义的话,被用于赋值的变量在赋值过后依然存在:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy)] struct KeyId(u32); let k = KeyId(42); let k2 = k; // value bitwise copied from k to k2 println!("k = {k:?}"); }
这使得 Copy
成为了最需要注意的 trait 之一:它从根本上改变了赋值的行为——包括方法调用时候的传参。
在这方面,这跟 C++ 的拷贝构造函数又有相似了,但是值得强调的一个关键区别在于:在 Rust 里面没有办法让编译器隐式调用用户定义的代码——要调用的话必须显式指定(比如使用 .clone()
),或者让编译器执行并非由用户定义的代码(按比特位的拷贝动作)。
因为 Copy
具有 Clone
trait 的特性,所以是可以 .clone()
任意一个满足 Copy
的对象的。但是这不是一个好的主意:按位拷贝总是会比调用 trait 方法要快。Clippy(第 29 条)会提示你:
#![allow(unused)] fn main() { let k3 = k.clone(); }
warning: using `clone` on type `KeyId` which implements the `Copy` trait
--> src/main.rs:79:14
|
79 | let k3 = k.clone();
| ^^^^^^^^^ help: try removing the `clone` call: `k`
|
跟讨论 Clone
的时候类似,何时应该或不应该实现 Copy
也是值得探讨的:
- 显而易见:如果按比特位拷贝不能生成有效的新对象,不要实现
Copy
。如果Clone
需要手动而不是通过derive
实现,那么很有可能是这种情况。 - 如果你的类型比较大,实现
Copy
可能是个坏主意。Copy
的基本约定是按比特位拷贝是合法的,然而这也通常隐含一个假设:拷贝是很快速的。如果你的类不符合这样的要求,不实现Copy
可以阻止意外的耗时拷贝的发生。 - 如果你的类型中某些部分不是
Copy
的那么你也不能为类型实现Copy
。 - 如果你的类型中所有部分都是
Copy
的,那么通常也值得为你的类型 derive 一个Copy
。编译器有一个默认的提示项 missing_copy_implementations 来指出这种情况。
Default
Default
trait 通过 default() 方法定义了一个默认构造函数。如果用户定义类型的内含类型都有 Default
的实现,那么类型可以通过 derive
来实现这个 trait;如果内含类型并非都实现了 Default
,那么用户需要手动为类型实现 这个 trait。还是跟 C++ 做比较:在 Rust 中需要显式地定义默认构造函数——编译器不会自动帮你创建。
enum
类型也可以通过 derive
实现 Default
trait,只要给编译器提供一个 #[default]
属性来提示编译器哪一个分支是默认值即可:
#![allow(unused)] fn main() { #[derive(Default)] enum IceCreamFlavor { Chocolate, Strawberry, #[default] Vanilla, } }
Default
trait 最有用的一个场景是和结构体更新语法 struct update syntax 组合使用。这个语法允许 struct
实例通过从同一种 struct
的实例中拷贝或者移动内容来初始化自身尚未尚未显式初始化的字段。要被拷贝的模板在初始化结束的时候给出,放在 ..
之后,Default
trait 提供了一个理想的模板供使用:
#![allow(unused)] fn main() { #[derive(Default)] struct Color { red: u8, green: u8, blue: u8, alpha: u8, } let c = Color { red: 128, ..Default::default() }; }
这使得初始化具有大量字段,但仅有部分字段不是使用默认值的结构体变得更加容易。(构造器模式,第 7 条,或许对于这种场景也是适用的)。
PartialEq 和 Eq
PartialEq
和 Eq
trait 允许你为用户定义的类型定义相等性。这些 trait 尤其重要,因为如果这些 trait 被定义了,编译器会自动在相等性(==
)判断的时候调用相关逻辑,类比于 C++ 中的 operator==
。derive
来的实现会执行逐字段的相等性判断。
Eq
版本只是一个标记 trait,用于扩展 PartialEq
,它添加了自反性:任何声明了 Eq
的类型 T
,对于任意的实例 x: T
都应该保证有 x == x
。
这很奇怪,你会立即提出这个问题:什么时候 x == x
是不成立的?对相等性的这种拆分主要跟浮点数 floating point numbers 有关,尤其是涉及到“不是数字”这个 NaN 值(对应 Rust 中的 f32:NAN
/f64:NAN
)。浮点数的标准要求任何东西不会等于 NaN,包括 Nan 自身;PartialEq
trait 的存在就是这种要求的连锁反应。
对于没有任何浮点数相关的特性的用户自定义类型,你应该在实现 PartialEq
的同时也实现 Eq
。如果你要把类型当作 HashMap 类型的 key,完整的 Eq
trait 也是需要实现的(同样还有 Hash
trait)。
如果类型包含一些不影响区分相等性的字段(如内部缓存或者其他类型的性能优化),则应该手动实现 PartialEq
。如果 Eq
也被定义了,实现也将用于 Eq
,因为 Eq
只是一个标记 trait,它内部并没有任何方法。
排序 trait PartialOrd
和 Ord
允许比较同一类型的两个实现,并返回 Less
、Greater
或 Equal
之一。这两个 trait 需要对应的相等性 trait 有对应的实现(PartialOrd
要求 PartialEq
; Ord
要求 Eq
),并且对应的两个必须逻辑自洽(手动实现的时候尤其要注意)。
跟相等性 trait 一样,比较 traits 也很重要,因为编译器会在比较运算中使用到它们(<
,>
,<=
,>=
)。
derive
产生的默认实现会按照字段(或 enum
的不同 variant)定义的顺序,按照字典序进行比较。如果这不符合预期结果则需要手动实现特征(或者对字段进行重新排序)。
跟 PartialEq
不同的是,PartialOrd
trait 确实对应各种真实发生的场景。比如说,它可以用于表示集合之间的子集关系(#footnote-2):{1, 2}
是 {1, 2, 4}
的子集,但 {1, 3}
不是 {2, 4}
的子集,反之亦然。
但是,即使偏序关系准确地描述了你的类型的行为,要小心仅仅实现了 PartialOrd
而没有实现 Ord
的场景(这种情况很少见,它与 第 2 条 中将行为编码到类型系统中的建议相违背)——它可能会导致令人惊讶的结果:
#![allow(unused)] fn main() { // Inherit the `PartialOrd` behavior from `f32`. // 从 `f32` 继承 `PartialOrd` #[derive(PartialOrd, PartialEq)] struct Oddity(f32); // Input data with NaN values is likely to give unexpected results. // NaN 值的输入可能会给出意想不到的结果。 let x = Oddity(f32::NAN); let y = Oddity(f32::NAN); // A self-comparison looks like it should always be true, but it may not be. // 一个看起来应该总是成立的自我比较,但也可能不成立。 if x <= x { println!("This line doesn't get executed!"); } // Programmers are also unlikely to write code that covers all possible // comparison arms; if the types involved implemented `Ord`, then the // second two arms could be combined. // 程序员也不太可能写出覆盖所有可能的比较分支;如果涉及的类型实现了 `Ord`,那么后两种情况可以合并。 if x <= y { println!("y is bigger"); // Not hit. } else if y < x { println!("x is bigger"); // Not hit. } else { println!("Neither is bigger"); } }
Hash
Hash
trait 用于给某个对象生成一个大概率与其他对象不相同的值。这个哈希值通常用于一些基于哈希桶的数据结构,比如 HashMap 和 HashSet;因此,这些数据结构中的 key 类型必须实现 Hash
(和 Eq
)。
反过来说,“相同”的项(以 Eq
来说)必须产生一样的的哈希值:如果 x y
(通过 Eq
),那么 hash(x) hash(y)
必须始终为真。**如果你手动实现了 Eq
,那么要检查确认你是否也需要手动实现 Hash
**以满足上述要求。
Debug 和 Display
Debug
和 Display
trait 允许类型定义它在输出中应该怎么显示,这包括常规显示({}
格式化参数)和调试({:?}
格式化参数)目的,这大致类似于 C++ 中对 iostream
的 operator<<
重载。
这两个 trait 的设计意图差异不仅仅在于格式化占位符的不同,还包括:
Debug
可以自动派生derive
获得,而Display
只能手动实现。Debug
的输出格式在不同的 Rust 版本下可能会不一样。如果输出需要被其他的代码做解析,那么使用Display
。Debug
是面向程序员的,Display
是面向用户的。一个有助于理解这个场景的头脑风暴是:如果程序被本地化到程序作者不懂的语言会发生什么——如果显示的内容应该被翻译,那么使用Display
是合适的,否则就应该使用Debug
。
通常来说,给你的类型添加一个自动生成的 Debug
实现是个不错的选择,除非类型里面包含一些敏感信息(个人详细信息、密码相关的内容等)。为了更容易遵守这个规则,Rust 编译器有一个提示 [missing_debug_implementations ] 可以指出没有实现 Debug
的类型。这个提示默认是禁用的,但可以通过以下任一方式在你的代码中启用:
#![allow(unused)] #![warn(missing_debug_implementations)] fn main() { }
#![allow(unused)] #![deny(missing_debug_implementations)] fn main() { }
如果自动生成的 Debug
实现输出了太多细节信息,那么或许手动实现并且汇总一下类型里面的内容会更好一点。
如果你的类型要作为文本向最终用户显示,那么就实现 Display
。
其他条款描述的标准库 trait
除了前面章节描述的常见 trait,标准库还包含其他没那么常见的 trait。在这些之中,以下是最重要的,但它们都在其他条款中有所介绍,因此这里不会详细地介绍:
- Fn,FnOnce 和 FnMut:实现了这些 trait 的对象代表它们是可以被调用的闭包。见第 2 条。
- Error:实现了这个 trait 的对象可以向用户或程序员呈现错误信息,并且可能包含嵌套的子错误信息。见第 4 条。
- Drop:实现了这个 trait 的对象会在它们被销毁的时候执行动作,这对于 RAII 模式来说是至关重要的。见第 11 条。
- From 和 TryFrom:实现了这些 trait 的对象,可以自动从别的类型的实例中转换过来,但后一个 trait 表示转换可能会失败。见第 5 条。
- Deref 和 DerefMut:实现了这些 trait 的对象是类似于指针的对象,它们可以被解引用以获得对内部对象的访问。见第 8 条。
- Iterator 及相关:实现了这些 trait 的对象表示可以它是一个可以被迭代的集合。见第 9 条。
- Send:实现了这个 trait 的对象可以在多个线程之间安全地传输。见第 17 条。
- Sync:实现了这个 trait 的对象可以在多个线程之间被安全地引用。见第 17 条。
这些 trait 都不能被直接 derive
获得。
运算符重载
标准库 trait 的最后一个类别跟运算符重载有关,Rust 允许用户自定义类型通过实现 std::ops 模块里面的标准库 trait,对内置的单目和双目运算符进行重载。这些 trait 不能通过 derive
获得,通常仅用于表示“代数”类型的对象,对于这些类型来说这些运算符都有很自然的解释。
然而,C++ 中的经验表明,最好避免对不相关的类型进行运算符重载,因为它通常会导致代码难以维护,也可能会出现一些意外的性能问题(比如,一个 x + y
操作调用了一个开销为 O(N) 方法)。
为了遵循最小惊讶原则,如果你实现了任何一个运算符重载,那么你应该实现一系列相关的运算符的重载。打个比方,如果 x + y
有一个重载(Add),并且 -y
(Neg)也有,那么你应该实现 x - y
(Sub)并确保它给出和 x + (-y)
一样的结果。
传递给运算符重载 trait 方法的对象会被移动掉,这意味着非 Copy
的类型默认会被消耗掉。为 &'a MyType
实现这些 trait 可以帮助解决这个问题,但需要更多的样板代码来覆盖所有的可能性(比如,对于双目运算符,入参类型可以是引用/非引用,就有 4 = 2 × 2 种可能性)。
总结
这个条款已经涵盖了很多方便,下面按序给出了一些表格,总结了我们谈论到的标准库 trait。首先,表格 2-1 涵盖了这个条款深入讲述过的 trait,除了 Display
以外,所有这些 trait 都能通过 derive
获得实现。
表格 2-1. 常见的标准库 trait
Trait | 编译器使用 | 约束 | 方法 |
---|---|---|---|
Clone | clone | ||
Copy | let y = x; | Clone | 标记 trait |
Default | default | ||
PartialEq | x == y | eq | |
Eq | x == y | PartialEq | 标记 trait |
PartialOrd | x < y , x <= y , … | PartialEq | partial_cmp |
Ord | x < y , x <= y , … | Eq + PartialOrd | cmp |
Hash | hash | ||
Debug | format!("{:?}", x) | fmt | |
Display | format!("{}", x) | fmt |
运算符重载相关的 trait 在表格 2-2 (#footnote-3) 中总结了。它们都不能通过 derive
获得。
表格 2-2. 运算符重载 trait
Trait | 编译器使用 | 约束 | Methods |
---|---|---|---|
Add | x + y | add | |
AddAssign | x += y | add_assign | |
BitAnd | x & y | bitand | |
BitAndAssign | x &= y | bitand_assign | |
BitOr | x ⎮ y | bitor | |
BitOrAssign | x ⎮= y | bitor_assign | |
BitXor | x ^ y | bitxor | |
BitXorAssign | x ^= y | bitxor_assign | |
Div | x / y | div | |
DivAssign | x /= y | div_assign | |
Mul | x * y | mul | |
MulAssign | x *= y | mul_assign | |
Neg | -x | neg | |
Not | !x | not | |
Rem | x % y | rem | |
RemAssign | x %= y | rem_assign | |
Shl | x << y | shl | |
ShlAssign | x <<= y | shl_assign | |
Shr | x >> y | shr | |
ShrAssign | x >>= y | shr_assign | |
Sub | x - y | sub | |
SubAssign | x -= y | sub_assign |
为完整起见,在其他条款中提及的 trait 在表格 2-3 中涵盖了。这些 trait 都不能通过 derive
获得(但是 Send
和 Sync
可能由编译器自动实现)。
表格 2-3. 在其他条款中提及的 trait
Trait | 条款 | 编译器使用 | 约束 | Methods |
---|---|---|---|---|
Fn | 第 2 条 | x(a) | FnMut | call |
FnMut | 第 2 条 | x(a) | FnOnce | call_mut |
FnOnce | 第 2 条 | x(a) | call_once | |
Error | 第 4 条 | Display + Debug | source | |
From | 第 5 条 | from | ||
TryFrom | 第 5 条 | try_from | ||
Into | 第 5 条 | into | ||
TryInto | 第 5 条 | try_into | ||
AsRef | 第 8 条 | as_ref | ||
AsMut | 第 8 条 | as_mut | ||
Borrow | 第 8 条 | borrow | ||
BorrowMut | 第 8 条 | Borrow | borrow_mut | |
ToOwned | 第 8 条 | to_owned | ||
Deref | 第 8 条 | *x , &x | deref | |
DerefMut | 第 8 条 | *x , &mut x | Deref | deref_mut |
Index | 第 8 条 | x[idx] | index | |
IndexMut | 第 8 条 | x[idx] = ... | Index | index_mut |
Pointer | 第 8 条 | format("{:p}", x) | fmt | |
Iterator | 第 9 条 | next | ||
IntoIterator | 第 9 条 | for y in x | into_iter | |
FromIterator | 第 9 条 | from_iter | ||
ExactSizeIterator | 第 9 条 | Iterator | (size_hint) | |
DoubleEndedIterator | 第 9 条 | Iterator | next_back | |
Drop | 第 11 条 | } (作用域结束) | drop | |
Sized | 第 12 条 | 标记 trait | ||
Send | 第 17 条 | 跨线程传递 | 标记 trait | |
Sync | 第 17 条 | 跨线程使用 | 标记 trait |
1:当然,比较浮点数总是一个危险的游戏,因为通常情况下没法保证精度舍入计算会产生跟最初设想的数字(按比特值存储)完全相同的结果。
2:更一般地说,任何序论中的“格” lattice structure 都具有偏序性质。
3:这里的一些名称有点隐晦——例如 Rem
是求余数,Shl
是按位左移——但是 std::ops 的文档清楚第说明了它们的预期行为。
第 11 条:为RAII模式实现Drop trait
“永远不要让人去做机器的工作。” —— 史密斯特工(出自电影《黑客帝国》)
RAII 代表“资源获取即初始化”(Resource Acquisition Is Initialization)是一种编程模式,其中值的生命周期与某些附加资源的生命周期完全相关。 RAII 模式由 C++ 编程语言普及,是 C++ 对编程的最大贡献之一。
值的生命周期与资源的生命周期之间的关联体现在 RAII 类型中:
- 该类型的构造函数获取对某些资源的访问权
- 该类型的析构函数释放对这些资源的访问权
其结果是 RAII 类型具有一个恒定的特性:当且仅当对象存在时,才能访问底层资源。因为编译器确保局部变量在作用域退出时会被销毁,这就意味着底层资源也会在退出作用域时被释放。
这对于程序的可维护性很有帮助:如果对代码的后续改动改变了控制流,对象和资源的生命周期仍然是正确的。为了说明这点,来看一些没有使用 RAII 模式,手动锁定、解锁互斥锁的代码;以下代码是用 C++ 编写的,因为 Rust 的 Mutex
不允许这种易出错的用法!
// C++ code
class ThreadSafeInt {
public:
ThreadSafeInt(int v) : value_(v) {}
void add(int delta) {
mu_.lock();
// ... more code here
value_ += delta;
// ... more code here
mu_.unlock();
}
如果修改程序以在错误发生时提前退出函数,将会导致互斥锁保持锁定状态:
// C++ code
void add_with_modification(int delta) {
mu_.lock();
// ... more code here
value_ += delta;
// Check for overflow.
if (value_ > MAX_INT) {
// Oops, forgot to unlock() before exit
return;
}
// ... more code here
mu_.unlock();
}
然而,如果我们把锁定、解锁的行为放到 RAII 类中:
// C++ code (real code should use std::lock_guard or similar)
class MutexLock {
public:
MutexLock(Mutex* mu) : mu_(mu) { mu_->lock(); }
~MutexLock() { mu_->unlock(); }
private:
Mutex* mu_;
};
对于同样的改动,代码就是安全的:
// C++ code
void add_with_modification(int delta) {
MutexLock with_lock(&mu_);
// ... more code here
value_ += delta;
// Check for overflow.
if (value_ > MAX_INT) {
return; // Safe, with_lock unlocks on the way out
}
// ... more code here
}
在 C++ 中, RAII 模式最初常用于内存管理,以确保手动分配( new,malloc() )和释放( delete,free() )操作保持同步。C++11 标准库中加入了一个通用版本的内存管理: std::unique_ptr
在 Rust 中,内存指针的这种行为被内置在语言中(第 15 条),但 RAII 的一般原则对于其他类型的资源仍然有用。我们应该对任何持有必须释放资源的类型实现 Drop
trait ,例如以下情况:
- 访问操作系统资源。对于类 Unix 系统,这通常意味着持有文件描述符的类型对象;未能正确释放这些资源将会占用系统资源(并最终导致程序的每个进程获取文件描述符受限)。
- 访问同步资源。标准库已经包括内存同步原语,但其他资源(例如文件锁、数据库锁等)可能需要类似的封装。
- 访问原始内存,对于处理低级内存管理的
unsafe
类型(例如,用于外部函数接口[FFI]功能)。
Rust 标准库中最明显的 RAII 实例是由 Mutex::lock()
操作返回的 MutexGuard
,它通常用于第 17 条中讨论的通过共享状态实现并行的程序。这大致类似于之前提到的 C++ 示例,但在 Rust 中, MutexGuard
不仅作为持有锁的 RAII 对象,还充当对互斥锁保护的数据的代理:
use std::sync::Mutex;
struct ThreadSafeInt {
value: Mutex<i32>,
}
impl ThreadSafeInt {
fn new(val: i32) -> Self {
Self {
value: Mutex::new(val),
}
}
fn add(&self, delta: i32) {
let mut v = self.value.lock().unwrap();
*v += delta;
}
}
第 17 条建议不要在大段代码中持有锁;为确保这点,可以使用代码块来限制 RAII 对象的作用域。虽然这样会导致奇怪的缩进,但为了增加安全性和确保生命周期的精确性,这是值得的:
impl ThreadSafeInt {
fn add_with_extras(&self, delta: i32) {
// ... more code here that doesn't need the lock
{
let mut v = self.value.lock().unwrap();
*v += delta;
}
// ... more code here that doesn't need the lock
}
}
在推崇了 RAII 模式的用法之后,有必要解释一下如何实现它。 Drop
trait 允许你在对象销毁时添加用户自定义的行为。这个 trait 只有一个方法, drop
,编译器会在释放持有对象的内存之前运行这个方法:
#[derive(Debug)]
struct MyStruct(i32);
impl Drop for MyStruct {
fn drop(&mut self) {
println!("Dropping {self:?}");
// Code to release resources owned by the item would go here.
}
}
drop
方法是专门为编译器保留的,不允许手动调用:
x.drop();
#![allow(unused)] fn main() { error[E0040]: explicit use of destructor method --> src/main.rs:70:7 | 70 | x.drop(); | --^^^^-- | | | | | explicit destructor calls not allowed | help: consider using `drop` function: `drop(x)` }
在这里,我们需要了解一些技术细节。请注意, Drop::drop
方法的签名是 drop(&mut self)
而不是 drop(self)
:它接收的是对象的可变引用,而不是将对象移动到方法中。如果 Drop::drop
像普通方法那样运行,就意味着对象在方法执行后仍然可用——尽管它的所有内部状态已经被清理完毕,资源也已释放!
#![allow(unused)] fn main() { { // If calling `drop` were allowed... x.drop(); // (does not compile) // `x` would still be available afterwards. x.0 += 1; } // Also, what would happen when `x` goes out of scope? }
编译器提供了一种简单的替代方案,即调用 drop()
函数手动销毁对象。该函数接收一个参数移动到函数内,其实 drop(_item: T)
函数的实现只是一个空的函数体{}
——所以当该作用域到右括号时,被移动的对象会就被销毁。
另外, drop(&mut self)
方法的签名没有返回类型,这意味着它无法传递失败信息。如果释放资源可能会失败,那么你可能需要一个单独的 release
方法来返回一个 Result ,以便用户检测详情。
无论技术细节如何, drop
方法仍然是实现RAII模式的关键;它是实现释放与对象相关资源的最佳位置。
原文点这里查看
第 12 条:理解泛型和特征对象之间的权衡
第2条描述了如何使用特征来封装类型系统中的行为,作为相关方法的集合,并观察到有两种使用特征的方法:作为泛型的特征约束( trait bound
)或特征对象( trait object
)。本条探讨了这两种可能性之间的权衡。
下面是一个运行示例,请考虑一个涵盖显示图形对象的功能的特征:
#[derive(Debug, Copy, Clone)]
pub struct Point {
x: i64,
y: i64,
}
#[derive(Debug, Copy, Clone)]
pub struct Bounds {
top_left: Point,
bottom_right: Point,
}
/// Calculate the overlap between two rectangles, or `None` if there is no
/// overlap.
fn overlap(a: Bounds, b: Bounds) -> Option<Bounds> {
// ...
}
/// Trait for objects that can be drawn graphically.
pub trait Draw {
/// Return the bounding rectangle that encompasses the object.
fn bounds(&self) -> Bounds;
// ...
}
泛型
Rust 的泛型大致等同于 C++ 的模板:它允许程序员编写适用于任意类型 T
的代码,而泛型代码的特定用途会在编译时生成--这一过程在 Rust 中称为单态化(monomorphization),在 C++ 中称为模板实例化(template instantiation)。与 C++ 不同的是,Rust 在类型系统中以泛型的特质约束的形式明确编码了对 T
类型的期望。
例如,一个使用特征的 bounds()
方法的泛型函数具有显式的 Draw
特征约束:
/// Indicate whether an object is on-screen.
pub fn on_screen<T>(draw: &T) -> bool
where
T: Draw,
{
overlap(SCREEN_BOUNDS, draw.bounds()).is_some()
}
这也可以通过将特征绑定放在泛型参数之后来更紧凑地编写:
pub fn on_screen<T: Draw>(draw: &T) -> bool {
overlap(SCREEN_BOUNDS, draw.bounds()).is_some()
}
或者使用 impl Trait
作为参数的类型1:
pub fn on_screen(draw: &impl Draw) -> bool {
overlap(SCREEN_BOUNDS, draw.bounds()).is_some()
}
如果一个类型实现了特征:
#[derive(Clone)] // no `Debug`
struct Square {
top_left: Point,
size: i64,
}
impl Draw for Square {
fn bounds(&self) -> Bounds {
Bounds {
top_left: self.top_left,
bottom_right: Point {
x: self.top_left.x + self.size,
y: self.top_left.y + self.size,
},
}
}
}
然后该类型的实例可以传递给泛型函数,通过单例化来产生特定于一种特定类型的代码:
let square = Square {
top_left: Point { x: 1, y: 2 },
size: 2,
};
// Calls `on_screen::<Square>(&Square) -> bool`
let visible = on_screen(&square);
如果将相同的泛型函数用于实现相关特征约束的其他类型:
#[derive(Clone, Debug)]
struct Circle {
center: Point,
radius: i64,
}
impl Draw for Circle {
fn bounds(&self) -> Bounds {
// ...
}
}
然后使用不同的单态化代码:
let circle = Circle {
center: Point { x: 3, y: 4 },
radius: 1,
};
// Calls `on_screen::<Circle>(&Circle) -> bool`
let visible = on_screen(&circle);
换句话说,程序员编写了一个单一的泛型函数,但编译器会为每次调用该函数时使用的不同类型输出一个不同的单态化版本的函数。
特征对象
相比之下,特征对象是一个胖指针(第8条),它将指向底层具体项目的指针与指向虚表(vtable)的指针结合在一起,而虚表又持有特征实现的所有方法的函数指针,如图 2-1 所示: 图 2-1.特征对象布局,包含指向具体项目的指针和指向vtable的指针
这意味着接受特征对象的函数不需要泛型,也不需要单态化:程序员使用特征对象编写函数,编译器只输出该函数的一个版本,它可以接受来自多种输入类型的特征对象:
/// Indicate whether an object is on-screen.
pub fn on_screen(draw: &dyn Draw) -> bool {
overlap(SCREEN_BOUNDS, draw.bounds()).is_some()
}
// Calls `on_screen(&dyn Draw) -> bool`.
let visible = on_screen(&square);
// Also calls `on_screen(&dyn Draw) -> bool`.
let visible = on_screen(&circle);
基本比较
这些基本事实已经允许在两种可能性之间进行一些直接比较:
- 泛型可能会导致代码量增大,因为编译器会为每个使用
on_screen
函数泛型版本的T
类型生成一份全新的代码副本(on_screen::<T>(&T)
)。相比之下,该函数的特征对象版本(on_screen(&dyn T)
)只需要生成一个实例。 - 从泛型中调用特征方法通常比从使用特征对象的代码中调用特征方法要稍微快一些,因为后者需要执行两次反引用来查找代码的位置(特征对象到 vtable,vtable 到实现位置)。
- 泛型的编译时间可能会更长,因为编译器要编译更多代码,链接器要做更多工作来折叠重复代码。
在大多数情况下,这些差异并不显著--只有当你测量了优化的影响并发现它确实会产生影响(速度瓶颈或占用率增加)时,才应将优化相关的问题作为主要的决策驱动因素。
更重要的区别在于,通用特征约束可用于有条件地提供不同的功能,这取决于类型参数是否实现了多个特征:
// The `area` function is available for all containers holding things
// that implement `Draw`.
fn area<T>(draw: &T) -> i64
where
T: Draw,
{
let bounds = draw.bounds();
(bounds.bottom_right.x - bounds.top_left.x)
* (bounds.bottom_right.y - bounds.top_left.y)
}
// The `show` method is available only if `Debug` is also implemented.
fn show<T>(draw: &T)
where
T: Debug + Draw,
{
println!("{:?} has bounds {:?}", draw, draw.bounds());
}
let square = Square {
top_left: Point { x: 1, y: 2 },
size: 2,
};
let circle = Circle {
center: Point { x: 3, y: 4 },
radius: 1,
};
// Both `Square` and `Circle` implement `Draw`.
println!("area(square) = {}", area(&square));
println!("area(circle) = {}", area(&circle));
// `Circle` implements `Debug`.
show(&circle);
// `Square` does not implement `Debug`, so this wouldn't compile:
// show(&square);
特征对象只对单个特征的实现虚表进行编码,因此要做与之等价的事情就比较麻烦。例如,可以为 show()
情况定义一个组合 DebugDraw
特征,同时定义一个空白实现,以方便使用:
trait DebugDraw: Debug + Draw {}
/// Blanket implementation applies whenever the individual traits
/// are implemented.
impl<T: Debug + Draw> DebugDraw for T {}
但是,如果存在不同特征的多种组合,这种方法的组合学显然会迅速变得臃肿不堪。
更多特征约束
除了使用特征约束来限制泛型函数可接受的类型参数外,还可以将其应用于特征定义本身:
/// Anything that implements `Shape` must also implement `Draw`.
trait Shape: Draw {
/// Render that portion of the shape that falls within `bounds`.
fn render_in(&self, bounds: Bounds);
/// Render the shape.
fn render(&self) {
// Default implementation renders that portion of the shape
// that falls within the screen area.
if let Some(visible) = overlap(SCREEN_BOUNDS, self.bounds()) {
self.render_in(visible);
}
}
}
在本例中,render()
方法的默认实现(第13条)使用了特征 bound
,依赖于 Draw
中的 bounds()
方法。
来自面向对象语言的程序员经常会混淆特征约束和继承,误以为这样的特征约束意味着 Shape
就是 Draw
。事实并非如此:这两种类型之间的关系最好表述为 Shape
也实现了 Draw
。
从底层来看,那些具有特征约束的特征对象:
let square = Square {
top_left: Point { x: 1, y: 2 },
size: 2,
};
let draw: &dyn Draw = □
let shape: &dyn Shape = □
有一个组合的虚表,其中包括顶层特征的方法以及所有特征约束的方法。如图 2-2 所示:Shape
的虚表包括 Draw
特征的 bounds
方法,以及 Shape
特征本身的两个方法。
图 2-2.具有 Draw
与 Shape
两种虚表的具有特征约束的特征对象
在撰写本文时(截至Rust 1.70版本),没有办法从 Shape
向上转型到 Draw
,因为无法在运行时恢复(纯净的) Draw
虚表;没有办法在相关的特征对象之间进行转换,这反过来又意味着没有里氏替换原则(Liskov substitution)。然而,这种情况很可能在 Rust 的后续版本中发生变化——有关更多信息,请参见第19条。
用不同的语言重复同样的观点,接受 Shape
特征对象的方法具有以下特征:
- 它可以使用
Draw
的方法(因为Shape
也实现了Draw
,而且Shape
虚表中存在相关函数指针)。 - 它还不能将特征对象传递给另一个期望使用
Draw
特征对象的方法(因为Shape
不是Draw
,而且Draw
虚表也不可用)。
相比之下,接受实现 Shape
的泛型方法具有以下特征:
- 它可以使用
Draw
方法。 - 它可以将项目传递给另一个具有
Draw
特征约束的泛型方法,因为特征约束在编译时被单态化为使用具体类型的Draw
方法。
特征对象安全
对特征对象的另一个限制是对对象安全的要求:只有符合以下两条规则的特征才能用作特征对象:
- 特征方法不能是通用的。
- 特征的方法不能涉及包含
Self
的类型,但接收方(调用方法的对象)除外。
第一个限制很容易理解:泛型方法 f
是一组无限多的方法,可能包含 f::<i16>
, f::<i32>
, f::<i64>
, f::<u8>
等。另一方面,特征对象的虚表在很大程度上是指针函数的有限集合,因此不可能将无限的单态实现集放入其中。
第二种限制比较微妙,但往往是在实践中更常遇到的限制--使用 Copy
或 Clone
特征约束(第10条)的特征会立即受到这条规则的限制,因为它们返回的是 Self
。如果代码调用(例如) let y = x.clone()
,会发生什么情况?调用代码需要在堆栈中为 y
预留足够的空间,但它不知道 y
的大小,因为 Self
是一个任意类型。因此,提及 Self
的返回类型会导致特征对对象不安全2。
第二个限制有一个例外。如果 Self
对编译时已知大小的类型有明确的限制,即 Sized
标记特征作为特征,那么返回某种 Self
相关类型的方法就不会影响对象的安全性:
/// A `Stamp` can be copied and drawn multiple times.
trait Stamp: Draw {
fn make_copy(&self) -> Self
where
Self: Sized;
}
let square = Square {
top_left: Point { x: 1, y: 2 },
size: 2,
};
// `Square` implements `Stamp`, so it can call `make_copy()`.
let copy = square.make_copy();
// Because the `Self`-returning method has a `Sized` trait bound,
// creating a `Stamp` trait object is possible.
let stamp: &dyn Stamp = □
这种特征约束意味着该方法无论如何都不能与特征对象一起使用,因为特征指的是未知大小的东西(dyn Trait
),所以该方法违背了对象安全:
// However, the method can't be invoked via a trait object.
let copy = stamp.make_copy();
error: the `make_copy` method cannot be invoked on a trait object
--> src/main.rs:397:22
|
353 | Self: Sized;
| ----- this has a `Sized` requirement
...
397 | let copy = stamp.make_copy();
| ^^^^^^^^^
权衡取舍
从目前各种因素的权衡来看,你应该更倾向于使用泛型而非特征对象,但在某些情况下,特征对象才是最合适的工具。
首先是实际考虑:如果生成代码的大小或编译时间是个问题,那么特征对象的性能会更好(如本项目前面所述)。
从理论上讲,特征对象从根本上涉及类型擦除:在转换为特征对象的过程中,具体类型的信息会丢失。这可能是一个缺点(见第19条),但它也可能是有用的,因为它允许异构对象的集合--因为代码只依赖于特质的方法,它可以调用和组合具有不同具体类型的项的方法。
渲染形状列表的传统面向对象例子就是一个例子:在同一个循环中,可以对正方形、圆形、椭圆形和星形使用相同的 render()
方法:
let shapes: Vec<&dyn Shape> = vec![&square, &circle];
for shape in shapes {
shape.render()
}
当编译时还不知道可用类型时,特征对象的潜在优势就显得模糊得多。如果新代码在运行时被动态加载(例如通过dlopen(3)),那么在新代码中实现特征的项目只能通过特征对象调用,因为没有源代码可以单态化。
原文点这里查看
注释
使用"impl Trait in argument position"并不完全等同于前两个版本,因为它取消了调用者通过类似on_screen::<Circle>(&c)
这样的方式明确指定类型参数的功能。
在撰写本文时,对返回 Self
的方法的限制包括像 Box<Self>
这样可以安全地存储在堆栈中的类型;这一限制将来可能会放宽。
第 13 条:使用默认实现来减少必需实现的 trait 方法
在设计 trait 时,需要考虑两种不同的受众:实现 trait 的人和使用 trait 的人。在 trait 设计中,这两种受众之间存在一定的冲突:
- 为了让实现者实现 trait 更轻松,最好让 trait 具有实现其目的所需的最少方法。
- 为了让用户使用 trait 更方便,最好提供一系列覆盖所有常见用法的方法。
通过涵盖更多方便用户的方法,同时为那些可以从接口上其他更基础的操作构建的方法提供默认实现,可以缓解这种冲突。
来看一个简单的例子, ExactSizeIterator
是一个知道确切迭代内容的 Iterator
,它的 is_empty()
方法有一个依赖于 len()
方法的默认实现:
#![allow(unused)] fn main() { fn is_empty(&self) -> bool { self.len() == 0 } }
存在默认的实现仅仅意味着它有一个默认值。如果 trait 的实现有不同的方法来判断迭代器是否为空,它也可以用自己的 is_empty()
替换默认实现。
这种方法使得 trait 定义具有少量必需的方法,以及大量默认实现的方法。实现者只需实现前者,即可随意使用所有后者。
Rust 标准库广泛采用了这种方法;Iterator
trait 就是一个很好的例子,它只有一个必需方法( next()
),但包含了大量预提供的方法( 第9条 ),撰写本文时已经超过50个。
trait 方法可以添加 trait 约束,这意味着只有在相关类型实现特定 trait 时,目标方法才可用。这在结合默认方法实现时非常有用,Iterator
也印证了这点。例如,cloned()
的迭代器方法有一个 trait 约束和一个默认实现:
fn cloned<'a, T>(self) -> Cloned<Self>
where
T: 'a + Clone,
Self: Sized + Iterator<Item = &'a T>,
{
Cloned::new(self)
}
换句话说,cloned()
方法只有在 Item
的类型实现了 Clone
trait 时才可用;一旦实现 Clone
trait, clone()
方法也会自动实现。
关于带有默认实现的 trait 方法,最后一个要点是,即使在特征的初始版本发布之后,通常也可以安全地向 trait 添加新方法。只要新方法名不与类型实现的其他 trait 方法名冲突,就能保持向后兼容性(详见 第21条)。
因此,请参照标准库的示例,通过添加带有默认实现的方法(并根据需要添加 trait 约束),为实现者提供最少的 API 接口,但为用户提供方便且全面的 API 。
原文点这里查看
概念
本书的前两章涵盖了 Rust
的类型和特征,这有助于提供编写 Rust
代码所需的一些概念词汇 —— 这正是本章的主题。
借用检查器和生命周期检查是 Rust
独特之处的核心;它们也是 Rust
新手常见的绊脚石,因此是本章前两个条目的重点。
本章的其他条目涵盖了一些更容易理解但与其他语言编写代码略有不同的概念。这包括以下内容:
- 关于
Rust
的unsafe
模式及其避免方法的建议(第 16 条) - 关于在
Rust
中编写多线程代码的好消息和坏消息(第 17 条) - 关于避免运行时终止的建议(第 18 条)
- 关于
Rust
反射方法的信息(第 19 条) - 关于平衡优化与可维护性的建议(第 20 条)
尝试将你的代码与这些概念的后果对齐是一个好主意。在 Rust
中重现(部分)C/C++
的行为是可能的,但如果你这样做,为什么还要麻烦使用 Rust
呢?
第 14 条:理解生命周期
这一条描述了 Rust 的生命周期,这是对先前的编译型语言(像 C 和 C++)中存在的概念的更精确的表述——不仅仅在理论上,更是在实践中。生命周期是第 15 条中描述的借用检查器的必要标注;总的来说,这些功能构成了 Rust 内存安全保证的核心。
介绍一下栈
生命周期在根本上跟栈有关,所以快速过一遍栈的知识。
当一个程序运行时,内存会被分成不同的区块,有时被称为段。其中一些块是固定大小的,比如包含程序代码和全局变量的块,但是有两个块——堆和栈——会随着程序运行而改变大小。为了实现这一点,堆和栈通常排列在程序虚拟内存的两端,因此一个可以向下增长,另外一个可以向上增长(至少在你的程序耗尽了内存并崩溃之前),如图 3-1 所示。
图 3-1. 程序内存布局,包括堆向上增长和栈向下增长
对于这两个动态大小的块来说,栈用来保存与当前执行的函数相关的状态。该状态可能包含以下元素:
- 传递给函数的参数
- 函数中的局部变量
- 函数计算的临时值
- 函数调用者在代码中的返回地址
当调用函数f()
,一个新的栈帧(stack frame)会被添加到栈中,当超出栈帧结束的位置时,CPU 通常会更新栈指针寄存器,去指向新的栈帧。
当内部函数f()
返回,栈指针会被重置到调用函数之前的位置,也就是调用者的完整且未修改的栈帧。
如果调用者随后调用了一个不同的函数g()
,这个过程会再次发生,这意味着g()
的栈帧将复用f()
先前使用的同一块内存区域(如图 3-2 所示):
#![allow(unused)] fn main() { fn caller() -> u64 { let x = 42u64; let y = 19u64; f(x) + g(y) } fn f(f_param: u64) -> u64 { let two = 2u64; f_param + two } fn g(g_param: u64) -> u64 { let arr = [2u64, 3u64]; g_param + arr[1] } }
图 3-2. 随着函数调用和返回,栈使用的演变
当然,这是实际情况的极大简化之后的版本——将东西放入栈或取出栈需要时间,所以实际上处理器会有很多优化。然而,这个简化的概念图已经足以理解本条的主题。
生命周期的演化
上一小节解释了参数和局部变量是如何在栈上存储的,同时指出了这些值只是临时存储的。
从历史上来说,这会允许出现一些危险情况:当你持有一个指向这些栈上临时值的指针,会发生什么?
对最开始的 C 来说,返回局部变量的指针是完全可以的(尽管现代编译器会警告):
/* C code. */
struct File {
int fd;
};
struct File* open_bugged() {
struct File f = { open("README.md", O_RDONLY) };
return &f; /* return address of stack object! */
}
如果你不幸并且立刻使用了调用代码的返回值,你或许会避开这段代码导致的问题:
struct File* f = open_bugged();
printf("in caller: file at %p has fd=%d\n", f, f->fd);
in caller: file at 0x7ff7bc019408 has fd=3
这是不幸的,因为它只是看起来有效。一旦发生任何其他函数调用,栈区域被复用,并且用于保存对象的区域将会被覆盖:
investigate_file(f);
/* C code. */
void investigate_file(struct File* f) {
long array[4] = {1, 2, 3, 4}; // put things on the stack
printf("in function: file at %p has fd=%d\n", f, f->fd);
}
in function: file at 0x7ff7bc019408 has fd=1592262883
这个示例中,丢弃对象的内容会产生额外的不良副作用:打开的文件对应的文件描述符丢失了,因此程序泄漏了结构体中保存的资源。
发展到 C++后,失去资源访问权的问题通过包含析构函数得到了解决,从而启用了 RAII(参考第 11 条)。现在,栈上的东西有了自我清理的能力:如果对象持有某种资源,析构函数可以清理它,并且 C++编译器确保了在清理栈帧的过程中调用栈上对象的析构函数:
// C++ code.
File::~File() {
std::cout << "~File(): close fd " << fd << "\n";
close(fd);
fd = -1;
}
调用者现在获得了一个指向已被销毁并且它的资源已经被回收的对象的(无效)指针:
File* f = open_bugged();
printf("in caller: file at %p has fd=%d\n", f, f->fd);
~File(): close fd 3
in caller: file at 0x7ff7b6a7c438 has fd=-1
然而,C++没有采取任何措施来解决悬垂指针的问题:持有一个指向已经销毁对象(通过调用析构函数)的指针仍是可能的:
// C++ code.
void investigate_file(File* f) {
long array[4] = {1, 2, 3, 4}; // put things on the stack
std::cout << "in function: file at " << f << " has fd=" << f->fd << "\n";
}
in function: file at 0x7ff7b6a7c438 has fd=-183042004
作为一个 C/C++程序员,取决于你来注意这一点,并确保你不会解引用一个指向已消失内容的指针。或者,如果你是一名攻击者并且发现一个悬垂指针,那么你很可能疯狂地咯咯笑,并且利用该漏洞,兴高采烈地解引用该指针。
对于 Rust 来说。Rust 核心吸引力就是从根本上解决了悬垂指针的问题,这立即解决了一堆安全问题。1
为了这一点需要把生命周期的概念从后台(C/C++程序员只需要知道注意它们,没有任何语言支持)拿到前台来:每个包含&
符号的类型都有一个关联的生命周期('a
),即使编译器允许你在大部分时间忽略它。
生命周期的作用域
一个在栈上的数据的生命周期是保证它在栈上同一个位置的时间段;换句话说,生命周期是保证对该数据的引用(指针)不会变得无效的时间段。
一个数据的生命周期从被创建开始,一直到该数据被 drop(Rust 中与 C++中对象销毁相同的概念)或被移动。
Rust 中生命周期的普遍性有时会让来自 C/C++的程序员惊讶:很多情况下,Rust 将数据从栈上的一个位置移动到另一个位置,或者从栈移动到堆上,或者从堆移动到栈上。
该数据被自动 drop 的确切位置取决于该数据是否有一个名称。
局部变量和函数参数都提供了名称,并且对应的生命周期从该数据有名称开始:
- 对于局部变量:在
let var = ...
处声明 - 对于函数参数:作为函数调用执行框架设置的一部分
当有名称的数据被移动或者名称超出范围,该命名数据的生命周期就会结束:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] /// 某数据的定义 pub struct Item { contents: u32, } }
#![allow(unused)] fn main() { { let item1 = Item { contents: 1 }; // `item1` 数据 1 在此创建 let item2 = Item { contents: 2 }; // `item2` 数据 2 在此创建 println!("item1 = {item1:?}, item2 = {item2:?}"); consuming_fn(item2); // `item2` 数据 2 在此被移动 } // `item1` 数据 1 在此被drop }
还可以“即时”地创建一个数据,作为要传入到其他数据的一部分。这些未命名的数据在不再需要时被 drop。考虑这个问题一个简单但有效的方法是想象表达式的每个部分都会展开它自己的块,通过编译器插入临时变量。例如,这样的表达式:
#![allow(unused)] fn main() { let x = f((a + b) * 2); }
大致相当于:
#![allow(unused)] fn main() { let x = { let temp1 = a + b; { let temp2 = temp1 * 2; f(temp2) } // `temp2` 临时数据 2 在这里drop }; // `temp1` 临时数据 1 在这里drop }
当执行到原始行末尾的分号时,临时数据已经全部被 drop。
查看编译器计算数据生命周期的一种方法是插入一个故意的错误,来让借用检查器(第 15 条)检测。例如,保留对超出生命周期的数据的引用:
#![allow(unused)] fn main() { let r: &Item; { let item = Item { contents: 42 }; r = &item; } println!("r.contents = {}", r.contents); }
错误消息会指明item
的生命周期的确切终点:
error[E0597]: `item` does not live long enough
--> src/main.rs:190:13
|
189 | let item = Item { contents: 42 };
| ---- binding `item` declared here
190 | r = &item;
| ^^^^^ borrowed value does not live long enough
191 | }
| - `item` dropped here while still borrowed
192 | println!("r.contents = {}", r.contents);
| ---------- borrow later used here
同样的,对于未命名的临时数据:
#![allow(unused)] fn main() { let r: &Item = fn_returning_ref(&mut Item { contents: 42 }); println!("r.contents = {}", r.contents); }
错误消息显示了表达式末尾的终点:
error[E0716]: temporary value dropped while borrowed
--> src/main.rs:209:46
|
209 | let r: &Item = fn_returning_ref(&mut Item { contents: 42 });
| ^^^^^^^^^^^^^^^^^^^^^ - temporary
| | value is freed at the
| | end of this statement
| |
| creates a temporary value which is
| freed while still in use
210 | println!("r.contents = {}", r.contents);
| ---------- borrow later used here
|
= note: consider using a `let` binding to create a longer lived value
关于引用生命周期的最后一点:如果编译器可以自证代码中某处之外没有再使用该引用,那么它将引用的生命周期的末尾端点视为最后使用的位置,而不是在一个中括号范围的末尾。这个特性被称为非词法生命周期(NLL),这允许借用检查器更灵活一些:
#![allow(unused)] fn main() { { // `s` 拥有 `String`。 let mut s: String = "Hello, world".to_string(); // 创建一个 `String` 的可变引用 let greeting = &mut s[..5]; greeting.make_ascii_uppercase(); // .. 在这之后没再使用 `greeting` // 创建一个 `String` 的不可变引用是允许的, // 即使这里有一个可变引用还在该中括号范围内 let r: &str = &s; println!("s = '{}'", r); // s = 'HELLO, world' } // 可变引用 `greeting` 会在这里被drop }
生命周期标注
尽管 Rust 中处理引用时生命周期无处不在,但是你不必详细标注——没有人会说:“我正在处理ref.rs
从第 17 行延伸到第 32 行的生命周期”。相反,代码中的引用有任意名称的生命周期,通常是'a
,'b
,'c
等等,并且编译器有其自己的内部,不可访问的表示形式,这些表示形式相当于源代码中内容。(唯一例外是'static
生命周期,这是一个特殊情况,后续将会介绍。)
你不必对生命周期的名称考虑太多;主要考虑的事情是比较一个名称和另一个名称,或重复名称来标注两个生命周期是“相同的”。
这种生命周期标注最容易用函数签名来说明:如果函数的输入和输出需要处理引用,那么它们的生命周期之间是什么关系?
最常见的情况就是接收单个引用作为输入并返回一个引用的函数。返回的引用必须有一个生命周期,那么是什么呢?这里只有一种可能(除了'static
)可供选择:输入的生命周期,这意味着它们共享相同的名称,例如'a
。把该名称作为生命周期标注,添加给这两个类型会得到:
#![allow(unused)] fn main() { pub fn first<'a>(data: &'a [Item]) -> Option<&'a Item> { // ... } }
因为这种情况非常常见,并且因为(几乎)没有选择关于输出的生命周期应该是什么,Rust 具有生命周期省略规则,这意味着你不必为这种情况显式标注生命周期名称。相同的函数签名更惯用的版本应该是这样的:
#![allow(unused)] fn main() { pub fn first(data: &[Item]) -> Option<&Item> { // ... } }
所涉及的引用仍具有生命周期——省略规则只是意味着你不必编写任何生命周期名称并在这两个地方使用它。
如果有不止一种输入的生命周期选择可以映射到返回值的生命周期呢?这种情况下,编译器无法弄清楚该怎么做:
#![allow(unused)] fn main() { pub fn find(haystack: &[u8], needle: &[u8]) -> Option<&[u8]> { // ... } }
error[E0106]: missing lifetime specifier
--> src/main.rs:56:55
|
56 | pub fn find(haystack: &[u8], needle: &[u8]) -> Option<&[u8]> {
| ----- ----- ^ 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 `haystack` or
`needle`
help: consider introducing a named lifetime parameter
|
56 | pub fn find<'a>(haystack: &'a [u8], needle: &'a [u8]) -> Option<&'a [u8]> {
| ++++ ++ ++ ++
基于函数和参数名称的精明的假设是,此处输出的预期生命周期应该和输入的haystack
匹配:
#![allow(unused)] fn main() { pub fn find<'a, 'b>( haystack: &'a [u8], needle: &'b [u8], ) -> Option<&'a [u8]> { // ... } }
有趣的是,编译器建议了一种不同的替代方式:让函数的两个输入使用相同的生命周期'a
。例如,以下这个函数其中的生命周期组合或许会有意义:
#![allow(unused)] fn main() { pub fn smaller<'a>(left: &'a Item, right: &'a Item) -> &'a Item { // ... } }
这似乎暗示输入的两个生命周期是“一样的”,但这话用了引号(这里和之前)表明情况并非如此。
设计生命周期的原因就是确保数据的引用不会活得比数据本身更长;考虑这点,输出的生命周期'a
与输入的生命周期'a
“相同”,只是意味着输入必须比输出活得久。
当两个生命周期都是“相同的”'a
时,这仅意味着输出的生命周期必须包含在两个输入的生命周期内。
#![allow(unused)] fn main() { { let outer = Item { contents: 7 }; { let inner = Item { contents: 8 }; { let min = smaller(&inner, &outer); println!("smaller of {inner:?} and {outer:?} is {min:?}"); } // `min` dropped } // `inner` dropped } // `outer` dropped }
换句话说,输出的生命周期必须包含在两个输入寿命中较小的那一个。
相反,如果生命周期与输入之一的生命周期无关,则不需要这些生命周期嵌套。
#![allow(unused)] fn main() { { let haystack = b"123456789"; // 生命周期 'a 开始 let found = { let needle = b"234"; // 生命周期 'b 开始 find(haystack, needle) }; // 生命周期 'b 结束 println!("found={:?}", found); // `found` 在 'a 内部, 在 'b 外部使用 } // 声明周期 'a 结束 }
生命周期省略规则
除了前面描述的“一入,一出”的省略规则之外,还有另外两种可以省略标注名称的规则。
第一种情况是函数的输出没有引用;这种情况下,输入的引用都会自动获得自己的生命周期,且与其他输入的参数不同。
第二种情况是方法中有self
的引用(&self
或&mut self
);这种情况下,编译器会假设任何输出的引用的生命周期与self
相同,因为这(到目前为止)是最常见的情况。
以下是函数生命周期省略规则的总结:
- 一个输入,一个或者多个输出:假设输入和输出有“相同的”生命周期:
#![allow(unused)] fn main() { fn f(x: &Item) -> (&Item, &Item) // ... 相当于 ... fn f<'a>(x: &'a Item) -> (&'a Item, &'a Item) }
- 多个输入,没有输出:假设所有输入都有不同的生命周期:
#![allow(unused)] fn main() { fn f(x: &Item, y: &Item, z: &Item) -> i32 // ... 相当于 ... fn f<'a, 'b, 'c>(x: &'a Item, y: &'b Item, z: &'c Item) -> i32 }
- 多个输入,包括
&self
,一个或多个输入:假设输出的生命周期与&self
的生命周期“相同”:
#![allow(unused)] fn main() { fn f(&self, y: &Item, z: &Item) -> &Thing // ... 相当于 ... fn f(&'a self, y: &'b Item, z: &'c Item) -> &'a Thing }
当然,如果省略的生命周期标注没有达到你的预期,你仍可以显式标注生命周期来指定哪些生命周期彼此相关。实际上,这会导致编译报错,该错误表明省略的生命周期不能匹配函数或调用者涉及的引用。
'static
生命周期
上一节描述了函数的输入和输出的引用的生命周期之间的各种可能的映射,但是忽略了一种特殊情况。如果没有输入生命周期,但输出的返回值仍然包含引用会发生什么?
#![allow(unused)] fn main() { pub fn the_answer() -> &Item { // ... } }
error[E0106]: missing lifetime specifier
--> src/main.rs:471:28
|
471 | pub fn the_answer() -> &Item {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there
is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
471 | pub fn the_answer() -> &'static Item {
| +++++++
唯一允许的改正是对于返回的引用,有一个生命周期保证永远它不会超出作用范围。这是由特殊的生命周期'static
来标注的,它也是唯一一个特定名称而不是任意占位符名称的生命周期。
#![allow(unused)] fn main() { pub fn the_answer() -> &'static Item { }
获取有'static
生命周期的引用最简单的方法是引用已经标记为static的全局变量:
#![allow(unused)] fn main() { static ANSWER: Item = Item { contents: 42 }; pub fn the_answer() -> &'static Item { &ANSWER } }
Rust 编译器保证这个static
变量在整个程序的持续时间中具有相同的地址并永远不会移动。这意味着static
变量具有'static
生命周期,从逻辑上来说是这样。
很多情况下,对const
变量的引用也会被提升为'static
生命周期,但是有一些小的副作用需要注意。首先,如果涉及的类型具有析构函数或者内部可变性,则不会发生此提升:
#![allow(unused)] fn main() { pub struct Wrapper(pub i32); impl Drop for Wrapper { fn drop(&mut self) {} } const ANSWER: Wrapper = Wrapper(42); pub fn the_answer() -> &'static Wrapper { // `Wrapper` 有析构函数,所以const变量的引用无法提升到 `'static` 生命周期 &ANSWER } }
error[E0515]: cannot return reference to temporary value
--> src/main.rs:520:9
|
520 | &ANSWER
| ^------
| ||
| |temporary value created here
| returns a reference to data owned by the current function
第二个潜在副作用是,只有const
的值才能保证在各处都相同;无论变量在何处被使用,编译器都可以根据需要制作任意份拷贝。如果你正在依赖'static
引用背后的底层指针值来做些邪恶事,请注意这可能涉及多个内存位置。
还有一种方法来获取具有'static
生命周期的引用。'static
主要承诺了该生命周期应该比程序中任何其他生命周期都长;在堆上分配且从未释放的值也满足此约束。
普通的堆上分配Box<T>
在此处不起作用,因为不能保证(如下一节所述)该数据不会在超出作用域后被丢弃。
#![allow(unused)] fn main() { { let boxed = Box::new(Item { contents: 12 }); let r: &'static Item = &boxed; println!("'static item is {:?}", r); } }
error[E0597]: `boxed` does not live long enough
--> src/main.rs:344:32
|
343 | let boxed = Box::new(Item { contents: 12 });
| ----- binding `boxed` declared here
344 | let r: &'static Item = &boxed;
| ------------- ^^^^^^ borrowed value does not live long enough
| |
| type annotation requires that `boxed` is borrowed for `'static`
345 | println!("'static item is {:?}", r);
346 | }
| - `boxed` dropped here while still borrowed
然而,Box::leak
函数可以把已有所有权的Box<T>
变为T
的可变引用。不再有所有者拥有该值的所有权,因此它将会永远不会被删除——这满足了'static
生命周期的要求:
#![allow(unused)] fn main() { { let boxed = Box::new(Item { contents: 12 }); // `leak()` 销毁了 `Box<T>` 并返回 `&mut T`. let r: &'static Item = Box::leak(boxed); println!("'static item is {:?}", r); } // `boxed` 在这里没被drop,因为它被移动进了 `Box::leak()` // 因为 `r` 没有超出作用域,`Item` 被永远的泄漏了。 }
无法 drop 该数据,也意味着永远无法使用 safe Rust 来回收该数据的内存,这可能会导致永久性的内存泄漏。(注意,这块泄漏的内存并不违反 Rust 的内存安全保证——内存中永远无法访问的数据仍然是安全的。)
生命周期和堆
到目前为止的讨论集中在栈上数据的生命周期,无论是函数参数,局部变量或临时变量。但堆上的数据又如何呢?
关于堆上的值要认识到关键的事情是每块数据都有一个所有者(除了特殊情况,例如上一节描述的故意泄漏(leak))。例如,一个简单的Box<T>
就可以把T
的值放堆上,同时变量也就是所有者拥有Box<T>
的所有权。
#![allow(unused)] fn main() { { let b: Box<Item> = Box::new(Item { contents: 42 }); } // `b` 在此drop,那么 `Item` 也drop了。 }
拥有所有权的Box<Item>
在超出生命周期范围后 drop 了里面的数据,因此堆上的Item
的生命周期与栈上的的Box<Item>
相同。
堆上的值的所有者本身可能也在堆上,而不是栈上,那么此时谁拥有该所有者?
#![allow(unused)] fn main() { { let b: Box<Item> = Box::new(Item { contents: 42 }); let bb: Box<Box<Item>> = Box::new(b); // `b` 被移动到堆上 } // `bb` 在此drop,那么 `Box<Item>` 也drop了,那么 `Item` 也drop了。 }
所有权链必须在某处结束,并只有两种可能性:
- 该链以局部变量或函数参数结束——这种情况下该链中所有数据的生命周期就是栈上变量的生命周期
'a
。当栈上的变量超出作用域,该链上所有数据也都被 drop。 - 该链以标记了
static
的全局变量结束——这种情况下,链中所有的数据的生命周期都是'static
的,static
变量永远不会超出作用域,所以链中任何内容都不会被自动 drop。
总结,堆上数据的生命周期根本上是绑定到栈上的生命周期。
结构体中的生命周期
前面关于生命周期的章节集中于函数的输入和输出,但是存储在结构体中的引用也存在类似问题。
如果试图在不使用生命周期标注的情况下,偷偷把引用放到结构体中,编译器会发生尖锐的爆鸣声:
#![allow(unused)] fn main() { pub struct ReferenceHolder { pub index: usize, pub item: &Item, } }
error[E0106]: missing lifetime specifier
--> src/main.rs:548:19
|
548 | pub item: &Item,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
546 ~ pub struct ReferenceHolder<'a> {
547 | pub index: usize,
548 ~ pub item: &'a Item,
|
像往常一样,编译器错误信息告诉我们应该怎么做。第一部分很简单:为引用类型显式标注一个生命周期'a
,因为结构体中使用引用没有生命周期省略规则。
第二部分不太明显,但具有更深层次的影响:结构体本身需要一个生命周期参数<'a>
,它与包含的引用的生命周期相匹配:
#![allow(unused)] fn main() { // 由于字段有引用,所以结构体需要有生命周期参数 pub struct ReferenceHolder<'a> { pub index: usize, pub item: &'a Item, } }
结构体的生命周期参数具有传染性:任何结构体包含了该类型也必须获取一个生命周期参数:
#![allow(unused)] fn main() { // 由于字段有生命周期参数,所以结构体需要有生命周期参数 pub struct RefHolderHolder<'a> { pub inner: ReferenceHolder<'a>, } }
如果结构体包含切片类型,则也需要生命周期参数,因为它们又是对借用数据的引用。
如果结构体包含多个相互关联的生命周期的字段,那么你必须选择合适的生命周期绑定。在一对字符串内查找公共子串是一个非常好的具有独立生命周期的例子:
#![allow(unused)] fn main() { /// 一对字符串的公共子串存在的位置 pub struct LargestCommonSubstring<'a, 'b> { pub left: &'a str, pub right: &'b str, } /// 在 `left` 和 `right` 字符串中找到最长公共子串 pub fn find_common<'a, 'b>( left: &'a str, right: &'b str, ) -> Option<LargestCommonSubstring<'a, 'b>> { // ... } }
而引用同一字符串的多个位置的结构体,将具有共同的生命周期:
#![allow(unused)] fn main() { /// 在一个字符串内重复的两个子串 pub struct RepeatedSubstring<'a> { pub first: &'a str, pub second: &'a str, } /// 找到 `s` 中第一个重复子串。 pub fn find_repeat<'a>(s: &'a str) -> Option<RepeatedSubstring<'a>> { // ... } }
生命周期参数的传播是有意义的:任何包含引用的东西,无论嵌套多深,都仅在所引用数据的生命周期内有效。如果该数据被移动或 drop,那么整个结构体嵌套链都不再有效。
然而,这也意味着涉及引用的结构体更难使用——结构体的所有者必须保证生命周期全部一致。因此,尽可能选择拥有其内部数据所有权的结构体,特别是在代码不需要高度优化(第 20 条)的情况下。如果做不到这一点,第 8 条描述的各种智能指针类型(例如Rc
)可以让你生命周期约束中解放出来。
匿名生命周期
当不能保证一个结构体拥有其数据的所有权时,结构体必然会以生命周期参数结束,如上一节所述。这可能会与本条之前说的生命周期省略规则产生一些不幸。
例如,考虑一个返回带有生命周期参数的结构体的函数。该函数的完全显式标注使涉及的生命周期变得清晰易读:
#![allow(unused)] fn main() { pub fn find_one_item<'a>(items: &'a [Item]) -> ReferenceHolder<'a> { // ... } }
然而,省略生命周期的相同标注可能会有点误导:
pub fn find_one_item(items: &[Item]) -> ReferenceHolder {
// ...
}
因为返回类型的生命周期参数被省略,因此阅读代码的人不会得到太多涉及生命周期的提示。
匿名生命周期'_
允许你将省略的生命周期标注出来,而无需恢复所有生命周期名称。
#![allow(unused)] fn main() { pub fn find_one_item(items: &[Item]) -> ReferenceHolder<'_> { // ... } }
粗略地说,'_
标注要求编译器为我们生成一个唯一的生命周期名称,我们可以在不需要其他地方使用该名称的情况下使用它。
这意味着对于其他生命周期省略场景也很有用。例如,Debug
trait 的fmt
方法,使用匿名生命周期来指示Formatter
实例具有和&self
不同的生命周期,但该生命周期是什么名称并不重要:
#![allow(unused)] fn main() { pub trait Debug { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>; } }
牢记
- 所有的 Rust 引用都有一个关联的生命周期,由生命周期标签(比如
'a
)指示。在某些常见情况下,函数参数和返回值的生命周期可以省略(但仍然存在)。 - 任何(传递地)包含引用的结构体都有关联的生命周期参数;因此,使用拥有其数据所有权的结构体通常更容易。
'static
生命周期用来保证永远不会超出作用域的数据的引用,例如全局数据或堆上已经显式泄漏的数据。- 生命周期标签只能指示生命周期是“相同的”,这意味着输出的生命周期被包含在输入的一个(或更多)生命周期内。
- 匿名生命周期
'_
可以用在不需要特定生命周期标签的地方。
注释
例如,Chromium 项目估计70%的安全 bug 都归咎于内存安全。
原文点这里查看
第 15 条:理解借用检查器
Rust 中的每一个值都有一个所有者,但该所有者可以将值借用到代码中的其他位置。这种借用机制涉及引用的创建和使用。并遵守借用检查器(本条的主题)的规则。
抛开表面,Rust 引用使用与 C 或 C++代码中相当常见的相同类型指针值(第 8 条),但为了确保避免 C/C++中的错误,这个指针有规则和限制。来快速比较一下:
- 就像 C/C++指针一样,Rust 引用使用&符号创建:
&value
。 - 就像 C++ 引用一样,Rust 引用不能为
nullptr
。 - 就像 C/C++指针或引用一样,Rust 引用可以在创建后进行修改来引用不同内容。
- 与 C++ 不同,从值创建的引用总是需要显式(
&
)传参——如果你看到这样的代码f(value)
,你应该意识到f
正在接收传入值的所有权。(但是,如果这个value
的类型实现了Copy
,那么它可能接收的是该数据的copy
的所有权——详见第 10 条。) - 与 C/C++ 不同,新创建的可变引用总是显式标明(
&mut
)。如果你看到了这样的代码f(&value)
,你应该意识到value
不能被改变(即 C/C++中的const
)。只有像f(&mut value)
这样的表达式才可能更改value
的内容。1
C/C++指针和 Rust 引用之间最重要的区别由术语借用 (borrow) 表示:你可以获取某个数据的引用(指针),但是你不能永久持有该引用。特别是,你不能持有它活得比指向数据的生命周期更长,正如编译器所跟踪的生命周期,以及我们在第 14 条中探索过的一样。
这些对引用的限制使得 Rust 能保证其内存安全,但这也意味着你需要接受借用规则的学习成本,并接受它改变你如何设计软件的方式——尤其是存储数据的结构体。
本条会首先描述 Rust 的引用可以做什么,以及使用它们的借用检查器规则。本条的其余部分重点关注使用这些规则后更改代码的措施:如何重构,返工和重新设计代码,以便在与借用检查器斗争中获胜。
访问控制
有三种办法来访问一块 Rust 数据:通过这块数据的所有者(item
),引用(&item
),或可变引用(&mut item
)。每种访问数据的方式都有不同的访问权。使用CRUD存储模型(增删改查)来粗略地概括(Rust drop 术语即 delete ):
- 数据的所有者可以创建 (create),读取 (read),更新 (update),或删除 (drop) 它。
- 可变引用可以从底层数据中读取,或更新它。
- (普通)引用只能读取底层数据。
这儿有个 Rust 独有的数据访问规则:只有该数据的所有者可以移动该数据。如果你认为移动就是创建(在新位置)并drop这些数据占用的内存(在旧位置)的某种组合,这或许有点道理。
对于一块数据的可变引用的代码,这可能会导致一些奇怪情况。例如,下面替换Option
内部值是允许的:
#![allow(unused)] fn main() { /// 这段代码使用的结构体。 #[derive(Debug)] pub struct Item { pub contents: i64, } /// 替换 `item` 的 content 为 `val`. pub fn replace(item: &mut Option<Item>, val: Item) { *item = Some(val); } }
但是返回之前的值的操作违反了移动限制:2
#![allow(unused)] fn main() { /// 替换 `item` 的 content 为 `val`,返回之前的 content pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { let previous = *item; // 移出所有权 *item = Some(val); // 替换 previous } }
error[E0507]: cannot move out of `*item` which is behind a mutable reference
--> src/main.rs:34:24
|
34 | let previous = *item; // move out
| ^^^^^ move occurs because `*item` has type
| `Option<inner::Item>`, which does not
| implement the `Copy` trait
|
help: consider removing the dereference here
|
34 - let previous = *item; // move out
34 + let previous = item; // move out
|
尽管从一个可变引用读取是有效的,但这段代码尝试在用新值替换该被移动的值之前,将值移出——并试图避免复制(copy)原始值。借用检查器非常保守,并注意到在两行中间存在一段可变引用未引用有效值的时间段。
对人来说,我们知道这种组合操作——提取旧值并使用新值替换——是既安全又实用的,所以标准库提供了 std::mem::replace
函数。在底层上,replace
使用了 unsafe
(参考第 16 条)来一次性执行交换:
#![allow(unused)] fn main() { /// 替换 `item` 的 content 为 `val`,返回之前的 content pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { std::mem::replace(item, Some(val)) // 返回之前的值 } }
特别对于 Option
类型,由于这是一种非常常见的模式,所以 Option
自己提供了 replace
方法:
#![allow(unused)] fn main() { /// 替换 `item` 的 content 为 `val`,返回之前的 content pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { item.replace(val) // 返回之前的值 } }
借用规则
Rust 中借用引用时主要有两条关键规则需牢记。
第一条规则是任何引用的作用域必须小于所引用的数据的生命周期。第 14 条详细讨论了生命周期,但是值得注意的是编译器对于引用生命周期有特殊行为;非词法生命周期特性允许引用生命周期缩短,以便让他们在最后一次使用时结束,而不是在中括号末尾结束。
借用引用的第二条规则是,除了数据的所有者之外,还可以是以下任一一种借用方式:
- 对数据的任意数量不可变引用
- 对数据的单个可变引用
当然,不能同时用两者(在代码中的同一个位置)。
因此,同一个数据的引用可以被传入参数为多个不可变引用的函数:
#![allow(unused)] fn main() { /// 判断两个参数是否都为零 fn both_zero(left: &Item, right: &Item) -> bool { left.contents == 0 && right.contents == 0 } let item = Item { contents: 0 }; assert!(both_zero(&item, &item)); }
但是参数有多个可变引用就不能了:
#![allow(unused)] fn main() { /// 判断两个参数是否都为零 fn zero_both(left: &mut Item, right: &mut Item) { left.contents = 0; right.contents = 0; } let mut item = Item { contents: 42 }; zero_both(&mut item, &mut item); }
error[E0499]: cannot borrow `item` as mutable more than once at a time
--> src/main.rs:131:26
|
131 | zero_both(&mut item, &mut item);
| --------- --------- ^^^^^^^^^ second mutable borrow occurs here
| | |
| | first mutable borrow occurs here
| first borrow later used by call
对于函数参数有可变引用和不可变引用的混合来说,也有同样的限制:
#![allow(unused)] fn main() { /// 把 `left` 的 content 设置为 `right` 的 content。 fn copy_contents(left: &mut Item, right: &Item) { left.contents = right.contents; } let mut item = Item { contents: 42 }; copy_contents(&mut item, &item); }
error[E0502]: cannot borrow `item` as immutable because it is also borrowed
as mutable
--> src/main.rs:159:30
|
159 | copy_contents(&mut item, &item);
| ------------- --------- ^^^^^ immutable borrow occurs here
| | |
| | mutable borrow occurs here
| mutable borrow later used by call
借用规则允许编译器对数据的别名作出更好的决策:即跟踪何时两个不同的指针可能或可能不,引用内存中同一个底层数据。如果编译器可以确定(正如 Rust 中那样)一些不可变引用所指向的内存位置,不会通过一个已设置别名的可变引用进行更改,那么编译器可以生成具有以下优点的代码:
- 得到更好的优化:例如值可以被缓存到寄存器中,因为已知底层的内存存储的内容不会同时改变。
- 更安全:线程之间对内存的不同步访问(第 17 条)不会引起数据竞争。
所有者操作
围绕引用存在的规则导致了一个重要后果,它们还会影响数据所有者可执行的操作。帮助理解这一点的一种方法是想象涉及所有者的操作是通过在幕后创建和使用引用来执行的。
例如,尝试通过数据所有者来更新数据相当于创建了一个临时的可变引用,然后通过这个引用来更新数据。如果另外一个引用已存在,那么这个名义上第二个可变引用将会无法被创建:
#![allow(unused)] fn main() { let mut item = Item { contents: 42 }; let r = &item; item.contents = 0; // ^^^ 更改这个 item 大概相当于 // (&mut item).contents = 0; println!("reference to item is {:?}", r); }
error[E0506]: cannot assign to `item.contents` because it is borrowed
--> src/main.rs:200:5
|
199 | let r = &item;
| ----- `item.contents` is borrowed here
200 | item.contents = 0;
| ^^^^^^^^^^^^^^^^^ `item.contents` is assigned to here but it was
| already borrowed
...
203 | println!("reference to item is {:?}", r);
| - borrow later used here
另一方面,由于多个不可变引用是被允许的,因此当存在不可变引用时,从所有者读取数据是可以的:
#![allow(unused)] fn main() { let item = Item { contents: 42 }; let r = &item; let contents = item.contents; // ^^^ 从 item 中读取大概相当于 // let contents = (&item).contents; println!("reference to item is {:?}", r); }
但是存在可变引用是不可以的:
#![allow(unused)] fn main() { let mut item = Item { contents: 42 }; let r = &mut item; let contents = item.contents; // i64 实现了 `Copy` r.contents = 0; }
error[E0503]: cannot use `item.contents` because it was mutably borrowed
--> src/main.rs:231:20
|
230 | let r = &mut item;
| --------- `item` is borrowed here
231 | let contents = item.contents; // i64 implements `Copy`
| ^^^^^^^^^^^^^ use of borrowed `item`
232 | r.contents = 0;
| -------------- borrow later used here
最后,任何处于活动状态的引用都会阻止数据的所有者移动或 drop 数据,因为这样的话,意味着引用将会引用无效的数据:
#![allow(unused)] fn main() { let item = Item { contents: 42 }; let r = &item; let new_item = item; // 移动 println!("reference to item is {:?}", r); }
error[E0505]: cannot move out of `item` because it is borrowed
--> src/main.rs:170:20
|
168 | let item = Item { contents: 42 };
| ---- binding `item` declared here
169 | let r = &item;
| ----- borrow of `item` occurs here
170 | let new_item = item; // move
| ^^^^ move out of `item` occurs here
171 | println!("reference to item is {:?}", r);
| - borrow later used here
这种情况下,第 14 条中描述的非词法生命周期非常有用,因为(粗略地说)它在最后一次使用引用后结束了生命周期,而不是在中括号封闭的结尾。在移动发生之前将引用的最终使用提前,意味着不会发生编译错误:
#![allow(unused)] fn main() { let item = Item { contents: 42 }; let r = &item; println!("reference to item is {:?}", r); // 引用 `r` 仍在作用域但是不再使用,所以它被当作该引用已经被 drop let new_item = item; // 正常进行移动 }
在与借用检查器斗争中获胜
Rust 新手(甚至更有经验的人!)常常会觉得他们在与借用检查器斗争中花费了太多时间。有什么方法可以帮助你赢得这些斗争?
本地代码重构
第一个策略是关注编译器的错误信息,因为 Rust 开发者已经付出了大量努力来让它们尽可能有用:
#![allow(unused)] fn main() { /// 如果 `needle` 存在于 `haystack` 中, 返回包含它的切片。 pub fn find<'a, 'b>(haystack: &'a str, needle: &'b str) -> Option<&'a str> { haystack .find(needle) .map(|i| &haystack[i..i + needle.len()]) } // ... let found = find(&format!("{} to search", "Text"), "ex"); if let Some(text) = found { println!("Found '{text}'!"); } }
error[E0716]: temporary value dropped while borrowed
--> src/main.rs:353:23
|
353 | let found = find(&format!("{} to search", "Text"), "ex");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - temporary value
| | is freed at the end of this statement
| |
| creates a temporary value which is freed while still in
| use
354 | if let Some(text) = found {
| ----- borrow later used here
|
= note: consider using a `let` binding to create a longer lived value
错误消息的第一部分是最重要的部分,因为它描述了编译器认为你违反哪些借用规则和原因。当你遇到足够多这样的错误时——你总会的——你会逐渐建立关于借用检查器的一种直觉,这种直觉与前面叙述的规则中包含的更理论化的版本相吻合。
错误消息的第二部分包括编译器对于如何修改该问题的建议,本例中是很简单的:
#![allow(unused)] fn main() { let haystack = format!("{} to search", "Text"); let found = find(&haystack, "ex"); if let Some(text) = found { println!("Found '{text}'!"); } // `found` 现在引用了 `haystack`,它可以活到函数以外 }
这是两个简单的代码调整实例之一,可以帮助“安抚”借用检查器:
- 扩展生命周期:使用
let
将一个临时变量(生命周期只到该表达式结尾)绑定到一个新的被命名的局部变量(生命周期被扩展到该块的末尾)。 - 缩减生命周期:在使用的引用周围添加一个额外的块
{ ... }
,以便其生命周期在新块的末尾结束。
后者不太常见,因为非词法生命周期的存在:编译器通常可以在引用在块末尾正式释放之前,提前判断出该引用不再被使用。但是,如果你确实发现自己反复在相似的小代码块中周围引入一个人为的中括号块,那么需要考虑是否应该将这些代码封装到一个独立的方法中。
编译器建议的修复对于简单的问题很有帮助,但是当你编写更复杂的代码时,你会发现这些建议不再有用,而且对不符合借用规则之处的解释更难以理解:
#![allow(unused)] fn main() { let x = Some(Rc::new(RefCell::new(Item { contents: 42 }))); // 调用函数签名 `check_item(item: Option<&Item>)` check_item(x.as_ref().map(|r| r.borrow().deref())); }
error[E0515]: cannot return reference to temporary value
--> src/main.rs:293:35
|
293 | check_item(x.as_ref().map(|r| r.borrow().deref()));
| ----------^^^^^^^^
| |
| returns a reference to data owned by the
| current function
| temporary value created here
这种情况下,临时引入一些局部变量会很有帮助,每个变量都对应了相应的转化步骤,并且每个变量都有对应的类型注解:
#![allow(unused)] fn main() { let x: Option<Rc<RefCell<Item>>> = Some(Rc::new(RefCell::new(Item { contents: 42 }))); let x1: Option<&Rc<RefCell<Item>>> = x.as_ref(); let x2: Option<std::cell::Ref<Item>> = x1.map(|r| r.borrow()); let x3: Option<&Item> = x2.map(|r| r.deref()); check_item(x3); }
error[E0515]: cannot return reference to function parameter `r`
--> src/main.rs:305:40
|
305 | let x3: Option<&Item> = x2.map(|r| r.deref());
| ^^^^^^^^^ returns a reference to
| data owned by the current function
这缩小了编译器报错的精确转换范围,从而允许代码可以重构:
#![allow(unused)] fn main() { let x: Option<Rc<RefCell<Item>>> = Some(Rc::new(RefCell::new(Item { contents: 42 }))); let x1: Option<&Rc<RefCell<Item>>> = x.as_ref(); let x2: Option<std::cell::Ref<Item>> = x1.map(|r| r.borrow()); match x2 { None => check_item(None), Some(r) => { let x3: &Item = r.deref(); check_item(Some(x3)); } } }
一旦根本问题明确并得到解决,之后你就可以轻松的把局部变量重新组合在一起,然后你就可以假装你是一直这样做对的:
#![allow(unused)] fn main() { let x = Some(Rc::new(RefCell::new(Item { contents: 42 }))); match x.as_ref().map(|r| r.borrow()) { None => check_item(None), Some(r) => check_item(Some(r.deref())), }; }
结构体设计
接下来有助于与借用检查器斗争的是在设计结构体时考虑使用借用检查器。你的结构体拥有所有它需要的数据是万能方法,避免使用任何引用以及随之而来的第 14 条中所描述的生命周期注解的传播。
然而,对于现实世界的结构体来说,这不总是可能的;当数据结构的内部连接成一个比树结构(一个根
拥有很多分支
,每个分支又拥有它自己的叶子
等)更相互关联的图时,那么简单的单一所有权是不可能的。
举一个简单的例子,想象一个简单的来宾详情登记册,记录他们按照顺序到达的时间:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] pub struct Guest { name: String, address: String, // ... many other fields } /// Local error type, used later. #[derive(Clone, Debug)] pub struct Error(String); /// Register of guests recorded in order of arrival. #[derive(Default, Debug)] pub struct GuestRegister(Vec<Guest>); impl GuestRegister { pub fn register(&mut self, guest: Guest) { self.0.push(guest) } pub fn nth(&self, idx: usize) -> Option<&Guest> { self.0.get(idx) } } }
如果此代码也需要根据到达时间和姓名字母表序来有效地查询来宾,那么根本上涉及两个结构体,并且其中一个拥有该数据。
如果涉及的数据很小且不可变,那么仅仅克隆数据就可以是一个快速的解决方案:
#![allow(unused)] fn main() { mod cloned { use super::Guest; #[derive(Default, Debug)] pub struct GuestRegister { by_arrival: Vec<Guest>, by_name: std::collections::BTreeMap<String, Guest>, } impl GuestRegister { pub fn register(&mut self, guest: Guest) { // 需要 `Guest` 来被 `Clone` self.by_arrival.push(guest.clone()); // 为了示例更短,这里没有检查重复的名字 self.by_name.insert(guest.name.clone(), guest); } pub fn named(&self, name: &str) -> Option<&Guest> { self.by_name.get(name) } pub fn nth(&self, idx: usize) -> Option<&Guest> { self.by_arrival.get(idx) } } } }
然而,如果数据可以被修改,那么这种克隆方法就显得捉襟见肘。例如,如果要更新 Guest
的地址,你需要找到两个存储的 Guest 的地方并确保这俩地方的数据保持同步。
另一种可能的方法是添加另一层中间层,将 Vec<Guest>
作为所有者,并使用向量索引来名称查找。
#![allow(unused)] fn main() { mod indexed { use super::Guest; #[derive(Default)] pub struct GuestRegister { by_arrival: Vec<Guest>, // Guest 的 name to 映射到 `by_arrival` 的 index 的索引。 by_name: std::collections::BTreeMap<String, usize>, } impl GuestRegister { pub fn register(&mut self, guest: Guest) { // 为了示例更短,这里没有检查重复的名字 self.by_name .insert(guest.name.clone(), self.by_arrival.len()); self.by_arrival.push(guest); } pub fn named(&self, name: &str) -> Option<&Guest> { let idx = *self.by_name.get(name)?; self.nth(idx) } pub fn named_mut(&mut self, name: &str) -> Option<&mut Guest> { let idx = *self.by_name.get(name)?; self.nth_mut(idx) } pub fn nth(&self, idx: usize) -> Option<&Guest> { self.by_arrival.get(idx) } pub fn nth_mut(&mut self, idx: usize) -> Option<&mut Guest> { self.by_arrival.get_mut(idx) } } } }
这个方法中,每一个来宾都由单个 Guest
数据表示,这允许 named_mut()
方法返回对该数据的可变引用。反过来意味着可以正常更改来宾的地址——(单个) Guest
由 Vec
所有,并且在底层上始终可被访问。
#![allow(unused)] fn main() { let new_address = "123 Bigger House St"; // 真实情况下,代码不会假设 "Bob" 存在... ledger.named_mut("Bob").unwrap().address = new_address.to_string(); assert_eq!(ledger.named("Bob").unwrap().address, new_address); }
但是,如果来宾可以注销,则很容易无意中出现 bug:
#![allow(unused)] fn main() { // 注销位置为 `idx` 的 `Guest`,随后的来宾都往前移 pub fn deregister(&mut self, idx: usize) -> Result<(), super::Error> { if idx >= self.by_arrival.len() { return Err(super::Error::new("out of bounds")); } self.by_arrival.remove(idx); // 哦,忘记更新了 `by_name`。 Ok(()) } }
现在 Vec
可能被打乱,其中的 by_name
索引实际上就像指针一样,但我们引入了一个 bug 会导致这些“指针”指向不存在的内容(超出 Vec
范围)或指向不正确的数据:
#![allow(unused)] fn main() { ledger.register(alice); ledger.register(bob); ledger.register(charlie); println!("Register starts as: {ledger:?}"); ledger.deregister(0).unwrap(); println!("Register after deregister(0): {ledger:?}"); let also_alice = ledger.named("Alice"); // Alice 的索引仍然是 0,但现在该位置是 Bob println!("Alice is {also_alice:?}"); let also_bob = ledger.named("Bob"); // Bob 的索引仍然是 1,但现在该位置是 Charlie println!("Bob is {also_bob:?}"); let also_charlie = ledger.named("Charlie"); // Charlie 的索引仍然是 2,但现在已经超出了 Vec 索引范围 println!("Charlie is {also_charlie:?}"); }
这里的代码使用自定义的 Debug
实现(没展示出来),以减少输出大小;截断的输出如下:
Register starts as: {
by_arrival: [{n: 'Alice', ...}, {n: 'Bob', ...}, {n: 'Charlie', ...}]
by_name: {"Alice": 0, "Bob": 1, "Charlie": 2}
}
Register after deregister(0): {
by_arrival: [{n: 'Bob', ...}, {n: 'Charlie', ...}]
by_name: {"Alice": 0, "Bob": 1, "Charlie": 2}
}
Alice is Some(Guest { name: "Bob", address: "234 Bobton" })
Bob is Some(Guest { name: "Charlie", address: "345 Charlieland" })
Charlie is None
前面的示例展示了 注销
代码的 bug,但是即便修复了这个 bug,也无法防止调用者使用该索引值,并传入 nth()
——从而获得意外或无效结果。
核心问题是两个结构体需要保持同步。处理这种问题的更好方法是使用 Rust 的智能指针(第 8 条)。使用 Rc
和 RefCell
来避免使用索引作为无效指针的问题。更新示例——但是保持该 bug 不变——如下所示:
#![allow(unused)] fn main() { mod rc { use super::{Error, Guest}; use std::{cell::RefCell, rc::Rc}; #[derive(Default)] pub struct GuestRegister { by_arrival: Vec<Rc<RefCell<Guest>>>, by_name: std::collections::BTreeMap<String, Rc<RefCell<Guest>>>, } impl GuestRegister { pub fn register(&mut self, guest: Guest) { let name = guest.name.clone(); let guest = Rc::new(RefCell::new(guest)); self.by_arrival.push(guest.clone()); self.by_name.insert(name, guest); } pub fn deregister(&mut self, idx: usize) -> Result<(), Error> { if idx >= self.by_arrival.len() { return Err(Error::new("out of bounds")); } self.by_arrival.remove(idx); // 哦,又忘了更新 `by_name`。 Ok(()) } // ... } } }
Register starts as: {
by_arrival: [{n: 'Alice', ...}, {n: 'Bob', ...}, {n: 'Charlie', ...}]
by_name: [("Alice", {n: 'Alice', ...}), ("Bob", {n: 'Bob', ...}),
("Charlie", {n: 'Charlie', ...})]
}
Register after deregister(0): {
by_arrival: [{n: 'Bob', ...}, {n: 'Charlie', ...}]
by_name: [("Alice", {n: 'Alice', ...}), ("Bob", {n: 'Bob', ...}),
("Charlie", {n: 'Charlie', ...})]
}
Alice is Some(RefCell { value: Guest { name: "Alice",
address: "123 Aliceville" } })
Bob is Some(RefCell { value: Guest { name: "Bob",
address: "234 Bobton" } })
Charlie is Some(RefCell { value: Guest { name: "Charlie",
address: "345 Charlieland" } })
输出不再有不匹配的名称,但是 Alice 挥之不去的数据仍旧存在,直到我们确保两个集合保持同步,才能修复该 bug:
#![allow(unused)] fn main() { pub fn deregister(&mut self, idx: usize) -> Result<(), Error> { if idx >= self.by_arrival.len() { return Err(Error::new("out of bounds")); } let guest: Rc<RefCell<Guest>> = self.by_arrival.remove(idx); self.by_name.remove(&guest.borrow().name); Ok(()) } }
Register after deregister(0): {
by_arrival: [{n: 'Bob', ...}, {n: 'Charlie', ...}]
by_name: [("Bob", {n: 'Bob', ...}), ("Charlie", {n: 'Charlie', ...})]
}
Alice is None
Bob is Some(RefCell { value: Guest { name: "Bob",
address: "234 Bobton" } })
Charlie is Some(RefCell { value: Guest { name: "Charlie",
address: "345 Charlieland" } })
智能指针
上一小节最后一个方法是更通用的解决方案的示例:在互联的结构体中使用 Rust 智能指针。
第 8 条描述了 Rust 标准库提供的常见的智能指针类型:
Rc
允许共享所有权,多个变量可以引用同一个数据。Rc
总是和RefCell
一起使用。RefCell
允许内部可变性,以便在不需要可变引用的情况下更改内部状态。这是将借用检查从编译期移到运行时为代价的。Arc
是Rc
的多线程版本。Mutex
(和RwLock
)允许多线程环境中内部可变性,大概相当于RefCell
。Cell
允许Copy
类型的内部可变性。
对于从 C++转向 Rust 并适应了的程序员来说,最常用的就是 Rc<T>
(和它的线程安全版本 Arc<T>
),并通常结合 RefCell
(或者线程安全的替代 Mutex
)使用。将共享指针(甚至是 std::shared_ptr
)简单地转换为 Rc<RefCell<T>>
实例通常在 Rust 中工作良好,而且借用检查器不会有太多抱怨。
然而,使用该方法意味着你会得不到 Rust 提供的一些保护。特别是,当同一个数据可变借用(通过 borrow_mut()
)时,另外一个引用已经存在,会导致运行时 panic!
,而不是一个编译期错误。
例如,打破树结构所有权单向流的一种模式是,有一个“所有者”指针从该数据指回这个数据的所有者,如图 3-3 所示。这些所有者
链接对于在数据结构中上下移动很有用;例如,向叶子添加新的同级节点,找到涉及该叶子所属的分支
。
图 3-3. 树结构层级关系
在 Rust 中实现此模式可以利用 Rc<T>
的精明的伙伴,Weak<T>
:
#![allow(unused)] fn main() { use std::{ cell::RefCell, rc::{Rc, Weak}, }; // 对每个标识符类型使用新类型。 struct TreeId(String); struct BranchId(String); struct LeafId(String); struct Tree { id: TreeId, branches: Vec<Rc<RefCell<Branch>>>, } struct Branch { id: BranchId, leaves: Vec<Rc<RefCell<Leaf>>>, owner: Option<Weak<RefCell<Tree>>>, } struct Leaf { id: LeafId, owner: Option<Weak<RefCell<Branch>>>, } }
Weak
引用不会增加主引用计数,因此必须显式验证所引用的数据是否已经被释放:
#![allow(unused)] fn main() { impl Branch { fn add_leaf(branch: Rc<RefCell<Branch>>, mut leaf: Leaf) { leaf.owner = Some(Rc::downgrade(&branch)); branch.borrow_mut().leaves.push(Rc::new(RefCell::new(leaf))); } fn location(&self) -> String { match &self.owner { None => format!("<unowned>.{}", self.id.0), Some(owner) => { // 升级弱引用所有者指针 let tree = owner.upgrade().expect("owner gone!"); format!("{}.{}", tree.borrow().id.0, self.id.0) } } } } }
如果 Rust 的智能指针不能很好的解决你的结构体所需,这里还有终极解决方案,即编写 unsafe
代码并使用裸(并且绝对不智能的)指针。然而,根据第 16 条,这应该是最后的解决方案——其他人可能已经在安全接口内实现了你想要的功能,你只需要搜索标准库和 crates.io
,你或许会找到适合这种情况的工具。
例如,假设你写了一个函数,它有时返回输入的参数之一的引用,有时返回一些新分配的数据。根据第 1 条,对这两种可能性在类型系统中进行编码的非常自然的方式是写一个 enum
,然后你可以实现第 8 条描述的各种指针 trait。但是你不必这样做:标准库已经提供了std::borrow::Cow
类型,它已经包含了这些情况:
自引用结构体
有一场与借用检查器的特殊斗争总是阻碍程序员从其他语言转向 Rust:尝试创建自引用结构体,结构体中包含已拥有的数据,和对该已拥有的数据内的引用的混合:
#![allow(unused)] fn main() { struct SelfRef { text: String, // `text` 的切片中有 title 文本。 title: Option<&str>, } }
在语法级别上,这段代码无法编译,因为它不符合第 14 条中所述的生命周期规则:引用需要生命周期注解,并且这意味着包含该数据的结构体也需要生命周期参数。但是生命周期是针对此 SelfRef
结构体外部的某些东西而言的,而这里没意义:被引用的数据在结构体内部。
关于此限制在语义层面的原因很值得思考。Rust 中的数据结构是可移动的:从栈到堆上,从堆到栈上,并从一个地方到另一个地方。如果上述情况发生了,"内部" title
指针将不再有效,并且也无法保持同步。
对于这种情况,一个简单的解决办法是使用前面讨论过的索引:对于 text
的一系列的偏移量不会因为移动而失效,并且对于借用检查器来说是不可见的,因为它不涉及引用:
#![allow(unused)] fn main() { struct SelfRefIdx { text: String, // title 文本的位置对 `text` 的索引 title: Option<std::ops::Range<usize>>, } }
然而,这种索引方法只适合简单的示例,并且具有前面提到过的相同的缺点:索引本身是一个伪指针,可能会变得不同步,甚至 text
已经不存在之后仍在索引不存在的数据。
当编译器处理异步
代码时,会出现常见的自引用问题。3粗略地说,编译器将待处理的异步
代码整合到闭包中,该闭包会既包含代码,又包含上下文环境中捕获到的需要的变量(如第 2 条所述)。该捕获环境能够同时包含值和对这些值的引用。这本质上是一种自引用数据结构,因此异步
支持是标准库中的 Pin
类型的主要动机。该指针类型可以“固定”值,强制该值保留在内存中的同一位置,从而确保内部自引用保持有效。
因此 Pin
可以作为自引用类型的一种可能解决方法,但如何正确使用该类型很棘手——请务必阅读官方文档。
在可能的情况下,避免使用自引用数据结构,或者尝试找到能够为此封装的包(例如ouroboros
)。
牢记
- Rust 的引用是被借用的,表明他们不能被永久保留。
- 借用检查器允许对某个数据进行多次不可变借用,或者单次可变借用,但不能同时有两者。由于非词法生命周期,引用的生命周期在引用最后一次被使用之后结束,而不是在中括号封闭范围内末尾。
- 借用检查器的错误可通过多种方式处理:
- 添加额外的
{ ... }
作用域来减少值的生命周期范围。 - 把值赋值给已命名的局部变量,可以延长该值的生命周期到该作用域的末尾。
- 暂时添加多个局部变量可以帮助缩小借用检查器“抱怨”的范围。
- 添加额外的
- Rust 的智能指针提供了绕过借用检查器规则的方法,并且对于互联的结构体非常有用。
- 然而,自引用结构体在 Rust 中仍难以处理。
注释
注意,上述提到的可能在这样的表达式失效,比如m!(value)
,因为这涉及宏(第 28 条),因为宏可以扩展出任意代码。
编译器的建议在这里没有任何用处,因为后续行需要 item
。
Cow
意思是写时克隆 (clone-on-write);仅当数据需要对其更改(写入)时才克隆底层数据。
处理 async
代码已经超出本书的范围;要了解更多关于自引用(self-referential)结构体的更多信息,参考 Jon Gjengset (No Starch Press) 编写的《Rust for Rustaceans》的第 8 章节。
原文点这里查看
第 16 条:避免写 unsafe
代码
Rust 独特的卖点就是其内存安全保证(无运行时开销);这是其他任何主流语言所没有的 Rust 的特性。这种保证是有代价的:写 Rust 需要你重新组织你的代码来通过借用检查器(第 15 条)并精确指定你使用的引用类型(第 8 条)。
Unsafe Rust 是 Rust 语言的超集,削弱了一些限制——以及对应的保证。代码块前面加上 unsafe
就可以切换到 unsafe 模式,这会允许通常情况下 Rust 所不支持的操作。特别是,它允许使用裸指针,就像旧式 C 指针一样。这些指针不受借用规则的约束,程序员有责任保证他们在解引用时仍指向有效的内存。
因此浅显地说,本条的建议很简单:如果你只想用 Rust 来编写 C 代码,为什么要用 Rust?然而,在某些情况下,unsafe
代码是必要的:对于底层库或当你的 Rust 代码必须与其他语言代码交互时(第 34 条)。
所以本条的建议非常明确:避免写 unsafe
代码。重点在于“写”,因为大多数时候,你可能需要的 unsafe
代码都已经为你编写好了。
Rust 标准库包含大量 unsafe
代码;快速查找发现 alloc
库中大约有 1000 个unsafe
使用,core
中有 1,500 个,std
中最多,有 2,000 个。这些代码都由大佬编写,并在数千个 Rust 代码库中久经考验。
其中一些 unsafe
代码在我们介绍过的标准库功能的底层:
- 第 8 条描述的智能指针类型(
Rc
,RefCell
,Arc
等)在底层上都使用unsafe
代码(通常是裸指针),以便向用户呈现其特定的语义。 - 第 17 条中的同步原语(
Mutex
,RwLock
和其他相关守卫)在内部使用unsafe
的系统特定操作代码。如果你想了解这些原语中设计的微妙细节,推荐 Mara Bos (O'Reilly) 的《Rust Atomics and Locks》。
标准库中还有一些涵盖了更高级特性的功能,其内部也使用 unsafe
实现:1
std::pin::Pin
强制数据不能在内存中移动(第 15 条)。这会允许自引用数据结构,这对于 Rust 新手通常来说是非常令人讨厌的(bête noire)。std::borrow::Cow
提供了写时克隆智能指针:同一个指针可用于读取和写入,并且只当写入时才会发生底层数据的克隆。std::mem
中的这些函数(take
,swap
,replace
)允许操作内存中的数据,而不会与借用检查器发生冲突。
这些功能可能仍需要小心一些才能正确使用,但 unsafe
代码已经通过消除该类问题的方式封装起来了。
除了标准库以外,crates.io
生态也有很多封装了 unsafe
代码来提供常用功能的 crate:
once_cell
:提供了一种类似全局变量的方法,只初始化一次。rand
:提供随机数生成,利用操作系统和 CPU 提供的较低级别的底层功能。byteorder
:允许数据的原始字节(raw bytes)与数字(number)之间进行转换。cxx
:允许 C++代码和 Rust 代码互操作(也在第 35 条提到)。
还有很多其他例子,但是期望的总体思路是清楚的。如果你想做的事情显然不符合 Rust 的约束(尤其对于第 14 条和第 15 条),搜索标准库,查看是否已有功能可以满足你的需要。如果你没有找到所需的,也可以尝试在 crates.io
中搜索。毕竟,遇到其他人从来没遇到过的独特问题是不寻常的。
当前,总有一些地方强制使用 unsafe
,比如,当你想要通过外部函数接口(FFI)编写的代码进行交互时,正如第 34 条中讨论的那样。但当必要时,考虑编写一个包装层来保存所有的 unsafe
代码,以便其他程序员可以遵循本条给出的建议。这也有助于定位问题:当出现问题时,unsafe
的包装层应是首先被怀疑的对象。
另外,如果你被迫编写 unsafe
代码,注意关键字本身蕴含的警告:此处有龙(Hic sunt dracones,中世纪航海术语,用来描述该地域很危险)。
- 添加安全注释,记录
unsafe
代码依赖的前提条件和不变量。Clippy(第 29 条)有一个警告来提醒您这一点。 - 最小化使用
unsafe
代码块,以限制错误影响的潜在范围。考虑启用unsafe_op_in_unsafe_fn
lint,以便于执行unsafe
操作时需要显式的unsafe
代码块,甚至这些操作是在本身就unsafe
函数中执行的。 - 编写比平时更多的测试(第 30 条)。
- 对代码运行附加诊断工具(第 31 条)。特别是,考虑在
unsafe
代码上运行 Miri——Miri 会解释编译器中间层的输出,使其能够检测到 Rust 编译器无法察觉到某类错误。 - 仔细考虑多线程的使用,特别是有共享状态情况下(第 17 条)。
添加 unsafe
标记并不意味着不再有任何规则适用,这意味着你(程序员)现在负责维护 Rust 的安全保证,而不是编译器负责。
注释
实际上,大多数 std
功能实际上都由 core
提供,因此可用于 no_std
代码,如第 33 条所述。
原文点这里查看
第 17 条:对共享状态的并行性保持警惕
"即使是最大胆的共享数据形式在 Rust 中也能保证安全。"-Aaron Turon
官方文档描述说 Rust 实现了“无畏并发”,但本条将探讨为什么(令人遗憾的是)即使是在 Rust 中,仍有一些理由需要对并发保持警惕。
本条特别关注共享状态的并行性:正在执行的不同线程通过共享内存相互通信。无论是哪种语言,线程之间共享状态通常会带来两个可怕的问题:
- 数据竞争:这可能导致数据损坏。
- 死锁:这可能导致你的程序陷入停滞。
上述两个问题都很可怕(“引起或可能导致恐慌”),因为他们在实际调试中会变得非常困难:错误的发生是不固定的,并且通常更有可能在有负载的情况下发生——这意味着他们可能不会在单元测试,集成测试或其他任何类型测试中被发现(第 30 条),但他们会在生产环境中出现。
Rust 已经向前迈出了一大步,因为它完全解决了上述两个问题之一。然而,正如我们所见,另外一个问题仍然存在。
数据竞争
让我们先通过探索数据竞争和 Rust 来看一个好消息。数据竞争的精确定义因语言而异,但我们可以将关键部分总结如下:
当两个不同的线程在以下条件访问内存中同一位置时,会发生数据竞争:
- 至少有一个线程在写入。
- 没有强制规定访问顺序的同步机制。
C++ 中的数据竞争
通过一个例子可以很好地说明这这些基础知识。考虑一个跟踪银行账户的数据结构:
// C++ code.
class BankAccount {
public:
BankAccount() : balance_(0) {}
int64_t balance() const {
if (balance_ < 0) {
std::cerr << "** Oh no, gone overdrawn: " << balance_ << "! **\n";
std::abort();
}
return balance_;
}
void deposit(uint32_t amount) {
balance_ += amount;
}
bool withdraw(uint32_t amount) {
if (balance_ < amount) {
return false;
}
// 如果此时其他线程更改了 `balance_` 会发生什么?
std::this_thread::sleep_for(std::chrono::milliseconds(500));
balance_ -= amount;
return true;
}
private:
int64_t balance_;
};
这个例子用 C++ 写的,不是 Rust,原因很快就会明了。然而,相同的一般概念也适用于许多其他(非 Rust)语言——Java,Go,Python 等。
该类在单线程中工作正常,但是考虑多线程下情况:
BankAccount account;
account.deposit(1000);
// 启动一个线程,用来监视余额不足的情况,并为账户充值。
std::thread payer(pay_in, &account);
// 启动三个线程,每个线程尝试重复取款。
std::thread taker(take_out, &account);
std::thread taker2(take_out, &account);
std::thread taker3(take_out, &account);
这里有若干线程反复尝试从账户中取款,并且有一个额外的线程在账户余额不足时为账户充值:
// 持续监控 `account` 余额,如果余额不足则进行充值。
void pay_in(BankAccount* account) {
while (true) {
if (account->balance() < 200) {
log("[A] Balance running low, deposit 400");
account->deposit(400);
}
// (此带有 sleep 的无限循环只是为了示范/模拟耗时操作的目的)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
// 反复尝试从 `account` 取款。
void take_out(BankAccount* account) {
while (true) {
if (account->withdraw(100)) {
log("[B] Withdrew 100, balance now " +
std::to_string(account->balance()));
} else {
log("[B] Failed to withdraw 100");
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
最终,程序会出错:
** Oh no, gone overdrawn: -100! **
这个问题不难发现,特别是 withdraw()
方法写了有用的注释:当涉及多个线程时,余额的值在检查和修改之间可能发生变化。然而,现实世界的此类 bug 会更难被发现——尤其是如果允许编译器在幕后执行各种小技巧以及代码重新排序(如 C++这样)。
包含了 sleep
的调用是为了人为地提高这种错误被命中,并尽早发现的机会;当此类问题实际发生时,他们很可能很少出现或间歇性发生——这使得他们很难被调试。
BankAccount
类是线程兼容的,这意味着它可以在多线程环境中使用,但使用该类的人要确保对其访问需要受到某种外部同步机制的控制。
通过添加内部同步操作,可以将该类转换为线程安全类,这意味着可以安全地从多个线程使用该类:1
// C++ code.
class BankAccount {
public:
BankAccount() : balance_(0) {}
int64_t balance() const {
// 对该作用域上锁 mu_。
const std::lock_guard<std::mutex> with_lock(mu_);
if (balance_ < 0) {
std::cerr << "** Oh no, gone overdrawn: " << balance_ << " **!\n";
std::abort();
}
return balance_;
}
void deposit(uint32_t amount) {
const std::lock_guard<std::mutex> with_lock(mu_);
balance_ += amount;
}
bool withdraw(uint32_t amount) {
const std::lock_guard<std::mutex> with_lock(mu_);
if (balance_ < amount) {
return false;
}
balance_ -= amount;
return true;
}
private:
mutable std::mutex mu_; // 保护 balance_
int64_t balance_;
};
内部字段 balance_
现在由锁 mu_
进行保护:这是一种可以确保同一时刻只有一个线程可以持有该锁的同步对象。调用者可以通过调用 std::mutex::lock()
来获取锁;第二个以及后续调用者调用 std::mutex::lock()
都会被阻塞,直到最开始的调用者调用了 std::mutex::unlock()
,然后被阻塞的线程之一会解除阻塞并继续执行 std::mutex::lock()
。
现在,对余额的所有访问都在持有锁的情况下进行,这确保了此值在检查和修改之间保持一致。std::lock_guard
也值得强调一下:它是一个 RAII 类(参考第 11 条),创建时调用 lock()
并在销毁时调用 unlock()
。这确保了锁在离开作用域时被 unlock,从而减少了在手动调用 lock()
和 unlock()
时出错的概率。
然而,这里的线程安全仍然非常脆弱;摧毁这种安全只需要对类进行一个错误的修改:
// 添加一个新的 C++ 方法...
void pay_interest(int32_t percent) {
// ...但是忘记关于 mu_ 的事情了
int64_t interest = (balance_ * percent) / 100;
balance_ += interest;
}
然后线程安全被摧毁了。2
Rust 中的数据竞争
对于一本关于 Rust 的书来说,本条已经写了够多 C++了,所以考虑将这个类直接转换成 Rust:
#![allow(unused)] fn main() { pub struct BankAccount { balance: i64, } impl BankAccount { pub fn new() -> Self { BankAccount { balance: 0 } } pub fn balance(&self) -> i64 { if self.balance < 0 { panic!("** Oh no, gone overdrawn: {}", self.balance); } self.balance } pub fn deposit(&mut self, amount: i64) { self.balance += amount } pub fn withdraw(&mut self, amount: i64) -> bool { if self.balance < amount { return false; } self.balance -= amount; true } } }
以及尝试永久向账户付款或取款的功能:
#![allow(unused)] fn main() { pub fn pay_in(account: &mut BankAccount) { loop { if account.balance() < 200 { println!("[A] Running low, deposit 400"); account.deposit(400); } std::thread::sleep(std::time::Duration::from_millis(5)); } } pub fn take_out(account: &mut BankAccount) { loop { if account.withdraw(100) { println!("[B] Withdrew 100, balance now {}", account.balance()); } else { println!("[B] Failed to withdraw 100"); } std::thread::sleep(std::time::Duration::from_millis(20)); } } }
这在单线程上下文中工作正常——即使该线程不是主线程:
#![allow(unused)] fn main() { { let mut account = BankAccount::new(); let _payer = std::thread::spawn(move || pay_in(&mut account)); // 在该作用域结尾,`_payer` 线程开始独立运行 // 并且成为 `BankAccount` 的唯一所有者。 } }
但如果简单地尝试跨多个线程使用 BankAccount
:
#![allow(unused)] fn main() { { let mut account = BankAccount::new(); let _taker = std::thread::spawn(move || take_out(&mut account)); let _payer = std::thread::spawn(move || pay_in(&mut account)); } }
会立即编译失败:
error[E0382]: use of moved value: `account`
--> src/main.rs:102:41
|
100 | let mut account = BankAccount::new();
| ----------- move occurs because `account` has type
| `broken::BankAccount`, which does not implement the
| `Copy` trait
101 | let _taker = std::thread::spawn(move || take_out(&mut account));
| ------- ------- variable
| | moved due to
| | use in closure
| |
| value moved into closure here
102 | let _payer = std::thread::spawn(move || pay_in(&mut account));
| ^^^^^^^ ------- use occurs due
| | to use in closure
| |
| value used here after move
借用检查器规则(第 15 条)能告知我们原因:对同一项目有两个可变引用,其中一个超出了允许的范围。借用检查器的规则是,你可以对某个项目有单个可变引用,或者多个(不可变)引用,但是不能同时有二者。
这与本条开头的数据竞争的定义有一个奇怪的相同点:强制只有一个写入者,或多个读取者(但不能两者同时),这意味着不能出现数据竞争。通过强制执行内存安全,Rust “免费” 获得了线程安全。
与 C++ 一样,需要某种同步来使得 struct
线程安全。最常见的机制也称为 Mutex
互斥锁,但 Rust 版本的 Mutex “包装” 受保护的数据,而不是变成一个独立的对象(如 C++中):
#![allow(unused)] fn main() { pub struct BankAccount { balance: std::sync::Mutex<i64>, } }
Mutex
泛型上的 lock()
方法返回具有 RAII 行为的 MutexGuard
对象,如 C++ 的 std::lock_guard
一样:在作用域结束时,guard 被 drop,互斥锁会自动释放。(与 C++ 相比,Rust 的互斥锁并没有手动获取或释放的方法,因为它们会让开发者陷入忘记保持这些调用完全同步的风险中。)
更准确地说,lock()
实际上返回了一个持有 MutexGuard
的 Result
,以应对 Mutex
被中毒的可能性。如果线程在持有锁时失败,就会发生中毒,因为这可能意味着任何被互斥锁保护的不变量已经不再可靠。实际上,锁中毒是非常罕见的(并且当它发生时让程序终止是可取的),因此通常会直接调用 .unwarp()
来处理 Result
(尽管这与第 18 条相违背)。
MutexGuard
对象还通过实现 Deref
和 DerefMut
trait 第 8 条来充当 Mutex
所包含数据的代理,允许它可以进行读取操作。
#![allow(unused)] fn main() { impl BankAccount { pub fn balance(&self) -> i64 { let balance = *self.balance.lock().unwrap(); if balance < 0 { panic!("** Oh no, gone overdrawn: {}", balance); } balance } } }
对于写入操作:
#![allow(unused)] fn main() { impl BankAccount { // 注意:不再需要 `&mut self`。 pub fn deposit(&self, amount: i64) { *self.balance.lock().unwrap() += amount } pub fn withdraw(&self, amount: i64) -> bool { let mut balance = self.balance.lock().unwrap(); if *balance < amount { return false; } *balance -= amount; true } } }
这些方法的签名中隐藏一个有趣的细节:尽管他们正在修改 BankAccount
的余额,但是这些方法参数是 &self
而不是 &mut self
。这是不可避免的:如果多个线程想要保存对同一个 BankAccount
的引用,根据借用检查器规则,这些引用最好是不可变的。这也是第 8 条中描述的内部可变性模式的另一个实例:借用检查实际上从编译时移动到运行时,但是此处具有了同步跨线程行为。如果可变引用已经存在,则尝试获取第二个引用将被阻止,直到第一个引用已被删除。
把共享状态包装在 Mutex
中可以安抚借用检查器,但仍存在生命周期问题(第 14 条)需要修复:
#![allow(unused)] fn main() { { let account = BankAccount::new(); let taker = std::thread::spawn(|| take_out(&account)); let payer = std::thread::spawn(|| pay_in(&account)); // 在该作用域末尾,`account` 被 drop,但是 // `_taker` 和 `_payer` 线程仍在运行,所以 // 仍持有对 `account` 的(不可变)引用。 } }
error[E0373]: closure may outlive the current function, but it borrows `account`
which is owned by the current function
--> src/main.rs:206:40
|
206 | let taker = std::thread::spawn(|| take_out(&account));
| ^^ ------- `account` is
| | borrowed here
| |
| may outlive borrowed value `account`
|
note: function requires argument type to outlive `'static`
--> src/main.rs:206:21
|
206 | let taker = std::thread::spawn(|| take_out(&account));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `account` (and any other
referenced variables), use the `move` keyword
|
206 | let taker = std::thread::spawn(move || take_out(&account));
| ++++
error[E0373]: closure may outlive the current function, but it borrows `account`
which is owned by the current function
--> src/main.rs:207:40
|
207 | let payer = std::thread::spawn(|| pay_in(&account));
| ^^ ------- `account` is
| | borrowed here
| |
| may outlive borrowed value `account`
|
note: function requires argument type to outlive `'static`
--> src/main.rs:207:21
|
207 | let payer = std::thread::spawn(|| pay_in(&account));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `account` (and any other
referenced variables), use the `move` keyword
|
207 | let payer = std::thread::spawn(move || pay_in(&account));
| ++++
给出的错误消息清晰展示问题所在:BankAccount
将在该块末尾被 drop
,但是这里有两个线程引用了它,并可能在这之后继续运行。(编译器给出关于如何修改的建议并没有太大帮助——如果 BankAccount
数据被移动到第一个闭包中,则第二个闭包将无法再接收对它的引用!)
用来确保对象保持活动状态,直到对它的所有引用都消失,这种标准工具是引用计数指针,Rust 用于多线程使用的变体是 std::sync::Arc
:
#![allow(unused)] fn main() { let account = std::sync::Arc::new(BankAccount::new()); account.deposit(1000); let account2 = account.clone(); let _taker = std::thread::spawn(move || take_out(&account2)); let account3 = account.clone(); let _payer = std::thread::spawn(move || pay_in(&account3)); }
每个线程都会获得自己的引用计数指针的副本,并移动至闭包中,并且仅当引用计数降至零时,才会使底层数据 BankAccount
被 drop
。Arc<Mutex<T>>
这种组合在使用共享状态并行性的 Rust 程序中很常见。
从技术细节退一步来看,Rust 完全避免了困扰其他语言的多线程编程的数据竞争问题。当然,这个好消息仅限于 safe Rust——unsafe
代码(第 16 条)和尤其是 FFI 绑定(第 34 条)可能不会避免数据竞争——但是这仍是一个值得注意的现象。
标准库 trait 标记
有两个标准库 trait 会影响线程之间 Rust 对象的使用。这两个 trait 都是标记 trait(第 10 条),他们没有关联的方法,但在多线程场景中对于编译器具有特殊含义:
Send
trait 表明某种类型的数据可以安全地跨线程传输;这种类型的数据的所有权可以从一个线程传递到另一个线程。Sync
trait 表明某种类型的数据可以由多个线程安全地访问,但必须遵守借用检查器规则。
换句话说,我们可以发现,Send
意味着 T
可以在线程间传输,Sync
意味着 &T
可以在线程间传输。
这两个 trait 都是自动 trait:编译器会自动把他们派生(derive)为新类型,只需要该类型的组成部分也实现了 Send
/ Sync
。
大多数安全类型都实现了 Send
和 Sync
,这些类型太多了,所以我们需要清楚的了解哪些类型没有实现这些 trait(以 impl !Sync for Type
来表示)。
没有实现 Send
的类型只能在单个线程使用。一个典型的例子是非同步引用计数指针 Rc<T>
(第 8 条)。这种类型在实现上就明确假定使用单线程(为了速度);它没有尝试同步内部引用计数来供多线程使用。因此,不允许在线程之间传输 Rc<T>
;为此应该用 Arc<T>
(以及额外性能开销)。
未实现 Sync
的类型无法安全地从多个线程通过非mut
引用来使用(因为借用检查器会确保永远不会有多个 mut
引用)。典型的例子是,以不同步方式提供内部可变性的类型,例如 Cell<T>
和 RefCell<T>
。使用 Mutex<T>
或 RwLock<T>
来在多线程环境中提供内部可变性。
原始指针类型,比如 *const T
和 *mut T
也都没实现 Send
和 Sync
;相见第 16 条和第 34 条。
死锁
现在有个坏消息。虽然 Rust 已经解决了数据竞争问题(如前所述),但对于具有共享状态的多线程代码来说,它仍会受到第二个可怕问题的影响:死锁。
考虑一个简化的多人服务器,它是用多线程应用来实现的,可以并行地为许多玩家提供服务。有两个核心的数据结构,可能是玩家的集合(按用户名索引),以及正在进行游戏的集合(按某个唯一的标识符进行索引):
#![allow(unused)] fn main() { struct GameServer { // 从玩家名字到玩家信息的映射 players: Mutex<HashMap<String, Player>>, // 当前游戏,由唯一的 game ID 来索引。 games: Mutex<HashMap<GameId, Game>>, } }
这两种数据结构都有 Mutex
来保护,所以不会出现数据竞争。然而,操作这两种数据结构的代码可能有潜在的问题。两者之间的单一交互可以正常运行:
#![allow(unused)] fn main() { impl GameServer { /// 添加新玩家并将他们加入当前游戏。 fn add_and_join(&self, username: &str, info: Player) -> Option<GameId> { // 添加新玩家 let mut players = self.players.lock().unwrap(); players.insert(username.to_owned(), info); // 找到一个未满的游戏房间来让他们加入 let mut games = self.games.lock().unwrap(); for (id, game) in games.iter_mut() { if game.add_player(username) { return Some(id.clone()); } } None } } }
然而,两个相互独立且锁定的数据结构之间的第二次交互,就会开始产生问题:
#![allow(unused)] fn main() { impl GameServer { /// 通过 `username` 来封禁掉玩家,把他们从任何当前游戏中移除 fn ban_player(&self, username: &str) { // 找到该用户所在所有的游戏房间,并移除。 let mut games = self.games.lock().unwrap(); games .iter_mut() .filter(|(_id, g)| g.has_player(username)) .for_each(|(_id, g)| g.remove_player(username)); // 从用户列表删除他们。 let mut players = self.players.lock().unwrap(); players.remove(username); } } }
为了理解该问题,想象有两个单独的线程使用这两种方法,他们的执行顺序如表 3-1 所示。
表 3-1. 线程死锁顺序
线程 1 | 线程 2 |
---|---|
进入 add_and_join() 并立即获取 players 锁。 | |
进入 ban_player() 并立即获取 games 锁。 | |
尝试获取 games 锁;但目前由线程 2 所有,所以线程 1 阻塞。 | |
尝试获取 players 锁;但目前由线程 1 所有,所以线程 2 阻塞。 |
此时,程序陷入死锁:两个线程都不会继续运行,任何其他线程也不会对两个 Mutex
保护的数据结构中的任何一个执行任何操作。
其根本原因是锁反转:一个函数按照 player
然后 games
的顺序获取锁,而另外一个函数使用相反的顺序(games
然后 players
)。这只是一个普遍问题的简单示例;更长的嵌套锁链也会出现这种情况(线程 1 取得锁 A,然后 B,然后尝试获取 C;线程 2 获取 C,然后尝试获取 A)以及跨更多线程(线程 1 给 A 上锁,然后 B;线程 2 给 B 上锁,然后 C;线程 3 给 C 上锁,然后 A)。
解决此问题的尝试的简单方法有:缩小锁的范围,因此我没有必要同时持有两个锁。
#![allow(unused)] fn main() { /// 添加新玩家,并把他们加入到当前游戏 fn add_and_join(&self, username: &str, info: Player) -> Option<GameId> { // 添加新玩家。 { let mut players = self.players.lock().unwrap(); players.insert(username.to_owned(), info); } // 找到一个未满的游戏房间来让他们加入 { let mut games = self.games.lock().unwrap(); for (id, game) in games.iter_mut() { if game.add_player(username) { return Some(id.clone()); } } } None } /// 通过 `username` 来封禁掉玩家,把他们从任何当前游戏中移除 fn ban_player(&self, username: &str) { // 找到该用户所在所有的游戏房间,并移除。 { let mut games = self.games.lock().unwrap(); games .iter_mut() .filter(|(_id, g)| g.has_player(username)) .for_each(|(_id, g)| g.remove_player(username)); } // 从用户列表删除他们。 { let mut players = self.players.lock().unwrap(); players.remove(username); } } }
(更好的方法是将 players
数据结构的操作封装到 add_player()
和 remove_player()
辅助方法中,来减少忘记关闭作用域范围的可能性。)
这解决了死锁问题,但是又多了数据一致性的问题:如果执行顺序如表 3-2 所示,players
和 games
的数据结构可能会彼此不同步。
表 3-2. 状态不一致顺序
线程 1 | 线程 2 |
---|---|
进入 add_and_join("Alice") 并且添加 Alice 到 players 数据结构中(然后释放 players 锁) | |
进入 ban_player("Alice") 并且从所有 games 中移除 Alice(然后释放 games 锁) | |
从 players 数据结构中删除 Alice;线程 1 已经释放了锁,所以不会阻塞 | |
继续并获取 games 锁(已由线程 2 释放)。持有锁后,添加 "Alice" 到正在进行的游戏中 |
此时,根据 player
数据结构,有一个游戏包含不存在的玩家!
问题的核心是,有两个数据结构都需要保持彼此同步。做到这一点的最好方法是使用一个覆盖二者的同步原语:
#![allow(unused)] fn main() { struct GameState { players: HashMap<String, Player>, games: HashMap<GameId, Game>, } struct GameServer { state: Mutex<GameState>, // ... } }
建议
为了避免共享状态并行性所出现的问题,最明显的建议就是避免共享状态的并行性。Rust 程序设计语言中引用了 Go 语言文档:“不用通过共享内存进行通信;相反,通过通信来共享内存。”
Go 语言内置了用于这种操作的管道;对 Rust 来说,相同的功能可以在标准库 std::sync::mpsc
模块中找到:函数 channel()
返回一个 (Sender, Receiver)
元组对,允许特性类型的值在线程之间进行通信。
如果共享状态进行并发无法避免,那么有一些方法可以减少编写容易出现死锁问题的代码:
- 将必须保持一致的数据结构包含在单个互斥锁中。
- 保持互斥锁的范围越小越明显越好;尽可能使用辅助方法来获取和设置锁所包含的内容。
- 避免调用持有锁的闭包;这会使得代码受到将来可能添加到代码库中的任何闭包的影响。
- 同样,避免将
MutexGuard
返回给调用者:从死锁的角度看,这就像是分发一把已经上膛的枪。 - 在 CI 系统(第 32 条)中加入死锁检测工具,例如
no_deadlocks
,ThreadSanitizer,或parking_lot::deadlock
。 - 最后手段:设计、记录、测试并严格执行一个上锁的层次结构,该结构描述了允许\需求的锁定顺序。这应该作为最后的手段,因为任何依赖于工程师从不犯错策略从长远来看都很可能失败。
更抽象地说,多线程代码应该应用于以下一般建议的理想场所:倾向于编写明显没有错误的简单代码,而不是编写复杂到不明显有错误的代码。
注释
第三种类的行为是*线程对立(thread-hostile)*的:即使对它的所有访问都是对外同步的,代码在多线程环境中也是危险的。
Clang C++ 编译器包含一个 -Wthread-safety
选项,有时也称为注释,它允许通过关于哪一个互斥锁保护该数据的信息来注释该数据,并通过关于该函数获取锁的信息来注释该函数。当这些不变量被破坏时,会在编译期产生错误,就像 Rust 一样;然而,并没有强制使用这些注释——例如,当一个线程兼容的库第一个在多线程环境中使用时。
原文点这里查看
不要 panic
“它看起来非常复杂,这就是为什么它紧凑的塑料盖子上用大大的友好字母写着 DON'T PANIC 的原因之一。”——Douglas Adams
本条的标题应当更准确的描述为更应该返回 Result
而不是使用 panic!
(但是不要 panic 更吸引人)。
Rust 的 panic 机制主要是针对程序中不可恢复的错误而设计的,默认情况下会终止发出 panic!
的线程。然而,除了默认情况还有其他选择。
特别是,来自具有异常系统的语言(例如 Java 或者 C++)的 Rust 新手通常会使用 std::panic::catch_unwind
作为模拟异常的方法,因为这似乎提供了一种在调用栈上捕获 panic 的机制。
考虑一个因无效输入而 panic 的函数:
#![allow(unused)] fn main() { fn divide(a: i64, b: i64) -> i64 { if b == 0 { panic!("Cowardly refusing to divide by zero!"); } a / b } }
尝试用无效输入调用它,会按预期一样报错:
#![allow(unused)] fn main() { // 尝试去计算 0/0 是什么... let result = divide(0, 0); }
thread 'main' panicked at 'Cowardly refusing to divide by zero!', main.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
以下包装函数使用 catch_unwind
来捕获 panic:
#![allow(unused)] fn main() { fn divide_recover(a: i64, b: i64, default: i64) -> i64 { let result = std::panic::catch_unwind(|| divide(a, b)); match result { Ok(x) => x, Err(_) => default, } } }
似乎可以正常运行并模拟 catch
:
#![allow(unused)] fn main() { let result = divide_recover(0, 0, 42); println!("result = {result}"); }
result = 42
然而,外在具有欺骗性。这种方法的第一个问题是,panic 并不总是被回退(unwind);有一个编译器选项(可通过 Cargo.toml 配置文件配置)可以改变 panic 后的行为,以便立即终止进程:
thread 'main' panicked at 'Cowardly refusing to divide by zero!', main.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
/bin/sh: line 1: 29100 Abort trap: 6 cargo run --release
这使得任何尝试模拟异常的方式完全受到整个项目配置文件的支配。还有一种情况是,无论编译器或项目配置如何,某些目标平台(例如,WebAssembly)总会因 panic 而终止。
一个更微妙的问题是,panic 处理引发了异常安全:如果 panic 发生在对数据结构进行操作的过程中,则会消除对数据结构已处于自一致状态的任何保证。自 20 世纪 90 年代以来,在存在异常的情况下保持内部不变量已被证明极其困难;1这也是为什么 Google (众所周知)禁止在其 C++ 代码中使用异常的主要原因之一。
最后,panic 传播与 FFI(外部函数接口)绑定(第 34 条)的交互也很差; 应使用 catch_unwind
来防止 Rust 代码中的 panic 跨 FFI 绑定传播到非 Rust 调用代码。
那么除了 panic!
之外还有什么方法可以处理错误呢?对于库代码,最好的替代方案就是返回具有合适错误类型的 Result
(第 4 条),以此将错误视为其他人的问题。这允许使用该库的人自行决定下一步该做什么——这可能涉及通过 ?
运算符将问题传播给队列中的下一个调用者。
错误传播必须在某处停止,如果你可以控制 main
的行为,那么根据经验适当调用 panic!
是可以的(或者 unwrap()
,expect()
等等);此时,就没有其他的调用者可以将错误传播给它了。
即使在库代码中,panic!
另一个合理的用处是在极少数遇到错误的情况下,并且你不希望用户必须通过 .unwrap()
调用来搞乱他们的代码。
如果错误情况应当发生只是因为(比如说)内部数据损坏,而不是由于无效输入,那么触发 panic!
是合理的。
允许无效输入引发的 panic 有时甚至是有用的,但这种无效输入应该是不常见的。这样的方法在相关的入点成对出现时效果最好:
- “万无一失”函数,其签名意味着它总是会成功运行(如果不能成功就会 panic)
- “可能出错”函数,返回一个
Result
对于前者,Rust 的 API 指南 建议 panic!
应该记录在内联文档的特定部分中(第 27 条)。
标准库中的 String::from_utf8_unchecked
和 String::from_utf8
的入点是后者的示例(尽管在这种情况下,panic 实际上被推迟到使用无效输入来构造 String
的位置)。
假设你正在尝试遵循本条给出的建议,则需要牢记以下几点。首先,panic 可能以不同形式出现;避免 panic!
的同时也要避免以下情况:
更难发现的情况如下:
slice[index]
索引超出范围x / y
当y
是零时
关于避免 panic 的第二种观察是,一个依赖于人类持续保持警觉的计划永远不是一个好主意。
然而,让机器去持续保持警觉是另一回儿事:向你的系统持续集成(详见第 32 条)检查系统,以发现新的,潜在的 panic 代码要可靠的多。一个简单的版本可以是针对最常见的 panic 入点进行简单地 grep(如前所述);更彻底的检查可以涉及使用 Rust 生态系统中的其他工具(第 31 条),例如设置一个构建变体,并引入 no_panic
crate。
注释
Tom Cargill 在 1994 年的文章《C++ Report》中探讨了保持 C++ 模板代码的异常安全性是多么困难,Herb Sutter 的 Guru of the Week #8 也有类似的讨论。
原文点这里查看
第 19 条:避免反射
从其他语言转向 Rust 的程序员通常习惯于将反射作为他们工具箱的一部分。他们会浪费大量时间尝试在 Rust 中实现基于反射的设计,结果他们会发现他们尝试做的事情即使能做成,也会很糟糕。本条希望通过描述 Rust 关于反射有的和没有的,以及可以用什么来代替,从而节省探索死胡同所浪费的时间。
反射是程序在运行时上检查自身的能力。给运行时丢一个数据,会有以下问题:
- 可以确定有关该数据的类型的哪些信息?
- 可以利用这些信息做什么?
具有完全反射支持的语言对这些问题有很多解答。具有反射的语言通常根据反射信息在运行时上支持以下或全部功能:
- 判断一个数据的类型
- 发掘其内容
- 更改其字段
- 调用其方法
具有这种程度的反射支持的语言也往往是动态类型语言(比如 Python, Ruby),但是还有一些出名的静态类型语言也支持反射,特别是 Java 和 Go。
Rust 不支持这种类型的反射,这使得避免反射的建议在这种层次上非常容易实现——但其实是不可能的。对于来自完整支持反射的语言的程序员来说,乍一看,反射的缺席似乎是一个重大差距,但 Rust 的其他功能提供了许多解决相同问题的替代方法。
C++ 有一种有限制的反射,称为运行时类型识别(run-time type identification, RTTI)。typeid
操作符为多态类型的对象(大概是:有虚函数的类),返回每个类型的独一无二的标识符。
typeid
:可以通过基类引用来恢复对象的具体类dynamic_cast<T>
:在安全且正确的情况下,允许将基类转换为派生类
Rust 也不支持这种 RTTI 风格的反射,延续了本条建议所遵循的主题。
Rust 确实支持一些在 std::any
模块中提供的类似功能特性,但它们功能受限(接下来将探索的)并且最好避免使用,除非没有其他代替方案。
std::any
中第一个类似反射的功能乍一看非常神奇——是一种确定数据类型名字的方法。以下示例使用了用户定义的 tname()
函数:
#![allow(unused)] fn main() { let x = 42u32; let y = vec![3, 4, 2]; println!("x: {} = {}", tname(&x), x); println!("y: {} = {:?}", tname(&y), y); }
输出在值的旁边显示类型:
x: u32 = 42
y: alloc::vec::Vec<i32> = [3, 4, 2]
tname()
的实现揭示了编译器的秘密:这是一个泛型函数(根据第 12 条),所以实际上每次调用都会生成一个不同的函数(tname::<u32>
或 tname::<Square>
):
#![allow(unused)] fn main() { fn tname<T: ?Sized>(_v: &T) -> &'static str { std::any::type_name::<T>() } }
函数的实现由 std::any::type_name<T>
库函数提供,也是泛型函数。该函数只能访问编译期信息;没有代码在运行时来确定类型。第 12 条中返回到 trait 对象的类型证实了这一点:
#![allow(unused)] fn main() { let square = Square::new(1, 2, 2); let draw: &dyn Draw = □ let shape: &dyn Shape = □ println!("square: {}", tname(&square)); println!("shape: {}", tname(&shape)); println!("draw: {}", tname(&draw)); }
只有 trait 对象的类型可用,而不是具体底层数据的类型(Square
):
square: reflection::Square
shape: &dyn reflection::Shape
draw: &dyn reflection::Draw
type_name
返回的字符串仅能用于诊断——它显然是一个“尽力了”的助手,其内容可能改变并且可能不唯一——所以不要尝试解析 type_name
的结果。如果你需要全局唯一的类型标识符,请使用 TypeId
代替:
#![allow(unused)] fn main() { use std::any::TypeId; fn type_id<T: 'static + ?Sized>(_v: &T) -> TypeId { TypeId::of::<T>() } }
#![allow(unused)] fn main() { println!("x has {:?}", type_id(&x)); println!("y has {:?}", type_id(&y)); }
x has TypeId { t: 18349839772473174998 }
y has TypeId { t: 2366424454607613595 }
输出对人类来说是不可读的,但唯一性确保结果可以在代码中使用。但是,通常最好不要直接用 TypeId
,而是使用 std::any::Any
trait 来替代,因为标准库有用于处理 Any
实例的附加方法(如下)。
Any
trait 有一个方法 [type_id()
],对实现了该 trait 的类型返回一个 TypeId
值。不过,你不能自行实现该 trait,因为 Any
已经为大多数任意类型 T
提供了全面的实现:
#![allow(unused)] fn main() { impl<T: 'static + ?Sized> Any for T { fn type_id(&self) -> TypeId { TypeId::of::<T>() } } }
这种一揽子实现并未覆盖所有类型 T
:T: 'static
生命周期约束意味着如果 T
包含任何具有非'static
生命周期的引用,那么 TypeId
就没有为 T
实现。这是有意的限制,因为生命周期并不完全属于该类型:TypdId::of::<&'a T>
与 TypeId::of::<&'b T>
虽然是相同的,但他俩生命周期不同,这增加了混淆和不安全代码的可能性(译者注:意思是 type_id()
对他俩只会返回相同的 TypeId
,但是他俩生命周期是不同的,生命周期不是类型的一部分,判断他俩的 id
会导致混淆并且不安全,所以要加限制为 'static
)。
回想一下第 8 条,trait 对象是一个胖指针,存储了指向底层具体数据的指针,以及指向该 trait 实现的虚表的指针。对于 Any
,虚表只有一个入点,即用于返回项目类型的 type_id()
方法,如图 3-4 所示:
#![allow(unused)] fn main() { let x_any: Box<dyn Any> = Box::new(42u64); let y_any: Box<dyn Any> = Box::new(Square::new(3, 4, 3)); }
图 3-4. Any
trait 对象,每个都有指向具体数据以及虚表的指针
除了一些间接寻址之外,一个 dyn Any
trait 对象实际上就是一个裸指针和一个类型标识符的结合体。这意味着标准库可以为一个 dyn Any
trait 对象提供一些附加的已定义的通用方法;这些方法对于某些附加类型 T
是通用的:
is::<T>()
:指示了 trait 对象是否等于某个特定的其他类型T
downcast_ref::<T>()
:返回对具体类型T
的引用,前提是 trait 对象的类型与T
匹配downcast_mut::<T>()
:返回对具体类型T
的可变引用,前提是 trait 对象的类型与T
匹配
你会发现 Any
trait 只是近似反射的功能:语法选择了(在编译时)显式构建一些东西(&dyn Any
)来跟踪该数据的编译时类型以及位置。仅当构建 Any
trait 对象的开销已经发生时,(比如)向下转型回原始类型的能力才是可能的。
Rust 中很少有场景会在一个数据上关联不同的编译时类型和运行时类型。其中最主要的就是 trait objects:具体类型为 Square
的数据可以被强制转换为该类型实现的 trait 对象 dyn Shape
。这种强制是从一个简单的指针(对象/数据)构建了一个胖指针(对象 + 虚表)。
回想第 12 条,Rust 的 trait 对象并不是真正面向对象的。Square
并不是一个 Shape
;只是 Square
实现了 Shape
的接口。对于 trait 约束也是如此:一个 trait 约束 Shape: Draw
并不意味着 is-a;这只意味着也实现了,因为 Shape
的虚表包含了 Draw
的方法的入点。
对于一些简单的 trait 约束:
#![allow(unused)] fn main() { trait Draw: Debug { fn bounds(&self) -> Bounds; } trait Shape: Draw { fn render_in(&self, bounds: Bounds); fn render(&self) { self.render_in(overlap(SCREEN_BOUNDS, self.bounds())); } } }
等效的 trait 对象:
#![allow(unused)] fn main() { let square = Square::new(1, 2, 2); let draw: &dyn Draw = □ let shape: &dyn Shape = □ }
带有箭头的图片(如图 3-5;与第 12 条重复)使问题变得清晰明了;给定一个 dyn Shape
对象,没有直接的方法来构建 dyn Draw
trait 对象,因为没有方法变回 impl Draw for Square
的虚表——即使其内容的相关部分(Square::bounds()
方法的地址)理论上是可以恢复的。(这可能在 Rust 的更高版本中发生变化;请参阅本条最后部分。)
图 3-5. 用于 trait 约束的 trait 对象,用于 Draw
和 Shape
的不同虚表
与上图对比,显然,显式构造的 &dyn Any
trait 对象没有用处。Any
允许恢复底层数据的具体类型,但没有方法可以查看它实现了哪些 trait,或者访问可能允许创建的 trait 对象的相关虚表。
那么可以用什么来替代呢?
最主要的工具是 trait 定义,这与其他语言的建议一致——Effective Java第 65 条建议,“接口比反射更好”。如果代码需要依赖于某个数据的某些行为的可用性,请为该行为编写一个 trait(第 2 条)。即使所需的行为无法表达为一组方法签名,也可以使用标记 trait 来表明所需行为所需的遵守,这比(例如)检查类名的特定前缀更安全且高效。
需要 trait 对象的代码也可以用于哪些在程序链接时尚未提供支持代码的对象,因为这些代码是在运行时动态加载的(通过 dlopen(3)
或类似方法)——这意味着范型的单态化(第 2 条)是不可能的。
相关地,反射有时也被用在其他语言中,以允许同一依赖库的多个不兼容版本一次加载到程序中,绕过“只能有一个”的链接约束。这在 Rust 中是不需要的,因为 Cargo 已经可以处理同一个库的多个版本(第 25 条)。
最后,宏——尤其是 derive
宏——可以被用于自动生成在编译期了解数据类型的代码,作为一种更高效、更类型安全的等效代码,相当于在运行时解析数据内容的代码。第 28 条讨论了 Rust 的宏系统。
Rust 未来版本中的向上转型
本条的文本首次于 2021 年编写,期间是一直保持准确的,直到本书准备于 2024 年出版——此时 Rust 将会添加一个新功能,该功能会更改一些细节。
新的“trait 向上转型”功能,当 U
是 T
的父 trait 时(trait T: U {...}
),可以将 trait 对象 dyn T
向上转型为 dyn U
。该功能在正式发布前已在 #![feature(trait_upcasting)]
开启,预计在 Rust 1.76 版本。
对于之前的示例,这意味着 &dyn Shape
trait 对象可以被转型为 &dyn Draw
trait 对象,更接近于 Liskov 替换原则中的 is-a 关系。允许这种转换会对虚表实现的内部细节产生连锁反应,它们可能会变得比上图所示的版本更复杂。
然而,该数据的核心不受影响——Any
trait 没有父 trait,所以其向上转型的能力不会增加任何功能。
原文点这里查看
第 20 条:避免过度优化的诱惑
Rust让你能写出既安全又高效的零拷贝算法,但这不意味着你写的每个算法都得这样。—— trentj
这本书里的大部分内容都是帮助程序员熟悉Rust及其惯用法。但本章节主要讨论另一个问题,当程序员过度追求Rust的性能潜力时,可能会牺牲代码的易用性和可维护性。
数据结构与分配
Rust的引用就像其他语言里的指针,可以让你不复制数据就能重用它。不同的是,Rust的引用生命周期和借用规则让你能安全地这么做。但是,要遵循这些规则(第 15 条里有讲),可能会写出更难用的代码。
这对数据结构来说尤其重要。你可以选择是分配一个数据结构里的新副本,还是引用一个已存在的副本。
例如,可以考虑这样的一段代码,它解析一个字节数据流,提取出类型-长度-值(TLV)结构的数据,数据是这样传输的:1
- 一个描述值类型的字节,保存在type_code字段里。
- 一个描述值长度的字节,创建指定长度的切片。
- 接着是值的指定字节数,保存在 value 字段里:
#![allow(unused)] fn main() { /// A type-length-value (TLV) from a data stream. #[derive(Clone, Debug)] pub struct Tlv<'a> { pub type_code: u8, pub value: &'a [u8], } pub type Error = &'static str; // Some local error type. /// Extract the next TLV from the `input`, also returning the remaining /// unprocessed data. pub fn get_next_tlv(input: &[u8]) -> Result<(Tlv, &[u8]), Error> { if input.len() < 2 { return Err("too short for a TLV"); } // The TL parts of the TLV are one byte each. let type_code = input[0]; let len = input[1] as usize; if 2 + len > input.len() { return Err("TLV longer than remaining data"); } let tlv = Tlv { type_code, // Reference the relevant chunk of input data value: &input[2..2 + len], }; Ok((tlv, &input[2 + len..])) } }
这个Tlv数据结构很高效,因为它引用了输入数据的相关部分,而不是复制任何数据。Rust的内存安全性保证了引用始终有效。这对于某些场景来说很完美,但如果需要保留数据结构实例(如第 15 条所述),事情就会变得尴尬。
例如,考虑一个以TLV数据格式接收消息的网络服务器。接收到的数据可以解析为Tlv实例,但那些实例的生命周期将与传入消息的生命周期相匹配 —— 这可能是堆上的Vec
如果服务器代码曾经想要存储传入消息以便稍后查询,这就会引起问题:
#![allow(unused)] fn main() { pub struct NetworkServer<'a> { // ... /// Most recent max-size message. max_size: Option<Tlv<'a>>, } /// Message type code for a set-maximum-size message. const SET_MAX_SIZE: u8 = 0x01; impl<'a> NetworkServer<'a> { pub fn process(&mut self, mut data: &'a [u8]) -> Result<(), Error> { while !data.is_empty() { let (tlv, rest) = get_next_tlv(data)?; match tlv.type_code { SET_MAX_SIZE => { // Save off the most recent `SET_MAX_SIZE` message. self.max_size = Some(tlv); } // (Deal with other message types) // ... _ => return Err("unknown message type"), } data = rest; // Process remaining data on next iteration. } Ok(()) } } }
这段代码可以编译,但实际上无法使用:NetworkServer的生命周期必须小于被其process() 方法处理的数据的生命周期。这意味着一个简单的处理循环:
#![allow(unused)] fn main() { let mut server = NetworkServer::default(); while !server.done() { // Read data into a fresh vector. let data: Vec<u8> = read_data_from_socket(); if let Err(e) = server.process(&data) { log::error!("Failed to process data: {:?}", e); } } }
这段代码编译失败,因为临时数据的生命周期被错误地绑定在运行时间更长的的服务器上:
#![allow(unused)] fn main() { error[E0597]: `data` does not live long enough --> src/main.rs:375:40 | 372 | while !server.done() { | ------------- borrow later used here 373 | // Read data into a fresh vector. 374 | let data: Vec<u8> = read_data_from_socket(); | ---- binding `data` declared here 375 | if let Err(e) = server.process(&data) { | ^^^^^ borrowed value does not live | long enough ... 378 | } | - `data` dropped here while still borrowed }
改变代码,使其重用一个生命周期更长的缓冲区,同样没有帮助:
#![allow(unused)] fn main() { let mut perma_buffer = [0u8; 256]; let mut server = NetworkServer::default(); // lifetime within `perma_buffer` while !server.done() { // Reuse the same buffer for the next load of data. read_data_into_buffer(&mut perma_buffer); if let Err(e) = server.process(&perma_buffer) { log::error!("Failed to process data: {:?}", e); } } }
这次,编译器抱怨代码试图在保持对同一缓冲区的引用的同时,也提供了一个可变引用:
#![allow(unused)] fn main() { error[E0502]: cannot borrow `perma_buffer` as mutable because it is also borrowed as immutable --> src/main.rs:353:31 | 353 | read_data_into_buffer(&mut perma_buffer); | ^^^^^^^^^^^^^^^^^ mutable borrow occurs here 354 | if let Err(e) = server.process(&perma_buffer) { | ----------------------------- | | | | | immutable borrow occurs here | immutable borrow later used here }
核心问题是Tlv结构引用了临时的数据 —— 这对于临时处理是没问题的,但与存储状态以备后用根本不兼容。然而,如果将Tlv数据结构转换为拥有其内容:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] pub struct Tlv { pub type_code: u8, pub value: Vec<u8>, // owned heap data } }
并且相应地调整 get_next_tlv() 代码,包括对 .to_vec() 的额外调用:
#![allow(unused)] fn main() { // ... let tlv = Tlv { type_code, // Copy the relevant chunk of data to the heap. // The length field in the TLV is a single `u8`, // so this copies at most 256 bytes. value: input[2..2 + len].to_vec(), }; }
这样一来,服务器代码的工作就轻松多了。拥有数据的Tlv结构没有生命周期参数,所以服务器数据结构也不需要,两种处理循环的变体都能正常工作。
谁害怕可怕的复制?
程序员过于迷恋减少复制的一个原因是,Rust通常使复制和内存分配显式化。像 .to_vec() 或 .clone() 这样的方法的显式调用,或像 Box::new() 这样的函数的调用,清楚地表明了复制和内存分配正在发生。这与C++ 形成了鲜明的对比,在C++ 中,很容易无意中写出在复制构造函数或赋值运算符下偷偷进行内存分配的代码。
使分配或复制操作可见而不是隐藏,并不是将其优化掉的好理由,尤其是如果这样做是以牺牲可用性为代价的话。在许多情况下,首先关注可用性更有意义,只有在性能真正成为问题并且基准测试(参考第 30 条)表明减少复制将产生显著影响时,才进行微调以达到最佳效率。
此外,代码的效率通常只有在需要扩展大规模使用时,才显得重要。如果事实证明代码的权衡是错误的,并且无法很好地应对数百万用户的使用 —— 好吧,那确实是一个不错的问题。
然而,还有几个具体的要点需要记住。首先是隐藏在“通常”这个词后面的,当指出复制通常是可见的时候。一个重要例外是Copy类型,编译器会无声地随意进行复制,从移动语义切换到复制语义。因此,第 10 条中的建议在这里值得重复:除非位复制是有效且快速的,否则不要实现Copy。但反过来说也成立:如果位复制是有效且快速的,那么请考虑实现Copy。例如,如果不携带额外数据的枚举类型派生自Copy,通常会更容易使用。
第二个可能相关的点是与no_std使用的潜在权衡。第 33 条建议,通常只需进行少量修改就可以编写与no_std兼容的代码,完全避免内存分配的代码使这一点更加简单。然而,针对支持堆分配的no_std环境(通过第 33 条中描述的alloc库)可能是可用性和no_std支持之间最佳平衡。
引用和智能指针
直到最近,我试着做了一个实验,不去纠结假设中的完美代码。相反,当我需要时,我会调用 .clone(),并使用Arc将本地对象更平滑地传递给线程和futures。
这感觉非常美妙。 ——乔希·特里普莱特(https://oreil.ly/1ViCT)
设计一个数据结构,使其拥有自己的内容,确实可以带来更好的用户体验,但如果多个数据结构需要使用相同的信息,仍然可能存在问题。如果数据是不可变的,那么每个地方都有自己的副本是可以的,但如果信息可能会改变(这在非常常见的情况下),那么多个副本意味着有多个需要同步更新的地方。
使用 Rust 的智能指针,例如 Rc 和 Arc,能有效地解决一些问题。它们允许设计从单一所有者模型转变为共享所有者模型。Rc(用于单线程代码)和 Arc(用于多线程代码)智能指针通过引用计数支持这种共享所有权模型。如果需要可变性,它们通常与一个允许内部可变性的内部类型配对,这一过程独立于 Rust 的借用检查规则:
- RefCell: 用于实现单线程代码中的内部可变性,常见的组合形式是 Rc<RefCell
> 。 - Mutex: 用于实现多线程代码中的内部可变性时(参考第 17 条),常见的组合形式是 Arc<Mutex
>。
这个转换在第 15 条的 GuestRegister 示例中有更详细的介绍,但这里的重点是,你不必将 Rust 的智能指针视为最后的手段。如果你的设计使用智能指针而不是复杂的相互连接的引用生命周期,这并不意味着承认失败 —— 智能指针可以带来更简单、更可维护、更易用的设计。
在 Rust 语言中,你不能将字段命名为 “type”,因为这是一个被保留的关键字。如果你确实需要使用这个名称,可以通过在前面加上 r# 来绕过这个限制(https://oreil.ly/oC8VO),比如将字段命名为 r#type: u8。但大多数情况下,更简单的方法是选择一个不同的字段名称。
依赖项
"当神想要惩罚我们时,他们会满足我们的祈祷。" —— 奥斯卡·王尔德
几十年来,代码重用的想法仅仅是一个梦想。编写一次代码,打包成库,并在许多不同的应用程序中重用的想法是一个理想,这种理想只实现了少数标准库和公司内部工具。
互联网的增长和开源软件的兴起最终改变了这一切。第一个公开可访问的仓库,它收集了大量的有用库、工具和辅助程序,全部打包以便于重用,这就是 CPAN
:综合 Perl
存档网络,自1995年以来在线。今天,几乎每种现代语言都有一个全面的开源库集合,存放在一个包仓库中,这使得添加新依赖项的过程变得简单快捷。1
然而,随着这种便捷、方便和速度,新的问题也随之而来。通常,重用现有代码仍然比自己编写代码容易,但依赖他人的代码会带来潜在的风险和陷阱。本书的这一章将帮助您意识到这些问题。
重点是特别针对 Rust
,以及使用 cargo
工具,但许多关注的问题、主题和议题同样适用于其他工具链(和其他语言)。
显著的例外是C和C++,其中包管理仍然有些碎片化。
第 21 条:理解语义化版本
“如果我们承认语义化版本号(SemVer)是一个有损的评估,并且仅代表可能的变更范围的一个子集,那么就可以将其视为一个有局限的工具。”
—— Titus Winters “《Google 软件工程》(O'Reilly)”
Rust 的包管理器 Cargo 允许使用语义化版本号(semver)自动选择依赖项的版本(见第 25 条)。Cargo.toml 中下面的内容:
[dependencies]
serde = "1.4"
表示针对这个依赖项, cargo
可以接受的语义化版本。关于可以接受的版本范围的详细描述可以参见官方文档,但是最常见的语义化版本如下所示:
"1.2.3"
:和 1.2.3 版本兼容的任何版本都可以"^1.2.3"
:同上,语义上更清晰"=1.2.3"
:指定的具体版本,不接受任何其他替代版本"~1.2.3"
:和 1.2.3 版本兼容的任何版本,但是仅允许最后一段不同(也即:1.2.4 可以,但是 1.3.0 就不可以了)"1.2.*"
:任何和通配符匹配的版本的都可以
表 4-1 列出了不同的语义化版本和其可接受版本的对照。
表 4-1. Cargo 依赖项版本规范
版本 | 1.2.2 | 1.2.3 | 1.2.4 | 1.3.0 | 2.0.0 |
---|---|---|---|---|---|
"1.2.3" | 否 | 是 | 是 | 是 | 否 |
"^1.2.3" | 否 | 是 | 是 | 是 | 否 |
"=1.2.3" | 否 | 是 | 否 | 否 | 否 |
"~1.2.3" | 否 | 是 | 是 | 否 | 否 |
"1.2.*" | 是 | 是 | 是 | 否 | 否 |
"1.*" | 是 | 是 | 是 | 是 | 否 |
"*" | 是 | 是 | 是 | 是 | 是 |
Cargo 在选择依赖项的版本时,会在符合条件的版本中选择最新的。
语义化版本是 cargo
依赖处理过程中非常核心的概念,本条目将详细介绍语义化版本。
语义化版本基础
关于语义化版本的概述在官方文档中已经明确了,这里我们将其摘录过来:
版本号的格式为:主版本.次版本.补丁版本。增加版本中各段数值的含义如下:
- 主版本:当做了了一些不兼容了的变更时
- 次版本:增加了一些功能,但是向后兼容
- 补丁版本:修复了一些问题,同时保持向后兼容
语义化版本号细则中,还约定了非常重要的一点:
- 软件包的某个版本一旦发布,不可以对其内容进行任何变更。如需变更,必须发布新的版本。
换句话说就是:
- 变更任何内容,都需要发布一个补丁版本
- 当需要增加内容,但是已经使用依赖包的用户可以正常编译并且应用可以正常运行,需要升级次版本号
- 当移除或者改变 API 中的内容时,需要升级主版本号
语义化版本规则文档中,还有一条非常重要:
- 初始版本的主版本号应该为 0(0.y.z),表示可能会做出较大的变更,这个版本的 API 应该被视为不稳定的版本
针对这个规则,Cargo 的处理方式如下:最左的非零版本号不同的,代表着不兼容的变更。也就是说,0.2.3 和 0.3.0 包含了不兼容的变更,同样,0.0.4 和 0.0.5 也是不兼容的。
Crate 作者语义化版本控制指南
“理论上,理论和实际会保持一致。实际上,并非如此”
理论上说,crate 作者遵守语义化版本号第一条规则是非常容易的:但凡变更任何内容,都需要发布一个新版本。Git 标签可以帮助我们做到这一点:除非你使用 --force
选项移动标签,否则一个 Git 标签一定关联到某个确定的提交。发布到 crates.io 的 crate 也被强制遵守这条规则:同一个 crate 再次发布已有版本号的时候会被拒绝。唯一的不便就是,你在刚刚发布的版本中发现了一个小问题,却不得不抑制住自己想马上修复它的冲动。
语义化版本规范是为了确保 API 的兼容性。如果你只是做了微小的变更,并没有修改 API 的行为,此时仅需发布一个补丁版本就足够了。(但是,如果你的 crate 被广泛依赖,就要留神海勒姆法则:无论你对代码做出多么微小的改动,都有人依赖老版本的行为,哪怕 API 本身并未改变。)
对于 crate 作者而言,要想完全遵守后面几条规则却非易事,因为需要精准判断所做变更是否向后兼容。有些变更明显不具备兼容性,例如,移除了一些类型,或者修改了方法签名等;另外一些,则明显兼容的,例如:给结构体增加方法,或者增加常量定义等。但是除此之外,还有很多不太容易判断的灰色地带。
为了帮助作者更好的判断变更的兼容性,Cargo 手册中有详细的描述。大部分都是意料之中的,但是也有一些值得格外留意的:
- 通常而言,增加新的条目在兼容性方面不会带来风险。但是可能会存在一些问题:使用这个 crate 的代码正好也增加了一些条目且和 crate 中条目重名了。
- 如果用户采用通配符导入的方式使用 crate 中的条目,通常会带来风险,因为 crate 中的所有条目都会被通配符引入到用户代码的主命名空间。在第 23 条中,明确不建议使用通配符导入。
- 即使没有采用通配符导入的方式,给已有 trait 增加包含默认实现的方法(见第 13 条),或者增加内部方法也可能导致与已经存在的名称冲突。
- 由于 Rust 要求涵盖所有的可能性,所以变更可能性集合也会导致不兼容。
- 针对
enum
做match
要求代码涵盖所有的可能性,所以如果 crate 中的enum
增加了一个值,会导致不兼容(除非enum
标记为non_exhaustive
。但是,增加non_exhaustive
标记本身也是一种不兼容变更)。 - 显式创建一个
struct
实例时,要求提供该struct
所有字段的初始值,所以,[给struct
增加公有初始化字段]的变更,也是不兼容的,私有字段则不存在这个问题。我们可以通过将struct
标记non_exhaustive
来阻止外部用户显式创建实例。
- 针对
- 将一个 trait 从对象安全的变更为非对象安全的(见第 12 条),属于不兼容变更。那些将这个 trait 当作 trait 对象来使用的代码将无法编译。
- 给 trait 增加泛化实现(blanket implementation)也是不兼容变更。如果用户已经在自己代码中给这个 trait 增加实现了,就会存在两个相互冲突的实现。
- 修改开源 crate 的许可证是不兼容变更。其他用户可能由于许可证的变更而无法继续使用。许可证应作为 API 的一部分来看待。
- 修改 crate 默认启用的特性(见第 26 条)是一种潜在的不兼容变更,移除默认特性也是类似,除非被移除的这个特性已经没有实际作用了。新增默认特性也可能导致兼容性问题。所以,默认启用的特性集合也应作为 API 的一部分来看待。
- 修改库代码的时候使用了 Rust 最新特性的,也可能导致兼容性问题,因为使用这个 crate 的用户可能还没有更新他们的编译器版本,以支持你所使用的新特性。但是,大部分的 crate 都有对 Rust 最低版本(MSRV)的要求,升级所需的最低版本被视为兼容变更。所以,要考虑 MSRV 是否应该成为 API 的一部分。
显而易见,crate 中对外公开的条目越少,就越不容易引发不兼容变更(见第 22 条)。
但是,不可否认的是,通过将所有的公共 API 条目进行逐一比较来确保两个发布版本之间的兼容性,是一个非常耗时的过程,充其量不过是对变更水平做一个大致的评估。鉴于这种对比过程比较机械化,所以希望可以有好用的工具(见第 31 条)来简化这个过程。1
如果你确实要发布一个不兼容的主版本变更,最好确保在即使是较大的变更之后,依然可以提供相应的功能。为了方便用户使用,如果可能的话,建议按照下面的顺序进行变更:
- 发布一个次版本变更,其中包含新的 API 同时将旧的 API 标记为
deprecated
,并且,应包含一份版本升级迁移指导。 - 然后发布一个移除了旧的 API 的主版本变更。
这里还有一层隐含的意思:要让不兼容的变更确实是不兼容的。如果变更对于现有用户是不兼容的,但是又可以重用相同的 API,那就不要这样做。强制更改类型(并进行主版本升级)以确保用户不会无意中错误地使用新版本。
对于 API 中不太明确的部分(例如 MSRV 或许可证)可以考虑设置一个持续集成(CI)检查(见第 32 条)来检测这些变化,并根据需要使用例如 cargo-deny
这样的工具(见第 25 条)。
最后,不要因为担心承诺 API 固定下来就害怕发布 1.0.0 版本,许多 crate 由于这个原因陷入了永远停留在 0.x 版本的尴尬境地。因为这会将语义化版本本就有限的表达能力从三个类别(主版本/次版本/补丁)减少到两个(有效主版本/有效次版本)。
Crate 用户的语义化版本控制指南
对于 crate 用户而言,理论上,一个 crate 新版本的含义如下:
- 一个 crate 的补丁版本升级应该可以正常工作。
- 一个 crate 的次版本升级应该可以正常工作,但是值得去研究 API 中新增加的部分,以发现是否有更好的方式来使用这个 crate。但是,如果你的代码中并未使用 API 中的新增内容,那么可以放心使用新版本,而无需将其退回到老版本。
- 如果一个 crate 的主版本升级了,那么所有的预期都可能失效;你的代码很可能无法正确编译,你需要重写部分代码以匹配新的 API。即使你的代码仍然可以编译,你也应该检查在主版本更改后对 API 的使用是否仍然有效,因为库的约束和前提条件可能已经改变。
在实际操作中,根据海勒姆法则,即使是仅仅升级了次版本或者补丁版本,也可能给你的项目带来非预期的变更,哪怕你的代码依然可以正确编译。
基于这些原因,为了更好的兼容后续的版本变更,你在使用 crate 的时候,通常应该以 "1.4.3"
或者 "0.7"
这样的方式来指定其版本。应该避免诸如 "*"
或者 "0.*"
这种完全通配符的版本号写法,因为这意味着对于所用 crate 的任何版本以及其提供的任意 API,都是可以在你的项目中正常使用的,通常情况下,这并不是你真正所希望的。当你把自己开发的 crate 发布到 crates.io
的时候,依赖项版本采用如 "*"
这种完全通配符格式的,会被拒绝。
但是从长远来看,为了保证兼容性而完全忽略所用 crate 的主版本更新,也是不安全的。一旦一个 crate 发布了主版本更新,很可能不会再对先前的版本进行更新了,这包括错误修复以及安全更。例如,一个 crate 发布了 2.x 版本之后,"1.4"
版本就会越来越落后于新版本,哪怕是安全问题,也得不到及时解决。
因此,要么将依赖项的版本锚定到旧版本并接受潜在的风险,要么就跟着依赖项的主版本持续升级。可以借助 cargo update
或者 Dependabot(见第 31 条)这些工具来帮你检查所用依赖项的更新情况,然后在合适的时机升级。
讨论
语义化版本控制是有代价的:对每一个 crate 的更改都必须根据其标准进行评估,以决定适当的版本升级。它只是一个粗略的工具:它最多只能反映 crate 所有者对于当前发布版属于三种类别中的哪一种的猜测。并不是每个人都会正确地处理,也不是所有事情都能明确说明“正确”究竟意味着什么,即便你做对了,也总有可能会受到海勒姆法则的影响。
然而,在不具备像 Google 高度测试的庞大内部单体库奢侈工作环境的前提下,语义化版本控制是唯一可用的方法。因此,理解它的概念和局限性对于管理依赖关系是很有必要的。
注释
例如:cargo-semver-checks
就可以帮你做一些检查工作
原文点这里查看
第 22 条:最小化可见性
Rust 允许用户控制一段代码中的元素对其他代码而言是隐藏还是可见的。本条目探索可见性机制,以及给出选择合适可见性的一些建议。
可见性的语法
模块(module)是 Rust 中控制可见性的基本单元。默认情况下,一个模块中的条目(类型、方法、常量等)是私有的,只能被本模块以及其子模块中的代码访问。
使用 pub
关键字,可以扩大代码的可见范围。对于大部分 Rust 语法特性来说,将某个条目标记为 pub
并不会公开其代码内容,例如:pub mod
中的类型和函数不是公开的,pub struct
的字段也不是公开的。但是,也有一些例外情况,将可见性应用到类型的内容中也是合理的:
- 一个标记为公开的
enum
,它所包含的枚举变体以及变体所包含的字段也是公开的。 - 一个标记为公开的 trait,它的所有方法都是公开的。
所以,一个模块中的类型:
#![allow(unused)] fn main() { pub mod somemodule { // 将 `struct` 标记为公开,不会自动将其字段公开。 #[derive(Debug, Default)] pub struct AStruct { // 默认情况下,字段是私有的 count: i32, // 必须显式增加 `pub` 以使得字段对外可见 pub name: String, } // 类似的,结构体中的方法也需要 `pub` 标记使其对外可见 impl AStruct { // 默认情况下,方法也是私有的。 fn canonical_name(&self) -> String { self.name.to_lowercase() } // 必须显式增加`pub` 让其对外可见 pub fn id(&self) -> String { format!("{}-{}", self.canonical_name(), self.count) } } // 标注为公开的 `enum` 它所包含的变体都是对外可见的 #[derive(Debug)] pub enum AnEnum { VariantOne, // 以及变体中的字段也是对外可见的 VariantTwo(u32), VariantThree { name: String, value: String }, } // 标记为公开的 `trait`,它的方法都是对外可见的 pub trait DoSomething { fn do_something(&self, arg: i32); } } }
允许外部代码访问模块中标记为 pub
的条目,以及,前述提及的例外情况(自动对外可见):
#![allow(unused)] fn main() { use somemodule::*; let mut s = AStruct::default(); s.name = "Miles".to_string(); println!("s = {:?}, name='{}', id={}", s, s.name, s.id()); let e = AnEnum::VariantTwo(42); println!("e = {e:?}"); #[derive(Default)] pub struct DoesSomething; impl DoSomething for DoesSomething { fn do_something(&self, _arg: i32) {} } let d = DoesSomething::default(); d.do_something(42); }
但是,未标记为 pub
的,则不可被外部访问:
#![allow(unused)] fn main() { let mut s = AStruct::default(); s.name = "Miles".to_string(); println!("(inaccessible) s.count={}", s.count); println!("(inaccessible) s.canonical_name()={}", s.canonical_name()); }
error[E0616]: field `count` of struct `somemodule::AStruct` is private
--> src/main.rs:230:45
|
230 | println!("(inaccessible) s.count={}", s.count);
| ^^^^^ private field
error[E0624]: method `canonical_name` is private
--> src/main.rs:231:56
|
86 | fn canonical_name(&self) -> String {
| ---------------------------------- private method defined here
...
231 | println!("(inaccessible) s.canonical_name()={}", s.canonical_name());
| private method ^^^^^^^^^^^^^^
Some errors have detailed explanations: E0616, E0624.
For more information about an error, try `rustc --explain E0616`.
最常用的控制可见性的标记就是 pub
关键字,只要外部可以访问这个模块,那么模块中所有标记为 pub
的条目都是对外可见的。这个细节很重要:如果模块(例如:somecrate::somemodule
)本身对外不可见,那么模块中的条目即使标记为 pub
,它们也是对外不可见的。
但是,还有一些 pub
关键字的变体形式,可以用来约束可见性生效的范围:
pub(crate)
:对其所在的 crate 中的其他条目可见。当你需要在 crate 范围提供一个类似助手函数,但是又不希望外部可见是,这个标记就非常实用了。pub(super)
:对当前所在模块的父模块及其子模块可见。这在具有较深层级模块结构的 crate 项目中会很有帮助,它可以让你有选择的扩大可见性。这也是模块级的有效可见性,mod mymodule
本身就对其父模块(或 crate)及其子模块可见。pub(in <path>)
:仅对指定<path>
的代码可见。其中,<path>
必须是对当前模块的祖先模块的描述。这在组织源代码时偶尔会有用,因为它允许将某些功能子集移动到无需在公共 API 中可见的子模块中。例如,Rust 标准库将所有迭代器的适配器合并到一个内部的std::iter::adapters
子模块中,并且具有以下内容:- 子模块中所需的适配器方法都带有
pub(in crate::iter)
可见性标记,例如:std::iter::adapters::map::Map::new
。 - 在外部
std::iter
模块中,对adapters::
中的类型使用pub use
。
- 子模块中所需的适配器方法都带有
pub(self)
:等同于pub(in self)
,也即不是pub
的。比较少见,可以用来减少在代码生成宏时特殊场景的数量。
如果你在一个模块定义了一个私有函数,但是并未在任何地方使用它,Rust 编译器会给出警告:
#![allow(unused)] fn main() { pub mod anothermodule { // 私有函数未被使用 fn inaccessible_fn(x: i32) -> i32 { x + 3 } } }
虽然说警告消息的字面意思说:在本模块“从未使用”,实际上的含义是指由于可见性的限制,你在模块外也无法使用此函数:
#![allow(unused)] fn main() { warning: function `inaccessible_fn` is never used --> src/main.rs:56:8 | 56 | fn inaccessible_fn(x: i32) -> i32 { | ^^^^^^^^^^^^^^^ | = note: `#[warn(dead_code)]` on by default }
可见性的语义
除了前面提到的如何扩大可见范围之外,还有一个问题:何时应该扩大可见范围?普遍可接受的答案是:应当尽可能不扩大可见范围,特别是对于将来会被使用或者重用的代码。
之所以给出这样的建议,第一个原因是已经扩大的可见范围是很难再收缩回来的。一旦 crate 中的某个条目可以被公开访问,就无法将其再改回私有,否则会破坏使用该 crate 的代码,从而不得不发布一个主版本号升级(见第 21 条)。反之则不成立:将一个私有项改为公有通常只需要小版本号的升级,并且不会影响使用 crate 的用户——查阅 Rust API 兼容性指南时你会注意到,其中有很多都和公共访问的条目有关系。
还有一个更重要但是不这么明显的原因就是:选择一个尽可能小的可见性范围,可以让你在将来保留更多的选择权。对公共访问暴露的内容越多,将来需要保持不变的东西也越多(除非进行不兼容的更改)。比如说,你对外暴露了数据结构的内部实现细节,那么将来为了使用更高效的算法而进行变更,就会破坏兼容性;如果你暴露了内部辅助函数,那么不可避免地会有一些外部代码依赖于这些函数的具体细节。
当然,这主要是针对可能有多个用户、较长生命周期的库代码的考虑,但是在平时的项目中也养成这种习惯终究是有益无害的。
值得注意的是,限制可见性的建议并不局限于本篇所述,也不局限于 Rust 这一门语言:
- Rust API 指南中,包含如下建议:
- Effective Java 第 3 版(Addison-Wesley Professional 出版)中有以下建议:
- 第 15 条:最小化类及其成员的可见范围
- 第 16 条:在公共类中,不要使用公共字段,应使用访问方法
- Scott Meyers 在 Effective C++ 第 2 版(Addison-Wesley Professional 出版)中建议:
- 第 18 条:努力让类接口既完整又精简(斜体字为原文所加)。
- 第 20 条:避免在公共接口中使用数据成员。
原文点这里查看
第 23 条:避免通配符导入
Rust 的 use
语句可以从另一个 crate 或者模块中引入一个具名项,使得该项可以在当前模块的代码中不加限定符使用。形如 use somecrate::module::*
的通配符导入 wildcard import(或称 glob import)表示那个模块所有的 public 符号都被添加到了本地的命名空间中。
如 第 21 条 中所述,一个外部 crate 可能会向 API 中添加新的条目作为次要版本升级的一部分;这是一种向后兼容的修改。
这两个场景一旦结合到一起就会产生新的忧虑,依赖项的一个非破坏性更改可能会破坏你的代码:如果依赖项添加了一个与你已经使用的名称冲突的新符号,会发生什么情况?
从最简单的角度来看,这倒不是个问题:通配符导入的符号被视为低优先级,所以在你的代码中使用的匹配的名称都会被优先匹配:
#![allow(unused)] fn main() { use bytes::*; // Local `Bytes` type does not clash with `bytes::Bytes`. // 本地定义的 `Bytes` 类型不会跟 `bytes::Bytes` 发生冲突。 struct Bytes(Vec<u8>); }
不幸的是,仍然存在发生冲突的情况。例如,依赖项添加了一个新的 trait 并且为某些类型实现了:
#![allow(unused)] fn main() { trait BytesLeft { // Name clashes with the `remaining` method on the wildcard-imported // `bytes::Buf` trait. // `remaining` 方法跟通配符导入的 `bytes::Buf` trait 冲突了。 fn remaining(&self) -> usize; } impl BytesLeft for &[u8] { // Implementation clashes with `impl bytes::Buf for &[u8]`. // 实现和 `impl bytes::Buf for &[u8]` 冲突了。 fn remaining(&self) -> usize { self.len() } } }
如果新 trait 中任一个方法的名称与该类型现有的方法名称发生冲突,编译器就没法明确地识别出要调用的是哪个方法:
#![allow(unused)] fn main() { let arr = [1u8, 2u8, 3u8]; let v = &arr[1..]; assert_eq!(v.remaining(), 2); }
就像编译时候的错误显示那样:
#![allow(unused)] fn main() { error[E0034]: multiple applicable items in scope --> src/main.rs:40:18 | 40 | assert_eq!(v.remaining(), 2); | ^^^^^^^^^ multiple `remaining` found | note: candidate #1 is defined in an impl of the trait `BytesLeft` for the type `&[u8]` --> src/main.rs:18:5 | 18 | fn remaining(&self) -> usize { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = note: candidate #2 is defined in an impl of the trait `bytes::Buf` for the type `&[u8]` help: disambiguate the method for candidate #1 | 40 | assert_eq!(BytesLeft::remaining(&v), 2); | ~~~~~~~~~~~~~~~~~~~~~~~~ help: disambiguate the method for candidate #2 | 40 | assert_eq!(bytes::Buf::remaining(&v), 2); | ~~~~~~~~~~~~~~~~~~~~~~~~~ }
因此,你应该避免从你无法控制的 crate 中进行通配符导入。
如果你可以控制被通配符导入的项目的代码,那么之前提到的问题就消失了。例如,test
模块通常会使用 use super::*;
。对于主要通过模块来划分代码的 crate 来说,从内部模块进行通配符导入也是一种可能的场景:
#![allow(unused)] fn main() { mod thing; pub use thing::*; }
然而,还有另一种常见的例外情况也是适用通配符导入的。有一些 crate 遵循一个约定,crate 中常见的条目会通过 prelude 模块重新导出,而这个就是特地被用于使用通配符导入的:
#![allow(unused)] fn main() { use thing::prelude::*; }
尽管从理论上来讲这种场景也会出现上面提及过的问题,但实际上这种 prelude 模块大多经过精心的设计,这样使用带来的更高的便利性可能会远超过未来出现问题的小概率风险。
最后,如果你不打断遵循这个条款的建议,考虑将你使用通配符引入的依赖项固定到一个特定的版本上(见 第 21 条),让依赖项不会自动升级次要版本。
第 24 条:重新导出在 API 中所用的依赖项类型
如果单看本章节的标题,会让人费解。没有关系,下面我们通过一个例子来把事情讲解清楚1。
在第 25 条描述了 cargo
是如何支持将同一个 crate 的多个不同版本链接到最终输出的二进制文件中的,而且这个过程对用户而言是透明的。假设一个使用 rand
crate 的二进制项目,更精确一些,使用的是 0.8
版本的 rand
:
# 顶层二进制 crate 的 Cargo.toml 文件内容
[dependencies]
# 依赖 `rand` crate
rand = "=0.8.5"
# 依赖 `dep-lib` crate
dep-lib = "0.1.0"
#![allow(unused)] fn main() { let mut rng = rand::thread_rng(); // rand 0.8 let max: usize = rng.gen_range(5..10); let choice = dep_lib::pick_number(max); }
上面的代码中最后一行,使用了一个假想的 crate dep-lib
,它可能来自 crates.io
,也可能是通过路径引入的本地 crate。
dep-lib
自己使用了 0.7 版本的 rand
crate:
# `dep-lib` 的 Cargo.toml
[dependencies]
# 依赖 `rand` crate
rand = "=0.7.3"
#![allow(unused)] fn main() { //! The `dep-lib` crate provides number picking functionality. use rand::Rng; /// Pick a number between 0 and n (exclusive). pub fn pick_number(n: usize) -> usize { rand::thread_rng().gen_range(0, n) } }
细心的读者可能会注意到这两段示例代码之间的区别:
dep-lib
所使用的 0.7 版本的rand
中的rand::gen_range()
方法有low
和high
2 个参数。- 在示例项目中所用的 0.8 版本的
rand
中的rand::gen_range()
方法只有range
1 个参数。
这种差异属于不兼容的变更,所以根据语义化版本控制的指导原则, rand
升级了最左非零版本号(见第 21 条)。虽然如此,基于 cargo
强大的能力,它仍然能这两个不兼容的版本合并到最终输出的二进制可执行文件中。
如果 dep-lib
的公共 API 中暴露了它所使用的依赖项中的类型的话,情况就会变得糟糕了,因为这会导致 rand
成为公共依赖项。
举例说明,假设 dep-lib
crate 暴露出来的函数中使用了来自 rand
0.7 版本的 Rng
trait:
#![allow(unused)] fn main() { /// 使用提供的 `Rng` 实例随机生成 0 到 n (不含) /// 之间的数字 pub fn pick_number_with<R: Rng>(rng: &mut R, n: usize) -> usize { rng.gen_range(0, n) // 0.7.x 版本的方法 } }
顺便说一下,在你对外暴露的 API 中使用其他 crate 类型之前请谨慎考虑:这会将你的 crate 与该依赖密切绑定。例如,依赖项的主版本号升级(见第 21 条)将会自动要求你的 crate 也进行主版本号升级。
rand
crate 本身只有少量的依赖项(见第 25 条),并且它被大量的其他项目所依赖,已经成为“事实上标准”的 crate 了。在上面的这段代码中,crate 中暴露出来的函数使用 Rng
倒也不是不可接受的。
好的,回到我们这个示例中来,假设在顶层的二进制项目中使用 dep-lib
的函数:
#![allow(unused)] fn main() { let mut rng = rand::thread_rng(); let max: usize = rng.gen_range(5..10); let choice = dep_lib::pick_number_with(&mut rng, max); }
将会无法通过编译,并且 Rust 编译器给出的错误消息也没什么实质的帮助:
error[E0277]: the trait bound `ThreadRng: rand_core::RngCore` is not satisfied
--> src/main.rs:22:44
|
22 | let choice = dep_lib::pick_number_with(&mut rng, max);
| ------------------------- ^^^^^^^^ the trait
| | `rand_core::RngCore` is not
| | implemented for `ThreadRng`
| |
| required by a bound introduced by this call
|
= help: the following other types implement trait `rand_core::RngCore`:
&'a mut R
上面的错误消息会让人感到困惑:明明 rand_core::RngCore
确实已经实现了 ThreadRng
trait 了啊!这是因为调用者传入的值实现的是 RngCore_v0_8_5
,而 dep-lib
期望的是 RngCore_v0_7_3
的实现。
至此,我们知道了依赖项的版本冲突才是引发上面编译错误的根本原因,那么应该如何解决呢?2 解决的这个问题的关键在于,虽然我们不能在二进制输出文件中直接使用同一个 crate 的两个不同版本,但是可以间接地做到这一点(就像最前面的例子中所示的那样)。
从二进制 crate 作者的角度来看,可以增加一个中间包装 crate,该 crate 独立于二进制的 crate,可以直接使用来自 rand
v0.7 的类型,而二进制 crate 仍然使用 rand
v0.8。虽然可以解决这个问题,但是这种方案实在是不太方便。
更优的一个解决方案是,让库作者显式地重新导出下列内容:
- 库中 API 所用的来自其他依赖项的类型
- 或者,完整的依赖项
这种方案显然是更优的,从库中重新导出 0.7 版本的 Rng
和 RngCore
供调用者使用,还可以提供用于构造类型实例的方法(例如 thread_rng()
):
#![allow(unused)] fn main() { // 重新导出本 crate 所用版本的 `rand` pub use rand; }
调用者使用 0.7 版本的 rand
时,需要换一种写法:dep_lib::rand
:
#![allow(unused)] fn main() { let mut prev_rng = dep_lib::rand::thread_rng(); // v0.7 Rng 实例 let choice = dep_lib::pick_number_with(&mut prev_rng, max); }
结合这个例子,标题中给出的建议现在应该不那么晦涩难懂了:重新导出在你 API 中的所用的依赖类型。这样可以减少用户在版本冲突或依赖管理方面的困扰,并提高库的易用性和兼容性。
原文点这里查看
注释
本示例(包含其中所用的类型),及其解决方法,受 RustCrypto crates 启发
还有一些场景也可能引发类似的错误:在项目的依赖图中,针对一个 crate 的同一个版本有多个不同的替代项时,以及使用 path 而不是 crates.io
来导入依赖项时
第 25 条:管理依赖项关系图
和大多数现代编程语言一样,Rust 通过 crate 的形式,简化了引入外部库的过程。很多相对复杂的 Rust 程序都会使用外部 crate,而这些 crate 本身可能也会有其他依赖,从而为整个程序形成一个依赖项关系图(简称依赖图)。
默认情况下,对于 Cargo.toml 文件里 [dependencies]
节中的 crate,Cargo 会从 crates.io
查找满足需求的版本并下载。
在这个简单的模式之下隐藏着一些微妙之处。首先要注意的是,来自 crates.io
的 crate 名称构成了一个扁平的明明空间 —— 并且,全局命名空间也会和 crate 中特征 (features)的命名产生重叠(见 第 26 条)1。
关于 crate 的名字,crates.io
采用“先到先得”的机制。所以,如果你计划将自己的 crate 发布到 crates.io
,那么请先查找一下要使用的名字是否已经被占用。然而,也不赞成发布一个空的 crate 来预占一个名字,除非你可以在短时间内发布代码。
需要注意的是,命名空间中的 crate 名称和在代码中使用时的名称略有不同,一个 crate 可以叫做 some-crate
,但是在代码中使用的时候它的名字是 some_crate
(中划线变成下划线)。也就是说,如果在代码中看到 some_crate
,那么对应的 crate 名字可能是 some-crate
也可能是 some_crate
。
还有一点,Cargo 允许在构建中包含同一个 crate 的多个版本,哪怕它们的版本号从语义化版本角度而言是不兼容的。看起来有点儿出乎意料,毕竟在 Cargo.toml 中,每个依赖项只能指定一个版本。但是实际上这种情况会经常发生:你的 crate 依赖 some-crate
的 3.x 版本,但同时也依赖 older-crate
,而后者又依赖 some-crate
的 1.x 版本。
如果你只是在代码内部使用它们还好说,但是如果需要把依赖项中的类型暴露出来的话,就会导致一些冲突(见第 24 条) —— 编译器倒是可以将不同版本视为不同的 crate 来处理,但是编译期间的错误消息可就没有这么清晰了。
如果 crate 中通过 Rust FFI 机制(见第 34 条)使用了 C/C++ 的代码,那么在其中引入其他 crate 的多个不同版本可能会导致错误。Rust 的工具链可以在代码中区分多个版本,但是所包含的 C/C++ 代码却必须遵从单一定义原则:对于任何函数、常量、全局变量,只能有一个定义。
Cargo 对于同一 crate 多版本支持也是有一定限制的:它不允许多个语义化版本号兼容的版本(见第 21 条):
some-crate
1.2 和some-crate
3.1 可以共存some-crate
1.2 和some-crate
1.3 不可以共存
在语义化版本号的基础上,Cargo 也做了一些扩展。它将最左非零的版本号段当作主版本号来看待:
other-crate
0.1.2 和other-crate
0.2.0 可以共存other-crate
0.1.2 和other-crate
0.1.4 不可以共存
Cargo 的版本选择算法决定应该使用的版本。根据语义化版本的规则,Cargo.toml 中的每个依赖项都有一系列可接受的版本,当一个 crate 在依赖图中多次出现时,Cargo 会将这个因素考虑进去。如果多个重叠的版本之间是兼容的,默认情况下,Cargo 会选择最新的;如果多个版本是不兼容的,那么 Cargo 会为每个版本单独构建一份拷贝。
Cargo 对所有的依赖项都确定了最终所选的版本之后,选择结果会记录到 Cargo.lock 文件中。后续的构建过程就会重用 Cargo.lock 中的数据,以确保构建结果的稳定性,并且可以减少不必要的内容下载。
那么,你就面临一个选择:是否应该把 Cargo.lock 文件提交到版本控制系统?Cargo 开发者给出的建议是:
- 构建输出是一个应用,或者二进制文件的,应该把 Cargo.lock 提交到版本控制系统,以确保稳定的构建输出。
- 库 crate 不应该提交 Cargo.lock,因为使用这个 crate 的项目会有自己的 Cargo.lock;要知道,库 crate 中的 Cargo.lock 文件会被库的使用者忽略。
即使对于库 crate,提交 Cargo.lock 文件会有助于常规构建以及持续集成(CI,见第 32 条)的过程中保持稳定的输出。理论上,语义化版本(见第 21 条)会防止由于版本号导致的构建错误,但是实际上这种错误时有发生。仅仅是因为某人变更了一个依赖项的依赖就导致构建失败,实在是令人懊恼。
如果把 Cargo.lock 提交到版本控制系统,那么需要建立一种升级流程(例如 Github 的 Dependabot),否则,你的依赖项就会固定在一个老旧的、过时的、甚至存在安全风险的版本。
通过提交 Cargo.lock 文件到版本控制系统从而达到固定依赖项版本的做法并不能解决依赖项升级的问题,但是它可以让你选择合适的时机来进行升级,而不是自动跟随所依赖的 crate 的升级而升级。如果依赖项的更新版本中存在问题,那么通常来说他们会快速修复,手动选择何时升级依赖项版本的做法可以让你跳过存在问题的版本,使用更新后的版本。
第三点,Cargo 的解析过程是特征统一的:在依赖图中不同位置的同一个 crate 的多个版本中的多个特征会形成特征并集,详细信息参见第 26 条。
版本规范
根据 Cargo book 中的规则,依赖项的版本号实际上定义了一系列允许的版本:
- 避免指定过于精确的版本号: 让依赖项锚定到一个精确的版本(
"=1.2.3"
)通常来说并不是一个好主意,你将无法看到新版本(可能包含了安全问题修复),并且大大缩小了依赖图中和其他使用当前依赖的 crate 之间的所允许的版本重叠范围(记住,在语义化版本兼容的范围内,Cargo 仅允许一个版本生效)。你可以借助 Cargo.lock 来达到在构建过程中使用一致的依赖集合的目的。 - 避免指定过于宽泛的版本号:你当然可以通过将依赖项的版本号设置为
"*"
来允许它的任意版本,但这也不是一个好主意。如果一个 crate 对 API 做出了重大变更,发布了一个主版本更新,那么在cargo update
拉取到新版本之后,你的代码大概率上无法正常工作了。
别太精确,也别太宽泛,指定一个语义化版本兼容的(如:"1"
)版本号,也可以带上次版本和补丁版本(如:"1.4.23"
),这就是普遍适用的“恰到好处”的规范。
这些版本规范都是 Cargo 的默认行为,它支持与指定的版本语义化兼容的版本,你也可以在版本号前面加入 ^
让表意更加清晰:
- 版本号
"1"
等同于"^1"
,允许 1.x 所有的版本,所以它也等同于"1.*"
。 - 版本号
"1.4.23"
等同于"^1.4.23"
,允许高于 1.4.23 版本的任意 1.x 版本。
使用工具解决问题
在第 31 条中,建议你充分利用 Rust 生态系统提供的优秀工具。本节讲解用来解决依赖图问题的相关工具。
编译器可以很快的告诉你代码中使用了未在 Cargo.toml 中定义的依赖项。反之,如果是在 Cargo.toml 中定义了,但是并未在代码中使用的依赖,或者说,代码中曾经用过,但是现在不用了,应该如何处理?此时,我们可使用 cargo-udeps
工具,它可以告诉你,Cargo.toml 中包含了未曾使用的依赖。
另外一个好用的工具叫做 cargo-deny
,它可以分析依赖项关系图,然后在整个依赖集合中检测潜在的问题:
- 所用依赖项版本存在安全问题
- 所用依赖项包含不可接受的许可证
- 所用依赖项不可接受
- 所用依赖项在依赖树上有多个不同版本
这些功能中的每一项都可以单独配置,用来处理例外情况。尤其是针对大型项目,通常会有多版本警告。随着依赖关系图的增长,依赖项之间的多版本警告是在所难免的。但是值得我们尝试使用工具尽量减少这种情况,虽然有时候没有办法完全避免,但是至少可以减少二进制构建文件的大小,提升编译速度。
你可以在需要的时候使用这些工具,但是更好的办法是,将这些工具包含到 CI 系统中,以确保它们可以定期可靠的执行(第 32 条),这有助于发现新的问题,包括在你代码之外的、上游依赖项中的诸如安全漏洞的相关问题。
如果上述工具报告了一个问题,往往很难精准地定位依赖图中发生问题的地方。此时,可以使用 cargo
中的命令 cargo tree
查看依赖树:
dep-graph v0.1.0
├── dep-lib v0.1.0
│ └── rand v0.7.3
│ ├── getrandom v0.1.16
│ │ ├── cfg-if v1.0.0
│ │ └── libc v0.2.94
│ ├── libc v0.2.94
│ ├── rand_chacha v0.2.2
│ │ ├── ppv-lite86 v0.2.10
│ │ └── rand_core v0.5.1
│ │ └── getrandom v0.1.16 (*)
│ └── rand_core v0.5.1 (*)
└── rand v0.8.3
├── libc v0.2.94
├── rand_chacha v0.3.0
│ ├── ppv-lite86 v0.2.10
│ └── rand_core v0.6.2
│ └── getrandom v0.2.3
│ ├── cfg-if v1.0.0
│ └── libc v0.2.94
└── rand_core v0.6.2 (*)
cargo tree
命令有一系列的选项可以帮助解决特定问题:
--invert
:显示依赖某个特定包的依赖项,可以聚焦特定的有问题的依赖项--edges features
:显示一个依赖项链接激活了哪些特征(feature),可以帮助你搞清楚特征统一时的情况(第 26 条)--duplicates
:显示在依赖图中存在多个版本的依赖项
依赖什么?
前面的小节讲解了依赖项之间的工作原理,但是还有一个更加哲学性的问题(因此也更加难以回答):何时使用依赖项?
大部分时候,比较容易做出决定:如果你的 crate 需要一个函数,唯一可选的替代方案就是你自己实现它2。
但是每个依赖都是有相应的成本的,完全由自己实现所需的功能,除了更长的构建时间,以及更大的二进制输出文件,还需要付出极大的努力来修复依赖中发现的问题。
你的项目的依赖图越庞大,就越容易遇到上面提到的问题。和其他包管理生态系统一样,Rust 的 crate 生态系统也会遇到依赖项问题。历史表明,一旦开发人员移除一个包,或者团队修改了许可证,都会带来大范围的连锁反应。
更加令人担忧的供应链攻击。别有用心的攻击者会通过误植域名、劫持维护者账号或者其他更加精巧且隐蔽的方式,来破坏被广泛使用的公共依赖项,从而达到攻击的目的。
千万不要以为攻击行为仅在运行代码的时候才会发生,在编译期间,依赖项可以通过 build.rs
或者过程宏也可以执行任意代码,这就意味着被破坏的依赖项可以在你的 CI 系统中偷偷运行挖矿程序!
因此,应该慎重考虑是否把那些无关紧要的依赖引入到你的项目中。
当然了,大部分时候的答案肯定的。毕竟,修复依赖项问题所花费的时间远远低于你自己从头开始实现依赖项的功能。
牢记
crates.io
上的 crate 名称,形成了一个扁平的命名空间,且与 crate 的特征名称共享。- Crate 的名字中可以包含中划线(
-
),但是在代码中使用的时候,会转换成下划线(_
)。 - Cargo 支持同一个依赖项在依赖图中存在多个版本,前提是这些版本在语义化版本的规则下是不兼容的。如果你的代码中包含 FFI 代码,多版本并存的依赖项可能会引发问题。
- 同一依赖项,最好使用在语义化版本层面兼容的版本(
"1"
,或者包含小版本号的版本:"1.4.23"
)。 - 使用 Cargo.lock 来确保构建的可重做性,但是也要知道公开发布的 crate 不会携带 Cargo.lock 。
- 使用工具协助解决依赖项问题,例如:
cargo truee
,cargo deny
,cargo udep
等。 - 要理解,相比自己开发而言,使用现成的依赖项相可以节约时间,但是也有一定的附加成本
原文点这里查看
注释
如果你的目标是 no_std
环境,也没有太多其他选择了,因为很多 crate 都未适配 no_std
环境,尤其是当 alloc
也不可用的时候(见第 33 条)。
第 26 条:警惕特征(feature
)蔓延
通过使用 Cargo 的 特征(feature) 机制,Rust 允许同一套代码有不同的配置,其底层基于条件编译机制。但是,关于 Rust 中的特征,有一些值得关注的点,本章将一一阐述。
条件编译
通过使用 cfg
或者 cfg_attr
,Rust 支持条件编译 ,可以让你决定函数、代码行、代码块等内容是否包含在编译后的文件中(相对而言,C/C++ 是基于代码行的预处理器)。这里的“条件”可以是一个像 test
这种单纯的名字,也可以是类似 panic = "abort"
这种名值对的形式。
注意,名值对的形式下,一个名字的值可以有多个:
#![allow(unused)] fn main() { // 构建时设置环境变量 `RUSTFLAGS` 如下: // '--cfg myname="a" --cfg myname="b"' #[cfg(myname = "a")] println!("cfg(myname = 'a') is set"); #[cfg(myname = "b")] println!("cfg(myname = 'b') is set"); }
cfg(myname = 'a') is set
cfg(myname = 'b') is set
除了前述这种显式指定 feature
值之外,更常用的配置项是由工具链自动引入的构建时的目标环境,包括;目标操作系统(target_os
)、 CPU 架构(target_arch
)、指针位宽(target_pointer_width
)、字节序(target_endian
)等,通过构建时的目标平台启用对应的特征来实现代码的可移植性。
另外,标准选项 target_has_atomic
是支持多值的,如果目标平台同时支持 32 位和 64 位架构,则 [cfg(target_has_atomic = "32")]
和 [cfg(target_has_atomic = "64")]
同时生效。(关于原子性的更多信息,请参考 O'Reilly 出版的,Mara Bos 所著的 《Rust Atomics and Locks》一书的第二章。)
特征
通过使用基于 cfg
的名值对机制,Cargo 包管理器提供了特征(features) 选择能力:在构建 crate 的时候,可以有选择地启用所需的函数或者对应的 crate。Cargo 将确保对于每个 crate,都使用所配置的 feature
值来进行编译。
这是 Cargo 的特有功能:对于 Rust 的编译器而言,feature
只是另一个可配置选项。
截至本文成稿时间,检测所启用的特征的最可靠方式就是查看 Cargo.toml 文件。举例说明,下面这段内容实际上包含了 6 个特征:
[features]
default = ["featureA"]
featureA = []
featureB = []
# 启用 `featureAB` 表示同时启用了 `featureA` 和 `featureB`.
featureAB = ["featureA", "featureB"]
schema = []
[dependencies]
rand = { version = "^0.8", optional = true }
hex = "^0.4"
可是,上面的例子中,[features]
小节只有 5 个特征啊!因此这里有一些细节需要注意。
首先,[features]
中的 default
是一个特殊的特征名字,它表示默认启用的特征。当然了,在构建时可以通过 --no-default-features
参数忽略默认特征,或者在 Cargo.toml 中这样写:
[dependencies]
somecrate = { version = "^0.3", default-features = false }
无论如何,default
仍然是一个可以在代码中正常使用的特征名字:
#![allow(unused)] fn main() { #[cfg(feature = "default")] println!("This crate was built with the \"default\" feature enabled."); #[cfg(not(feature = "default"))] println!("This crate was built with the \"default\" feature disabled."); }
在示例的 Cargo.toml 中,另一个不明显的特征隐藏在 [dependencies]
小节:依赖项 rand
crate 被标记为 optional = true
,这就使得 rand
成为一个特征名字 1。当示例的 crate 使用 --features rand
编译的时候,rand
特征将被激活:
#![allow(unused)] fn main() { #[cfg(feature = "rand")] pub fn pick_a_number() -> u8 { rand::random::<u8>() } #[cfg(not(feature = "rand"))] pub fn pick_a_number() -> u8 { 4 // chosen by fair dice roll. } }
虽然 crate 名字是全局的(由 crates.io
管理),而特征名字是本地的,但事实上 crate 和特征共享命名空间 。因此,谨慎选择特征名字,以避免和可能作为特征存在的 crate 名字冲突。虽然 Cargo 支持通过修改 package
键重命名所引入的 crate 来避免潜在的冲突问题,但是提前避免冲突总比重命名来的好。
所以你要检查 Cargo.toml 中依赖的crate 的 [features]
,同时,还要检查 [dependencies]
中标注为 optional
的 crate 来确认当前 crate 的全部特征名字。如果要启用依赖项的一个特征,需要在 [dependencies]
小节增加 features
属性:
[dependencies]
somecrate = { version = "^0.3", features = ["featureA", "rand" ] }
上述的依赖项设置对于 somecrate
启用了 featureA
和 rand
两个特征。但是,你在 Cargo.toml 中指定的特征并不代表针对这个 crate 只启用了这些特征,因为 Cargo 中有特征联合现象:最终构建时实际启用的特征是构建图中针对此 crate 所启用的所有特征的并集。换句话说,在上面的例子中,如果引用的某个依赖项也依赖 somecrate
,并且启用了 featureB
特征,那么最终构建的时候,会同时启用 featureA
,featureB
以及 rand
三个特征,以满足各个 crate 的需求 2。这个规则也适用于 default
特征,如果你的 create 通过 default-features = false
禁用了默认特征,但是构建图中其他依赖项没有显式关闭默认特征,那么最终构建的时候,default
特征仍然是启用的。
特征联合的特点意味着多个特征之间应该是可累加的。因此 crate 中包含不兼容的特征并不是一个好主意,毕竟我们没有任何办法阻止使用者同时启用这些相互不兼容的特征。
例如,下面的例子中,crate 向外暴露了一个结构体,它的字段是公开访问的。把公开访问的字段设计成特征依赖的就会显得很糟糕:
#![allow(unused)] fn main() { /// 结构体的字段是公开访问的, /// 所以使用者可以通过指定字段初始值来构建结构体的实例。 #[derive(Debug)] pub struct ExposedStruct { pub data: Vec<u8>, /// 仅当 `schema` 特征启用的时候, /// 才需要的额外数据 #[cfg(feature = "schema")] pub schema: String, } }
那么使用这个 crate 的用户可能会存在一些困惑:当构造结构体的实例时,schema
字段是否应该填充对应的值?为了解决这个问题,他将不得不在 Cargo.toml 中启用对应的特征:
[features]
# `use-schema` 特征启用了 `somecrate` 中的 `schema` 特征
# (为了清晰起见,这里使用了不同的特征名字,
# 实际开发中,大概率是重用原有的特征名字)
use-schema = ["somecrate/schema"]
然后,在代码中依赖这个特征:
#![allow(unused)] fn main() { let s = somecrate::ExposedStruct { data: vec![0x82, 0x01, 0x01], // 仅当启用 `somecrate/schema` 特征时, // 才填充此字段的值 #[cfg(feature = "use_schema")] schema: "[int int]", }; }
但是,这并不能涵盖所有的情况:当这段代码没有激活 somecrate/schema
特征,但是所用的其他依赖项启用了这个特征,就会导致错误。问题的关键在于,只有拥有该特征的 crate 能够检测到该特征;对于 crate 的用户来说,无法确定 Cargo 是否启用了 somecrate/schema
。因此,你应该避免在结构体中对公共字段进行特征隔离(feature-gating)。
类似的考虑也适用于公开的 trait,尤其是暴露出来给其他代码使用的 trait。假设一个 trait 中包含了特征隔离的方法:
#![allow(unused)] fn main() { /// 为支持 CBOR 序列化的条目设计的 trait pub trait AsCbor: Sized { /// 将条目序列化成 CORB 数据 fn serialize(&self) -> Result<Vec<u8>, Error>; /// 从 CBOR 数据反序列化一个条目 fn deserialize(data: &[u8]) -> Result<Self, Error>; /// 返回这个条目对应的模式 #[cfg(feature = "schema")] fn cddl(&self) -> String; } }
在项目中使用这个 trait 的用户同样会面临困惑:到底要不要实现 cddl(&self)
方法?外部代码根本没有办法得知是否应该实现 trait 中特征隔离的方法。
所以,结论就是:避免在公共 trait 中设计特征隔离的方法。包含默认实现的 trait 方法是个例外(见第 13 条) —— 前提是,外部代码永远不会改写默认实现。
特征联合也意味着,如果你的 crate 包含了 N 个特征 3,那么可能的特征组合是 2N 种。为了避免不必要的问题,应在你的 CI 系统中(见第 32 条)通过完备的测试用例(见第 30 条)来涵盖所有的 2N 种特征组合。
然而,当需要控制向展开后的依赖图(见第 25 条)暴露的内容时,使用可选的特征还是非常有帮助的,尤其是那些可以在 no_std
环境(见第 33 条)中使用的偏底层的 crate,通常会包含 std
或者 alloc
特征,来方便你在标准环境中使用它们。
牢记
- 特征和依赖项共享命名空间
- 选择特征名字的时候,应该慎重考虑,以避免和依赖项名字冲突
- 特征应该是可累加的
- 在公开暴露的结构体或者 trait 上,避免使用特征隔离的字段或方法
- 拥有很多相对独立的特征会导致可能的构建配置组合数量过于庞大
原文点这里查看
注释
这种默认行为可以通过在 features
节的其他地方使用 "dep:<crate>"
来禁用。详细信息请参考文档。
cargo tree --edges features
命令可以帮助你检测哪个 crate 启用了哪些特征,以及为什么要启用。
一个特征可以强制启用另外的特征,在最上面的例子中,featureAB
特征同时启用了 featureA
和 featureB
。
工具
泰特斯-温特斯(Titus Winters,谷歌 C++ 库开发负责人)将软件工程描述为随着时间推移而整合的编程,有时也称为随着时间和人员而整合的编程。在更长的时间尺度和更广泛的团队中,代码库中的内容不仅仅是代码。
包括 Rust 在内的现代语言都意识到了这一点,并建立了一个工具生态系统,而不仅仅是将程序转换为可执行的二进制代码(编译器)。
本章将探讨 Rust 工具生态系统,并提出一些建议关于基础工具的使用建议。比如在开发过程中显而易见的是,需要考虑适度性 —— 对于一个只运行若干次的一次性程序来说,设置 CI、文档构建以及六种类型的验证测试可能是不必要的情况(杀鸡焉用牛刀)。但是,对于本节中描述的大部分内容,通过巧妙的根据项目需求选择合适的工具集成进来,能够提高开发效率,减少错误,并且增强代码质量。适当学工具使用学习的投入,在长远来看会有更大的回报。最终达到"物有所值"的效果。
第 27 条:为公共接口撰写文档
如果你的包(crate)会被其他程序员所使用,那么为包中的内容添加文档就是很好的实践,尤其是包中的公共接口。如果你的包不仅仅是随用随丢的代码,那么这个“其他程序员”就包括未来的你,那个已经忘掉了自己代码细节的你。
这个建议并不是 Rust 所独有的,它也并不是一个新的建议 —— 比如,Effective Java 第二版(2008年出版)在第 44 条中建议:“为所有导出的 API 元素编写文档注释”。
Rust 文档类型注释的细节 —— 基于 Markdown 格式,以 /// 或者 //! 分割 —— 已经在Rust book中介绍了,如下为示例:
/// Calculate the [`BoundingBox`] that exactly encompasses a pair
/// of [`BoundingBox`] objects.
pub fn union(a: &BoundingBox, b: &BoundingBox) -> BoundingBox {
// ...
}
然而,关于文档型注释的格式仍有一些值得关注的细节:
- 使用代码格式:对于任何作为源代码的注释,使用反引号来确保在最终的文档中代码会以一种等宽字体来展示,并以此来明确的区分
code
以及一般的文本。 - 添加丰富的引用内容:为任何能够给读者提供上下文信息的内容添加 Markdown 链接。特别地,可以使用比较方便的 [
SomeThing
] 格式的交叉引用标注符语法 —— 括号内的Something
将会在最终文档中被添加正确的超链接。 - 多添加示例代码:如果接口应该如何使用并非一目了然的,那么添加一个使用该接口的
# Example
段落将会很有用。如在文档注释里的示例代码会在你执行cargo test
(详情查看第 13 条)时编译并且运行,这一特性将有助于示例代码和它希望表述的代码保持一致。 - 为
panic
和unsafe
的代码添加说明文档:如果存在会导致函数panic
的输入,在文档(# Panics
段落)里说明规避panic!
的前置条件。同样地,在文档(# Safety
段落)里说明unsafe
代码的使用要求。
Rust 的标准库是一个能够实践了上述所有细节的优秀示例。
工具
在注释文档中使用 Markdown 格式不仅意味着优美的输出,还意味着需要有一个明确的转换步骤(cargo doc
)。而转换也就会增加出现问题的可能性。
对于这个问题,最简单的建议是在写完文档后,运行cargo doc --open
(或者cargo doc --no-deps --open
,这个指令能够严格约束仅产生当前包中的文档)并来仔细阅读生成的结果。
对于所有生成超链接的有效性,你当然可以人工地去校验它们,或者让机器来完成这项工作 —— 通过broken_intra_dock_links
的包特性1:
#![allow(unused)] #![deny(broken_intra_doc_links)] fn main() { /// The bounding box for a [`Polygone`]. #[derive(Clone, Debug)] pub struct BoundingBox { // ... } }
当特性生效的时候,cargo doc
将会找出无效的链接:
#![allow(unused)] fn main() { error: unresolved link to `Polygone` --> docs/src/main.rs:4:30 | 4 | /// The bounding box for a [`Polygone`]. | ^^^^^^^^ no item named `Polygone` in scope | }
你也可以设置要求文档化,通过在包里设置![warn(missing_docs)]
属性。当设置生效的时候,编译器将会给每个未配置文档的公开条目生成警告信息。然而,这样设置也存在着为了解决编译器报错而提供低质量注释文档的风险 —— 当然设置引入的问题不仅如此。
同样地,为了能够及时发现潜在风险,这些工具应该被纳入到你的持续集成系统(第 32 条)。
其他的文档位置
cargo doc
的输出是包中文档所在的主要位置,但并不是唯一的地方 —— 在项目中的其他地方添加注释也可以帮助用户理解如何使用你的代码。
在 Cargo 项目的examples/
子目录下可以添加一些方便使用包的示例代码。这些代码可以构建并运行,和集成测试(第 30 条)的运行方式非常类似,不同的是这些代码提供的是便于理解包中接口使用的代码。
需要说明的是,tests/
子目录下的集成测试代码也可以给用户提供帮助,虽然它们的主要作用是测试包的对外接口。
发布包的文档
如果你的包会发布到crates.io
,项目的文档就可以在docs.rs中查看到。docs.rs 是为发布的包构建并提供文档的官方 Rust 网站。
注意,crates.io
和docs.rs
的受众是不同的:crates.io
旨在为选择包的用户提供服务,而docs.rs
的受众是那些需要弄明白他们已经引用的包该如何使用的人(很明显的,这两种场景有很大的重叠)。
综上,一个包的主页在不同的地方会展示不同的内容:
docs.rs
:展示cargo doc
产出结果的顶层页面,比如从顶层src/lib.rs
文件的//!
生成的文档。crates.io
:展示包含在项目仓库中的任何顶层README.md 2文件内容。
不文档化的内容
当一个项目要求公共条目都需要添加注释的时候,很容易就陷入到给无价值的内容也文档化的陷阱中。编译器的缺少注释文档的警告只是提醒你添加真正需要内容 —— 有用的文档 —— 的一种表现,并且仅仅期望程序员添加必要的内容来消除警告。
好的注释文档是一种能够帮助用户了解他们所使用代码的福利;糟糕的注释文档则增加了代码的维护成本并且让用户在它们不再和代码保持一致的时候变得更加困惑。那么好与不好的区别是什么呢?
最重要的建议是避免重复可以从代码中看出的信息。第 1 条建议你的代码尽量的和 Rust 的类型系统保持一致;一旦你做到了这一点,就通过类型系统来说明这些语意。可以假定使用代码的用户对 Rust 已经熟悉了 —— 可能他们已经读了一些描述了如何高效使用语言的建议 —— 并且不需要重复从代码中的参数类型和函数签名中就能读出来的东西。
回到之前的例子,一个冗余的注释文档可能如下面描述的这样:
#![allow(unused)] fn main() { /// Return a new [`BoundingBox`] object that exactly encompasses a pair /// of [`BoundingBox`] objects. /// /// Parameters: /// - `a`: an immutable reference to a `BoundingBox` /// - `b`: an immutable reference to a `BoundingBox` /// Returns: new `BoundingBox` object. pub fn union(a: &BoundingBox, b: &BoundingBox) -> BoundingBox { }
这个注释重复了很多从函数签名中就能读到的信息,注释信息毫无益处。
更糟的是,考虑一种代码重构后,将结果存储到其中一个参数(这是一种不兼容的变更;参照第 21 条)。没有编译器或者工具能够发现注释没有随之更新,结果就产生了一个未能和代码逻辑保持一致的注释:
#![allow(unused)] fn main() { /// Return a new [`BoundingBox`] object that exactly encompasses a pair /// of [`BoundingBox`] objects. /// /// Parameters: /// - `a`: an immutable reference to a `BoundingBox` /// - `b`: an immutable reference to a `BoundingBox` /// Returns: new `BoundingBox` object. pub fn union(a: &mut BoundingBox, b: &BoundingBox) { }
相反地,原本恰当的注释在重构中则可以毫发无损地保留下来,因为它的文本描述的是行为,而非语意本身:
#![allow(unused)] fn main() { /// Calculate the [`BoundingBox`] that exactly encompasses a pair /// of [`BoundingBox`] objects. pub fn union(a: &mut BoundingBox, b: &BoundingBox) { }
先前的建议也可以帮助提升文档质量:在文档中包含任何从代码中无法了解的内容。这包含前置条件、可变性、异常、报错条件以及任何可能会让用户感到意外的事情;如果你的代码不能遵守最小惊讶原则,确保这些意外都被记录在文档里,至少你可以说“我已经告诉过你了”。
另一个常见的失败情形是,注释里描述了其他使用这个方法的代码,而非这个方法做了什么:
#![allow(unused)] fn main() { /// Return the intersection of two [`BoundingBox`] objects, returning `None` /// if there is no intersection. The collision detection code in `hits.rs` /// uses this to do an initial check to see whether two objects might overlap, /// before performing the more expensive pixel-by-pixel check in /// `objects_overlap`. pub fn intersection( a: &BoundingBox, b: &BoundingBox, ) -> Option<BoundingBox> { }
像这样的注释几乎不可能和代码保持一致:当使用了这个方法的代码(比如,hits.rs
)变更的时候,这段描述了调用行为的注释相隔甚远而无法保持一致。
应当将注释重新组织以聚焦在为什么这样使用,可以让这段注释更好的适应未来的变更。
#![allow(unused)] fn main() { /// Return the intersection of two [`BoundingBox`] objects, returning `None` /// if there is no intersection. Note that intersection of bounding boxes /// is necessary but not sufficient for object collision -- pixel-by-pixel /// checks are still required on overlap. pub fn intersection( a: &BoundingBox, b: &BoundingBox, ) -> Option<BoundingBox> { }
当编写软件时,“面向未来的编程”3是一种很好的实践:调整代码结构以适应未来的变更。同样的原则也适用于文档:聚焦在语意,为什么这样做以及为什么不这样做,会让文本在未来的运行中始终是有意义的。
总结
- 给公共的 API 内容添加注释文档。
- 为那些从代码中无法明确看出的内容添加描述 —— 比如
panics
以及unsafe
的条件。 - 不要给可以从代码中明确看出的内容重复描述。
- 通过交叉引用及添加标志符来让导航变得明确。
注释
这个配置也曾称成为intra_doc_link_resolution_failure
。
包含 README.md 的引用动作可以被Cargo.toml 中的 readme 字段覆盖。
Scott Meyers,More Effective C++ (Addison-Wesley),第 32 条。
原文点这里查看。
第 28 条:在合适的时候使用宏
“在一些场景下,我们会很容易来决定应该使用宏(macro)而非函数(function),因为只有宏才能满足我们的需求。” - Paul Graham,“On Lisp (Prentice Hall)”
Rust 的宏能够让你实现元编程(metaprogramming):在项目中使用代码来生成代码。这一特性在需要编写很多确定性、重复性都很强的“样板代码”时会很有用,不借助宏的话我们就只能手动维护这些代码了。
程序员接触 Rust 之前可能已经预先了解了 C/C++ 中通过预处理(preprocessor)来实现的宏,这种方式是在预处理阶段通过文本替换来展开宏定义。而 Rust 的宏则有一些不同,它是在符号流(parsed tokens of the program)或者在抽象语法树(abstract syntax tree, AST)的基础上实现的宏,而非在文本处理阶段。
这就意味着 Rust 的宏是能够理解代码结构并且规避掉一系列的文本替换方式实现的宏所存在的意外情况。比如说,在接下来的内容中,我们可以看到 Rust 所声明的宏是卫生的 —— 在宏里不会意外引用(或者捕获)宏所嵌入代码中的变量信息。
/* 这段内容较为晦涩。引用一段维基百科上的内容来说明文本替换方式实现的宏所带来的问题。*/
#define INCI(i) { int a=0; ++i; }
int main(void)
{
int a = 4, b = 8;
INCI(a);
INCI(b);
printf("a is now %d, b is now %d\n", a, b);
return 0;
}
/* 以上代码中的 INCI 宏期望分别对 a, b 进行加一操作。文本替换后的结果如下所示。*/
int main(void)
{
int a = 4, b = 8;
{ int a = 0; ++a; }; // 注意这里对 a 进行了重新声明,实际上是对声明的这个 a 进行了自增。
{ int a = 0; ++b; };
printf("a is now %d, b is now %d\n", a, b);
return 0;
}
/* 最终的结果会输出如下。
*
* a is now 4, b is now 9
*
* 这显然是不符合预期的。产生这一结果的原因是由于文本替换过于粗暴,而无法进行实际语意上的理解。
*
* 本注释内容为译者添加。
*/
一种理解宏的方法是将其视为代码的不同抽象方式。函数也是代码的一种简单抽象:它将同一类型的不同值的不同抽象出来,实现了针对这一类型,而非特定的值,会做的操作及方法。而宏中的生成则是另外一个层面的抽象:宏是对符合同一特性的不同类型进行抽象,使用针对这些不同类型所具备的相同特性,而非特性的类型,进行代码的实现。
宏可以对不同程序中扮演相同角色(类型、标记、表达式等)的代码抽象出来,然后这些程序就可以以同一种方式来使用抽象出的逻辑。
Rust 提供了两种方式来定义宏:
- 声明宏,也被成为“示例宏”。声明宏允许将输入到宏中任意的 Rust 程序,基于抽象语法树中的结果,集成到代码中。
- 过程宏。过程宏同样可以将任意的 Rust 程序集成到代码中,不过是基于源码中的解析符号。
derive
宏就是常见的过程宏。derive
宏可以基于代码的结构定义来展开代码。
声明宏
虽然这篇文章不是为了重复声明宏的内容,但还是有必要来提醒下声明宏中需要关注的内容。
首先,需要注意的是声明宏的作用域范围和直觉上的理解的是不同的(对比 C 里的预处理宏)。如果一个声明宏在源代码中被定义了,就只有跟在宏里的代码能够使用:
#![allow(unused)] fn main() { fn before() { println!("[before] square {} is {}", 2, square!(2)); } /// Macro that squares its argument. macro_rules! square { { $e:expr } => { $e * $e } } fn after() { println!("[after] square {} is {}", 2, square!(2)); } }
#![allow(unused)] fn main() { error: cannot find macro `square` in this scope --> src/main.rs:4:45 | 4 | println!("[before] square {} is {}", 2, square!(2)); | ^^^^^^ | = help: have you added the `#[macro_use]` on the module/import? }
#[macro_export]
特性让宏可以访问更多的数据,但是也存在一些奇怪的事情:尽管宏并没有在模块中定义,它还是出现在了模块的顶层。
#![allow(unused)] fn main() { mod submod { #[macro_export] macro_rules! cube { { $e:expr } => { $e * $e * $e } } } mod user { pub fn use_macro() { // Note: *not* `crate::submod::cube!` let cubed = crate::cube!(3); println!("cube {} is {}", 3, cubed); } } }
Rust 的声明宏是卫生(hygienic)的:宏内部展开的代码无法使用所在作用域的局部变量。比如,宏内部使用了局部变量 x 时:
#![allow(unused)] fn main() { // Create a macro that assumes the existence of a local `x`. macro_rules! increment_x { {} => { x += 1; }; } }
这样的用法将会造成编译错误:
#![allow(unused)] fn main() { let mut x = 2; increment_x!(); println!("x = {}", x); }
#![allow(unused)] fn main() { error[E0425]: cannot find value `x` in this scope --> src/main.rs:55:13 | 55 | {} => { x += 1; }; | ^ not found in this scope ... 314 | increment_x!(); | -------------- in this macro invocation | = note: this error originates in the macro `increment_x` }
这种“卫生”的特性意味着 Rust 的宏比 C 的基于预处理替换的宏要安全很多。然而,仍有一些需要在使用时注意的内容。
第一,尽管一个宏看起来很像是函数的声明,它并不是。宏将会在调用的地方进行代码展开,而且可以随着传入参数的不同进行不同形式的展开:
#![allow(unused)] fn main() { macro_rules! inc_item { { $x:ident } => { $x.contents += 1; } } }
这就意味着常规意义上的参数被移动(moved)或者 & (被引用)的情形没有发生:
#![allow(unused)] fn main() { let mut x = Item { contents: 42 }; // type is not `Copy` // Item is *not* moved, despite the (x) syntax, // but the body of the macro *can* modify `x`. inc_item!(x); println!("x is {x:?}"); }
#![allow(unused)] fn main() { x is Item { contents: 43 } }
如果我们还记得宏只是在调用它的地方进行展开的话,上述示例就会变得清楚了 —— 在这个示例中,调用宏的地方只相当于添加了一行增加x.contents
值的代码。借助cargo-expand可以很清晰地看到编译器将宏进行展开后的代码:
#![allow(unused)] fn main() { let mut x = Item { contents: 42 }; x.contents += 1; { ::std::io::_print(format_args!("x is {0:?}\n", x)); }; }
展开的代码中可以看到直接使用了变量本身,而非其引用。(一个有意思的事情是,我们可以看到println!
的展开中,依赖了format_args!
宏1。)
所以,宏里的!
起到了一个警示的作用:展开的代码可能会对参数做一些任性的事情。
展开的代码也可能会包含一些在调用代码中无法访问的控制流,可能包括循环、判断、返回值甚至使用?
操作符。显然,这里会和[最小惊讶原则]相冲突,所以在使用宏时,应当考虑封装常规的 Rust 语句。(另一方面,如果使用宏的目的是实现一些奇怪的控制流,请确保这些控制流在文档中都给用户提供了!)
举例来说,考虑这样一个宏(用来校验 HTTP 状态码)包含了一个return
语句:
#![allow(unused)] fn main() { /// Check that an HTTP status is successful; exit function if not. macro_rules! check_successful { { $e:expr } => { if $e.group() != Group::Successful { return Err(MyError("HTTP operation failed")); } } } }
用这段宏来校验一些 HTTP 行为的代码可能会以一些很晦涩的控制流来结束:
#![allow(unused)] fn main() { let rc = perform_http_operation(); check_successful!(rc); // may silently exit the function // ... }
另一种可以实现上述功能的宏是产生一个Result
:
#![allow(unused)] fn main() { /// Convert an HTTP status into a `Result<(), MyError>` indicating success. macro_rules! check_success { { $e:expr } => { match $e.group() { Group::Successful => Ok(()), _ => Err(MyError("HTTP operation failed")), } } } }
而这样依赖,代码就很好理解了:
#![allow(unused)] fn main() { let rc = perform_http_operation(); check_success!(rc)?; // error flow is visible via `?` // ... }
对于声明宏来说,第二件需要注意的事情是和 C 的预编译宏同样的问题:如果宏的参数是一个存在副作用的表达式,当心在宏里多次使用的情况。比如我们在早先定义的square!
宏输入了较为随意的表达式来作为参数,然后使用两次,这将会造成奇怪的结果:
#![allow(unused)] fn main() { let mut x = 1; let y = square!({ x += 1; x }); println!("x = {x}, y = {y}"); // output: x = 3, y = 6 }
假设这种行为并非有意的,一种修复的方法是尝试仅执行给定的表达式一次,然后将结果赋值给一个本地的变量:
#![allow(unused)] fn main() { macro_rules! square_once { { $e:expr } => { { let x = $e; x*x // Note: there's a detail here to be explained later... } } } // output now: x = 2, y = 4 }
另一种可选的方案是不允许将表达式作为宏的输入。如果将[expr]替换为indent
,那么这个宏就仅会接受标志符作为入参,而使用类似任意的表达式将不再能编译通过。
格式化参数
声明宏的一种常见的使用模式将多个值汇聚成一个消息。比如,标准库中的format!
用来拼接一个字符串,println!
用来输出到标准输出,eprintln!
用来输出到标准错误输出。fmt 文档中阐述了format!
的语法,和C
中的printf
使用几乎是相同的。当然,Rust 中的format!
参数是类型安全并且会在编译时进行检查的,并且format!
宏实现时使用了Display
以及Debug
特性用来约束宏的参数。Display
以及Debug
宏的使用参见第 10 条2。
你可以(同时也建议)在项目中的宏中使用相同的格式化语法。比如,一个log
库中的logging
宏就可以使用和format!
相同的语法。在实践中,使用format_args!
来实现参数的格式化而不是重复造轮子。
#![allow(unused)] fn main() { /// Log an error including code location, with `format!`-like arguments. /// Real code would probably use the `log` crate. macro_rules! my_log { { $($arg:tt)+ } => { eprintln!("{}:{}: {}", file!(), line!(), format_args!($($arg)+)); } } }
#![allow(unused)] fn main() { let x = 10u8; // Format specifiers: // - `x` says print as hex // - `#` says prefix with '0x' // - `04` says add leading zeroes so width is at least 4 // (this includes the '0x' prefix). my_log!("x = {:#04x}", x); }
#![allow(unused)] fn main() { src/main.rs:331: x = 0x0a }
过程宏
Rust 也支持了过程宏,也被称为proc macros
。和声明宏类似,过程宏能够任意的 Rust 代码插入到程序的源代码中。不同的时,过程宏的输入不再仅限制在特定的传入参数。过程宏可以访问一些源代码中的解析符号(parsed tokens)。这就过程宏一定程度上类似动态语言,比如 Lisp,的非常富有表达力的能力 —— 但是仍然在编译时进行检查。这也帮助缓解了 Rust 中反射的局限,这在第 19 条中讨论了。
过程宏需要和其被使用的代码定义在不同的包中(并且包需要被声明为proc_macro
),并且包中往往需要引入[proc-macro](官方工具链中提供)或者[proc-macro2](由 David Tolnay 提供)的依赖,这两个依赖可以宏能够操作输入的符号。
实际上,有三种不同的过程宏:
- 类函数宏(Function-like macros):通过传入的参数调用。
- 类属性宏(Attribute macros):附加到程序中的某些特定语法的代码中。
- 派生宏(Derive macros):附加到特定的数据结构中。
类函数宏
函数式的宏会通过传递参数来调用,宏的实现中可以访问参数的解析符号(parsed tokens),并且返回任意的符号。注意在先前的表述中,我们使用的是单数的参数, —— 即使函数式的宏调用的时候看起来传入了很多参数:
#![allow(unused)] fn main() { my_func_macro!(15, x + y, f32::consts::PI); }
但是宏本身只接收到了一个解析后的符号流。一个将符号流输出(在编译时)的宏实现示例如下:
#![allow(unused)] fn main() { use proc_macro::TokenStream; // Function-like macro that just prints (at compile time) its input stream. #[proc_macro] pub fn my_func_macro(args: TokenStream) -> TokenStream { println!("Input TokenStream is:"); for tt in args { println!(" {tt:?}"); } // Return an empty token stream to replace the macro invocation with. TokenStream::new() } }
其运行结果如下:
#![allow(unused)] fn main() { Input TokenStream is: Literal { kind: Integer, symbol: "15", suffix: None, span: #0 bytes(10976..10978) } Punct { ch: ',', spacing: Alone, span: #0 bytes(10978..10979) } Ident { ident: "x", span: #0 bytes(10980..10981) } Punct { ch: '+', spacing: Alone, span: #0 bytes(10982..10983) } Ident { ident: "y", span: #0 bytes(10984..10985) } Punct { ch: ',', spacing: Alone, span: #0 bytes(10985..10986) } Ident { ident: "f32", span: #0 bytes(10987..10990) } Punct { ch: ':', spacing: Joint, span: #0 bytes(10990..10991) } Punct { ch: ':', spacing: Alone, span: #0 bytes(10991..10992) } Ident { ident: "consts", span: #0 bytes(10992..10998) } Punct { ch: ':', spacing: Joint, span: #0 bytes(10998..10999) } Punct { ch: ':', spacing: Alone, span: #0 bytes(10999..11000) } Ident { ident: "PI", span: #0 bytes(11000..11002) } }
由于输入流涉及到的底层特性式的这段宏在实现时必须要能够解析所传入的参数。比如,宏中分隔那些需要分隔的参数需要使用TokenTree:Punct
。syn 包(David Tolnay开发)提供了一个解析的库来辅助这些事情,下一节会进行介绍。
正因为这些解析的工作,使用声明宏往往比函数式的过程宏要简单,因为声明宏所处理的是匹配所定义的结构。
这种需要手动处理繁杂的另一面是函数是的宏可以更加灵活的接受那些无法像一般 Rust 代码解析的输入。这种特性并非经常需要的,所以函数式的宏相对较少出现。
类属性宏
类属性宏通过将其放置在程序的一些片段前调用的,而这些片段的解析符号会被传入到宏的内部进行处理。类属性宏也可以将任意的结果作为返回值,但是一般返回值是对输出的一些转换处理。
比如,下面是一个用来封装函数体的类属性宏:
#![allow(unused)] fn main() { #[log_invocation] fn add_three(x: u32) -> u32 { x + 3 } }
之后,在调用这个被封装的函数时,就会有日志输出:
#![allow(unused)] fn main() { let x = 2; let y = add_three(x); println!("add_three({x}) = {y}"); }
#![allow(unused)] fn main() { log: calling function 'add_three' log: called function 'add_three' => 5 add_three(2) = 5 }
这个宏的实现是极为复杂的,这里并不打算将其细节附上。在实现时,需要校验输入符号的结构以便构建新的输出符号。当然,这一过程仍然可以使用syn
包来辅助实现。
派生宏
最后一种过程宏是派生宏。派生宏可以为其修饰的数据(struct, enum 或者 union 均可)自动地生成代码。这一点和类属性宏有些像,但是会多一些派生的操作 —— 请注意理解这里的派生概念。
首先,派生宏会附加到输入的符号中,而非将其替换。这就意味着原始的数据结构的定义会被保留,而派生宏将在原始数据结构的基础上附加代码。
其次,派生宏可以用来声明一些辅助性的特征。当数据需要用作一些特殊的处理时,可以使用派生宏来辅助标注。比如,[serde 库]的Deserialize派生宏有一个serde
的辅助特性,用户可以使用派生宏来声明这些结构体符合某种特性:
#![allow(unused)] fn main() { fn generate_value() -> String { "unknown".to_string() } #[derive(Debug, Deserialize)] struct MyData { // If `value` is missing when deserializing, invoke // `generate_value()` to populate the field instead. #[serde(default = "generate_value")] value: String, } }
关于派生宏的最后一个概念是,syn 包可以完成将输入符号解析到相应的语法树的工作。syn::parse_macro_input!宏可以将符号转换成syn::DeriveInput数据结构,这种结构描述被修饰对象的主要内容,并且DeriveInput
操作起来远比原始的符号流要好处理。
特别地,derive
宏是所有过程宏中最常使用的宏 —— 这种逐字段或逐变量操作的能力能够让程序员最简单地实现最多的功能 —— 比如,仅通过添加一行类似#[derive(Debug, Clone, PartialEq, Eq)]
的代码,即可实现预期的目的。
由于派生宏的代码插入是自动实现地,这也意味着这些插入的代码可以同时和结构体的实现保持一致。比如,如果你向struct
中插入了一个新的字段,如果采用手动实现Debug
特征的话,你就需要在插入后对结构体进行更新以使其满足特征的需求。而对于自动插入代码的派生宏来说,你并不需要做任何调整(当然了,如果插入的字段不满足派生宏的实现要求,编译时会报错)。
什么时候使用宏
使用宏的首要原因当然是避免重复的代码 —— 尤其是那些需要人工确保和其他代码关联正确性的重复代码。从这一点来说,使用宏仅是编程常用的封装抽象的扩展:
- 如果需要重复一段处理同一类型的不同值的代码,将其封装为一个函数并在所有需要这段逻辑的地方使用它。
- 如果需要重复一段处理不同类型的代码,构建一个
trait
并且使用该trait
来封装逻辑并在所有满足该特性的要求的地方进行使用。 - 如果需要重复一段结构相同的代码,将其封装成一个宏并且在所有满足类似结构的代码中进行使用。
举例如下:如果希望规避重复处理不同enum
的代码,使用宏即可:
enum Multi { Byte(u8), Int(i32), Str(String), } /// Extract copies of all the values of a specific enum variant. #[macro_export] macro_rules! values_of_type { { $values:expr, $variant:ident } => { { let mut result = Vec::new(); for val in $values { if let Multi::$variant(v) = val { result.push(v.clone()); } } result } } } fn main() { let values = vec![ Multi::Byte(1), Multi::Int(1000), Multi::Str("a string".to_string()), Multi::Byte(2), ]; let ints = values_of_type!(&values, Int); println!("Integer values: {ints:?}"); let bytes = values_of_type!(&values, Byte); println!("Byte values: {bytes:?}"); // Output: // Integer values: [1000] // Byte values: [1, 2] }
另一个宏的使用场景是,规避同一结构体中的数据被分散在代码的不同区域。
比如,假设一个结构体封装了 HTTP 的状态码。通过宏可以避免实现这些信息时代码的分散:
#![allow(unused)] fn main() { // http.rs module #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Group { Informational, // 1xx Successful, // 2xx Redirection, // 3xx ClientError, // 4xx ServerError, // 5xx } // Information about HTTP response codes. http_codes! { Continue => (100, Informational, "Continue"), SwitchingProtocols => (101, Informational, "Switching Protocols"), // ... Ok => (200, Successful, "Ok"), Created => (201, Successful, "Created"), // ... } }
通过使用宏,可以将每个 HTTP 状态码的所有相关联的信息 —— 数值、元组以及描述信息 —— 都聚集起来,看起来就像是使用一种领域特定语言(domain-specifix language, DSL)来保存数据一样。
汇聚之后,宏就可以生成代码。每一行类似$( ... )+
中的代码都会被扩展成特定的代码:
#![allow(unused)] fn main() { macro_rules! http_codes { { $( $name:ident => ($val:literal, $group:ident, $text:literal), )+ } => { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(i32)] enum Status { $( $name = $val, )+ } impl Status { fn group(&self) -> Group { match self { $( Self::$name => Group::$group, )+ } } fn text(&self) -> &'static str { match self { $( Self::$name => $text, )+ } } } impl core::convert::TryFrom<i32> for Status { type Error = (); fn try_from(v: i32) -> Result<Self, Self::Error> { match v { $( $val => Ok(Self::$name), )+ _ => Err(()) } } } } } }
这样处理后,宏就可以依据输入的参数来派生如下的代码:
- 一个
enum
枚举用来保存所有的数值。 - 一个
group()
方法来返回一个 HTTP 状态码的分组归属。 - 一个
text()
方法来将状态码映射到对应的文字描述中。 - 一个
TryFrom<i32>
的特征实现来将数值转换成enum
中的枚举值。
如果需要新增一个状态码,只需要添加这样的一行代码:
#![allow(unused)] fn main() { ImATeapot => (418, ClientError, "I'm a teapot"), }
如果不使用宏的话,就需要对四部分代码分别更新。编译器可能会有一些提示信息(match
表达式需要覆盖所有的场景),但是存在一些遗漏 —— TryFrom<i32
就很容易被遗忘。
由于宏可以在调用的地方对代码进行展开,所有它们也可以用来自动生成一些提示信息 —— 尤其是,在使用了标准库中的file!()以及line!()宏了之后,可以生成代码的位置信息:
#![allow(unused)] fn main() { macro_rules! log_failure { { $e:expr } => { { let result = $e; if let Err(err) = &result { eprintln!("{}:{}: operation '{}' failed: {:?}", file!(), line!(), stringify!($e), err); } result } } } }
当报错出现时,日志文件中就会自动地包含报错内容、位置等细节:
#![allow(unused)] fn main() { use std::convert::TryInto; let x: Result<u8, _> = log_failure!(512.try_into()); // too big for `u8` let y = log_failure!(std::str::from_utf8(b"\xc3\x28")); // invalid UTF-8 }
宏的缺点
使用宏的最大缺点是引入之后代码的可读性及可维护性。之前在声明宏小结中介绍了宏允许我们创建一个特定的语言来简明地描述代码及数据的关键特性。但是,这也意味着任何阅读这段代码的人将不得不理解这段使用 Rust 实现的特定的语句 —— 而且这还是使用宏来定义的。比如,在http_codes!
宏的示例中使用了一个名为Status
的 Rust enum
,但是在使用的时候并不能察觉到。
这种使用宏而引入的不可知性远超一般程序员所能带来的影响:很多分析或和 Rust 交互的工具无法理解这样晦涩的代码,因为它不在遵循 Rust 代码交互语法。先前展示的square_once!
宏就是一个直观的例子:宏的主体并没有按照rustfmt
的规则来格式化:
#![allow(unused)] fn main() { { let x = $e; // The `rustfmt` tool doesn't really cope with code in // macros, so this has not been reformatted to `x * x`. x*x } }
另一个例子是已经提到的http_codes!
宏,这里使用了Group
枚举了诸如Informational
的值,而没有使用Group::
前缀或use
语句。这一点会让代码的补全工具感到混淆。
甚至编译器本身也无法提供更多的帮助:编译器提供的报错信息没有完全符合宏的定义及使用。当然,还是有一些工具(参照第 31 条)可以辅助宏的使用,比如早先使用的 David Tolnay 的 cargo-expand。
使用宏也可能会导致代码的膨胀 —— 一个简单的宏调用就可能引入数百行的生成代码,并且在进行代码分析时是无法直观看到的。这在代码第一次编写时可能不会成为问题,因为彼时这些代码是需要的,并且帮助开发者节约了大量的代码编写时间。但是,如果这些代码随后不再需要了,考虑实际生成的数百行代码,仅从数行的宏调用中可能并不能看到将其删除的必要性。
建议
尽管上节我们列举了很多宏的缺点,但是当我们需要合并存在一些存在一致性的代码,但是没有其他可用的方式时,使用宏仍然是完成这样工作的正确工具:当宏是确保不同代码保持一致的唯一方式时,使用它!
当我们需要合并一些模版化的代码时,宏也是可以使用的工具:使用宏来处理模版代码,当它们无法合并为一个函数或者一个特性时。
为了降低宏对可读性的影响,请尽量避免在宏中使用和 Rust 的一般语法规则相冲突的语法。要么让宏在调用时和一般的代码表现的一致;要么让宏在调用时和一般的代码完全不同,这样就没有用户会混淆宏和一般的代码。特别地,可以遵循如下的准则:
- 尽可能的避免向宏传递参数的引用 —— 类似
my_macro!(list)
的使用就比my_macro!(&list)
要好。 - 尽量避免在宏中引入非局部的控制流,这样所有阅读这段代码的人都可以在不了解宏的细节的情况下,正确理解上下文中的控制流。
这种倾向于类似 Rust 一般代码的可读性偏好有时会影响声明宏或者过程式宏的选择。如果你需要给一个struct
的每一个字段或者enum
中的每一个枚举值都生成代码,尽量使用派生宏来处理(暂时忽略在上一节中列举的问题) —— 这样会更加符合语言习惯并且读起来更加简单。
然而,如果要添加的派生宏并非是项目中所独有的功能,可以检查下外部的库中是否已经提供了所需要的宏(参照第 25 条)。比如,类似将数值类型转换为合适的 C 风格的枚举值的需求:在enumn::N、[num_enum::TryFromPrimitive]、num_derive::FromPrimitive以及strum::FromRepr中都一定程度的实现了这个需求。
注释
眼神儿好的读者可能已经注意到了format_arg!
仍然像是一个宏的调用,尽管它在println!
宏的展开代码里。这是因为它是编译器的内建宏。
在std::fmt 模块中也包含了很多其他展示特定格式数据是会使用的特性。比如,当需要一个 x 格式的特殊说明符来输出小写的十六进制输出时,就会使用LowerHex特性。
原文点这里查看。
第 29 条:遵循 Clippy 的建议
“看起来你在写信。需要什么帮助么?” —— [Microsoft Clippit]
第 31 条会描述了 Rust 工具箱中一些很有用的工具。但是其中一个特别有用且重要的工具值的在这里进行进行单独的介绍:Clippy。
Clippy 是 Cargo 的一个附加模块(通过cargo clippy
的方式调用)。它可以生成涵盖多种类别的warining
信息:
- 正确性:提示常见的编程错误。
- 风格:提示不完全符合 Rust 标准风格的代码结构。
- 简洁性:指出能让代码更加简洁的可行变更。
- 性能:提示能避免无效处理或者内存分配的可选项。
- 可读性:给出能让代码更易读或者更易懂的建议。
比如,如下这段代码编译是正常的:
#![allow(unused)] fn main() { pub fn circle_area(radius: f64) -> f64 { let pi = 3.14; pi * radius * radius } }
但是 Clippy 会指出这里对 π 的近似赋值是没必要且不准确的:
#![allow(unused)] fn main() { error: approximate value of `f{32, 64}::consts::PI` found --> src/main.rs:5:18 | 5 | let pi = 3.14; | ^^^^ | = help: consider using the constant directly = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant = note: `#[deny(clippy::approx_constant)]` on by default }
链接中的文档解释了问题并且给出了优化代码的方式。上述示例可调整为:
#![allow(unused)] fn main() { pub fn circle_area(radius: f64) -> f64 { std::f64::consts::PI * radius * radius } }
正如示例中所展示的,每个 Clippy 警告都会伴随着一个网页的链接来描述问题。链接的网页中会说明为什么目标代码会被认为是不恰当的。这些说明很重要:它们的存在使得你可以自行判断采纳这些建议或者由于特殊的原因而忽略它们。有的时候,说明文本中还会描述一些校验器的已知问题,这些描述会解释一些令人困惑的误报。
如果你认定一些警告信息和自己的代码没有关系,你可以通过添加(#[allow(clippy::some_line)])来忽略关联代码的报错,或者在包的顶层(top level)添加(#![allow(clipy::some_lint)])来忽略整个包中的警告信息。通常情况下,建议调整目标代码而非花费很多时间来确认警告关联的代码是否是一个罕见的误报。
无论你选择了修复或者忽略掉这些警告信息,请确保你的代码中没有 Clippy-warning 的信息。
这样,当新的警告信息出现时 —— 无论是由于代码发生了调整还是 Clippy 升级后包含了新的校验信息 —— 我们就能够及时的关注到。Clippy 也应当被纳入你的持续集成系统中(第 32 条)。
Clippy 的警告信息在你学习 Rust 时特别重要,因为它们可以揭示那些被你忽略的细节,并帮助你熟悉 Rust 的风格。
本书中提到的很多建议,在 Clippy 中均存在相关的警告信息:
- 第 1 条建议使用更具表现力的类型,而非一般的
bool
类型。Clippy 也指出了在函数参数以及结构体中使用多个bool
类型的问题。 - 第 3 条包括了一些
Option
及Result
类型的操作。Clippy 指出了一些可行的精简行为,比如: - 第 3 条同样建议了应当将错误返回给调用方。Clippy [指出了应当返回的地方]。
- 第 5 条应当实现
From
特征而非Into
。 - 第 5 条还描述了一些强制转换,而 Clippy 给出了如下的警告(对应的检查项默认是关掉的):
- 第 8 条描述了胖指针类型,并且很多 Clippy 的校验器指出了一些非必要的额外的指针间接访问:
- 第 9 条描述了操作
Iterator
实例的诸多方法。Clippy 包含了诸多的可以简化迭代器方法使用的校验器1。 - 第 10 条描述了 Rust 的标准特性,并且包含了很多 Clippy 会校验到的实现时的要求:
- 第 18 条提供了一些关于减少
panic!
或类似expect
的方法,这些建议也会在 Clippy 的校验器中检查到。 - 第 21 条表述了引入通过通配符限定的包是不明智的。Clippy 同样对此进行了校验。
- 第 23 条及第 25 条涉及到一种不同版本的包出现在同一个项目的依赖中。Clippy 可以通过配置,在问题出现时给出警告信息。
- 第 26 条叙述了 Cargo 特性的一些相加性,而 Clippy 会将与此原则相违背特性提示为“否定”的特性。
- 第 26 条同样表述了一个包的可选依赖项同样是其特征集的一部分。如果存在需要明确的特性名(如:"use-crate-x"形式的命名)时,Clippy 将会提示直接应当使用明确的特性名,而非模糊的。
- 第 27 条描述了文档注释的约束,Clippy 同时有如下的提示:
上述的信息无疑说明了阅读 Clippy 的警告信息列表同样是一种有意义的学习方式 —— 包括那些默认被关掉校验的原因,是由于它们太严苛了还是由于它们会产生虚警?尽管你可能并不希望代码中出现这么多的警告信息,领悟这些校验规则出现的原因将会提升你对 Rust 及其风格的理解。
注释
部分校验器列举如下。explicit_counter_loop,explicit_iter_loop,explicit_into_iter_loop,filter_map_identity,from_iter_instead_of_collect,into_iter_on_ref,iter_count,iter_next_loop,iter_not_returning_iterator,manual_filter_map,manual_find_map,map_clone,needless_range_loop,search_is_some,skip_while_next,suspicious_map,unnecessary_filter_map,unnecessary_fold。
原文点这里查看
第 30 条:不仅仅是单测
“所有的公司都拥有测试环境。但只有那些幸运的公司拥有独立于测试环境的生产环境。” —— @FearlessSon
和大多数其他的现代编程语言一样,Rust 也包含一些便于编写测试的特性。借助这些特性,你可以将单测和代码共存。而通过测试,可以提升代码运行准确性的信心。
这并非是兜售测试重要性的文章。从测试最基本功能来说,如果代码缺少了测试,它很可能并非如我们所希望的那样运行。本条目是在你已经建立了为代码编写测试这一信念的基础上展开的。
单元测试(unit tests)以及集成测试(integration tests)是测试领域内的两大重要成员。在接下来的两节内将会介绍。但是,Rust 工具链,也包括它的扩展,允许多种多样的测试形式。本条目将会介绍它们的基本使用流程及应用场景。
单元测试
Rust 代码中最常见的测试类型是单元测试:
#![allow(unused)] fn main() { // ... (code defining `nat_subtract*` functions for natural // number subtraction) #[cfg(test)] mod tests { use super::*; #[test] fn test_nat_subtract() { assert_eq!(nat_subtract(4, 3).unwrap(), 1); assert_eq!(nat_subtract(4, 5), None); } #[should_panic] #[test] fn test_something_that_panics() { nat_subtract_unchecked(4, 5); } } }
这段单测示例体现了 Rust 所有单测都会有的形式:
- 使用模块对单测函数进行封装。
- 每个单测都会使用
#[test]
属性来进行标志。 - 用来测试的模块使用
#[cfg(test)]
进行声明,所以单测的代码仅会在测试时生效。
示例也展示了一些仅在特定测试中才会出现的形式:
- 这里的测试代码放置在单独的模块里,模块名一般为
tests
或者test
。这个模块可以和逻辑代码放置在一起,或者放置在单独的tests.rs文件里。放置在单独的文件里可以让人更加便捷的区分代码是用来测试的,还是用于一般的功能逻辑。 - 测试的模块可以使用一个通配符
use super::*
来将父模块的所有依赖都声明到测试模块里。这种操作可以让编写测试代码更加方便(同时依据第 23 条来说,通配符的引入是一种要规避的操作)。 - 在模块的一般可达性规则下,单测可以使用所有父模块里包含的内容,无论是否声明为公共的。基于单测对内部功能可访问的特点,可以对代码进行“开箱”测试。
- 测试代码使用
expect()
或者unwrap()
来标志其希望的结果。显然,第 18 条中声明的规则并不适用这些单测的代码。单测中需要使用panic!
来标注失败的结果。同样的,测试的代码中也会使用assert_eq!
来校验期待的值,并且会在失败时抛出panic
。 - 测试代码中使用了一个函数,该函数在一些非法的输入下会造成
panic
。为了校验该函数的这一功能是否生效,单测的函数中使用了#[should_panic]
特性。这一特性在需要测试一个内部函数且希望保持这个函数的各校验规则不发生改变,或者测试一个公共的函数且由于一些原因需要忽略第 18 条中的建议。(这样的函数需要在它的注释文档中有“Panics”小节,就像第 27 条中描述的。)
第 27 条中建议不要对已经通过类型表述出的内容。同样的,也不需要对已经由类型进行约束的内容进行测试。如果你的enum
类型派生出了不在声明列表中罗列的变量,你可能遇到了比单测失败更加严重的问题。
然而,如果你的代码依赖了一些依赖库中的独特功能,对这些功能准备基础的单测会很有用。这里的单测目的并非是重复依赖中已经具备的功能测试,而是尽量早地暴露依赖的包依赖的这些功能发生了变更的风险 —— 尤其是公共的接口约定发生了变化,通常应当通过版本号来表明(第 21 条)。
集成测试
Rust 项目中另一种常用到的测试模式是:集成测试(integration tests),测试通常被放置在tests/
目录下。这个目录下的每个文件都会作为一个单独的测试程序运行,每个测试程序都会执行其包含的所有以#[test]
标志的测试函数。
集成测试没有访问包内部内容的权限,因此集成测试仅能覆盖包的公共 API。
文档测试
第 27 条描述了在注释中包含一小段代码的示例,通常是为了说明特定的公共 API 的使用方式。每段这样的代码都包含在一个隐式的fn main() { ... }
函数中,并且可以在cargo test
时被执行。这是一种高效的代码添加测试用例的方法,一般被称为文档测试(doc tests)。每个类似的测试都可以通过cargo test --doc <item-name>
的方式来选择性的执行。
定期的通过 CI 系统(第 32 条)来执行这些测试可以确保代码不会离包中期望提供的 API 太远。
代码示例
第 27 条也描述了为公共接口提供示例代码的实践。在examples/
目录下的每个 Rust 文件(或者examples
目录下每个子目录中的main.rs
文件)都可以通过cargo run --example <name>
或者cargo test --example <name>
的方式来作为独立的可执行文件运行。
这些程序仅能访问包中的公共接口,并且可以说明这些公共接口的使用方式。示例代码并非被设计为测试代码(没有#[test]
,没有[cfg(test)]
的标注),而且由于处于一些不起眼的角落,它们并不适合放置代码 —— 尤其是,它们并不在cargo test
时默认执行。
尽管如此,CI 系统(第 32 条)构建并且运行这些示例代码(通过cargo test --examples
)仍然是一个很好的实践。通过执行这些代码,可以为那些会影响大多数用户的接口提供一个很好的回归校验机制。特别地,如果你的示例揭示了接口使用的一般方式,那么示例运行的失败往往意味着存在如下的错误:
- 如果这是个高超的错误,它可能会影响很多用户 —— 示例中的代码将会被很多用户复制、粘贴或者参照。
- 如果公共接口发生了变更,那么这些示例也需要参照最新的接口定义来实现。接口的改变往往意味着不兼容。所以当包被发布时,版本号需要随着调整以说明这是个不兼容的升级(第 21 条)。
用户复制、粘贴测试代码的行为意味着示例代码和测试代码的形式有很大的不同。如第 18 条中描述的一样,你可以避免对 Results 进行 unwrap() 使用,从而为用户提供一个很好的参照。同样的,在每个示例代码的main()
函数中返回类似Result<(), Box<dyn Error>>
的结果,并且在内部使用?
符号来组织代码(第 3 条)也是一种很好的行为。
基准测试
[第 20 条]试图说明极致的代码性能优化并非总是必要的。尽管如此,有时性能肯定时很关键的,并且在这种情况下,衡量以及追踪代码的性能变化是很好的实践。具备定期运行的基准测试(benchmarks)(比如,作为 CI 系统的一部分,第 32 条)允许你发觉代码或者工具链的变更可以如何影响代码的性能。
[cargo bench]
命令可以运行重复执行特定操作的测试代码,并且计算出这个操作的平均耗时。在撰写本文时,Rust 对基准测试的支持还不太稳定,所以基准测试相关的指令需要通过cargo +nightly bench
的方式来执行。(Rust 不稳定的特性,包括本文中使用的test特性,都描述在 Rust Unstable Book中。)
然而,这里存在着编译器给出错误结果的风险,尤其是当你将操作约束在很简单的代码时。考虑如下一个简单的算数函数:
#![allow(unused)] fn main() { pub fn factorial(n: u128) -> u128 { match n { 0 => 1, n => n * factorial(n - 1), } } }
这段代码的一个简单的基准测试实现是:
#![allow(unused)] #![feature(test)] fn main() { extern crate test; #[bench] fn bench_factorial(b: &mut test::Bencher) { b.iter(|| { let result = factorial(15); assert_eq!(result, 1_307_674_368_000); }); } }
输出了一段奇妙的结果:
#![allow(unused)] fn main() { test bench_factorial ... bench: 0 ns/iter (+/- 0) }
由于在测试过程中使用固定的输入以及很少的代码指令,编译器可能会对迭代进行优化并且直接输出计算结果。这就将会导致不切实际的测试结论。
使用 std::hint::black_box 可以解决这一问题。这是一个标志函数,编辑器识别后将不对其进行优化。
上述基准测试可以变更为如下形式:
#![allow(unused)] fn main() { #[bench] fn bench_factorial(b: &mut test::Bencher) { b.iter(|| { let result = factorial(std::hint::black_box(15)); assert_eq!(result, 1_307_674_368_000); }); } }
给出了如下更加接近实际的结果:
#![allow(unused)] fn main() { test blackboxed::bench_factorial ... bench: 16 ns/iter (+/- 3) }
Godbolt 编辑资源管理器也可以通过展示实际的机器码的方式来辅助测试,这样就能让实际执行的优化后的字节码变得清晰以确认是否过度优化而不能得到实际的结果。
最后,如果你为 Rust 代码准备了基准测试,criterion包可能提供了test::bench::Bencher的替代品,而且使用起来更加便捷(可以在稳定的 Rust 工具链上运行),功能也更多(支持结果的数据统计及图表)。
模糊测试
模糊测试(fuzzy testing)是将代码暴露在随机输入中以期能够发现错误,尤其导致异常的场景,的测试方法。从技术校验的角度来说它已经很重要了,而当你的输入会被其他人填充或者攻击时,它将会显得更加重要 —— 所以如果你的代码输入可能暴露给潜在的攻击者时,你应当使用模糊测试。
从历史上来看,C/C++ 代码通过模糊测试发现的往往时内存安全问题,通常会通过结合模糊测试与内存访问模式的运行时结合来检测(比如AddressSanitizer或者ThreadSanitizer)。
Rust 对其中的一些(但并非全部)内存安全问题免疫,尤其是未引入unsafe
的代码时(第 16 条)。然而,Rust 并不能杜绝全部的错误,触发panic!
(第 18 条)的代码仍可能引发导致拒绝服务攻击(denial-of-service,DOS)。
模糊测试的最佳实践是以覆盖率引导:测试的基础设施监控代码的哪些部分被执行,随机更改输入直至能够触发新的代码路径。“American fuzzy lop”(AFL)是其中的佼佼者。但是近些年来,类似的功能已经被引入了 LLVM 的工具链,比如libFuzzer。
Rust 编译器是在 LLVM 的基础上构建的,因此cargo-fuzz自然地为 Rust 引入了libFuzzer
(仅在部分平台上可用)。
模糊测试的首要要求是确定代码的入口点,该入口点需要采用(或者可以适应)任意字节的数据作为输入:
#![allow(unused)] fn main() { /// Determine if the input starts with "FUZZ". pub fn is_fuzz(data: &[u8]) -> bool { if data.len() >= 3 /* oops */ && data[0] == b'F' && data[1] == b'U' && data[2] == b'Z' && data[3] == b'Z' { true } else { false } } }
当目标入口点确定后,Rust Fuzz Book给出了如何启动测试的说明。它的核心是一个小型的驱动程序,会将目标入口点连接到模糊测试的基础设施上:
#![allow(unused)] fn main() { // fuzz/fuzz_targets/target1.rs file #![no_main] use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = somecrate::is_fuzz(data); }); }
运行cargo +nightly fuzz run target1
将会持续使用随机数据来执行模糊测试的目标函数,直至异常出现。上述示例中,错误将被立即发现:
#![allow(unused)] fn main() { INFO: Running with entropic power schedule (0xFF, 100). INFO: Seed: 1607525774 INFO: Loaded 1 modules: 1624 [0x108219fa0, 0x10821a5f8), INFO: Loaded 1 PC tables (1624 PCs): 1624 [0x10821a5f8,0x108220b78), INFO: 9 files found in fuzz/corpus/target1 INFO: seed corpus: files: 9 min: 1b max: 8b total: 46b rss: 38Mb 10 INITED cov: 26 ft: 26 corp: 6/22b exec/s: 0 rss: 39Mb thread panicked at 'index out of bounds: the len is 3 but the index is 3', testing/src/lib.rs:77:12 stack backtrace: 0: rust_begin_unwind at /rustc/f77bfb7336f2/library/std/src/panicking.rs:579:5 1: core::panicking::panic_fmt at /rustc/f77bfb7336f2/library/core/src/panicking.rs:64:14 2: core::panicking::panic_bounds_check at /rustc/f77bfb7336f2/library/core/src/panicking.rs:159:5 3: somecrate::is_fuzz 4: _rust_fuzzer_test_input 5: ___rust_try 6: _LLVMFuzzerTestOneInput 7: __ZN6fuzzer6Fuzzer15ExecuteCallbackEPKhm 8: __ZN6fuzzer6Fuzzer6RunOneEPKhmbPNS_9InputInfoEbPb 9: __ZN6fuzzer6Fuzzer16MutateAndTestOneEv 10: __ZN6fuzzer6Fuzzer4LoopERNSt3__16vectorINS_9SizedFileENS_ 16fuzzer_allocatorIS3_EEEE 11: __ZN6fuzzer12FuzzerDriverEPiPPPcPFiPKhmE 12: _main }
导致上述错误的测试数据也给出了。
一般来说,模糊测试并不能如此快地发现错误,因此将模糊测试作为 CI 流程中的一部分也是没有意义的。模糊测试的不确定性及随之而来的计算成本意味着你需要考虑如何开展以及何时开展模糊测试 —— 可能仅需要在新的发布版本或者主要变更发生时才运行,或者仅运行确定性的时间1。
你也可以通过存储和重用先前模糊测试程序已找到用来触发新的代码路径的语料库的方式来加速模糊测试的执行。这将有助于后续的模糊测试过程将时间放在尝试新的输入上,而非重新测试以前访问过的代码路径。
测试的建议
一般性的建议也适用于 Rust 项目中:
- 由于测试是需要持续进行的,每次变更后都需要在 CI 中执行单测(除了模糊测试)。
- 当你在修复一个错误时,在修复前,准备一个能反映错误的测试用例。这样当你完成错误的修复时,就可以通过测试用例的执行来说名修复效果。并且在未来不会重新引入。
- 如果你的包中包含了某些功能([第 26 条]),对所有可能的功能组合都要进行测试。
- 更一般性的,如果你的包中包含了任何特殊的配置,(比如,
#[cfg(target_os="windows")]
),每种包含了独特配置的平台上的测试都需要运行。
这些建议包含了很多不同类型的测试,在项目中应当选择那些最有价值的测试。
如果你有很多测试的代码并且会将你的包推送到crates.io中,那么就需要考虑下哪些测试项发布后是有意义的。一般地,cargo
项目中会包含单元测试、集成测试、基准测试以及代码示例(但是并没有包含模糊测试,因为cargo-fuzz
工具会将模糊测试的内容放置在包的子目录中)等等远超一般用户使用所需要的测试项。如果某些测试项并非是必须的,你可以移除一些测试项或者将这些测试项(尤其是行为性的测试)移入单独的测试包中。
需要注意的点
- 编写单元测试来达到全面测试的目的,包括仅包含内部代码的测试。通过
cargo test
来运行它们。 - 编写集成测试代码来测试公共的接口。通过
cargo test
来运行它们。 - 编写文档测试来校验公共接口的调用方式。通过
cargo test
来调用它们。 - 编写示例代码来完整的说明如何使用包中的公共 API。通过
cargo test --exmaples
或者cargo run --example <name>
的方式来运行它们。 - 如果代码对性能有很明确的要求,编写基准测试来确认代码的性能表现。通过
cargo bench
来执行它们。 - 如果代码会暴露在未被信任的输入中,编写模糊测试来确认对输入的参数的约束。通过
cargo fuzz
来(持续地)运行它们。
注释
如果你的代码是一个被广泛运用的开源包,Google OSS-Fuzz program可以为你的项目进行模糊测试。
原文点这里查看
第 31 条:使用生态系统工具
Rust 生态系统拥有丰富的附加工具集,它们提供了超出将 Rust 转换为机器代码基本任务的功能。
在设置Rust开发环境时,您可能希望拥有以下基本工具的大多数:1
- cargo 工具,用于组织依赖项(第 25 条)并驱动编译器
- rustup 工具,用于管理安装的 Rust 工具链
- 支持 Rust 的 IDE,或像 rust-analyzer 这样的 IDE/编辑器插件,它允许您快速在 Rust 代码库中导航,并为编写 Rust 代码提供自动补全支持
- Rust playground,用于独立探索 Rust 的语法,并与同事分享结果
- Rust 标准库的文档
除了这些基本工具,Rust 还包括许多帮助维护代码库和提高代码库质量的工具。官方 Cargo 工具链中包含的工具涵盖了除 cargo build
、cargo test
和 cargo run
基本操作之外的各种基本任务,例如:
cargo fmt
:根据标准约定重新格式化 Rust 代码。cargo check
:执行编译检查而不生成机器代码,这有助于快速进行语法检查。cargo clippy
:执行代码嗅探检查,检测低效或不地道的代码(第 29 条)。cargo doc
:生成文档(第 27 条)。cargo bench
:运行基准测试(第 30 条)。cargo update
:升级依赖项到最新版本,默认选择符合语义版本控制(第 21 条)的版本。cargo tree
:显示依赖关系图(第 25 条)。cargo metadata
:输出工作空间中存在的包及其依赖项的元数据。
最后提到的这个工具特别有用,尽管它是间接的:因为有一个工具以明确定义的格式输出关于 crates
的信息,人们更容易生产出其他使用这些信息的工具(通常通过 cargo_metadata crate
,它提供了一套 Rust 类型来持有元数据信息)。
第 25 条描述了一些由这种元数据可用性启用的工具,例如 cargo-udeps
(允许检测未使用的依赖项)或 cargo-deny
(允许检查许多事情,包括重复的依赖项、允许的许可证和安全建议)。
Rust 工具链的可扩展性不仅限于包元数据;编译器的抽象语法树也可以构建,通常通过 syn crate
。这些信息使得过程宏(第 28 条)变得强大,但也为各种其他工具提供了支持:
cargo-expand
:显示宏扩展产生的完整源代码,这对于调试复杂的宏定义至关重要。cargo-tarpaulin
:支持生成和跟踪代码覆盖率信息。
任何特定工具的列表总是主观的、过时的和不完整的;更一般的观点是探索可用的工具。
例如,搜索 cargo-<something>
工具会得到数十个结果;其中一些可能不合适,一些可能已被放弃,但有些可能正好符合您的需求。
还有各种努力将形式验证应用于 Rust 代码,如果您的代码需要对其正确性有更高层次的保证,这可能会有帮助。
最后,提醒一下:如果一个工具不仅仅是一次性使用,您应该将该工具集成到您的 CI 系统(如第 32 条所述)。如果该工具快速且没有误报,将其集成到您的编辑器或 IDE 中也是合理的;Rust Tools
页面提供了相关文档的链接。
需要记住的工具
除了应该定期和自动运行在您的代码库上的工具(第 32 条),书中其他地方还提到了各种其他工具。为了参考,这里将它们汇集在一起——但请记住,还有更多的工具存在:
- 第 16 条建议在编写微妙的
unsafe
代码时使用Miri
。 - 第 21 条和第 25 条提到了
Dependabot
,用于管理依赖项更新。 - 第 21 条还提到了
cargo-semver-checks
作为检查语义版本控制是否正确完成的可能选项。 - 第 28 条解释了
cargo-expand
在调试宏问题时可以提供帮助。 - 第 29 条完全致力于使用
Clippy
。 Godbolt
编译器探索器允许您探索与您的源代码对应的机器代码,如第 30 条所述。- 第 30 条还提到了其他测试工具,例如用于模糊测试的
cargo-fuzz
和用于基准测试的criterion
。 - 第 35 条涵盖了使用
bindgen
从C
代码自动生成Rust FFI
包装器的使用。
注释
在某些环境中,这个列表可能会减少。例如,在 Android
上进行 Rust
开发有一个集中控制的工具链(所以没有 rustup
),并与 Android
的 Soong
构建系统集成(所以没有 cargo
)。
第 32 条:搭建持续集成(CI)系统
持续集成系统是一种在代码库发生变更时,自动运行一系列工具对代码进行检测的机制。
搭建持续集成系统的建议不是 Rust 特有的,所以本章节的内容涵盖了通用建议以及一些 Rust 特有的工具的建议。
CI 步骤
具体而言,你应该在 CI 系统中包含哪些步骤?很明显,至少应该包含以下内容:
- 构建代码
- 测试代码
CI 应该顺畅、快速且确定的运行每个步骤,并且不能产生结果误报。更多内容将在下一节详细介绍。
“确定性”要求还引出了对构建步骤的建议:使用 rust-toolchain.toml 指定所用工具链的固定版本。
rust-toolchain.toml 文件指定了构建代码时所用的 Rust 版本号,可以是一个具体的版本(例如:1.70
),也可以是某个渠道(stable
, beta
或者 nightly
),渠道后也可以跟随具体的构建日期(例如:nightly-2023-09-19
)1。如果选择的是不固定的渠道值,那么随着工具链新版本的发布,可能会导致每次 CI 构建结果不同;使用固定的版本号可以使得构建结果是确定的,屏蔽掉工具链发布新版本带来的结果不确定问题。
在本书的其他章节都有建议通过使用工具来提升代码质量,在 CI 系统中,亦是如此。例如,针对 CI 系统中的两个基本步骤,可以采用一些工具来提升效果:
- 构建代码
- 第 26 条建议使用特征来根据条件编译不同的代码片段。如果你的 crate 中包含特征,应在 CI 系统中构建所有可能的特征的组合,但是这会导致 2N 种可能的组合方式,所以建议避免 crate 的特征蔓延。
- 第 33 条建议考虑尽可能将库代码设计成
no_std
环境兼容的。只有在 CI 系统通过no_std
的兼容性测试,才可以确保代码是no_std
兼容的。可以利用 Rust 编译器的交叉编译能力,显式指定no_std
目标构建(例如:thumbv6m-none-eabi
)。 - 第 21 条中探讨了代码支持的最低 Rust 版本(MSRV)的问题,如果你的代码包含了 MSRV,那么应该在 CI 系统中包含针对对应 Rust 版本的测试。
- 测试代码
- 第 30 条讲述 Rust 项目不同类型的测试,应在 CI 系统中执行全部的测试。有些类型的测试可以在
cargo test
中自动包含(例如:单体测试、集成测试、文档测试等),但是另外一些类型(比如:示例代码)的测试就需要显式触发了。
- 第 30 条讲述 Rust 项目不同类型的测试,应在 CI 系统中执行全部的测试。有些类型的测试可以在
除此之外,还有一些工具和建议可以用来提升代码质量:
- 第 29 条中,阐述了在代码上使用 Clippy 的好处,同样的,应在 CI 中运行 Clippy。在 CI 中运行 Clippy 的时候,为了确保对失败进行明确的标记,应该设置
-Dwarnings
选项(cargo clippy -- -Dwarnings
)。 - 第 27 条建议为公开 API 编写完整的文档。使用
cargo doc
来测试文档的正确性以及其中的超级链接是否都被正确的解析。 - 第 25 条中提到了使用诸如
cargo-udeps
和cargo-deny
这样的工具来管理依赖图。在 CI 系统中也应该运行这些命令以避免潜在的问题。 - 第 31 条讲述了 Rust 的工具生态系统。应当考虑在 CI 系统中定期使用这些工具。例如,可以使用
rustfmt
或者cargo fmt
来检测未遵守格式规范的代码。为了确保标记失败点,请设置--check
选项。
你也可以在 CI 系统中包含一些用来测量代码某些切面的步骤:
- 使用
cargo-tarpaulin
生成代码覆盖统计数据,可以显示测试用例对代码的覆盖率。 - 使用
cargo bench
(见第 30 条)执行基准测试,以检测在某些关键场景下的代码性能。但是,很多 CI 系统是运行在共享环境中的,外部因素影响测试结果是不可避免的。如果希望得到更加精准的基准测试数据,那就需要更加独立的 CI 环境。
由于测量结果只有在和之前的结果对比来看才有意义,所以在 CI 系统中搭建用于测量的步骤略显复杂。理想情况下,当代码发生变动,CI 系统需要检测测试用例是否完全覆盖了这些变动以及这些变动是否对性能存在负面影响,这通常需要集成外部跟踪系统。
下面是一些关于 CI 中步骤的建议,不一定适用于你的代码库,但是不妨了解一下:
- 如果是一个库项目,请记住(见第 25 条)项目中所包含的 Cargo.lock 文件会被使用这个库的用户忽略。理论上,Cargo.toml 中语义化版本号(见第 21 条)约束应该可以让使用库的项目正常工作。实际上,应该在 CI 系统中包含不依赖 Cargo.lock 构建项目的步骤,以检测所用依赖的当前版本(根据语义化版本规则拉取到最新的符合要求的依赖)是否可以正常工作。
- 如果项目中包含机器生成的资源,并且这些资源提交到版本控制系统(例如使用
prost
命令生成的 protocol buffer 消息),那么,建议在 CI 系统中包含重生这些资源的步骤,并和版本控制系统中的比较,确保其一致性。 - 如果你的代码库包含平台特有(例如:
#[cfg(target_arch = "arm")]
)的代码,应该在 CI 系统中包含相应的步骤,确保针对特有平台可以成功构建且可以正常工作。由于 Rust 的工具链具有交叉编译的能力,所以前者比后者更简单。 - 如果你的项目需要处理诸如访问令牌或加密密钥等保密信息,考虑在 CI 系统中引入检查代码是否包含机密信息的步骤。如果你的项目是公开的,这一点尤为重要(在这种情况下,建议将该检查从 CI 阶段前置到版本控制预提交检查阶段)。
CI 系统中不是所有的步骤都需要和 Cargo 或者其他 Rust 工具链集成,有时,一个简单的 shell 脚本文件即可获得很好的效果。例如,你的代码库中包含了一些并非普遍遵循的特有约定:所有包含 panic 的方法调用都应该具有包含特殊标记的注释(见第 18 条),或者,每个 TODO:
项都要有所有者,可以是他的名字,也可以是一个跟踪 ID。这种情况下,shell 脚本文件是可以满足需求的理想工具。
最后,可以通过研究公开的 Rust 项目所用的 CI 系统,来获取一些适用于你自己项目的额外 CI 步骤。例如,Cargo 项目就有包含很多步骤的 CI 系统,希望可以给你一些启发。
CI 基本原则
从前面提到的具体的指导之外,还有一些总体原则可以给你 CI 系统细节方面的指导。
最基本的原则是:不要浪费人类的时间。如果 CI 系统不必要的浪费了人们的时间,就需要寻找避免这种浪费的解决办法。
最让工程师恼火的时间浪费是不稳定的测试,这种测试有时能够通过,有时无法通过,哪怕是配置和代码库都完全相同。对不稳定的测试要毫不留情:搞定它!从长远来看,花时间来研究和修复导致不稳定测试结果的原因,这种投入是值得的。
另外一种常见的时间浪费就是 CI 系统执行时间过长,并且,它仅在发起代码审查请求之后才运行。在这种情况下,可能浪费了作者和代码审查者两个人的时间,后者可能不得不花费一定的时间指出代码中的问题,而这些问题本来可以由 CI 机器人标记出来的。
为了解决这个问题,尽量让手动执行 CI 检查变得容易些,并且独立于自动化的体系。这可以让工程师养成定期执行检查的习惯,代码审查者就不会看到 CI 能够标记的问题了。更好的做法是,通过在编辑器或者 IDE 中集成一些工具,让检查用起来更顺畅,例如,糟糕格式的代码永远不会被保存在磁盘上。
如果 CI 系统中存在一些耗时较长但是很少发现问题的测试,那么应该将其拆分开来,仅作为测试极端场景的后备方案。
更普遍地说,一个大型项目要根据 CI 检查运行的频率来拆分开来:
- 集成到每个工程师开发环境中的检查,例如:
rustfmt
- 每次代码审查时要做的检查,并且最好可以方便地手动执行。例如:
cargo build
,cargo clippy
等 - 每次向主干分支合并代码时要做的检查,例如使用
cargo test
在所有支持的环境中的完整测试 - 定期执行的检查,可能是每天或者每周运行一次,可以在事后捕获到罕见的回归问题。例如:长时间运行的集成测试和基准对比测试
- 随时在代码上运行的检查,例如,模糊测试
将 CI 系统和代码审查系统集成是非常重要的,这样一来,代码审查者可以清晰地看到由 CI 标注的通过检查的部分,让其可以集中关注代码的意义,而无需在琐碎的细节上花费时间。
同样,一个成功的构建也要求代码可以通过 CI 系统所有的检查,无一例外。即使偶尔你需要处理工具产生的误报,这也是值得的。一旦你的 CI 系统中存在可接受的失败(“哦,大家都知道这个测试从来没有通过”),那么发现新的回归问题将会变得非常困难。
第 30 条建议,在修复 bug 之前,先增加能够重现 bug 的测试用例。这个建议也适用于 CI 系统:当你发现 CI 系统中存在流程问题,那么在修复问题之前,增加一个检测问题的步骤。例如,如果你发现一些自动生成的代码和它的源代码不同步,先在 CI 系统中增加一个检查步骤,直到问题修复,否则这个检查一直是失败的 —— 这让你可以确信在将来不会再次发生类似的错误。
公共 CI 系统
如果你的代码库是开源的并且是公开的,使用 CI 系统时有一些额外的事情需要考虑。
首先,好消息是,有很多免费的、可信赖的开源的 CI 系统。截至成稿时,GitHub Actions 可能是最好的选择,但不是唯一的选择,还有很多可选的 CI 系统。
其次,对于开源项目,值得牢记的是,你的 CI 系统可以作为设置代码库先决条件的指南。对于纯 Rust crate 而言不是问题,但是如果你的代码库需要额外的依赖项 —— 例如数据库、用于 FFI 代码的替代工具链、自定义的配置等 —— 那么 CI 脚本就是如何让这个项目在一个全新环境上成功运行的有力佐证。把这些步骤编码到可重用的安装脚本中,可以让人们以及自动化机器人轻松获得一个可正常运行的系统。
最后,对于公共可见的 crate ,坏消息就是存在被滥用或被攻击的可能性。可能包括:利用你的 CI 系统挖矿、窃取代码库令牌、供应链攻击等。为了降低这些风险,请考虑以下指南:
- 限制访问。确保 CI 脚本仅对已知的协作者自动运行,对于新加入的协作者需要手动执行。
- 所使用的任何外部脚本,都固定到可信的版本,或者,更优的做法是固定到已知的脚本哈希值。
- 密切关注任何对代码库超出读取权限的集成步骤。
原文点这里查看
注释
如果你的代码确实依赖一些仅在每日构建版本的编译器中提供的特征,可以通过 rust-toolchain.toml 文件来清晰指明该工具链的依赖。
超越标准Rust
Rust
工具链支持的环境范围远比仅仅运行在用户空间的纯 Rust
应用程序代码要广泛:
- 它支持跨平台编译,其中运行工具链的系统(宿主)与编译后的代码将运行的系统(目标)不同,这使得针对嵌入式系统变得简单。
- 它支持与用非
Rust
语言编译的代码链接,通过内置的FFI
功能。 - 它支持没有完整标准库
std
的配置,允许针对没有完整操作系统的系统(例如,没有文件系统,没有网络)。 - 它甚至支持不支持堆分配而只有栈的配置(通过省略标准
alloc
库的使用)。
这些非标准 Rust
环境可能更难工作,可能不那么安全——它们甚至可能不安全——但它们为完成工作提供了更多选项。
本书的这一章仅讨论了在这些环境中工作的基础知识。除了这些基础知识之外,您还需要查阅更多特定环境的文档(例如 Rustonomicon)。
第 33 条: 考虑使库代码与 no_std
兼容
Rust 附带一个名为 std
的标准库,其中包含了从标准数据结构到网络,从多线程支持到文件 I/O 等用于各种常见任务的代码。为了方便使用, std
中的一些项目会通过 prelude 自动导入到你的程序中,prelude 是一组 use
语句,可以在不需要指定完整名称的情况下直接使用这些常见类型(例如使用 Vec
而不是 std::vec::Vec
)。
Rust 还支持为无法提供完整标准库的环境构建代码,如引导加载程序、固件或一般的嵌入式平台。通过在 src/lib.rs
文件顶部添加 #![no_std]
属性,可以指示 crate 在这类受限环境中编译。
本节将探讨在 no_std
环境中编译时我们会失去哪些功能,以及仍然可用的库函数。我们会发现,即使在这种受限环境中,依然有相当多的功能可供使用。
不过,这里特别关注的是针对库代码的 no_std
支持。构建 no_std
可执行文件的复杂性超出了本文的范围1,因此本节重点是对那些不得不在如此极简环境中工作的可怜人来说,如何确保库代码是可用的。
core
即使在为最受限的平台构建时,标准库中的许多基本类型也仍然可用。例如 Option
和 Result
以及各种 Iterator
,尽管它们名称不同。
这些基本类型的不同名称以 core::
开头,表明它们来自 core
库,这是一个即使在大多数 no_std
环境中也可用的标准库。这些 core::
类型的行为与等效的 std::
类型完全相同,因为它们实际上是同个类型 —— 对应的 std::
版本都只是底层 core::
类型的重新导出。
这意味着有一种快速而肮脏的方法来判断某个 std::
项目在 no_std
环境中是否可用: 访问 doc.rust-lang.org
并找到感兴趣的 std
项目,点击 “soure”(在右上角)2,如果它跳转到了 src/core/...
中,则说明该项可通过 core::
在 no_std
下使用。
正常情况下 core
中的类型可自动用于所有 Rust 程序。但在 no_std
环境需要显式 use
,因为此时没有 std
prelude 帮忙导入。
实际中,对于许多环境(甚至是 no_std
)来说,纯粹依赖 core
会有很大的限制,因为 core
的一个核心(双关语)约束是它不会进行堆分配。
虽然 Rust 很擅长将数据放入栈中并安全地跟踪其相应的生命周期(第 14 条),但 core
的这种限制仍然意味着无法提供标准数据结构(vector, map, set),因为它们都需要分配堆空间来存放内部元素。所以这个限制也大大减少了在此环境中可用的 crate 数量。
alloc
但如果所在的 no_std
环境的确支持堆分配,则 std
中的许多标准数据结构仍然可用。这些数据结构以及其他使用分配功能的部分被分组到 Rust 的 alloc
库中。
和 core
一样,这些 alloc
变体实际上在底层是相同的类型。例如 std::vec::Vec
的真实名称是 alloc::vec::Vec
。
如果 no_std
的 crate 要使用 alloc
,需要在 src/lib.rs
额外显式声明 extern crate alloc;
3:
#![allow(unused)] fn main() { //! 我的 `no_std` 兼容 crate. #![no_std] // 引入 `alloc`. extern crate alloc; }
引入 alloc
库可以启用许多我们熟悉的朋友,现在他们以真实的名称被称呼:
alloc::boxed::Box<T>
alloc::rc::Rc<T>
alloc::sync::Arc<T>
alloc::vec::Vec<T>
alloc::string::String
alloc::format!
alloc::collections::BTreeMap<K, V>
alloc::collections::BTreeSet<T>
有了这些朋友,许多 crate 就可以兼容 no_std
—— 前提是不涉及 I/O 或网络。
但 alloc
提供的数据结构显然还缺少了两个集合 —— HashMap
和 HashSet
,它们特定于 std
而不是 alloc
。这是因为这些基于 hash 的容器依靠随机种子来防止 hash 冲突攻击,但安全的随机数生成需要依赖操作系统的帮助,而 alloc
不能假设操作系统存在。
另一个缺失的部分是同步功能,例如 std::sync::Mutex
,这是多线程代码所必需的,但这些类型同样特定于 std
,它们依赖操作系统的同步原语,如果没有操作系统,这些同步原语也将不可用。在 no_std
下编写多线程代码,第三方 crate 可能是唯一选择,例如 spin
。
为 no_std
编写代码
前文已经指出,对于某些 crate 库,要使代码兼容 no_std
只需要以下修改:
- 用相同的
core::
或alloc::
crate 替换std::
类型(由于缺少std
prelude,还需要use
完整的类型名称) - 将
HashMap
/HashSet
迁移为BTreeMap
/BTreeSet
但这些操作只有在依赖的所有 crate(第 25 条)也兼容 no_std
时才有意义 —— 如果使用您 crate 的用户被迫链接到任何 std
中,与 no_std
的兼容就会失去意义。
这里还有一个问题:Rust 编译器并不会告诉你一个 no_std
crate 中是否有引入使用了 std
的依赖。这意味着只要添加或更新一个使用了 std
的依赖,就能轻松破坏掉 no_std
crate 的兼容性。
为了避免这个情况,请为 no_std
构建添加 CI 检查,以便 CI 系统(第 32 条)能在这种情况发生时发出警告。Rust 工具链支持开箱即用的交叉编译,因此只需为不支持 std
的目标系统(例如 --target thumbv6m-none-eabi
)执行交叉编译,任何无意中依赖 std
的代码就会在此时编译失败。
所以,如果依赖项都支持,并且上面的简单修改就够了的话,不妨考虑让库代码兼容 no_std
。这不需要太多额外工作,却能让库有更广泛的适用性。
如果这些转换并没有覆盖 crate 中的所有代码,但未覆盖的只是代码中的一小部分或包装良好的部分,那么可以考虑向 crate 添加一个 feature (第 26 条)以控制是否启用这部分代码。
这种允许使用 std
特定功能的 feature ,通常会将其命名为 std
:
#![allow(unused)] #![cfg_attr(not(feature = "std"), no_std)] fn main() { }
或是将控制是否启用了 alloc
派生功能的 feature 命名为 alloc
:
#![allow(unused)] fn main() { #[cfg(feature = "alloc")] extern crate alloc; }
注意,设计这类 feature 时有个容易忽视的陷阱:不要设置一个 no_std
feature 来禁用需要 std
的功能(或类似的 no_alloc
feature),正如第 26 条中提到的,feature 应当是可累加的,这样做会导致无法将启用了 no_std
和没有启用的两个 crate 用户组合在一起 —— 前者会删除后者所依赖的代码。
和所有带有 feautre 门控的代码一样,确保你的 CI 系统(第 32 条)构建了所有相关的组合 —— 包括在 no_std
平台上关闭 std
特性的构建。
可错分配(Fallible Allocation)
前面考虑了两种不同的 no_std
环境:不允许堆分配的完全嵌入式的环境(core
)和允许堆分配的更宽松的环境(core
+ alloc
)。
然而,有一些重要的环境介于两者之间,特别是那些允许堆分配但可能会失败的环境,因为堆空间是有限的。
不幸的是,Rust 的标准 alloc
库假设堆分配不会失败,但这个假设并不总是成立。
即使简单地使用 alloc::vec::Vec
,也可能在每一行代码中都触发分配:
#![allow(unused)] fn main() { let mut v = Vec::new(); v.push(1); // 可能会分配 v.push(2); // 可能会分配 v.push(3); // 可能会分配 v.push(4); // 可能会分配 }
这些操作都不会返回 Result
,当这些分配失败了,会发生什么?
答案取决于工具链、目标平台和配置,但很可能会导致 panic!
和程序终止。这里无法通过某种方式处理第 3 行的分配失败,并让程序继续执行第 4 行。
这种可靠分配(infallible allocation)的假设简化了在“正常”用户空间中运行的代码,因为在这些环境中,内存几乎是无限的 —— 或者内存耗尽意味着计算机本身出现了更严重的问题。
但可靠分配不适用于内存有限且程序必须应对内存不足的环境。这是一个在一些较老、内存安全性较差的语言中反而有更好支持的(罕见)领域:
- C 的级别足够低,分配是手动的,因此可以检查
malloc
的返回值是否为NULL
。 - C++ 可以使用异常机制,捕获以
std::bad_alloc
异常形式出现的分配失败4。
历史上,Rust 标准库无法处理分配失败的问题在一些备受瞩目的场合(如 Linux 内核、Android 和 Curl 工具)中被指出,因此修复这一缺陷的工作正在进行中。
第一个尝试是添加“可错的集合分配”,它为许多涉及分配的集合 API 添加了允许可错分配的替代实现,通常是添加一个会返回 Result<_, AllocError>
的 try_<operation>
操作,例如:
Vec::try_reserve
作为Vec::reserve
的替代品Box::try_new
作为Box::new
的替代品(在 nightly toolchain 中可用)
但这些允许可错分配的 API 功能也仅限于此。例如,(目前为止)还没有与 Vec::push
等效的可错分配版本,因此编写组装 vector 的代码时,可能需要进行精确计算,以确保不会发生内存分配错误。
#![allow(unused)] fn main() { fn try_build_a_vec() -> Result<Vec<u8>, String> { let mut v = Vec::new(); // 仔细计算一下需要多少空间,这里简化为: let required_size = 4; v.try_reserve(required_size) .map_err(|_e| format!("Failed to allocate {} items!", required_size))?; // 我们现在知道这是安全的了 v.push(1); v.push(2); v.push(3); v.push(4); Ok(v) } }
除了增加允许可错分配的入口外,还可以通过关闭默认开启的 no_global_oom_handling
配置来禁用可靠分配操作。在堆内存有限的环境(如 Linux 内核)中,可以显式禁用此标志,以确保不会在代码中无意使用了可靠分配。
要记住的事
std
crate 中的许多项目实际上来自core
或alloc
。- 因此,使库代码兼容
no_std
可能比您想象的更简单。 - 通过在 CI 中检查
no_std
代码来确认no_std
代码保持no_std
兼容。 - 注意,当前支持在有限堆环境中工作的库十分有限。
注释
有关创建 no_std
可执行文件所涉及的内容,请参阅 The Embedonomicon 或 Philipp Opperamann 的早期博客文章。
注意,该方法不一定正确。例如在撰写本文时,Error
trait 在 [core::]
中定义,但被标记为不稳定(unstable),只有 std::
版本是稳定的(stable)
在 Rust 2018 之前,extern crate
声明用来引入依赖项。现在这些依赖完全由 Cargo.toml
处理,但仍使用 extern crate
机制引入那些 no_std
环境中 Rust 标准库的可选部分(即 sysroot crates)。
还可以为 new
调用添加 std::nothrow
重载,并检查是否返回 nullptr
。但一些容器方法比如 vector<T>::push_back
,在内部进行分配,因此只能通过抛出异常来表示分配失败。
原文点这里查看
第 34 条:控制跨越 FFI 边界的内容
虽然 Rust 已经具备了能力丰富的标准库,并且还有迅速发展的crate 生态系统,但现实中还是存在大量的非 Rust 的代码。
与其他较新的语言一样,Rust 提供了 外部函数接口(foreign function interface, FFI) 机制,该机制使得 Rust 可以与其他语言编写的代码以及数据结构进行互操作。虽然 FFI 的名字中带有“函数”,实际上这种互操作的能力并不局限于函数调用。这使得 Rust 程序可以使用其他语言编写的已有的库,无需“使用 Rust 重写”。
Rust 的默认目标是可以与 C 程序互操作,许多其他提供跨语言互通的语言也是首先提供与 C 互通的能力。一部分原因是因为 C 的库普遍存在,另外也是因为 C 的简洁性:C 作为互操作性的“最小公分母”,它不依赖具备高级功能的工具链。如果与其他语言互通可能需要考虑更多高级功能(例如:Java 或 Go 中的垃圾收集、C++ 的异常处理和模板类、Java 和 C++ 的函数重载等)。
但是,这并不是说 Rust 和 C 互操作就是非常简单的事情。由于引入了其他语言编写的代码,Rust 提供的安全保证和保护将不再使用,尤其是涉及到内存安全的部分。
所以,Rust 中的 FFI 代码都是 unsafe
的,第 16 条的建议将不再适用于此场景。本章节提供一些针对 FFI 的替代建议,第 35 条探讨了用来解决使用 FFI 时遇到的某些问题(但不是全部)的工具,《Rustonomicon》 中的 FFI 一章也提供了很有帮助的建议和信息。
从 Rust 调用 C 函数
最简单的 FFI 交互就是从Rust 代码调用 C 函数,并且参数都是不涉及指针、引用或者内存地址的“直接”类型:
/* 文件:lib.c */
#include "lib.h"
/* C 函数定义。 */
int add(int x, int y) {
return x + y;
}
C 代码中定义了一个函数,通常还伴随一个头文件来声明这个函数以方便其他 C 代码使用它:
/* 文件:lib.h */
#ifndef LIB_H
#define LIB_H
/* C 函数声明。 */
int add(int x, int y);
#endif /* LIB_H */
该声明大致如下:在某处存在一个名为 add
的函数,它接受两个整数作为参数,并且返回另外一个整数。这使得其他 C 代码可以使用这个函数,但是实际上该函数的实现代码会在后续的链接阶段提供。
如果要在 Rust 代码中使用这个 add
函数,也需要类似的声明,来描述函数的签名且对应的实现代码会在后续环节提供:
#![allow(unused)] fn main() { use std::os::raw::c_int; extern "C" { pub fn add(x: c_int, y: c_int) -> c_int; } }
通过在函数声明中使用 extern "C"
标记表示未来会有一个 C 的库提供函数代码 1,同时,这个标记也会自动将函数标记为 #[no_mangle]
,下一节会详细讲解这个属性。
链接过程
C 工具链是如何生成外部库以及该库的格式,和平台环境相关,这些细节超出了本书范畴。然而,在类 Unix 系统上,静态库文件是常见的简单形态。静态库文件可以使用 ar
工具生成,文件名通常是 lib<something>.a 的格式,例如:libcffi.a。
Rust 的构建系统需要知道对于所声明的外部函数,在哪个库文件中包含其对应代码。可以通过在代码中使用 link
属性指明对应的库文件:
#![allow(unused)] fn main() { #[link(name = "cffi")] // 需要名为 `libcffi.a` 的外部库文件 extern "C" { // ... } }
或者,可以使用构建脚本向 cargo
发起 cargo:rustc-link-lib
指令 2:
// 文件: build.rs fn main() { // 需要名为 `libcffi.a` 的外部库文件 println!("cargo:rustc-link-lib=cffi"); }
后者更加灵活,因为构建脚本可以检查所处的环境,然后根据所找到的内容采取不同的行为。
无论是哪种方案,如果所需的 C 的库不在系统的库路径中,Rust 构建系统都要有一种能够找到这个库文件的机制。可以在构建脚本中向 cargo
发起 cargo:rustc-link-search
指令,其中包含了库文件所在的路径:
// 文件:build.rs fn main() { // ... // 获取 `Cargo.toml` 文件所在目录。 let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); // 在上级目录中查找库文件。 println!( "cargo:rustc-link-search=native={}", std::path::Path::new(&dir).join("..").display() ); }
代码层面的考量
让我们回到代码本身,即使是最简单的 FFI 调用都可能存在一些陷阱。首先,使用 FFI 的函数会被自动标注为 unsafe
的,需要将其包裹在 unsafe { }
块中:
#![allow(unused)] fn main() { let x = add(1, 1); }
error[E0133]: call to unsafe function is unsafe and requires unsafe function
or block
--> src/main.rs:176:13
|
176 | let x = add(1, 1);
| ^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to
avoid undefined behavior
另一个需要注意的问题是 C 的 int
类型,在 Rust 中对应的是 [std::os::raw::c_int
][c_int ]。一个 int
是多大?有可能下面两个值是一样的:
- 用来编译 C 代码的工具链中的
int
类型大小 - Rust 工具链中的
std::os::raw::c_int
大小
但是我们不能冒险假设此二者一定一样大。因此,尽量在 FFI 边界选择已知大小的类型 —— 在 C 代码中,使用 <stdint.h>
中的类型,例如:uint32_t
。然而,如果是一个已经使用了 int
/ long
/ size_t
类型的现有 C 代码库,那这个要求就难以满足了。
最后一个实际问题是,C 代码和 Rust 代码中的函数声明要完全匹配。更糟糕的是,如果它们不匹配,构建工具不会给出任何警告,而是默默的生成错误的代码。
第 35 条中提到,可以使用 bindgen
工具来避免类似问题,为什么构建工具无法检测这种问题,其背后的原因是什么?这值得我们花时间去搞搞清楚,特别是,了解名称重整的基本原理。
名称重整
编译型语言通常支持独立编译:先将程序的不同部分分别转换成机器代码块(目标文件),最后再由链接器将其整合成一个完整的程序。这就意味着,如果只改动了一小部分代码,仅需重新编译对应的目标文件即可,然后由链接器将变动过的和未变动过的目标文件合并起来重建程序。
粗略来讲,链接步骤就像“按点连线”游戏那样,一部分目标文件提供函数或者变量的定义,另一些目标文件中包含占位符,表示期望在其他目标文件中找到在编译期间尚未提供的对应定义,链接器会将二者合并起来,确保每个占位符都会被对应的具体定义替换。
链接器通过简单的名称匹配机制来查找占位符和定义之间的关系,这就意味着所有的关联关系都存在于一个全局命名空间中。
这种方式对于链接 C 程序来说是没有问题的,因为一个名字不能以任何方式来重用 —— 函数的名字就是它在目标文件中的名字。因此,C 库的一个常见的约定是,通过在符号前增加前缀以避免命名冲突,例如:lib1_process
和 lib2_process
。
但是,对于允许重载定义的 C++ 语言来说,这样是行不通的:
// C++ 代码
namespace ns1 {
int32_t add(int32_t a, int32_t b) { return a+b; }
int64_t add(int64_t a, int64_t b) { return a+b; }
}
namespace ns2 {
int32_t add(int32_t a, int32_t b) { return a+b; }
}
为了解决这个问题,引入了名称重整机制:编译器将重载函数的签名和类型信息编码到输出到目标文件中,链接器还是保持原来的处理方式:在占位符和定义之间一一匹配。
在类 Unix 系统上,可以使用 nm
命令行工具查看目标文件:
% nm ffi-lib.o | grep add # C 链接器看到的
0000000000000000 T _add
% nm ffi-cpp-lib.o | grep add # C++ 链接器看到的
0000000000000000 T __ZN3ns13addEii
0000000000000020 T __ZN3ns13addExx
0000000000000040 T __ZN3ns23addEii
在本例中,有 3 个经过重整的符号,都指向其对应的代码。(T
表示二进制输出文件中的文本段,也就是代码所在的区域)。
c++filt
工具可以将重整后的名称还原到代码中的名称:
% nm ffi-cpp-lib.o | grep add | c++filt # what the programmer sees
0000000000000000 T ns1::add(int, int)
0000000000000020 T ns1::add(long long, long long)
0000000000000040 T ns2::add(int, int)
由于重整后的名称中包含了类型信息,所以链接器可以检测占位符和定义之间是否匹配,这可以保证类型安全:如果定义发生了改变,但是占位符中尚未进行相应的更新,链接器会报错。
回到 Rust,标记为 extern "C"
的外部函数被隐式地加上了 #[no_mangle]
的标记,所以在输出的目标文件中,函数将会保持原始的名称,就像 C 的处理方式一样。这就意味着函数签名的类型安全能力的丢失:链接器只能看到函数名字,所以即使函数的定义和使用之间在类型上的期望是不一致的,链接器也无法感知这一点,问题只会到运行程序的时候才会显现。
从 Rust 访问 C 的数据
前面所示的 add
函数在 Rust 和 C 之间交换的都是简单的数据类型:一个可以存储到寄存器中的整数。即使如此,仍然有一些细节需要注意。所以,不难想象当处理负责数据结构时会有怎样的棘手问题。
C 和 Rust 都使用 struct
将一系列相关的数据合并到一个数据结构之内。但是,当在内存中表示一个 struct
时,这两种语言就会有区别了,它们会将字段放到不同的位置,甚至是按照不同的顺序来存放数据(即布局)。位了防止不匹配问题,对在 FFI 使用的 Rust 中的类型使用 #[repr(C)]
标记,这种表示方式是专门为与 C 互操作设计的:
/* C 结构体定义 */
/* 这里的变动要同步映射到 lib.rs 中。 */
typedef struct {
uint8_t byte;
uint32_t integer;
} FfiStruct;
#![allow(unused)] fn main() { // 对应的 Rust 数据结构。 // 这里的变动要同步映射到 lib.h / lib.c。 #[repr(C)] pub struct FfiStruct { pub byte: u8, pub integer: u32, } }
上面所示的结构体定义中,有一行注释专门提醒程序员此两处的定义一定要保持同步。长期来看,完全依靠程序员来保证两处的同步是不可靠的,所以应该借助类似 bindgen
这样的工具来实现两种语言代码之间的自动化同步(见第 25 条)。
在 FFI 互操作场景中,要特别小心字符串类型。C 和 Rust 中默认的字符串类型是完全不同的:
- Rust
String
是已知长度的 UTF-8 编码的数据,可能包括值为 0 的字节。 - C 字符串(
char *
)保存的是字节值(可能有符号的,也可能无符号),它的长度由数据中的第一个值为 0 (\0
) 的字节决定。
幸运的是,鉴于 Rust 库的设计者已经完成了底层的繁重工作,我们可以在 Rust 中简单明了的使用 C 字符串。在和 C 的互操作过程中,如果需要拥有字符串值,可以使用 CString
类型,如果需要借用字符串值,可以使用 CStr
类型。当你需要向 FFI 函数传递 const char*
类型的字符串时,可以使用 CStr
的 as_ptr()
方法。注意,这里的 const
很重要,如果 FFI 函数需要修改字符串内容(char *
),就不可以这样使用了。
生命周期
大部分的数据都比较大,以至于无法存储到寄存器,只能存储到内存中。也就是说,访问数据实际上访问的是内存地址。在 C 中对应的是指针:一个无任何其他附加语义信息的、代表内存地址的数值(见第 8 条)。
在 Rust 中,表示内存地址的概念叫做引用,其数值可以提取为一个裸指针,方便传递给 FFI :
#![allow(unused)] fn main() { extern "C" { // C 函数操作 // `FfiStruct` 的内容 pub fn use_struct(v: *const FfiStruct) -> u32; } }
#![allow(unused)] fn main() { let v = FfiStruct { byte: 1, integer: 42, }; let x = unsafe { use_struct(&v as *const FfiStruct) }; }
但是,正如第 14 条所述,Rust 中的引用包含所关联内存段生命周期相关的额外约束,当将引用转换成裸指针时,这种约束将会丢失。
因此,使用裸指针本质上是不安全的,unsafe
标记表明这里存在风险:FFI 边界另一侧的 C 代码可以做出一些破坏 Rust 内存安全性的操作:
- C 代码可能保留指针的值,并在后续的代码中使用。当关联的内存已经从堆上释放,或者在栈上重用了,就会发生使用已释放的内存问题。
- C 代码可能会抛弃传递给它的指针的
const
限定符,然后修改指针指向的数据,但是 Rust 一侧期望这段数据是不可变的。 - C 代码不受限于 Rust 的
Mutex
保护,因此引发数据竞争(见第 17 条)问题。 - C 代码可能错误地(例如,调用
free()
函数)将关联的堆内存地址返回给内存分配器,意味着 Rust 代码面临使用已释放内存的问题。
这些风险是通过 FFI 机制重用现有代码以节约成本的时候不可避免的。优点是,你只需编写或者自动生成相应的声明就可以重用现有的、大概率可以正常工作的代码;缺点是你失去了使用 Rust 的最大优势 —— 内存保护。
避免 FFI 中的内存问题的首要法则是:在同一侧分配和释放内存。例如,下面这段代码包含一对对称的函数:
/* C 函数。 */
/* 为 `FfiStruct` 分配内存 */
FfiStruct* new_struct(uint32_t v);
/* 释放前面为 `FfiStruct` 分配的内存 */
void free_struct(FfiStruct* s);
对应的 Rust FFI 声明:
#![allow(unused)] fn main() { extern "C" { // 为 `FfiStruct` 分配内存的 C 代码。 pub fn new_struct(v: u32) -> *mut FfiStruct; // 释放前面为 `FfiStruct` 分配的内存的 C 代码。 pub fn free_struct(s: *mut FfiStruct); } }
为了确保分配内存的代码有对应的释放内存的代码,建议实现一个 RAII 包装来自动避免 C 一侧分配的内存泄漏问题(见第 11 条)。用作包装器的结构体持有 C 一侧分配的内存:
#![allow(unused)] fn main() { /// 包装器结构体拥有 C 一侧分配的内存 struct FfiWrapper { // 不可变量: inner 是非空的。 inner: *mut FfiStruct, } }
然后为这个结构体实现 Drop
trait,将内存地址返回给 C 代码库来避免内存泄漏风险:
#![allow(unused)] fn main() { /// 手动实现 [`Drop`] /// 以确保从 C 代码分配的内存能够正确释放 impl Drop for FfiWrapper { fn drop(&mut self) { // 安全的: `inner` 是非空的 // 同时,`free_struct()` 还处理了空指针的情况 unsafe { free_struct(self.inner) } } } }
为 FFI 派生的资源实现 Drop
trait 以实现 RAII 这条法则同样适用于除内存之外的其他资源:打开的文件、数据库连接等(见第 11 条)。
将与 C 的互操作包装到一个结构体中,还可以捕获一些其他潜在的陷阱,例如:可以将原本不可见的失败转换成 Result
:
#![allow(unused)] fn main() { type Error = String; impl FfiWrapper { pub fn new(val: u32) -> Result<Self, Error> { let p: *mut FfiStruct = unsafe { new_struct(val) }; // 裸指针不能保证是非空的。 if p.is_null() { Err("Failed to get inner struct!".into()) } else { Ok(Self { inner: p }) } } } }
包装后的结构体对外提供安全的使用 C 函数的方法:
#![allow(unused)] fn main() { impl FfiWrapper { pub fn set_byte(&mut self, b: u8) { // 安全的:依赖于 `inner` 是非空的。 let r: &mut FfiStruct = unsafe { &mut *self.inner }; r.byte = b; } } }
或者,如果底层的 C 数据结构有一个等价的 Rust 映射,并且可以安全的直接操作该数据,那么实现 AsRef
和 AsMut
trait (见第 8 条)用起来更直接:
#![allow(unused)] fn main() { impl AsMut<FfiStruct> for FfiWrapper { fn as_mut(&mut self) -> &mut FfiStruct { // 安全:`inner` 非空 unsafe { &mut *self.inner } } } }
#![allow(unused)] fn main() { let mut wrapper = FfiWrapper::new(42).expect("real code would check"); // 直接修改在 C 一侧分配的数据 wrapper.as_mut().byte = 12; }
上面的示例展示了使用 FFI 时一条非常有帮助的法则:将访问 unsafe
FFI 库的代码封装到安全的 Rust 代码之内。这可以使得其他代码遵循第 16 条的建议,避免编写 unsafe
代码。它还可以将危险的代码集中在一个地方,便于仔细研究和测试以发现问题,并在出现问题的时候将这些代码视为首要怀疑点来处理。
从 C 调用 Rust
哪一侧算是“外部”,这取决于你的观察角度:如果你正在使用 C 开发应用,那么 Rust 就可以视为通过 FFI 访问的“外部”。
将 Rust 库导出给 C 的基本原理类似,只不过是相反方向的:
- Rust 中导出的函数需要
extern "C"
标记来确保和 C 兼容。 - 默认情况下,Rust 中的符号名称会被重整,类似 C++ 的行为 3 ,所以,在函数定义上也需要
#[no_mangle]
属性来保持原始名字。但是这同样意味着函数名称作为全局命名空间的一部分,可能和其他程序中的其他符号冲突。所以,建议在导出的名称前增加前缀避免混淆,例如:mylib_...
。 - 给需要导出的结构体增加
#[repr(C)]
标记来确保其内存布局和 C 数据类型相同。
同样的,当处理指针、引用或者生命周期的时候,也会有一些潜在的问题。毕竟,C 指针和 Rust 的引用是有所不同的,如果忘记这一点,可能会引发严重问题。
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn add_contents(p: *const FfiStruct) -> u32 { // 将调用者传入的裸指针 // 转换成 Rust 的引用 let s: &FfiStruct = unsafe { &*p }; // Ruh-roh s.integer + s.byte as u32 } }
/* C 调用 Rust。 */
uint32_t result = add_contents(NULL); // 出错啦!
切记你有责任保证遵循 Rust 引用的方式使用裸指针:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn add_contents_safer(p: *const FfiStruct) -> u32 { let s = match unsafe { p.as_ref() } { Some(r) => r, None => return 0, // 如果 C 代码给我们空指针的时候 }; s.integer + s.byte as u32 } }
在上面的示例代码中,C 代码给 Rust 代码传入了一个裸指针,Rust 代码将其转换成一个引用来操作结构体。但是,这个指针从哪里来?Rust 的引用到底引用了什么?
在第 8 条的示例中,演示了 Rust 的内存安全机制会防止返回对栈上过期对象的引用。当你把引用作为裸指针返回的时候,就会出现类似的问题:
#![allow(unused)] fn main() { impl FfiStruct { pub fn new(v: u32) -> Self { Self { byte: 0, integer: v, } } } // 这里不会发生编译错误 #[no_mangle] pub extern "C" fn new_struct(v: u32) -> *mut FfiStruct { let mut s = FfiStruct::new(v); &mut s // 返回指向即将过期的栈上对象的裸指针! } }
任何从 Rust 返回给 C 一侧的指针都应该是指向堆内存的地址,而非栈地址。但是,尝试通过 Box
来把对象放到堆上是不能解决这个问题的:
#![allow(unused)] fn main() { // 这里不会发生编译错误 #[no_mangle] pub extern "C" fn new_struct_heap(v: u32) -> *mut FfiStruct { let s = FfiStruct::new(v); // 在栈上创建 `FfiStruct` let mut b = Box::new(s); // 将 `FfiStruct` 移入堆中 &mut *b // 返回指向即将过期的堆上对象的裸指针! } }
拥有这个值的 Box
是在栈上的,所以当它超出作用范围,将会被释放,同时,堆上对象也将被释放,此时返回了无效的指针。
Box::into_raw
可以解决这个问题,它放弃了对堆上对象的拥有责任,“忘记”了它:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn new_struct_raw(v: u32) -> *mut FfiStruct { let s = FfiStruct::new(v); // 在栈上创建 `FfiStruct` let b = Box::new(s); // 将 `FfiStruct` 移入堆中 // 消费 `Box`,并接管堆上内存 Box::into_raw(b) } }
但是这样做引发了另外一个问题:堆上对象如何释放?之前我们建议在同一侧代码中申请和释放内存,那么就是说,Rust 一侧负责释放内存。对应的工具是 Box::from_raw
,它可以从裸指针构建一个 Box
:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn free_struct_raw(p: *mut FfiStruct) { if p.is_null() { return; // 如果 C 代码给了空指针 } let _b = unsafe { // 安全:p 一定非空 Box::from_raw(p) }; } // `_b` 在作用范围结束后被抛弃,释放 `FfiStruct` 内存 }
但是这仍然使得 Rust 代码受制于 C 代码。如果 C 代码出现混乱,两次请求 Rust 释放同一指针,会导致 Rust 的分配器出现致命问题。
以上表明了本章的主题:使用 FFI 会让你面对标准 Rust 中不存在的风险。只要你能够意识到其中的风险和成本,那也是值得的。控制跨越 FFI 边界内容的细节有助于降低风险,但是无法完全消除它。
当使用 C 代码调用 Rust 代码的时候,还有一点需要关注的:如果你的 Rust 代码忽略了[第 18 条][Item 18]的建议,你应该防止 panic!
跨越 FFI 边界,因为这会导致未定义的、糟糕的行为 4。
牢记
- 与其他语言的代码接口使用 C 作为最小公分母,这意味着符号都存在于一个全局命名空间中。
- 尽可能减少跨越 FFI 边界时可能发生的错误:
- 使用安全的包装器包裹
unsafe
的 FFI 代码 - 分配和释放内存应该在 FFI 的同一侧完成,无论是哪一侧都可以
- 让数据结构的内存布局是 C 兼容的
- 使用已知大小的整数类型
- 使用标准库中提供的 FFI 相关助手函数或者类型
- 防止
panic!
跨越 FFI 边界
- 使用安全的包装器包裹
原文点这里查看
注释
如果所用的 FFI 函数来自 C 标准库, libc
crate 已经具备这些声明了,无需重复编写。
在 Cargo.toml 中使用 links
键可以让这个依赖对 Cargo 可见。
Rust 中用来将重整后的名称变回阅读友好名称的工具叫做 rustfilt
,这个工具基于 [rustc-demangle
][rustc-demangle] 命令构建,类似于 c++filt
工具。
Rust 1.71 版本中包含 C-unwind ABI,可以实现跨语言的错误回退功能。
第 35 条:优先使用bindgen而不是手动FFI映射
第 34 条讨论了从 Rust 程序调用 C 代码的机制,描述了 C 结构和函数的声明需要有一个等效的 Rust 声明,以允许它们通过 FFI 使用。C 和 Rust 的声明需要保持同步,并且第 34 条还警告说工具链不会帮助解决这个问题 —— 不匹配将会被默默忽略,隐藏以后会出现的问题。
让两件事情完全同步听起来像是自动化的好目标,Rust 项目为此提供了正确的工具:bindgen。bindgen 的主要功能是解析 C 头文件并生成相应的 Rust 声明。 以第 34 条中的一些 C 声明为例:
/* File lib.h */
#include <stdint.h>
typedef struct {
uint8_t byte;
uint32_t integer;
} FfiStruct;
int add(int x, int y);
uint32_t add32(uint32_t x, uint32_t y);
bindgen
工具可以手动调用(或通过 build.rs
构建脚本调用)以创建相应的 Rust 文件:
% bindgen --no-layout-tests \
--allowlist-function="add.*" \
--allowlist-type=FfiStruct \
-o src/generated.rs \
lib.h
生成的 Rust 代码与第 34 条中手工编写的声明是完全相同的:
#![allow(unused)] fn main() { /* automatically generated by rust-bindgen 0.59.2 */ #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct FfiStruct { pub byte: u8, pub integer: u32, } extern "C" { pub fn add( x: ::std::os::raw::c_int, y: ::std::os::raw::c_int, ) -> ::std::os::raw::c_int; } extern "C" { pub fn add32(x: u32, y: u32) -> u32; } }
并且可以通过源码级别的 include!
宏引入到 Rust 代码中:
#![allow(unused)] fn main() { // Include the auto-generated Rust declarations. include!("generated.rs"); }
对于任何非最简单的 FFI 声明,请使用 bindgen 为 C 代码生成 Rust 绑定 —— 在这个领域,机器制作的大规模生产代码绝对优于手工精制的声明。如果 C 函数定义发生变化,C 编译器会在 C 声明不再与 C 定义匹配时发出抱怨,但不会有东西抱怨手工编写的 Rust 声明不再与 C 声明匹配;从 C 声明自动生成 Rust 声明可以确保两者保持同步。
这也意味着 bindgen 步骤是理想的候选者,可以包含在 CI 系统(第 32 条)中;如果生成的代码包含在源码控制中,CI 系统可以在新生成的文件与检入的版本不匹配时报错。
当您处理具有大量 API 的现有 C 代码库时,bindgen 工具才能真正发挥其作用。为一个庞大的 lib_api.h
头文件创建 Rust 等价物是手动且乏味的,因此容易出错 —— 并且如前所述,许多不匹配错误的类别不会被工具链检测到。bindgen 还拥有一系列选项,允许针对 API 的特定子集(比如,之前展示的 -- allowlist-function
和 --allowlist-type
选项)。1
这也允许采用分层方法在 Rust 中暴露现有的 C 库;包装某个 xyzzy 库的一个常见约定是拥有以下内容:
- 一个仅包含 bindgen 生成的代码的
xyzzy-sys crate
—— 其使用必然是不安全的。 - 一个
xyzzy crate
,它封装了不安全代码,并提供对底层功能的安全 Rust 访问。
这将在一个层中集中不安全代码,并允许程序的其他部分遵循第 16 条的建议。
超越C语言
bindgen
工具能够处理一些 C++
结构,但只是有限的一部分,并且方式有限。为了更好的(尽管仍然有限)集成,可以考虑使用 cxx crate
进行 C++/Rust
交互操作。cxx
不是从 C++
声明生成 Rust
代码,而是采用从公共模式自动生成 Rust
和 C++
代码的方法,允许更紧密的集成。
注释
示例还使用了 --no-layout-tests
选项以保持输出简单;默认情况下,生成的代码将包含 #[test]
代码,以检查结构体确实正确布局。