外观
第 14 章 小火车与共享玩具
小螃蟹说
欢迎来到高级卷!这一卷的每一章,都是"威力更大、也更需要动脑筋"的知识。恭喜你走到这里——基础卷毕业的你,已经有足够的内力来驾驭它们了。
这一章,我们认识智能指针(smart pointer)三兄弟:Box、Rc、RefCell。它们是 Rust 世界里管理内存的"进阶武器",解决三个从前解决不了的问题:
Box:装下一个"不知道多大"的东西Rc:让好几个地方共享同一个东西RefCell:共享的东西也能偷偷修改
我们用两个好玩的场景来学:一列玩具小火车(每节车厢连着一节,教 Box),和一只共享宠物小咪(小螃蟹和小乌龟的收藏册里都有它,教 Rc 和 RefCell)。
先说好消息
"智能指针"听起来吓人,其实你早就见过它们了——String 和 Vec 就是智能指针!不信?这一章看完你就明白了。
14.1 预览:我们要做什么
- 用
Box造一列火车:车头→煤炭→礼物,车厢一节连一节 - 用
Rc让两只小动物共享宠物小咪 - 用
RefCell给小咪洗澡:共享的宠物,可爱度照样能改
运行结果
text
== 玩具小火车 ==
车厢装着:车头
车厢装着:煤炭
车厢装着:礼物
(车尾)
一共 3 节车厢。
== 共享宠物 ==
小螃蟹的收藏册里有 小咪
小乌龟的收藏册里有 小咪
小咪有 3 个主人。
小咪 洗香香了!可爱度变成 10
小螃蟹翻收藏册:小咪 可爱度 10
小乌龟翻收藏册:小咪 可爱度 10
小咪还是只有 3 个主人。这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 智能指针 | 会自己收拾的"导航卡片" | Box / Rc / RefCell |
Box<T> | 堆上的小盒子 | 火车车厢连车厢 |
| 递归类型 | 类型里装着"自己" | 为什么火车需要 Box |
Rc<T> | 多人共享的遥控器 | 两个收藏册共享小咪 |
RefCell<T> | 带锁的柜子,借来的也能改 | 给小咪洗澡 |
14.2 动手做
步骤一:什么是智能指针?
先复习:第 3 章的引用(&)是"借条"——告诉程序"东西在哪儿",但不拥有它。
智能指针(smart pointer)是"更聪明的借条":它拥有它指的东西(所以叫指针),还自带附加功能(所以叫智能)。比喻一下:
普通引用(
&)是"你告诉我图书馆在哪,我自己去看"。 智能指针是"我开着卡车去,把书搬回家,还能自己整理书架"。
Rust 的智能指针家族有一大群,我们认识其中三个:
| 智能指针 | 附加功能 | 一句话 |
|---|---|---|
Box<T> | 把值放堆上 | 装下"不知道自己多大"的东西 |
Rc<T> | 计数,多人共享 | 好几个地方一起用 |
RefCell<T> | 内部可变性 | 借来的也能改 |
还记得第 3 章说 String 的"小名片"住在栈上、文字住在堆上吗?String 就是一个智能指针(它拥有堆上的文字,还知道长度、会自己清理)。第 7 章的 Vec 也是。你早就用过智能指针了,今天只是给它们正式的名字。
步骤二:为什么火车需要 Box?
新建项目:
bash
cargo new toy_train
cd toy_train我们的火车是一节车厢连着一节车厢——每节车厢里装着"货物"和"下一节车厢"。听起来很自然,先故意写一个"想当然"的版本:
rust
enum TrainCar {
End,
Car { cargo: String, next: TrainCar },
}next 的类型是 TrainCar——车厢里装着下一节车厢,下一节里又装着下下一节……编译一下,看看 Rust 怎么回答:
运行结果
text
error[E0072]: recursive type `TrainCar` has infinite size
--> src\main.rs:1:1
|
1 | enum TrainCar {
| ^^^^^^^^^^^^^
2 | End,
3 | Car { cargo: String, next: TrainCar },
| -------- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle"recursive type has infinite size"——递归类型是无限大的。 想一想:一列火车要装进内存,得知道每节车厢多大;可车厢里装着车厢,车厢里又装着车厢……永远算不出大小,就像照镜子照镜子照镜子,没有尽头。
编译器还给了提示:"insert some indirection (e.g., a Box)"——插一个间接层,比如 Box。
把 next 的类型改成 Box<TrainCar>:
rust
enum TrainCar {
End,
Car { cargo: String, next: Box<TrainCar> },
}为什么这就行了?Box<TrainCar> 是"指向火车的盒子"——盒子本身大小固定(里面就一个地址),装的东西多大无所谓。就像一节车厢不再"装着下一节车厢",而是"挂着一根链子,链子另一头连着下一节车厢"——链子很短,车厢再大也挂得下。
小贴士
递归类型(类型里装着自己)是"树状数据"的标志:火车、链表、文件夹、家族谱……全都长这样。而 Box 就是它们的"链子",把无限大变成有限大。以后看到 Box,先想:"这里有个装自己的类型。"
步骤三:组装小火车
现在造车!在 enum TrainCar 下面加 impl:
rust
impl TrainCar {
fn new_train() -> TrainCar {
TrainCar::Car {
cargo: String::from("车头"),
next: Box::new(TrainCar::Car {
cargo: String::from("煤炭"),
next: Box::new(TrainCar::Car {
cargo: String::from("礼物"),
next: Box::new(TrainCar::End),
}),
}),
}
}
fn show(&self) {
match self {
TrainCar::End => println!("(车尾)"),
TrainCar::Car { cargo, next } => {
println!("车厢装着:{}", cargo);
next.show();
}
}
}
}Box::new(值) 是"把值装进盒子"(像 String::new 一样,::new 是关联函数,第 4 章的老朋友)。一层套一层:车头 盒子里是 煤炭,煤炭 盒子里是 礼物,礼物 盒子里是车尾——火车造好了。
show 里有个第一次见面的新朋友——递归(recursion):函数调用自己。
rust
next.show();show 打印完自己这节车厢,就喊"下一节车厢,你来打!"下一节又喊下一节……直到车尾 End 分支打印"(车尾)"收工。就像传话游戏,一节车厢传一节,传到最后。
递归像俄罗斯套娃
大娃娃里套中娃娃,中娃娃里套小娃娃……show 就是"打开自己,然后打开里面的娃娃"。递归的规矩:一定要有"到底"的那一层(我们的 End 分支)——不然就会无限套下去,永远不停(第 1 章说的"所有循环都要有尽头",递归也一样)。
match self 里的 { cargo, next } 用到了 .. 吗?没有——Car 只有两个字段,全写就行。以后字段多了,可以用 { next, .. }("只要 next,其它不管"),练习里你会见到。
步骤四:数数车厢
火车造好了,数数有几节。在 impl 里加 count:
rust
fn count(&self) -> u32 {
match self {
TrainCar::End => 0,
TrainCar::Car { next, .. } => 1 + next.count(),
}
}又是递归:车尾是 0 节;每节车厢是"1 节 + 后面的节数"。{ next, .. } 里的 .. 是"其它字段我不管"(第 19 章模式匹配会细讲,先会用)。
现在写 main,跑起来:
rust
fn main() {
let train = TrainCar::new_train();
println!("== 玩具小火车 ==");
train.show();
println!("一共 {} 节车厢。", train.count());
}运行结果
text
== 玩具小火车 ==
车厢装着:车头
车厢装着:煤炭
车厢装着:礼物
(车尾)
一共 3 节车厢。小火车跑起来了!3 节车厢,车尾收队。
步骤五:Rc:共享宠物小咪
火车学会造了,现在请出第二位主角——共享宠物。
小螃蟹和小乌龟各有一本收藏册(第 7 章!),两个人想共享同一只宠物小咪:小螃蟹翻收藏册能看到小咪,小乌龟翻也能看到——同一只,不是两只克隆猫。
问题:第 3 章说"一个值只有一个主人"。小咪只有一个,主人该是谁?Rust 的答案是 Rc(reference counting,引用计数)——给小咪装上计数器:有几个主人,计数就是几。最后一个主人放手时,小咪才被清理。
在 main 函数后面加:
rust
use std::rc::Rc;
struct Pet {
name: String,
cuteness: u32,
}
fn main() {
// ……小火车部分照旧……
println!();
println!("== 共享宠物 ==");
let pet = Rc::new(Pet {
name: String::from("小咪"),
cuteness: 9,
});
let gallery1 = vec![Rc::clone(&pet)];
let gallery2 = vec![Rc::clone(&pet)];
println!("小螃蟹的收藏册里有 {}", gallery1[0].name);
println!("小乌龟的收藏册里有 {}", gallery2[0].name);
println!("小咪有 {} 个主人。", Rc::strong_count(&pet));
}三个动作:
Rc::new(...):把小咪装进"带计数器"的盒子。计数器现在记 1(一个主人)。
Rc::clone(&pet):注意!这不是第 3 章的"复印一份"——Rc::clone 是"再发一个遥控器":小咪本人不复制,只是计数器 +1。给它起个别名更好懂:Rc::clone 是"共享",不是"复印"。
Rc::strong_count(&pet):问计数器"现在几个主人?"("strong" 是"强引用"的意思,现在只要知道:数主人的就叫这个)。
运行:
运行结果
text
== 共享宠物 ==
小螃蟹的收藏册里有 小咪
小乌龟的收藏册里有 小咪
小咪有 3 个主人。3 个主人:小咪自己(pet)+ 小螃蟹的收藏册 + 小乌龟的收藏册。一只小咪,三个主人,谁也不会抢走它。
Rc vs Box
Box 是一人一个的盒子:一个盒子,一个主人,用完了就扔。 Rc 是多人共享的遥控器:一个东西,好几个人用,最后一个人走了才扔。 想想火车:每节车厢只能挂在一列火车上(Box);小咪却可以同时出现在两本收藏册里(Rc)。
步骤六:RefCell:给小咪洗澡
共享问题解决了,新的麻烦来了:小乌龟想给小咪洗澡(可爱度 +1)。可是第 3 章说过,共享(Rc)只能读,不能改——借来的东西,凭什么改?
RefCell 登场:它给"只读的盒子"配了一把带锁的柜子——锁在外面(借来的引用),柜门由 RefCell 自己管。改 Pet 和 bath:
rust
use std::cell::RefCell;
struct Pet {
name: String,
cuteness: RefCell<u32>,
}
impl Pet {
fn bath(&self) {
let mut cuteness = self.cuteness.borrow_mut();
*cuteness += 1;
println!("{} 洗香香了!可爱度变成 {}", self.name, *cuteness);
}
}bath 的参数还是 &self——普普通通的"借来看"。但 self.cuteness.borrow_mut() 是"打开柜子,拿到里面的东西来改":borrow_mut() 借出可变的钥匙(第 3 章的 &mut 规则在柜子里面重新执行)。
在 main 里,Pet 的可爱度字段要换成 RefCell::new(9),然后洗澡:
rust
let pet = Rc::new(Pet {
name: String::from("小咪"),
cuteness: RefCell::new(9),
});
// ……两个收藏册……
pet.bath();
println!(
"小螃蟹翻收藏册:{} 可爱度 {}",
gallery1[0].name,
*gallery1[0].cuteness.borrow()
);
println!(
"小乌龟翻收藏册:{} 可爱度 {}",
gallery2[0].name,
*gallery2[0].cuteness.borrow()
);
println!("小咪还是只有 {} 个主人。", Rc::strong_count(&pet));*gallery1[0].cuteness.borrow():borrow() 是"打开柜子看一眼"(只读钥匙),* 是解引用拿里面的数字(第 7 章认识过 *count += 1 的 *)。
运行完整程序:
运行结果
text
== 玩具小火车 ==
车厢装着:车头
车厢装着:煤炭
车厢装着:礼物
(车尾)
一共 3 节车厢。
== 共享宠物 ==
小螃蟹的收藏册里有 小咪
小乌龟的收藏册里有 小咪
小咪有 3 个主人。
小咪 洗香香了!可爱度变成 10
小螃蟹翻收藏册:小咪 可爱度 10
小乌龟翻收藏册:小咪 可爱度 10
小咪还是只有 3 个主人。魔法发生了:小乌龟给小咪洗了澡(可爱度 9→10),小螃蟹翻开收藏册——小咪也是 10 了!因为两本收藏册里是同一只小咪。主人没变(还是 3 个),但共享的宠物更可爱了。
内部可变性,一句话
"内部可变性"(interior mutability):外面的引用是只读的(&Pet),里面的柜子(RefCell)却是可开的。借来的东西不能改?那是柜子外面的事;柜子里面,规矩由 RefCell 自己定。洗澡不用 mut,共享不用抢,这就是它的妙处。
14.3 知识深挖
14.3.1 智能指针家族
智能指针 = 拥有数据 + 附加功能。Rust 的标准库里有一大家子,我们逐个认识:
| 智能指针 | 附加功能 | 什么时候用 |
|---|---|---|
Box<T> | 值放堆上,大小固定 | 递归类型、大树 |
Rc<T> | 引用计数,多人共享(只读) | 只想一起读 |
RefCell<T> | 运行时检查的借用规则 | 想偷偷改共享数据 |
String | 拥有堆上的文字(第 3 章) | 你早就用了! |
Vec<T> | 拥有堆上的元素(第 7 章) | 你早就用了! |
它们都实现了一个叫 Deref 的 trait(第 9 章的能力证书)——意思是"可以当普通引用用":&*box、gallery1[0].name……智能指针用起来和引用差不多,只是多了附加功能。还有 Drop trait(丢弃时自动清理)——String 离开作用域自动放回内存,就是它的功劳(第 3 章说过"主人走了,值被清理",那就是 Drop)。
记住:智能指针不是魔法,是"有功能"的指针。
14.3.2 Box:堆上的盒子
Box<T> 是最朴素的智能指针:把值放上堆(第 3 章的仓库),自己在栈上留一个小名片(地址)。
rust
let boxed = Box::new(42); // 把 42 放进堆上的盒子
let number = *boxed; // * 把 42 从盒子里取出来它最大的用武之地是递归类型:类型里装着自己,直接装会"无限大"(14.2 步骤二的错误),Box 把它变成"一个固定大小的地址",链条就断了。火车、链表、树……一切"一环扣一环"的数据结构,都是 Box 的舞台。
14.3.3 Rc:多人共享的遥控器
Rc<T>(reference counting,引用计数)解决"一个值,多个主人"。
rust
let pet = Rc::new(Pet { ... });
let a = Rc::clone(&pet); // 计数器 2
let b = Rc::clone(&pet); // 计数器 3
println!("{}", Rc::strong_count(&pet)); // 3
drop(a); // a 放手,计数器 2
drop(b); // b 放手,计数器 1
// pet 也走了 → 计数器 0 → 小咪被清理Rc::clone 不是复印,是"再发一个遥控器"——数据只有一份,计数器 +1。drop(今天练习二的新朋友)是"提前放手":不等离开作用域,现在就丢弃。
规则:Rc 只能共享"读"(多个不可变引用,第 3 章规则:大家一起看没问题)。想共享"改"?那就是 RefCell 的活了。
单线程限定
Rc 只在单线程里用。想在线程间共享?第 15 章我们会遇到它的双胞胎 Arc(原子计数)——名字就差一个字母,功能多一份线程安全。
14.3.4 RefCell:带锁的柜子
RefCell<T>(cell,格子)实现内部可变性:"外面看着只读,里面能改"。
rust
let cuteness = RefCell::new(9);
let read = cuteness.borrow(); // 打开柜子看一眼(只读)
let read_again = cuteness.borrow(); // 还能再看一眼 ✓
let mut write = cuteness.borrow_mut(); // 打开柜子改东西(可变)
*write += 1;它的关键:借用规则(第 3 章)还在,只是检查时间变了。
| 普通借用(第 3 章) | RefCell | |
|---|---|---|
| 检查时间 | 编译时(编译器检查) | 运行时(跑起来才检查) |
| 违规时 | 编译失败 | 运行崩溃(panic) |
borrow_mut 和 borrow_mut 同时出现?编译时没拦住,运行时 RefCell 当场 panic——"柜子已经被改东西的人锁住了,你怎么又要开!"(第 8 章:这种程序员错误,崩溃反而是好事。)
口诀:Rc 让多个人"拥有",RefCell 让共享的东西"能改"。两个常常搭配:外面 Rc 共享,里面 RefCell 可改——这就是我们今天的小咪。
14.3.5 三兄弟对比表
Box<T> | Rc<T> | RefCell<T> | |
|---|---|---|---|
| 功能 | 堆上的盒子 | 多人共享 | 内部可变 |
| 主人 | 一个 | 多个(计数) | 一个 |
| 能改吗 | 能(mut 时) | 不能(共享只读) | 能(borrow_mut) |
| 检查 | 编译时 | 编译时 | 运行时 |
| 像什么 | 一人一个的玩具 | 共享遥控器 | 带锁的柜子 |
| 典型搭配 | 递归类型 | Rc<RefCell<T>> 共享可改 | 同左 |
什么时候用什么? 一个主人 → Box;多个主人只读 → Rc;多个主人要改 → Rc<RefCell<T>>(外面共享,里面可改)。
14.4 动脑筋练习
练习一:挂上零食车厢
火车要开长途,给 new_train 加一节"零食"车厢(挂在礼物后面)。改完之后,count 应该报 4。
点开看答案
把 new_train 里"礼物"车厢的 next 改成再套一层:
rust
next: Box::new(TrainCar::Car {
cargo: String::from("礼物"),
next: Box::new(TrainCar::Car {
cargo: String::from("零食"),
next: Box::new(TrainCar::End),
}),
}),每挂一节车厢,就多一层 Box::new(TrainCar::Car { ... })——链条越来越长。运行:
text
车厢装着:车头
车厢装着:煤炭
车厢装着:礼物
车厢装着:零食
(车尾)
一共 4 节车厢。挂车厢很简单,但这样手写很累。想象 100 节车厢——代码要套 100 层!真正的火车程序会写一个 add_car 方法(把车厢接到车尾),但那需要遍历到最后一节再挂。这也是练习三的引子:怎么"走到车尾"?
练习二:谁放手了?(drop 试验)
Rc::strong_count 能看到"小咪有几个主人"。现在,我们让小螃蟹和小乌龟先后放手(把收藏册送人),看计数器怎么变。
用 drop(收藏册) 立刻放手(不等程序结束)。注意顺序:先洗澡、先翻收藏册,再放手——放手后就不能再用了(第 3 章:移动之后不能再碰)。
点开看答案
把 main 里共享宠物部分的结尾改成:
rust
pet.bath();
println!(
"小螃蟹翻收藏册:{} 可爱度 {}",
gallery1[0].name,
*gallery1[0].cuteness.borrow()
);
println!(
"小乌龟翻收藏册:{} 可爱度 {}",
gallery2[0].name,
*gallery2[0].cuteness.borrow()
);
drop(gallery1);
println!("小螃蟹把收藏册送人了,小咪现在有 {} 个主人。", Rc::strong_count(&pet));
drop(gallery2);
println!("小乌龟也不要了,小咪只剩 {} 个主人了。", Rc::strong_count(&pet));运行:
text
小咪有 3 个主人。
小咪 洗香香了!可爱度变成 10
小螃蟹把收藏册送人了,小咪现在有 2 个主人。
小乌龟也不要了,小咪只剩 1 个主人了。看计数器:3 → 2 → 1。每放手一个,计数器减一;最后那个主人(pet 自己)也走了,小咪才真正说再见。 这就是引用计数的生活:有人惦记,就还活着。
练习三:迭代版 show(不用递归)
递归的 show 很优雅,但火车太长时,递归会一直往深处钻(每一层都占一点"呼吸空间")。写一个不用递归的版本:用 loop 和"指针走一步"的方式遍历整列火车。
提示:用一个 let mut current = self; 当"当前位置",每打印一节车厢,就 current = next; 走到下一节(注意 next 是 &Box<TrainCar>,赋给 current(类型 &TrainCar)时 Rust 会自动"穿过盒子"——第 14.3.1 节的 Deref 魔法)。
点开看答案
把 show 换成迭代版:
rust
fn show(&self) {
let mut current = self;
loop {
match current {
TrainCar::End => {
println!("(车尾)");
break;
}
TrainCar::Car { cargo, next } => {
println!("车厢装着:{}", cargo);
current = next;
}
}
}
}对比两个版本:
rust
// 递归版:喊下一节车厢自己打
next.show();
// 迭代版:自己走到下一节
current = next;递归版像"传话":第一节喊第二节,第二节喊第三节……每层都要等下面回话。迭代版像"自己走":我打印完,走到下一节继续打——一圈一圈走完,不往下钻。效果一模一样,但火车多长都不怕。
(小贴士:current = next; 能编译,靠的是 Deref 魔法——&Box<TrainCar> 自动变成 &TrainCar,盒子里的东西可以直接用。)
14.5 完整代码清单
项目结构:
text
toy_train/
├── Cargo.toml
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "toy_train"
version = "0.1.0"
edition = "2024"文件:src/main.rs
rust
use std::cell::RefCell;
use std::rc::Rc;
// ===== 小火车(Box 链表) =====
enum TrainCar {
End,
Car { cargo: String, next: Box<TrainCar> },
}
impl TrainCar {
fn new_train() -> TrainCar {
TrainCar::Car {
cargo: String::from("车头"),
next: Box::new(TrainCar::Car {
cargo: String::from("煤炭"),
next: Box::new(TrainCar::Car {
cargo: String::from("礼物"),
next: Box::new(TrainCar::End),
}),
}),
}
}
fn count(&self) -> u32 {
match self {
TrainCar::End => 0,
TrainCar::Car { next, .. } => 1 + next.count(),
}
}
fn show(&self) {
match self {
TrainCar::End => println!("(车尾)"),
TrainCar::Car { cargo, next } => {
println!("车厢装着:{}", cargo);
next.show();
}
}
}
}
// ===== 共享宠物(Rc + RefCell) =====
struct Pet {
name: String,
cuteness: RefCell<u32>,
}
impl Pet {
fn bath(&self) {
let mut cuteness = self.cuteness.borrow_mut();
*cuteness += 1;
println!("{} 洗香香了!可爱度变成 {}", self.name, *cuteness);
}
}
fn main() {
let train = TrainCar::new_train();
println!("== 玩具小火车 ==");
train.show();
println!("一共 {} 节车厢。", train.count());
println!();
println!("== 共享宠物 ==");
let pet = Rc::new(Pet {
name: String::from("小咪"),
cuteness: RefCell::new(9),
});
let gallery1 = vec![Rc::clone(&pet)];
let gallery2 = vec![Rc::clone(&pet)];
println!("小螃蟹的收藏册里有 {}", gallery1[0].name);
println!("小乌龟的收藏册里有 {}", gallery2[0].name);
println!("小咪有 {} 个主人。", Rc::strong_count(&pet));
pet.bath();
println!(
"小螃蟹翻收藏册:{} 可爱度 {}",
gallery1[0].name,
*gallery1[0].cuteness.borrow()
);
println!(
"小乌龟翻收藏册:{} 可爱度 {}",
gallery2[0].name,
*gallery2[0].cuteness.borrow()
);
println!("小咪还是只有 {} 个主人。", Rc::strong_count(&pet));
}怎么运行:
bash
cd toy_train
cargo run运行检查单:
火车能打印"车头、煤炭、礼物、(车尾)",报 3 节车厢
小咪有 3 个主人(自己 + 两本收藏册)
洗澡后,两本收藏册看到的可爱度都是 10
把 next: TrainCar 的 Box 删掉,看编译器报"infinite size"错误(看完改回来!)Rc::clone 换成普通 .clone(),编译报错(小咪是 Rc<Pet>,不是 Pet 本身,复印不了)
试试在 bath 里再调一次 borrow_mut(),运行看 RefCell 怎么崩溃