- 教程
- 文档
【免费下载链接】book
The Rust Programming Language
导读
Rc<T>(Reference Counting,引用计数)是 Rust 标准库中用于实现多所有权的核心智能指针:它允许同一个堆上值被程序的多个部分同时持有所有权,并通过计数决定何时安全回收。本篇文章基于《The Rust Programming Language》开源仓库的 src/ch15-04-rc.md 章节,以经典的 cons list 链表为例,完整演示如何用Rc<T>替换Box<T>解决"一个值被多处共享"的编译期所有权冲突,并配合仓库中的可运行代码与输出,讲透引用计数增减、Rc::clone与深拷贝clone的本质区别,以及Rc<T>为何只能用于单线程场景。读完你不仅能正确使用Rc<T>,还能理解它与后续RefCell<T>组合使用的缘由。
什么是多所有权:为什么单个值需要多个拥有者
在大多数情况下,Rust 的所有权模型清晰明确:你能准确说出某个值归哪个变量所有。但现实中存在一种场景——同一个值可能有多个拥有者。
最典型的例子是图(graph)数据结构:多条边(edge)可能指向同一个节点(node),从概念上讲,这个节点同时被指向它的所有边共同拥有。只有当不再有任何边指向该节点(即它没有任何拥有者)时,它才能被安全清理。
Rc<T>正是为这种需求设计的标准库类型。它维护着一个"对这个值有多少个引用"的计数器:
- 当引用数为0时,说明值已不再被使用,可以安全清理,且不会产生悬垂引用;
- 当引用数大于 0 时,值保持有效。
一个形象的类比:Rc<T>像客厅里的一台电视机。第一个人进屋时打开电视;其他人可以陆续进来一起看;只有当最后一个人离开房间时,电视才会被关掉。如果有人还在看电视就把电视关了,剩下的观众必然抗议——这和引用计数不足时提前释放数据导致的未定义行为如出一辙。
何时该用Rc<T>
书中给出了明确的使用判据:当你要在堆上分配数据,供程序多个部分只读共享,并且无法在编译期确定哪个部分会最后用完该数据时,就用Rc<T>。
反过来说,如果你能提前知道谁最后用完数据,直接让那一方成为数据的唯一拥有者,编译期的常规所有权规则就能生效,无需Rc<T>。
单线程限定
Rc<T>只能用于单线程场景。它内部使用非原子的计数器,在多线程下会产生数据竞争。多线程环境下的引用计数需要原子类型Arc<T>(在第 16 章并发部分讲解),仓库的 src/ch16-00-concurrency.md 一章对此有专门展开。
共享数据实战:两个链表共享第三个链表的所有权
让我们回到书中第 15 章的 cons list 例子。回忆 Listing 15-5,它用Box<T>定义了递归的链表类型:
enum List { Cons(i32, Box<List>), Nil, } use crate::List::{Cons, Nil}; fn main() { let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil)))))); }现在,我们要创建两个链表b和c,让它们共同拥有第三个链表a的所有权。结构如下图所示(图 15-3):链表b以元素 3 开头,链表c以元素 4 开头,而两者都指向链表a的首元素(5 → 10 → Nil)。
用Box<T>实现为什么失败(E0382)
如果我们沿用Box<List>的定义,代码会写成 Listing 15-17 这样:
enum List { Cons(i32, Box<List>), Nil, } use crate::List::{Cons, Nil}; fn main() { let a = Cons(5, Box::new(Cons(10, Box::new(Nil)))); let b = Cons(3, Box::new(a)); let c = Cons(4, Box::new(a)); }编译时立即报错,仓库中对应的错误输出 listing-15-17/output.txt 显示:
$ cargo run Compiling cons-list v0.1.0 (file:///projects/cons-list) error[E0382]: use of moved value: `a` --> src/main.rs:11:30 | 9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil)))); | - move occurs because `a` has type `List`, which does not implement the `Copy` trait 10 | let b = Cons(3, Box::new(a)); | - value moved here 11 | let c = Cons(4, Box::new(a)); | ^ value used here after move原因很清楚:Cons变体拥有它所持有的数据。创建b时,a被移动进了b,b成为a的唯一拥有者;再想用a创建c时,a已经被移走,编译器直接拒绝。这也是所有权模型最典型的价值——在编译期就拦住数据被双重拥有导致的隐患。
改用引用 + 生命周期行不行?
另一种思路是把Cons改成持有引用,但这会引入生命周期参数,且意味着链表中的每个元素都要活得和整个链表一样长。这在 Listing 15-17 的场景里恰好成立,但在很多真实场景中并不满足(例如节点在运行时才动态创建、随时可能被独立释放)。因此,更通用的方案是换用Rc<T>。
用Rc<T>重写:Rc::clone让引用计数从 1 涨到 3
将List定义中的Box<T>替换为Rc<T>,即可解决共享问题,见 Listing 15-18:
enum List { Cons(i32, Rc<List>), Nil, } use crate::List::{Cons, Nil}; use std::rc::Rc; fn main() { let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil))))); let b = Cons(3, Rc::clone(&a)); let c = Cons(4, Rc::clone(&a)); }关键变化有三点:
- 引入
Rc<T>:Rc<T>不在标准库 prelude 中,必须显式use std::rc::Rc;引入; - 用
Rc::new构造:a的数据被放进堆上的Rc<List>中,a本身是一个Rc<List>; - 用
Rc::clone(&a)共享:创建b、c时不再把a移动走,而是克隆a持有的Rc<List>。
每次调用Rc::clone,指向同一份数据的引用计数就加 1:创建a后计数为 1,创建b后为 2,创建c后为 3。只有当计数降到 0 时,Rc<List>里的数据才会被清理。
从该示例的 Cargo.toml 可以看到这是一个名为cons-list、edition = "2024"的标准 Cargo 二进制项目,没有任何第三方依赖,你可以直接cargo run验证。
为什么是Rc::clone(&a)而不是a.clone()
你完全可以写a.clone(),但 Rust 社区的约定是在引用计数场景显式调用Rc::clone。原因很实际:
- 大多数类型的
clone实现是深拷贝:复制全部数据,开销大; Rc::clone的实现只递增引用计数,不做任何数据深拷贝,开销极小;- 混用两种
clone会让人分不清哪一次是昂贵的深拷贝、哪一次只是计数+1。统一写Rc::clone,在做性能排查时就能一眼跳过这些廉价调用,只聚焦真正的深拷贝点。
观察引用计数变化:Rc::strong_count
光说不练不行,Listing 15-19 在main中增加了一个内部作用域包裹链表c,并在每个计数变化点调用Rc::strong_count(&a)打印当前强引用计数:
enum List { Cons(i32, Rc<List>), Nil, } use crate::List::{Cons, Nil}; use std::rc::Rc; fn main() { let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil))))); println!("count after creating a = {}", Rc::strong_count(&a)); let b = Cons(3, Rc::clone(&a)); println!("count after creating b = {}", Rc::strong_count(&a)); { let c = Cons(4, Rc::clone(&a)); println!("count after creating c = {}", Rc::strong_count(&a)); } println!("count after c goes out of scope = {}", Rc::strong_count(&a)); }仓库中对应的实际运行输出 listing-15-19/output.txt 为:
$ cargo run Compiling cons-list v0.1.0 (file:///projects/cons-list) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s Running `target/debug/cons-list` count after creating a = 1 count after creating b = 2 count after creating c = 3 count after c goes out of scope = 2输出验证了完整的行为闭环:
a创建后计数为1;- 每次
Rc::clone,计数+1(2、3); c离开内部作用域后,计数-1(回到 2)。
计数下降是自动的:Drop的实现
注意,下降不需要你调用任何函数。Rc<T>实现了Droptrait:当一个Rc<T>值离开作用域时,Drop会自动把引用计数减 1。这个例子看不到的隐藏部分是:当b、a在main末尾依次离开作用域后,计数归零,Rc<List>中的数据被完全清理。
Rc<T>的核心价值由此体现:一个值可以有多个拥有者,且只要还有任何一个拥有者存活,计数就大于 0,值就保证有效。
为什么方法叫strong_count而不是count
Rc<T>除了强引用计数,还有weak_count(弱引用计数)。weak_count的用途是防止引用循环(reference cycles):如果两个Rc<T>互相持有对方,强引用计数将永远无法归零,造成内存泄漏。书中下一节 Preventing Reference Cycles UsingWeak<T>专门讲解了如何用Weak<T>打破这种循环。
为什么Rc<T>只允许不可变共享
Rc<T>通过不可变引用让你在程序的多个部分之间共享只读数据。它刻意不提供多路可变引用,因为那会违反第 4 章讨论的借用规则之一:对同一位置的多个可变借用会引发数据竞争与状态不一致。
但现实需求往往需要"多处共享 + 可变"。解决方案正是本书接下来要讲的内部可变性(interior mutability)模式:把Rc<T>与RefCell<T>组合使用——Rc<T>负责共享所有权,RefCell<T>在运行时检查借用规则、允许安全地修改共享数据。这部分内容在仓库的 src/ch15-05-interior-mutability.md 中有完整实现与示例。
小结与使用建议
- 适用场景:堆上数据需要被程序的多个部分只读共享,且无法在编译期确定谁最后用完——用
Rc<T>; - 两个 API 记牢:
Rc::new创建、Rc::clone递增计数(廉价,非深拷贝)、Rc::strong_count观察计数; - 自动回收:计数归零由
Drop自动完成,无需手动减计数; - 线程边界:单线程用
Rc<T>,多线程请改用原子计数版本的Arc<T>(见 src/ch16-00-concurrency.md); - 可变需求:
Rc<T>只支持不可变共享,若要修改共享数据,需配合RefCell<T>使用内部可变性(见 src/ch15-05-interior-mutability.md); - 循环风险:
Rc<T>强引用可能形成循环导致泄漏,必要时用Weak<T>打断循环(见 src/ch15-06-reference-cycles.md)。
想要亲手验证,可进入仓库对应示例目录(如listings/ch15-smart-pointers/listing-15-18/、listing-15-19/)执行cargo run,直观观察编译错误与引用计数的实时变化。
- 教程
- 文档
【免费下载链接】book
The Rust Programming Language
相关推荐
Rust 引用计数智能指针 `Rc<T>` 完全指南:多所有权共享与引用计数原理(The Rust Programming Language 实战解析)
Rust 引用计数智能指针 Rc<T 完全指南:多所有权共享与引用计数原理(The Rust Programming Language 实战解析) Rc<T (
教程文档Rust Arc 原子引用计数详解:跨线程安全共享所有权与实战
Rust Arc 原子引用计数详解:跨线程安全共享所有权与实战 Rust 的所有权模型保证了内存安全,但也使得"多个持有者共享同一份数据"成为一个需要专门工具解
文档教程Rust 关联关系(Association)实战:用 `Rc`、`Weak` 与 `RefCell` 实现共享所有权与双向对象关联
Rust 关联关系(Association)实战:用 Rc 、 Weak 与 RefCell 实现共享所有权与双向对象关联 导读 在 Rust 中实现 OOP
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考