【AI开发之Rust】第 11 课:智能指针与内部可变性

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/(写作时同步给出)。

  1. 递归枚举 :实现二叉树 enum Tree { Empty, Node(i32, Box<Tree>, Box<Tree>) },写 fn sum(&self) -> i32fn depth(&self) -> u32(不写 Box 先看编译报错,体会"infinite recursive type")。
  2. Box 大小 :用 std::mem::size_of 打印 i32Box<i32>Rc<i32>Vec<i32>String 在栈上的占用,解释为什么 Box 版本能放进递归类型。
  3. Rc 共享只读配置 :定义 struct Config { host: String, port: u16 },两个函数各拿一个 Rc<Config>,打印 strong_count 变化。
  4. Rc 计数陷阱:写 11.6 的循环版本,打印计数归零后的泄漏表现;再用 Weak 改写,确认 main 结束时计数能归零(可用自定义 Drop 打印来观察释放时机)。
  5. RefCell 缓存(重点) :写 struct CachedSum { input: Vec<i32>, cache: RefCell<Option<i32>> }fn sum(&self) 若缓存空则计算并写缓存、否则返回缓存(两次调用第二次不打日志以证明命中)。
  6. RefCell 违规 :故意 borrow 未 drop 时 borrow_mut,运行观察 BorrowMutError panic 文案并记录。
  7. 多态容器复习 :用 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::spawnmoveArc<Mutex<T>> 共享状态、channel 消息传递、以及最重要的设计取舍:什么时候用共享内存、什么时候用消息。这正是第 13 课讲 async 前必须夯实的心智基础。

相关推荐
子兮曰1 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰1 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
小羊没烦恼!1 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
爱勇宝1 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
胡写代码1 天前
别再前后端各写一套表单校验了
java·后端
伞伞悦读1 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
大勇前进1 天前
原生 PHP 还是 Laravel?小项目到底要不要上框架
后端
yuzhi_liu1 天前
我用 LangGraph4j 实现 Multi-Agent Supervisor
后端
alsmile1 天前
Node-RED 之外,国产规则引擎的新方案:基于标准语法,Go 先行实现
后端·开源·go
大白801 天前
PHP 内存溢出排查思路:看懂报错日志,精准定位问题
后端