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, &copy));

        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来避免循环引用。应该从普通借用开始,只有当默认的所有权规则真正无法满足时才转向智能指针。

rust智能指针所有权并发内存