11.1 这节课解决什么问题
所有权规则(第 4 课)有个"令人窒息"的前提:一棵数据树里每个节点只有一个 owner、借用要么共享要么独占。但真实系统需要打破它:
场景一:一棵树/图/双向链表 ------ 一个节点被多处引用,谁先 Drop 都说不清
场景二:多个地方要"共享同一个只读配置/缓存"
场景三:想要"不可变接口下偷偷做内部计数/缓存/懒加载"
场景四:单线程内递归/自引用类型(编译期长度未知,必须上堆)
应对这些场景的工具叫智能指针 :Box<T>、Rc<T>、RefCell<T> 及其组合。它们让"共享所有权""内部可变性"成为可能------代价是把一部分安全检查从编译期推迟到运行期(该 panic 就 panic)。
💡 认知准备:智能指针是"实现了 Deref 与 Drop 的胖指针"。
Deref让*ptr、方法自动解引用可用;Drop让它在被丢弃时做清理(Rc 减计数、Box 释放堆)。学完本课,你会对"所有权模型的能力边界"有完整认识------而这正是后续手写 FFI、UniFFI 绑定选择数据布局时决策的理论依据。
11.2 Box<T>:把值放到堆上(最朴素的智能指针)
11.2.1 用途一:解决"编译期不知大小"的递归类型
rust
// 链表:每个节点持有下一个节点。直接写 `next: List` 会无限递归 → 编译器拒绝
enum List {
Cons(i32, Box<List>), // Box 固定指针大小,递归就合法了
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
println!("有指针那层就够了: 类型大小固定为 {} 字节", std::mem::size_of::<Box<List>>());
}
Box<T> 大小总是"一个指针"大小,所以把递归部分包进 Box,类型大小就确定、可以被放进栈上变量/结构体。
11.2.2 用途二:把大对象放堆、只搬指针(避免整块拷贝)
rust
fn main() {
let big = Box::new([0u8; 1024 * 1024]); // 1MB 数据在堆上
let moved = big; // 只移动 8 字节指针,不拷贝 1MB
drop(moved); // 手动提前释放(也可不写)
}
11.2.3 Box 的三个默认能力
rust
fn main() {
let b = Box::new(42);
// Deref:*b 取到内部值;b 也能当 &i32 用(自动解引用)
println!("{}", *b + 1); // 43
let r: &i32 = &b; // &Box<i32> 自动解引用 → &i32
println!("{r}");
// Drop:离开作用域自动释放堆内存(Box 是"手工 new 不 free"时代最想要的封装)
}
💡 记忆锚点:Box 就是"带自动释放的堆指针",本身不改变所有权语义(仍然是独占),只是换了个存放位置。真正的变化从 Rc 开始。
11.3 Rc<T>:单线程共享所有权(引用计数)
11.3.1 场景:一个值多个"拥有者"
rust
use std::rc::Rc;
enum List {
Cons(i32, Rc<List>),
Nil,
}
fn main() {
use List::*;
// a 被 b 和 c 同时共享持有
let a = Rc::new(Cons(5, Rc::new(Nil)));
println!("a 的强引用数: {}", Rc::strong_count(&a)); // 1
let b = Cons(3, Rc::clone(&a)); // 计数 2(clone 只复制 Rc 句柄,不深拷贝数据)
println!("now {}", Rc::strong_count(&a)); // 2
{
let c = Cons(4, Rc::clone(&a));
println!("now {}", Rc::strong_count(&a)); // 3
} // c 出作用域 → 计数回到 2
println!("now {}", Rc::strong_count(&a)); // 2
// 当所有 Rc 都被丢弃、计数归 0 时,数据才真正 Drop
}
要点:
Rc::clone(&a)不是深拷贝 ------只把引用计数 +1。要数据副本请用a.as_ref().clone();Rc::strong_count用来观察(注意循环引用会产生计数永不归零的内存泄漏,见 11.6);Rc<T>只读 :不能拿到&mut T。想改?往下看RefCell。
11.4 RefCell<T>:把"借用检查"推迟到运行期
11.4.1 为什么需要它:违反"一个 & 一个 &mut"却仍要安全
RefCell 允许在运行时 执行借用规则:你仍然只能有一个 &mut 或多个 &,但检查时机从编译期挪到调用期------不满足时直接 panic。
rust
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
*cell.borrow_mut() += 1; // 运行时可变借用(等价于 &mut)
let r1 = cell.borrow(); // 运行时不可变借用
let r2 = cell.borrow(); // 多个不可变借用 OK
println!("{} {}", *r1, *r2); // 6 6
drop(r1); drop(r2); // 借用结束
// 如果违反规则:
// let r3 = cell.borrow();
// let m = cell.borrow_mut(); // 💥 panic: already borrowed: BorrowMutError
}
11.4.2 真实价值:&self 方法里改内部缓存(interior mutability)
最经典案例------接口不可变、内部偷偷缓存结果:
rust
use std::cell::RefCell;
struct Temperature {
cached_c: RefCell<Option<f64>>, // 内部缓存(外部看不见、也借不走)
}
impl Temperature {
fn from_f(f: f64) -> Temperature {
Temperature { cached_c: RefCell::new(Some((f - 32.0) * 5.0 / 9.0)) }
}
fn celsius(&self) -> f64 {
// 需要写 cache,但 &self 拿不到 &mut ------ RefCell 绕过编译期限制,运行期自检
let mut cache = self.cached_c.borrow_mut();
if cache.is_none() {
// 实际场景:这里做昂贵计算再填缓存
}
cache.unwrap()
}
}
fn main() {
let t = Temperature::from_f(100.0);
println!("{}", t.celsius());
}
💡 铁律提醒:
RefCell只推迟 检查,不取消 规则。运行期违规仍 panic。多线程场景用Mutex/RwLock(第 13 课),不要试图拿RefCell跨线程(它没实现Sync,编译期就拦住了)。
11.5 组合拳:Rc<RefCell<T>>(共享 + 可变)
共享要 Rc,可变要 RefCell------两者叠加就是 Rust 单线程里的"共享可变状态":
rust
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
// 一份可变的共享数据
let shared = Rc::new(RefCell::new(42));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
*a.borrow_mut() += 1; // 通过 a 改
*b.borrow_mut() += 1; // 通过 b 改
println!("最终值: {}", shared.borrow()); // 44
}
| 类型 | 拥有者 | 可变性 | 检查时机 |
|---|---|---|---|
Box<T> |
独占 | 可直接 &mut |
编译期 |
Rc<T> |
共享(计数) | 只读 | 编译期 |
RefCell<T> |
独占 | 运行期借用 | 运行期(panic) |
Rc<RefCell<T>> |
共享 + 可变 | 运行期借用 | 运行期(panic) |
💡 实战判断(第 17 课会有实例):单线程里确实需要"几处共享一块可变状态",
Rc<RefCell<T>>是直白解;但如果能靠"把数据归到单一 owner、别的模块借引用"解决问题,永远优先后者------编译期安全比运行期 panic 值钱。高级代码的目标不是"炫技用上 RefCell",而是"能不用就不用"。
11.6 ⚠️ 循环引用与内存泄漏:Rc 的陷阱
Rc 依赖计数归零才释放。如果 A 持 B、B 又持 A(互相 Rc::clone),计数永远 ≥1,数据泄漏:
rust
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
name: String,
next: RefCell<Option<Rc<Node>>>, // 指向"下家"------可能是自己也可能是兄弟
}
fn main() {
let a = Rc::new(Node { name: String::from("A"), next: RefCell::new(None) });
let b = Rc::new(Node { name: String::from("B"), next: RefCell::new(None) });
a.next.borrow_mut().replace(Rc::clone(&b)); // A → B
b.next.borrow_mut().replace(Rc::clone(&a)); // B → A(循环!)
println!("{} {}", Rc::strong_count(&a), Rc::strong_count(&b)); // 都是 2
// main 结束:各自减到 1,但互为对方的 next → 永不归零 → 泄漏
}
破解办法:把一边改成弱引用 Weak<T>(计数不算 strong,不阻止释放):
rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: String,
next: RefCell<Option<Weak<Node>>>, // 弱引用:B 指向 A 用 Weak
}
fn main() {
let a = Rc::new(Node { name: "A".into(), next: RefCell::new(None) });
let b = Rc::new(Node { name: "B".into(), next: RefCell::new(None) });
// A 强持 B;B 弱持 A → 无环
a.next.borrow_mut().replace(Rc::downgrade(&b)); // a.next 是 Weak
b.next.borrow_mut().replace(Rc::downgrade(&a));
println!("strong: a={} b={}", Rc::strong_count(&a), Rc::strong_count(&b)); // 1 1
// Weak 要变成 Rc 用 upgrade(),若目标已释放则拿 None
if let Some(shared) = a.next.borrow().as_ref().and_then(|w| w.upgrade()) {
println!("通过 a 访问 b: {}", shared.name);
}
}
💡 面试必考一问:"Rc 会内存泄漏吗?怎么解决?"标准答:循环引用使计数永不归零 → 用 Weak 打破环。Weak 是"我认识你,但不决定你的生死"。
11.7 智能指针家族在内存上的画像
┌─────────────────────────────────────┐
String │ ptr + len + cap → 堆上 UTF-8 字节 │(你早已用熟)
Vec<T> │ ptr + len + cap → 堆上 [T] │
Box<T> │ ptr → 堆上单个 T │(独占、自动释放)
Rc<T> │ ptr + 强计数/弱计数表(控制块)→ 堆上 T │(共享只读,单线程)
RefCell<T> │ T + 借用标记(运行期检查) │(独占可变,单线程)
Rc<RefCell<T>> │ 组合:共享句柄 + 运行期可变借用 │
同系列(了解即可,用到再查):Arc<T>(多线程版 Rc,13 课)、Mutex<T>/RwLock<T>(多线程版 RefCell)、Cell<T>(Copy 值的"无 panic 版 RefCell")、Cow<'a, B>(写时复制)。
11.8 📝 动手练习
参考实现放 code/11-smart-pointers/(写作时同步给出)。
- 递归枚举 :实现二叉树
enum Tree { Empty, Node(i32, Box<Tree>, Box<Tree>) },写fn sum(&self) -> i32、fn depth(&self) -> u32(不写 Box 先看编译报错,体会"infinite recursive type")。 - Box 大小 :用
std::mem::size_of打印i32、Box<i32>、Rc<i32>、Vec<i32>、String在栈上的占用,解释为什么 Box 版本能放进递归类型。 - Rc 共享只读配置 :定义
struct Config { host: String, port: u16 },两个函数各拿一个Rc<Config>,打印 strong_count 变化。 - Rc 计数陷阱:写 11.6 的循环版本,打印计数归零后的泄漏表现;再用 Weak 改写,确认 main 结束时计数能归零(可用自定义 Drop 打印来观察释放时机)。
- RefCell 缓存(重点) :写
struct CachedSum { input: Vec<i32>, cache: RefCell<Option<i32>> },fn sum(&self)若缓存空则计算并写缓存、否则返回缓存(两次调用第二次不打日志以证明命中)。 - RefCell 违规 :故意 borrow 未 drop 时 borrow_mut,运行观察
BorrowMutErrorpanic 文案并记录。 - 多态容器复习 :用
Vec<Rc<dyn Area>>把第 8 课的形状混装(Rc 包 dyn,验证"共享 + 动态分发"可共存)。
验收门禁 :能说清 Box/Rc/RefCell 三者分别解决什么问题、何时 panic;能解释 Rc::clone 不是深拷贝;能画出"循环引用→Weak 破解"的原理一句话版。
✅ 本节小结
- 智能指针 = Deref + Drop:自动解引用与自动清理;
- Box:堆上独占值;解递归类型、避免大对象整块移动;
- Rc :单线程共享所有权,计数归零才释放;
Rc::clone只 +1 计数; - RefCell :把借用检查推迟到运行期,
&self下也能改内部缓存(interior mutability);违规 panic; - Rc<RefCell>:共享 + 可变,是单线程"多引用可变状态"的最后一招;
- 循环引用 → Weak :弱引用不阻止释放,
upgrade()尝试转强引用(可能 None); - 工程原则:优先编译期安全的单一 owner 设计,智能指针是必要时的工具箱而不是默认解。
下一课预告 :第 12 课《并发模型与同步原语》------把所有权模型带进多线程:thread::spawn 与 move、Arc<Mutex<T>> 共享状态、channel 消息传递、以及最重要的设计取舍:什么时候用共享内存、什么时候用消息。这正是第 13 课讲 async 前必须夯实的心智基础。