一. 简述
Rust中最常用的指针就是我们在第四章学习过的引用过。引用使用&
符号表示的,会借用它所指向的值。智能指针则是一些数据结构,它们的行为类似于指针但拥有额外的元数据和附加功能。智能指针不是Rust所独有的。Rust标准库中不同的人智能指针提供了比引用更为强大的功能。
二. Box
Box
是最为简单直接的一种智能指针,它的类型被写作Box<T>
。Box
使我们可以将数据存储在堆上,并在栈中保留一个指向堆数据的指针。
Box
除了将它们的数据存储在堆上而不是栈上,Box
没有任何其他的性能开销。
2.1. 使用场景
Box
常常被用于下面的场景中:
- 当我们拥有一个无法在编译时确定大小的类型,但是又想在一个要求固定尺寸的上下文环境中使用这个类型的值时;
- 当我们需要传递大量数据的所有权,但是又不希望产生大量数据的复制行为时;
- 当我们希望拥有一个实现了指定trait的类型值,但又不关心具体的类型时;
2.2. 存储数据
我们先看一下Box
如何在堆上存储数据,下面的例子中定义了一个持有Box
的值的变量data
,它指向了堆上的值5
。
Box
和其他任何拥有所有权的值一样,Box
会在离开自己的作用域时被释放。Box
被释放的东西除了有存储在栈上的指针,还有它指向的堆数据。
2.3. 存储递归数据
Rust必须在编译时知道每一种类型占据的空间大小,但有一种被称作递归的类型却无法在编译时确定大小。如下:
此时编译器会报如下错误:recursive type List has infinite size
。如果你有其他的语言的编程基础的话,这就是一种比较常见的递归结构。但是由于Rust
在编译时需要知道一种类型占据空间的大小,但是此时递归结构就无法确定类型的大小了。此时我们就可以通过Box
修复这个问题,因为Box
是大小可确定的。
三. Deref和Drop
上面的Box
属于智能指针,是因为它实现了Deref
,并允许我们将Box
的值当作引用来对待。当一个Box
值离开作用域时,因为它实现了Drop
,所以Box
指向堆数据会自动的被清理释放掉。
3.1. Deref将智能指针视作常规引用
实现Deref
使我们可以自定义解引用运算符*
的行为(这一符号也也同时被用作乘法运算符和通配符)。通过实现Deref
,我们可以将智能指针视作常规引用来进行处理。
常规引用就是一种类型的指针。我们可以理解为一个箭头,它会指向存储在别处的某个值,下面看一个通过解引用运算符跟踪数据的值:
接着我们看一下Box
的解引用,此时解引用运算符能够正常工作。
此时解引用运算符可以使用是因为Box
实现了Deref
,标准库中的Deref
要求我们现实一个deref
方法,该方法会借用self
并返回一个指向内部数据的引用。在没有Deref
的情形下,编译器只能对&
形式的常规引用执行解引用操作。deref
方法使编译器可以从任何实现了Deref
的类型中获取值,并能够调用deref
方法来获取一个可以进行解引用操作的引用。上面例子中的*y
会被Rust
隐式地展开为:
Rust
使用*运算符
来替代deref
方法和另一个朴素的解引用操作
,这样我们就不用考虑是否需要调用deref
方法了。
3.1.1. 自定义智能指针
下面我们实现一个自己的智能指针,实现deref
方法,该方法会借用self
并返回一个指向内部数据的引用。
3.1.2. 函数和方法的隐式解引用转换
解引用转换是Rust
为函数和方法的参数提供了一个种便携特性。当某个类型T
实现了Deref
,它能够将T
的引用转换为T
经过Deref
操作后生成的引用。当我们将某个特定类型的值引用作为参数传递给函数或方法,但是传入的类型与参数类型不一致时,解引用转换就会自动发生。例子:
借助于解引用转换特性,我们这里可以将字符串切片作为参数传入hello
函数,也可以将MyBox<String>
值的引用传入hello
函数。将参数&m
传入hello
函数,而&m
正是一个指向MyBox<String>
值的引用。因为MyBox
实现了Deref
,所有Rust
可以通过deref
来将&MyBox<String>
转换为&String
,标准库为String
提供的Deref
实现会返回字符串切片,所以Rust
可以继续调用deref
来将&String
转换为&str
,并最终与hello
函数的定义相匹配。
如果Rust
没有实现解引用转换功能,必须编写@1
这样,首先*m
将MyBox
进行解引用得到String
,然后通过&
和[..]
来获取包含整个String
的字符串切片以匹配hello
函数签名。整个过程代码编写非常不友好。
3.1.3. 解引用转换和可变性
使用Deref
能够重载不可变引用的*运算符。与之类似,使用DerefMut
能够重载可变引用的*
运算符。Rust
会在类型与trait
满足一下三种情形时执行解引用转换:
- 当
T: Deref<Target=U>
时,允许&T
转换为&U
; - 当
T: DerefMut<Target=U>
时,允许&mut T
转换为&mut U
; - 当
T: Deref<Target=U>
时,允许将&mut T
转化为&U
;
前两种情形除可变性之外是完全相同的。其中,情形一意味着,如果T实现了类型U
的Deref
,那么&T
就可以直接转换为&U
。情形二意味着,同样的解引用转换过程会作用于可变引用。
情形三则意味着,Rust会将一个可变引用自动转换为一个不可变的引用。但是这个过程中不可变引用永远不可能转换为可变引用。
3.2. Drop清理运行时代码
下面我们在介绍一个对于智能指针十分重要的trait
就是Drop
,它允许我们在变量离开作用域的时候执行某些自定义操作。我们可以为任意类型实现一个Drop,它常常被用来释放诸如文件、网络连接等资源。几乎每一种智能指针都会用到Drop。下面我们看一个例子:
当我们类型实例离开作用域时运行的逻辑都可以放在drop
函数体内,Rust
会自动调用我们在drop
方法中放置的代码来打印出最终的信息,而无需显示地调用drop
方法。编译执行输出:
Rust
在实例离开作用域时自动调用了我们编写的drop
代码。因为变量的丢弃顺序与创建顺序相反,所有c
在a
之前被丢弃。
在Rust
中我们是无法直接禁用自动drop
功能,因为Drop
存在就是为了完成自动释放的逻辑。但是我们倒是常常会碰到需要提前清理一个值的情形,例如使用智能指针来管理锁时,我们就需要强制运行drop
方法来提前释放锁。此时我们就可以调用标准库中std::mem::drop
函数来提前清理某个值。例子:
注意:我们是不能手动显示的调用
Drop
的drop
方法。
编译执行输出如下:说明在值离开作用域调用std::mem::drop
来显式地丢弃它。
四. Rc/RefCell/Weak
4.1. 基于引用计数的智能指针Rc<T>
Rust
提供了一个名为Rc<T>
的类型来支持多重所有权,它名称中的Rc
的缩写。Rc<T>
类型的实例会在内部维护一个用于记录值引用次数的计数器,从而确认这个值是否仍在使用。如果对一个值的引用次数为零,那么就可以意味着这个值可以安全地清理掉,而不会触发引用失效的问题。
当你希望将堆上的一些数据分享给程序的多个部分同时使用,而无法在编译期确定哪个部分会最后释放这些数据时,我们就可以使用Rc<T>
类型。相反地,如果我们能够在编译期确定那一部分会最后释放数据,那么就只需要让这部分代码称为数据的所有者即可,仅仅靠编译期的所有权规则也可以保证程序的正确性。例子:
注意:
Rc<T>
只能被用于单线程场景中。
由于Rc<T>
没有包含在预导入模块中,需要我们使用use
语句将它引入作用域。上面例子调用Rc::clone
不会执行数据的深拷贝操作,只会增加引用计数,并且这不会花费太多时间。深拷贝则常常需要花费大量时间来搬用数据。并且我们可以使用Rc::strong_count
方法获取引用计数。上面例子输出结果:
当变量c
离开作用域被丢弃时,引用计数为1。Rc<T>
的Drop
实现会在Rc<T>
离开作用域时自动将引用计数减1。
4.2. RefCell<T>和内部可变性模式
内部可变性是Rust
的设计模式之一,它允许你在只持有不可变引用的前提下对数据进行修改;通常情况下,类似的行为会被借用规则所禁止。内部可变性模式在它的数据结构中使用了unsafe
代码来绕过Rust
正常的可变性和借用规则。
在Rust
的借用规则是这样的:在任何给定的时间里,要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用;引用总是有效的。对于一般引用和Box<T>
的代码,Rust
会在编译阶段强制代码准守这些借用规则。但是对于RefCell<T>
的代码,Rust
则只会在运行时检查这些规则,并在出现违反借用规则的情况下触发panic
来提前终止程序。
将借用规则的检查放在编译期有许多优势:它不仅可以帮助我们在开发阶段尽早的暴露问题,而且不会带来任何运行时开销。
RefCell<T>
也只能使用在单线程场景中。强行将它运用于多线程场景会产生编译错误。下面列举上一些Box
、Rc
或者RefCell
选择依据:
-
Rc<T>
允许一份数据有多个所有者,而Box<T>
和RefCell<T>
都只有一个所有者; -
Box<T>
允许在编译时检查的可变或不可变借用,Rc<T>
仅允许编译时检查不可变借用,RefCell<T>
允许运行时检查的可变和不可变借用; - 由于
RefCell<T>
允许我们在运行时检查可变借用,所以即便RefCell<T>
本身是不可变的,我们仍然能够改变其中存储的值
4.2.1. RefCell修改不可变值
下面我们使用RefCell<T>
修改不可变值的例子:
下面我们在单元测试编写具体的Messenger
约束的实现:
上面的例子很明显是无法编译通过的,这时因为我们在Messenger
的约束中的send
方法中的参数是&self
,不可变引用;但是我们在send
的具体实现中我们却需要一个可变引用去修改Vec
中的数据。此时我们就可以使用RefCell<T>
进行修改修复这个问题,实现既不用修改约束的定义还可以实现sent_message
属性的修改。
上面代码中我们进行了4处修改,sent_message
的类型修改为RefCell<Vec<String>>
,在send
具体实现中使用borrow_mut
方法来获取RefCell<T>
内部值的可变引用,此时我们就可以在动态数组中push
字符串了。
4.2.2. RefCell详解
Rust
中不可变和可变引用分别使用语法&
和&mut
;对应RefCell<T>
而言,我们需要将borrow
和borrow_mut
方法来实现类似的功能,borrow
和borrow_mut
两个方法分别返回的是Ref<T
>和RefMut<T>
两种指针指针,它们都是实现了Deref
,所有我们可以将它们当作一般引用来对待。
RefCell<T>
会记录当前存在多少个活跃的Ref<T>
和RefMut<T>
智能指针。每次调用borrow
方法,RefCell<T>
会将活跃的不可变借用计数加1,并且在任何一个Ref<T>
值离开作用域被释放时,不可变借用计数减1。RefCell<T>
会基于这一技术来维护和编译器同样的借用检查规则:在任何一个给定的时间里,它只允许你拥有多个不可变借用或一个可变借用。
4.2.3. 实现一个拥有多重所有权的可变数据
将Rc<T>
和RefCell<T>
结合使用是一种很常见的用法。Rc<T>
允许多个所有者持有同一个数据,但是只能提供对数据的不可变访问。如果我们在Rc<T>
内存储RefCell<T>
,就可以定义处拥有多个所有者且能够对值进行修改。
在Rust
的标准库中还有其他的一些类型来实现内部可变性,例如与RefCell<T>
十分类似的Cell<T>
,Cell<T>
是通过复制来访问数据,还有后续章节中的Mutex<T>
,它被用于实现跨线程情形下的内部可变性模式。
4.3. Weak<T>避免循环引用
Rust
提供的内存安全保障是我们很难在程序中意外地制造出永远不会得到的释放的内存空间。但是这也不是不可能的。与数据竞争不同,在编译期彻底防止内存泄漏并不是Rust
作出的保证之一,这也意味着内存泄漏在Rust
中是一种内存安全行为。
4.3.1. 创建循环引用
我们可以通过使用Rc<T>
和RefCell<T>
看到Rust
是允许内存泄漏的,我们能够创建出相互引用成环状的实例,由于环中每一个指针的引用计数都不会减少到0
,所以对应的值也不会被释放丢弃,这就造成了内存泄漏。下面我们演示一个循环引用的例子。
我们先定一个枚举,并发实现一个tail
方法用以获取元素:
这里的RefCell<Rc<T>>
类型,这可以让我们灵活修改Cons
变体指向下一个List
值。下面看下如何构建一个循环引用的情况(其实很简单,就是相当于环形链表一样)
上面的例子我们实现了a、b和c的引用首尾相连的环,当此时因为首尾相连导致引用计数都不会是0,导致内存会永远以引用计数为1的状态保留在堆上。
一般来说解决循环引用的方案需要重新组织数据结构,它会将引用拆分为持有所有权和不持有所有权两种情形。
4.3.2. 使用Weak<T>替换Rc<T>
在上面的学习过程中,我们知道Rc::clone
来增加Rc<T>
实例的strong_count
引用计数,并指出在strong_count
为0
时才会被清理。此时我们还可以通过Rc::downgrade
函数创建出Rc<T>
实例中值的弱引用。这个函数会返回一个类型为Weak<T>
的智能指针,这一操作会让Rc<T>中的weak_count
计数增加1
,而不会改变strong_count
的状态;并且Rc<T>
在执行清理之前不会要求weak_count
必须减为0
。
强引用可以用来共享Rc<T>
实例的所有权,而弱引用则不会表达所有权关系。一旦强引用计数减为0
,任何由弱引用组成的循环就会被打破,所有弱引用不会造成循环引用。
注意:由于我们无法确认
Weak<T>
引用的值是否已经被释放了,所以我们在使用Weak<T>
指向的值之前确保它仍然存在。此时我们可以调用Weak<T>
实例上的upgrade
方法来验证,该函数返回的是一个Option<Rc<T>>
。
下面我们看一个创建树状数据结构的例子:
接着我们创建几个节点构建一颗简单的树,并查看它的强弱引用数量:
测试结果很明显,就不做解释了。