Rc和RefCell:严父Rust的叛逆小儿子

背景

这是rust九九八十一难的第三篇,说下RcRefCell。最初想分开整理,但这俩经常一起用,所以集中说。这俩都是智能指针,侧重点不同,Rc偏向管理所有权,RefCell管理内部可变性。(什么是智能指针?智能指针 = 内存管理 + 额外功能,可以参考上一篇介绍Box的文章)。最初用这俩晕的不行,为了念头通达,研究了下用法和原理。

一、简单入门

跟以前一样,先举三个例子,看下怎么用:

1. 只用 Rc(多所有权共享)
rust 复制代码
use std::rc::Rc;

#[derive(Debug)]
struct Person {
    name: String,
    age: u32,
}

fn main() {
    let alice = Rc::new(Person { name: "Alice".to_string(), age: 30 });
    let bob = Rc::clone(&alice);
    let charlie = Rc::clone(&alice);

    println!("alice = {:?}, bob = {:?}, charlie = {:?}", alice, bob, charlie);
    println!("Rc strong count = {}", Rc::strong_count(&alice)); // 3
}

Rc 允许多个变量共享Person。alice不可变,如果需要可变,就需要下面的 RefCell

2. 只用 RefCell(内部可变性)
rust 复制代码
use std::cell::RefCell;

#[derive(Debug)]
struct Counter {
    value: i32,
}

fn main() {
    let counter = RefCell::new(Counter { value: 0 });

    counter.borrow_mut().value += 1;
    counter.borrow_mut().value += 2;

    println!("counter = {:?}", counter.borrow()); // value = 3
}

RefCell 允许在运行时对不可变变量进行可变借用

3. Rc + RefCell(多所有权 + 可变性)
rust 复制代码
use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    name: String,
    value: i32,
}

fn main() {
    let shared_node = Rc::new(RefCell::new(Node { name: "Node2".to_string(), value: 10 }));

    let a = Rc::clone(&shared_node);
    let b = Rc::clone(&shared_node);

    a.borrow_mut().value += 5;
    println!("b sees: {:?}", b.borrow()); // 输出 value = 15

    println!("Rc strong count = {}", Rc::strong_count(&shared_node)); // 3
}

这种组合常用在树或图结构中,既能多所有权,又可变。

二、 Rc:多所有权的超级共享指针

在 Rust 的世界里,内存管理被严肃地控制着。想象一下,如果你有一个宝贵的蛋糕(数据),但是你想让好几个朋友同时享用该怎么办?这时候就轮到 Rc(Reference Counted) 上场了。

Rc 的特点很简单:

  • 多所有权:单线程环境,多个 Rc 可以共享同一个数据
  • 自动释放:当最后一个 Rc 离开作用域,内存自动回收
  • 不可变性:共享的数据默认不可修改

基本用法:

rust 复制代码
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: Vec<Rc<Node>>,
}

fn main() {
    let leaf1 = Rc::new(Node { value: 1, children: vec![] });
    let leaf2 = Rc::new(Node { value: 2, children: vec![] });

    let root = Rc::new(Node { value: 0, children: vec![Rc::clone(&leaf1), Rc::clone(&leaf2)] });

    println!("{:?}", root);
}

说明

  • Rc::clone(&leaf1)并不拷贝底层数据,只增加引用计数

  • 当最后一个 Rc 离开作用域时,内存自动释放

但是,Rc 也有一些"脾气":

  1. 你不能直接修改它的内容
  2. 循环引用会导致内存泄漏

如果想修改数据,那么就需要 RefCell 上场了,内存泄漏后面也会说。

三、RefCell不可变 vs 可变借用规则:表面规矩,背地放纵

这里总结说下RefCell 的规则:

  • 多个不可变借用可以共存

    rust 复制代码
    use std::cell::RefCell;
    
    let v = RefCell::new(10);
    let r1 = v.borrow();
    let r2 = v.borrow(); // ✅ 可以多个不可变借用
    println!("{} {}", *r1, *r2);
  • 只能有一个可变借用

    rust 复制代码
    let v = RefCell::new(10);
    let mut m = v.borrow_mut(); // ✅ 独占可变借用
    *m += 1;
  • 不可变借用和可变借用不能同时存在

    ini 复制代码
    let v = RefCell::new(10);
    
    let r1 = v.borrow();
    let mut m = v.borrow_mut(); // 💥 panic: already borrowed

    编译器不会报错 ✅

    运行时检查到「一边一堆人看,一边又有人乱改」 ❌ → 直接 panic

  • 借用计数在运行时维护

    • borrow() 会把「不可变借用计数」+1,类比上面观众
    • borrow_mut() 要求当前「不可变借用计数=0 且 没有可变借用」,否则 panic。

    所以并不是「RefCell 只能借用一次」,而是:

    • 不可变借用:可以多次,只要没有可变借用。
    • 可变借用:一次只能有一个,且不能和不可变借用共存。

四、Rc配合RefCell使用:两全其美

如果只用RefCell

ini 复制代码
use std::cell::RefCell;

let cat = RefCell::new("喵".to_string());

let r1 = cat.borrow();        // ✅ 不可变借用
let r2 = cat.borrow();        // ✅ 多个不可变借用
// let m = cat.borrow_mut();  // ❌ panic!已有不可变借用存在

要同时满足 "多方拥有""可以修改" 两个需求,但 Rust 默认规则不允许这么做,所以就需要 Rc + RefCell 配合。

ini 复制代码
use std::cell::RefCell;
use std::rc::Rc;

let cat = Rc::new(RefCell::new("喵".to_string()));
let owner1 = cat.clone();
let owner2 = cat.clone();

*owner1.borrow_mut() = "喵(霸气御姐)".to_string();
*owner2.borrow_mut() = "喵(甜美萝莉)".to_string();

Rc :允许多个 owner 拥有同一份数据。

RefCell :允许在运行时可变访问数据。

组合起来,你就可以:

  1. 多人持有同一个值。
  2. 每个人都能修改它(独占修改时会检查借用规则)。

当然,这里也有个问题,像猫突然有了「多份培养计划书」,每个人都有决策权。

  • 主人1 把猫训练成「霸气系」;
  • 主人2 把猫训练成「甜美型」;
  • 猫自己心里想:我是谁?我在哪?我到底是几号人格?

从这里可以看出 RefCell 的意义:

「Rust 像深宅大院的高高围墙,但 RefCell 告诉你:其实有狗洞可以钻。

它有灵活性,但需要开发者自觉

五、循环引用问题:互不放手

1. 悲剧的来源

在rust代码里,A引用了B,B引用了A,结果互相等待,无法释放。举例如下:

rust 复制代码
use std::rc::Rc;
use std::cell::RefCell;

struct Node {
    value: i32,
    next: RefCell<Option<Rc<Node>>>,
}

fn main() {
    let a = Rc::new(Node { value: 1, next: RefCell::new(None) });
    let b = Rc::new(Node { value: 2, next: RefCell::new(None) });

    // a -> b
    *a.next.borrow_mut() = Some(Rc::clone(&b));
    // b -> a
    *b.next.borrow_mut() = Some(Rc::clone(&a));

    println!("a strong = {}", Rc::strong_count(&a)); // 2
    println!("b strong = {}", Rc::strong_count(&b)); // 2
} // ❌ a 和 b 都不会被释放
  • 结果:
    • a 和 b 都互相强引用对方。
    • 当作用域结束时,Rc 的引用计数永远不归零。
    • 析构函数 (Drop) 不会执行 → 内存泄漏。
2. 怎么解决:弱引用(Weak)

Rust 给的解药是 Weak

rust 复制代码
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,      // 弱引用,避免循环
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let parent = Rc::new(Node { value: 1, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
    let child = Rc::new(Node { value: 2, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });

    *child.parent.borrow_mut() = Rc::downgrade(&parent); // 弱引用
    parent.children.borrow_mut().push(Rc::clone(&child));

    println!("parent strong = {}, weak = {}", Rc::strong_count(&parent), Rc::weak_count(&parent));
}

Weak 不会增加强引用计数。

Rc 全部释放后,Weak 会自动失效,不会导致内存泄漏。

3. 如何检测循环引用

Rust 程序跑完以后,用 Valgrind 检查内存:

bash 复制代码
cargo install cargo-valgrind
cargo valgrind run

如果有循环引用导致的内存泄漏,Valgrind 会在报告里指出「仍然可达的内存块」。

在官方的链接里可以看到使用的示例,除了循环引用,还可以检查其他内存问题

ASan 是 clang/LLVM 提供的内存错误检测工具。 Rust 可以通过 cargo-leak 来使用它:

arduino 复制代码
cargo install cargo-leak
cargo leak run

它能检测:

Miri 可以检查 未定义行为,包括借用冲突、悬垂引用,但不直接报循环引用。 不过它能帮助你发现某些「RefCell 借用未释放」的问题。

arduino 复制代码
cargo miri setup
cargo miri run

总结下:

Valgrind / heaptrack → 看堆里有没有垃圾没清掉。

ASan / cargo-leak → 动态监控,发现泄漏就报警。

Miri → 借用规则检查,侧面发现 RefCell 使用不当。

六、总结

除了循环引用,这俩有额外性能开销,每次 borrow() / borrow_mut() 都要更新内部的计数器(运行时检查),这比普通 &T / &mut T 慢。滥用会导致「Rust 退化成脚本语言」,如果你到处用 Rc<RefCell<T>>,等于放弃了编译期安全保障,最后写出来的 Rust 代码,跟 Python 没啥区别(还不如 Python 方便)。尽量当个高手,在编译期就搞定一切,实在不行再用 Rc<RefCell<T>>打个补丁。

本人公众号大鱼七成饱,历史文章会在上面同步

相关推荐
l1t4 小时前
使用DeepSeek辅助测试一个rust编写的postgresql协议工具包convergence
开发语言·postgresql·rust·协议·datafusion
Kiri霧5 小时前
Rust数组与向量
开发语言·后端·rust
特立独行的猫a5 小时前
Rust语言入门难,难在哪?所有权、借用检查器、生命周期和泛型介绍
开发语言·后端·rust
yihai-lin10 小时前
Rust/C/C++ 混合构建 - Cmake集成Cargo编译动态库
c语言·c++·rust
fcm1910 小时前
(6) tauri之前端框架性能对比
前端·javascript·rust·前端框架·vue·react
yihailin1 天前
Rust/C/C++ 混合构建 - Cmake集成Cargo编译动态库
rust
李剑一1 天前
为了免受再来一刀的痛苦,我耗时两天开发了一款《提肛助手》
前端·vue.js·rust
红尘散仙1 天前
使用 Tauri Plugin-Store 实现 Zustand 持久化与多窗口数据同步
前端·rust·electron