进阶篇 (1讲)
12|智能指针:从所有权看智能指针
进阶篇--外功招式,掌握实用的基础设施,提高编程效率。
在所有权视角下学习 Rust 中的智能指针。
智能指针
指针是什么。
指针和指针的类型
一个变量,里面存的是另一个变量在内存里的地址值,这个变量就叫指针。引用( & 号)就是一种指针。
- 引用是必定有效的指针,一定指向一个目前有效(没有被释放掉)的类型实例。
- 指针不一定是引用。Rust 中,还有其他类型的指针存在。
引用分为不同的类型,单独的 & 符号本身没有什么意义,但它和其他类型组合起来就能形成各种各样的引用类型。如:
- &str 是字符串切片引用类型。
- &String 是所有权字符串的引用类型。
- &u32 是 u32 的引用类型。
注:&str、&String、&u32 都是一个整体。
三种引用类型,类型是不同的。同一种引用类型的实例,如 &10u32 和 &20u32,类型相同。
指向不同类型实例的指针,类型也有区别,叫指针的类型。
智能指针
Rust 中指针的概念非常灵活,如,可以是一个结构体类型,只要其中的一个字段存储其他类型实例的地址,然后对这个结构体实现一些 Rust 标准库里提供的 trait,就可以把它变成指针类型。这种指针可以在传统指针的基础上添加一些额外信息,比如放在额外的一些字段中;也可以做一些额外操作,比如管理引用计数,资源自动回收等。从而显得更加智能,所以被叫做智能指针。
String 和 Vec<T> 智能指针。标准库中 String 的定义和 Vec<T> 的定义。
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<T> 实际都定义为结构体。
注:Rust 中智能指针的概念也直接来自于 C++。C++ 里面有unique_ptr、shared_ptr。
智能指针可以让代码的开发相对来说容易一些。
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<T> 智能指针。
Box<T>
Box<T> 是一个类型整体,智能指针 Box<T> 可把资源强行创建在堆上,并获得资源的所有权,让资源的生命期得以被程序员精确地控制。
注:堆上的资源,默认与整个程序进程的存在时间一样久。
用 Box<T> 处理前面那个示例。
fn foo() -> Box<u32> {
let i = 100u32;
Box::new(i)
}
fn main() {
let _i = foo();
}
通过 Box,把栈上 i 的值,强行 copy 了一份并放在堆上某个地址,然后 Box 指针指向这个地址。
从函数中返回结构体的 Box 指针。
struct Point {
x: u32,
y: u32
}
fn foo() -> Box<Point> {
let p = Point {x: 10, y: 20}; // 这个结构体的实例创建在栈上
Box::new(p)
}
fn main() {
let _p = foo();
}
Point 的实例 p 实际是创建在栈上的。通过 Box::new(p),把 p 实例强行按位复制了一份,放到了堆上,记为 p’。然后 foo() 函数返回,把 Box 指针实例 move 给了 _p。之后,_p 拥有了对 p’ 的所有权。
Box<T> 中的所有权分析
编译期间已知尺寸的类型实例会默认创建在栈上。Point 有两个字段:x、y,尺寸是固定的。它的实例会被创建在栈上。第 7 行的 p 拥有这个 Point 实例的所有权。注意 Point 并没有默认实现 Copy,虽然它的尺寸是固定的。
创建 Box<Point> 实例的时候会发生所有权转移:资源从栈上 move 到了堆上,原来栈上的那片资源被置为无效状态,因此下面的代码编译不会通过。
struct Point {
x: u32,
y: u32
}
fn foo() -> Box<Point> {
let p = Point {x: 10, y: 20};
let boxed = Box::new(p); // 创建Box实例
let q = p; // 这一句用来检查p有没有被move走
boxed
}
fn main() {
let _p = foo();
}
// 提示
error[E0382]: use of moved value: `p`
--> src/main.rs:9:13
|
7 | let p = Point {x: 10, y: 20};
| - move occurs because `p` has type `Point`, which does not implement the `Copy` trait
8 | let boxed = Box::new(p); // 创建Box实例
| - value moved here
9 | let q = p; // 这一句用来检查p有没有被move走
| ^ value used here after move
之所以会发生所有权这样的转移,是因为 Point 类型本身就是 move 语义的。作为对照,我们来看一个示例。
fn foo() -> Box<u8> {
let i = 5;
let boxed = Box::new(i); // 创建Box实例
let q = i; // 这一句用来检查i有没有被move走
boxed
}
fn main() {
let _i = foo();
}
编译通过。
执行 Box::new() 创建 Box 实例时,具有 copy 语义的整数类型和具有 move 语义的 Point 类型行为不一样。整数会 copy 一份自己,Point 实例会把自己 move 到 Box 里面去。
创建好 Box 实例,这个实例就具有了对里面资源的所有权了,它是 move 语义的,示例。
fn foo() -> Box<u8> {
let i = 5;
let boxed = Box::new(i); // 创建Box实例
let q = i; // 这一句用来检查i有没有被move走
let boxed2 = boxed; // 这一句检查boxed实例是不是move语义
boxed
}
fn main() {
let _i = foo();
}
// 提示
error[E0382]: use of moved value: `boxed`
--> src/main.rs:6:5
|
3 | let boxed = Box::new(i); // 创建Box实例
| ----- move occurs because `boxed` has type `Box<u8>`, which does not implement the `Copy` trait
4 | let q = i; // 这一句用来检查i有没有被move走
5 | let boxed2 = boxed; // 这一句检查boxed实例是不是move语义
| ----- value moved here
6 | boxed
| ^^^^^ value used here after move
验证了我们刚才的说法。
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() 的逆操作,整个过程是相反的。
对于具有 copy 语义的 u8 类型来说,解引用回来后,boxed 还能使用,看示例
fn main() {
let boxed: Box<u8> = Box::new(5);
let val: u8 = *boxed;
println!("{:?}", val);
println!("{:?}", boxed); // 用于u8类型,解引用后,boxed实例还能用
}
// 输出
5
5
对于具有 move 语义的类型来说,会发生所有权的转移。如:
#[derive(Debug)]
struct Point {
x: u32,
y: u32
}
fn main() {
let p = Point {x: 10, y: 20};
let boxed: Box<Point> = Box::new(p);
let val: Point = *boxed; // 这里做了解引用,Point实例回到栈上
println!("{:?}", val);
println!("{:?}", boxed); // 解引用后想把boxed再打印出来
}
出错,提示 *boxed 已经 move 了。
error[E0382]: borrow of moved value: `boxed`
--> src/main.rs:13:22
|
10 | let val: Point = *boxed;
| ------ value moved here
...
13 | println!("{:?}", boxed);
| ^^^^^ value borrowed here after move
如果 Box<T> 的 T 是 move 语义的,对 Box 实例做解引用操作,会把这个 Box 实例的所有权释放。
Box<T> 实现了 trait
Box<T> 的明确性,里面的资源一定在堆上。一种类型 T,被 Box<T> 的过程叫盒化(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!("I'am a method of Point.");
}
}
fn main() {
let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
boxed.play(); // 点操作符触发deref
println!("{:?}", boxed);
}
// 输出
I'am a method of Point.
Point { x: 10, y: 20 }
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 }
Box<T> 的 Clone
Box<T> 能否 Clone,要看 T 是否实现了 Clone,也要把 T 的资源克隆一份。示例。
#[derive(Debug, Clone)]
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 mut another_boxed = boxed.clone(); // 克隆
*another_boxed = Point{x: 100, y: 200}; // 修改新的一份值
println!("{:?}", boxed); // 打印原来一份值
println!("{:?}", another_boxed); // 打印新的一份值
}
// 输出
Point { x: 10, y: 20 }
Point { x: 100, y: 200 }
Box<T> 作为函数参数
可以把 Box<T> 作为参数传入函数。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
fn foo(p: Box<Point>) { // 这里参数类型是 Box<Point>
println!("{:?}", p);
}
fn main() {
foo(Box::new(Point {x: 10, y: 20}));
}
// 输出
Point { x: 10, y: 20 }
&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 }
第 18 行,两次解引用,第一次是对 &mut 做的,第二次是对 Box<T> 做的。
Box<Self>
类型的方法可以用 self、&self、&mut self 三种形态传入 Self 参数。其中第一种 self 形态还有一种变体 Box<Self>,示例。
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play_ref(&self) {
println!("I'am play_ref of Point.");
}
fn play_mutref(&mut self) {
println!("I'am play_mutref of Point.");
}
fn play_own(self) {
println!("I'am play_own of Point.");
}
fn play_boxown(self: Box<Self>) { // 注意这里
println!("I'am play_boxown of Point.");
}
}
fn main() {
let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
boxed.play_ref();
boxed.play_mutref();
boxed.play_boxown();
// boxed.play_own(); // play_boxown()和 play_own() 只能同时打开一个
}
注意,play_boxown() 和 play_own() 只能同时打开一个,为什么呢?你思考一下。
结构体中的 Box
Box<T> 作为类型,可以出现在 struct 里,示例。
struct Point {
x: u32,
y: u32,
}
struct Triangle {
one: Box<Point>, // 三个字段类型都是 Box<Point>
two: Box<Point>,
three: Box<Point>,
}
fn main() {
let t = Triangle {
one: Box::new(Point {
x: 10,
y: 10,
}),
two: Box::new(Point {
x: 20,
y: 20,
}),
three: Box::new(Point {
x: 10,
y: 20,
}),
};
}
Box<dyn trait>
第 10 讲的 trait object,代表一种类型,可以代理一批其他的类型。但 dyn trait 本身的尺寸在编译期是未知的,所以 dyn trait 的出现总是要借助于引用或智能指针。
Box<dyn trait> 是最常见的,比 &dyn trait 更常见。原因 Box<dyn Trait> 拥有所有权,方便而 &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));
}
doit() 函数能接收 Atype、Btype、Ctype 三种不同类型的实例。
如果 dyn trait 出现在结构体里,那么 Box<dyn trait> 形式就比 &dyn trait 形式要方便得多。如,下面示例里的结构体字段类型是 Box<dyn TraitA>,能正常编译。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
struct MyStruct {
x: Box<dyn TraitA> // 结构体的字段类型是 Box<dyn TraitA>
}
fn main() {
let a = Atype;
let t1 = MyStruct {x: Box::new(a)};
let b = Btype;
let t2 = MyStruct {x: Box::new(b)};
let c = Ctype;
let t3 = MyStruct {x: Box::new(c)};
}
而下面这个示例,结构体字段类型是 &dyn TraitA,就没办法通过编译。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
struct MyStruct {
x: &dyn TraitA // 结构体字段类型是 &dyn TraitA
}
// 报错
error[E0106]: missing lifetime specifier
--> src/lib.rs:12:8
|
12 | x: &dyn TraitA
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
11 ~ struct MyStruct<'a> {
12 ~ x: &'a dyn TraitA
|
这个错误涉及到第 20 讲引用的生命期的概念,现在我们不去深究。
另一种智能指针 Arc<T>。
Arc<T>
Box<T> 是单所有权或独占所有权模型的智能指针,而 Arc<T> 是共享所有权模型的智能指针,也就是多个变量可以同时拥有一个资源的所有权。和 Box<T> 一样,Arc<T> 也会保证被包装的内容被分配在堆上。
clone
Arc 的主要功能是和 clone() 配合使用。
在 Arc 实例上每一次新的 clone() 操作,总是会将资源的引用数 +1,而保持原来那一份资源不动,这个信息记录在 Arc 实例里面。每一个指向同一个资源的 Arc 实例走出作用域,就会给这个引用计数 -1。直到最后一个 Arc 实例消失,目标资源才会被销毁释放。
示例。
use std::sync::Arc;
#[derive(Debug)] // 这里不需要目标type实现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
和 Box<T> 一样,Arc 也可以用在方法中的 self 参数上面,作为所有权 self 的一个变体形式。
继续扩展上面的代码。
use std::sync::Arc;
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
impl Point {
fn play_ref(&self) {
println!("I'am play_ref of Point.");
}
fn play_mutref(&mut self) {
println!("I'am play_mutref of Point.");
}
fn play_own(self) {
println!("I'am play_own of Point.");
}
fn play_boxown(self: Box<Self>) { // 注意这里
println!("I'am play_boxown of Point.");
}
fn play_arcown(self: Arc<Self>) { // 注意这里
println!("I'am play_arcown of Point.");
}
}
fn main() {
let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
boxed.play_ref();
boxed.play_mutref();
boxed.play_boxown();
// boxed.play_own(); // play_boxown()和 play_own() 只能同时打开一个
let arced: Arc<Point> = Arc::new(Point{x: 10, y: 20});
arced.play_ref();
// arced.play_mutref(); // 不能用
// arced.play_own(); // 不能用,Arc<T> 中的T无法被移出
arced.play_arcown();
}
// 输出
I'am play_ref of Point.
I'am play_mutref of Point.
I'am play_boxown of Point.
I'am play_ref of Point.
I'am play_arcown of Point.
不能通过 Arc<> 直接修改里面类型的值,也不能像 Box<> 的解引用操作那样,把里面的内容从 Arc<> 中移动出来。你可以试着打开示例里注释掉的几行看看 Rustc 小助手的提示信息。
Arc<dyn trait>
可以把前面 Box<dyn trait> 的示例改编成 Arc<T> 的。
use std::sync::Arc;
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
struct MyStruct {
x: Arc<dyn TraitA>
}
fn main() {
let a = Atype;
let t1 = MyStruct {x: Arc::new(a)};
let b = Btype;
let t2 = MyStruct {x: Arc::new(b)};
let c = Ctype;
let t3 = MyStruct {x: Arc::new(c)};
}
值的修改
多所有权条件下,怎么修改 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 的开发效率。
思考题你试着打开示例中的这两句,看看报错信息,然后分析一下是为什么?
// arced.play_mutref(); // 不能用
// arced.play_own(); // 不能用
答:
Arc本质上是个引用,所以不允许同时存在可变引用或者移动。
作者回复: 👍
play_boxown() 和 play_own() 只能同时打开一个,两个都是所有权转移了,所以就只能调用一次。
作者回复: 👍
arced.play_mutref(); // Arc<T>没有实现智能指针的DerefMut trait arced.play_own(); // 不能从Arc<T> 中移出值,除非T实现了Copy
作者回复: 对的,自己动手试验,得到最准确最全面的信息。👍