跳转到内容

Rust 智能指针

所有权和借用规则能应付大多数情况,但有几种真实场景没法简单地套进“只有一个所有者”:值的大小要到运行时才知道;数据确实需要不止一个所有者;或者你需要通过共享引用去修改一个值。智能指针(smart pointer)就是 Rust 针对这些情况给出的答案。

默认情况下,值存放在栈上,编译器需要提前知道它们的大小。Box<T> 把值移动到堆上,交给你一个指向它、大小固定的指针。当某个类型的大小在编译时无法确定时,它就派上用场了,最常见的就是递归类型:

fn main() {
let boxed = Box::new(5);
println!("{}", *boxed); // 用 * 取出里面的值
}

输出:

5
▶ 动手试一试

一个必须用 Box 的经典例子:包含自身的类型,比如链表节点:

enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
match list {
List::Cons(value, _) => println!("第一个值:{value}"),
List::Nil => println!("空列表"),
}
}

输出:

第一个值:1
▶ 动手试一试

如果没有 Box,Cons(i32, List) 需要无限大的空间来存储:一个 List 里面装着一个 List,里面又装着一个 List,没完没了。而 Box<List> 是一个大小固定的指针,这样编译器就能算出它需要多少空间了。

所有权规则说,一个值只有一个所有者。大多数时候这样正合适,但有时你确实需要让程序的多个部分共享同一份数据。Rc<T>(reference counted,引用计数)就允许这样做,它会记录当前有多少个所有者:

use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("共享数据"));
let b = Rc::clone(&a); // 不是深拷贝,只是多了一个所有者
let c = Rc::clone(&a);
println!("{a} {b} {c}");
println!("所有者数量:{}", Rc::strong_count(&a));
}

输出:

共享数据 共享数据 共享数据
所有者数量:3
▶ 动手试一试

Rc::clone 不会复制底层的数据,它只是把计数器加 1,再交给你一个指向同一个值的引用。只有当所有的所有者都离开作用域之后,数据才会被清理掉。

通常,借用规则是在编译时检查的:要么有多个只读引用,要么只有一个可变引用,两者不能同时存在。RefCell<T> 把这项检查挪到了运行时,这样即使你手里拿的看起来是一个共享的、不带 mut 的引用,也能修改里面的值:

use std::cell::RefCell;
fn main() {
let value = RefCell::new(5);
*value.borrow_mut() += 1;
println!("{}", value.borrow());
}

输出:

6
▶ 动手试一试

RefCell 最常和 Rc 搭配使用:Rc<RefCell<T>> 给你一个有多个所有者、而且仍然可以修改的值,这是单独一个 Rc<T> 做不到的。

别一上来就用智能指针。先用普通的所有权和借用,只有当编译器明确告诉你行不通时再考虑它们:递归类型(Box)、确实需要多个所有者的值(Rc),或者借用规则不允许的、通过共享引用进行的修改(RefCell)。

使用 Box 一节里的 List 枚举。构建一个包含 1、2、3 的链表,并写一个函数 fn sum(list: &List) -> i32,把所有值加起来。提示:对链表做 match。对于 Cons(value, rest),结果是 value 加上 rest 的和;对于 Nil,结果是 0。

查看答案
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(list: &List) -> i32 {
match list {
List::Cons(value, rest) => value + sum(rest),
List::Nil => 0,
}
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
println!("总和:{}", sum(&list));
}

输出:

总和:6
▶ 动手试一试

这个函数会对链表剩下的部分调用自身,直到遇到 Nil 为止。注意,在需要 &List 的地方,你可以直接传入 rest(它是 &Box<List>),Rust 会自动帮你透过 Box 拿到里面的值。

创建一份购物清单(一个 Vec<String>),让 alice 和 bob 两个人共同拥有,而且两个人都能往里面添加东西。每人添加一项,然后打印清单和所有者的数量。提示:把 Vec 包进 Rc<RefCell<...>>,给每个人一个 Rc::clone,再调用 .borrow_mut().push(...) 添加。

查看答案
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let list = Rc::new(RefCell::new(Vec::new()));
let alice = Rc::clone(&list);
let bob = Rc::clone(&list);
alice.borrow_mut().push(String::from("牛奶"));
bob.borrow_mut().push(String::from("面包"));
println!("{:?}", list.borrow());
println!("所有者数量:{}", Rc::strong_count(&list));
}

输出:

["牛奶", "面包"]
所有者数量:3
▶ 动手试一试

这里一共有三个所有者:list、alice 和 bob。三者都指向同一个 Vec,所以通过 alice 添加的东西,通过 bob 和 list 也能看到。这些变量都不需要 mut,因为修改由 RefCell 负责处理。

  • Box<T> 把值存放在堆上,用一个大小固定的指针指向它,递归类型离不开它。
  • Rc<T> 通过引用计数,让单线程代码中的同一个值可以有多个所有者;跨线程时用 Arc<T>。
  • RefCell<T> 在运行时而不是编译时检查借用规则,允许通过共享引用修改值,但违反规则时会 panic。
  • 只有当普通的所有权和借用不适合你的情况时,才去用它们。

小测验

1. 为什么像链表节点这样的递归类型需要 Box<T>?

2. Rc::clone(&a) 实际上做了什么?

3. RefCell<T> 在什么时候检查借用规则?

4. Rc<RefCell<T>> 能做到而单独的 Rc<T> 做不到的是什么?

得分: 0 / 4