条款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>>>
也持有一个具有共享所有权的向量,但这里向量中的每个条目都可以独立于其他条目进行修改。
所涉及的类型准确地描述了这些行为。