从所有权的视角再看 Rust 智能指针
指针和指针的类型
如果一个变量里面存的是另一个变量在内存里的地址,那么这个变量就被叫做指针,引用(用 & 号表示)就是一种指针。并且引用是必定有效的指针,它一定指向一个目前有效(比如没有被释放掉)的类型实例,而指针不一定是引用。也就是说,在 Rust 中还有一些其它类型的指针存在,我们下面就来学习其中一些。
这里要再次明晰一下引用的类型,引用分为不同的类型,单独的 & 符号本身没有什么意义,但是它和其他类型组合起来就能形成各种各样的引用类型。比如:
&str 是字符串切片引用类型
&String 是所有权字符串的引用类型
&u32 是 u32 的引用类型
注:&str、&String、&u32 都是一个整体。
这三种都是引用类型,作为引用类型,它们之间是不同的。但是同一种引用类型的实例,比如 &10u32 和 &20u32,它们的类型是相同的。指针其实也类似,指向不同类型实例的指针,它的类型也是有区别的,这叫做指针的类型。
智能指针
Rust 中指针的概念非常灵活,比如它可以是一个结构体类型,只要其中的一个字段存储其它类型实例的地址,然后对这个结构体实现一些 Rust 标准库里提供的 trait,就可以把它变成指针类型。这种指针可以在传统指针的基础上添加一些额外信息,比如放在额外的一些字段中,也可以做一些额外操作,比如管理引用计数,资源自动回收等。从而显得更加智能,所以被叫做智能指针。
String 和 Vec 就是一种智能指针,我们来看标准库代码中 String 和 Vec 的定义。
pub struct String {
vec: Vec<u8>,
}
pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
buf: RawVec<T, A>,
len: usize,
}
通过代码我们可以看到,String 和 Vec 实际都定义为结构体。
智能指针可以让代码的开发相对来说容易一些。经过前面的学习,我们知道 Rust 基于所有权出发,定义了一套完整的所有权和借用规则。很多我们习以为常的代码写法,在 Rust 中变成了"违法",这导致很多人觉得学习 Rust 的门槛很高,而智能指针可以在某些方面降低这种门槛。
前面我们看到过这种代码:
fn foo() -> u32 {
let i = 100u32;
i
}
fn main() {
let _i = foo();
}
我们看到,foo() 函数将 i 返回用的不是 move 行为,而是 copy 行为,将 100u32 这个值复制了一份,返回给外面的 _i。foo() 函数调用结束后,foo() 里的局部变量 i 被销毁。
fn foo() -> String {
let s = "abc".to_string();
s
}
fn main() {
let _s = foo();
}
上述代码可以在函数 foo() 里生成一个字符串实例,这个字符串实例资源在堆内存中分配,s 是 foo 函数里的局部变量,拥有字符串资源的所有权。在代码的最后一行,s 把所有权返回给外部调用者并传递给 _s。foo() 调用完成后,栈上的局部变量 s 被销毁。
这种写法可行是因为返回了资源的所有权,如果我们把代码里的 String 换成 &String,把 s 换成 &s 就不行了。
fn foo() -> &String {
let s = "abc".to_string();
&s
}
fn main() {
let _s = foo();
}
你可能会问,既然 String 资源本身是在堆中,为什么我们不能拿到这个资源的引用而返回呢?很简单,在 foo() 函数里,其实我们返回的并不是那个堆里字符串资源的引用,而是栈上局部变量 s 的引用。堆里的字符串资源由栈上的变量 s 管理,而 s 在 foo() 函数调用完成后就被销毁了,堆里的字符串资源也一并被回收了(堆内存的生命周期和创建它的栈内存是绑定在一起的),所以刚刚那段代码当然行不通了。
同样的,下面这段代码也是不允许的。
fn foo() -> &u32 {
let i = 100u32;
&i
}
fn main() {
let _i = foo();
}
那么有什么办法能让这种意图变得可行呢?其实是有的,比如使用 Box 智能指针。
Box<T>
Box<T> 是一个类型整体,作为智能指针 Box<T> 可以把资源强行创建在堆上,并获得资源的所有权,让资源的生命期得以被程序员精确地控制。
此时堆上的资源,默认与整个程序进程的存在时间一样久。
我们来看使用 Box 如何处理前面那个示例。
fn foo() -> Box<u32> {
let i = 100u32;
Box::new(i)
}
fn main() {
let _i = foo();
}
通过 Box,我们把栈上 i 的值,强行 copy 了一份并放在堆上的某个地方,然后 Box 指针指向这个地址。虽然我们叫它指针,但它其实是一个结构体(也叫智能指针),类型为 Box<u32>,并且拥有堆上数据的所有权。而返回 Box<u32> 也仅仅是将所有权转移到了变量 main 函数里的变量 _i 上面,但堆数据还在。
当然返回一个整数 i 的指针确实没多大用,如果我们定义了一个结构体,可以采用类似的办法从函数中返回结构体的 Box 指针。
struct Point {
x: u32,
y: u32
}
fn foo() -> Box<Point> {
// 这个结构体的实例创建在栈上,因为它内部的字段都是可 Copy 的原生类型
// 但即便在栈上,由于它没有实现 Copy 语义,所以在赋值的时候依旧会转移所有权
// 因此是否会 Move 不取决于它的值是否在栈上,而是取决于它是否实现了 Copy
// 当然值全部在栈上是实现 Copy 的前提
let p = Point {x: 10, y: 20};
// 将数据拷贝到堆上,由于 Rust 不会自动深度拷贝,对于可 Copy 的类型来说,深浅拷贝是等价的
// 但对于没有实现 Copy 语义的类型来说,此时的浅拷贝等价于 Move
Box::new(p) // 从此刻开始 p 不再有效
}
fn main() {
let _p = foo();
}
编译期间已知尺寸的类型实例会默认创建在栈上。Point 有两个字段:x、y,它们的尺寸是固定的,都是 4 个字节,所以 Point 的尺寸就是 8 个字节,它的尺寸也是固定的,所以它的实例会被创建在栈上。注意:Point 并没有默认实现 Copy,虽然它的尺寸是固定的。
所以 Point 的实例 p 会创建在栈上,然后通过 Box::new(p) 把 p 实例强行按位复制了一份,并且放到了堆上。但 Point 没有实现 Copy,在创建 Box<Point> 实例的时候会发生所有权转移:资源从栈上 move 到了堆上,原来栈上的那片资源被置为无效状态。然后 foo() 函数返回,把 Box 指针实例 move 给了 _p。之后,_p 拥有了堆上数据的所有权。
Box<T> 的解引用
前面说了,创建一个 Box 实例把栈上的内容包起来,可以把栈上的值拷贝(移动)到堆上,比如:
let val: u8 = 5;
let boxed: Box<u8> = Box::new(val); // 这里 boxed 里面那个 u8 就是堆上的值
*还可以在 Box 实例上使用解引用符号 ,把里面的堆上的值再次移动回栈上,比如:
let boxed: Box<u8> = Box::new(5);
let val: u8 = *boxed; // 这里这个val整数实例就是在栈上的值
解引用是 Box::new() 的逆操作,可以看到整个过程是相反的。
另外我们说过 Rust 默认永远不会深度拷贝,只会浅拷贝,因此:
- 如果 val 实现了 Copy 语义,Box::new(val) 会将 val 拷贝一份到堆上,并返回持有所有权的智能指针,val 依旧保持有效。
- 如果 val 没有实现 Copy 语义,Box::new(val) 会将 val 移动到到堆上,并返回持有所有权的智能指针,val 不再有效。
我们知道,如果值实现了 Copy 语义,那么传递之后两个变量的值前后是独立的,互不影响,因此这个过程叫做复制。如果值没有实现 Copy,那么会转移所有权,因此这个过程叫做移动。但 Copy 和 Move 其实是等价的,都是将栈上数据拷贝一份,只不过根据传递后的两个变量是否有效,而将这个过程区分成了复制和移动。
同样的,如果对 Box<T> 解引用,那么会将 T 拷贝或移动到栈上。
fn main() {
// 5u8 分配在堆上,但它是可 Copy 的
let boxed = Box::new(5u8);
// 会将 Box 里面的值浅拷贝一份到栈上,因为 val 是栈上的局部变量
// 但由于 u8 具有 Copy 语义,浅拷贝之后彼此独立
// 因此我们说 *boxed 会将堆上的值复制到栈上
let val = *boxed;
// val 和 boxed 均有效
println!("{} {}", val, boxed); // 5 5
// 原本 String 的实际文本是存储在堆上,但结构体(元数据)存储在栈上
// 而现在结构体和实际文本都存储在堆上,不过这里的 boxed 是存储在栈上的
let boxed = Box::new(String::from("古明地觉"));
// 注意:元素必须有一个已知的大小,所以 Box 里面存放的依旧是结构体本身,至于堆上的动态文本数据永远是通过栈上的指针与之关联
// 任何动态可变的数据,它的可变部分都不会放在结构体里面,而是在结构体内部维护一个指针,这个指针指向一片内存区域
// 该区域负责存放动态数据,如果满了,那么只需要申请一片更大的内存,改变指针的指向即可,结构体本身的地址是不需要改变的
// 解引用时,同样会将 Box 里面的值浅拷贝一份到栈上,但 String 没有实现 Copy 语义
// 这里只是将结构体本身拷贝到栈上,结构体内部的指针指向的堆区动态数据则没有拷贝
// 浅拷贝之后,原来 Box 里面的值就不能用了,因此这里我们可以说 *boxed 会将堆上的值(字符串结构体)移动到栈上
let val = *boxed;
println!("{}", val); // 古明地觉
// boxed; boxed 此时不再有效
}
如果 Box<T> 的 T 是 Copy 语义的,那么对这个 Box 实例做解引用操作,不影响原有的 Box 实例。但如果是 Move 语义的,那么解引用操作会把这个 Box 实例的所有权释放。
*需要注意:上面的 boxed 之所以可以移动,是因为 boxed 是智能指针。但如果 boxed 只是一个普通引用,那么就不可以这么做了,Rust 会提示我们无法移动。如果是可 Copy 的,那么浅拷贝一份,这个没有问题。但如果不是可 Copy 的,就会报错:告诉我们指定类型没有实现 Copy trait,而不会发生移动。
因为普通引用,如果通过 * 将所有权转移,那么会导致原有变量不可用。而我们之所以要拿它的引用,就是为了让它保持有效,于是就产生了矛盾,因此 Rust 提示不允许移动。
总之,如果要转移所有权,那么需要将变量本身赋值给别的变量,明确表示要转移。而对引用进行解引用这种方式是不允许转移的,它只能拷贝,因此当无法转移时会提示你该类型没有实现 Copy trait。
关于这些细节,我们不用太担心能不能一次性掌握好,因为用错的时候,Rustc 小助手会贴心地准确提示我们,所以不要有心理负担,慢慢熟悉了就好。
Box<T> 实现了 trait
Box<T> 的好处在于它的明确性,它里面的资源一定在堆上,所以我们就不用再去关心资源是在栈上还是堆上这种细节问题了,类型被 Box<> 包起来的过程就叫作该类型的盒化(boxed)。
Rust 在标准库里为 Box<T> 实现了 Deref、Drop、AsRef<T> 等 trait,所以 Box<T> 可以直接调用 T 实例的方法,访问 T 实例的值。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play(&self) {
println!("{:?}", self);
println!("I'am a method of Point.");
}
}
fn main() {
let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
boxed.play(); // 点操作符触发deref
/*
I'am a method of Point.
Point { x: 10, y: 20 }
*/
// 等价于如下,先通过解引用拿到 Point 实例,再将它的引用传给 self 参数
(*boxed).play();
/*
I'am a method of Point.
Point { x: 10, y: 20 }
*/
// 注意:这里虽然使用了 *boxed,但我们并没有将它赋值给别的变量,所以 boxed 依然有效
}
另外,既然 Box<T> 拥有对 T 实例的所有权,那么也可以对 T 实例进行写操作。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
fn main() {
let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
*boxed = Point { // 这一行,使用解引用操作更新值
x: 100,
y: 200
};
println!("{:?}", boxed); // Point { x: 100, y: 200 }
}
*使用 boxed 更新的时候,只需要更新 T 即可,这里的 boxed 仍是 Box<T> 类型。
Box<T> 的 Clone
Box<T> 能否 Clone,取决于 T 是否实现了 Clone,因为我们也需要把 T 的资源克隆一份。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
fn main() {
// 虽然 i32 是可 Copy 的,但我们将它的值拷贝到了堆上
let n1 = Box::new(1);
// 所以 Box<T> 不是可 Copy 的,我们必须 clone 一份,否则 n1 不再保持有效
let mut n2 = n1.clone();
println!("{} {}", n1, n2); // 1 1
*n2 = 666;
println!("{} {}", n1, n2); // 1 666
// Box<T> 在 clone 的时候会创建一个新的 Box,并将 T 深度拷贝一份
// 但不是所有的 Box<T> 都可以 clone,它取决于 T 是否可以 clone
let p = Box::new(Point { x: 1, y: 2 });
// p.clone(); 不合法,因此 T 不可以 clone,编译器建议我们给 Point 加上 #[derive(Clone)]
}
比较简单。
&Box<T>
Box<T> 本身作为一种类型,对它做引用操作当然是可以的。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play(&self) {
println!("I'am a method of Point.");
}
}
fn main() {
let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
boxed.play(); // 调用类型方法
let y = &boxed; // 取 boxed 实例的引用
y.play(); // 调用类型方法
println!("{:?}", y);
/*
I'am a method of Point.
I'am a method of Point.
Point { x: 10, y: 20 }
*/
}
在示例中,boxed 是一个所有权型变量,y 是一个引用型变量。它们都能调用到 Point 类型上的方法。当然,对 Box 实例做可变引用(&mut)也是可以的。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play(&self) {
println!("I'am a method of Point.");
}
}
fn main() {
let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
let y = &mut boxed; // 这里 &mut Box<Point>
y.play(); // 调用类型方法
println!("{:?}", y); // 修改前的值
**y = Point {x: 100, y: 200}; // 注意这里用了二级解引用
println!("{:?}", y); // 修改后的值
/*
I'am a method of Point.
Point { x: 10, y: 20 }
Point { x: 100, y: 200 }
*/
}
注意里面做了两次解引用,第一次是对 &mut 做的,第二次是对 Box 做的。
Box<dyn trait>
回忆一下之前说的 trait object,它代表一种类型,这种类型可以代理一批其他的类型。但是 dyn trait 本身的尺寸在编译期是未知的,所以 dyn trait 的出现总是要借助于引用或智能指针。而 Box<dyn trait> 是最常见的,甚至比 &dyn trait 更常见。原因就是 Box<dyn Trait> 拥有所有权,这就是 Box<T> 方便的地方,而 &dyn Trait 不拥有所有权,有的时候就没那么方便。
我们来看使用 Box<dyn trait> 做函数参数的一个示例。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(x: Box<dyn TraitA>) {}
fn main() {
let a = Atype;
doit(Box::new(a));
let b = Btype;
doit(Box::new(b));
let c = Ctype;
doit(Box::new(c));
}
这里使用智能指针和普通引用都可以的,但如果 dyn trait 出现在结构体里,那么 Box<dyn trait> 形式就比 &dyn trait 形式要方便得多,因为不需要生命周期。
Arc<T>
Box<T> 是单所有权或独占所有权模型的智能指针,而 Arc<T> 是共享所有权模型的智能指针,也就是多个变量可以同时拥有一个资源的所有权。和 Box<T> 一样,Arc<T> 也会保证被包装的内容被分配在堆上。
Arc 的主要功能是和 clone() 配合使用,在 Arc 实例上进行 clone() 操作时,总是会将资源的引用数 +1,而保持原来那一份资源不动,这个信息记录在 Arc 实例里面。每一个指向同一个资源的 Arc 实例走出作用域,就会给这个引用计数 -1。直到最后一个 Arc 实例消失,目标资源才会被销毁释放。可以看一下示例。
use std::sync::Arc;
#[derive(Debug)] // 这里不需要目标类型实现 Clone trait
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play(&self) {
println!("I'am a method of Point.");
}
}
fn main() {
let arced: Arc<Point> = Arc::new(Point{x: 10, y: 20});
let another_arced = arced.clone(); // 克隆引用
println!("{:?}", arced); // 打印一份值
println!("{:?}", another_arced); // 打印同一份值
arced.play();
another_arced.play();
let arc3_ref = &another_arced;
arc3_ref.play();
/*
Point { x: 10, y: 20 }
Point { x: 10, y: 20 }
I'am a method of Point.
I'am a method of Point.
I'am a method of Point.
*/
}
我们可以看到,相比于 Box<T>,Arc<T> 的 clone 不要求 T 实现了 Clone trait。Arc<T> 的克隆行为只会改变 Arc 的引用计数,而不会克隆里面的内容。由于不需要克隆原始资源,所以性能是很高的。
类似于 Box<T>,Arc<T> 也实现了 Deref、Drop、Clone 等 trait。因此,Arc<T> 也可以符合人类的习惯,访问到里面类型 T 的方法,Arc<T> 的不可变引用 &Arc<> 也可以顺利调用到 T 上的方法。
然后 Arc 也可以作用在 self 参数上面,以及用于包裹 dyn trait。但要注意的是,Arc 里面的值不允许修改,虽然 Arc<T> 是拥有所有权的,但 Arc<T> 不提供修改 T 的能力,这也是 Arc<T> 和 Box<T> 不一样的地方。后面我们在并发编程部分会讲到 Mutex、RwLock 等锁,想要修改 Arc 里面的内容,必须配合这些锁才能完成,比如 Arc<Mutex<T>>。
其实很好理解,共享所有权的场景下,如果任意一方能随意修改被包裹的值,那就会影响其他所有权的持有者,整个就乱套了。所以要修改的话必须引入锁的机制。
Arc<T> 与不可变引用 & 的区别
首先它们都是共享对象的行为,本质上都是指针。但 Arc<T> 是共享了所有权模型,而 & 只是共享借用模型。共享借用模型就得遵循借用检查器的规则——借用的有效性依赖于被借用资源的 scope,对于这个的分析是非常复杂的。而所有权模型是由自己来管理资源的 scope,所以处理起来比较方便。
小结
我们一起学习了最常用的两个智能指针:Box<T> 和 Arc<T>,当然 Rust 里还有很多智能指针,比如 Rc、Cell、RefCell 等等,每一种智能指针类型都有自己的特点。但是不管怎样,学习的方法都是一样的,那就是从所有权的视角去分析研究。
后面我们会看到,在智能指针的加持下,Rust 代码写起来会非常流畅,可以和 Java 不相上下。再结合 Rust 强大的类型系统建模能力,等你写得熟练之后,在中大项目中,使用 Rust 甚至会有超越 Python 的开发效率。
本文来自于:极客时间《Rust 语言从入门到实战》
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏