跳转到内容

Rust Weak 与 Cow

在智能指针一页,你已经认识了 Box、Rc 和 RefCell。这一页再介绍两个工具,它们分别解决两个非常实际的问题。Weak<T> 让你能指向一个值,却不让它一直活着。Cow<T> 让函数在能借用时直接交回借来的数据,只有在不得不复制时才复制。

可以把 Rc 想成一把房门钥匙:只要还有人拿着钥匙,房子就一直在。Weak 更像写在纸条上的地址:你能凭它找到房子,但如果大家都交还了钥匙、房子也拆了,这个地址就什么都找不到了。没有人会因为一栋自己早忘了的房子一直付钱。

当 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 的强引用计数:2
bob 的强引用计数:2
main 结束
▶ 动手试一试

注意少了什么:既没有“丢弃 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 的朋友是 bob
alice:强引用 = 1,弱引用 = 1
main 结束
丢弃 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/trips
home:强引用 = 1,弱引用 = 1
photos:强引用 = 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 收到:得分是 10
logger 收到:得分是 10
screen 收到:得分是 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 做 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>。

下面是一份简短的速查表,汇总了你目前见过的所有“类指针”类型:

类型 它给你什么 什么时候用
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` value
note: 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<Parent>,父节点又持有 Rc<Child>,你就造出了第一节里的那种循环。程序不会崩溃,也不会有任何警告,只是内存永远回不来了。一条好用的规则:先想清楚谁拥有谁,在这个方向上用 Rc,反方向的每个链接都用 Weak。

struct Folder {
parent: RefCell<Rc<Folder>>, // 错误:子节点让父节点一直活着
children: RefCell<Vec<Rc<Folder>>>,
}

把 parent 改成 RefCell<Weak<Folder>>,循环就消失了。

创建一个带有 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。

写一个函数 fn escape(text: &str) -> Cow<'_, str>,把每个 < 替换成 &lt;。如果文本里没有 <,就以借用的形式返回它。用 "hello world" 和 "1 < 2" 测试,并打印你拿到的是哪个变体。提示:先用 text.contains('<') 检查,需要拥有所有权的情况再用 text.replace('<', "&lt;")。

查看答案
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> {
if text.contains('<') {
Cow::Owned(text.replace('<', "&lt;"))
} 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 &lt; 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