Skip to content

智能指针总结

为什么需要智能指针?

  • 在 C++ 或 C 语言中,指针就是一个内存地址;但在 Rust 中,指针被赋予了管理所有权的任务.
  • 之所以需要这些特殊的结构体(智能指针),是因为 Rust 的所有权规则(Ownership)非常严苛.这几个指针本质上是"合法的后门",用来解决某些特定场景下的编译限制.

是对 Rust 所有权的一种补丁

  • Box<T>:空间扩展补丁,当你需要在堆上分配内存或处理递归类型时使用.
  • Rc<T>:所有权共享补丁,当你需要多个所有者共享同一数据时使用.
  • RefCell<T>:延迟检查补丁,当你需要在不可变引用中修改数据时使用.

Box<T>:解决"大小未知"和"堆分配"

Rust 在编译时必须知道每个变量占多少字节.

  • 痛点:如果你想写一个递归定义(比如链表或树),或者想把大型数据存在堆上避免栈溢出,普通变量做不到.
  • 作用:它像是一个定长的快递盒,盒子里装的内容(T)可以很大或大小不定,但盒子本身(指针)大小固定.
  • 一句话理解:当你需要确定性的大小或堆内存时用它.

Rc<T>:解决"多重所有权"

Rust 规定一个值只能有一个主人(Owner).

  • 痛点:在现实逻辑中(比如图结构、多个 UI 组件共享同一个配置),一个数据可能被多个地方同时拥有.
  • 作用:它像是一个计数器.每多一个使用者,计数加 1;使用者走光了,数据才销毁.
  • 一句话理解:当你需要一个值有多个主人(仅限只读)时用它.

RefCell<T>:解决"内部可变性"

Rust 规定,如果你手里只有某个数据的只读引用,你就绝对不能修改它.

  • 痛点:有时候你逻辑上需要修改某个数据,但它被包在一个不可变的结构里,或者你在用 Rc<T>(Rc<T> 只能给只读引用).
  • 作用:它把原本在编译期进行的借用检查,推迟到了运行期.它允许你在拥有不可变引用时,依然能修改内部的值.
  • 一句话理解:当你需要"修改不可变引用的值"时用它.

总结对比表

类型所有权可变性线程安全适用场景
Box<T>唯一可变递归类型、大数据转移
Rc<T>多重不可变图结构、共享配置
RefCell<T>单一运行期检查内部可变性、Mock测试
Rc<RefCell<T>>多重可变多所有权动态修改
  • 想省空间/递归/存堆:用 Box<T>.
  • 想多人共享数据:用 Rc<T>.
  • 想在只读时也能改:用 RefCell<T>.

最常见的搭配是 Rc<RefCell<T>>,实现了多所有权的动态修改.


Box<T> 的三个经典用途

Box<T> 是 Rust 中最简单、最常用的智能指针.它的核心作用只有一句话:在堆(Heap)上分配内存,并在栈(Stack)上保留一个指向它的指针.

你可能会问:"Rust 默认不就能处理变量吗?为什么非要费劲把它挪到堆上,再用个盒子装起来?"

这主要是为了解决以下三个核心问题:

① 解决"递归类型"的大小难题

Rust 在编译时必须确切知道每个类型占用多少字节.

痛点:如果你定义一个递归结构(比如链表):

rust
enum List {
  Cons(i32, List),  // ❌ 错误!List 包含 List,无限循环,大小无穷大
  Nil,
}

编译器会报错,因为它算不出 List 到底有多大.

解法:用 Box 打破递归.

rust
enum List {
  Cons(i32, Box<List>),  // ✓ 成功!Box 是一个指针,大小固定(64位系统下8字节)
  Nil,
}

比喻:你不能在一个盒子里放一个同样大的盒子(套娃),但你可以在盒子里放一张写着"下一个盒子地址"的小纸条.

② 转移超大数据的所有权

痛点:如果你在栈上有一个非常大的数组(比如 100MB),当你把它传给另一个函数时,Rust 默认会进行内存拷贝(从一个栈帧拷贝到另一个栈帧),这非常耗时.

解法:把数据存在堆上,只传递 Box.

比喻:你要送给朋友一架钢琴.

  • ❌ 不使用 Box:你费力地把钢琴搬到他家.
  • ✓ 使用 Box:你把钢琴存在仓库里(堆),只给了他一把仓库钥匙(栈上的指针).钥匙拿起来多轻快!

③ 类型擦除(Trait Object)

当你只关心对象"能做什么",而不关心它是"什么类型"时,需要 Box<dyn Trait>.

场景:你有一个 Draw 特性,想存一个 Vec 里面既有 Circle 又有 Square.

痛点:Vec 要求所有元素大小必须一致.但圆形和方形的大小不一样.

解法:Vec<Box<dyn Draw>>.盒子里装的对象大小不一,但盒子本身是一样大的.

Box 的特性总结

特性说明
唯一所有权和普通变量一样,Box 离开作用域时,它指向的堆数据会被立即销毁(除非所有权转让)
零成本抽象Box 仅仅是封装了指针,没有任何运行时的额外开销(不像 Rc 需要维护计数)
简单解引用通过 *my_box 就能直接拿到里面的值

简单代码示例

rust
fn main() {
  // 把 5 存到堆上
  let b = Box::new(5);

  // 像使用普通数字一样使用它,Rust 会自动帮你解引用
  println!("b = {}", b);

} // 这里 b 离开作用域,堆内存被自动释放

Rc<T> 的核心特性和使用场景

Rc<T> 全称是 Reference Counted(引用计数).在 Rust 的世界里,通常每个值都有且只有一个"主人".但在复杂的业务逻辑中,这种严苛的关系会导致代码极难编写.Rc 就是为了解决"一个值有多个所有者"的问题而诞生的.

前提: 需要共享的值必须被包裹在 Rc 里,并且拥有所有权.

为什么需要 Rc

想象一个"客厅电视机"的场景:

  • 普通所有权(Box/变量):电视机只有一个人能拥有.如果这个人走了,电视机就会关机(释放内存).其他人想看?没门.
  • Rc 指针:每多一个进来看电视的人,计数加 1.只要房间里还有人在看,电视机就不会关机.只有当最后一个人离开(计数归零)时,电视机才会关机.

核心工作原理

Rc 在堆上除了存储你的数据 T,还维护了一个引用计数器:

  • Rc::new(data):在堆上创建数据,计数设为 1.
  • Rc::clone(&rc_ptr):这不是深度拷贝数据,而只是增加一个指向相同堆地址的指针,并将计数加 1.这非常轻量高效.
  • Drop 自动释放:当一个 Rc 变量离开作用域时,计数减 1.如果减到了 0,Rust 就会真正清理堆内存.

典型使用场景

  • 图或复杂树结构:一个子节点可能属于多个父节点(多父结构),或者多个边指向同一个节点.
  • 共享配置/数据:在 UI 开发或系统架构中,多个组件需要同时持有同一份全局配置文件的访问权.

⚠️ 关键限制

限制说明
只读性通过 Rc 获取的引用默认是不可变的.如果你想让多个主人都能改数据,就必须搭配 RefCell,即使用 Rc<RefCell<T>>.
单线程限制Rc 不是线程安全的,它在增加计数时没加锁.如果要在多线程共享数据,必须改用 Arc<T>(Atomic Rc).
循环引用风险如果 A 持有 B 的 Rc,B 也持有 A 的 Rc,它们的计数永远不会清零,导致内存泄漏.解决方法是使用 Weak<T>(弱引用)来打破循环.

代码直观对比

rust
use std::rc::Rc;

fn main() {
  let data = Rc::new(5);

  let a = Rc::clone(&data);  // 计数 = 2
  {
    let b = Rc::clone(&data);  // 计数 = 3
    println!("当前有 {} 个主人", Rc::strong_count(&data));
  }  // b 离开作用域,计数 = 2

  println!("现在剩 {} 个主人", Rc::strong_count(&data));
}  // 只有当 main 结束,a 和 data 都失效,计数归 0,内存才释放.

RefCell<T> 的核心特性和使用场景

理解 RefCell 的关键在于:它打破了 Rust "不可变引用不能修改"的死律.这种能力被称为 "内部可变性"(Interior Mutability).

前提: 需要修改的值必须被包裹在 RefCell 里,并且拥有所有权.

为什么需要 RefCell

痛点:被编译器"锁死"的逻辑

  • 在 Rust 中,如果你有一个不可变借用 &T,编译器会死死盯着你,不让你调用任何修改数据的方法.
  • 但在实际开发中,有些数据从逻辑上讲必须是可变的,但从架构上讲它被包在了一个不可变的结构里.

场景示例

Mock 测试或回调

想象你在写一个发送邮件的接口.你有一个 Messenger trait,它的 send 方法是只读的(因为发邮件不应该改变发送器本身).

rust
trait Messenger {
  fn send(&self, msg: &str);  // 注意:这里是 &self,不可变
}

现在你想写一个测试用的 MockMessenger,记录一共发送了多少条短信.

❌ 不用 RefCell

rust
struct MockMessenger {
  sent_messages: Vec<String>,
}

impl Messenger for MockMessenger {
  fn send(&self, msg: &str) {
    // 报错!self 是不可变的,你不能 push 东西进 Vec
    self.sent_messages.push(String::from(msg));
  }
}

死局:Trait 定义了是 &self(为了通用性),但你的实现又必须修改内部数据.

✅ 使用 RefCell

RefCell 允许你在 &self 内部"偷偷"修改数据.它把借用检查从编译期挪到了运行期.

rust
use std::cell::RefCell;

struct MockMessenger {
  // 把 Vec 包在 RefCell 里
  sent_messages: RefCell<Vec<String>>,
}

impl Messenger for MockMessenger {
  fn send(&self, msg: &str) {
    // 虽然 self 是不可变的,但我们可以通过 borrow_mut() 获取内部的可变引用
    self.sent_messages.borrow_mut().push(String::from(msg));
  }
}

为什么要用它?

场景说明
绕过 Trait 限制当你必须遵循某个只读接口,但内部实现需要状态变更时
配合 Rc 使用Rc<T> 只允许你获取 &T(只读).如果你想让多个主人共同修改同一个值,必须用 Rc<RefCell<T>>
延迟检查当你确定你的逻辑在逻辑上是安全的,但 Rust 编译器太"死板"无法通过静态检查时

⚠️ 危险警告

RefCell 并不是银弹.如果你在同一个作用域里同时调用了两个 borrow_mut(),程序在编译时不会报错,但运行到那里会直接 panic(崩溃).

rust
let data = RefCell::new(5);
let mut a = data.borrow_mut();
let mut b = data.borrow_mut();  // ⚠️ 运行时 panic!

所以使用 RefCell 时,要确保你理解 Rust 的借用规则,并在逻辑上自己承担责任.

三大智能指针对比表

特性Box<T>Rc<T>RefCell<T>
核心目的在堆上分配空间实现多人共享所有权实现内部可变性
所有权独占所有权(单一主人)共享所有权(多个主人)独占所有权(通常包在 Rc 内)
借用检查编译时检查编译时检查运行时检查(出错会 panic!)
可变性遵循普通规则(&&mut)不可变(只能读不能改)可变(即使外层是只读引用)
性能开销极低(仅堆分配开销)中等(需维护引用计数)中等(需维护借用状态标志)
典型场景递归类型、大对象、Trait 对象共享配置、图结构、多父节点绕过编译器限制、Mock 测试
线程安全是(如果 TSend)否(多线程请用 Arc)否(多线程请用 Mutex)

💡 记忆口诀(实战指南)

  1. 想省空间/做递归:无脑选 Box.
  2. 想让一个数据有多个主人:选 Rc.
  3. 想在"只读"的情况下偷偷修改内部数据:选 RefCell.
  4. 终极武器 Rc<RefCell<T>>:这是最常用的组合方案.Rc 让大家都能拿着这个数据,RefCell 让大家都能修改这个数据.

基于 MIT 协议发布