智能指针

一句话理解

所有权规则是编译期的严格约束。当”一个值只有一个所有者”或”不可变就是不可变”满足不了需求时,用智能指针把一部分检查挪到运行时或挪到原子操作上:

  • Box<T>:唯一所有权 + 堆分配
  • Rc<T> / Arc<T>:多个所有者(共享)
  • RefCell<T> / Mutex<T>:内部可变性
  • Weak<T>:不打引用计数的”弱”引用,用来打破循环

1. Box<T>:最简单的堆分配

let b = Box::new(5);
println!("{}", b);   // 自动解引用

三个典型用途:

① 递归类型必须装箱(否则大小无法确定)

enum List {
    Cons(i32, Box<List>),
    Nil,
}

② 把大对象从栈挪到堆(避免拷贝、稳定地址)

③ 装 trait 对象

let handlers: Vec<Box<dyn Fn(i32) -> i32>> = vec![
    Box::new(|x| x + 1),
    Box::new(|x| x * 2),
];

Box 不是"引用"

Box<T> 拥有 T,离开作用域会 drop 它。它只是把数据放在堆上,语义上仍是唯一所有者。

2. Rc<T> 与 Arc<T>:共享所有权

use std::rc::Rc;
 
let a = Rc::new(String::from("共享数据"));
let b = Rc::clone(&a);          // 引用计数 +1,不拷贝数据
println!("{}", Rc::strong_count(&a));  // 2
Rc<T>Arc<T>
计数方式普通整数加减原子操作
线程安全❌ !Send !Sync✅ Send + Sync(当 T: Send + Sync)
开销更小更快有原子操作成本
用在哪单线程共享跨线程共享

别用 Arc 当默认选择

Arc 的原子操作不是免费的。只在确实要跨线程时用 Arc,单线程内一律 Rc。

Rc::clone(&a) 语义上不是”克隆数据”,只是把计数 +1。写 a.clone() 会让读者误以为在深拷贝,圈内惯例是显式写 Rc::clone(&a)。

3. 内部可变性:RefCell 与 Cell

Rc<T> 只给你不可变访问。想要”共享 + 可改”,需要内部可变性:

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

RefCell 把借用检查从编译期挪到运行时:

  • borrow() / borrow_mut() 返回智能指针,drop 时释放借用
  • 违反规则(同时存在 borrow 与 borrow_mut)会panic,而不是编译错误
  • 因为运行时记账,RefCell 不是 Sync——不能跨线程共享

Cell<T> 更轻,但只适合 Copy 类型或整体替换:

use std::cell::Cell;
let c = Cell::new(1);
c.set(c.get() + 1);
Cell<T>RefCell<T>
获取引用❌ 只能 get/set 整体读写✅ borrow/borrow_mut
适用Copy 类型的小状态需要借用的复杂结构
违规行为不存在(无借用)运行时 panic

4. 跨线程的内部可变性:Mutex 与 RwLock

use std::sync::{Arc, Mutex};
 
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
 
for _ in 0..10 {
    let c = Arc::clone(&counter);
    handles.push(std::thread::spawn(move || {
        let mut n = c.lock().unwrap();   // 拿锁,返回 MutexGuard
        *n += 1;
    }));                                  // guard 在这里 drop,自动解锁
}
 
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10
Mutex<T>RwLock<T>
并发读❌ 独占✅ 多个读者
写✅✅ 独占
适合读写都频繁、临界区短读多写少
注意可能死锁;.lock() 返回 Result(锁中毒)写者可能饥饿;实现依赖平台

Mutex 的锁中毒

持锁线程 panic 时锁会被标记为 “poisoned”,后续 .lock() 返回 Err。很多代码直接 .unwrap(),等于把上一个线程的 panic 传染给所有线程。生产代码里可以考虑 PoisonError::into_inner() 显式忽略,或统一在任务边界处理 panic。

5. 两个经典组合

Rc<RefCell<T>>:单线程共享可变状态

use std::cell::RefCell;
use std::rc::Rc;
 
#[derive(Debug)]
struct Node {
    value: i32,
    next: Option<Rc<RefCell<Node>>>,
}
 
let a = Rc::new(RefCell::new(Node { value: 1, next: None }));
let b = Rc::new(RefCell::new(Node { value: 2, next: Some(Rc::clone(&a)) }));

Arc<Mutex<T>>:跨线程共享可变状态

见上一节的计数器例子。这是 Rust 里最标准的”共享可变状态”配方:

Arc 负责共享所有权(跨线程)
Mutex 负责互斥访问(内部可变性)

先想想能不能不用共享状态

消息传递(channel)+ 单一所有者,往往比 Arc<Mutex<_>> 更容易推理、也更少死锁。Rust 社区的说法是”用通信来共享内存,而不是共享内存来通信”。共享状态是手段,不是默认。

6. Weak<T>:打破循环引用

Rc / Arc 的引用计数不会自动发现环,环会让计数永远不为零,导致内存泄漏——Rust 只保证不悬垂,不保证不泄漏。

use std::rc::{Rc, Weak};
use std::cell::RefCell;
 
struct Node {
    parent: RefCell<Weak<Node>>,   // 父节点用弱引用,避免环
    children: RefCell<Vec<Rc<Node>>>,
}
 
let leaf = Rc::new(Node {
    parent: RefCell::new(Weak::new()),
    children: RefCell::new(vec![]),
});
 
let branch = Rc::new(Node {
    parent: RefCell::new(Weak::new()),
    children: RefCell::new(vec![Rc::clone(&leaf)]),
});
 
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);

Weak::upgrade() 返回 Option<Rc<T>>:对象还活着就是 Some,已被回收就是 None。

典型需要弱引用的地方:父子双向关系、观察者/订阅者列表、缓存(键的引用不该阻止条目被回收)。

7. Cow<'a, T>:写时克隆

use std::borrow::Cow;
 
fn normalize(s: &str) -> Cow<'_, str> {
    if s.contains(' ') {
        Cow::Owned(s.replace(' ', "_"))   // 需要改才分配
    } else {
        Cow::Borrowed(s)                  // 不改就不分配
    }
}

Cow(Clone-on-Write)只有两种状态:借用或拥有。适合”大多数输入不需要修改”的场景,能省掉大量不必要的分配。

8. 怎么选:一张决策表

需求选择
唯一所有权,但要在堆上 / 递归类型 / trait objectBox<T>
多个所有者,单线程Rc<T>
多个所有者,跨线程Arc<T>
单线程 + 需要修改共享值Rc<RefCell<T>>
跨线程 + 需要修改共享值Arc<Mutex<T>> / Arc<RwLock<T>>
共享但允许”有时改”Cow<'a, T>
打破循环引用Weak<T>
只需在少数地方改值,且类型是 CopyCell<T>

9. 与所有权规则的关系

一句话看穿

智能指针并没有绕过所有权,而是换了一种执行方式:

  • Rc / Arc 把”唯一所有权”放宽成”多个所有者 + 计数”,代价是运行时计数和可能的泄漏
  • RefCell / Mutex 把”借用规则”从编译期挪到运行时,代价是 panic 风险或加锁开销
  • Box 完全遵守原来的规则,只是加了一层堆间接

也就是说:越灵活,越多运行时风险。能用编译期检查解决,就不要用智能指针。

相关笔记