说实话,第一次看到RefCell的时候,我差点把电脑关了。

Rust不是号称"安全第一"吗?不是默认不可变吗?现在突然冒出个东西,告诉你"嘿,我们可以通过不可变引用来修改数据",这不是自己打自己脸吗?

我的脑子里全是问号:这不就是Rust花那么大力气要防止的事情吗?

但后来,当我真正理解它在解决什么问题的时候,才发现这个设计有多精妙。

今天咱们就来聊聊这个让我抓狂又让我顿悟的特性。

RefCell是什么?带门禁的可变房间

把RefCell想象成一个带门禁系统的房间。这个房间有个特殊的规定:表面上看起来,这个房间是"只读"的,任何人都可以进去参观。但如果你有门禁卡,你进去后可以改变房间里的东西。

这个"门禁系统"就是RefCell的运行时借用检查。用个图来说明:

正常Rust(编译期检查):

┌─────────┐     ┌─────────────┐
│ 变量 x  │────▶│   值:10    │  ← 不可变引用 &x
└─────────┘     └─────────────┘
                想改成20?❌
           编译器直接拦住你!
用RefCell(运行时检查):

┌─────────┐     ┌─────────────────────────┐
│ 变量 x  │────▶│  RefCell  │  值:10   │  ← borrow_mut()可以改
└─────────┘     └─────────────────────────┘
                门禁卡 ✓
           运行时检查通过→改成20

正常情况下,Rust的借用规则是在编译期检查的:

// 这是行不通的
let x = 10;
let y = &x;       // 不可变引用
*y = 20;          // 编译器直接报错:不能通过不可变引用修改数据

但RefCell把这个检查推迟到了运行时:

use std::cell::RefCell;

fn main() {
    let value = RefCell::new(10);
    *value.borrow_mut() = 20;  // 运行时检查,通过了就能改
    println!("Value: {}", value.borrow());
}

这意味着什么?意味着Rust在运行的时候会盯着你,确保你没有同时拿着可变引用和不可变引用。如果你违规了,程序会直接panic,但这panic发生在运行时而不是编译时。

为什么需要这种东西?

我遇到RefCell是在做一个插件系统的时候。每个插件可能依赖其他插件,这就形成了一个依赖图。问题来了,每个插件需要引用其他插件,但同时又要能修改自己的状态。

用最朴素的方式写,大概是这样:

struct Plugin {
    name: String,
    dependencies: Vec<&Plugin>,  // 引用其他插件
    active: bool,
}

编译器直接给你甩一个错误:“借用值活得不够长”。

这就是Rust借用检查器的局限性。它没法在编译期表达"共享所有权同时还需要可变内部状态"这种关系。

这时候,Rc<RefCell>就像救星一样出现了。

Rc + RefCell:黄金搭档

Rc负责共享所有权,RefCell负责内部可变性,这两个一配合,就能解决刚才的问题。

先看个结构图:

Rc<RefCell<T>> 的工作原理:

                    ┌──────────────────┐
          ┌────────│   Rc<T> (引用计数) │────────┐
          │        └──────────────────┘        │
          │                                    │
     拥有者 A                              拥有者 B
          │                                    │
          └──────────┬─────────────┬───────────┘
                     ▼             ▼
              ┌──────────────────────────┐
              │    RefCell<T>            │
              │  ┌──────────────────┐    │
              │  │   borrow flag    │    │  ← 运行时借用检查
              │  │  0=空闲 1=借用   │    │
              │  └──────────────────┘    │
              │  ┌──────────────────┐    │
              │  │      数据 T      │    │
              │  └──────────────────┘    │
              └──────────────────────────┘

完整代码:

use std::cell::RefCell;
use std::rc::Rc;

// 定义一个类型别名,写起来方便点
type PluginRef = Rc<RefCell<Plugin>>;

struct Plugin {
    name: String,
    dependencies: Vec<PluginRef>,
    active: bool,
}

impl Plugin {
    fn new(name: &str) -> PluginRef {
        Rc::new(RefCell::new(Self {
            name: name.to_string(),
            dependencies: Vec::new(),
            active: false,
        }))
    }

    fn activate(&mut self) {
        self.active = true;
        println!("{} 已激活!", self.name);
    }
}

现在你可以轻松地创建插件依赖关系:

fn main() {
    let plugin_a = Plugin::new("核心模块");
    let plugin_b = Plugin::new("用户界面");

    // UI插件依赖核心模块
    plugin_b.borrow_mut().dependencies.push(plugin_a.clone());

    // 激活核心模块
    plugin_a.borrow_mut().activate();

    // 打印依赖关系
    println!(
        "{} 依赖 {}",
        plugin_b.borrow().name,
        plugin_b.borrow().dependencies[0].borrow().name
    );
}

依赖关系图:

        插件依赖图:

    ┌─────────────┐
    │  核心模块    │ ◀───────┐
    │  (plugin_a) │         │
    └─────────────┘         │
           │                │
           │ depends        │ owns (Rc)
           ▼                │
    ┌─────────────┐         │
    │  用户界面    │ ────────┘
    │(plugin_b)   │
    └─────────────┘

    Rc让两个插件都能持有对方,
    RefCell让它们在需要时能修改内部状态

这段代码能跑,而且跑得很顺畅。Rc让多个地方可以同时拥有这个插件的所有权,RefCell让这些拥有者可以在需要的时候修改插件的状态。

RefCell的借用标志:运行时的守门员

RefCell内部有个"借用标志"(borrow flag),就像个守门员:

RefCell内部状态:

    ┌─────────────────────────────────┐
    │         RefCell<T>              │
    │                                 │
    │   borrow_flag:                   │
    │   ┌─────┬─────┬─────┬─────┐    │
    │   │  0  │  1  │  2  │ ... │    │
    │   │空闲 │读1 │读2 │读N │    │
    │   └─────┴─────┴─────┴─────┘    │
    │         │                       │
    │         │ -1 = 写独占            │
    │                                 │
    │   ┌───────────────────────┐     │
    │   │        数据 T         │     │
    │   └───────────────────────┘     │
    └─────────────────────────────────┘

    borrow()     → flag+1  (读锁)
    borrow_mut() → flag=-1  (写锁)
    drop()       → 恢复原值

当你调用borrow()或borrow_mut()时,RefCell会检查这个标志:

// 这些操作是安全的
let r1 = refcell.borrow();     // flag: 0 → 1 (开始读)
let r2 = refcell.borrow();     // flag: 1 → 2 (继续读)
drop(r1);                      // flag: 2 → 1 (结束一个读)
drop(r2);                      // flag: 1 → 0 (全部结束)

// 这些会panic
let r1 = refcell.borrow();     // flag: 0 → 1 (开始读)
let w1 = refcell.borrow_mut(); // panic! 已经有人在读了

let w1 = refcell.borrow_mut(); // flag: 0 → -1 (开始写)
let w2 = refcell.borrow_mut(); // panic! 已经有人在写

运行时检查的代价是什么?

天下没有免费的午餐,RefCell的灵活性也不是没有代价的。

有人做过benchmark,对比直接修改、RefCell修改、Rc修改的性能:

操作类型耗时(纳秒)相对倍数
直接修改1.01倍
RefCell borrow_mut13.5约13倍
Rc 修改21.0约21倍
性能对比(纳秒级):

    直接修改:  ███ 1.0ns
    RefCell:  ████████████████████ 13.5ns
    Rc+RefCell: █████████████████████████████ 21.0ns

    看着差距大,但记住:这是纳秒!
    你的代码大部分时间不在这种操作上

看着挺吓人,但仔细想想,这是纳秒级别的差异。在大多数实际应用中,这点开销完全可以忽略。你要是在一个紧密循环里频繁调用borrow_mut,那可能是设计有问题,不是RefCell的问题。

有个教训我要分享:我用RefCell的时候,不小心搞出了循环依赖,插件A依赖B,B又依赖A。结果在运行时触发了借用错误,程序直接panic:

循环依赖的危险:

    ┌─────────┐         ┌─────────┐
    │ Plugin A│◄────────│ Plugin B│
    └─────────┘         └─────────┘
         │                   │
         └───────相互持有─────┘
               (Rc<RefCell>)
         可能导致内存泄漏!
    需要用Weak打破循环

但这个panic让我意识到,RefCell给你的是灵活性,不是自由。你还是得把逻辑设计清楚。

RefCell不是打破规则,是完善规则

理解RefCell后,我最大的感受是:它不是在打破Rust的规则,而是在完善这套规则。

Rust的安全策略:

    编译期检查
    ┌─────────────────────┐
    │   静态分析         │ ← 大部分情况
    │   (借用检查器)      │
    └─────────────────────┘
             │ 无法表达?
    ┌─────────────────────┐
    │   运行时检查         │ ← RefCell/Mutex
    │   (动态借用检查)     │
    └─────────────────────┘

有些场景,编译期的静态检查确实无法表达,但运行时的动态检查可以。RefCell就是连接这两者的桥梁。它让你在保持Rust安全承诺的前提下,写出更灵活、更动态的架构。

借用检查器像一堵墙,RefCell就是墙上那扇门。你得找到正确的钥匙才能打开它,但有了这扇门,你可以去那些原本去不了的地方。

借用检查器这堵墙:

    ❌ 编译期检查不过
    ┌─────────────────────────────┐
    │                             │
    │   ╔═══════════════════════╗  │
    │   ║   Rust借用检查器      ║  │
    │   ║       (墙壁)          ║  │
    │   ╠═══════════════════════╣  │
    │   ║                       ║  │
    │   ║   [🚪 RefCell]       ║  │ ← 后门
    │   ║   (运行时检查)        ║  │
    │   ║                       ║  │
    │   ╚═══════════════════════╝  │
    │                             │
    └─────────────────────────────┘
              ✅ 运行时通过

总结

RefCell做的事情其实很简单:它把借用检查从编译期推迟到了运行时,让你能在共享所有权的情况下修改内部状态。

用的时候记住几点:

  1. 运行时检查意味着违规会panic,不是编译错误
  2. 和Rc配合用,解决共享可变状态的问题
  3. 性能开销是纳秒级的,实际应用中不用担心
  4. 它给的是灵活性,不是让你写乱七八糟的代码

Rust的设计哲学不是把你限制死,而是在安全的前提下给你足够的表达力。RefCell就是这种哲学的体现:规则不是用来打破的,是用来理解和运用的。


觉得这篇文章有用吗?

  1. 点赞:如果觉得有帮助,点个赞让更多人看到
  2. 转发:分享给正在学习Rust的朋友或同事
  3. 关注:关注梦兽编程,不错过更多实用技术文章
  4. 留言:有什么问题或想法?欢迎在评论区交流

你的支持是我持续创作的最大动力!


参考资源