拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Rust 智能指针之 `Rc<T>`:引用计数与多所有权共享实战(TRPL 第 15 章)

Rust 智能指针之 `Rc<T>`:引用计数与多所有权共享实战(TRPL 第 15 章)
  • 教程
  • 文档

【免费下载链接】book

The Rust Programming Language

项目地址:https://gitcode.com/gh_mirrors/bo/book
点击查看免费下载

导读

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)); }

关键变化有三点:

  1. 引入Rc<T>:Rc<T>不在标准库 prelude 中,必须显式use std::rc::Rc;引入;
  2. 用Rc::new构造:a的数据被放进堆上的Rc<List>中,a本身是一个Rc<List>;
  3. 用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

项目地址:https://gitcode.com/gh_mirrors/bo/book
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

返回列表