Effective Rust
改进 Rust 代码的 35 种具体方法
前言
“代码更像是‘指导原则’,而非实际规则。” —— 赫克托·巴博萨
在拥挤的现代编程语言领域,Rust 与众不同。Rust提供了编译型语言的速度、非GC型语言的效率和函数式语言的类型安全——同时提供了解决内存安全问题的独特方案。因此,Rust经常被评选为最受欢迎的编程语言。
Rust类型系统的强大和一致性意味着,如果Rust程序通过编译,它已有相当大的机会正常运行——这种现象以前只有在Haskell等更学术、更不易理解的语言中才能观察到。如果Rust程序编译成功,它也将安全的运行。
然而,这种安全(包括类型安全和内存安全)是有代价的。尽管基础文档的质量很高,但Rust仍以其陡峭的入门坡度而闻名,新手必须经历与借用检查器作斗争、重新设计数据结构以及被生命周期所困扰的入门仪式。一个通过编译的Rust程序有很大机会第一次就能正常运行,但让它通过编译是真的困难——即使Rust编译器的错误诊断非常有用。
本书面向的读者
本书试图帮助程序员解决这些困难领域,即使他们已经拥有使用 C++ 等现有编译语言的经验。因此,与其他 Effective <Language> 书籍一样,本书旨在成为 Rust 新手可能需要的第二本书,因为他们已经在其他地方接触过基础知识,例如《Rust 编程语言》(Steve Klabnik 和 Carol Nichols,No Starch Press)或《Rust 编程》(Jim Blandy 等,O'Reilly)。
但是,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 的移动语义发挥作用时。)
本书导读
本书的内容分为六章:
- 第 1 章 — types:围绕 Rust 核心类型系统的建议
- 第 2 章 — traits:使用 Rust trait的建议
- 第 3 章 — 概念:构成 Rust 设计的核心思想
- 第 4 章 — 依赖项:使用 Rust 包生态系统的建议
- 第 5 章 — 工具:超越 Rust 编译器来改进代码库的建议
- 第 6 章 — 超越标准 Rust:当您必须在 Rust 标准、安全环境之外工作时的建议
虽然“概念”一章可以说比“types”和“traits”一章更基础,但它被故意放在本书的后面,以便从头到尾阅读的读者可以先建立一些信心。
本书中使用的约定
本书中使用了以下印刷约定:
- 斜体:表示新术语、URL、电子邮件地址、文件名和文件扩展名。
- 等宽:用于程序列表,以及段落内引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
以下标记用于标识某些方面不正确的代码: | 标记 | 含义 | | ---- | ---- | | x | 该代码无法编译!| | ? | 此代码未产生所需的行为。|
致谢
我要感谢帮助本书问世的人们:
- 对文本各个方面提供专业和详细反馈的技术审阅者: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 类型系统的建议。该类型系统更比其他主流语言更具表现力; 它与OCaml或Haskell等“学术”语言有更多的共同点。
其中核心部分是Rust的enum类型,它比其他语言中的枚举类型更具表现力,并且允许代数数据类型。
本章的条款涵盖了该语言提供的基本类型以及如何将它们组合成能够精确表达程序语义的数据结构。将行为编码到类型系统中的概念有助于减少所需的检查和错误路径代码量,因为无效状态在编译时被工具链拒绝,而不是在运行时被程序拒绝。
本章还介绍了Rust标准库提供的一些普遍的数据结构:Option、Result、Error、Iterator。熟悉这些标准工具有助于你编写高效、紧凑、惯用的Rust——特别是,它们允许使用 Rust 的问号运算符,该运算符支持隐式但仍是类型安全的错误处理。
请注意,涉及 Rust trait 的条款将在下一章中介绍,但必然与本章中的条款有一定程度上的重叠,因为trait描述了类型的行为。
条款1:用类型系统去表示你的数据结构
“谁叫他们是程序员而不是打字员”—— @thingskatedid
本条款快速介绍了 Rust 的类型系统,从编译器提供的基本类型开始,然后介绍将值组合成数据结构的各种方式。
随后Rust的枚举类型扮演主角。虽然基本版本与其他语言提供的枚举相同,但将枚举变量与数据字段组合的能力可以增强其灵活性和表现力。
基本类型
Rust类型系统的基础知识对于来自其他静态类型编程语言(例如 C++、Go 或 Java)的人来说非常熟悉。有一批具有特定大小的整数类型,包括有符号 (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; }
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:18:18 | 18 | 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 | 18 | let y: i16 = x.try_into().unwrap(); | ++++++++++++++++++++ }
这让人放心:当程序员做有风险的操作时,Rust不会坐视不理。虽然我们可以看到,这种特定转换中涉及的值是没问题的,但编译器必须考虑到转换不顺利的可能性:
#![allow(unused)] fn main() { let x: i32 = 66_000; let y: i16 = x; // 这个值将会是多少? }
错误输出也给出了一个早期迹象,表明虽然 Rust 有更严格的规则,但它也有有用的编译器消息,指出如何遵守规则。建议的解决方案提出了一个问题,即如何处理转换必须改变值以适应的情况,我们稍后将更多地讨论错误处理(第 4 条)和使用 panic!
(第 18 条)。
Rust 还不允许一些看似“安全”的事情,例如将较小整数类型的值放入较大的整数类型中:
#![allow(unused)] fn main() { let x = 42i32; // Integer literal with type suffix let y: i64 = x; }
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:36:18 | 36 | let y: i64 = x; | --- ^ expected `i64`, found `i32` | | | expected due to this | help: you can convert an `i32` to an `i64` | 36 | let y: i64 = x.into(); | +++++++ }
在这里,建议的解决方案不会引发错误处理的担忧,但转换仍然需要明确。我们将在后面更详细地讨论类型转换(第 5 条)。
继续讨论Rust的基本类型。Rust 有 bool 类型、浮点类型(f32、f64)和 unit 类型 ()(类似 C 的 void)。
更有趣的是 char
字符类型,它保存一个 Unicode值(类似于 Go 的 rune
类型)。虽然它在内部存储为四个字节,但同样没有与32位整数的隐式转换。
类型系统中的这种精度迫使您明确说明您想要表达的内容 - u32
值不同于char
,而 char
又不同于 UTF-8 字节序列,而 UTF-8 字节序列又不同于任意字节序列,而具体说明您指的是哪个则取决于您。Joel Spolsky 的著名博客文章可以帮助您了解您需要哪个。
当然,有一些辅助方法允许您在这些不同类型之间进行转换,但它们的签名会迫使您处理(或明确忽略)失败的可能性。例如,Unicode 代码点始终可以用 32 位表示,因此'a' as u32
是允许的,但反过来就难办了(因为有些 u32 值不是有效的 Unicode 代码点):
char::from_u32
:返回一个Option<char>
,强制调用者处理失败情况。char::from_u32_unchecked
:假设有效性,但如果该假设不成立,则有可能导致未定义的行为。因此,该函数被标记为不安全,从而迫使调用者也使用不安全(第 16 条)。
聚合类型
说到聚合类型,Rust有多种组合相关值的方法。其中大多数与其他语言中可用的聚合机制相似:
- 数组:保存单一类型的多个实例,其中实例数在编译时已知。例如,
[u32; 4]
是一行中的四个4字节整数。 - 元组:保存多个异构类型的实例,其中元素数及其类型在编译时已知,例如
(WidgetOffset、WidgetSize、WidgetColor)
。如果元组中的类型没有区别(例如(i32、i32、&'static str、bool)
),最好为每个元素命名并使用结构。 - 结构体:还保存编译时已知的异构类型的实例,但允许通过名称引用整体类型和各个字段。
Rust 还包括元组结构体,它是结构体和元组的混合体:整体类型有名称,但没有各个字段的名称 - 它们用数字来引用:
s.0
、s.1
,等等:
#![allow(unused)] fn main() { /// Struct with two unnamed fields. struct TextMatch(usize, String); // Construct by providing the contents in order. let m = TextMatch(12, "needle".to_owned()); // Access by field number. assert_eq!(m.0, 12); }
枚举
让我们看看 Rust 类型系统中的瑰宝——枚举。枚举的基本形式很难让人兴奋。与其他语言一样,枚举允许您指定一组互斥的值,可能还附加一个数值:
#![allow(unused)] fn main() { enum HttpResultCode { Ok = 200, NotFound = 404, Teapot = 418, } let code = HttpResultCode::NotFound; assert_eq!(code as i32, 404); }
由于每个枚举定义都会创建一个不同的类型,因此可以使用它来提高采用 bool
参数的函数的可读性和可维护性。而不是:
#![allow(unused)] fn main() { print_page(/* both_sides= */ true, /* color= */ false); }
使用一对枚举的版本:
#![allow(unused)] fn main() { pub enum Sides { Both, Single, } pub enum Output { BlackAndWhite, Color, } pub fn print_page(sides: Sides, color: Output) { // ... } }
在调用时更加类型安全并且更易于阅读:
#![allow(unused)] fn main() { print_page(Sides::Both, Output::BlackAndWhite); }
与 bool
版本不同,如果库用户意外地颠倒了参数的顺序,编译器会立即报错:
#![allow(unused)] fn main() { error[E0308]: arguments to this function are incorrect --> src/main.rs:104:9 | 104 | print_page(Output::BlackAndWhite, Sides::Single); | ^^^^^^^^^^ --------------------- ------------- expected `enums::Output`, | | found `enums::Sides` | | | expected `enums::Sides`, found `enums::Output` | note: function defined here --> src/main.rs:145:12 | 145 | pub fn print_page(sides: Sides, color: Output) { | ^^^^^^^^^^ ------------ ------------- help: swap these arguments | 104 | print_page(Sides::Single, Output::BlackAndWhite); | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ }
使用 newtype 模式(参见第 6 条)包装 bool
也能实现类型安全性和可维护性;如果语义始终为布尔值,则通常最好使用 newtype 模式,如果将来有可能出现新的替代方案(例如 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 }; }
#![allow(unused)] fn main() { error[E0004]: non-exhaustive patterns: `HttpResultCode::Teapot` not covered --> src/main.rs:44:21 | 44 | let msg = match code { | ^^^^ pattern `HttpResultCode::Teapot` not covered | note: `HttpResultCode` defined here --> src/main.rs:10:5 | 7 | enum HttpResultCode { | -------------- ... 10 | Teapot = 418, | ^^^^^^ not covered = note: the matched value is of type `HttpResultCode` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 46 ~ HttpResultCode::NotFound => "Not found", 47 ~ HttpResultCode::Teapot => todo!(), | }
编译器强制程序员考虑枚举所表示的所有可能性,即使结果只是添加一个默认的 arm _ => {}
。(请注意,现代 C++ 编译器也可以并且确实会警告枚举缺少 switch
分支。)
带字段的枚举
Rust 枚举特性的真正威力在于,每个变量都可以拥有随附的数据,使其成为充当代数数据类型 (ADT) 的聚合类型。主流语言的程序员对此不太熟悉;用 C/C++ 术语来说,它就像enum
与union
的组合——只是类型安全的。
这意味着程序数据结构的不变量可以编码到 Rust 的类型系统中;不符合这些不变量的状态甚至不会编译。设计良好的枚举可以让人类和编译器清楚地了解创建者的意图:
#![allow(unused)] fn main() { use std::collections::{HashMap, HashSet}; pub enum SchedulerState { Inert, Pending(HashSet<Job>), Running(HashMap<CpuId, Vec<Job>>), } }
仅从类型定义来看,可以合理地猜测作业会排队等待,直到调度程序完全激活,此时它们会被分配到某个 CPU 池中。
这突出了本条目的中心主题,即使用 Rust 的类型系统来表达与软件设计相关的概念。
当这种情况没有发生时,一个明显的迹象是注释解释了某些字段或参数何时有效:
#![allow(unused)] fn main() { pub struct DisplayProps { pub x: u32, pub y: u32, pub monochrome: bool, // `fg_color` must be (0, 0, 0) if `monochrome` is true. pub fg_color: RgbColor, } }
这是用枚举保存数据进行替换的最佳候选:
#![allow(unused)] fn main() { pub enum Color { Monochrome, Foreground(RgbColor), } pub struct DisplayProps { pub x: u32, pub y: u32, pub color: Color, } }
这个小例子说明了一条关键建议:让无效状态无法在类型中表达。仅支持有效值组合的类型意味着整个错误类别都会被编译器拒绝,从而产生更小、更安全的代码。
无处不在的枚举类型
回到枚举的强大功能,有两个概念非常常见,以至于 Rust 的标准库包含内置枚举类型来表达它们;这些类型在 Rust 代码中无处不在。
Option
第一个概念是 Option
:要么存在特定类型的值(Some(T)
),要么不存在(None
)。始终使用 Option
来表示可能不存在的值;切勿回退到使用标记值(-1、nullptr
等)来尝试在带内表达相同的概念。
不过,有一个微妙的点需要考虑。如果您处理的是事物集合,则需要确定集合中没有事物是否等同于没有集合。在大多数情况下,不会出现这种区别,您可以继续使用(例如)Vec<Thing>
:计数为零意味着没有事物。
但是,肯定还有其他罕见的情况需要使用 Option<Vec<Thing>>
来区分这两种情况 - 例如,加密系统可能需要区分“单独传输的有效载荷”和“提供的空有效载荷”。 (这与 SQL 中关于列的 NULL标记的争论有关。)
同样,对于可能不存在的字符串,最佳选择是什么?""
或None
是否更适合表示值不存在?两种方式都可以,但 Option<String>
清楚地传达了该值可能不存在的可能性。
Result<T, E>
第二个常见概念来自错误处理:如果函数失败,应该如何报告该失败?从历史上看,使用特殊的标记值(例如,Linux 系统调用的 -errno
返回值)或全局变量(POSIX 系统的 errno
)。最近,支持函数返回多个或元组返回值的语言(例如 Go)可能有一个惯例,即返回 (result, error)
对,假设当error
非“零”时,result
存在一些合适的“零”值。
在 Rust 中,有一个enum
专门用于此目的:始终将可能失败的操作的结果编码为 Result<T, E>
。T
类型保存成功的结果(在 Ok
变体中),E
类型保存失败时的错误详细信息(在 Err
变体中)。
使用标准类型使设计意图清晰。它还允许使用标准转换(第 3 项)和错误处理(第 4 项),这反过来又使得使用 ?
运算符简化错误处理成为可能。
条款2:使用类型系统来表达公共行为
第 1 条讨论了如何在类型系统中表达数据结构;本项继续讨论 Rust 类型系统中的行为编码。
本项中描述的机制通常会让人感觉很熟悉,因为它们在其他语言中都有直接类似物:
- 函数:将代码块与名称和参数列表关联的通用机制。
- 方法:与特定数据结构的实例关联的函数。方法在面向对象作为编程范式出现后创建的编程语言中很常见。
- 函数指针:C 家族中的大多数语言(包括 C++ 和 Go)都支持函数指针,作为一种允许在调用其他代码时增加额外间接级别的机制。
- 闭包:最初在 Lisp 语言家族中最常见,但已被改造到许多流行的编程语言中,包括 C++(自 C++11 以来)和 Java(自 Java 8 以来)。
- 特征:描述所有适用于同一底层项目的相关功能集合。特征在许多其他语言中都有大致的对应物,包括 C++ 中的抽象类和 Go 和 Java 中的接口。
当然,所有这些机制都有 Rust 特有的细节,本条款将介绍这些细节。
在前面的列表中,特征对本书最为重要,因为它们描述了 Rust 编译器和标准库提供的大量行为。第 2 章重点介绍了为设计和实现特征提供建议的条目,但它们的普遍性意味着它们也经常出现在本章的其他条目中。
函数和方法
与其他编程语言一样,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}` --> src/main.rs:102:17 | 102 | assert!(op1 == op2); | --- ^^ --- fn(i32, i32) -> i32 {main::sum} | | | fn(i32, i32) -> i32 {main::sum} | help: use parentheses to call these | 102 | assert!(op1(/* i32 */, /* i32 */) == op2(/* i32 */, /* i32 */)); | ++++++++++++++++++++++ ++++++++++++++++++++++ }
相反,编译器错误表明该类型类似于 fn(i32, i32) -> i32 {main::sum}
,这种类型完全是编译器内部的类型(即不能用用户代码编写),并且标识特定函数及其签名。换句话说,出于优化原因,sum
的类型对函数的签名及其位置进行了编码;此类型可以自动强制(条目 5)为 fn
类型。
闭包
裸函数指针具有局限性,因为调用函数可用的输入只有那些明确作为参数值传递的输入。例如,考虑一些使用函数指针修改切片的每个元素的代码:
#![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 --> src/main.rs:125:13 | 125 | 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); }
在这个概念上下文中保存的值通常是引用(第 8 条),就像这里一样,但它们也可以是对环境中事物的可变引用,或者是完全移出环境的值(通过在输入参数前使用 move 关键字)。
回到modify_all示例,在需要函数指针的地方不能使用闭包:
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:199:31 | 199 | modify_all(&mut data, |y| y + amount_to_add); | ---------- ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, | | found closure | | | arguments to this function are incorrect | = note: expected fn pointer `fn(u32) -> u32` found closure `[closure@src/main.rs:199:31: 199:34]` note: closures can only be coerced to `fn` types if they do not capture any variables --> src/main.rs:199:39 | 199 | modify_all(&mut data, |y| y + amount_to_add); | ^^^^^^^^^^^^^ `amount_to_add` | captured here note: function defined here --> src/main.rs:60:12 | 60 | pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) { | ^^^^^^^^^^ ----------------------- }
相反,接收闭包的代码必须接受 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*
trait,它们之间表达了这种环境捕获行为的一些区别:
FnOnce
:描述只能调用一次的闭包。如果环境的某个部分被移动到闭包的上下文中,并且闭包的主体随后将其移出闭包的上下文,那么这些移动只能发生一次 - 没有其他源项的副本可以移动 - 因此闭包只能被调用一次。FnMut
:描述可以重复调用的闭包,它可以更改其环境,因为它可变地从环境中借用。Fn
:描述可以重复调用的闭包,并且只能不可变地从环境中借用值。
编译器会自动为代码中的任何 lambda 表达式实现这些 Fn*
trait的适当子集;无法手动实现任何这些trait(与 C++ 的 operator()
重载不同)。
回到前面闭包的粗略思维模型,编译器自动实现的特征大致对应于捕获的环境上下文是否具有以下元素:
FnOnce
:任何移动的值FnMut
:任何对值的可变引用(&mut T
)Fn
:仅对值的正常引用(&T
)
此列表中的后两个特征各自具有前一个特征的特征界限,当您考虑使用闭包的事物时,这是有道理的:
- 如果某些东西只需要调用一次闭包(通过接收 FnOnce 表示),则可以向其传递一个可以重复调用的闭包 (FnMut)。
- 如果某些东西需要重复调用可能会改变其环境的闭包(通过接收 FnMut 表示),则可以向其传递一个不需要改变其环境的闭包 (Fn)。
裸函数指针类型 fn
名义上也属于此列表的末尾;任何(非不安全)的 fn
类型都会自动实现所有 Fn*
特征,因为它不会从环境中借用任何东西。
因此,在编写接受闭包的代码时,请使用最通用的 Fn 特征*,以便为调用者提供最大的灵活性 - 例如,对于仅使用一次的闭包,接受 FnOnce。同样的道理也导致建议优先使用 Fn*
特征界限而不是裸函数指针 (fn
)。
trait
Fn*
特征比裸函数指针更灵活,但它们仍然只能描述单个函数的行为,而且即使这样也只能根据函数的签名来描述。
但是,它们本身就是 Rust 类型系统中描述行为的另一种机制的示例,即特征。特征定义了一组相关函数,某些底层项会将其公开;此外,这些函数通常(但不一定是)是方法,以 self 的一些变体作为其第一个参数。
特征中的每个函数也都有一个名称,提供一个标签,允许编译器消除具有相同签名的函数的歧义,更重要的是,允许程序员推断函数的意图。
Rust 特征大致类似于 Go 和 Java 中的“接口”,或 C++ 中的“抽象类”(所有虚方法,没有数据成员)。特征的实现必须提供所有函数(但请注意,特征定义可以包括默认实现;第 13 项),并且还可以具有这些实现使用的关联数据。这意味着代码和数据以一种面向对象 (OO) 的方式封装在一个通用抽象中。
接受结构并调用其函数的代码只能与该特定类型一起使用。如果有多个类型实现通用行为,那么定义一个封装该通用行为的特征并让代码使用该特征的函数(而不是涉及特定结构的函数)会更加灵活。
这导致了与其他受 OO 影响的语言相同的建议:如果预期未来灵活性,则优先接受特征类型而不是具体类型。
有时,您希望在类型系统中区分某些行为,但它无法在特征定义中表达为某些特定函数签名。例如,考虑用于对集合进行排序的 Sort 特征;实现可能是稳定的(比较相同的元素将在排序前后以相同的顺序出现),但没有办法在排序方法参数中表达这一点。
在这种情况下,仍然值得使用类型系统来跟踪此要求,使用标记特征:
#![allow(unused)] fn main() { pub trait Sort { /// Rearrange contents into sorted order. fn sort(&mut self); } /// Marker trait to indicate that a [`Sort`] sorts stably. pub trait StableSort: Sort {} }
标记特征没有函数,但实现仍必须声明它正在实现该特征——这充当了实现者的承诺:“我庄严宣誓我的实现排序稳定”。依赖稳定排序的代码可以指定 StableSort 特征界限,依靠荣誉系统来保留其不变量。使用标记特征来区分无法在特征函数签名中表达的行为。
一旦行为作为特征封装到 Rust 的类型系统中,它就可以以两种方式使用:
- 作为特征界限,它限制了在编译时通用数据类型或函数可以接受的类型
- 作为特征对象,它限制了在运行时可以存储或传递给函数的类型
以下部分描述了这两种可能性,第 12 条更详细地介绍了它们之间的权衡。
特征界限
条款3:首选Option和Result转换而不是显式匹配表达式
第 1 项阐述了枚举的优点,并展示了匹配表达式如何迫使程序员考虑所有可能性。第 1 项还介绍了 Rust 标准库提供的两个普遍存在的枚举:
Option<T>
:表示值(类型 T)可能存在也可能不存在Result<T, E>
:表示返回值(类型 T)的操作可能不成功,而可能返回错误(类型 E)
本项探讨了在哪些情况下应尽量避免对这些特定枚举使用显式匹配表达式,而是倾向于使用标准库为这些类型提供的各种转换方法。使用这些转换方法(它们本身通常作为匹配表达式在幕后实现)会使代码更紧凑、更符合地道,并且意图更清晰。
第一种不需要匹配的情况是,只有值相关,而值的缺失(以及任何相关错误)可以忽略:
#![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! 失败意味着程序终止,但其余代码可以假设成功编写。使用显式匹配执行此操作会不必要地冗长:
#![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!"), }; // Assume `f` is a valid `std::fs::File` from here onward. }
Option 和 Result 都提供了一对方法来提取其内部值,如果不存在则恐慌!:unwrap 和 expect。后者允许在失败时个性化错误消息,但无论哪种情况,生成的代码都更短更简单 - 错误处理委托给 .unwrap() 后缀(但仍然存在):
#![allow(unused)] fn main() { let f = std::fs::File::open("/etc/passwd").unwrap(); }
条款4:首选惯用的错误类型
第 3 项条款描述了如何使用标准库为 Option
和 Result
类型提供的转换,以便使用 ?
运算符简洁、惯用地处理结果类型。它没有讨论如何最好地处理作为 Result<T, E>
的第二个类型参数出现的各种不同错误类型 E
;这是本项的主题。
这仅在存在各种不同错误类型时才有意义。如果函数遇到的所有不同错误都已经是同一类型,则它只需返回该类型即可。当存在不同类型的错误时,需要决定是否应保留子错误类型信息。
Error Trait
了解标准 Trait
(第 10 项)涉及的内容总是好的,这里相关的 Trait
是 std::error::Error
。Result
的 E
类型参数不必是实现 Error
的类型,但它是一种允许包装器表达适当 Trait
界限的常见约定——因此最好为错误类型实现 Error
。
首先要注意的是,Error
类型的唯一硬性要求是 Trait
界限:任何实现 Error
的类型还必须实现以下 Trait
:
- Display Trait,意味着它可以用 {} 格式化!
- Debug Trait,意味着它可以用 {:?} 格式化!
换句话说,应该可以向用户和程序员显示 Error
类型。
该 Trait
中唯一的方法是 source()
,它允许 Error
类型公开内部嵌套错误。此方法是可选的——它带有默认实现(第 13 项),返回 None
,表示内部错误信息不可用。
最后要注意的一点是:如果你正在为 no_std
环境(第 33 条)编写代码,则可能无法实现 Error
— Error
特征当前是在 std
中实现的,而不是core
中实现的,因此不可用。
最小错误
如果不需要嵌套错误信息,则 Error
类型的实现不需要比 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
,我们希望这样,以便其他代码区域可以处理 Error
。无法为 String
实现 Error
,因为特征和类型都不属于我们(所谓的孤儿规则):
#![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
trait,前提是也实现了 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
实现将其转换为字符串。?
使编译器查找并使用可将其从字符串转换为MyError
的From
实现。
嵌套错误
另一种情况是,嵌套错误的内容非常重要,应该保留并提供给调用者。
考虑一个库函数,它尝试将文件的第一行作为字符串返回,只要该行不太长。片刻的思考揭示了可能发生的(至少)三种不同类型的故障:
- 该文件可能不存在或无法读取。
- 该文件可能包含无效的 UTF-8 数据,因此无法转换为字符串。
- 该文件的第一行可能太长。
与第 1 条一致,您可以使用类型系统将所有这些可能性表达并包含在枚举中:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum MyError { Io(std::io::Error), Utf8(std::string::FromUtf8Error), General(String), } }
这个枚举定义包括一个 derive(Debug)
,但是为了满足Error
trait,还需要一个 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
trait也是一个好主意 (第 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
,因为trait和struct都对他们来说是外部的。
更好的是,实现 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
包,它提供了此类宏的高质量、广泛使用的实现。thiserror
生成的代码还小心避免使任何 thiserror
类型在生成的 API 中可见,这反过来意味着与第 24 项相关的问题不适用。
trait对象
第一种处理嵌套错误的方法抛弃了所有子错误细节,只保留了一些字符串输出(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
trait,因为它本身就是一个错误。From<Error>
trait,允许轻松包装子错误。
这意味着可以从内部 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
添加额外的间接层),并且还添加了其他有用的功能(例如堆栈跟踪)。因此,它正在迅速成为错误处理的标准建议 — 此处附议此建议:考虑在应用程序中使用 anyhow
包进行错误处理。
库与应用程序
上一节的最终建议包括限定条件“…应用程序中的错误处理”。这是因为,为在库中重用而编写的代码与构成顶级应用程序的代码之间通常存在区别。
为库编写的代码无法预测代码的使用环境,因此最好发出具体、详细的错误信息,让调用者弄清楚如何使用这些信息。这倾向于前面描述的枚举样式嵌套错误(并且还避免了对库的公共 API 中的 anyhow
的依赖,参见第 24 条)。
但是,应用程序代码通常需要更多地关注如何向用户呈现错误。它还可能必须应对其依赖关系图中存在的所有库发出的所有不同错误类型(第 25 条)。因此,更动态的错误类型(例如 anyhow::Error
)使整个应用程序的错误处理更简单、更一致。
要记住的事情
标准 Error
trait对您要求不高,因此最好为您的错误类型实现它。
处理异构底层错误类型时,请确定是否有必要保留这些类型。
如果不需要,请考虑使用 anyhow
包装应用程序代码中的子错误。
如果是,请将它们编码为枚举并提供转换。考虑使用 thiserror
来帮助完成此操作。
考虑使用 anyhow
包在应用程序代码中方便地进行惯用错误处理。
这是您的决定,但无论您决定什么,都将其编码到类型系统中(第 1 条)。
条款5:理解类型转换
Rust类型转换分为三类:
- 手动:通过实现
From
和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...
变体的不同之处在于唯一的特征方法返回结果而不是保证的新项,这并不奇怪。 Try...
特征定义还需要一个关联类型,该类型给出在失败情况下发出的错误 E 的类型。
因此,第一条建议是,如果转换可能失败,则(仅)实现 Try...
特征,与第 4 条一致。另一种方法是忽略错误的可能性(例如,使用 .unwrap()
),但这需要深思熟虑,在大多数情况下,最好将该选择留给调用者。
类型转换特征具有明显的对称性:如果类型 T
可以转换为类型 U
(通过 Into<U>
),那么这是否与可以通过从类型 T
的项目(通过 From<T>
)转换来创建类型 U
的项目相同?
条款6:拥抱新类型模式
条款 1 描述了元组结构体,其中结构体的字段没有名称,而是通过数字 (self.0) 引用。本条款重点介绍具有某个现有类型的单个条款的元组结构,从而创建一个可以容纳与封闭类型完全相同的值范围的新类型。这种模式在 Rust 中非常普遍,值得拥有自己的条目并拥有自己的名称:新类型模式。
新类型模式最简单的用途是指示类型的附加语义,超出其正常行为。为了说明这一点,想象一个要向火星发送卫星的项目。这是一个大项目,因此不同的小组构建了项目的不同部分。一个小组负责火箭发动机的代码:
#![allow(unused)] fn main() { /// Fire the thrusters. Returns generated impulse in pound-force seconds. pub fn thruster_impulse(direction: Direction) -> f64 { // ... return 42.0; } }
而另一个小组负责惯性制导系统:
#![allow(unused)] fn main() { /// Update trajectory model for impulse, provided in Newton seconds. pub fn update_trajectory(force: f64) { // ... } }
最终需要将这些不同的部分连接在一起:
#![allow(unused)] fn main() { let thruster_force: f64 = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force); }
Rust 包含一个类型别名功能,它允许不同的群体更清楚地表达他们的意图:
#![allow(unused)] fn main() { /// Units for force. pub type PoundForceSeconds = f64; /// Fire the thrusters. Returns generated impulse. pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds { // ... return 42.0; } }
#![allow(unused)] fn main() { /// Units for force. pub type NewtonSeconds = f64; /// Update trajectory model for impulse. pub fn update_trajectory(force: NewtonSeconds) { // ... } }
但是,类型别名实际上只是文档;它们比以前版本的文档注释更有力,但没有什么可以阻止在需要 NewtonSeconds 值的地方使用 PoundForceSeconds 值:
#![allow(unused)] fn main() { let thruster_force: PoundForceSeconds = thruster_impulse(direction); let new_direction = update_trajectory(thruster_force); }
这是 newtype 模式有帮助的地方:
#![allow(unused)] fn main() { /// Units for force. pub struct PoundForceSeconds(pub f64); /// Fire the thrusters. Returns generated impulse. pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds { // ... return PoundForceSeconds(42.0); } }
#![allow(unused)] fn main() { /// Units for force. pub struct NewtonSeconds(pub f64); /// Update trajectory model for impulse. 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() { 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()); }
使用新类型来标记类型的附加“单元”语义的相同模式也有助于使纯布尔参数不那么含糊。重新审视第 1 条中的示例,使用新类型使参数的含义变得清晰:
#![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 项的一个具体示例 — 将语义编码到类型系统中,以便编译器负责监管这些语义。
绕过特征的孤儿规则
需要使用新类型模式的另一种常见但更微妙的场景围绕着 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>") } } }
导致编译器错误(这又指向新类型的方向):
#![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: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 项)中的两个不同的包都(比如说)为 rand::rngs::StdRng 实现 impl std::fmt::Display,那么编译器/链接器就无法在它们之间进行选择。
这经常会导致挫败感:例如,如果您尝试序列化包含来自另一个包的类型的数据,则孤儿规则会阻止您为 somecrate::SomeType.3 编写 impl serde::Serialize
但 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 声明最终会得到大量派生:
#![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) } } }
条款7:针对复杂类型使用构造器
条款8:熟悉引用和指针类型
对于一般编程而言,引用是一种间接访问某个数据结构的方式,与拥有该数据结构的变量无关。在实践中,这通常被实现为指针:一个数字,其值是数据结构在内存中的地址。
现代 CPU 通常会对指针施加一些限制 - 内存地址应在有效的内存范围内(无论是虚拟的还是物理的),并且可能需要内存对齐(例如,只有当 4 字节整数值的地址是 4 的倍数时才可以访问它)。
但是,高级编程语言通常会在其类型系统中编码有关指针的更多信息。在包括 Rust 在内的 C 派生语言中,指针具有一种类型,该类型指示指向的内存地址处预期存在哪种数据结构。这允许代码解释该地址处的内存内容以及该地址之后的内存中的内容。
这种基本级别的指针信息 - 假定的内存位置和预期的数据结构布局 - 在 Rust 中表示为原始指针。但是,安全的 Rust 代码不使用原始指针,因为 Rust 提供了更丰富的引用和指针类型,可提供额外的安全保证和约束。这些引用和指针类型是本条款的主题;原始指针被归入条款 16(讨论不安全代码)。
Rust引用
Rust 中最普遍的指针类型是引用,其类型写为 &T,用于表示某个类型 T。尽管这是一个指针值,但编译器会确保遵守有关其使用的各种规则:它必须始终指向相关类型 T 的有效、正确对齐的实例,其生命周期(第 14 条)超出其使用范围,并且必须满足借用检查规则(第 15 条)。Rust 中的术语“引用”始终暗示这些额外的约束,因此裸术语“指针”通常很少见。
Rust 引用必须指向有效、正确对齐的项目这一约束与 C++ 的引用类型相同。但是,C++ 没有生命周期的概念,因此允许使用悬垂引用:
// C++
const int& dangle() {
int x = 32; // 栈上变量在退出函数后成为悬挂变量,当有新的函数调用时该内存会被覆盖
return x; // return 指向栈变量的引用
}
Rust 的借用和生命周期检查意味着等效代码甚至无法编译:
#![allow(unused)] fn main() { fn dangle() -> &'static i64 { let x: i64 = 32; // on the stack &x } }
#![allow(unused)] fn main() { 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 所示排列在堆栈上:
Rust 引用可以引用位于堆栈或堆上的项。Rust 默认在堆栈上分配项,但 Box<T>
指针类型(大致相当于 C++ 的 std::unique_ptr<T>
)强制在堆上进行分配,这反过来意味着分配的项可以超出当前块的范围。在幕后,Box<T>
也是一个简单的八字节指针值:
#![allow(unused)] fn main() { let box_pt = Box::new(Point { x: 10, y: 20 }); }
图 1-3 对此进行了描述:
指针trait
需要引用参数的方法(如 &Point)也可以输入 &Box
#![allow(unused)] fn main() { fn show(pt: &Point) { println!("({}, {})", pt.x, pt.y); } show(ref_pt); show(&box_pt); }
#![allow(unused)] fn main() { (1, 2) (10, 20) }
这是可能的,因为 Box<T>
实现了 Deref
trait,其中 Target = T
。某些类型对该trait的实现意味着可以使用trait的 deref()
方法创建对 Target 类型的引用。还有一个等效的 DerefMut 特征,它发出对 Target 类型的可变引用。
Deref/DerefMut 特征有些特殊,因为 Rust 编译器在处理实现它们的类型时具有特定的行为。当编译器遇到解引用表达式(例如 *x)时,它会查找并使用其中一个特征的实现,具体取决于解引用是否需要可变访问。这种 Deref 强制允许各种智能指针类型表现得像普通引用,并且是 Rust 中允许隐式类型转换的少数机制之一(如第 5 条所述)。
从技术角度来看,值得理解为什么 Deref
trait不能对目标类型通用(Deref<Target>
)。如果是,那么某些类型 ConfusedPtr 就有可能同时实现 Deref<TypeA>
和 Deref<TypeB>
,这将导致编译器无法为像 *x 这样的表达式推断出单个唯一类型。因此,目标类型被编码为名为 Target 的关联类型。
这个技术问题与其他两个标准指针特征 AsRef 和 AsMut 特征形成了对比。这些特征不会在编译器中引起特殊行为,但允许通过显式调用其特征函数(分别为 as_ref() 和 as_mut())转换为引用或可变引用。这些转换的目标类型被编码为类型参数(例如,AsRef
例如,标准 String 类型使用 Target = str 实现 Deref 特征,这意味着像 &my_string 这样的表达式可以强制转换为 &str 类型。但它还实现了以下内容:
AsRef<[u8]>
,允许转换为字节切片&[u8]
AsRef<OsStr>
,允许转换为 OS 字符串AsRef<Path>
,允许转换为文件系统路径AsRef<str>
,允许转换为字符串切片&str
(与Deref
一样)
胖指针类型
Rust 有两种内置胖指针类型:切片和特征对象。这些类型充当指针,但保存了指向对象的额外信息。
切片
第一个胖指针类型是切片:对某个连续值集合子集的引用。它由一个(非拥有)简单指针和一个长度字段构建而成,大小是简单指针的两倍(64 位平台上为 16 字节)。切片的类型写为 &[T]
— 对 [T]
的引用,[T]
是类型 T 的连续值集合的名义类型。
名义类型 [T]
无法实例化,但有两个常见容器可以体现它。第一个是数组:一个连续的值集合,其大小在编译时已知 — 具有五个值的数组将始终具有五个值。因此,切片可以引用数组的子集(如图 1-4 所示):
#![allow(unused)] fn main() { let array: [u64; 5] = [0, 1, 2, 3, 4]; let slice = &array[1..3]; }
另一个用于存储连续值的常见容器是 Vec<T>
。它像数组一样保存连续的值集合,但与数组不同的是,Vec 中的值数量可以增加(例如,使用 push(value))或减少(例如,使用 pop())。
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]; }
表达式 &vector[1..3] 的背后有很多事情要做,因此值得将其分解成各个组成部分:
1..3
部分是范围表达式;编译器将其转换为Range<usize>
类型的实例,该实例包含一个包含下限和一个排除上限。Range
类型实现了SliceIndex<T>
特征,该特征描述了对任意类型T
的切片的索引操作(因此输出类型为[T]
)。vector[ ]
部分是索引表达式;编译器将其转换为对向量的 Index 特征的索引方法的调用,以及取消引用(即*vector.index( )
)。2 因此,vector[1..3]
调用Vec<T>
的Index<I>
实现,这要求I
是SliceIndex<[u64]>
的实例。这是因为Range<usize>
为任何T
(包括u64
)实现了SliceIndex<[T]>
。&vector[1..3]
撤消了取消引用,导致最终表达式类型为&[u64]
。
Trait objects
第二个内置胖指针类型是特征对象:对实现特定特征的某个项的引用。它由指向该项的简单指针以及指向该类型的 vtable 的内部指针构建而成,大小为 16 字节(在 64 位平台上)。类型特征实现的 vtable 保存每个方法实现的函数指针,允许在运行时动态分派(第 12 项)。
因此,一个简单的特征:
#![allow(unused)] fn main() { trait Calculate { fn add(&self, l: u64, r: u64) -> u64; fn mul(&self, l: u64, r: u64) -> u64; } }
使用实现它的结构:
#![allow(unused)] fn main() { 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); }
可以转换为 &dyn Trait 类型的特征对象。dyn 关键字强调了涉及动态调度的事实:
#![allow(unused)] fn main() { // 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所示:
持有特征对象的代码可以通过 vtable 中的函数指针调用该特征的方法,将项目指针作为 &self 参数传入;有关更多信息和建议,请参阅第 12 条。
更多指针trait
上一节描述了两对Trait(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
trait的方法可以同样处理 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
是一个枚举,可以保存自有数据或对借用数据的引用。这个奇特的名字代表“写入时克隆”:Cow
输入可以保留为借用数据,直到需要修改它为止,但在需要更改数据时,它会变成自有副本。
智能指针类型
Rust 标准库包含各种类型,它们在某种程度上充当指针,由前面描述的标准库特征介导。这些智能指针类型各自带有一些特定的语义和保证,其优点是,正确的组合可以对指针的行为进行细粒度控制,但缺点是,生成的类型乍一看似乎令人难以理解(Rc<RefCell<Vec<T>>>
,有人知道吗?)。
第一个智能指针类型是 Rc<T>
,它是指向某个项的引用计数指针(大致类似于 C++ 的 std::shared_ptr<T>
)。它实现了所有与指针相关的特征,因此在许多方面充当 Box<T>
。
这对于可以通过不同方式访问同一项的数据结构很有用,但它删除了 Rust 关于所有权的核心规则之一 — 每个项只有一个所有者。放宽此规则意味着现在可能会泄漏数据:如果项目 A 具有指向项目 B 的 Rc
指针,而项目 B 具有指向 A 的 Rc
指针,则该对将永远不会被丢弃。换句话说:您需要 Rc
来支持循环数据结构,但缺点是您的数据结构中现在有循环。
在某些情况下,可以通过相关的 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); }
当强引用计数降至零时,底层项将被删除,但只有当弱引用计数也降至零时,簿记结构才会被删除。
Rc
本身使您能够以不同的方式访问某个项,但当您访问该项时,只有在没有其他方式访问该项时,您才能修改它(通过 get_mut
),即没有其他现存的 Rc
或 Weak
引用指向同一项。这很难安排,因此 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(); }
这些检查的运行时性质意味着 RefCell
用户必须在两个选项之间做出选择,但这两个选项都不令人愉快:
- 接受借用是一种可能失败的操作,并处理来自
try_borrow[_mut]
的结果值 - 使用据称万无一失的借用方法
borrow[_mut]
,并接受在运行时(第 18 条)出现panic!
的风险(如果借用规则未得到遵守)
无论哪种情况,此运行时检查都意味着 RefCell
本身未实现任何标准指针特征;相反,其访问操作返回实现这些特征的 Ref<T>
或 RefMut<T>
智能指针类型。
如果底层类型 T
实现了 Copy
特征(表示快速逐位复制产生有效项;参见第 10 条),则 Cell<T>
类型允许以更少的开销进行内部变异 — get(&self)
方法复制出当前值,set(&self, val)
方法复制新值。 Cell
类型由 Rc
和 RefCell
实现在内部使用,用于共享跟踪无需 &mut self
即可变异的计数器。
到目前为止描述的智能指针类型仅适用于单线程使用;它们的实现假设没有对其内部的并发访问。如果不是这种情况,则需要包含额外同步开销的智能指针。
Rc<T>
的线程安全等效项是 Arc<T>
,它使用原子计数器来确保引用计数保持准确。与 Rc
一样,Arc
实现了所有各种与指针相关的特征。
但是,Arc
本身不允许对底层项进行任何类型的可变访问。这由 Mutex
类型覆盖,它确保只有一个线程可以访问底层项(无论是可变的还是不可变的)。与 RefCell
一样,Mutex
本身不实现任何指针特征,但其 lock()
操作返回一个类型的值:MutexGuard
,它实现了 Deref[Mut]
。
如果读者可能多于写者,则最好使用 RwLock
类型,因为它允许多个读者并行访问底层项,前提是当前没有(单个)写者。
无论哪种情况,Rust 的借用和线程规则都强制在多线程代码中使用其中一个同步容器(但这只能防止共享状态并发的一些问题;参见第 17 条)。
相同的策略(查看编译器拒绝的内容以及它建议的内容)有时可以应用于其他智能指针类型。 但是,了解不同智能指针的行为意味着什么会更快,也更不令人沮丧。 借用 Rust 书第一版中的一个例子(双关语):
Rc<RefCell<Vec<T>>>
保存一个具有共享所有权(Rc
)的向量(Vec
),其中向量可以修改——但只能作为整个向量。Rc<Vec<RefCell<T>>>
也持有一个具有共享所有权的向量,但这里向量中的每个条目都可以独立于其他条目进行修改。
所涉及的类型准确地描述了这些行为。
条款9:考虑使用迭代器转换而不是显式循环
不起眼的循环经历了漫长的历史,变得越来越方便,越来越抽象。B语言(C 的前身)只有 while (condition) { ... }
,但随着 C 的出现,通过添加 for
循环,遍历数组索引的常见场景变得更加方便:
// C code
int i;
for (i = 0; i < len; i++) {
Item item = collection[i];
// body
}
C++ 的早期版本通过允许将循环变量声明嵌入到 for
语句中进一步提高了便利性和范围(这也被 C99 中的 C 所采用):
// C++98 code
for (int i = 0; i < len; i++) {
Item item = collection[i];
// ...
}
大多数现代语言进一步抽象了循环的概念:循环的核心功能通常是移动到某个容器的下一个项目。跟踪到达该项目所需的变量(index++
或 ++it
)大多是无关紧要的细节。这种认识产生了两个核心概念:
- 迭代器:一种旨在重复发出容器的下一个项目,直到耗尽的类型
- For-each 循环:一种紧凑的循环表达式,用于迭代容器中的所有项目,将循环变量绑定到该项目而不是到达该项目的细节
这些概念允许循环代码更短,重要的是它更清楚地说明意图:
// C++11 code
for (Item& item : collection) {
// ...
}
一旦这些概念可用,它们就显然非常强大,以至于它们很快就被改造到那些尚未拥有它们的语言中(例如,for-each循环被添加到Java 1.5和C++ 11中)。
Rust 包含迭代器和 for-each 样式的循环,但它还包括抽象的下一步:允许将整个循环表示为iterator transform(迭代器转换有时也称为迭代器适配器)。与第 3 条对 Option
和 Result
的讨论一样,本条将尝试展示如何使用这些迭代器转换代替显式循环,并指导何时使用迭代器转换是好主意。特别是,迭代器转换比显式循环更有效,因为编译器可以跳过它可能需要执行的边界检查。
在本项的末尾,一个类似 C 的显式循环用于对向量的前五个偶数项的平方求和:
#![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 迭代器特征之一的类型的实例
- 迭代器转换序列
- 最终消费者方法,用于将迭代结果组合成最终值
前两个部分有效地将功能从循环体移出并移入 for 表达式;最后一个部分完全消除了对 for 语句的需求。
迭代器trait
核心 Iterator trait 具有一个非常简单的接口:一个单一方法 next
,它会产生一些项,直到不产生(None)。发出的项的类型由trait的关联 Item 类型给出。
允许对其内容进行迭代的集合(在其他语言中称为可迭代对象)实现了 IntoIterator
trait;此trait的 into_iter
方法使用 Self
并代替它发出 Iterator
。编译器将自动将此特征用于以下形式的表达式:
#![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
实现,它只返回自身;毕竟,将 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:?}"); }
#![allow(unused)] fn main() { 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
的 Thing
类型(第 10 条),因为这会隐藏所有权问题(第 15 条)——编译器会默默地在任何地方进行复制:
#![allow(unused)] fn main() { // Deliberately not `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:?}"); }
然后 Rust 编译器会为类型 &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); } }
如果提供对可变引用的迭代是有意义的,那么 for item in &mut collection
也适用类似的模式:编译器查找并使用 &mut Collection
的 IntoIterator
实现,其中每个 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
trait 只有一个必需方法 (next
),但也提供了对迭代器执行转换的大量其他方法的默认实现 (第 13 项)。
其中一些转换会影响整个迭代过程:
take(n)
:限制迭代器最多发出 n 个项目。skip(n)
:跳过迭代器的前 n 个元素。step_by(n)
:转换迭代器,使其仅发出每第 n 个项目。chain(other)
:将两个迭代器粘合在一起,以构建一个组合迭代器,该迭代器先穿过一个迭代器,然后穿过另一个迭代器。cycle()
:将终止的迭代器转换为永远重复的迭代器,每当到达末尾时,都会从头开始。(迭代器必须支持Clone
才能允许这样做。)rev()
:反转迭代器的方向。(迭代器必须实现DoubleEndedIterator
特征,它具有额外的next_back
必需方法。)
其他转换会影响迭代器所针对的项目的性质:
map(|item| {...})
:重复应用闭包依次转换每个项目。这是最通用的版本;此列表中的以下几个条目是可以等效地实现为映射的便捷变体。cloned()
:生成原始迭代器中所有项目的克隆;这对于 &Item 引用上的迭代器特别有用。(这显然需要底层 Item 类型实现 Clone。)copied()
:生成原始迭代器中所有项目的副本;这对于 &Item 引用上的迭代器特别有用。(这显然需要底层 Item 类型实现 Copy,但如果是这种情况,它可能比 cloned() 更快。)enumerate()
:将项目上的迭代器转换为 (usize, Item) 对上的迭代器,为迭代器中的项目提供索引。zip(it)
:将一个迭代器与第二个迭代器连接起来,生成一个组合迭代器,该迭代器发出成对的项目,每个项目来自原始迭代器中的一个,直到两个迭代器中较短的一个完成。
还有其他转换对迭代器发出的项目执行过滤:
filter(|item| {...})
:将布尔返回闭包应用于每个项目引用,以确定是否应传递它。take_while()
:根据谓词发出迭代器的初始子范围。skip_while
的镜像。skip_while()
:根据谓词发出迭代器的最终子范围。take_while
的镜像。
flatten()
方法处理迭代器,其项本身也是迭代器,从而展平结果。就其本身而言,这似乎没什么帮助,但结合以下观察结果,就会发现 Option 和 Result 都充当迭代器:它们要么产生零个项(对于 None、Err(e)),要么产生一个项(对于 Some(v)、Ok(v))。这意味着展平 Option/Result 值流是一种简单的方法,可以仅提取有效值,而忽略其余值。
总的来说,这些方法允许迭代器进行转换,以便它们精确生成大多数情况下所需的元素序列。
迭代器消费者
前两节描述了如何获取迭代器以及如何将其转换为精确迭代所需的正确形状。这种精确定位的迭代可以作为显式 for-each 循环进行:
#![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 实现(参见 Item 10)。max()
:查找集合的最大值,相对于 Item 的 Ord 实现(参见 Item 10)。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 个元素(如果可用)。
有一些方法可以针对集合中的每个项目进行测试:
any(p)
:表示谓词对于集合中的任何项是否为真。all(p)
:表示谓词对于集合中的所有项是否为真。
无论哪种情况,如果找到相关反例,迭代都会提前终止。
有些方法允许每个项目使用的闭包出现失败的可能性。在每种情况下,如果闭包返回某个项目的失败,迭代就会终止,整个操作会返回第一个失败:
try_for_each(f)
:行为类似于 for_each,但闭包可能会失败try_fold(f)
:行为类似于 fold,但闭包可能会失败try_find(f)
:行为类似于 find,但闭包可能会失败
最后,还有一些方法可以将所有迭代项累积到新集合中。其中最重要的是 collect()
,它可用于构建实现 FromIterator
特征的任何集合类型的新实例。
FromIterator 特征已为所有标准库集合类型(Vec、HashMap、BTreeSet 等)实现,但这种普遍性也意味着您经常必须使用显式类型,因为否则编译器无法确定您是在尝试组装(例如)Vec
#![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(); }
此示例还说明了如何使用范围表达式来生成要迭代的初始数据。
其他(更晦涩难懂的)集合生成方法包括以下内容:
unzip()
:将一个对的迭代器分成两个集合partition(p)
:根据应用于每个项目的谓词将迭代器分成两个集合
本条目涉及了多种迭代器方法,但这只是可用方法的子集;有关更多信息,请查阅迭代器文档或阅读《Rust 编程》第 2 版(O'Reilly)第 15 章,其中详细介绍了各种可能性。
这个丰富的迭代器转换集合可供使用。它生成的代码更符合语言习惯、更紧凑,并且意图更清晰。
将循环表达为迭代器转换还可以生成更高效的代码。出于安全考虑,Rust 对访问连续容器(如向量和切片)执行边界检查;尝试访问集合边界以外的值会触发恐慌,而不是访问无效数据。访问容器值(例如 values[i])的旧式循环可能会受到这些运行时检查的影响,而生成一个又一个值的迭代器已知在范围内。
但是,与等效迭代器转换相比,旧式循环可能不需要额外的边界检查。Rust 编译器和优化器非常擅长分析切片访问周围的代码,以确定是否可以安全地跳过边界检查;Sergey“Shnatsel”Davidoff 的 2023 年文章探讨了其中的微妙之处。
从结果值构建集合
上一节描述了如何使用 collect()
从迭代器构建集合,但 collect()
在处理结果值时还有一个特别有用的功能。
考虑尝试将 i64 值的向量转换为字节 (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]; }
并导致运行时失败:
#![allow(unused)] fn main() { 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,而不是包含 Results 的 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 的显式循环开始,对向量的前五个偶数项的平方求和:
#![allow(unused)] fn main() { 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; } } }
第一步是在 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; } } }
接下来,一旦发现五个偶数项,就会提前退出循环,映射到 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; } }
循环的每次迭代都只使用值 * 值组合中的项的平方,这使其成为 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 的能力通常允许仍然使用 ? 运算符处理错误条件。 如果性能至关重要,则应优化涉及闭包的迭代器转换,使其与等效的显式代码一样快。但是如果核心循环的性能如此重要,请测量不同的变体并进行适当的调整: 请务必确保您的测量结果反映了实际性能——编译器的优化器可能会对测试数据产生过于乐观的结果(如第 30 条所述)。 Godbolt 编译器资源管理器是一款出色的工具,可用于探索编译器输出的内容。 最重要的是,如果转换是强制的或不方便的,请不要将循环转换为迭代转换。这当然是一个品味问题——但请注意,随着您越来越熟悉函数式风格,您的品味可能会发生变化。
Traits
Rust类型系统的第二个核心支柱是trait的应用,它允许对不同类型中公共的行为进行编码。trait大致相当于其他语言中的接口类型,但它们也与 Rust 的泛型(第12条)相关联,以允许接口重用而无需运行时开销。
本章中的条款描述了 Rust 编译器和 Rust 工具链提供的标准trait,并提供了有关如何设计和使用trait编码行为的建议。
条款10:熟悉标准特征
Rust 通过一组描述其行为的细粒度标准trait (参见第 2 条),在类型系统本身中编码其类型系统的关键行为。
这些trait中的许多对于来自 C++ 的程序员来说似乎很熟悉,对应于诸如复制构造函数、析构函数、相等和赋值运算符等概念。
与在 C++ 中一样,为您自己的类型实现许多这些trait通常是一个好主意;如果您的类型执行了某些操作,但没有实现这些对应必要的trait,Rust编译器将为您提供有用的错误消息。
实现这么一大堆trait似乎令人生畏,但大多数常见trait都可以使用派生宏自动应用于用户定义的类型。这些派生宏生成该trait的“样板”实现代码(例如,对结构体上的 Eq
进行逐字段比较);这通常要求所有组成部分也实现该trait。自动生成的实现通常是您想要的,但偶尔会出现一些例外情况,后面将在每个trait部分进行讨论。
使用派生宏确实会导致如下类型定义:
#![allow(unused)] fn main() { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] enum MyBooleanOption { Off, On, } }
针对八种不同的trait自动生成的实现。
这种细粒度的行为规范一开始可能会令人不安,但熟悉这些标准trait中最常见的trait很重要,这样才能立即理解类型定义的可用行为。
常见标准特征
本节讨论最常见的标准trait。以下对每个trait用一句话进行粗略摘要:
Clone
:此类型的项可以在被要求时通过运行用户定义的代码来复制自身。Copy
:如果编译器逐位复制此项的内存表示(不运行任何用户定义的代码),则结果为有效的trait。Default
:可以使用合理的默认值创建此类型的新实例。PartialEq
:此类型的项存在部分等价关系 - 任何两个项都可以明确比较,但x==x
可能并不总是正确的。Eq
:此类型的项存在等价关系 - 任何两个项都可以明确比较,并且x==x
始终是正确的。PartialOrd
:此类型的某些项可以进行比较和排序。Ord
:此类型的所有项都可以进行比较和排序。Hash
:当被要求时,此类型的项目可以生成其内容的稳定哈希值。Debug
:此类型的项目可以显示给程序员。Display
:此类型的项目可以显示给用户。
这些trait都可以从用户定义的类型中派生出来,但 Display
除外(此处包括它,因为它与调试重叠)。但是,有时手动实现(或不实现)是更好的选择。
以下各节将更详细地讨论这些常见trait中的每一个。
Clone trait
表示可以通过调用 clone()
方法来创建项目的新副本。这大致相当于 C++ 的复制构造函数,但更明确:编译器永远不会默默地自行调用此方法(请继续阅读下一节)。
如果类型的所有字段都实现了 Clone
,则可以为该类型派生 Clone
。派生实现通过依次克隆其每个成员来克隆聚合类型;同样,这大致相当于 C++ 中的默认复制构造函数。这使得trait可选择加入(通过添加 #[derive(Clone)]
),与 C++ 中的选择退出行为(MyType(const MyType&) = delete;
)相反。
这是一个非常常见且有用的操作,因此更有趣的是研究您不应该或不能实现 Clone
的情况,或者默认派生实现不合适的情况。
- 如果项目体现了对某些资源的唯一访问(例如 RAII 类型;项目 11),或者有其他原因需要限制复制(例如,如果项目包含加密密钥材料),则不应实现
Clone
。 - 如果类型的某些组件反过来不可克隆,则无法实现
Clone
。示例包括以下内容:- 可变引用的字段(
&mut T
),因为借用检查器(项目 15)一次只允许一个可变引用。 - 属于前一类的标准库类型,例如
MutexGuard
(体现唯一访问)或Mutex
(限制复制以确保线程安全)。
- 可变引用的字段(
- 如果项目中的任何内容无法通过(递归)逐字段复制捕获,或者项目生命周期有额外的簿记,则应手动实现
Clone
。例如,考虑一种在运行时跟踪现有项目数量的类型,用于度量目的;手动实现Clone
可以确保计数器保持准确。
Copy
Copy
trait有一个简单的声明:
#![allow(unused)] fn main() { pub trait Copy: Clone { } }
此trait中没有方法,这意味着它是一个标记特征(如第 2 项所述):它用于指示类型系统中未直接表达的某些类型的约束。
在 Copy
的情况下,此标记的含义是保存项目的内存的逐位复制会给出正确的新项目。实际上,此特征是一个标记,表示类型是“普通旧数据”(POD)类型。
这也意味着 Clone
特征界限可能有点令人困惑:尽管 Copy
类型必须实现 Clone
,但当复制类型的实例时,不会调用 clone()
方法 - 编译器会在不涉及用户定义代码的情况下构建新项目。
与用户定义的标记特征(第 2 项)相比,Copy
对编译器具有特殊意义(std::marker
中的其他几个标记特征也是如此),除了可用于特征界限之外 - 它将编译器从移动语义转变为复制语义。
对于赋值运算符,使用移动语义,右手给予什么,左手就拿走什么:
#![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:?}"); }
#![allow(unused)] fn main() { 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
特征绑定,所以可以 .clone()
任何可复制的项目。然而,这不是一个好主意:按位复制总是比调用特征方法更快。Clippy(第 29 项)会就此发出警告:
#![allow(unused)] fn main() { let k3 = k.clone(); }
#![allow(unused)] fn main() { 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
需要手动实现而不是自动派生实现,则很可能是这种情况。 - 如果您的类型很大,实现
Copy
可能不是一个好主意。Copy
的基本承诺是按位复制是有效的;但是,这通常与复制速度快的假设相伴而生。如果不是这种情况,跳过Copy
可防止意外的慢速复制。 - 如果您类型的某些组件反过来不可复制,则您无法实现
Copy
。 - 如果您类型的所有组件都是可复制的,那么通常值得派生
Copy
。编译器有一个默认关闭的 lintmissing_copy_implementations
,它指出了这种情况的机会。
Default
Default
特征通过 default()
方法定义默认构造函数。此特征可从用户定义类型派生,前提是所有涉及的子类型都有自己的 Default
实现;如果没有,则必须手动实现该特征。继续与 C++ 进行比较,请注意必须显式触发默认构造函数 — 编译器不会自动创建默认构造函数。
Default
特征也可从枚举类型派生,只要有一个 #[default]
属性来提示编译器哪个变体是默认的:
#![allow(unused)] fn main() { #[derive(Default)] enum IceCreamFlavor { Chocolate, Strawberry, #[default] Vanilla, } }
Default
特征最有用的方面是它与结构更新语法的结合。对于任何未明确初始化的字段,此语法允许通过从同一结构的现有实例复制或移动其内容来初始化结构字段。要复制的模板在初始化结束时给出,在 ..
之后,Default
特征提供了一个理想的模板供使用:
#![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
特征允许您为用户定义类型定义相等性。这些特征具有特殊意义,因为如果它们存在,编译器将自动使用它们进行相等性 (==
) 检查,类似于 C++ 中的运算符 ==
。默认的派生实现通过递归逐字段比较来执行此操作。
Eq
版本只是 PartialEq
的标记特征扩展,它添加了反身性假设:任何声称支持 Eq
的类型 T
都应确保 x == x
对于任何 x:T
都为真。
这很奇怪,立即引发了一个问题,什么时候 x == x
不会?这种分裂背后的主要原理与浮点数有关,特别是与特殊的“非数字”值 NaN
(Rust 中的 f32::NAN
/ f64::NAN
)有关。浮点规范要求没有任何东西与 NaN
进行比较相等,包括 NaN
本身;PartialEq
特征是这种连锁反应。
对于没有任何浮点相关特性的用户定义类型,您应该在实现 PartialEq
时实现 Eq
。如果您想将类型用作 HashMap
中的键(以及 Hash
特征),则还需要完整的 Eq
特征。
如果您的类型包含任何不影响项目身份的字段,例如内部缓存和其他性能优化,则应该手动实现 PartialEq
。(如果定义了 Eq
,任何手动实现也将用于它,因为 Eq
只是一个没有自己的方法的标记特征。)
PartialOrd 和 Ord
排序特征 PartialOrd
和 Ord
允许对同一类型的两个项进行比较,返回 Less
、Greater
或 Equal
。这些特征需要实现等效的相等特征(PartialOrd
需要 PartialEq
;Ord
需要 Eq
),并且两者必须一致(手动实现时尤其要注意这一点)。
与相等特征一样,比较特征具有特殊意义,因为编译器会自动将它们用于比较操作(<
、>
、<=
、>=
)。
由 derive
生成的默认实现按定义顺序按字典顺序比较字段(或枚举变量),因此如果这不正确,您需要手动实现这些特征(或重新排序字段)。
与 PartialEq
不同,PartialOrd
特征确实对应于各种实际情况。例如,它可用于表达集合之间的子集关系: {1, 2}
是 {1, 2, 4}
的子集,但 {1, 3}
不是 {2, 4}
的子集,反之亦然。
但是,即使偏序确实准确地模拟了类型的行为,也要注意不要只实现 PartialOrd
而不实现 Ord
(这种情况很少见,与第 2 项中在类型系统中编码行为的建议相矛盾)——它可能会导致令人惊讶的结果:
#![allow(unused)] fn main() { // Inherit the `PartialOrd` behavior from `f32`. #[derive(PartialOrd, PartialEq)] struct Oddity(f32); // Input data with NaN values is likely to give unexpected results. 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. 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
特征用于生成单个值,该值对于不同的项目而言很可能不同。此哈希值用作基于哈希桶的数据结构(如 HashMap
和 HashSet
)的基础;因此,这些数据结构中的键的类型必须实现 Hash
(和 Eq
)。
反过来说,“相同”的项目(根据 Eq
)始终生成相同的哈希值至关重要:如果 x == y
(通过 Eq
),则 hash(x) == hash(y)
必须始终为真。如果您有手动 Eq
实现,请检查是否还需要手动实现 Hash
以满足此要求。
Debug 和 Display
Debug
和 Display
特征允许类型指定应如何将其包含在输出中,无论是用于正常目的({}
格式参数)还是调试目的({:?}
格式参数),大致类似于 C++ 中 iostream
的运算符<<
重载。
不过,这两个特征的意图之间的差异超出了需要哪种格式说明符:
Debug
可以自动派生,Display
只能手动实现。Debug
输出的布局可能会在不同的 Rust 版本之间发生变化。如果输出将被其他代码解析,请使用Display
。Debug
面向程序员;Display
面向用户。一个有助于此的思想实验是考虑如果程序本地化为作者不会说的语言会发生什么——如果内容应该翻译,Display
是合适的,否则Debug
。 作为一般规则,除非您的类型包含敏感信息(个人详细信息、加密材料等),否则请为您的类型添加自动生成的 Debug 实现。为了使此建议更容易遵循,Rust 编译器包含一个missing_debug_implementations
lint,它指出没有 Debug 的类型。默认情况下,此 lint 是禁用的,但可以通过以下任一方式为您的代码启用:
#![allow(unused)] #![warn(missing_debug_implementations)] fn main() { }
#![allow(unused)] #![deny(missing_debug_implementations)] fn main() { }
如果自动生成的 Debug
实现会发出大量详细信息,那么包含一个手动实现的 Debug
来总结类型的内容可能更为合适。
如果您的类型旨在以文本输出的形式向最终用户显示,请实现 Display
。
其他地方涵盖的标准特征
除了上一节中描述的常见特征外,标准库还包括其他不太常见的标准特征。在这些额外的标准特征中,以下是最重要的,但它们已在其他项目中介绍,因此这里不再深入介绍:
Fn
、FnOnce
和FnMut
:实现这些特征的项目表示可以调用的闭包。请参阅项目 2。Error
:实现此特征的项目表示可以显示给用户或程序员的错误信息,并且可能包含嵌套的子错误信息。请参阅项目 4。Drop
:实现此特征的项目在被销毁时执行处理,这对于 RAII 模式至关重要。请参阅项目 11。From
和TryFrom
:实现这些特征的项目可以从其他类型的项目中自动创建,但在后一种情况下可能会失败。参见第 5 条。Deref
和DerefMut
:实现这些特征的项目是指针类对象,可以取消引用以访问内部项目。参见第 8 条。Iterator
和它的相关派生:实现这些特征的项目表示可以迭代的集合。参见第 9 条。Send
:实现此特征的项目可以安全地在多个线程之间传输。参见第 17 条。Sync
:实现此特征的项目可以安全地被多个线程引用。参见第 17 条。 这些特征都不是可派生的。
运算符重载
最后一类标准特征与运算符重载有关,Rust 允许各种内置的一元和二元运算符为用户定义类型重载,方法是实现 std::ops
模块中的各种标准特征。这些特征不可派生,通常仅用于表示“代数”对象的类型,这些类型对这些运算符有自然解释。
但是,C++ 的经验表明,最好避免为不相关的类型重载运算符,因为这通常会导致代码难以维护且具有意外的性能属性(例如,x + y
默默调用昂贵的 O(N) 方法)。
为了遵守最小惊讶原则,如果您实现任何运算符重载,则应实现一组连贯的运算符重载。例如,如果 x + y
有一个重载(Add
),并且 -y
(Neg
)也有,那么您还应该实现 x - y
(Sub
)并确保它给出与 x +(-y)
相同的答案。
传递给运算符重载特征的项目被移动,这意味着默认情况下将使用非复制类型。添加 &'a MyType
的实现可以帮助解决这个问题,但需要更多样板代码来涵盖所有可能性(例如,将引用/非引用参数组合到二元运算符有 4 = 2 × 2
种可能性)。
摘要
本条目涵盖了很多内容,因此需要一些表格来总结已涉及的标准特征。首先,表 2-1 涵盖了本条目深入涵盖的特征,除 Display
之外,所有特征都可以自动得出。
Trait | Compiler Use | Bound | Methods |
---|---|---|---|
Clone | clone | ||
Copy | let y = x; | Clone | Marker trait |
Default | default | ||
PartialEq | x == y | eq | |
Eq | x == y | PartialEq | Marker 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 |
表 2-2 总结了运算符重载,其中没有一个是可以派生的。
Trait | Compiler Use | Bound | 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 |
为了完整性,其他项目中涵盖的标准特征包含在表 2-3 中;这些特征都不是可派生的(但 Send 和 Sync 可以由编译器自动实现)。
Trait | Item | Compiler Use | Bound | Methods |
---|---|---|---|---|
Fn | Item 2 | x(a) | FnMut | call |
FnMut | Item 2 | x(a) | FnOnce | call_mut |
FnOnce | Item 2 | x(a) | call_once | |
Error | Item 4 | Display + Debug | [source] | |
From | Item 5 | from | ||
TryFrom | Item 5 | try_from | ||
Into | Item 5 | into | ||
TryInto | Item 5 | try_into | ||
AsRef | Item 8 | as_ref | ||
AsMut | Item 8 | as_mut | ||
Borrow | Item 8 | borrow | ||
BorrowMut | Item 8 | Borrow | borrow_mut | |
ToOwned | Item 8 | to_owned | ||
Deref | Item 8 | *x, &x | deref | |
DerefMut | Item 8 | *x, &mut x | Deref | deref_mut |
Index | Item 8 | x[idx] | index | |
IndexMut | Item 8 | x[idx] = ... | Index | index_mut |
Pointer | Item 8 | format("{:p}", x) | fmt | |
Iterator | Item 9 | next | ||
IntoIterator | Item 9 | for y in x | into_iter | |
FromIterator | Item 9 | from_iter | ||
ExactSizeIterator | Item 9 | Iterator | (size_hint) | |
DoubleEndedIterator | Item 9 | Iterator | next_back | |
Drop | Item 11 | } (end of scope) | drop | |
Sized | Item 12 | Marker trait | ||
Send | Item 17 | cross-thread transfer | Marker trait | |
Sync | Item 17 | cross-thread use | Marker trait |
条款11:实现RAII模式的Drop trait
“永远不要派人去做机器的工作。”——Agent Smith
RAII 代表“资源获取即初始化”,这是一种编程模式,其中值的生命周期与某些附加资源的生命周期完全相关。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 中的 C++ 标准库中:std::unique_ptr<T>
类型确保单个位置具有内存的“所有权”,但允许“借用”指向内存的指针以供临时使用(ptr.get()
)。
在 Rust 中,内存指针的这种行为内置于语言中(第 15 条),但 RAII 的一般原则对于其他类型的资源仍然有用。为任何包含必须释放的资源的类型实现 Drop
,例如:
- 访问操作系统资源。对于 Unix 派生的系统,这通常意味着包含文件描述符的东西;如果无法正确释放这些资源,则会占用系统资源(并且最终还会导致程序达到每个进程的文件描述符限制)。
- 访问同步资源。标准库已经包含内存同步原语,但其他资源(例如文件锁、数据库锁等)可能需要类似的封装。
- 对于处理低级内存管理的不安全类型(例如,用于外部函数接口 [FFI] 功能),可以访问原始内存。
Rust 标准库中最明显的 RAII 实例是 Mutex::lock()
操作返回的 MutexGuard
项,它往往广泛用于使用第 17 项中讨论的共享状态并行性的程序。这大致类似于前面显示的最后一个 C++ 示例,但在 Rust 中,MutexGuard
项除了是持有锁的 RAII 项之外,还充当互斥保护数据的代理:
#![allow(unused)] fn main() { 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 项的范围。这会导致略微奇怪的缩进,但为了增加安全性和生命周期精度,这是值得的:
#![allow(unused)] fn main() { 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允许您将用户定义的行为添加到项目的销毁中。 此特性只有一个方法 drop
,编译器会在释放保存该项目的内存之前运行该方法:
#![allow(unused)] fn main() { #[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 方法是专门为编译器保留的,不能手动调用:
#![allow(unused)] fn main() { 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
方法返回结果,这样用户就可以检测到此失败。
无论技术细节如何,drop
方法仍然是实现 RAII 模式的关键位置;它的实现是释放与项目相关的资源的理想位置。
理解泛型和特征对象之间的权衡
第 2 项描述了使用特征将行为封装在类型系统中,作为相关方法的集合,并观察到有两种使用特征的方法:作为泛型的特征边界或在特征对象中。本项探讨了这两种可能性之间的权衡。
作为一个运行示例,考虑一个涵盖显示图形对象功能的特征:
#![allow(unused)] fn main() { #[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 中称为单态化,在 C++ 中称为模板实例化。与 C++ 不同,Rust 以泛型的特征边界形式在类型系统中明确编码对类型 T 的期望。
例如,使用特征的 bounds() 方法的泛型函数具有显式 Draw 特征边界:
#![allow(unused)] fn main() { /// 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() } }
也可以通过将特征绑定放在泛型参数之后来更紧凑地编写此代码:
#![allow(unused)] fn main() { pub fn on_screen<T: Draw>(draw: &T) -> bool { overlap(SCREEN_BOUNDS, draw.bounds()).is_some() } }
或者使用 impl Trait 作为参数的类型:
#![allow(unused)] fn main() { pub fn on_screen(draw: &impl Draw) -> bool { overlap(SCREEN_BOUNDS, draw.bounds()).is_some() } }
如果类型实现了该特征:
#![allow(unused)] fn main() { #[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, }, } } } }
然后可以将该类型的实例传递给泛型函数,将其单态化以生成特定于某一特定类型的代码:
#![allow(unused)] fn main() { let square = Square { top_left: Point { x: 1, y: 2 }, size: 2, }; // Calls `on_screen::<Square>(&Square) -> bool` let visible = on_screen(&square); }
如果将相同的泛型函数与实现相关特征绑定的不同类型一起使用:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] struct Circle { center: Point, radius: i64, } impl Draw for Circle { fn bounds(&self) -> Bounds { // ... } } }
然后使用不同的单态代码:
#![allow(unused)] fn main() { let circle = Circle { center: Point { x: 3, y: 4 }, radius: 1, }; // Calls `on_screen::<Circle>(&Circle) -> bool` let visible = on_screen(&circle); }
换句话说,程序员编写一个通用函数,但编译器会针对调用该函数的每种不同类型输出该函数的不同单态版本。
Trait Objects
相比之下,特征对象是胖指针(项目 8),它将指向底层具体项的指针与指向 vtable 的指针结合在一起,而 vtable 又保存了所有特征实现方法的函数指针,如图 2-1 所示:
#![allow(unused)] fn main() { let square = Square { top_left: Point { x: 1, y: 2 }, size: 2, }; let draw: &dyn Draw = □ }
这意味着接受特征对象的函数不需要是通用的,也不需要单态化:程序员使用特征对象编写函数,编译器只输出该函数的单个版本,该版本可以接受来自多种输入类型的特征对象:
#![allow(unused)] fn main() { /// Indicate whether an object is on-screen. pub fn on_screen(draw: &dyn Draw) -> bool { overlap(SCREEN_BOUNDS, draw.bounds()).is_some() } }
#![allow(unused)] fn main() { // 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 到实现位置)。
- 泛型的编译时间可能会更长,因为编译器正在构建更多代码,并且链接器需要做更多工作来折叠重复项。 在大多数情况下,这些并不是显著的差异——只有当您衡量了影响并发现它确实有效果(速度瓶颈或有问题的占用率增加)时,您才应该将与优化相关的问题作为主要决策驱动因素。
更重要的差异是,通用特征边界可用于有条件地提供不同的功能,具体取决于类型参数是否实现多个特征:
#![allow(unused)] fn main() { // 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()); } }
#![allow(unused)] fn main() { 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); }
特征对象仅为单个特征编码实现 vtable,因此执行等效操作会更加尴尬。例如,可以为 show() 案例定义组合 DebugDraw 特征,并结合一个总体实现以使生活更轻松:
#![allow(unused)] fn main() { trait DebugDraw: Debug + Draw {} /// Blanket implementation applies whenever the individual traits /// are implemented. impl<T: Debug + Draw> DebugDraw for T {} }
然而,如果存在多种不同特征的组合,那么这种方法的组合显然很快就会变得难以处理。
更多特征界限
除了使用特征界限来限制泛型函数可接受的类型参数外,您还可以将它们应用于特征定义本身:
#![allow(unused)] fn main() { /// 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)利用了特征边界,依赖于 Draw
中 bounds()
方法的可用性。
来自面向对象语言的程序员经常将特征边界与继承相混淆,误以为这样的特征边界意味着 Shape
是 Draw
。事实并非如此:这两种类型之间的关系最好表达为 Shape
也实现了 Draw
。
在幕后,具有特征边界的特征的特征对象:
#![allow(unused)] fn main() { let square = Square { top_left: Point { x: 1, y: 2 }, size: 2, }; let draw: &dyn Draw = □ let shape: &dyn Shape = □ }
具有单个组合 vtable,其中包含顶级特征的方法以及所有特征边界的方法。如图 2-2 所示:Shape
的 vtable 包含来自 Draw
特征的边界方法,以及来自 Shape
特征本身的两种方法。
在撰写本文时(以及从 Rust 1.70 开始),这意味着无法从 Shape
“上溯” 到 Draw
,因为(纯)Draw
vtable 无法在运行时恢复;无法在相关特征对象之间进行转换,这反过来意味着没有 Liskov 李氏替换。但是,这在 Rust 的后续版本中可能会发生变化——有关更多信息,请参阅第 19 条。
用不同的词重复同一点,接受 Shape
特征对象的方法具有以下特征:
-
它可以使用
Draw
中的方法(因为Shape
也实现了Draw
,并且相关函数指针存在于Shape
vtable 中)。 -
它不能(暂时)将特征对象传递给另一个需要
Draw
特征对象的方法(因为Shape
不是Draw
,并且Draw
vtable 不可用)。 相反,接受实现Shape
的项目的泛型方法具有以下特征: -
它可以使用
Draw
中的方法。 -
它可以将该项目传递给具有
Draw
特征绑定的另一个泛型方法,因为特征绑定在编译时被单态化以使用具体类型的Draw
方法。
特征对象安全
特征对象的另一个限制是对象安全要求:只有符合以下两个规则的特征才能用作特征对象:
- 特征的方法不得是通用的。
- 特征的方法不得涉及包含
Self
的类型,接收者(调用该方法的对象)除外。 第一个限制很容易理解:通用方法f
实际上是一组无限的方法,可能包含f::<i16>
、f::<i32>
、f::<i64>
、f::<u8>
等。另一方面,特征对象的 vtable 在很大程度上是函数指针的有限集合,因此不可能将无限的单态化实现集合放入其中。
第二个限制稍微微妙一些,但往往是实践中更常见的限制——施加 Copy
或 Clone
特征界限(第 10 条)的特征立即属于此规则,因为它们返回 Self
。要了解为什么不允许这样做,请考虑拥有特征对象的代码;如果该代码调用(例如) let y = x.clone()
,会发生什么?调用代码需要在堆栈上为 y
保留足够的空间,但它不知道 y
的大小,因为 Self
是任意类型。因此,返回提及 Self
的类型会导致不安全的特征。
第二个限制有一个例外。如果 Self
带有对编译时已知大小类型的明确限制,则返回某些 Self
相关类型的方法不会影响对象安全,由 Sized
标记特征指示为特征界限:
#![allow(unused)] fn main() { /// A `Stamp` can be copied and drawn multiple times. trait Stamp: Draw { fn make_copy(&self) -> Self where Self: Sized; } }
#![allow(unused)] fn main() { 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
),所以该方法与对象安全无关:
#![allow(unused)] fn main() { // However, the method can't be invoked via a trait object. let copy = stamp.make_copy(); }
#![allow(unused)] fn main() { 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),但它也很有用,因为它允许异构对象的集合——因为代码只依赖于特征的方法,它可以调用和组合具有不同具体类型的项目的方法。
渲染形状列表的传统 OO 示例就是一个例子:同一个 render()
方法可以用于同一个循环中的正方形、圆形、椭圆形和星形:
#![allow(unused)] fn main() { let shapes: Vec<&dyn Shape> = vec![&square, &circle]; for shape in shapes { shape.render() } }
特征对象还有一个更不为人知的潜在优势,即在编译时无法获知可用类型。如果在运行时动态加载新代码(例如通过 dlopen(3)
),那么新代码中实现特征的项目只能通过特征对象来调用,因为没有源代码可以进行单态化。
使用默认实现来最小化所需的特征方法
特征的设计者需要考虑两种不同的受众:将要实现特征的程序员和将要使用特征的程序员。这两种受众导致特征设计中存在一定程度的紧张关系:
为了使实现者的工作更轻松,特征最好具有实现其目的的绝对最少方法数。
为了使用户的工作更方便,提供一系列涵盖特征可能使用的所有常见方式的变体方法会很有帮助。 可以通过包括使用户的工作更轻松的更广泛的方法来平衡这种紧张关系,但为可以从接口上的其他更原始的操作构建的任何方法提供默认实现。
一个简单的例子是 ExactSizeIterator
的 is_empty()
方法,它是一个知道它正在迭代多少事物的迭代器。此方法有一个依赖于 len()
特征方法的默认实现:
#![allow(unused)] fn main() { fn is_empty(&self) -> bool { self.len() == 0 } }
默认实现的存在就是:默认。如果特征的实现有不同的方式来确定迭代器是否为空,它可以用自己的方法替换默认的 is_empty()
。
这种方法导致特征定义具有少量必需方法,以及大量默认实现的方法。特征的实现者只需实现前者,即可免费获得所有后者。
这也是 Rust 标准库广泛遵循的一种方法;也许最好的例子是 Iterator
特征,它只有一个必需方法(next()
),但包含大量预先提供的方法(条目 9),在撰写本文时超过 50 种。
特征方法可以施加特征界限,表明只有当涉及的类型实现特定特征时,方法才可用。 Iterator
特征还表明这与默认方法实现结合使用很有用。例如, cloned()
迭代器方法具有特征界限和默认实现:
#![allow(unused)] fn main() { fn cloned<'a, T>(self) -> Cloned<Self> where T: 'a + Clone, Self: Sized + Iterator<Item = &'a T>, { Cloned::new(self) } }
换句话说,只有当底层 Item
类型实现 Clone
时, cloned()
方法才可用;当实现 Clone
时,实现将自动可用。
关于具有默认实现的特征方法的最终观察是,即使在特征的初始版本发布后,通常也可以安全地将新方法添加到特征中。只要新方法名称不与该类型实现的其他特征的方法名称冲突,这样的添加就可以为特征的用户和实现者保留向后兼容性(参见 Item 21)。
因此,请遵循标准库的示例,通过添加具有默认实现的方法(以及适当的特征界限),为实现者提供最小的 API 接口,但为用户提供方便而全面的 API。
概念
本书的前两章介绍了 Rust 的类型和traits,这有助于提供处理编写 Rust 代码所涉及的一些概念所需的词汇——本章的主题。
借用检查器和生命周期检查是 Rust 核心的独特之处;它们也是 Rust 新手常见的绊脚石,因此也是本章前两个条款的重点。
本章中的其他项目涵盖了更容易掌握的概念,但与用其他语言编写代码略有不同。其中包括:
- 关于 Rust 不安全模式的建议以及如何避免它(条款16)
- 关于用 Rust 编写多线程代码的好消息和坏消息(条款17)
- 关于避免运行时中止的建议(条款18)
- 关于 Rust 反射方法的信息(项目19)
- 关于平衡优化和可维护性的建议(项目20)
尝试将你的代码与这些概念的后果保持一致是一个好主意。可以在 Rust 中重新创建(部分) C/C++ 的行为,但如果这样做,为什么还要使用 Rust 呢?
理解生命周期
本条款描述了 Rust 的生命周期,这是对之前编译语言(如 C 和 C++)中存在的概念的更精确的表述——即使不是在理论上,也是在实践中。生命周期是条目 15 中描述的借用检查器的必需输入;这些特性合在一起构成了 Rust 内存安全保障的核心。
堆栈简介
生命周期从根本上与堆栈有关,因此需要快速介绍/提醒。
当程序运行时,它使用的内存被划分为不同的块,有时称为段。其中一些块是固定大小的,例如保存程序代码或程序全局数据的块,但其中两个块——堆和堆栈——会随着程序的运行而改变大小。为了实现这一点,它们通常排列在程序虚拟内存空间的两端,因此一个可以向下增长,另一个可以向上增长(至少在程序内存耗尽并崩溃之前),如图3-1所示。
在这两个动态大小的块中,堆栈用于保存与当前正在执行的函数相关的状态。此状态可以包括以下元素:
传递给函数的参数
函数中使用的局部变量
函数内计算的临时值
函数调用者代码中的返回地址
调用函数 f() 时,会在堆栈中添加一个新的堆栈帧,超出调用函数的堆栈帧结束的位置,并且 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] } }
当然,这只是对实际发生情况的一个大大简化的版本——将数据放入堆栈和从堆栈中取出需要时间,因此实际处理器会进行许多优化。但是,简化的概念图足以理解本条款的主题。
生命周期的演化
上一节解释了参数和局部变量如何存储在堆栈中,并指出这些值只是暂时存储的。
从历史上看,这会导致一些危险的意外情况:如果你持有指向这些暂时堆栈值之一的指针会发生什么?
从 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 的核心吸引力之一是它从根本上解决了悬空指针的问题,立即解决了很大一部分安全问题。
这样做需要将生命周期的概念从后台(C/C++ 程序员只需知道注意它们,无需任何语言支持)移到前台:每个包含 & 符号的类型都有一个关联的生命周期('a),即使编译器允许您在大部分时间省略提及它。
生命周期的范围
堆栈上项目的生命周期是保证该项目停留在同一位置的时间段;换句话说,这正是保证该项目的引用(指针)不会失效的时间段。
这从创建项目的位置开始,一直延伸到项目被丢弃(Rust 相当于 C++ 中的对象销毁)或移动的位置。
后者的普遍性有时会让来自 C/C++ 的程序员感到惊讶:在很多情况下,Rust 将项目从堆栈上的一个位置移动到另一个位置,或者从堆栈移动到堆,或者从堆移动到堆栈。
项目自动丢弃的确切位置取决于项目是否有名称。
局部变量和函数参数都有名称,相应项的生命周期从创建项并填充名称时开始:
对于局部变量:在 let var = ...
声明中
对于函数参数:作为设置函数调用执行框架的一部分 当项被移动到其他地方或名称超出范围时,命名项的生命周期结束:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] /// Definition of an item of some kind. pub struct Item { contents: u32, } }
#![allow(unused)] fn main() { { let item1 = Item { contents: 1 }; // `item1` created here let item2 = Item { contents: 2 }; // `item2` created here println!("item1 = {item1:?}, item2 = {item2:?}"); consuming_fn(item2); // `item2` moved here } // `item1` dropped here }
还可以“即时”构建一个项,作为表达式的一部分,然后将其输入到其他内容中。这些未命名的临时项在不再需要时将被删除。一种过于简单但有用的思考方法是想象表达式的每个部分都会扩展为其自己的块,临时变量由编译器插入。例如,如下表达式:
#![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` dropped here }; // `temp1` dropped here }
当执行到达原始行末尾的分号时,临时变量已全部删除。
查看编译器计算的项目生命周期的一种方法是插入一个故意的错误以供借用检查器(第 15 条)检测。例如,保留对超出项目生命周期范围的项目的引用:
#![allow(unused)] fn main() { let r: &Item; { let item = Item { contents: 42 }; r = &item; } println!("r.contents = {}", r.contents); }
错误消息指示了项目生命周期的确切终点:
#![allow(unused)] fn main() { 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); }
错误消息显示表达式末尾的端点:
#![allow(unused)] fn main() { 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 }
关于引用生命周期的最后一点:如果编译器可以证明在代码中的某个点之外没有使用引用,那么它会将引用生命周期的终点视为最后使用的位置,而不是封闭范围的末尾。这个特性称为非词法生命周期,它使借用检查器更加慷慨:
#![allow(unused)] fn main() { { // `s` owns the `String`. let mut s: String = "Hello, world".to_string(); // Create a mutable reference to the `String`. let greeting = &mut s[..5]; greeting.make_ascii_uppercase(); // .. no use of `greeting` after this point // Creating an immutable reference to the `String` is allowed, // even though there's a mutable reference still in scope. let r: &str = &s; println!("s = '{}'", r); // s = 'HELLO, world' } // The mutable reference `greeting` would naively be dropped here. }
生命周期代数
尽管在 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]> { // ... } }
#![allow(unused)] fn main() { 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"; // start of lifetime 'a let found = { let needle = b"234"; // start of lifetime 'b find(haystack, needle) }; // end of lifetime 'b println!("found={:?}", found); // `found` used within 'a, outside of 'b } // end of lifetime 'a }
生命周期省略规则
除了前面描述的“一进一出”省略规则外,还有另外两个省略规则,这意味着可以省略生命周期名称。
第一条规则发生在函数输出中没有引用时;在这种情况下,每个输入引用都会自动获得自己的生命周期,与任何其他输入参数都不同。
第二种规则发生在使用对 self 的引用(&self 或 &mut self)的方法中;在这种情况下,编译器假定任何输出引用都采用 self 的生命周期,因为事实证明这是(迄今为止)最常见的情况。
以下是函数省略规则的摘要:
- 一个输入,一个或多个输出:假定输出具有与输入“相同”的生命周期:
#![allow(unused)] fn main() { fn f(x: &Item) -> (&Item, &Item) // ... is equivalent to ... fn f<'a>(x: &'a Item) -> (&'a Item, &'a Item) }
- 多个输入,没有输出:假设所有输入都有不同的生命周期:
#![allow(unused)] fn main() { fn f(x: &Item, y: &Item, z: &Item) -> i32 // ... is equivalent to ... 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 // ... is equivalent to ... fn f(&'a self, y: &'b Item, z: &'c Item) -> &'a Thing }
当然,如果省略的生命周期名称不符合您的要求,您可以随时明确编写生命周期名称来指定哪些生命周期相互关联。实际上,这很可能是由编译器错误触发的,该错误表明省略的生命周期与函数或其调用者使用相关引用的方式不匹配。
'static
生命周期
上一节描述了函数的输入和输出引用生命周期之间的各种可能映射,但忽略了一种特殊情况。如果没有输入生命周期,但输出返回值包含引用,会发生什么情况?
#![allow(unused)] fn main() { pub fn the_answer() -> &Item { // ... } }
#![allow(unused)] fn main() { 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 生命周期的东西的最简单方法是引用被标记为静态的全局变量:
#![allow(unused)] fn main() { static ANSWER: Item = Item { contents: 42 }; pub fn the_answer() -> &'static Item { &ANSWER } }
Rust 编译器保证静态项在整个程序运行期间始终具有相同的地址,并且永远不会移动。这意味着对静态项的引用具有 '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` has a destructor, so the promotion to the `'static` // lifetime for a reference to a constant does not apply. &ANSWER } }
#![allow(unused)] fn main() { 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
#![allow(unused)] fn main() { { let boxed = Box::new(Item { contents: 12 }); let r: &'static Item = &boxed; println!("'static item is {:?}", r); } }
#![allow(unused)] fn main() { 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
#![allow(unused)] fn main() { { let boxed = Box::new(Item { contents: 12 }); // `leak()` consumes the `Box<T>` and returns `&mut T`. let r: &'static Item = Box::leak(boxed); println!("'static item is {:?}", r); } // `boxed` not dropped here, as it was already moved into `Box::leak()` // Because `r` is now out of scope, the `Item` is leaked forever. }
无法删除该项目还意味着保存该项目的内存永远无法使用安全 Rust 回收,这可能会导致永久性内存泄漏。(请注意,泄漏内存并不违反 Rust 的内存安全保证 - 您无法再访问的内存中的项目仍然是安全的。)
生命周期和堆
到目前为止,讨论都集中在堆栈上项目的生命周期上,无论是函数参数、局部变量还是临时变量。但是堆上的项目呢?
关于堆值,关键是要意识到每个项目都有一个所有者(除了上一节中描述的故意泄漏等特殊情况)。例如,一个简单的 Box
#![allow(unused)] fn main() { { let b: Box<Item> = Box::new(Item { contents: 42 }); } // `b` dropped here, so `Item` dropped too. }
拥有它的 Box
堆上值的所有者本身可能在堆上而不是在堆栈上,但那么谁拥有所有者呢?
#![allow(unused)] fn main() { { let b: Box<Item> = Box::new(Item { contents: 42 }); let bb: Box<Box<Item>> = Box::new(b); // `b` moved onto heap here } // `bb` dropped here, so `Box<Item>` dropped too, so `Item` dropped too. }
所有权链必须在某处结束,并且只有两种可能性:
链在局部变量或函数参数处结束 - 在这种情况下,链中所有内容的生命周期只是该堆栈变量的生命周期 'a。当堆栈变量超出范围时,链中的所有内容也会被丢弃。 链在标记为静态的全局变量处结束 - 在这种情况下,链中所有内容的生命周期都是 'static。静态变量永远不会超出范围,因此链中的任何内容都不会自动被丢弃。 因此,堆上项目的生命周期从根本上与堆栈生命周期相关。
数据结构中的生命周期
前面关于生命周期代数的部分集中讨论了函数的输入和输出,但当引用存储在数据结构中时也存在类似的问题。
如果我们试图将引用偷偷放入数据结构中而不提及相关的生命周期,编译器会严厉警告我们:
#![allow(unused)] fn main() { pub struct ReferenceHolder { pub index: usize, pub item: &Item, } }
#![allow(unused)] fn main() { 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() { // Lifetime parameter required due to field with reference. pub struct ReferenceHolder<'a> { pub index: usize, pub item: &'a Item, } }
数据结构的生命周期参数具有传染性:任何使用该类型的包含数据结构也必须获取生命周期参数:
#![allow(unused)] fn main() { // Lifetime parameter required due to field that is of a // type that has a lifetime parameter. pub struct RefHolderHolder<'a> { pub inner: ReferenceHolder<'a>, } }
如果数据结构包含切片类型,则也需要生命周期参数,因为这些也是对借用数据的引用。
如果数据结构包含具有相关生命周期的多个字段,则必须选择合适的生命周期组合。在一对字符串中查找公共子字符串的示例是具有独立生命周期的良好候选者:
#![allow(unused)] fn main() { /// Locations of a substring that is present in /// both of a pair of strings. pub struct LargestCommonSubstring<'a, 'b> { pub left: &'a str, pub right: &'b str, } /// Find the largest substring present in both `left` /// and `right`. pub fn find_common<'a, 'b>( left: &'a str, right: &'b str, ) -> Option<LargestCommonSubstring<'a, 'b>> { // ... } }
而引用同一字符串中多个位置的数据结构将具有共同的生命周期:
#![allow(unused)] fn main() { /// First two instances of a substring that is repeated /// within a string. pub struct RepeatedSubstring<'a> { pub first: &'a str, pub second: &'a str, } /// Find the first repeated substring present in `s`. pub fn find_repeat<'a>(s: &'a str) -> Option<RepeatedSubstring<'a>> { // ... } }
生命周期参数的传播是有意义的:任何包含引用的东西,无论嵌套程度如何,都只在引用项的生命周期内有效。如果移动或删除了该项,则整个数据结构链将不再有效。
但是,这也意味着涉及引用的数据结构更难使用——数据结构的所有者必须确保所有生命周期都对齐。因此,尽可能优先选择拥有其内容的数据结构,特别是如果代码不需要高度优化(第 20 条)。如果这不可能,第 8 条中描述的各种智能指针类型(例如 Rc)可以帮助解开生命周期约束。
匿名生命周期
当无法坚持使用拥有其内容的数据结构时,数据结构必然会以生命周期参数结束,如上一节所述。这可能会与本条目前面描述的生命周期省略规则产生略微不利的交互。
例如,考虑一个返回具有生命周期参数的数据结构的函数。此函数的完全显式签名使所涉及的生命周期清晰可见:
#![allow(unused)] fn main() { pub fn find_one_item<'a>(items: &'a [Item]) -> ReferenceHolder<'a> { // ... } }
但是,省略生命周期的相同签名可能会有点误导:
#![allow(unused)] fn main() { pub fn find_one_item(items: &[Item]) -> ReferenceHolder { // ... } }
由于返回类型的生命周期参数被省略,因此阅读代码的人无法获得太多有关生命周期的提示。
匿名生命周期 '_ 允许您将省略的生命周期标记为存在,而无需完全恢复所有生命周期名称:
#![allow(unused)] fn main() { pub fn find_one_item(items: &[Item]) -> ReferenceHolder<'_> { // ... } }
粗略地说,'_ 标记要求编译器为我们发明一个唯一的生命周期名称,我们可以在不需要在其他地方使用该名称的情况下使用该名称。
这意味着它对于其他生命周期省略场景也很有用。例如,Debug 特征的 fmt 方法的声明使用匿名生命周期来指示 Formatter 实例具有与 &self 不同的生命周期,但该生命周期的名称是什么并不重要:
#![allow(unused)] fn main() { pub trait Debug { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>; } }
要记住的事情
- 所有 Rust 引用都有一个关联的生命周期,由生命周期标签(例如 'a)表示。函数参数和返回值的生命周期标签在某些常见情况下可以省略(但仍存在于幕后)。
- 任何(可传递地)包含引用的数据结构都有一个关联的生命周期参数;因此,使用拥有其内容的数据结构通常更容易。
- 'static 生命周期用于保证永远不会超出范围的项目的引用,例如全局数据或堆上已明确泄漏的项目。
- 生命周期标签只能用于指示生命周期是“相同的”,这意味着输出生命周期包含在输入生命周期内。
- 匿名生命周期标签 '_ 可用于不需要特定生命周期标签的地方。
理解借用检查器
Rust 中的值有所有者,但该所有者可以将值借给代码中的其他地方。这种借用机制涉及引用的创建和使用,受借用检查器(本条目的主题)监管的规则约束。
在幕后,Rust 的引用使用与 C 或 C++ 代码中非常普遍的相同类型的指针值(条目 8),但有规则和限制,以确保避免 C/C++ 的错误。快速比较:
- 与 C/C++ 指针一样,Rust 引用是用
&value
符号创建的。 - 与 C++ 引用一样,Rust 引用永远不会为
nullptr
。 - 与 C/C++ 指针一样,Rust 引用可以在创建后修改以引用其他内容。
- 与 C++ 不同,从值生成引用始终涉及显式 (
&
) 转换 - 如果您看到像f(value)
这样的代码,您就知道f
正在接收该值的所有权。 (但是,如果值的类型实现了Copy
,则它可能是该项目副本的所有权——请参阅第 10 条。) - 与 C/C++ 不同,新创建的引用的可变性始终是显式的 (
&mut
)。如果您看到像f(&value)
这样的代码,您就知道该值不会被修改(即,在 C/C++ 术语中是const
)。只有像f(&mut value)
这样的表达式才有可能改变值的内容。 C/C++ 指针和 Rust 引用之间最重要的区别是用术语“借用”表示的:您可以获取对某个项目的引用(指针),但不能永远保留该引用。具体来说,您不能将其保留的时间超过底层项的生命周期,如编译器所跟踪并在第 14 项中探讨的那样。
这些对引用使用的限制使 Rust 能够保证其内存安全,但它们也意味着您必须接受借用规则的认知成本,并接受它将改变您设计软件的方式——特别是其数据结构。
本项首先描述 Rust 引用可以做什么,以及借用检查器使用它们的规则。本项的其余部分重点介绍如何处理这些规则的后果:如何重构、重新设计和重新设计您的代码,以便您能够赢得与借用检查器的斗争。
访问控制
有三种方式可以访问 Rust 项目的内容:通过项目的所有者 (item
)、引用 (&item
) 或可变引用 (&mut item
)。访问项目的每种方式都具有对项目的不同权限。粗略地按照存储的 CRUD(创建/读取/更新/删除)模型来表示(使用 Rust 的删除术语代替删除):
- 项目的所有者可以创建、读取、更新和删除它。
- 可变引用可用于读取底层项目并更新它。
- (普通)引用只能用于读取底层项目。 这些数据访问规则有一个重要的 Rust 特定方面:只有项目的所有者才能移动项目。如果您将移动视为创建(在新位置)和删除项目内存(在旧位置)的某种组合,那么这是有道理的。
这可能会导致对项目具有可变引用的代码出现一些奇怪之处。例如,覆盖Option
是可以的:
#![allow(unused)] fn main() { /// Some data structure used by the code. #[derive(Debug)] pub struct Item { pub contents: i64, } /// Replace the content of `item` with `val`. pub fn replace(item: &mut Option<Item>, val: Item) { *item = Some(val); } }
但修改为同时返回先前的值却违反了移动限制:
#![allow(unused)] fn main() { /// Replace the content of `item` with `val`, returning the previous /// contents. pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { let previous = *item; // move out *item = Some(val); // replace previous } }
#![allow(unused)] fn main() { 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 | }
虽然从可变引用读取是有效的,但此代码试图将值移出,就在用新值替换移动的值之前——试图避免复制原始值。借用检查器必须保守,并注意到两行之间有一段时间可变引用没有引用有效值。
作为人类,我们可以看到这种组合操作——提取旧值并用新值替换它——既安全又有用,因此标准库提供了 std::mem::replace
函数来执行它。在幕后,replace
使用 unsafe
(根据第 16 条)一次性执行交换:
#![allow(unused)] fn main() { /// Replace the content of `item` with `val`, returning the previous /// contents. pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { std::mem::replace(item, Some(val)) // returns previous value } }
特别是对于 Option 类型,这是一个足够常见的模式,Option 本身也有一个 replace 方法:
#![allow(unused)] fn main() { /// Replace the content of `item` with `val`, returning the previous /// contents. pub fn replace(item: &mut Option<Item>, val: Item) -> Option<Item> { item.replace(val) // returns previous value } }
借用规则
在 Rust 中借用引用时,需要记住两个关键规则。
第一条规则是,任何引用的范围都必须小于它所引用项的生命周期。生命周期在条目 14 中进行了详细探讨,但值得注意的是,编译器对引用生命周期有特殊行为;非词汇生命周期功能允许缩短引用生命周期,使其在最后一次使用时结束,而不是在封闭块处结束。
借用引用的第二条规则是,除了项的所有者之外,还可以有以下任一项:
- 对项的任意数量的不可变引用
- 对项的单个可变引用 但是,不能同时存在两者(在代码中的同一点)。
因此,可以将对同一项的引用提供给接受多个不可变引用的函数:
#![allow(unused)] fn main() { /// Indicate whether both arguments are zero. 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() { /// Zero out the contents of both arguments. 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); }
#![allow(unused)] fn main() { 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() { /// Set the contents of `left` to the contents of `right`. fn copy_contents(left: &mut Item, right: &Item) { left.contents = right.contents; } let mut item = Item { contents: 42 }; copy_contents(&mut item, &item); }
#![allow(unused)] fn main() { 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; // ^^^ Changing the item is roughly equivalent to: // (&mut item).contents = 0; println!("reference to item is {:?}", r); }
#![allow(unused)] fn main() { 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; // ^^^ Reading from the item is roughly equivalent to: // 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 implements `Copy` r.contents = 0; }
#![allow(unused)] fn main() { 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 }
最后,任何类型的活动引用的存在都会阻止物品的所有者移动或丢弃该物品,因为这意味着引用现在指向一个无效物品:
#![allow(unused)] fn main() { let item = Item { contents: 42 }; let r = &item; let new_item = item; // move println!("reference to item is {:?}", r); }
#![allow(unused)] fn main() { 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); // Reference `r` is still in scope but has no further use, so it's // as if the reference has already been dropped. let new_item = item; // move works OK }
赢得与借用检查器的斗争
Rust 新手(甚至是经验丰富的人!)经常会觉得他们正在花时间与借用检查器作斗争。什么样的事情可以帮助你赢得这些战斗?
本地代码重构
第一个策略是注意编译器的错误消息,因为 Rust 开发人员已经付出了很多努力使它们尽可能有用:
#![allow(unused)] fn main() { /// If `needle` is present in `haystack`, return a slice containing it. 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}'!"); } }
#![allow(unused)] fn main() { 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` now references `haystack`, which outlives it }
这是两个简单的代码调整之一,可以帮助安抚借用检查器:
- 生命周期延长:使用
let
绑定将临时变量(其生命周期仅延伸到表达式的末尾)转换为新的命名局部变量(其生命周期延伸到块的末尾)。 - 生命周期缩短:在引用的使用周围添加一个额外的块
{ ... }
,以便其生命周期在新块的末尾结束。 - 后者不太常见,因为存在非词汇生命周期:编译器通常可以在块末尾的正式放置点之前找出不再使用的引用。但是,如果您确实发现自己在类似的小块代码周围反复引入人工块,请考虑是否应将该代码封装到自己的方法中。
编译器建议的修复对于较简单的问题很有帮助,但随着您编写更复杂的代码,您可能会发现这些建议不再有用,并且破坏借用规则的解释更难理解:
#![allow(unused)] fn main() { let x = Some(Rc::new(RefCell::new(Item { contents: 42 }))); // Call function with signature: `check_item(item: Option<&Item>)` check_item(x.as_ref().map(|r| r.borrow().deref())); }
#![allow(unused)] fn main() { 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); }
#![allow(unused)] fn main() { 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 项所述。
但是,对于现实世界的数据结构来说,这并不总是可能的;任何时候,数据结构的内部连接形成一个比树形模式更相互关联的图形(一个拥有多个分支的Root
,每个分支拥有多个Leaf
等),那么简单的单一所有权就是不可能的。
举一个简单的例子,想象一个简单的登记册,按客人到达的顺序记录客人的详细信息:
#![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) { // Requires `Guest` to be `Clone` self.by_arrival.push(guest.clone()); // Not checking for duplicate names to keep this // example shorter. 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
的地址,则必须找到两个版本并确保它们保持同步。
另一种可能的方法是添加另一层间接层,将 Vec<Guest>
视为所有者,并使用该向量中的索引进行名称查找:
#![allow(unused)] fn main() { mod indexed { use super::Guest; #[derive(Default)] pub struct GuestRegister { by_arrival: Vec<Guest>, // Map from guest name to index into `by_arrival`. by_name: std::collections::BTreeMap<String, usize>, } impl GuestRegister { pub fn register(&mut self, guest: Guest) { // Not checking for duplicate names to keep this // example shorter. 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"; // Real code wouldn't assume that "Bob" exists... ledger.named_mut("Bob").unwrap().address = new_address.to_string(); assert_eq!(ledger.named("Bob").unwrap().address, new_address); }
但是,如果客人可以取消注册,则很容易无意中引入错误:
#![allow(unused)] fn main() { // Deregister the `Guest` at position `idx`, moving up all // subsequent guests. 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); // Oops, forgot to update `by_name`. Ok(()) } }
现在 Vec
可以被混洗了,其中的 by_name
索引实际上就像指针一样,我们重新引入了一个世界,在这个世界中,一个错误可能导致这些“指针”指向虚无(超出 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 still has index 0, which is now Bob println!("Alice is {also_alice:?}"); let also_bob = ledger.named("Bob"); // Bob still has index 1, which is now Charlie println!("Bob is {also_bob:?}"); let also_charlie = ledger.named("Charlie"); // Charlie still has index 2, which is now beyond the Vec println!("Charlie is {also_charlie:?}"); }
这里的代码使用了自定义的 Debug
实现(未显示),以减少输出的大小;这个截断的输出如下:
#![allow(unused)] fn main() { 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 }
前面的示例显示了deregister
代码中的一个错误,但即使修复了该错误,也无法阻止调用者挂起索引值并将其与 nth()
一起使用——从而获得意外或无效的结果。
核心问题是两个数据结构需要保持同步。处理此问题的更好方法是改用 Rust 的智能指针(第 8 条)。转换为 Rc
和 RefCell
的组合可避免使用索引作为伪指针的无效问题。更新示例(但保留其中的错误)可得到以下内容:
#![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); // Oops, still forgot to update `by_name`. Ok(()) } // ... } } }
#![allow(unused)] fn main() { 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
的条目仍然存在,直到我们通过确保两个集合保持同步来修复该错误:
#![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(()) } }
#![allow(unused)] fn main() { 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_ptrs
)简单转换为 Rc<RefCell<T>>
实例通常会产生一些在 Rust 中可以正常工作的东西,而不会引起借用检查器的太多抱怨。
但是,这种方法意味着您会错过 Rust 为您提供的一些保护。 特别是,当同一项被可变地借用(通过 borrow_mut()
)而另一个引用存在时,会导致运行时恐慌! 而不是编译时错误。
例如,一种打破树状数据结构中所有权单向流动的模式是当有一个“所有者”指针从一个项目指向拥有它的东西时,如图 3-3 所示。 这些所有者链接对于在数据结构中移动很有用;例如,向 Leaf
添加新的兄弟需要涉及拥有它的 Branch
。
在 Rust 中实现这种模式可以利用 Rc<T>
的更具尝试性的伙伴 Weak<T>
:
#![allow(unused)] fn main() { use std::{ cell::RefCell, rc::{Rc, Weak}, }; // Use a newtype for each identifier type. 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>>>, } }
弱引用不会增加主引用计数,因此必须明确检查底层项目是否已消失:
#![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) => { // Upgrade weak owner pointer. let tree = owner.upgrade().expect("owner gone!"); format!("{}.{}", tree.borrow().id.0, self.id.0) } } } } }
如果 Rust 的智能指针似乎不能满足您的数据结构的需求,那么最后总会有使用原始(并且绝对不智能)指针编写不安全代码的退路。但是,根据第 16 条,这应该是最后的手段——其他人可能已经在安全接口中实现了您想要的语义,如果您搜索标准库和 crates.io,您可能会找到适合这项工作的工具。
例如,假设您有一个函数,它有时会返回对其输入之一的引用,但有时需要返回一些新分配的数据。与第 1 条一致,对这两种可能性进行编码的枚举是在类型系统中表达这一点的自然方式,然后您可以实现第 8 条中描述的各种指针特征。但您不必这样做:标准库已经包含了 std::borrow::Cow
类型,一旦您知道它存在,它就会完全覆盖这种情况。
自引用数据结构
与借用检查器之间的一个特殊斗争总是阻碍着从其他语言转到 Rust 的程序员:尝试创建自引用数据结构,其中包含自有数据以及对该自有数据内部的引用:
#![allow(unused)] fn main() { struct SelfRef { text: String, // The slice of `text` that holds the title text. title: Option<&str>, } }
从语法层面上看,此代码无法编译,因为它不符合第 14 条中描述的生命周期规则:引用需要生命周期注释,这意味着包含的数据结构也需要生命周期参数。但生命周期是针对此 SelfRef
结构外部的某些东西,这并非本意:引用的数据是结构内部的。
值得从更语义的层面考虑这种限制的原因。Rust 中的数据结构可以移动:从堆栈到堆,从堆到堆栈,从一个地方到另一个地方。如果发生这种情况,“内部”标题指针将不再有效,并且无法保持同步。
这种情况的一个简单替代方法是使用前面探讨的索引方法:文本中的偏移范围不会因移动而失效,并且对借用检查器不可见,因为它不涉及引用:
#![allow(unused)] fn main() { struct SelfRefIdx { text: String, // Indices into `text` where the title text is. title: Option<std::ops::Range<usize>>, } }
但是,这种索引方法仅适用于简单示例,并且具有前面提到的相同缺点:索引本身变成伪指针,可能会不同步,甚至引用不再存在的文本范围。
当编译器处理异步代码时,会出现更通用的自引用问题。4 粗略地说,编译器将待处理的异步代码块捆绑到一个闭包中,该闭包包含代码和代码使用的任何捕获的环境部分(如第 2 条所述)。这个捕获的环境可以包含值和对这些值的引用。这本质上是一个自引用数据结构,因此异步支持是标准库中 Pin 类型的主要动机。此指针类型将其值“固定”在适当位置,强制该值保持在内存中的同一位置,从而确保内部自引用保持有效。
因此,Pin
可用于自引用类型,但正确使用起来很棘手——请务必阅读官方文档。
尽可能避免使用自引用数据结构,或者尝试寻找能够为您解决困难的库箱(例如 ouroborous)。
要记住的事情
- Rust 的引用是借用的,这表明它们不能被永远持有。
- 借用检查器允许对一个项目进行多个不可变引用或单个可变引用,但不能同时进行。由于非词汇生命周期,引用的生命周期在最后一次使用时停止,而不是在封闭范围的末尾。
- 借用检查器的错误可以通过多种方式处理:
- 添加额外的
{ ... }
范围可以缩短值的生命周期。 - 为值添加命名局部变量将值的生命周期延长到范围的末尾。
- 临时添加多个局部变量可以帮助缩小借用检查器抱怨的范围。
- Rust 的智能指针类型提供了绕过借用检查器规则的方法,因此对于互连数据结构很有用。
- 然而,自引用数据结构在 Rust 中仍然很难处理。
避免编写不安全的代码
条款17: 警惕共享状态并发性
“即使是最大胆的共享形式,在 Rust 中也能保证安全。”
官方文档将 Rust 描述为“不畏 并发“,但本项目将探讨为什么(可悲地)仍然有一些理由害怕并发,即使在 Rust 中也是如此。
此项特定于共享状态并行性:其中不同的执行线程与每个线程进行通信 其他通过共享内存。线程之间的共享状态通常会带来两个可怕的问题,无论 涉及语言:
- 数据争用:这些可能导致数据损坏。
- 死锁:这些可能导致您的程序停止运行。
这两个问题都是可怕的(“引起或可能引起恐怖”),因为它们很难调试 实践:故障是不确定的,通常更有可能在负载下发生,这意味着 它们不会出现在单元测试、集成测试或任何其他类型的 测试(第 30 项),但它们确实出现在生产环境中。
Rust 是向前迈出的一大步,因为它完全解决了这两个问题之一。然而,另一个仍然存在, 正如我们将看到的。
不要panic
条款19:避免反射
从其他语言转到 Rust 的程序员通常习惯于将反射作为他们工具箱中的一种工具。他们可能会浪费大量时间尝试在 Rust 中实现基于反射的设计,结果却发现他们尝试的东西做得不好,甚至根本做不出来。本条款希望通过描述 Rust 在反射方面能做什么和不能做什么,以及可以用什么来代替,来节省那些浪费在探索死胡同上的时间。
反射是程序在运行时检查自身的能力。给定一个运行时的数据项,它涵盖了以下问题:
- 可以确定关于数据项类型的哪些信息?
- 可以用这些信息做什么?
具有完全反射支持的编程语言对这些问题有广泛的答案。根据反射信息,具有反射功能的语言通常在运行时支持以下部分或全部功能:
- 确定数据项的类型
- 探索其内容
- 修改其字段
- 调用其方法
具有此级别反射支持的语言也往往是动态类型语言(例如 Python、Ruby),但也有一些著名的静态类型语言也支持反射,特别是 Java 和 Go。
Rust 不支持这种类型的反射,这使得避免反射的建议在这个级别上很容易遵循——这根本不可能。对于来自支持完全反射的语言的程序员来说,这种缺失乍一看似乎是一个很大的差距,但 Rust 的其他功能提供了解决许多相同问题的替代方法。
C++ 有一种更有限的反射形式,称为运行时类型识别 (RTTI,run-time type identification)。 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); }
显示类型和值:
#![allow(unused)] fn main() { 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 项中使用的特征对象类型可以演示这一点:
#![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)); }
仅特征对象的类型可用,而不是具体基础项目的类型(Square
):
#![allow(unused)] fn main() { 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)); }
#![allow(unused)] fn main() { x has TypeId { t: 18349839772473174998 } y has TypeId { t: 2366424454607613595 } }
输出对人类的帮助不大,但唯一性的保证意味着结果可以在代码中使用。但是,通常最好不要直接使用 TypeId
,而是使用 std::any::Any
特征,因为标准库具有用于处理 Any 实例的附加功能(如下所述)。
Any
特征有一个方法 type_id(),它返回实现该特征的类型的 TypeId 值。但是,您无法自己实现此特征,因为 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
生命周期的引用,则不会为 T
实现 TypeId
。这是故意施加的限制,因为生命周期并非类型的完全组成部分:TypeId::of::<&'a T>
与 TypeId::of::<&'b T>
相同,尽管生命周期不同,这增加了混淆和代码不合理的可能性。
回想一下第 8 条,特征对象是一个胖指针,它包含指向底层项的指针以及指向特征实现的 vtable
的指针。对于 Any
,vtable
只有一个条目,用于返回项类型的 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)); }
除了几个间接引用之外,dyn Any
特征对象实际上是原始指针和类型标识符的组合。这意味着标准库可以提供一些为 dyn Any
特征对象定义的其他通用方法;这些方法在某些其他类型 T
上是通用的:
is::<T>()
:指示特征对象的类型是否等于某个特定的其他类型T
downcast_ref::<T>()
:返回对具体类型T
的引用,前提是特征对象的类型与T
匹配downcast_mut::<T>()
:返回对具体类型T
的可变引用,前提是特征对象的类型与T
匹配
请注意,Any
trait仅近似于反射功能:程序员选择(在编译时)明确构建某些东西(&dyn Any
)来跟踪项目的编译时类型及其位置。只有在构建 Any
特征对象的开销已经发生的情况下,才有可能(比如说)向下转换回原始类型。
Rust 具有与项目相关的不同编译时和运行时类型的场景相对较少。其中最主要的是特征对象:具体类型 Square 的项目可以强制转换为该类型实现的特征的特征对象 dyn Shape。此强制从简单指针(对象/项目)构建一个胖指针(对象 + vtable
)。
还记得第 12 条中提到的 Rust 的特征对象并不是真正面向对象的。并不是 Square
是 Shape
;而只是 Square
实现了 Shape
的接口。trait边界也是如此:trait边界 Shape: Draw
并不意味着是;它只是意味着也实现了,因为 Shape
的 vtable
包含 Draw
方法的条目。
对于一些简单的特征边界:
#![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())); } } }
等效特征对象:
#![allow(unused)] fn main() { let square = Square::new(1, 2, 2); let draw: &dyn Draw = □ let shape: &dyn Shape = □ }
有一个带箭头的布局(如图 3-5 所示;重复自 Item 12),使问题清晰:给定一个 dyn Shape
对象,没有立即构建 dyn Draw
trait对象的方法,因为没有办法返回到 impl Draw for Square
的 vtable
——即使其内容的相关部分(Square::bounds()
方法的地址)理论上是可恢复的。(这在 Rust 的后续版本中可能会发生变化;请参阅本 Item 的最后一节。)
将此图与上图进行比较,很明显,显式构造的 &dyn Any
trait对象没有帮助。Any
允许恢复底层项的原始具体类型,但没有运行时方法来查看它实现的特征,或访问可能允许创建特征对象的相关 vtable
。
那么有什么可用的呢?
主要工具是特征定义,这符合对其他语言的建议——Effective Java Item 65 建议“优先使用接口而不是反射”。如果代码需要依赖于某个项的某些行为的可用性,请将该行为编码为特征(Item 2)。即使所需的行为不能用一组方法签名来表示,也可以使用标记特征来指示符合所需的行为——它比(比如说)自省类的名称来检查特定前缀更安全、更有效。
期望特征对象的代码也可以与具有在程序链接时不可用的支持代码的对象一起使用,因为它已在运行时动态加载(通过 dlopen(3) 或等效方法)——这意味着泛型的单态化(条目 12)是不可能的。
相关地,反射有时也用于其他语言,以允许将同一依赖库的多个不兼容版本同时加载到程序中,从而绕过只能有一个的链接约束。这在 Rust 中是不必要的,因为 Cargo 已经可以处理同一库的多个版本(条目 25)。
最后,宏(尤其是派生宏)可用于自动生成在编译时理解项目类型的辅助代码,作为在运行时解析项目内容的代码的更高效、更类型安全的等效代码。条目 28 讨论了 Rust 的宏系统。
Rust 未来版本中的向上转型
本条目的文本最初写于 2021 年,一直准确无误,直到 2024 年准备出版这本书时——届时 Rust 将添加一项新功能,以更改一些细节。
当 U 是 T 的超特征之一(trait T: U {...}
)时,此新“特征向上转型”功能可实现将特征对象 dyn T
转换为特征对象 dyn U
的向上转型。该功能在正式发布之前已在 #![feature(trait_upcasting)]
上进行门控,预计为 Rust 版本 1.76。
对于前面的示例,这意味着 &dyn Shape
特征对象现在可以转换为 &dyn Draw
特征对象,更接近 Liskov 替换的 is-a 关系。允许这种转换会对 vtable 实现的内部细节产生连锁反应,这可能会比前面的图表中显示的版本更复杂。
但是,此项的中心点不受影响 - Any
特征没有超特征,因此上行转换的能力不会对其功能产生任何影响。
避免过度优化的诱惑
依赖
“当神灵想要惩罚我们时,他们会回应我们的祈祷。” – Oscar Wilde
几十年来,代码重用的想法只是一个梦想。代码可以编写一次,打包成库,然后在许多不同的应用程序中重用,这种想法是一种理想,只适用于少数标准库和企业内部工具。
互联网的发展和开源软件的兴起最终改变了这一现状。第一个可公开访问的存储库是 CPAN1:Comprehensive Perl Archive Network,它包含大量有用的库、工具和帮助程序,所有这些都打包起来以便于重用,该存储库自 1995 年起上线。如今,几乎每种现代语言都有一套全面的开源库,它们存储在一个软件包存储库中,使添加新依赖项的过程变得简单快捷。
然而,这种轻松、方便和快速也带来了新的问题。重用现有代码通常比自己编写代码更容易,但依赖他人的代码可能会带来潜在的陷阱和风险。本书的这一章将帮助您了解这些内容。
重点是 Rust,以及其对 Cargo 工具的使用,但所涵盖的许多关注点、主题和问题同样适用于其他工具链(和其他语言)。
除了 C 和 C++ 之外,包管理仍然有些分散。
了解语义版本控制约定
最小化可见性
避免通配符导入
重新导出依赖项的API类型
本条款的标题有点复杂,但通过一个例子可以更清楚地理解。
第 25 项描述了 Cargo 如何以透明的方式支持将同一库包的不同版本链接到单个二进制文件中。考虑一个使用 rand 包的二进制文件——更具体地说,是一个使用 0.8 版包的二进制文件:
#![allow(unused)] fn main() { Cargo.toml file for a top-level binary crate. [dependencies] The binary depends on the `rand` crate from crates.io rand = "=0.8.5" It also depends on some other crate (`dep-lib`). 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); }
最后一行代码还使用了一个名义上的 dep-lib
包作为另一个依赖项。此包可能是来自 crates.io 的另一个包,也可能是通过 Cargo 的路径机制定位的本地包。
此 dep-lib
包内部使用 rand
包的 0.7 版本:
#![allow(unused)] fn main() { Cargo.toml file for the `dep-lib` library crate. [dependencies] The library depends on the `rand` crate from crates.io 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) } }
眼尖的读者可能会注意到两个代码示例之间的区别:
- 在
rand
的 0.7.x 版本中(由 dep-lib 库包使用),rand::gen_range()
方法采用两个参数,low
和high
。 - 在
rand
的 0.8.x 版本中(由二进制包使用),rand::gen_range()
方法采用单个参数范围。
这不是向后兼容的更改,因此 rand 相应地增加了其最左边的版本组件,这是语义版本控制的要求(第 21 项)。尽管如此,结合两个不兼容版本的二进制文件工作得很好 - cargo 会把所有东西都整理好。
但是,如果 dep-lib 库包的 API 公开了其依赖项的类型,使该依赖项成为公共依赖项,事情就会变得更加尴尬。
例如,假设 dep-lib 入口点涉及一个 Rng 项 - 但具体来说是版本 0.7 的 Rng 项:
#![allow(unused)] fn main() { /// Pick a number between 0 and n (exclusive) using /// the provided `Rng` instance. pub fn pick_number_with<R: Rng>(rng: &mut R, n: usize) -> usize { rng.gen_range(0, n) // Method from the 0.7.x version of Rng } }
另外,在您的 API 中使用另一个 crate 的类型之前请仔细考虑:它将您的 crate 与依赖项的类型紧密联系在一起。例如,依赖项的主要版本升级(第 21 项)也将自动要求您的 crate 的主要版本升级。
在这种情况下,rand 是一个半标准 crate,被广泛使用并且只引入了少量自己的依赖项(第 25 项),因此在 crate API 中包含其类型可能总体上是可以的。
回到示例,尝试从顶级二进制文件使用此入口点失败:
#![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 来说,编译器错误消息不太有用,这是很不寻常的:
#![allow(unused)] fn main() { 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 }
调查所涉及的类型会导致混淆,因为相关特征似乎确实已实现 - 但调用者实际上实现了(名义上的)RngCore_v0_8_5,而库期望实现 RngCore_v0_7_3。
一旦你最终破译了错误消息并意识到版本冲突是根本原因,你该如何修复它?关键的观察是意识到虽然二进制文件不能直接使用同一个包的两个不同版本,但它可以间接地这样做(如前面显示的原始示例所示)。
从二进制文件作者的角度来看,可以通过添加中间包装器包来解决这个问题,该包装器包隐藏了对 rand v0.7 类型的裸露使用。包装器包与二进制包不同,因此可以独立于二进制包对 rand v0.8 的依赖而独立于对 rand v0.7 的依赖。
这很尴尬,库包的作者可以使用更好的方法。它可以通过明确重新导出以下任一内容来让用户的生活更轻松:
- API 中涉及的类型
- 整个依赖包
对于此示例,后一种方法效果最好:除了提供 0.7 版 Rng 和 RngCore 类型外,它还提供构造类型实例的方法(如 thread_rng()):
#![allow(unused)] fn main() { // Re-export the version of `rand` used in this crate's API. pub use rand; }
调用代码现在有一种不同的方式直接引用 rand 的 0.7 版本,如 dep_lib::rand:
#![allow(unused)] fn main() { let mut prev_rng = dep_lib::rand::thread_rng(); // v0.7 Rng instance let choice = dep_lib::pick_number_with(&mut prev_rng, max); }
记住这个例子,项目标题中给出的建议现在应该不那么晦涩难懂了:重新导出类型出现在你的 API 中的依赖项。
管理依赖关系图
警惕功能变化
工具
Titus Winters(Google 的 C++ 库负责人)将软件工程描述为随时间推移而集成的编程,或者有时将其描述为随时间和人员推移而集成的编程。在更长的时间尺度和更广泛的团队中,代码库不仅仅是其中包含的代码。
包括 Rust 在内的现代语言都意识到了这一点,并提供了一个工具生态系统,它远远超出了将程序转换为可执行二进制代码(编译器)的范围。
本章探讨了 Rust 工具生态系统,并提出了利用所有这些基础设施的一般建议。显然,这样做需要适度 - 对于只运行一两次的一次性程序来说,设置 CI、文档构建和六种类型的测试会有些过度。但对于本章中描述的大多数内容,都有很多“物有所值”的东西:对工具集成进行一点点投资将产生有价值的收益。
为公共接口提供文档
条款28:谨慎使用宏
“在某些情况下,决定编写宏而不是函数很容易,因为只有宏才能完成所需的操作。” – Paul Graham, "On Lisp (Prentice Hall)"
Rust 的宏系统允许您执行元编程:编写代码将代码发送到您的项目中。当存在确定性和重复性的“样板”代码块并且否则需要手动保持同步时,这最有价值。
接触 Rust 的程序员可能以前遇到过 C/C++ 预处理器提供的宏,它们对输入文本的标记执行文本替换。Rust 的宏是另一种野兽,因为它们可以处理程序的解析标记或程序的抽象语法树 (AST),而不仅仅是其文本内容。
这意味着 Rust 宏可以了解代码结构,从而可以避免整个与宏相关的错误类别。特别是,我们在以下部分中看到 Rust 的声明性宏是卫生的 - 它们不会意外引用(“捕获”)周围代码中的局部变量。
思考宏的一种方法是将它们视为代码中不同级别的抽象。一种简单的抽象形式是函数:它抽象出相同类型的不同值之间的差异,实现代码可以使用该类型的任何特性和方法,而不管当前操作的值是什么。泛型是不同级别的抽象:它抽象出满足特征边界的不同类型之间的差异,实现代码可以使用特征边界提供的任何方法,而不管当前操作的类型是什么。
宏抽象出程序中扮演相同角色(类型、标识符、表达式等)的不同片段之间的差异;然后实现可以包含以相同角色使用这些片段的任何代码。
Rust 提供了两种定义宏的方法:
- 声明宏,也称为“示例宏”,允许根据宏的输入参数(根据其在 AST 中的角色分类)将任意 Rust 代码插入程序中。
- 过程宏允许根据源代码的解析标记将任意 Rust 代码插入程序中。这最常用于派生宏,它可以根据数据结构定义的内容生成代码。
声明宏
虽然本条目不是重现声明性宏文档的地方,但还是需要提醒大家注意一些细节。
首先,请注意使用声明性宏的范围规则与其他 Rust 条目不同。如果在源代码文件中定义了声明性宏,则只有宏定义之后的代码才能使用它:
#![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 的声明性宏是所谓的卫生的:宏主体中的展开代码不允许使用局部变量绑定。例如,假设某个变量 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; } } }
这意味着关于参数是否被移动或&
-引用的正常直觉不适用:
#![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 状态代码),它在其主体中默默地包含一个返回:
#![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 // ... }
生成发出结果的代码的宏的替代版本:
#![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 语法片段说明符替换为 ident 片段说明符,则宏将只接受标识符作为输入,并且尝试向其输入任意表达式将不再编译。
格式化值
一种常见的声明式宏样式涉及组装一条消息,该消息包含来自代码当前状态的各种值。例如,标准库包括 format!
用于组装字符串、println!
用于打印到标准输出、eprintln!
用于打印到标准错误,等等。文档描述了格式化指令的语法,它们大致相当于 C 的 printf
语句。但是,格式参数是类型安全的,并在编译时进行检查,并且宏的实现使用第 10 条中描述的 Display
和 Debug
特征来格式化单个值。
您可以(并且应该)对任何执行类似功能的宏使用相同的格式化语法。例如,log
crate 提供的日志记录宏使用与 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
宏。与声明性宏一样,过程宏能够将任意 Rust 代码插入程序的源代码中。但是,宏的输入不再只是传递给它的特定参数;相反,过程宏可以访问与原始源代码的某些块相对应的解析标记。这提供了接近动态语言(如 Lisp)灵活性的表达能力水平——但仍然具有编译时保证。它还有助于减轻 Rust 中反射的限制,如第 19 项所述。
过程宏必须在使用它们的单独包(包类型为 proc-macro
)中定义,并且该包几乎肯定需要依赖 proc-macro
(由标准工具链提供)或 proc-macro2
(由 David Tolnay 提供)作为支持库,以便能够使用输入标记。
过程宏有三种不同的类型:
- 函数类宏:使用参数调用
- 属性宏:附加到程序中的一些语法块
- 派生宏:附加到数据结构的定义
函数式宏
函数式宏使用参数调用,宏定义可以访问组成参数的解析标记,并发出任意标记作为结果。请注意,上一句说的是“参数”,单数——即使函数式宏使用看起来像多个参数的参数调用:
#![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
crate(来自 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 }
这个宏的实现太大,无法包含在这里,因为代码需要检查输入标记的结构并构建新的输出标记,但 sync
crate 可以再次帮助完成这个处理。
派生宏
最后一种程序宏是派生宏,它允许生成的代码自动附加到数据结构定义(结构、枚举或联合)。这类似于属性宏,但需要注意一些派生特定方面。
首先,派生宏会添加到输入标记中,而不是完全替换它们。这意味着数据结构定义保持不变,但宏有机会附加相关代码。
第二,派生宏可以声明相关的辅助属性,然后可以使用这些属性标记标记需要特殊处理的数据结构部分。例如,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, } }
需要注意的派生宏的最后一个方面是,sync
crate 可以处理将输入标记解析为 AST 中的等效节点所涉及的大部分繁重工作。syn::parse_macro_input!
宏将标记转换为描述项目内容的 syn::DeriveInput
数据结构,而 DeriveInput
比原始标记流更容易处理。
在实践中,派生宏是最常见的过程宏类型 - 能够逐个字段(对于结构)或逐个变量(对于枚举)生成实现,这使得程序员几乎不费吹灰之力就可以提供许多功能 - 例如,通过添加一行代码,如 #[derive(Debug, Clone, PartialEq, Eq)]
。
由于派生实现是自动生成的,因此这也意味着实现会自动与数据结构定义保持同步。例如,如果要向结构添加新字段,则需要手动更新 Debug 的手动实现,而自动派生版本将无需额外努力即可显示新字段(如果不可能,则编译失败)。
何时使用宏
使用宏的主要原因是避免重复代码 — 尤其是那些必须手动与代码的其他部分保持同步的重复代码。在这方面,编写宏只是编程中通常使用的同一种泛化过程的扩展:
- 如果您对特定类型的多个值重复完全相同的代码,请将该代码封装到一个通用函数中,并从所有重复的位置调用该函数。
- 如果您对多种类型重复完全相同的代码,请将该代码封装到具有特征绑定的泛型中,并从所有重复的位置使用该泛型。
- 如果您在多个位置重复相同结构的代码,请将该代码封装到宏中,并从所有重复的位置使用该宏。
例如,只有通过宏才能避免对不同枚举变量起作用的代码重复:
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 状态代码的所有相关信息(数值、组、描述),充当一种领域特定语言 (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(()) } } } } } }
因此,宏的整体输出负责生成从真实源值派生的所有代码:
- 包含所有变体的枚举的定义
group()
方法的定义,指示 HTTP 状态属于哪个组text()
方法的定义,将状态映射到文本描述TryFrom<i32>
的实现,用于将数字转换为状态枚举值
如果稍后需要添加额外的值,则只需添加一行:
#![allow(unused)] fn main() { ImATeapot => (418, ClientError, "I'm a teapot"), }
如果没有该宏,则必须手动更新四个不同位置。编译器会指出其中一些(因为匹配表达式需要涵盖所有情况),但不会指出全部 — 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 }
#![allow(unused)] fn main() { src/main.rs:340: operation '512.try_into()' failed: TryFromIntError(()) src/main.rs:341: operation 'std::str::from_utf8(b"\xc3\x28")' failed: Utf8Error { valid_up_to: 0, error_len: Some(1) } }
宏的缺点
使用宏的主要缺点是它对代码的可读性和可维护性的影响。前面的“声明性宏”部分解释了宏允许您创建 DSL 来简洁地表达代码和数据的关键特性。然而,这意味着现在任何阅读或维护代码的人除了了解 Rust 之外,还必须了解这个 DSL——以及它在宏定义中的实现。例如,上一节中的 http_codes!
示例创建了一个名为 Status
的 Rust 枚举,但它在用于宏调用的 DSL 中不可见。
基于宏的代码的这种潜在的不可渗透性超出了其他工程师的范围:分析和与 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!
宏,其中 DSL 使用 Group
枚举变体名称(如 Informational
),既没有 Group::
前缀,也没有 use
语句,这可能会使某些代码导航工具感到困惑。
甚至编译器本身也没什么帮助:它的错误消息并不总是遵循宏使用和定义的链条。(但是,工具生态系统 [参见第 31 条] 的某些部分可以帮助解决这个问题,例如之前使用的 David Tolnay 的 cargo-expand
。)
使用宏的另一个可能的缺点是代码膨胀的可能性——一行宏调用可能会导致生成数百行代码,这些代码对于粗略查看代码来说是不可见的。当代码首次编写时,这不太可能成为问题,因为那时需要代码,并且可以节省相关人员自己编写代码的时间。但是,如果代码随后不再需要,则可能删除大量代码并不那么明显。
建议
尽管上一节列出了宏的一些缺点,但当需要保持一致但无法以其他方式合并的不同代码块时,宏仍然是完成这项工作的正确工具:当只有宏才能确保不同的代码保持同步时,请使用宏。
当需要压缩样板代码时,宏也是可以使用的工具:对于无法合并为函数或泛型的重复样板代码,请使用宏。
为了减少对可读性的影响,请尽量避免宏中的语法与 Rust 的正常语法规则相冲突;要么让宏调用看起来像正常代码,要么让它看起来足够不同,这样就不会有人混淆这两者。特别是,请遵循以下准则:
- 尽可能避免插入引用的宏扩展 - 像
my_macro!(&list)
这样的宏调用比my_macro!(list)
更符合正常的 Rust 代码。 - 尽量避免在宏中使用非局部控制流操作,这样任何阅读代码的人都可以跟踪流程,而无需了解宏的细节。
这种对 Rust 式可读性的偏好有时会影响声明性宏和过程宏之间的选择。如果您需要为结构的每个字段或枚举的每个变体发出代码,则最好使用派生宏而不是发出类型的过程宏(尽管前面部分显示了示例)——它更符合惯用语,并使代码更易于阅读。
但是,如果您要添加具有非特定于项目的功能的派生宏,请检查外部包是否已经提供了您所需的功能(参见第 25 条)。例如,将整数值转换为类似 C 的枚举的适当变体的问题已经得到很好的解决:enumn::N
、num_enum::TryFromPrimitive
、num_derive::FromPrimitive
和 strum::FromRepr
都涵盖了这个问题的某些方面。
多写单元测试
利用工具生态的优势
建立持续集成(CI)系统
超越标准 Rust
Rust 工具链支持的范围远不止纯 Rust 应用程序代码,运行在用户空间中:
- 它支持交叉编译,其中运行工具链的系统(主机)与编译代码将在其上运行的系统(目标)不同,这使得它很容易定位嵌入式系统。
- 它支持通过内置 FFI 功能链接使用 Rust 以外的语言编译的代码。
- 它支持没有完整标准库 std 的配置,允许定位没有完整操作系统(例如,没有文件系统、没有网络)的系统。
- 它甚至支持不支持堆分配而只有堆栈的配置(通过省略使用标准 alloc 库)。
这些非标准 Rust 环境可能更难工作,可能不太安全——它们甚至可能不安全——但它们为完成工作提供了更多选择。
本书的这一章仅讨论了在这些环境中工作的一些基础知识。除了这些基础知识之外,您还需要查阅更多特定于环境的文档(例如 Rustonomicon)。
考虑使库代码no_std兼容
控制跨越外部功能接口边界的内容
首选bindgen而非手动外部函数接口映射
后记
希望这本书中的建议和推荐信息能帮助你成为一名流利、高效的Rust程序员。正如前言所描述的,这本书旨在涵盖此过程中的第二步,在您从核心Rust参考书中学习了基础知识后。您可以更进一步探索更多方向:
- 本书没有介绍Async Rust,但它可能是高效、并发的服务端应用程序所必需的。该在线文档介绍了异步,即将由Maxwell Flitton和Caroline Morton出版的Async Rust(O'Reilly,2024)也可能有所帮助。
- 从另一个方向来看,裸机Rust可能符合您的兴趣和要求。这超出了第33条中对no_std的介绍,进入了一个没有操作系统也没有内存分配的世界。Comprehensive Rust在线课程的裸机Rust部分在这里提供了很好的介绍。
- 无论你的兴趣是底层还是高层,第三方开源生态crates.io都值得探索和贡献。像blessed.rs或lib.rs这样的总结摘要可以帮助你探索大量的可能性。
- Rust论坛,如Rust语言论坛或Reddit的r/Rust,可以提供帮助,并包含了以前提过的问题和回答的可搜索索引。
- 如果你发现自己依赖的现有库不是用Rust编写(根据第34条),你可以用Rust(RiiR)重写它。但不要低估重现经过实战考验的成熟代码库所需的努力。
- 随着你对Rust的掌握越来越熟练,Jon Gjengset的《Rust for Rustaceans》(No Starch,2022)是Rust更高级方面的必备参考。
祝你好运!