Rust中的智能指针:Box、Rc和Arc
1. 总览
在Rust中,默认情况下,每个值都只有一个所有者,而且Rust必须在编译时知道值的大小。许多现实场景无法满足这两个条件:递归数据类型、包含多种不同类型的列表,或者一份数据需要多个所有者。本文介绍了三种最常见的智能指针:Box<T>、Rc<T>和Arc<T>。读完本文,你将了解如何为每种场景选择合适的类型,如何将它们与Weak、RefCell、Mutex结合使用,以及常见的错误。
2. 智能指针是什么?
智能指针是一个包装指针的结构体,实现了两个重要特征:
Deref:允许你像使用普通引用一样使用智能指针(*ptr、直接调用方法)。Drop:当智能指针离开作用域时自动释放资源。
String和Vec<T>实际上也是智能指针。Box、Rc和Arc的不同之处在于它们专注于拥有heap上的数据的方式。这三个都在标准库中。你不需要在Cargo.toml中添加任何依赖。
3. Box<T>:单个所有者,数据在heap上
3.1. 基本分配
fn main() {
let order_total = Box::new(250_000u64);
println!("总额:{}", order_total);
let doubled = *order_total * 2;
println!("翻倍:{}", doubled);
}
Box::new在heap上分配值250_000。在stack上只保存一个指针,其大小是固定的。println!可以直接使用,因为当T实现Display时,Box也实现了Display。解引用操作符*通过Deref取出内部的值。当order_total离开main时,heap内存会自动被释放。
总额:250000
翻倍:500000
如上例所示,将u64包装在Box中不会带来任何好处。这段代码只是演示语法。Box在以下两种情况下真正有用。
3.2. 递归数据类型
假设你将订单处理流程描述为一系列步骤:
#[derive(Debug)]
enum OrderStep {
Done,
Next(String, Box<OrderStep>),
}
use OrderStep::{Done, Next};
fn count(step: &OrderStep) -> usize {
match step {
Done => 0,
Next(_, rest) => 1 + count(rest),
}
}
fn main() {
let flow = Next(
"下单".into(),
Box::new(Next(
"付款".into(),
Box::new(Next("配送".into(), Box::new(Done))),
)),
);
println!("步数:{}", count(&flow));
}
如果你写Next(String, OrderStep)而没有Box,编译器会报错:
error[E0072]: recursive type `OrderStep` has infinite size
原因是一个OrderStep包含另一个OrderStep,后者又包含再一个,所以计算出的大小是无限的。Box<OrderStep>的大小总是等于一个指针的大小,因此enum的大小变成了有限的。在count函数中,rest的类型是&Box<OrderStep>。Rust会通过deref coercion自动将其转换为&OrderStep。
步数:3
3.3. Trait对象
Box<dyn Trait>允许你在同一个集合中存放多种不同的类型:
trait PaymentMethod {
fn pay(&self, amount: u64) -> String;
}
struct Cash;
struct Card {
last4: String,
}
impl PaymentMethod for Cash {
fn pay(&self, amount: u64) -> String {
format!("现金:{amount}")
}
}
impl PaymentMethod for Card {
fn pay(&self, amount: u64) -> String {
format!("卡 ****{}:{amount}", self.last4)
}
}
fn main() {
let methods: Vec<Box<dyn PaymentMethod>> = vec![
Box::new(Cash),
Box::new(Card { last4: "4242".into() }),
];
for m in &methods {
println!("{}", m.pay(100_000));
}
}
Cash和Card的大小不同,所以不能直接放在同一个Vec中。当包装在Box<dyn PaymentMethod>中时,所有元素都有相同的大小(一个胖指针:数据指针加vtable指针)。pay调用在运行时被解析。
现金:100000
卡 ****4242:100000
提示: 如果所有元素都是同一类型,应使用泛型(
impl PaymentMethod或<T: PaymentMethod>)而不是Box<dyn …>。泛型使用静态分发,不需要heap分配,也不需要通过vtable调用。
4. Rc<T>:多个所有者,单线程
4.1. 引用计数
假设多个Order属于同一个Customer。你不想把客户信息复制到每个订单中,也不知道哪个订单会“活”最久以成为所有者。这正是使用Rc(引用计数)的时候:
use std::rc::Rc;
struct Customer {
name: String,
}
struct Order {
id: u32,
customer: Rc<Customer>,
}
fn main() {
let alice = Rc::new(Customer { name: "Alice".into() });
println!("创建后:{}", Rc::strong_count(&alice));
let o1 = Order { id: 1, customer: Rc::clone(&alice) };
{
let o2 = Order { id: 2, customer: Rc::clone(&alice) };
println!("块内:{}", Rc::strong_count(&alice));
println!("订单{}属于{}", o2.id, o2.customer.name);
}
println!("块后:{}", Rc::strong_count(&alice));
println!("订单{}属于{}", o1.id, o1.customer.name);
}
Rc::clone不会复制Customer。它只是将strong count增加1。当o2在块的末尾被drop时,计数减1。Customer只有在计数变为0时才被释放。
创建后:1
块内:3
订单2属于Alice
块后:2
订单1属于Alice
按照惯例,应该写Rc::clone(&alice)而不是alice.clone()。这种写法让读者看出这是一个廉价的引用计数操作,而不是深拷贝。
4.2. 用Rc<RefCell<T>>修改数据
Rc<T>只给你不可变引用(&T)。如果多个所有者需要修改数据,应与RefCell结合使用:
use std::cell::RefCell;
use std::rc::Rc;
struct Cart {
items: Vec<String>,
}
fn main() {
let cart = Rc::new(RefCell::new(Cart { items: vec![] }));
let web = Rc::clone(&cart);
let mobile = Rc::clone(&cart);
web.borrow_mut().items.push("T恤".into());
mobile.borrow_mut().items.push("竹笠".into());
println!("{:?}", cart.borrow().items);
}
RefCell将借用规则检查从编译时转移到运行时。borrow_mut()返回一个guard。这个guard在语句末尾就失效了,所以下一个调用仍然是合法的。
["T恤", "竹笠"]
提示: 如果你在另一个
borrow()或borrow_mut()仍然存在的情况下调用borrow_mut(),程序会panic并显示already borrowed: BorrowMutError。应该尽可能短地保持guard的生命周期。如果你想处理错误而不是panic,应使用try_borrow_mut()。
4.3. 循环引用和Weak<T>
如果Customer持有Rc<Order>的列表,而每个Order又持有Rc<Customer>,两个计数永远不会变为0。结果是内存泄漏。Rust无法在编译时阻止这个错误。解决方案是为一个方向的引用使用Weak:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Customer {
name: String,
orders: RefCell<Vec<Rc<Order>>>,
}
struct Order {
id: u32,
customer: Weak<Customer>,
}
fn main() {
let bob = Rc::new(Customer {
name: "Bob".into(),
orders: RefCell::new(vec![]),
});
let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
bob.orders.borrow_mut().push(Rc::clone(&order));
if let Some(c) = order.customer.upgrade() {
println!("订单{}属于{}", order.id, c.name);
}
println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));
drop(bob);
println!("还有客户吗?{}", order.customer.upgrade().is_some());
}
Rc::downgrade创建一个Weak。Weak只增加weak count,不会使数据保持活跃。当需要使用时,你调用upgrade()来获得Option<Rc<T>>。如果数据已经被释放,结果是None。在drop(bob)之后,Customer的strong count变为0,所以Customer被释放,尽管order中的Weak仍然存在。
订单7属于Bob
strong=1, weak=1
还有客户吗?false
总体规则:父→子方向使用Rc,子→父方向使用Weak。
5. Arc<T>:多个所有者,多线程
5.1. 为什么Rc不能用于线程
Rc使用普通加法更新计数,当多个线程同时操作时不安全。因此Rc没有实现Send trait。如果你尝试将Rc传入thread::spawn,编译器会拒绝:
error[E0277]: `Rc<Customer>` cannot be sent between threads safely
这是Rust的一个优势:数据竞争错误在编译时就被阻止。Arc(原子引用计数)的API与Rc完全相同,但使用原子操作来更新计数。
5.2. 共享只读数据
use std::sync::Arc;
use std::thread;
struct ShopConfig {
currency: String,
vat_percent: u64,
}
fn main() {
let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });
let handles: Vec<_> = [100_000u64, 200_000]
.into_iter()
.map(|price| {
let config = Arc::clone(&config);
thread::spawn(move || {
let vat = price * config.vat_percent / 100;
println!("{} + VAT = {} {}", price, price + vat, config.currency);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
每个线程通过move获得自己的Arc副本。所有副本都指向同一个ShopConfig。数据只被读取,所以不需要锁。两行输出的顺序在不同的运行中可能会改变。
100000 + VAT = 110000 VND
200000 + VAT = 220000 VND
5.3. 用Arc<Mutex<T>>修改数据
RefCell在线程间不安全。多线程对应的版本是Mutex(或RwLock):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let revenue = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for amount in [100u64, 250, 400] {
let revenue = Arc::clone(&revenue);
handles.push(thread::spawn(move || {
*revenue.lock().unwrap() += amount;
}));
}
for h in handles {
h.join().unwrap();
}
println!("收入:{}", *revenue.lock().unwrap());
}
lock()返回一个MutexGuard。当guard被drop时锁自动释放,这里是在语句末尾。unwrap()处理mutex被poisoned的情况,即另一个线程在持有锁时panic。
收入:750
提示: 对于一个简单的整数计数器,
Arc<AtomicU64>比Arc<Mutex<u64>>更快。另外,不应该在只有一个线程的地方使用Arc。原子操作比普通加法成本高,尽管差异不大。
6. 为引用计数行为编写测试
Rust内置了测试框架,不需要添加额外的crate。将以下测试放在src/lib.rs或src/main.rs的末尾:
#[cfg(test)]
mod tests {
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
use std::thread;
struct Customer {
name: String,
}
#[test]
fn rc_clone_increases_strong_count_and_drop_decreases_it() {
let alice = Rc::new(Customer { name: "Alice".into() });
let copy = Rc::clone(&alice);
assert_eq!(Rc::strong_count(&alice), 2);
assert!(Rc::ptr_eq(&alice, ©));
drop(copy);
assert_eq!(Rc::strong_count(&alice), 1);
assert_eq!(alice.name, "Alice");
}
#[test]
fn weak_upgrade_returns_none_after_owner_dropped() {
let bob = Rc::new(Customer { name: "Bob".into() });
let weak: Weak<Customer> = Rc::downgrade(&bob);
assert!(weak.upgrade().is_some());
drop(bob);
assert!(weak.upgrade().is_none());
}
#[test]
fn arc_mutex_sums_correctly_across_threads() {
let revenue = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (1..=100)
.map(|i| {
let revenue = Arc::clone(&revenue);
thread::spawn(move || *revenue.lock().unwrap() += i)
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
assert_eq!(*revenue.lock().unwrap(), 5050);
assert_eq!(Arc::strong_count(&revenue), 1);
}
}
第一个测试检查计数,并使用Rc::ptr_eq来确认两个Rc指向同一内存,即没有深拷贝。第二个测试确认Weak不会保持数据活跃。第三个测试运行100个线程进行求和,结果应该是5050。所有线程join后,只有一个strong reference。
运行测试:
cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok
test result: ok. 3 passed; 0 failed
7. 选择哪种类型?
| 需求 | 选择 |
|---|---|
| 单个所有者,需要heap(递归类型、trait对象、需要廉价move的大数据) | Box<T> |
| 多个所有者,单线程,只读 | Rc<T> |
| 多个所有者,单线程,需要修改 | Rc<RefCell<T>> |
| 多个所有者,多线程,只读 | Arc<T> |
| 多个所有者,多线程,需要修改 | Arc<Mutex<T>> / Arc<RwLock<T>> |
| 反向引用、缓存、避免循环 | Weak<T> |
常见错误:
- 在只需要借用时使用智能指针。 如果函数只读取数据,应该接收
&Customer而不是Rc<Customer>。这样函数会更灵活,也不需要增加或减少计数。 - 过度使用
Rc<RefCell<T>>。 充斥这种模式的代码通常表明你在从Java或C#带来OOP思维。应该考虑重新组织所有权,或在Vec中使用索引(arena pattern)。 - 循环引用。
Rc和Arc在两个对象使用strong reference互相指向时都可能导致内存泄漏。应该为其中一个方向使用Weak。 - 持有
MutexGuard过久。 特别是在async代码中跨.await持有guard,或在不同线程中以不同顺序锁定多个mutex时,都会导致死锁。
8. 结论
Box<T>用于当你需要在heap上放置数据且只有单个所有者时,典型情况是递归类型和trait对象。Rc<T>允许单线程中的多个所有者,而Arc<T>通过原子计数在多线程间实现相同功能。这些智能指针本身只允许读取。当需要修改数据时,应与RefCell(单线程)或Mutex/RwLock(多线程)结合,并使用Weak来避免循环引用。应该从普通借用开始,只有当默认的所有权规则真正无法满足时才转向智能指针。