智能指针
本章讲 Rust 里「指针」的完整工具箱:谁拥有数据、谁能改数据(内部可变性)、 谁能在多线程里共享数据。前置知识:所有权与移动、 trait、 泛型与生命周期。
本章目标
- 能看着一张需求,从
Box/Rc/Arc/RefCell/Cell/Mutex里挑出正确的组合。 - 能自己实现一个带
Deref与Drop的智能指针(smart pointer),并解释解引用强制转换 (deref coercion)是编译器怎么插进去的。 - 能解释
Rc为什么不能改数据、RefCell为什么能在&self方法里改数据,以及后者的代价。 - 能用
Weak打破Rc引用循环(reference cycle),并用strong_count/weak_count证明没泄漏。 - 能说清闭包(closure)的三种捕获方式,知道什么时候必须写
move、move对Copy类型意味着什么。 - 能独立定位
E0382/E0507/E0373与RefCell运行时 panic 的成因。
💡 对照:Rust 的智能指针不是「给 GC 语言加语法糖」,而是把「所有权怎么分、 数据能不能改、跨不跨线程」这三件事变成类型系统里的三个正交维度。C++ 里这三件事 靠约定和注释,Rust 里靠类型——选错类型就编译不过或运行期 panic。
指针总览:一张表决定选型
标准库里这些类型都实现了 Deref,所以都能用 *p 取值、都能自动解引用调用方法, 它们都算「智能指针」。区别只在所有权语义、可变性、线程安全和运行时开销。
| 类型 | 所有权数 | 可变性 | 线程安全 | 运行时开销 | 典型用途 |
|---|---|---|---|---|---|
&T | 多个(借用,不拥有) | 只读 | Send/Sync 取决于 T | 无(就是一个机器字) | 只读传参、切片视图 |
&mut T | 唯一(借用,不拥有) | 独占可变 | Send,不同步 Sync | 无 | 可变传参,编译期保证独占 |
Box<T> | 唯一 | 可变(DerefMut) | 随 T | 一次堆分配 | 递归类型、大对象转移、trait 对象 |
Rc<T> | 多个(引用计数) | 只能只读 | !Send !Sync | 非原子计数增减 | 单线程共享只读数据、图/树 |
Arc<T> | 多个(原子计数) | 只能只读 | Send+Sync(若 T: Send+Sync) | 原子计数(比 Rc 慢) | 跨线程共享只读数据 |
Cell<T> | 唯一(但可共享 &) | 可整体替换 | !Sync | 近乎为零 | Copy 小字段的计数器、状态位 |
RefCell<T> | 唯一(但可共享 &) | 运行时可读写 | !Sync | 借用计数(非原子) | 单线程共享可变数据 |
Mutex<T> | 唯一(但可共享 &) | 运行时可读写 | Sync(若 T: Send) | 加锁/解锁、可能阻塞 | 跨线程共享可变数据 |
RwLock<T> | 唯一(但可共享 &) | 运行时可读写 | Sync | 读写锁,读者并行 | 读多写少的跨线程共享 |
Cow<'a, T> | 借用或拥有二选一 | 写时克隆 | 随 T | 几乎为零,写时一次分配 | 可能不改就返回借用的 API |
Weak<T> | 零个(弱引用) | 只读升强 | 随 Rc/Arc | 与 Rc 同一控制块 | 打破循环、缓存、观察者 |
OnceLock<T> | 全局唯一 | 初始化一次后只读 | Send+Sync | 一次原子判断 | 进程级单例、延迟初始化 |
LazyLock<T> | 全局唯一 | 初始化一次后只读 | Send+Sync | 一次原子判断 | 用表达式初始化的静态表 |
「运行时开销」一列的读法:Box / &T / &mut T 是零或一次分配; Rc / Arc 每次 clone 要改计数;RefCell 每次借用要改计数; Mutex / RwLock 每次访问都要过操作系统同步原语。
栈上指针 + 堆上数据长什么样
栈(函数栈帧,编译期已知大小) 堆(运行时分配,大小任意)
let b: Box<String> = Box::new(String::from("hi"));
b ┌──────────┐ ┌─────────────────────────┐
┌────────┐ │ ptr │──────────▶ │ String 结构体(24 字节)│
│ 0x7ff… │───┐ ├──────────┤ │ ptr ───────────────────┼──▶ ┌───┬───┐
└────────┘ │ │ len = 2 │ │ len = 2 │ │ h │ i │
8 字节指针 └───▶│ cap = 2 │ │ cap = 2 │ └───┴───┘
└──────────┘ └─────────────────────────┘
栈上的 String 本身 Box 指向的 String 在堆上Box<T> 本身永远是 8 字节(一个指针),和 T 多大无关——这就是「装箱」的全部意义: 把编译期未知或无法确定的大小挪到运行期。
递归类型为什么必须 Box:
enum List { Cons(i32, List), Nil } ← 编译器算不出大小,报 E0072
List 的大小 = max(i32 + List, 0) = ∞ (自指,无解)
加一层指针就有解了:
enum List { Cons(i32, Box<List>), Nil }
List 的大小 = max(4 + 8, 0) = 16 字节(含对齐) ← 已知!
├─ i32 占 4 字节
└─ Box<List> 占 8 字节,指向堆上的下一个 List🧠 原理:
size_of::<T>()必须是编译期常量,因为栈帧大小在生成代码时就定死了。 只有指针/引用(&、Box、Rc、Arc)的大小与所指数据无关,所以它们能「打断」 无限递归的尺寸计算。这也解释了&[T](宽指针:指针 + 长度)为什么是 16 字节。
💡 对照:Java 里所有对象变量天然就是「指针」,栈上只有引用,所以没有递归类型的 尺寸问题;代价是每次访问都要多一次间接寻址,且装箱(boxing)的时机由 GC 决定。 Rust 让你显式选择:想要引用语义就写
Box,不写就在栈上直接展开。
Box<T>:唯一的堆所有权
Box<T> 是最朴素的智能指针:一个指向堆上 T 的指针,独占所有权,出了作用域释放堆内存。
rust
fn main() {
let b = Box::new(5); // 5 在堆上,b 在栈上
println!("b = {}", *b);
println!("b + 1 = {}", *b + 1);
// b 在这里离开作用域,堆上的 5 被释放
}装箱的动机:递归类型与「转移大对象」
rust
#[derive(Debug)]
enum List {
Cons(i32, Box<List>), // 必须是 Box:否则 List 尺寸无限大(E0072)
Nil,
}
fn main() {
// Cons(1, Cons(2, Cons(3, Nil)))
let list = List::Cons(
1,
Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))),
);
println!("{list:?}");
}输出:
Cons(1, Cons(2, Cons(3, Nil)))
如果写成 Cons(i32, List),编译器会直接拒绝:
error[E0072]: recursive type `List` has infinite size
--> e0072.rs:2:1
|
2 | enum List {
| ^^^^^^^^^
3 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
3 | Cons(i32, Box<List>),
| ++++ +第二个动机是避免大对象搬家。把一个 4 KB 的结构体按值传来传去,每次移动都要 memcpy;Box::new 只有一次分配,之后传的永远是一个 8 字节指针:
rust
struct Big {
data: [u64; 512], // 4 KiB
}
fn consume(_big: Big) {}
fn main() {
let boxed = Box::new(Big { data: [0; 512] });
consume(*boxed); // 仍然会拷贝 4 KiB:解引用后是按值传
// 想避免拷贝就传 Box 本身或 &Big
}⚠️ 陷阱:
*boxed传给按值参数会把值从堆上搬出来(栈拷贝),不是零成本。 要么让函数收&Big,要么让函数收Box<Big>。只有Box<T>本身移动才是廉价的。
Box<dyn Trait>:trait 对象的标准载体
dyn Trait 是不定长类型(unsized type),不能直接放在栈上、不能当返回值,必须装进 指针里。Box<dyn Trait> 是最常用的一种。
rust
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Square {
side: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.r * self.r
}
fn name(&self) -> &str {
"circle"
}
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
fn name(&self) -> &str {
"square"
}
}
fn main() {
// 不同具体类型放进同一个 Vec,靠 Box<dyn Shape> 抹掉类型
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { side: 2.0 }),
];
for s in &shapes {
println!("{} -> {:.3}", s.name(), s.area());
}
}输出:
circle -> 3.142 square -> 4.000
Box<dyn Shape> 是个胖指针(fat pointer):16 字节 = 数据指针 + 虚表(vtable)指针。
Box<dyn Shape>(16 字节)
┌────────────┬────────────┐
│ data ptr │ vtable ptr │
└─────┬──────┴─────┬──────┘
│ └──▶ ┌──────────────────────┐
│ │ drop_in_place │
│ │ size / align │
│ │ area() ──▶ Circle::area
│ │ name() ──▶ Circle::name
│ └──────────────────────┘
▼
堆上的 Circle { r: 1.0 }🧠 原理:
dyn Trait的「动态分发」就是运行期查虚表跳转,和 C++ 虚函数一样; 而泛型fn f<T: Shape>(x: T)是静态分发,每个具体类型生成一份代码(单态化), 没有虚表、也没有指针间接。选dyn还是泛型,本质是「代码体积 vs 运行期灵活性」。
🚀 进阶:
dyn Trait默认带+ 'static约束,想放带生命周期的东西必须写Box<dyn Shape + 'a>。Box<dyn Fn(i32) -> i32>是 「闭包(Closure)」一节闭包返回值的标准写法。
Box::leak:故意泄漏
Box::leak(b) 把 Box<T> 的所有权「漏」掉,返回 &'a mut T。这个引用永远不释放, 生命周期可以长达 'static。
rust
fn config_name() -> &'static str {
// 启动时初始化一次、活到进程结束的配置字符串
let boxed: Box<str> = Box::from("my-app");
Box::leak(boxed) // 返回 &'static mut str,自动协变为 &'static str
}
fn main() {
println!("{}", config_name());
}输出:
my-app
Box::leak 是有意的内存泄漏(leak),不是 bug:Rust 允许泄漏,泄漏只是「不释放」, 不会造成悬垂指针或 UB(详见「引用循环与内存泄漏:用 Weak 打破环」)。它常用于「初始化一次、活到进程退出」的配置/单例。
⚠️ 陷阱:循环里调用
Box::leak就是真的漏内存(比如处理请求时泄漏每个请求的字符串)。 需要「初始化一次」的共享数据,用 「Rc<RefCell<T>>:单线程的共享可变」一节的OnceLock/LazyLock,它们是线程安全且 不会无限增长的。
Box<[T]> 与 Box<str>:定长化
Box<[T]> 是「拥有所有权的切片」,Box<str> 是「拥有所有权的字符串切片」。 它们把 Vec<T> / String 的容量字段丢掉,大小 = 16 字节(指针 + 长度),不能再增长。
rust
fn main() {
let v = vec![1, 2, 3, 4];
let boxed: Box<[i32]> = v.into_boxed_slice(); // 丢掉多余的 capacity
println!("{boxed:?} len = {}", boxed.len());
let s: Box<str> = "hello".into();
println!("{} len = {}", s, s.len());
}输出:
[1, 2, 3, 4] len = 4 hello len = 5
何时不必 Box
| 场景 | 该不该 Box |
|---|---|
| 结构体字段是另一个结构体,且大小固定 | 不用,直接内联(缓存友好,少一次跳转) |
递归类型(enum、链表节点) | 必须 Box(或 Rc/Arc),否则 E0072 |
| 需要在运行期决定具体类型(插件、异构集合) | 必须 Box<dyn Trait> |
| 只是想把大对象传出去 | 优先传 &T,而不是 Box<T> |
| 返回值要逃出函数 | 直接返回值即可,Rust 有返回值优化;Box 只用于打破尺寸/生命周期限制 |
| 想让数据「活到进程结束」 | Box::leak,或者更好的 OnceLock |
💡 对照:C# / Java 里
class的实例天生在堆上、struct天生在栈上;Rust 只有Box::new显式装箱。Go 的「逃逸分析」帮你自动决定堆还是栈,Rust 则是由你写Box决定,因此没有「编译器偷偷把变量搬到堆上导致性能意外」这种情况。
Deref、Drop 与自定义智能指针
*b 是语法糖
*b 会调用 Deref::deref 拿到 &T,再解引用。Deref 的定义极简:
rust
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
pub trait DerefMut: Deref {
fn deref_mut(&mut self) -> &mut Self::Target;
}rust
use std::ops::Deref;
fn main() {
let b = Box::new(String::from("hi"));
println!("{}", *b); // *(b.deref())
println!("{}", *b); // String -> str 的是 str 的 Deref,与 Box 无关
}输出:
hi hi
注意 Deref 只给不可变访问。Box<T> 额外实现了 DerefMut,所以 *b = x 能编译; Rc<T> 没有 DerefMut,所以 *rc = x 编译不过——这是设计,不是遗漏(见「T 必须是 Sized(但可以是 [T] / str)」)。
解引用强制转换链
编译器在需要 &U 但拿到 &T 时,会尝试插入 deref(),并且可以连续插多次:
MyBox<String> ──deref()──▶ String ──deref()──▶ str
&MyBox<String> &String &strrust
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0 // 返回内部值的引用,所有权仍在 MyBox 手里
}
}
fn hello(name: &str) {
println!("hello {name}");
}
fn str_len(s: &str) -> usize {
s.len()
}
fn main() {
let mb = MyBox::new(String::from("world"));
hello(&mb); // &MyBox<String> -> &String -> &str,编译器插了两次 deref
println!("len = {}", str_len(&mb));
println!("upper = {}", mb.to_uppercase()); // 方法调用也自动解引用
}输出:
hello world len = 5 upper = WORLD
🧠 原理:强制转换只发生在已知目标类型的位置(函数实参、方法接收者、
let带类型标注等)。hello(&mb)中编译器知道hello要&str,于是插入Deref::deref。如果只写let x = &mb;而不给出目标类型,就不会发生转换。
Drop 与释放顺序
Drop::drop(&mut self) 在值离开作用域时被编译器自动插入调用。关键规则:
- 同作用域内,变量按声明顺序的逆序 drop(后声明的先释放,像栈一样)。
- 结构体字段按声明顺序 drop(注意:和变量规则相反)。
- 实现了
Drop的类型,字段仍然会被自动 drop,drop里只该写「额外清理」。 Drop::drop不能手动调用;想提前释放用std::mem::drop(value)(它按值拿走并立刻析构)。
rust
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
first: Noisy,
second: Noisy,
}
fn main() {
let _a = Noisy("a(先声明)");
let _b = Noisy("b(后声明)");
println!("--- 进入内层作用域 ---");
{
let _inner = Noisy("inner");
} // inner 在这里被 drop
println!("--- 离开内层作用域 ---");
let _p = Pair {
first: Noisy("first 字段"),
second: Noisy("second 字段"),
};
let c = Noisy("c");
drop(c); // 手动提前释放,等价于 mem::drop
println!("--- main 结束,剩余变量开始逆序释放 ---");
}输出:
--- 进入内层作用域 --- drop inner --- 离开内层作用域 --- drop c --- main 结束,剩余变量开始逆序释放 --- drop second 字段 drop first 字段 drop b(后声明) drop a(先声明)
std::mem::drop 与 Drop::drop 的区别是新手最容易混的一对:
std::mem::drop(x) | Drop::drop(&mut self) | |
|---|---|---|
| 是什么 | 标准库函数 pub fn drop<T>(_x: T) {} | trait 方法 |
| 怎么调用 | drop(c); | 不能手动调用(c.drop() 报 E0040) |
| 做什么 | 按值接收 x,函数结束时 x 被析构 | 你自己实现的清理逻辑 |
| 用途 | 提前结束作用域、释放锁守卫 | 自定义资源释放(文件、socket) |
⚠️ 陷阱:
mem::drop对实现了Copy的类型是空操作——Copy类型没有所有权转移,drop(3i32)只是复制了一个 3。想「提前释放」Copy变量是不可能的,也不需要。
⚠️ 陷阱:
Drop里 panic 而外层正在 panic,会直接 abort 进程(double panic)。Drop实现里不要写会 panic 的代码;锁守卫建议.unwrap_or_else(|e| e.into_inner())。
完整的自定义智能指针
下面这个 MyBox<T> 实现了 Deref、DerefMut、Drop、Display,覆盖了语法糖、DerefMut 与 Drop 三个点 的全部机制:
rust
use std::fmt;
use std::ops::{Deref, DerefMut};
struct MyBox<T: fmt::Display>(T);
impl<T: fmt::Display> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T: fmt::Display> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T: fmt::Display> DerefMut for MyBox<T> {
fn deref_mut(&mut self) -> &mut T {
&mut self.0
}
}
impl<T: fmt::Display> Drop for MyBox<T> {
fn drop(&mut self) {
// 真实场景这里会释放文件句柄 / socket / 归还连接池
println!("释放 MyBox: {}", self.0);
}
}
impl<T: fmt::Display> fmt::Display for MyBox<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "MyBox({})", self.0)
}
}
fn hello(name: &str) {
println!("hello {name}");
}
fn main() {
let mb = MyBox::new(42);
println!("解引用 = {}", *mb);
println!("显示 = {mb}");
let mut text = MyBox::new(String::from("world"));
hello(&text); // 解引用强制转换:&MyBox<String> -> &str
text.push_str("!"); // DerefMut:走 String 的方法
println!("改后 = {text}");
*text = String::from("重新赋值"); // *text = ... 也走 DerefMut
println!("重赋 = {text}");
drop(mb);
println!("mb 已提前释放");
}输出:
解引用 = 42 显示 = MyBox(42) hello world 改后 = MyBox(world!) 重赋 = MyBox(重新赋值) 释放 MyBox: 42 mb 已提前释放 释放 MyBox: 重新赋值
Rc<T>:单线程的共享所有权
Rc<T>(reference counting,引用计数)让多个所有者共享同一份只读数据。 数据在堆上,旁边有一个「控制块(control block)」记录强引用数和弱引用数。
栈 堆
┌──────────────────────────┐
a ──────────────▶ │ strong: 2 weak: 0 │ ← RcBox 控制块
b ──────────────▶ ├──────────────────────────┤
│ 数据:String("payload") │
└──────────────────────────┘rust
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("payload"));
println!("a 之后 strong = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // 计数 +1,不复制字符串
println!("clone 后 strong = {}", Rc::strong_count(&a)); // 2
println!("两个引用看到同一份数据:{a} / {b}");
println!("指针相同 = {}", Rc::ptr_eq(&a, &b)); // true
println!("地址相同 = {}", std::ptr::eq(&*a, &*b)); // true
drop(b);
println!("drop b 后 strong = {}", Rc::strong_count(&a)); // 1
}输出:
a 之后 strong = 1 clone 后 strong = 2 两个引用看到同一份数据:payload / payload 指针相同 = true 地址相同 = true drop b 后 strong = 1
💡 对照:
Rc::clone(&a)看起来像深拷贝,其实只是strong += 1。写a.clone()也对, 但社区惯例是写Rc::clone(&a)——为了在代码里一眼看出「这里是加计数,不是拷贝数据」。 这是 Clippy 不会报但 reviewer 会在意的可读性约定。
为什么 Rc 只读
Rc<T> 只实现了 Deref,没有实现 DerefMut:
rust
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("hi"));
let b = Rc::clone(&a);
a.push_str(" there"); // ❌ 需要 &mut String,但 Rc 只给 &String
println!("{b}");
}编译器会告诉你为什么:
error[E0596]: cannot borrow data in an `Rc` as mutable
--> rc_mut.rs:6:5
|
6 | a.push_str(" there"); // ❌ 需要 &mut String,但 Rc 只给 &String
| ^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference,
but it is not implemented for `Rc<String>`原因很实在:如果 Rc 允许可变解引用,那 a 和 b 就同时拥有了「可变访问同一份数据」的 能力,〈所有权〉一章的借用规则(要么多个只读、要么一个独占)就被彻底破坏了。 想在共享的前提下修改,必须把「借用的检查」推迟到运行期——这正是 「内部可变性:Cell 与 RefCell」一节的 RefCell。
⚠️ 陷阱:
*a读没问题(Deref给了&T),但任何需要&mut T的操作 (a.push_str(..))都会报E0596,*a = new_value则报E0594——两者都是 「Rc没实现DerefMut」。这是设计上的安全,不要用unsafe绕过。
T 必须是 Sized(但可以是 [T] / str)
Rc<T> 的 T: ?Sized 是针对 Rc<[T]> / Rc<str> 这类定长类型的;日常使用中 Rc::new(v) 要求你给的 v 是具体大小的类型。想共享一个切片:
rust
use std::rc::Rc;
fn main() {
// 先用 Vec 造,再转成 Rc<[i32]>
let rc_slice: Rc<[i32]> = vec![1, 2, 3].into();
let rc2 = Rc::clone(&rc_slice);
println!("{:?} 长度 {}", rc2, rc2.len());
let rc_str: Rc<str> = Rc::from("shared text");
println!("{} {}", rc_str, rc_str.len());
}输出:
[1, 2, 3] 长度 3 shared text 11
Rc::try_unwrap 与 Rc::get_mut:取回独占权
Rc 的数据只有在强引用数为 1 时才能安全地变成独占:
rust
use std::rc::Rc;
fn main() {
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a);
// 还有人共享 -> 拿不到可变引用
println!("get_mut 在有共享时 = {:?}", Rc::get_mut(&mut a.clone()));
// try_unwrap 在有共享时返回 Err,把 Rc 原样还给你
match Rc::try_unwrap(b) {
Ok(v) => println!("取回:{v:?}"),
Err(rc) => println!("还有人用,取不回:{:?}", &*rc),
}
// 只剩一个强引用之后,get_mut 成功,可以零拷贝改数据
let mut only = Rc::new(String::from("solo"));
if let Some(s) = Rc::get_mut(&mut only) {
s.push_str(" -> mutated");
}
println!("{}", only);
}输出:
get_mut 在有共享时 = None 还有人用,取不回:[1, 2, 3] solo -> mutated
🚀 进阶:
Rc::get_mut返回Option<&mut T>,是「用引用计数做写时复制(COW)」的 基础。想让共享转独占时只拷贝一次,就能用Arc::make_mut(对应多线程版)。
引用循环与内存泄漏:用 Weak 打破环
Rust 允许泄漏,泄漏不是 UB
Rc 的释放靠计数归零,而互相强引用会让计数永远到不了 0:
a ──Rc──▶ b a.strong = 2
▲ │ (a 变量 + b.next)
│ │ b.strong = 2
└──Rc──────┘ (b 变量 + a.next)
drop(a); drop(b); → a.strong = 1, b.strong = 1,谁都不归零,两个节点永不释放rust
use std::cell::RefCell;
use std::rc::Rc;
struct Node {
name: &'static str,
next: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("drop Node {}", self.name);
}
}
fn main() {
let a = Rc::new(Node {
name: "a",
next: RefCell::new(None),
});
let b = Rc::new(Node {
name: "b",
next: RefCell::new(None),
});
*a.next.borrow_mut() = Some(Rc::clone(&b)); // a 强引用 b
*b.next.borrow_mut() = Some(Rc::clone(&a)); // b 强引用 a -> 成环
println!("成环后:a strong = {}", Rc::strong_count(&a));
drop(a);
drop(b);
println!("两个句柄都 drop 了,但 Drop 一次也没跑 —— 这就是泄漏");
}输出:
成环后:a strong = 2 两个句柄都 drop 了,但 Drop 一次也没跑 —— 这就是泄漏
为什么这不是 unsafe:泄漏的内存不会被别人访问,没有悬垂指针、没有数据竞争, 所有安全保证(内存安全、线程安全)都还成立——只是这块内存永远不会被回收。 Rust 的承诺是「安全」,不是「不泄漏」。极端情况下 std::mem::forget 也能泄漏, 它是安全函数(详见「std::mem 常用函数」)。真正的泄漏(比如无界缓存)是性能/资源 bug,不是 UB。
💡 对照:Python 的引用计数同样处理不了循环,所以 CPython 额外加了分代 GC 来定期回收循环垃圾。Rust 没有 GC,也不打算加——它把「怎么打破循环」交给你, 给你一个零成本的工具
Weak。
用 Weak<T> 打破环
Weak<T> 是弱引用:它指向同一份数据但不增加强引用数,因此不阻止数据释放。 访问时必须先 upgrade() 升级成 Rc<T>,如果数据已经释放就返回 None。
a(父)────Rc────▶ b(子) Rc:进入数据需要它归零
│
└──Weak──▶ a Weak:指向控制块,但不阻止释放
数据结构死亡顺序:强引用数归零 -> 数据析构 -> 强引用数 0 时控制块整体释放
(Weak 存活期间控制块必须保留,这就是 weak 计数存在的意义)upgrade / downgrade 是两个方向的转换:
| 操作 | 方向 | 作用 |
|---|---|---|
Rc::downgrade(&rc) | Rc<T> → Weak<T> | 生成弱引用,weak_count += 1 |
weak.upgrade() | Weak<T> → Option<Rc<T>> | 成功则 strong_count += 1,失败返回 None |
父子树的标准写法:父节点用 Vec<Rc<Node>> 强引用子节点(拥有), 子节点用 Weak<Node> 弱引用父节点(不拥有)。这不会有环:所有权是单向的。
rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: String,
parent: RefCell<Weak<Node>>, // 子 -> 父:弱引用
children: RefCell<Vec<Rc<Node>>>, // 父 -> 子:强引用
}
impl Node {
fn new(name: &str) -> Rc<Node> {
Rc::new(Node {
name: name.to_string(),
parent: RefCell::new(Weak::new()), // Weak::new() 是一个「空」弱引用
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(Rc::clone(child));
}
}
fn main() {
let root = Node::new("root");
let leaf = Node::new("leaf");
Node::add_child(&root, &leaf);
// leaf 的强引用:局部变量一份 + root.children 里一份 = 2
// leaf 的弱引用:没人弱引用它 = 0
println!(
"leaf: strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf)
);
// 自下而上:upgrade 拿到父节点
let up: Option<Rc<Node>> = leaf.parent.borrow().upgrade();
println!("leaf.parent = {}", up.as_ref().map_or("(已释放)", |p| &p.name));
// root 的强引用只有局部变量一份 = 1;
// 弱引用是 leaf.parent 里的那份 = 1
println!(
"root: strong = {}, weak = {}",
Rc::strong_count(&root),
Rc::weak_count(&root)
);
} // root 和 leaf 都离开作用域 -> strong 归零 -> 两者都正常析构,无泄漏输出:
leaf: strong = 2, weak = 0 leaf.parent = root root: strong = 2, weak = 1
验证无泄漏:给 Weak 版的 Node 加上 Drop,然后运行下面的完整程序, 就能看到 root 和 leaf 都被正常析构:
rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: String,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
impl Node {
fn new(name: &str) -> Rc<Node> {
Rc::new(Node {
name: name.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(Rc::clone(child));
}
}
impl Drop for Node {
fn drop(&mut self) {
println!("drop {}", self.name);
}
}
fn main() {
let root = Node::new("root");
let leaf = Node::new("leaf");
Node::add_child(&root, &leaf);
println!("构建完成,main 即将结束");
}输出:
构建完成,main 即将结束 drop root drop leaf
注意释放顺序:局部变量按声明逆序释放,所以 leaf 的句柄先 drop(计数从 2 降到 1), 然后 root 的句柄 drop(计数归零)→ root 的 Drop::drop 执行 → 编译器接着释放 root 的字段 → children: Vec<Rc<Node>> 里那份 leaf 的引用被 drop,leaf 计数归零 → leaf 的 Drop::drop 执行。 两行 drop 都出现,就是「没有泄漏」的证据;如果 parent 用的是 Rc 而不是 Weak, 这里一行都不会打印。
⚠️ 陷阱:
Weak的upgrade()结果是Option<Rc<T>>,必须处理None(数据已被释放)。不要在upgrade()拿到Rc后把它存进会成环的地方,否则又变回强引用环。 常见错误是self.parent.borrow().upgrade().unwrap()——父节点已释放时会 panic。
🧠 原理:
Weak的计数(weak_count)保证控制块活到最后一个弱引用消失。 强引用先归零 → 数据(T)被析构 → 控制块保留到weak_count == 0才整体释放。 所以upgrade()总是安全的:要么数据还在,要么控制块明确告诉你「已经没了」。
💡 对照:C++ 的
weak_ptr::lock()和Weak::upgrade()语义一致。 区别是 C++ 需要手动shared_ptr/weak_ptr配对,漏写就循环泄漏; Rust 里Weak必须显式upgrade才能用,忘不掉。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。