阶段二 · 智能指针

组合,循环引用与 Weak

一句话总结

Rc(多所有者)和 RefCell(内部可变)组合成 Rc<RefCell<T>>, 就得到"既可共享, 又可修改"的数据--这是构建图, 树等结构的常用模式.
但它有个陷阱: 两个对象互相用 Rc 指向对方会形成循环引用, 导致引用计数永不归零, 内存泄漏.
破解之道是用 Weak 弱引用打破环.

经典组合: Rc<RefCell>

前两篇各留了一半: Rc 给多个所有者但只读, RefCell 给内部可变但单一所有者. 把它们嵌套起来, 两个能力就都有了:

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

// 多个所有者共享, 且每个都能修改
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

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

a.borrow_mut().push(4); // 通过 a 修改
b.borrow_mut().push(5); // 通过 b 修改

println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5] -- 三者看到的是同一份数据

读法是从外到内: Rc 负责"多个所有者共享", RefCell 负责"共享的同时还能改". 这个组合是单线程下构建可变共享数据结构(链表, 树, 图)的标准搭配.

Rc<RefCell<T>> 像一份多人共管, 可编辑的共享文档: Rc 是"多人都持有访问权"(共享所有权), RefCell 是"任何持有者都能编辑, 但同一时刻的编辑权要排他"(内部可变 + 运行期检查).
它在单线程里扮演的角色, 正是多线程里 Arc<Mutex<T>> 的角色(第 10 篇)--结构完全对应.

陷阱: 循环引用导致内存泄漏

引用计数有一个众所周知的软肋: 循环引用. 如果对象 A 用 Rc 指向 B, B 又用 Rc 指向 A, 它们的引用计数会互相托底, 永远不会归零:

A.next ──Rc──▶ B
B.next ──Rc──▶ A ← 互相指向

即使外部不再持有 A, B:
A 的计数 ≥ 1(因为 B 还指着它)
B 的计数 ≥ 1(因为 A 还指着它)
→ 两者计数都归不了零 → 永不释放 → 内存泄漏
use std::rc::Rc;
use std::cell::RefCell;

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

let a = Rc::new(Node { next: RefCell::new(None) });
let b = Rc::new(Node { next: RefCell::new(None) });

*a.next.borrow_mut() = Some(Rc::clone(&b)); // a → b
*b.next.borrow_mut() = Some(Rc::clone(&a)); // b → a, 环形成

// 函数结束, a, b 离开作用域, 但它们的计数因互指而停在 1, 数据永不释放

Rust 也会内存泄漏?

是的. Rust 的安全保证针对的是"内存不安全"(悬垂, 越界, 数据竞争), 而内存泄漏(该释放的没释放)在 Rust 看来是"安全但不理想"--它不会导致崩溃或未定义行为, 因此不被编译器禁止.

Rc 循环引用是制造泄漏的主要途径. 这提醒我们: 安全 ≠ 没有任何资源问题, 设计共享结构时要警惕成环.

Weak: 不增加计数的"弱引用"

解法是 Weak<T>(弱引用). 它和 Rc 指向同一数据, 但有本质区别: Weak 不增加强引用计数, 因此不拥有数据, 不阻止其被释放.

Rc<T>(强引用) Weak<T>(弱引用)
影响释放 计数不为零就不释放 不影响释放
表达的关系 "我拥有它" "我引用它, 但不拥有"
访问数据 直接用 upgrade() 尝试升级为 Rc

因为 Weak 指向的数据可能已被释放, 访问前必须用 upgrade() 检查--它返回 Option<Rc<T>>: 数据还在则 Some, 已释放则 None:

use std::rc::{Rc, Weak};

let strong = Rc::new(5);
let weak: Weak<i32> = Rc::downgrade(&strong); // 从 Rc 创建 Weak

// 用之前必须 upgrade 检查数据是否还活着
if let Some(rc) = weak.upgrade() {
println!("还在: {rc}"); // 还在: 5
}

drop(strong); // 强引用没了, 数据被释放
println!("{:?}", weak.upgrade()); // None -- 数据已释放

典型应用: 父子关系破环

最常见的破环场景是父子双向引用: 子节点需要访问父节点, 父节点也持有子节点. 规则是--所有权方向用 Rc, 反向用 Weak:

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
children: RefCell<Vec<Rc<Node>>>, // 父 → 子: 强引用(父拥有子)
parent: RefCell<Weak<Node>>, // 子 → 父: 弱引用(子不拥有父)
}

设计原则: 谁拥有谁, 决定强弱

判断该用 Rc 还是 Weak, 问一句: "这个引用是否表达所有权?"

父节点拥有子节点(子的存活由父负责), 所以父→子用 Rc; 子不拥有父(只是想能访问到), 所以子→父用 Weak. 这样环被打破: 即使外部释放, 父→子的强引用断开后, 子的计数能归零.

"所有权方向 Rc, 引用方向 Weak"是破环的通用心法.

快速回顾

  • Rc<RefCell>: 多所有者 + 内部可变, 单线程构建可变共享结构的标准组合; 对应多线程的 Arc<Mutex<T>>.
  • 循环引用: 两个对象用 Rc 互指, 计数互相托底, 永不归零, 造成内存泄漏.
  • Rust 允许泄漏: 泄漏是"安全但不理想", 不被编译器禁止; Rc 成环是主因.
  • Weak: 弱引用, 不增加强计数, 不阻止释放; 访问需 upgrade() 返回 Option.
  • 破环心法: 表达所有权的方向用 Rc, 非所有权的反向引用用 Weak(如父子关系).

动手练习

  1. 用一句话解释: 为什么 Rc<RefCell<T>> 能同时做到"共享"和"可变", 而单独的 Rc 或 RefCell 不行?
参考答案

Rc 提供多所有者共享但只读; RefCell 提供内部可变但只能单一所有者. 嵌套后, Rc 负责"多个所有者共享同一份", RefCell 负责"被共享的数据仍可通过 borrow_mut 修改", 两者能力互补, 合起来即"可共享 + 可变".

  1. 下面构造了一个环. 函数结束时, a 和 b 的数据会被释放吗? 为什么?

    let a = Rc::new(RefCell::new(Node::new()));
    let b = Rc::new(RefCell::new(Node::new()));
    a.borrow_mut().link = Some(Rc::clone(&b));
    b.borrow_mut().link = Some(Rc::clone(&a));
参考答案

不会被释放, 发生内存泄漏. 函数结束时局部变量 a, b 离开作用域, 各自的强计数减一; 但由于 a 内部持有指向 b 的 Rc, b 内部持有指向 a 的 Rc, 两者的强计数都停在 1, 永远归不了零, 数据无法释放.

这是经典的 Rc 循环引用泄漏. 修法: 把其中一个方向的 Rc 改为 Weak.

  1. Weak 指向的数据可能已被释放, 所以访问要用 upgrade(). 它返回什么类型? 如何使用?
参考答案

返回 Option<Rc<T>>: 数据还活着则得到 Some(Rc<T>)(同时强计数临时加一, 保证使用时它不被释放), 数据已释放则得到 None. 典型用法:

if let Some(rc) = weak.upgrade() {
// 数据还在, 用 rc 访问
} else {
// 数据已被释放
}
  1. 设计一棵树, 节点既要能访问子节点, 也要能访问父节点. 父子各用 Rc 还是 Weak? 给出理由.
参考答案

父→子用 Rc(强引用), 子→父用 Weak(弱引用). 理由: 父节点拥有子节点--子节点的存活应由父负责, 所以用表达所有权的 Rc; 子节点只是需要访问父节点而非拥有它, 用不增加计数的 Weak.

这样不会成环: 释放根节点后, 父→子的强引用逐层断开, 各节点计数能正常归零. 结构示意:

struct Node {
children: RefCell<Vec<Rc<Node>>>, // 强: 父拥有子
parent: RefCell<Weak<Node>>, // 弱: 子不拥有父
}