0%

Rust 权威指南(14):智能指针

指针是一个通用的概念,它指代那些包含内存地址的变量。Rust 中最常用的指针就是之前介绍过的 引用。引用是用 & 符号表示的,会借用它所指向的值。引用除了指向数据之外没有任何其他功能,也没有任何开销,它是 Rust 中最常见的指针。而智能指针(smart pointer)则是一些数据结构,它们的行为类似于指针但是拥有额外的元数据和附件功能。Rust 标准库中不同的智能指针提供了比引用更为强大的功能。

在拥有所有权和借用概念的 Rust 中,引用和智能指针之间还有另外一个差别:引用是只借用数据的指针,而相反地,大多数智能指针本身就拥有它们指向的数据。其实 StringVec<T> 也可以算作智能指针,它们都拥有一片内存区域并允许用户对其进行操作,它们还拥有元数据(例如容量等),并提供额外功能。

通常会使用结构体来实现智能指针,但区别于一般结构体的地方在于它们会实现 Deref 和 Drop 这两个 trait:

  • Deref trait 使得智能指针结构体的实例拥有与引用一致的行为,它使得你可以编写出同时用于引用和智能指针的代码
  • Drop trait 则使你可以自定义智能指针离开作用域时运行的代码

使用 Box<T> 在堆上分配数据

Box<T> 是最简单直接的智能指针,它可以让我们将数据存储在堆上,并在栈中保留一个指向堆数据的指针。Box<T> 常被用于以下场景:

  • 当拥有一个无法在编译时确定大小的类型,但又想在一个要求固定尺寸的上下文环境中使用这个类型的值时
  • 当需要传递大量数据的所有权,但又不希望产生大量数据的复制行为时
  • 当希望拥有一个实现了指定 trait 类型的值时,但又不关心具体的类型时

如下是一个简单示例:

1
2
3
4
fn main() {
let b = Box::new(5);
println!("b = {}", b);
}

代码中用来访问 Box<T> 数据的语法与访问栈数据的语法非常类似。另外,和其他任何拥有所有权的值一样,Box<T> 会在离开自己作用域时被释放,被释放的东西除了存储在栈上的指针,还有它指向的那些堆数据。

使用 Box<T> 定义递归类型

Rust 在编译时必须知道每一种类型占据的空间大小,但是递归类型却无法在编译时确定具体大小。由于 Box<T> 拥有固定的大小,因此我们可以在递归类型定义中使用 Box<T> 即可。

如下尝试用一个枚举定义一个链接列表:

1
2
3
4
5
6
7
8
9
10
enum List {
Cons(i32, List),
Nil,
}

use List::{Cons, Nil};

fn main() {
let l = Cons(1, Cons(2, Cons(3, Nil)));
}

这段代码无法编译通过,因为无法确定 List 类型的具体大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +

For more information about this error, try `rustc --explain E0072`.

其实 Rust 已经给出了建议,indirection 意味着需要改变数据结构来存储指向这个值的指针,而不是直接存储这个值。因为 Box<T> 是一个指针,所以 Rust 总是可以确定 Box<T> 的具体大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum List {
Cons(i32, Box<List>),
Nil,
}

use List::{Cons, Nil};

fn main() {
let l = Cons(1,
Box::new(Cons(2,
Box::new(Cons(3,
Box::new(Nil))))));
}

除了间接访问内存和堆分配之外,Box<T> 并没有提供其他特殊功能。Box<T> 属于智能指针的一种,因为它实现了 Deref trait,并允许我们将 Box<T> 的值当做引用对待。当一个 Box<T> 离开作用域时,由于它实现了 Drop trait,因此 Box<T> 指向的堆数据会自动释放。

通过 Deref trait 将智能指针视作常规引用

实现 Deref trait 使得我们可以自定义 解引用运算符(dereference operator)* 的行为。通过实现 Deref,可以将智能指针视为常规引用来进行处理,这也就意味着原先处理引用的代码可以不加修改地用于处理智能指针。

使用解引用运算符跳转到指针指向的值

常规引用就是一种类型的指针。如下代码中,对于变量 y,我们需要使用解引用运算符 * 来跟踪引用并跳转到它指向的值(即解引用)。

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = &x;

assert_eq!(5, x);
assert_eq!(5, *y);
}

由于数值和引用是两种不同的类型,所以不能直接比较这两者。对于上述代码,也可以使用 Box<T> 来替换引用:

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = Box::new(x);

assert_eq!(5, x);
assert_eq!(5, *y);
}

定义自己的智能指针

接下来将实现一个类似于 Box<T> 类型的智能指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct MyBox<T>(T);

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
type Target = T;

fn deref(&self) -> &T {
&self.0
}
}

impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}

fn main() {
let x = 5;
let y = MyBox::new(x);

assert_eq!(5, x);
assert_eq!(5, *y);
}
  • 定义了一个名为 MyBox 的结构体,它带有泛型参数 T,它也是一个拥有 T 类型单元素的元组结构体
  • 为了支持使用 * 完成解引用操作,需要实现 Deref trait。标准库的 Deref trait 要求我们实现一个 deref 方法,该方法会借用 self 并返回一个指向内部数据的引用
    • type Target = T 定义了 Deref trait 的一个关联类型,关联类型是一种有些不同的泛型参数定义方式,后续会详细介绍
    • deref 方法中填入了 &self.0,因此返回一个指向值的引用,所以允许调用者通过 * 运算符访问值

在没有 Deref trait 的情形下,编译器只能对 & 形式的常规引用执行解引用操作。deref 方法可以让编译器从任何实现了 Deref 类型中获取值,并且能够调用 deref 方法获得一个可以进行解引用的引用。上述代码 *y 等效于:

1
*(y.deref())

需要注意,这种将 * 运算符替换为 deref 方法和另外一个朴素 * 运算符的过程,对代码中的每一个 * 都只会进行一次

函数和方法的隐式解引用转换

解应用转换是 Rust 为函数/方法的参数提供的一种便捷特性。当某个类型实现了 Deref trait 时,它能够将 T 的引用转换为 T 经过 Deref 操作后生成的引用。当我们将某个类型的值引用作为参数传递给函数或者方法时,但传入的类型和参数类型不一致时,解引用转换就会自动发生。编译器会插入一系列的 deref 方法调用来将我们提供的类型转换为参数所需的类型

Rust 通过实现解引用转换功能,使能在方法或函数调用时无需多次显式地使用 & 和 * 运算符来进行引用和解引用操作。如下是一个示例:

1
2
3
4
5
6
7
8
fn hello(name: &str) {
println!("Hello {}", name)
}

hello("rust");
let m = MyBox::new(String::from("rust"));
hello(&m);
hello(&(*m)[..]);

由于 &m 是一个指向 MyBox 值的引用,因为我们的 MyBox<T> 实现了 Deref trait,所以 Rust 可以通过 deref 来将 &MyBox<String> 转换为 &String。又因为标准库 String 提供的 Deref 操作实现会返回字符串切片,所以 Rust 可以继续调用 deref 来将 &String 转换为 &str,最终与 hello 函数定义相匹配。

&(*m)[..] 则是复杂写法:

  • *mMyBox<String> 进行解引用得到 String
  • 通过 &[..] 来获取包含整个 String 的字符串切片

只要代码涉及的类型实现了 Deref trait,Rust 就会自动分析类型并不断尝试插入 Deref::deref 来获得与参数类型匹配的引用。该过程在编译时完成,所以解引用转换不会带来任何运行时开销。

解引用转换与可变性

使用 Deref trait 能够重载不可变引用的 * 运算符,类似地,DerefMut trait 可以重载可变引用的 * 运算符。Rust 会在类型与 trait 满足下面三种情形时执行解引用转换:

  • 当 T:Deref<Target=U> 时,允许 &T 转换为 &U
  • 当 T:DerefMut<Target=U> 时,允许 &mut T 转换为 &mut U
  • 当 T:Deref<Target=U> 时,允许 &mut T 转换为 &U

第三种情况说明 Rust 可以将一个可变引用自动的转换为一个不可变引用。但这个过程不可逆,即不可变引用永远不能转换为可变引用。

借助 Drop trait 在清理时运行代码

另一个对智能指针十分重要的 trait 就是 Drop,它允许我们在变量离开作用域时执行某些自定义操作。可以为任意一个类型执行一个 Drop trait,它尝尝被用来释放诸如文件、网络等连接资源。几乎所有智能指针的实现都会用到这一 trait。

Drop trait 要求实现一个接收 self 可变引用作为参数的 drop 函数。如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct CustomSmartPointer {
data: String,
}

impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data {}", self.data);
}
}

fn main() {
let c = CustomSmartPointer{data: String::from("c stuff")};
let d = CustomSmartPointer{data: String::from("d stuff")};
println!("CustomSmartPointer created")
}
  • Drop trait 已经被包含在了预导入模块中,所以不需要显式导入作用域
  • Rust 实例离开作用域时自动调用了我们编写的 Drop 代码,变量的丢弃顺序与创建顺序相反

我们不能禁止一个值离开作用域时自动插入 Drop,也无法手动调用 Drop trait 的 drop 方法(也被称为析构方法),但是可以调用标准库中的 std::mem::drop 函数来提前清理某个值。std::mem::drop 函数接收需要提前丢弃的值作为参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct CustomSmartPointer {
data: String,
}

impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data {}", self.data);
}
}

fn main() {
let c = CustomSmartPointer{data: String::from("c stuff")};
let d = CustomSmartPointer{data: String::from("d stuff")};
println!("CustomSmartPointer created");
drop(c);
println!("CustomSmartPointer c dropped before main end");
}

运行结果如下:

1
2
3
4
CustomSmartPointer created
Dropping CustomSmartPointer with data c stuff
CustomSmartPointer c dropped before main end
Dropping CustomSmartPointer with data d stuff

所有权系统会保证所有的引用有效,而 drop 只会在确定不再使用这个值时被调用一次。

基于引用计数的智能指针 Rc

Rust 提供了一个名为 Rc<T> 的类型来支持多重所有权。Rc 是 Reference counting(引用计数)的缩写。它的实例会在内部维护一个用于记录值引用次数的计数器,从而确认该值是否仍然在使用。如果一个值的引用计数为 0,则该值可以被安全释放,而不会触发引用失效问题。Rc<T> 只能用于单线程场景。

如下代码会出现编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum List {
Cons(i32, Box<List>),
Nil,
}


use crate::List::{Cons, Nil};

fn main() {
let a = Cons(5,
Box::new(Nil));
let b = Cons(3, Box::new(a));
let c = Cons(5, Box::new(a));
}

由于整个 a 列表会在我们创建 b 列表时被移动至 b 中,所以 b 列表持有了 a 列表的所有权,之后 c 就无法再持有 a 了。虽然我们可以改变 Cons 的定义让它持有一个引用而不是所有权,并为其指定对应的生命周期参数。但是这个生命周期参数会要求列表中所有元素的存活时间都至少和列表本身一样长。

如下使用 Rc<T> 来解决这个问题,每次调用 Rc::clone 都会使得引用计数增加,而 Rc 智能指针中的数据只有在引用计数器减少为 0 时才会被真正清理掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum List {
Cons(i32, Rc<List>),
Nil,
}


use crate::List::{Cons, Nil};

use std::rc::Rc;

fn main() {
let a = Rc::new(Cons(5,
Rc::new(Nil)));
let b = Cons(3, Rc::clone(&a));
let c = Cons(5, Rc::clone(&a));
}
  • Rc<T> 没有包含在预导入模块中,所以需要使用 use 语句将它引入作用域中
  • 可以使用 Rc::strong_count 来读取引用计数
  • Rc<T> 的 Drop 实现会在 Rc<T> 离开作用域时自动将引用计数减 1

使用 Rc<T> 可以让单个值拥有多个所有者,而引用计数机制则保证了这个值会在其拥有的所有者存活时一直有效,并在所有者离开作用域时被自动清理。需要注意,Rc<T> 通过不可变引用使你可以在程序的不同部分之间共享只读数据。

RefCell<T> 和内部可变性模式

内部可变性(interior mutability)是 Rust 的设计模式之一,它允许你在只持有不可变引用的前提下对数据进行修改,通常类似行为会被借用规则所禁止。为了能够改变数据,内部可变性模式在它的数据结构中使用了 unsafe 代码来绕过 Rust 正常的可变性和借用规则。假如我们能够保证自己的代码在运行时符合借用规则,那么即使编译器无法在编译阶段保证符合借用规则,也能使用那些采取了内部可变性模式的类型。实现过程中不安全的代码被封装在安全的 API 内,而类型本身从外部看来依然是不可变的。RefCell<T> 就是一种使用了内部可变性模式的类型。

对于使用一般引用和 Box 的代码,Rust 会在编译阶段强制代码遵守借用规则,而对于使用 RefCell 的代码,Rust 则只会在运行时检查这些规则,并在出现违反借用规则的情况下触发 panic 来提前中止程序。编译期检查借用规则可以帮助我们在开发阶段尽早暴露出问题,而且不会带来任何运行时开销。而运行时检查借用规则可以使我们实现某些特定的内存安全场景,即便这些场景无法通过编译时检查。与 Rc<T> 类似,RefCell<T> 也只能用于单线程场景中。

内部可变性模式允许用户更改一个不可变值的内部数据。某些情况下,我们会需要一个值在对外保持不可变性的同时能够在方法内部修改自身。除了这个值本身的方法,其余的代码依然不能修改这个值。RefCell 就是获得这种内部可变性的一种方法。但是 RefCell 并没有完全绕开借用规则:虽然使用内部可变性通过了编译阶段的借用检查,但是借用检查的工作仅仅是被延后到了运行阶段。如果违反了借用规则,就会得到一个 panic! 运行时错误。

我们在创建不可变和可变引用时分别使用语法 &&mut,对于 RefCell<T> 则是通过如下方式:

  • 通过调用 RefCell<T> 的 borrow 方法,可以获取 RefCell 内部值的不可变引用
  • 通过调用 RefCell<T> 的 borrow_mut 方法可以获取 RefCell 内部值的可变引用

其实这两个方法分别返回的是 Ref<T>RefMut<T> 这两种智能指针,这两种智能指针都实现了 Deref,所以可以当做一般的引用来对待。RefCell 会记录当前活跃的 Ref 和 RefMut 智能指针个数,基于这些信息来维护和编译器同样的借用规则:在任何一个给定的时间只允许拥有多个不可变借用或者一个可变借用。违背借用规则时会在运行时触发 panic。

使用 RefCell 可以让我们在不可变的环境中修改自身数据,只要能够正确做出取舍,就可以借助 RefCell 来完成某些常规引用无法完成的功能。

将 RefCell 和 Rc 结合使用是一种常见用法,Rc 允许多个持有者持有同一数据,但是只能提供针对数据的不可变访问,如果我们在 Rc<T> 内存储了 RefCell<T>,那么就可以拥有多个所有者且能够进行修改的值了。

1
2
3
4
5
6
7
8
9
10
11
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
let value = Rc::new(RefCell::new(5));

let a = Rc::clone(&value);

*a.borrow_mut() = 10;
println!("{:?}", value);
}

标准库还提供了其他一些类型来实现内部可见性,例如 Cell<T> 或者 Mutex<T>,具体参考标准库文档。

循环引用会造成内存泄漏

Rust 提供的内存安全机制可以让我们很难在程序中制造出 永远不会释放的内存空间(即内存泄漏),但也并非是不可能的。通过使用 Rc<T>RefCell<T> 看到 Rust 是会出现内存泄漏的:我们能够创建出相互引用成环状的实例,由于环中每一个指针的引用计数都不可能减少为 0,所以对应的值也不会被丢弃,从而造成内存泄漏。

如果程序中存在 RefCell 包含了 Rc 或其他联用了内部可变性与引用计数的情形,那么你就需要自行确保不会在代码中创建出循环引用。

我们可以通过 Rc::downgrade 函数创建出 Rc 实例中值的弱引用。使用 Rc<T> 的引用来调用 Rc::downgrade 函数会返回一个类型为 Weak<T> 的智能指针,它会让 Rc<T> 的 weak_count 计数增加 1,而不会改变 strong_count。Rc 并不会在执行清理操作前要求 weak_count 为 0。弱引用不会造成循环引用。

由于我们无法确定 Weak<T> 引用的值是否已经被释放,所以在使用 Weak<T> 指向的值之前,需要使用 Weak<T> 的 upgrade 方法来确认它所指向的值依然存在,它返回一个 Option 类型,所以 Rust 能够确保不会 Some 和 None 两个分支都得到妥善处理,不会产生无效指针。

通过在形成的环状实例中让某些指向关系持有所有权,并让另外某些指向关系不持有所有权,可以避免循环引用