第 8 条:熟悉引用和指针类型

在一般的编程中,引用(reference) 是一种间接访问数据结构的方式,它与拥有该数据结构的变量是分开的。在实践中,引用 通常由 指针(pointer) 来实现。指针 是一个数字,它的值是数据结构的变量在内存中的地址。

现代 CPU 通常会对指针施加一些限制:内存地址应该处于有效的内存范围内(虚拟内存或物理内存),并且可能需要对齐(例如,一个4字节的整数值可能只有在其地址是4的倍数时才能访问)。

然而,高级编程语言通常会在其类型系统中编码更多关于指针的信息。在 C 衍生的语言(包括 Rust )中,指针中有一个类型,该类型表示期望在所指向的内存地址存储哪种类型数据结构。这允许通过代码解释在该地址以及随后内存中的内容。

这种基本的指针信息-假定的内存位置和预期的数据结构布局-在 Rust 中被表示为一个裸指针(raw point)。然而,安全的 Rust 代码不使用裸指针,因为 Rust 提供了更丰富的引用和指针类型,这些类型提供了额外的安全保证和约束。这些引用和指针类型是本节的主题;裸指针则留待第16条讨论(该节讨论 unsafe 代码)。

Rust引用

在 Rust 中,最常见的指针类型是 引用,用 &T 表示,其中 T 是任意类型。尽管在底层这是一个指针值,但编译器会确保在使用时遵循一些规则:

  • 始终指向有效且对齐正确的类型 T 的实例。
  • 被引用数据的生命周期(在第14条中介绍)必须比 引用 本身的生命周期更长。
  • 遵守借用检查规则(在第15条中解释)。

这些额外的约束总是隐含在 Rust 中的 引用 中,因此 裸指针 通常很少出现。

Rust 引用必须指向有效、正确对齐的项的约束,与 C++ 的引用类型相同。然而,C++ 没有生命周期的概念,因此允许使用悬空引用而导致错误1

// C++
const int& dangle() {
  int x = 32; // on the stack, overwritten later
  return x; // return reference to stack variable!
}

Rust 的借用和生命周期检查会让等价的代码甚至不能编译:

#![allow(unused)]
fn main() {
fn dangle() -> &'static i64 {
    let x: i64 = 32; // 在栈上
    &x
}
}
error[E0515]: cannot return reference to local variable `x`
   --> src/main.rs:477:5
    |
477 |     &x
    |     ^^ returns a reference to data owned by the current function

Rust 的引用 &T 允许只读访问底层元素(大致相当于 C++ 的const T&)。一个允许修改底层元素的可变引用写为 &mut T,同样也遵循第 15 项讨论的借用检查规则。这种命名方式反映了 Rust 和 C++ 之间略微不同的思维方式:

  • 在 Rust 中,默认情况下变量是只读的,可写类型需要特别标记(用 mut)。
  • 在 C++ 中,默认情况下引用是可写的,只读类型需要特别标记(用 const)。 编译器会将使用引用的 Rust 代码转换为使用简单指针的机器码,在 64 位平台上这些指针的长度为 8 个字节(本节假设一直如此)。

例如,一对局部变量以及对它们的引用:

#![allow(unused)]
fn main() {
pub struct Point {
    pub x: u32,
    pub y: u32,
}

let pt = Point { x: 1, y: 2 };
let x = 0u64;
let ref_x = &x;
let ref_pt = &pt;
}

可能最终在栈上布局如图1-2所示。

img 图1-2.带有指向局部变量的指针的栈布局

Rust 引用可以指向位于上的元素。Rust 默认情况下会在栈上分配内存,但是 Box<T> 指针类型(大致相当于 C++ 的 std::unique_ptr<T>) 会强制分配到堆上,这意味着分配的元素可以比当前代码块的作用域更长寿。本质上,Box<T> 也是一个简单的8字节指针值(64 位平台):

注意

  • 栈是一种快速但有限制的内存区域,函数调用时分配,函数结束后释放。
  • 堆是一种更大但速度较慢的内存区域,程序可以显式分配和释放内存。
#![allow(unused)]
fn main() {
let box_pt = Box::new(Point { x: 10, y: 20 });
}

这在图1-3中被描述。 img 图1-3.栈上的 Box 指针指向堆上的 struct

指针特征

期望一个引用参数,如 &Point 的方法也可以接受一个 &Box<Point>

fn show(pt: &Point) {
    println!("({}, {})", pt.x, pt.y);
}
show(ref_pt);
show(&box_pt);
(1, 2)
(10, 20)

这之所以可能,因为 Box<T> 实现了 Deref 特征(Trait),Target = T。某个类型实现这个特征意味着该特征的 deref() 方法可以用于创建对 Target 类型的引用。还有一个等效的 DerefMut 特征,它会生成对 Target 类型的可变引用。

Deref/DerefMut 特征有点特别,因为Rust编译器在处理实现它们的类型时有特定的行为。当编译器遇到解引用表达式(例如,*x),它会根据解引用是否需要可变访问来查找并使用这些特征的实现。这种 Deref 转换允许各种智能指针类型像普通引用一样工作,它是 Rust 中少数允许隐式类型转换的机制之一(如第5条所述)。

作为一个技术细节,理解为什么 Deref 特征不能对目标类型是泛型的(Deref<Target>)是很值得的。如果它们是,那么某些类型 ConfusedPtr 就可以同时实现 Deref<TypeA>Deref<TypeB>,这将使编译器无法为 *x 这样的表达式推导出唯一的类型。因此,目标类型被编码为一个名为 Target 的关联类型。 这种技术细节与另两个标准指针特征 AsRefAsMut 形成对比。这些特征不会在编译器中引起特殊行为,但允许通过对其特征函数(as_ref()as_mut())的显式调用进行引用或可变引用的转换。转换的目标类型被编码为类型参数(例如,AsRef<Point>),这意味着一个容器类型可以支持多个目标类型。

例如,标准 String 类型实现了 Deref 特征,Target = str,这意味着像 &my_string 这样的表达式可以强制转换为类型 &str。但它也实现了以下特征:

  • AsRef<[u8]>,允许转换为字节切片 &[u8]
  • AsRef<OsStr>,允许转换为OS字符串
  • AsRef<Path>,允许转换为文件系统路径
  • AsRef<str>,允许转换为字符串切片 &str(与 Deref 相同)

胖指针类型

Rust有两个内置的胖指针类型:切片(Slice)和特征(Trait)对象。这些类型的行为像指针,但它们持有关于指向对象的额外信息。

切片

第一种胖指针类型是切片:它引用某个连续值集合的子集。切片由一个(没有所有权的)简单指针和一个长度字段组成,因此大小是简单指针的两倍(在 64 位平台上为 16 字节)。切片的类型写为 &[T] - 它表示对 [T] 的引用,[T]是类型 T 的连续值集合的概念类型。

概念类型 [T] 不能被实例化,但是有两种常见的容器实现了它。第一种是数组:一个连续的值集合,其大小在编译时是已知的。

一个有5个值的数组将始终有5个值。因此,切片可以引用数组的一个子集(如图1-4所示):

#![allow(unused)]
fn main() {
let array: [u64; 5] = [0, 1, 2, 3, 4];
let slice = &array[1..3];
}

img 图1-4.指向栈数组的栈切片

连续值的另一种常见容器是 Vec<T>。这像数组一样持有连续的值集合,但与数组不同,Vec中的值的数量可以增长(例如,用 push(value) )或缩小(例如,用 pop() )。 Vec 的内容保存在堆上(这允许其大小发生变化),并且总是连续的,因此切片可以引用 Vec 的子集,如图 1-5 所示:

#![allow(unused)]
fn main() {
let mut vector = Vec::<u64>::with_capacity(8);
for i in 0..5 {
    vector.push(i);
}
let vslice = &vector[1..3];
}

img 图1-5.指向堆上的Vec内容的栈切片

表达式 &vector[1..3] 的底层有很多细节,所以值得将其拆解成多个部分:

  • 1..3 部分是一个范围表达式(range expression);编译器会将其转换为 Range<usize> 类型的实例,该类型包含下限(1)但不包含上限(3)。
  • Range 类型实现了特征,该特征描述了对任意类型 T 的切片的索引操作(因此Output类型为[T])。
  • vector[]部分是一个索引表达式(indexing expression);编译器将其转换为在 vector 上调用 Index 特征的 index 方法,并附加一次解引用(即 *vector.index() )。2
  • vector[1..3]会调用 Vec<T>Index<I> [实现],它要求 ISliceIndex<[u64]> 的一个实例。这是因为 Range<usize> 对于任何 T 类型来说,包括 u64,都实现了 SliceIndex<[T]> 特征。
  • &vector[1..3] 取消了解引用,最终得到的表达式类型为 &[u64]

特征对象

第二种内置的胖指针类型是特征对象:它引用实现了特定特征的某个元素。特征对象由一个指向该元素的简单指针和一个指向类型 vtable 的内部指针共同构成,大小为 16 字节(在 64 位平台上)。类型的 vtable 存储了该类型所实现特征的方法实现的函数指针,从而允许在运行时进行动态分配(第12条)。3

例如,定义一个简单的特征:

#![allow(unused)]
fn main() {
trait Calculate {
    fn add(&self, l: u64, r: u64) -> u64;
    fn mul(&self, l: u64, r: u64) -> u64;
}
}

以及一个实现该特征的结构体:

struct Modulo(pub u64);

impl Calculate for Modulo {
    fn add(&self, l: u64, r: u64) -> u64 {
        (l + r) % self.0
    }
    fn mul(&self, l: u64, r: u64) -> u64 {
        (l * r) % self.0
    }
}

let mod3 = Modulo(3);

我们可以将 Modulo 转换为特征对象 &dyn Calculate。dyn 关键字强调了涉及动态分配的事实:

// Need an explicit type to force dynamic dispatch.
let tobj: &dyn Calculate = &mod3;
let result = tobj.add(2, 2);
assert_eq!(result, 1);

如图 1-6 所示的相应内存布局。 img 图1-6.指向具体项和 vtable 的 Trait 对象

持有特征对象的代码可以通过 vtable 中的函数指针调用特征的方法,并将元素指针作为 &self 参数传递;有关更多信息和建议,请参考第12条

更多指针特征

之前的一节描述了两组特征(Deref/DerefMutAsRef/AsMut),它们用于处理可以轻松转换为引用的类型。除此之外,还有一些标准特征在处理类似指针的类型时也会发挥作用,这些类型可以来自标准库或用户定义的类型。

其中最简单的是 Pointer 特征,它用于格式化指针值以供输出。这对于底层开发调试很有帮助,编译器在遇到{:p}格式说明符时会自动使用这个特征。

更有趣的是 BorrowBorrowMut 特征,它们各自只有一个方法(分别为 borrowborrow_mut )。这些方法的签名与相应的 AsRef/AsMut 特征方法相同。

通过标准库提供的泛型实现,可以看到这些特征之间的主要意图差异。给定任意Rust引用 &T,标准库都提供了 AsRefBorrow 的泛型实现;同样地,对于可变引用 &mut T,也提供了 AsMutBorrowMut 的泛型实现。

然而,Borrow 还有一个针对(非引用)类型的泛型实现:impl<T> Borrow<T> for T。这意味着,一个接受 Borrow 特征的方法可以同样处理 T 的实例以及对 T 的引用:

#![allow(unused)]
fn main() {
fn add_four<T: std::borrow::Borrow<i32>>(v: T) -> i32 {
    v.borrow() + 4
}
assert_eq!(add_four(&2), 6);
assert_eq!(add_four(2), 6);
}

标准库的容器类型有更贴合实际的 Borrow 用法。例如,HashMap::get 使用 Borrow 以便无论通过值还是引用作为键,都可以方便地检索条目。

ToOwned 特征建立在 Borrow 特征的基础上,增加了一个 to_owned() 方法,该方法生成一个新的底层类型的所有权项。这是 Clone 特征的泛化: 其中 Clone 特征特别要求一个Rust引用 &T,而 ToOwned 则处理实现 Borrow 的内容。

这为以统一的方式处理引用和移动项提供了几种可能性:

  • 一个操作某些类型引用的函数可以接受 Borrow,以便它也可以用移动项以及引用调用。
  • 一个操作某些类型拥有项的函数可以接受 ToOwned,以便它也可以用引用项以及移动项调用;传递给它的任何引用都将被复制到本地拥有项中。

此时值得一提的是 Cow 类型,虽然它不是指针类型,因为它提供了处理相同情况的替代方式。Cow 是一个枚举,可以持有拥有的数据或对借用数据的引用。这个特殊的名称代表“写入时复制”(clone-on-write):一个 Cow 输入可以作为借用数据一直保持,直到它需要被修改,但在数据需要被更改的时,它才会变成一个拥有的副本。

智能指针类型

Rust 标准库包含多种在某种程度上类似于指针的类型,这些类型在一定程度上充当指针的角色,并由之前描述的标准库特质进行调解。每种智能指针类型都具有一些特定的语义和保证,正确组合它们可以对指针的行为进行细粒度控制,但缺点是由此产生的类型乍一看可能会让人不知所措(比如 Rc<RefCell<Vec<T>>>)。

第一个智能指针类型是 Rc<T>,它是对某个项的引用计数指针(大致类似于 C++中的 std::shared_ptr<T>)。它实现了所有与指针相关的特征,因此在许多方面的行为类似于 Box<T>

这对于可以通过不同途径访问同一个项的数据结构很有用,但它消除了 Rust 围绕所有权的核心规则之一,每个项只有一个所有者。放宽这条规则意味着现在可能会发生数据泄漏:如果变量 A 有一个指向变量 B 的 Rc 指针,而变量 B 有一个指向 A 的 Rc 指针,那么这对变量将永远不会被释放。4换句话说:你需要 Rc 来支持循环数据结构,但缺点是现在你的数据结构中存在循环。

在某些情况下,可以通过使用相关的 Weak<T> 类型来降低数据泄漏的风险。Weak<T> 持有对底层数据的非所有权引用(弱引用,类似于 C++ 的 std::weak_ptr<T>)。持有弱引用不会阻止底层数据被删除(当所有强引用都被移除时),所以使用 Weak<T> 需要升级为 Rc<T> ——这可能会失败。

在底层,Rc(目前)实现为一对引用计数和被引用的项,所有这些都存储在堆上(如图 1-7 所示):

#![allow(unused)]
fn main() {
use std::rc::Rc;
let rc1: Rc<u64> = Rc::new(42);
let rc2 = rc1.clone();
let wk = Rc::downgrade(&rc1);
}

img 图1-7. Rc和Weak指针都指向堆上同一个项

当强引用计数降至零时,底层数据将被释放,但只有弱引用计数也降至零时,才会释放 bookkeeping 结构。

Rc 本身允许你以不同的方式访问一个项,但是当你访问该项时,只有在没有其他方式访问该项时(即,没有其他现存的 RcWeak 引用指向同一项),你才能修改它(通过 get_mut)。这很难协调,因此 Rc 通常与 RefCell 结合使用。

下一个智能指针类型是RefCell<T>,它放宽了只能由所有者或持有唯一可变引用的代码修改数据的规则(参考第15条)。这种内部可变性带来了更大的灵活性,例如允许特征实现修改内部,即使方法签名只允许 &self。然而,这也带来了代价:除了额外的存储开销(需要一个额外的 isize 用于跟踪当前的借用,如图 1-8 所示),正常的借用检查也从编译时转移到了运行时:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
let rc: RefCell<u64> = RefCell::new(42);
let b1 = rc.borrow();
let b2 = rc.borrow(); 
}

img 图1-8. 引用了 RefCell 容器的 Ref 借用

这些运行时的借用检查意味着用户使用 RefCell 时,必须在两个不愉快的选项之间做出选择:

  • 接受借用可能失败的操作,并处理来自 try_borrow[_mut] 的 Result 值。
  • 使用所谓不会失败的借用方法 borrow[_mut],并接受在运行时由于借用规则不合规而引发 panic 的风险(参考第18条)。

无论哪种情况,这种运行时检查意味着 RefCell 不实现任何标准指针特征;相反,它的访问操作返回一个实现了这些特征的 Ref<T>RefMut<T> 智能指针类型。

如果底层类型 T 实现了 Copy 特征(表示按位快速复制生成一个有效的项;参考第10条),那么 Cell<T> 类型允许以更少的开销进行内部修改——get(&self) 方法复制出当前值,set(&self, val) 方法设置一个新值进去。Cell 类型在 RcRefCell 实现中都被内部使用,用于共享跟踪可以修改的计数器,而无需 &mut self

到目前为止描述的智能指针类型仅适用于单线程使用;它们的实现假设对其内部没有并发访问。如果不是这种情况,则需要包含额外同步开销的智能指针。

Arc<T> 线程安全 Rc<T> 等价实现,它使用原子计数器来确保引用计数保持准确。与 Rc一样,Arc 实现了所有各种与指针相关的特征。

然而,单独的 Arc 不允许对底层项目进行任何形式的可变访问。这是由 Mutex 类型引起的,它确保只有一个线程可以访问(无论是可变的还是不可变的)底层项目。与 RefCell 一样,Mutex 本身不实现任何指针特征,但它的 lock() 操作返回一个实现了 Deref[Mut] 的类型的值:MutexGuard

如果读者(读操作线程)可能多于写者(写操作线程),则更推荐使用 RwLock 类型,因为它允许多个读者并行访问底层项目,前提是当前没有(单个)写者。

在任何情况下,Rust 的借用和线程规则都强制在多线程代码中使用这些同步容器中的一个(但这仅能防止共享状态并发的一些问题;参考第17条)。

对于其他智能指针类型,有时也可以应用相同的策略——查看编译器拒绝的内容,并替换为它建议的内容。然而,更快、更少挫折的方法是理解不同智能指针的行为含义。借用来自Rust编程语言第一版的示例:

  • Rc<RefCell<Vec<T>>> 持有一个具有共享所有权 (Rc) 的 Vec,其中 Vec 可以作为一个整体被修改。
  • Rc<Vec<RefCell<T>>> 也持有具有共享所有权的 Vec,但这里 Vec 中的每个单独的条目都可以独立于其他条目进行修改。

涉及的类型精确描述了这些行为。

注释

1

尽管有来自现代编译器的警告

2

可变表达式的等价特征是IndexMut

3

这是一种简化解释;完整的虚函数表(vtable)还包含类型的尺寸和对齐信息,以及一个drop()函数指针,用于安全地释放底层对象

4

请注意,这并不影响 Rust 的内存安全保证:项目仍然是安全的,只是无法访问