第十章 集合与智能指针:把所有权装进盒子

你有没有过这种纠结:明明一个值该在堆上待着、该被好几处共享、甚至该在”编译期看来是只读、运行时却想改”的地方改一下——可第二章教的所有权、第三章教的借用规则,像两道门神,把你卡得死死的?

墨叔理解。所有权和借用是安全地基,但它们不是全部真相。真实世界里,“一个值被多方共享""一个值在编译期看来不可变、运行时却要变”这类需求天天有。Rust 没有说”不行”,而是给了你一套智能指针(smart pointer)——它们表面上是个值,骨子里替你管理着堆内存、引用计数、运行时借用检查。这一章我们把最常用的几个(Vec、String、Box、Rc、RefCell、Arc)的底层拆开,顺带把”内部可变性”这个哲学讲透。

先说结论:Vec/String 是”指针+长度+容量”三段式的小盒子,真正数据在堆上;Box 是单个值的堆搬家和递归类型的救星;Rc/Arc 用引用计数实现”多主人共享”;RefCell 把借用检查从编译期推迟到运行时,就是”内部可变性”。它们全是所有权系统的扩展,不是例外。

一、Vec 与 String 的底层:三段式小盒子

第二章我们埋过伏笔:String、Vec 这种”大小运行时才定”的东西,栈上只放一个”指向堆的小结构”。现在把盖子掀开,看里面到底啥样。

Vec<T> 在内存里,本质就是三个 usize 大小的字段打包:

  • ptr:一个指针,指向堆上那块连续存放元素的内存。
  • len:当前有多少个元素(“逻辑长度”)。
  • cap:实际分配了多少个元素的位置(“容量”)。

也就是所谓的”指针 + 长度 + 容量”三段式。画出来大概是这样:

栈上的 Vec 变量            堆上
+------------------+      +------+------+------+------+------+
| ptr  |----------|----> | elem0| elem1| elem2| elem3| (闲置)|
+------------------+      +------+------+------+------+------+
| len  | 4        |                          ↑
+------------------+                          cap = 5
| cap  | 5        |
+------------------+

String 完全同理,只是堆上存的不是 T 元素,而是一串 UTF-8 字节;len 是字节数(注意不是字符数,Rust 字符串按字节计长,因为一个字符可能占多字节)。

为什么要有 cap 这个字段?因为”动态增长”是 Vec 的卖点。当你 push 一个元素,如果 len < cap,直接往 ptr[len] 写,撑死 len++;如果 len == cap(装满了),Vec 就在堆上重新申请一块更大的内存(通常是翻倍)、把老数据搬过去、释放老的、更新 ptr 和 cap。这个过程叫 reallocate,有成本——所以 Vec 用”翻倍扩容 + 预留容量”来摊薄成本,让 push 的均摊复杂度是 O(1)。你调 Vec::with_capacity(n) 预先订好容量,就是告诉它”别老搬家了”。

String 的 push_str 同理。理解三段式,你就懂了为什么 Vec/String 的 len() 是 O(1)(直接读字段)、capacity() 也是 O(1),以及为什么”频繁 push 且不预分配”会有隐性搬运开销。

打个比方。你去停车场,手里拿的是一张”停车卡”:卡上印着”我的车位在 B2 区第 7 排(ptr)、现在停了 4 辆车(len)、这一排总共划了 5 个车位(cap)“。真车停在 B2 的空地上(堆),卡在你兜里(栈)。车越来越多,5 个车位满了,管理员去 B3 包下 10 个车位、把车挪过去、给你换张卡(reallocate)。Vec 就是这张会自己管车位的卡。

二、Box:把单个值搬上堆,或救活递归类型

Box<T> 是最朴素的智能指针:它在堆上分配一个 T,栈上只留一个指向它的指针。Box 主要干两件事:

第一,把大值搬上堆,避免栈溢出或巨大拷贝。 栈空间小且要求编译期定长,一个超大结构体直接放栈可能爆。包进 Box,栈上只剩一个指针:

let b = Box::new(5);     // 5 在堆上,b 是栈上的指针
println!("{}", *b);      // * 解引用,拿到堆上的 5

第二,也是更关键的,让”递归类型”在编译期有已知大小。 看这个看似合理却编不过的结构:

// 想定义一个"链表节点":它含一个值和指向下一个节点的指针
// struct List {
//     val: i32,
//     next: List,   // 编译错误!List 包含 List,无限大,编译器算不出大小
// }

next 字段若是 List 本身,类型大小无限递归,编译器懵了。解法是 Box:

enum List {
    Nil,
    Cons(i32, Box<List>),   // Box 把"下一个节点"搬堆上,栈上只是指针,大小可知
}

Box<List> 的大小就是一个指针大小(固定),于是 List 整体大小可算,递归类型成立。这招在标准库链表、树结构里是基本功。墨叔点一句:Box 不改变所有权语义,它只是”拥有堆上那一个值”的普通所有者,离开作用域时 Box 里的 drop 会先 drop 堆上的值、再释放那块内存——RAII 一脉相承。

三、Rc:单线程下的”多主人共享”

第二章铁律说”一个值同一时刻只有一个主人”。可现实里,一个配置对象被整个程序多处读取、一个 AST 节点被多个父节点引用——这种”多处在读、无人写”的共享太常见了。Rust 的解决方案:Rc<T>(Reference Counted,引用计数)。

Rc 的思路朴实得像合租。一个值放进 Rc,谁想共享它,就 Rc::clone(&x) 生成一个新的 Rc 指针——注意这里 clone 不复制堆上的值,只是把”引用计数 +1”,新指针和老指针指向同一块堆内存。当某个 Rc 离开作用域,计数 -1;只有计数归零,堆内存才真正释放。

use std::rc::Rc;
 
let config = Rc::new(Config { timeout: 30 });
let a = Rc::clone(&config);   // 计数变成 2,堆上的 Config 没被复制
let b = Rc::clone(&config);   // 计数变成 3
// a、b、config 都指向同一份 Config,谁离开作用域计数减一,全走完才 drop

关键约束:Rc 只给”共享读”,不给”共享写”。 你拿到的 &Config 只能读不能改。因为多处同时改共享值,正是数据竞争的温床,Rc 从设计上就堵死写——它要的是”多读者、零写者”的安全共享。Rc 是单线程的(它为了性能不做原子操作,标了 !Send/!Sync,不能跨线程)。多线程要共享,请用 Arc,马上讲。

如果你写过带 GC 的语言,这里的心智要重装:Rc 不是 GC。GC 是后台定时扫、不可预测的停顿;Rc 的计数在每次 clone/drop 时即时增减,零运行时扫描、确定性释放,只是把”引用计数”这门手工活自动化了。它比 GC 轻,但比”真·单所有者”重一点(每次 clone/drop 要动计数)。

四、Arc:多线程版的 Rc

Arc<T> 和 Rc 几乎一模一样,唯一区别:它的引用计数是原子操作(atomic),能在多线程间安全增减。代价是原子操作比普通整数加减略慢一点。

use std::sync::Arc;
use std::thread;
 
let data = Arc::new(vec![1, 2, 3]);
for _ in 0..3 {
    let d = Arc::clone(&data);        // 原子计数 +1
    thread::spawn(move || {
        println!("线程里读到: {:?}", d);
    });
}   // 三个线程结束,计数归零,data 释放

选型口诀:单线程共享用 Rc,跨线程共享用 Arc。 第十三章讲并发时你会发现,Arc 通常还配 Mutex/RwLock 来补上”共享写”的能力——因为 Arc 本身和 Rc 一样只给共享读,要写得加锁。这点先记着,别急。

五、RefCell 与内部可变性:把借用检查推迟到运行时

这是本章的哲学高潮,也是全书的”反直觉之最”。回忆第三章:借用规则说”要么多个 &T 读,要么一个 &mut T 写,编译期定死”。但有些设计天生是”对外看着不可变、内部却要改”。典型例子:一个缓存对象,你调 get(key) 时它对外签名是 &self(不接收 &mut),但内部可能要更新缓存表。

RefCell<T> 解决的就是这个。它的魔法叫内部可变性(interior mutability):值在编译期看来是”不可变引用 &RefCell<T>”,你却能在运行时通过 borrow_mut() 拿到一个可写句柄去改里面的值。

use std::cell::RefCell;
 
let cache = RefCell::new(vec![1, 2, 3]);
// 即使 cache 是 &(不可变引用),也能改里面:
cache.borrow_mut().push(4);          // 运行时借到可变句柄,push 成功
let first = cache.borrow()[0];        // 运行时借到只读句柄

RefCell 怎么做到”编译期不可变、运行时尚可改”?它把第三章那套”多读/一写”的借用规则,从编译期搬到了运行时。编译期它对编译器宣称”我就是个普通值,你别管借用”,于是 &RefCell<T> 能到处传;真到运行时 borrow()/borrow_mut() 被调用,它才去检查”现在有没有人已经在写(或读)“——如果违反了规则(比如已经 borrow_mut 了你还想再 borrow_mut),它直接 panic,而不是编译报错。

这带来一个根本性的权衡,墨叔请你刻进脑子:

编译期借用检查:错误在编译时发现,零运行时成本,但有些合法模式它表达不了。 RefCell 运行时借用检查:那些编译期表达不了的模式它能表达,但违反规则不再编译报错,而是运行时 panic。

换句话说,RefCell 是用”把潜在 bug 从编译期推迟到运行时”为代价,换来了”编译期无法建模的可变共享”。这是一笔清醒的账:你明知某段代码在逻辑上保证”不会同时多写”,只是编译器那套静态规则推导不出,那就用 RefCell 人工担保,把检查留在运行时。

RefCell 通常是和 Rc 搭伙用的。 Rc 给”单线程多主人共享读”,但 Rc 不给写;想”多线程(单线程情形)共享且还能改”,就 Rc<RefCell<T>>:外层 Rc 负责多处持有同一份,内层 RefCell 负责在运行时安全地改那份。

use std::rc::Rc;
use std::cell::RefCell;
 
let shared = Rc::new(RefCell::new(0));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
*a.borrow_mut() += 1;     // a 改了
*b.borrow_mut() += 1;     // b 也改了,改的是同一份
// 注意:这里没并发,是单线程里交替 borrow_mut,所以不会 panic

六、内部可变性:为什么有时要绕开编译期检查

墨叔想把”内部可变性”的哲学讲透,因为它暴露了 Rust 设计上最诚实的一次妥协。借用规则不是万能的——它能静态证明”一大类”数据竞争不存在,但有一小类逻辑上安全、却无法被静态规则表达的模式,被它一并拦下了。比如:

  • 缓存:读方法签名是 &self,内部却要写缓存(上面讲过)。
  • 借用检查器算不出”两次不可变借用其实访问的是不同字段”的某些复杂情形。
  • 某些自引用、或”方法返回内部数据的可变视图”的结构。

这些不是 bug,是”借用规则的静态证明力有边界”。Rust 的回答很优雅:默认用编译期检查(零成本、最安全);当你确实需要越过这道边界、且你人工担保安全时,用 RefCell/Cell 这类内部可变性类型,把检查推迟到运行时,并承担相应的 panic 风险。

所以内部可变性不是”破坏规则”,而是”规则之外、由你担保的一块特区”。它把”编译期能证明”和”编译期证明不了但运行时守得住”两件事分层处理——能静态证明的,绝不留到运行时;静态证明不了的,给你一个明码标价的后门,且这个后门是显式的(你写 RefCell 就是在告诉读者”这里我在运行时担保安全”),不是偷偷绕过。

对比一下:别的语言里”随便改”是默认,安全靠纪律;Rust 里”编译期保证”是默认,运行时担保是显式 opt-in 的特区。同样是”绕开静态检查”,Rust 让你清清楚楚地绕,而不是稀里糊涂地绕。这又是那句老话:Rust 的严格是精准的严格,只在会出事的地方严格,且严格到证明不了时,给你一个看得见、算得清代价的安全阀。

【思考题】

  1. Vec 的”三段式”(ptr/len/cap)里,为什么 len 和 cap 是两个独立字段,而不是只留一个 cap?请你推:如果只有 cap、没有 len,Vec 在”读已有元素”和”判断是否装满”这两件事上会出什么困难?再想:&vec[2] 取第三个元素时,编译期和运行时会用哪个字段做边界检查?

  2. Rc 只给”共享读”不给”共享写”。现在假设 Rust 允许 Rc 直接 .clone() 后各自拿到 &mut 去写同一块内存。请推:这会复活第二章讲的哪一类内存安全 bug?和”数据竞争”有什么关系?为什么 Rc 宁可让你配 RefCell 才改、也不直接放开写?

  3. RefCell 把借用检查从编译期推迟到运行时。请你设计一个场景:某段代码在逻辑上保证”不会同时出现两个 borrow_mut”,但编译器的静态借用检查器就是推导不出来、会报错拒绝。你如何用 RefCell 解决了它?同时,请你诚实地讲:用 RefCell 之后,原来”编译期必报”的某种错误,变成了什么形态、在什么时刻暴露?这个代价值不值,你怎么判断?

【参考答案】

  1. len 和 cap 职责不同,不能合并。len 是”逻辑上有多少个有效元素”,cap 是”底层分配了多少个位置”。push 一次,逻辑上多一个元素,len++,但 cap 不变(直到装满才扩容)。如果只剩 cap、没有 len,第一,vec[2] 这类”取第几个有效元素”就失去了依据——你不知道前 2 个位置到底有没有被写进有效数据,只能假设”分配了多少就有多少”,那半途的状态(分配了 5、只写了 3)就会把未初始化的内存当有效元素读出来,是内存安全问题。第二,“判断是否装满”其实也需要 len:len == cap 才扩容;若只有 cap,你无从知道”已写几个”,也就不知道何时该 reallocate。所以 len 是”使用视角”,cap 是”分配视角”,二者独立才说得清状态。

至于 &vec[2] 的边界检查:编译期能确定的是 vec 的类型和”索引 2 是个常量”,但”2 是否小于当前元素个数”取决于运行时的 len(因为 push 是动态的),所以索引越界检查必须落到运行时——运行时比较 2 < len,不成立就 panic。编译期只能保证”你取的是一个同类型元素的引用”,真正的越界防护靠运行时读 len 字段。这也解释了为什么 Vec 下标访问是可能 panic 的、而 get(2) 返回 Option 让你显式处理越界——二者底层都读 len。

  1. 若 Rc 直接给各处 &mut 写同一块内存,会复活两类 bug。第一,多个 &mut 同时存在,直接违反第三章”同一时刻只能有一个可变借用”的铁律——这本身就是未定义行为的入口。第二,更现实的是数据竞争(data race):虽然 Rc 是单线程的,但即便单线程,多处持有 &mut 指向同一值,意味着逻辑上”谁改了什么”彻底失控,一处改了另一处不知,读到的可能是半更新状态(撕裂读)。Rc 只给共享读,是因为”多读者、零写者”是静态可证明安全的(只读不会互相干扰);一旦放开写,单所有者那套”释放责任唯一”和”借用排他”的保护全被打破。所以它逼你显式套 RefCell:意思是”想改可以,但改的动作要通过 borrow_mut 在运行时受查”,把”写”从默认放行变成”显式、受控、可被运行时拦截”的动作。这正呼应第二章——Rust 不让”隐式共享可变状态”存在,Rc+RefCell 让你把共享摆到台面上、用运行时检查兜底,而非偷偷多发 &mut。

  2. 场景举例:一个缓存结构,方法 get(&self, key) 签名只能拿 &self(不可变),但内部要维护一个 HashMap 记录”访问计数”或”懒加载结果”。逻辑上你保证:每次 get 调用是串行的(无并发),所以”同时两个 borrow_mut”在物理上不可能发生;但编译器的静态检查器看到 get 接收 &self、内部却要改字段,它无法从签名推出”这安全”,会报”不能从 &self 拿到 &mut”拒绝编译。解决:把内部字段包成 RefCell,用 self.cache.borrow_mut() 在运行时拿可变句柄——编译期它只看到一个 &RefCell(普通不可变引用),放行;运行时只要不并发借用,检查通过。

代价:原来”编译期必报”的借用冲突错误,现在变成了运行时 panic——若你代码真有某路径同时 borrow_mut 两次,编译照过,运行时才 already borrowed 炸。换句话说,你把”编译期保证”换成”运行时守门”,安全从”证明”降级为”运行时检查”。这个代价值不值,判断标准是:这段逻辑你是否真能用人工推理保证”不会同时多写”? 能,且这模式静态规则确实表达不了(如缓存、内部状态惰性更新),那 RefCell 是干净的正解,值;若你只是图省事不想理清借用、其实本可以改成 &mut self 或拆分所有权,那用 RefCell 就是把本可在编译期抓到的 bug 推迟到线上,不值。墨叔的原则:RefCell 是给”逻辑安全但静态不可证”留的特区,不是给”懒得设计所有权”开的口子。

小结

Vec/String 是”指针+长度+容量”三段式、真数据在堆;Box 搬单值上堆、救活递归类型;Rc/Arc 用引用计数实现”多主人共享读”(单/多线程之分);RefCell 以运行时借用检查换得内部可变性。它们全是所有权系统的扩展而非例外——编译期证明不了的,才显式交给你在运行时担保。