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 协议发布