Rust Weak 与 Cow
在智能指针一页,你已经认识了 Box、Rc 和 RefCell。这一页再介绍两个工具,它们分别解决两个非常实际的问题。Weak<T> 让你能指向一个值,却不让它一直活着。Cow<T> 让函数在能借用时直接交回借来的数据,只有在不得不复制时才复制。
可以把 Rc 想成一把房门钥匙:只要还有人拿着钥匙,房子就一直在。Weak 更像写在纸条上的地址:你能凭它找到房子,但如果大家都交还了钥匙、房子也拆了,这个地址就什么都找不到了。没有人会因为一栋自己早忘了的房子一直付钱。
问题:Rc 循环永远不会被清理
Section titled “问题:Rc 循环永远不会被清理”当 Rc 值的强引用计数(strong count)降到 0 时,它就会被丢弃。这套机制很好用,直到两个值互相持有对方的 Rc。这时它们会让对方一直活着,永远如此。
为了看清这一点,下面的 Person 类型实现了 Drop,被清理时会打印一条消息。注意看哪些消息会出现:
use std::cell::RefCell;use std::rc::Rc;
struct Person { name: String, friend: RefCell<Option<Rc<Person>>>,}
impl Drop for Person { fn drop(&mut self) { println!("丢弃 {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(None) }); let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(None) });
// alice 指向 bob,bob 又指回 alice *alice.friend.borrow_mut() = Some(Rc::clone(&bob)); *bob.friend.borrow_mut() = Some(Rc::clone(&alice));
println!("alice 的强引用计数:{}", Rc::strong_count(&alice)); println!("bob 的强引用计数:{}", Rc::strong_count(&bob)); println!("main 结束");}输出:
alice 的强引用计数:2bob 的强引用计数:2main 结束注意少了什么:既没有“丢弃 alice”,也没有“丢弃 bob”。main 结束时,变量 alice 和 bob 消失了,所以两个计数都从 2 降到 1。但它们永远到不了 0,因为每个人仍然被对方的 friend 字段持有着。这块内存就这样泄漏了。
如果这让你吃了一惊,别担心。编译器发现不了这种问题,它也不会让程序崩溃,只是有一块内存再也回不来了。如果一个长时间运行的程序不停地制造循环,泄漏就会越积越多。
Weak<T>:不会让值一直活着的指针
Section titled “Weak<T>:不会让值一直活着的指针”解决办法是把其中一个或两个链接变成弱引用(weak)。用 Rc::downgrade 就能从一个 Rc 得到一个 Weak。弱引用不算所有者,所以它不会阻止值被丢弃。
因为值可能已经不在了,你不能直接使用 Weak。要调用 .upgrade(),它会给你一个 Option<Rc<T>>:值还活着就是 Some,否则就是 None。
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Person { name: String, friend: RefCell<Weak<Person>>,}
impl Drop for Person { fn drop(&mut self) { println!("丢弃 {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(Weak::new()) }); let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(Weak::new()) });
*alice.friend.borrow_mut() = Rc::downgrade(&bob); *bob.friend.borrow_mut() = Rc::downgrade(&alice);
if let Some(friend) = alice.friend.borrow().upgrade() { println!("alice 的朋友是 {}", friend.name); }
println!("alice:强引用 = {},弱引用 = {}", Rc::strong_count(&alice), Rc::weak_count(&alice)); println!("main 结束");}输出:
alice 的朋友是 bobalice:强引用 = 1,弱引用 = 1main 结束丢弃 bob丢弃 alice这次两行“丢弃”都出现了。alice 有一个强引用所有者(变量 alice)和一个弱引用(来自 bob)。main 结束时,强引用计数降到 0,Rust 就把她清理掉了。bob 先被丢弃,因为变量是按照创建顺序的相反顺序丢弃的。
有几点要记住:
Weak::new()创建一个什么都不指向的空弱引用,对它调用upgrade()永远返回None。拿它当初始值很方便。Rc::strong_count统计所有者的数量,Rc::weak_count统计弱引用的数量。只有强引用计数决定值什么时候被丢弃。upgrade()交回的Rc是一个真正的临时所有者。它离开作用域时,强引用计数会再降回去。
文件夹树:子节点被拥有,父链接是弱引用
Section titled “文件夹树:子节点被拥有,父链接是弱引用”树是 Weak 的经典用武之地。文件夹拥有它的子文件夹,所以 children 存放的是 Rc。子文件夹也想知道自己的父文件夹,但不应该拥有它。如果两个方向都用 Rc,就又形成循环了。所以 parent 是一个 Weak:
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Folder { name: String, parent: RefCell<Weak<Folder>>, children: RefCell<Vec<Rc<Folder>>>,}
fn new_folder(name: &str) -> Rc<Folder> { Rc::new(Folder { name: name.to_string(), parent: RefCell::new(Weak::new()), children: RefCell::new(Vec::new()), })}
fn add_child(parent: &Rc<Folder>, child: Rc<Folder>) { *child.parent.borrow_mut() = Rc::downgrade(parent); parent.children.borrow_mut().push(child);}
fn full_path(folder: &Folder) -> String { match folder.parent.borrow().upgrade() { Some(parent) => format!("{}/{}", full_path(&parent), folder.name), None => folder.name.clone(), }}
fn main() { let home = new_folder("home"); let photos = new_folder("photos"); let trips = new_folder("trips");
add_child(&home, Rc::clone(&photos)); add_child(&photos, Rc::clone(&trips));
match photos.parent.borrow().upgrade() { Some(parent) => println!("photos 位于 {} 里", parent.name), None => println!("photos 没有父文件夹"), }
println!("完整路径:{}", full_path(&trips)); println!("home:强引用 = {},弱引用 = {}", Rc::strong_count(&home), Rc::weak_count(&home)); println!("photos:强引用 = {},弱引用 = {}", Rc::strong_count(&photos), Rc::weak_count(&photos));}输出:
photos 位于 home 里完整路径:home/photos/tripshome:强引用 = 1,弱引用 = 1photos:强引用 = 2,弱引用 = 1慢慢读一下这些计数。photos 有两个强引用所有者:变量 photos,以及 home 里面的 children 列表。它还有一个弱引用:trips 的 parent 字段。home 只有一个强引用所有者,也就是它自己的变量,因为没有谁拥有自己的父文件夹。这正是你想要的形状:所有权沿着树向下流动,向上的链接都是弱引用。
之所以要用 RefCell,是因为你是在文件夹已经放进 Rc 之后才去设置 parent、往 children 里添加元素的,而单靠 Rc 只能给你共享的只读访问。
不会让东西一直活着的弱引用列表
Section titled “不会让东西一直活着的弱引用列表”Weak 也非常适合“盯着这些东西,但不要攥住它们”的列表:监听者、观察者,或者缓存别人拥有的东西。下面的例子里,游戏得分会把更新发给所有还在监听的对象。某个监听者被丢弃后,它的弱引用就自然失效了:
use std::rc::{Rc, Weak};
struct Listener { name: String,}
fn notify(listeners: &[Weak<Listener>], event: &str) { for weak in listeners { match weak.upgrade() { Some(listener) => println!("{} 收到:{event}", listener.name), None => println!("(有个监听者已经不在了,跳过)"), } }}
fn main() { let screen = Rc::new(Listener { name: String::from("screen") }); let logger = Rc::new(Listener { name: String::from("logger") });
let mut listeners: Vec<Weak<Listener>> = Vec::new(); listeners.push(Rc::downgrade(&screen)); listeners.push(Rc::downgrade(&logger));
notify(&listeners, "得分是 10");
drop(logger); // logger 被它的所有者关掉了
notify(&listeners, "得分是 20");
// 清理掉什么都不指向的链接 listeners.retain(|weak| weak.upgrade().is_some()); println!("剩下的监听者:{}", listeners.len());}输出:
screen 收到:得分是 10logger 收到:得分是 10screen 收到:得分是 20(有个监听者已经不在了,跳过)剩下的监听者:1这个列表从来没有阻止 logger 被丢弃。如果列表里存的是 Rc,logger 就会在列表里继续活下去,还一直收到没人想要的消息。用了 Weak,什么时候让东西消失由所有者决定,其他人只需用 upgrade() 确认一下。
Cow:能借用就借用,必须复制时才复制
Section titled “Cow:能借用就借用,必须复制时才复制”现在换一个问题。假设你写了一个整理用户名的函数。大多数名字本来就没问题,只有少数带着多余的空格或大写字母。如果函数总是返回一个新的 String,那每个名字都会被复制一遍,连你根本没改的那些也不例外。
Cow(“clone on write”,写时克隆,来自 std::borrow::Cow)正好解决这个问题。Cow<'_, str> 是一个有两个变体的枚举:
Cow::Borrowed(&str):“这是原来的文本,一点没动”。不分配内存。Cow::Owned(String):“我不得不改了它,所以这是一个新的String”。
下面的用户名保留英文,因为函数要用 len() 比较长度,还要处理大小写:
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); let has_upper = name.chars().any(|c| c.is_uppercase());
if trimmed.len() == name.len() && !has_upper { Cow::Borrowed(name) // 已经很干净:直接交回原来的 } else { Cow::Owned(trimmed.to_lowercase()) // 需要修整:构建一个新的 String }}
fn main() { for input in ["alice", " Bob ", "CAROL", "dave"] { match normalize(input) { Cow::Borrowed(name) => println!("{input:?} -> {name:?}(借用,没有复制)"), Cow::Owned(name) => println!("{input:?} -> {name:?}(拥有所有权,新的 String)"), } }}输出:
"alice" -> "alice"(借用,没有复制)" Bob " -> "bob"(拥有所有权,新的 String)"CAROL" -> "carol"(拥有所有权,新的 String)"dave" -> "dave"(借用,没有复制)四个名字里有两个完全没有额外开销。在真实的程序里,如果 99% 的输入本来就是干净的,你就省掉了大量复制。Cow<'_, str> 里的 '_ 只是表示“这个值可能借用了输入”。如果你对生命周期还有点模糊,可以看看生命周期一页。
像普通字符串一样使用 Cow
Section titled “像普通字符串一样使用 Cow”通常你根本不需要对 Cow 做 match。它会解引用(deref)成 &str,所以你可以直接在它上面调用字符串方法,也可以把 &name 传给任何接收 &str 的函数。真的需要一个自己拥有的 String 时,调用 .into_owned():如果里面本来就是 String,就直接把它移出来;只有 Cow 是借用的时候才会复制。
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); if trimmed.len() == name.len() && !name.chars().any(|c| c.is_uppercase()) { Cow::Borrowed(name) } else { Cow::Owned(trimmed.to_lowercase()) }}
fn greet(name: &str) { println!("欢迎你,{name}!");}
fn main() { let name = normalize(" Eve ");
println!("长度:{}", name.len()); // 可以直接用 str 的方法 println!("以 e 开头:{}", name.starts_with('e')); greet(&name); // &Cow<str> 会变成 &str
let saved: String = name.into_owned(); // 现在它是一个普通的 String println!("已把 {saved:?} 存进用户列表");}输出:
长度:3以 e 开头:true欢迎你,eve!已把 "eve" 存进用户列表Cow 也能用在其他类型上,比如 Cow<'_, [i32]> 表示一个可能需要修改的 slice。不过处理文本时,你最常见到的还是 Cow<'_, str>。
什么时候用哪个
Section titled “什么时候用哪个”下面是一份简短的速查表,汇总了你目前见过的所有“类指针”类型:
| 类型 | 它给你什么 | 什么时候用 |
|---|---|---|
Box<T> |
一个所有者,值在堆上 | 递归类型,或者想低成本移动的大值 |
Rc<T> |
多个所有者,单线程 | 程序的好几个部分共享同一份数据 |
Arc<T> |
多个所有者,跨线程 | 和 Rc 一样,但要在线程之间共享(见共享状态) |
Weak<T> |
不拥有值的链接 | 父链接、观察者列表、缓存、打破 Rc 循环 |
RefCell<T> |
通过共享引用修改,运行时检查 | 需要修改放在 Rc 里面的东西 |
Cow<'_, T> |
借用或拥有,运行时决定 | 一个通常原样返回输入的函数 |
拿不准的时候,先用普通的所有权和借用。只有当编译器(或者一次内存泄漏)告诉你需要它们时,再把它们加进来。
值已经不在了,还对 upgrade 的结果调用 unwrap
Section titled “值已经不在了,还对 upgrade 的结果调用 unwrap”upgrade() 返回 Option 是有原因的。如果你习惯性地 .unwrap(),那么值一旦被丢弃,程序就会 panic:
use std::rc::Rc;
fn main() { let session = Rc::new(String::from("user-42")); let link = Rc::downgrade(&session);
drop(session); // 会话结束了
let current = link.upgrade().unwrap(); println!("仍以 {current} 的身份登录");}运行它,你会看到:
thread 'main' (12345) panicked at src/main.rs:9:34:called `Option::unwrap()` on a `None` valuenote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace这条 panic 信息的意思是:程序对一个 None 值调用了 Option::unwrap()。修复方法是把“它已经不在了”当作一种正常情况来处理,用 match 或 if let:
match link.upgrade() { Some(current) => println!("仍以 {current} 的身份登录"), None => println!("会话已经结束"),}指回去的链接也用了 Rc
Section titled “指回去的链接也用了 Rc”如果子节点持有 Rc<Parent>,父节点又持有 Rc<Child>,你就造出了第一节里的那种循环。程序不会崩溃,也不会有任何警告,只是内存永远回不来了。一条好用的规则:先想清楚谁拥有谁,在这个方向上用 Rc,反方向的每个链接都用 Weak。
struct Folder { parent: RefCell<Rc<Folder>>, // 错误:子节点让父节点一直活着 children: RefCell<Vec<Rc<Folder>>>,}把 parent 改成 RefCell<Weak<Folder>>,循环就消失了。
1. 已删除文章下的评论
Section titled “1. 已删除文章下的评论”创建一个带有 title 的 Post,放进 Rc 里。再创建一个 Comment,带有 text 和 post: Weak<Post>。写一个函数 describe(comment: &Comment):如果文章还在,就打印 “<text>”,评论于《<title>》;如果不在了,就打印 “<text>”,评论于一篇已删除的文章。先调用它一次,然后丢弃文章,再调用一次。提示:使用 match comment.post.upgrade()。
查看答案
use std::rc::{Rc, Weak};
struct Post { title: String,}
struct Comment { text: String, post: Weak<Post>,}
fn describe(comment: &Comment) { match comment.post.upgrade() { Some(post) => println!("“{}”,评论于《{}》", comment.text, post.title), None => println!("“{}”,评论于一篇已删除的文章", comment.text), }}
fn main() { let post = Rc::new(Post { title: String::from("学习 Rust") }); let comment = Comment { text: String::from("写得真好!"), post: Rc::downgrade(&post) };
describe(&comment); drop(post); describe(&comment);}输出:
“写得真好!”,评论于《学习 Rust》“写得真好!”,评论于一篇已删除的文章评论从来不会让文章一直活着。唯一的 Rc 被丢弃之后,upgrade() 返回 None,评论会从容地处理这种情况,而不是 panic。
2. 只在需要时才转义文本
Section titled “2. 只在需要时才转义文本”写一个函数 fn escape(text: &str) -> Cow<'_, str>,把每个 < 替换成 <。如果文本里没有 <,就以借用的形式返回它。用 "hello world" 和 "1 < 2" 测试,并打印你拿到的是哪个变体。提示:先用 text.contains('<') 检查,需要拥有所有权的情况再用 text.replace('<', "<")。
查看答案
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> { if text.contains('<') { Cow::Owned(text.replace('<', "<")) } else { Cow::Borrowed(text) }}
fn main() { for text in ["hello world", "1 < 2"] { match escape(text) { Cow::Borrowed(s) => println!("{s}(借用)"), Cow::Owned(s) => println!("{s}(拥有所有权)"), } }}输出:
hello world(借用)1 < 2(拥有所有权)只有真正包含 < 的文本才需要付出新建 String 的代价,其余的都原封不动地直接通过。
- 两个互相指向的
Rc值,强引用计数永远到不了 0,所以会发生泄漏,它们的Drop也永远不会执行。 Rc::downgrade创建一个Weak<T>,它不会让值一直活着。upgrade()交回Option<Rc<T>>。- 在树里,子节点用
Rc拥有,父链接用Weak。所有权向下流动,弱引用指向上方。 - 观察者列表和缓存可以存放
Weak,这样就不会意外地让东西一直活着。 Cow<'_, str>在没有改动时返回借用的文本,只有改动了才返回一个拥有所有权的String。它能解引用成&str,.into_owned()会给你一个String。
小测验
1. 两个值各自持有指向对方的 Rc。main 结束时会发生什么?
2. weak.upgrade() 返回什么?
3. 在文件夹树里,哪个链接应该是 Weak?
4. 返回 Cow<'_, str> 的函数什么时候能避免新建 String?
得分: 0 / 4
- 引用循环与内存泄漏 — Rust 程序设计语言:官方教程对
Rc循环的完整讲解,以及一棵用Weak做父链接的树。 std::rc::Weak— 标准库文档(英文):Weak的所有方法,包括upgrade、strong_count和Weak::new。std::rc— 标准库文档(英文):强引用计数和弱引用计数怎样配合工作,附一个打破循环的更长示例。std::borrow::Cow— 标准库文档(英文):Cow枚举、to_mut、into_owned,以及字符串之外的例子。