- 引言 — 什么是智能指针
- Box — 把值放到堆上
- Rc — 分享所有权
- Arc — 跨线程的共享所有权
- RefCell 与内部可变性 — 把规则搬到运行期
- Mutex — 线程安全的内部可变性
- Deref 强制转换 — 把智能指针当值来用
- Rc 循环引用与 Weak — 防止内存泄漏
- 该用什么、什么时候用 — 小结
- 结语
- 参考资料
引言 — 什么是智能指针
Rust 的所有权系统很强大,但有时会让人觉得过于严格。一个值只能有一个所有者,借用要遵循规则,大小必须在编译期确定。仅凭这些规则,很难表达递归数据结构,或是在多处共享的状态。填补这一空白的,正是智能指针(smart pointer)。
智能指针不是只装着地址的原始指针,而是一种在指向数据的同时,还携带额外元数据和行为的数据结构。它们大多拥有所指向的值,并在离开作用域时负责清理这个值。事实上,你每天都在用的 String 和 Vec<T>,因为拥有并管理堆内存,本身就已经是智能指针。
本文将依次梳理标准库中五个核心智能指针:Box、Rc、Arc、RefCell、Mutex。我们会通过示例了解每一个是为了解决什么问题而存在、什么时候该伸手去用它,以及用错了会掉进什么陷阱。
Box — 把值放到堆上
最简单的智能指针是 Box<T>。它做的事情正如其名:把值放到堆(heap)而不是栈上,栈上只留一个指向该堆地址的指针。所有权依然只有一个,规则也就很简单。
fn main() {
let boxed: Box<i32> = Box::new(5);
println!("{}", boxed); // 5 —— 多亏 Deref,可以像值一样使用
// boxed 离开作用域时,堆内存也会自动释放
}
只把一个整数放到堆上,本身几乎没有实际收益。Box 真正大显身手的场景另有其他。
第一,递归类型。Rust 需要在编译期知道类型的大小,而直接包含自身的类型,其大小会变成无穷大。下面的代码无法编译:
// 编译错误:recursive type has infinite size
enum List {
Cons(i32, List),
Nil,
}
List 里又完整地装了一个 List,大小自然无法确定。这时插入一个 Box,内层的值就放在堆上,栈上只留下指针(大小固定的值),大小便随之确定。
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
// 现在大小已确定,可以编译
}
第二,移动大值时。移动一个大结构体,需要在栈上整体复制字节,而用 Box 包一层后,值留在堆上,只需移动指针。
第三,trait 对象。要用同一个 trait 统一处理不同的具体类型,因为大小未知,所以要以 Box<dyn Trait> 的形式放到堆上、通过指针处理。这一点会在 Deref 一节继续展开。
Rc — 分享所有权
Box 建立在“只有一个所有者”的前提之上。但有时候,同一份数据需要被多处同时拥有——比如图中的一个节点被多个节点指向,或者多个数据结构共享同一个配置值。这时要用的就是 Rc<T>,引用计数(reference counting)指针。
Rc 会在值旁边放一个计数器,用来记录“现在这个值有多少个所有者”。调用 Rc::clone 并不会复制数据,只会把计数器加 1;当某个所有者离开作用域,计数器就减 1。计数器归零的那一刻,值才真正被释放。
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared config"));
println!("count = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // 不是复制数据,只是计数 +1
println!("count = {}", Rc::strong_count(&a)); // 2
{
let c = Rc::clone(&a);
println!("count = {}", Rc::strong_count(&a)); // 3
} // c 在这里离开作用域,计数 -1
println!("count = {}", Rc::strong_count(&a)); // 2
}
按照惯例,我们写 Rc::clone(&a) 而不是 a.clone(),为的是与深拷贝整个值的一般 clone 区分开来。这个调用不动值本身,只碰计数器,所以代价非常低。
有一点重要的限制:Rc 仅限单线程使用。因为它不是原子地更新计数器,如果多个线程同时操作计数,值就可能被破坏。所以 Rc 既不是 Send 也不是 Sync,一旦试图跨线程传递,编译器就会拦下来。如果需要跨线程共享,就要用下面的 Arc。
Arc — 跨线程的共享所有权
Arc<T> 是 Atomic Reference Counted 的缩写,其 API 与 Rc 几乎相同,只是用原子操作来更新计数器。因此可以在多个线程间安全共享。
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data = Arc::clone(&data); // 分给每个线程一份所有权
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("thread {i}: sum = {sum}");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
那为什么不干脆一直用 Arc 呢?原子操作比普通整数运算更昂贵。如果只在单线程内共享,就没有理由付出这笔成本,所以 Rc 更快。原则是只在需要时用 Arc,其余情况用 Rc。由于编译器会强制线程安全,在需要 Arc 的地方误用 Rc,代码根本编译不过,出错的空间也就很小。
另外,无论是 Rc 还是 Arc,共享的值默认都是不可变的。因为如果有多个所有者,其中一个又能修改值,就会破坏借用规则(存在共享引用期间不可修改)。可实际中,人们常常既想共享又想修改值。这正是内部可变性登场的地方。
RefCell 与内部可变性 — 把规则搬到运行期
Rust 的借用规则通常在编译期检查:“可以有多个不可变引用,或者只有一个可变引用,二者不能同时存在。”而 RefCell<T> 把这项检查推迟到运行期。这就是内部可变性(interior mutability)——一种即便外部只持有不可变引用,也能修改内部值的模式。
RefCell 通过 borrow() 借出不可变引用,通过 borrow_mut() 借出可变引用。规则的检查不在编译器手上,而是放到运行期;违反规则时不会是编译错误,而是panic。
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
{
let mut m = cell.borrow_mut(); // 可变借用
*m += 10;
} // m 在这里被归还
println!("{}", cell.borrow()); // 15
// 违反规则的例子 —— 运行时会 panic
let _a = cell.borrow_mut();
let _b = cell.borrow_mut(); // panic: already borrowed
}
编译器本来就能抓住的问题,为什么非要推迟到运行期?编译期检查虽然安全,却偏保守,会拒绝一些实际上安全的代码。RefCell 是在“我知道自己遵守了规则,但说服不了编译器”这种处境下的一条出路。代价是,安全检查挪到了运行期,一旦违反规则,就会以 panic 的形式暴露出来。
RefCell 真正的价值,要和 Rc 组合起来才能体现。Rc<RefCell<T>> 是“多个所有者共享,同时还能修改值”这种常见模式。
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);
clone.borrow_mut().push(4); // 修改共享的值
println!("{:?}", shared.borrow()); // [1, 2, 3, 4]
}
有一点要注意,RefCell 同样仅限单线程使用。如果需要跨线程的内部可变性,就要转向下面的 Mutex。
Mutex — 线程安全的内部可变性
要修改多个线程共享的值,需要用 Mutex<T>(互斥锁,mutual exclusion)。Mutex 用锁(lock)保护值,确保同一时刻只有一个线程能访问它。调用 lock() 会一直等待,直到获得锁为止,拿到锁后会返回一个可以访问值的守卫(guard)。守卫离开作用域时,锁会自动释放。
Rc<RefCell<T>> 的线程安全版本就是 Arc<Mutex<T>>。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // 获取锁
*num += 1;
}); // 守卫消失,锁被释放
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("result = {}", *counter.lock().unwrap()); // 10
}
用 Arc 把所有权分给多个线程,用 Mutex 把值的修改序列化。这一组合是 Rust 中处理线程间共享可变状态的标准写法。
如果读远多于写,还有 RwLock<T> 可用——读锁可以被多个线程同时持有,只有写锁是排他的。最后,互斥锁还有一个陷阱叫死锁(deadlock)——如果两个线程各自等待对方持有的锁,就会永远卡住。保持锁获取顺序的一致性,是最基本的防御手段。
Deref 强制转换 — 把智能指针当值来用
前面的例子里,直接把 Box<i32> 传给 println!,它也像整数一样被打印出来。让这一切成立的,是 Deref trait 和 Deref 强制转换(deref coercion)。
实现了 Deref 的类型,可以用 * 解引用运算符访问内部的值。不仅如此,编译器还会在需要时自动插入这种转换。比如把 &Box<String> 传给一个接收 &str 的函数,编译器会按 Box<String> → String → str 的顺序自动串联解引用。
fn greet(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let boxed = Box::new(String::from("Rust"));
greet(&boxed); // &Box<String> 会自动转换为 &str
}
有了这种自动转换,智能指针几乎可以像内部值本身一样自然地使用。前面提到的 trait 对象 Box<dyn Trait> 或 Vec<Box<dyn Trait>>,也因此不需要额外的语法负担——一次解引用,就能直接调用 trait 的方法。
Rc 循环引用与 Weak — 防止内存泄漏
Rust 以内存安全著称,但如果用错 Rc,同样可能造成内存泄漏。当两个 Rc 相互指向,形成循环引用(reference cycle)时,两个值的引用计数会因为彼此的存在而永远降不到零。于是两者都不会被释放,一直留在内存里。
想象一棵父子树。父节点用 Rc 拥有子节点,如果子节点也需要认得父节点,并用 Rc 指回父节点,就会形成循环。父→子、子→父两个方向都挂着强引用,计数永远不会归零。
解决办法是 Weak<T>,弱引用。用 Rc::downgrade 创建出的 Weak,虽然指向该值,但不主张所有权。也就是说它不会增加强引用计数(strong_count),因此能够切断循环。原则是只在一个方向(父→子)挂强引用,反方向(子→父)挂弱引用。
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // 父节点弱引用(不拥有)
children: RefCell<Vec<Rc<Node>>>, // 子节点强引用(拥有)
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// 子节点弱引用父节点 —— 不会形成循环
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
// 弱引用要用 upgrade 取出来使用(已消失则为 None)
if let Some(parent) = leaf.parent.borrow().upgrade() {
println!("leaf's parent value = {}", parent.value); // 5
}
}
Weak 指向的值可能已经被释放,所以要访问它,得用 upgrade() 拿到 Option<Rc<T>> 来确认是否还存活。存活则是 Some,已经消失则是 None。这一层确认,正是在没有循环的前提下安全处理双向引用的关键。
该用什么、什么时候用 — 小结
把目前看到的智能指针的选择标准压缩一下,大致如下:
Box<T>:想让值以单一所有者的身份放在堆上时。递归类型、大值的移动、trait 对象。Rc<T>:在单线程内让多个所有者共享一个值。只读共享。Arc<T>:需要跨多个线程共享所有权时。Rc的线程安全版本。RefCell<T>:在单线程内想在不可变引用背后修改值(内部可变性)。常见形式是Rc<RefCell<T>>。Mutex<T>/RwLock<T>:需要跨多个线程共享可变状态时。常见形式是Arc<Mutex<T>>。Weak<T>:切断Rc/Arc的循环引用、防止泄漏时。不拥有所有权的反向引用。
核心感觉可以归结为三个维度:所有者是一个还是多个(Box 对比 Rc/Arc),单线程还是多线程(Rc/RefCell 对比 Arc/Mutex),以及是不可变还是需要内部可变性(普通 Rc 对比 Rc/RefCell)。回答清楚这三个问题,答案通常就会收窄到一个。
结语
智能指针不是绕开 Rust 所有权规则的后门,而是把这些规则扩展到更广泛情形的工具。Box 安全地解开了大小与位置的约束,Rc 和 Arc 解开了单一所有权的约束,RefCell 和 Mutex 解开了不可变共享的约束。而为了这份安全,还配备了 Weak 这样的安全阀。
刚开始接触 Rc<RefCell<T>> 这类嵌套时会觉得陌生,但一旦弄清楚每一层各自解决什么问题,其中的意图反而会读得很清楚。只挑选真正需要的最少工具来用的这种克制,正是在 Rust 里写出既安全又灵活的数据结构的路径。
参考资料
- The Rust Programming Language — Smart Pointers 章节:https://doc.rust-lang.org/book/ch15-00-smart-pointers.html
- Rust 标准库:
std::boxed::Box:https://doc.rust-lang.org/std/boxed/struct.Box.html - Rust 标准库:
std::rc::Rc:https://doc.rust-lang.org/std/rc/struct.Rc.html - Rust 标准库:
std::sync::Arc:https://doc.rust-lang.org/std/sync/struct.Arc.html - Rust 标准库:
std::cell::RefCell:https://doc.rust-lang.org/std/cell/struct.RefCell.html - Rustonomicon —— 内部可变性及相关概念:https://doc.rust-lang.org/nomicon/
현재 단락 (1/166)
Rust 的所有权系统很强大,但有时会让人觉得过于严格。一个值只能有一个所有者,借用要遵循规则,大小必须在编译期确定。仅凭这些规则,很难表达递归数据结构,或是在多处共享的状态。填补这一空白的,正是**...