Rust 智能指针 II

常见智能指针与内部可变性

技术
技术 Rust 标准库 编程语言特性 智能指针

2026-03-30

了解过 DerefDrop 两个基本特征后,智能指针神秘面纱的一角已经显现出来。下面会介绍常见的智能指针。

Box<T>

Box<T> 只进行简单的堆内存分配,也就是将数据存放在堆上,同时在栈上保存一个指向堆的指针。它拥有其分配内存的所有权,并在离开作用域时释放其中内容。

let val: u8 = 5;
// 通过创建 `Box` 将值从栈移到堆
let boxed: Box<u8> = Box::new(val);
// 通过解引用将值从 `Box` 移回栈
let val: u8 = *boxed;

Box<T> 对于创建递归的数据结构很有用。如果不用 Box 包裹数据会无法经过编译检查,因为编译器无法推定整个数据结构的内存分配大小。

// 此枚举是递归的数据结构
// 如果第一个变体定义为 `Cons(T, List<T>)`
// 会无法通过编译
enum List<T> {
    Cons(T, Box<List<T>>),
    Nil,
}

let list: List<i32> = List::Cons(
    1, 
    Box::new(List::Cons(
        2, 
        Box::new(List::Nil)
    ))
);

由于 Box 是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗,代价是也没什么别的功能。

Box 和普通的数据在所有权上表现差不多:一个 Box 只能有一个所有者。

内部可变性

由所有权与借用规则可以推知:无法可变地借用一个不可变值。

let x = 5;
let y = &mut x;
// 编译失败,提示:
// cannot borrow `x` as mutable, as it is not declared as mutable

然而,特定情况下令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的(这样值方法的外部代码就不能修改其值了)。为此我们提出内部可变性的概念。

内部可变性(Interior mutability)是 Rust 中的一种设计模式,它允许你即使在有不可变引用时也可以改变数据,简言之就是在不可变值内部改变值。这通常是借用规则所不允许的。

Cell<T>RefCell<T>OnceCell<T> 类型的值可以通过共享引用(即 &T 类型)进行修改,而大多数 Rust 类型只能通过可变引用(&mut T)进行修改。因此我们说这些容器类型提供了内部可变性(通过 &T 可变)。

Important

要注意,Cell<T>RefCell<T>OnceCell<T> 类型都只适合单线程环境,它们均未实现 Sync。如果想在多线程环境中实现内部可变性,Mutex<T>RwLock<T>OnceLock<T> 或原子类型才是正确的选择。

Cell<T>

Cell<T> 通过将值移入和移出容器来实现内部可变性。通过把数据包装在 Cell 里,即使只有不可变引用(&Cell<T>)你也能修改内部的值。代价是你永远无法拿到内部值的引用。

换言之,一旦把数据放入 Cell,对其进行修改轻而易举(set 方法就行),但是想访问它却变得很困难,除非:

下面是使用 Cell 在不可变结构体内部实现可变性的例子:

use std::cell::Cell;

struct SomeStruct {
    regular_field: u8,
    special_field: Cell<u8>,
}

let my_struct = SomeStruct {
    regular_field: 0,
    special_field: Cell::new(1),
};

let new_value = 100;
my_struct.special_field.set(new_value);
// 如果写成
// my_struct.regular_field = new_value;
// 编译就会报错
// 因为 `my_struct` 是不可变的

// 但其内部的 `special_field` 字段是 `Cell`
// 因此在内部可变性的加持下
// 我们能修改 `special_field`
assert_eq!(my_struct.special_field.get(), new_value);

Cell<T> 通常用于那些复制或移动开销不大的简单类型(例如数字),或者说是 Copy 类型。

RefCell<T>

Rust 的所有权与借用规则强有力地保障了程序运行时的内存安全。绝大多数情况下,编译器会在编译阶段就应用这些规则对代码进行分析检查:符合规则就放行;违反规则就抛出错误拒绝编译。这样既捕获了那些内存安全的隐患,又能让程序运行时的性能得到最大发挥。

但有些时候,这种编译时的行为会成为程序开发的阻碍。静态分析——正如 Rust 编译器所做的——天生保守,但代码的一些属性是不可能通过分析静态代码发现的。

很多情况下,你确信代码遵守了借用规则,而编译器却不能理解和确定。此时我们选择将对某些代码的借用检查从编译期延后至运行时。这相当于告诉编译器:“我为我的代码担保运行时不会出现内存安全问题,所以你也应该放我们编译通过。”

RefCell<T> 正是应用在这种场景中。

RefCell<T> 利用 Rust 的生命周期来实现“动态借用”。与 Rust 原生引用类型完全在编译时进行静态跟踪不同,RefCell<T> 的借用是在运行时动态跟踪的。

Important

RefCell<T> 并不是绕开了借用规则,借用规则始终是生效的,只不过对规则的检查从编译期延后至运行时了。如果违反了这些规则,程序依然会在运行时崩溃,但不再是出现编译错误了。

可以通过 borrow 方法获得指向其内部值的不可变引用(&T),通过 borrow_mut 获得可变借用(&mut T)。此处依然要遵守借用规则,即:同一作用域中要么全是不可变借用,要么至多有一个可变借用,不能同时出现不可变借用与可变借用。

use std::cell::RefCell;

let c = RefCell::new(5);
let m = c.borrow();

let b = c.borrow_mut(); // 这里会 panic
// 因为已经有了不可变引用
// 不能再出现可变引用

你可能觉得既然 RefCell 依旧受借用规则的限制,只不过把编译时的错误移到了运行时发生,那它存在的意义是什么?

答案是通过延后借用检查,在代码中实现内部可变性。让我们看下面这个例子:

假设 Messenger 这个特征来自外部库。

pub trait Messenger {
    fn send(&self, msg: String);
}

然后在当前代码中有:

// 一个异步消息队列
// 先把数据写进缓存里
struct MsgQueue {
    msg_cache: Vec<String>,
}

impl Messenger for MsgQueue {
    fn send(&self, msg: String) {
        self.msg_cache.push(msg);
        /* 其他操作 */
    }
}

上面的代码无法编译通过!因为 send 方法中对 self.msg_cache 进行了修改,但传入的是 &self 而不是 &mut self。问题在于 send 是定义在外部的,我们肯定修改不了方法签名,那该怎么办呢?

答案是利用 RefCell 的内部可变性。通过包裹这个需要修改的字段,实现在不可变引用(方法传入 &self)的情况下内部可变(修改字段)。

pub struct MsgQueue {
    msg_cache: RefCell<Vec<String>>,
}

impl Messenger for MsgQueue {
    fn send(&self, msg: String) {
        self.msg_cache.borrow_mut().push(msg)
    }
}

RefCell 的另一处用武之地是与 Rc 结合使用。这会在 Rc 部分详细介绍。

Note

线程安全版本的 RefCell<T>RwLock<T>

OnceCell<T>

OnceCell<T> 适用于通常只需要设置一次的值。

创建 OnceCell 时,它内部还是未初始化的。此时可以通过 setget_or_init 这类只接收 &OnceCell 的方法来初始化内部值。一旦被初始化,内部值就无法再更新了,除非你拥有对 OnceCell 的可变引用。

use std::cell::OnceCell;

// 这是不可变值
let cell = OnceCell::new();
assert!(cell.get().is_none());
// `set` 方法接收的是 `&self`
assert_eq!(cell.set(92), Ok(())); // 使用内部可变性
assert_eq!(cell.set(62), Err(62)); // 已初始化,无法再修改
assert!(cell.get().is_some());

let mut cell = OnceCell::new(); // 这是可变值
assert_eq!(cell.set(92), Ok(()));
// `take` 方法接收的是 `&mut self`
assert_eq!(cell.take(), Some(92)); // 取出内部值,使其重新回到未初始化状态
assert_eq!(cell.get(), None);

OnceCell<T> 允许在不替换或拷贝内部值的情况下(与 Cell 不同),且没有运行时借用检查(与 RefCell 不同)的情况下获得引用 &T

OnceCell 这个名字表明它名义上只能被写入一次。这使其可以被视为对未初始化数据的安全抽象,该数据在写入后即变为已初始化状态。

Note

其实 OnceCell<T> 并不是严格意义上的“智能指针”,但它属于 Rust 内部可变性工具家族,和 RefCell<T>Cell<T> 很接近。因此这里放在一起介绍。

Note

线程安全版本的 OnceCell<T>OnceLock<T>

LazyCell<T, F>

LazyCell<T, F> 是首次访问时才初始化的类型。意思就是在它被创建时并不会通过执行传入的闭包来初始化内部值,初始化是直到其内部值要被首次访问时才进行的。这也是为什么称其“lazy”的缘故。

LazyCell 实现了 DerefDerefMut,未初始化的情况下解引用会触发其初始化过程。

use std::cell::LazyCell;

let lazy: LazyCell<i32> = LazyCell::new(|| {
    println!("initializing...");
    92
});
println!("ready");
println!("{}", *lazy);
println!("{}", *lazy);
// 输出
// ready
// initializing...
// 92
// 92

可以通过 forceforce_mut 方法来强行开始初始化并返回初始化后内部值的(可变)引用。这两种方法与解引用的作用类似,只不过它们都是显式的。

Important

LazyCell::new 通过传入一个闭包来指定如何初始化自身,但问题是闭包不一定能够成功执行。

一旦该闭包在运行时 panic,这个 LazyCell 就会处于中毒状态,任何试图访问此 cell 的线程都会 panic。

Note

线程安全版本的 LazyCell<T, F>LazyLock<T, F>

Rc<T>

Rust 所有权机制要求一个值只能有一个所有者,在大多数情况下这都没有问题,但是考虑以下情况:

以上场景不是很常见,但是一旦遇到就非常棘手。为解决此类问题,Rust 在所有权机制之外又引入了额外的措施来简化相应实现:通过引用计数的方式,允许一个数据资源在同一时刻拥有多个所有者。

在单线程环境下,使用引用计数的智能指针是 Rc

Note

线程安全版本的 Rc<T>Arc<T>

Rc<T> 类型提供了对 T 类型值的共享所有权,该值会被分配在堆上。对 Rc 调用 clone 会生成一个新的指针,指向堆上的同一分配区域。当指向该分配区域的最后一个 Rc 指针被销毁(即离开其作用域)时,该分配区域中存储的值(即 Rc 的内部值)也会被丢弃。

使用关联函数 Rc::strong_count 可以获取当前引用计数的值。

use std::rc::Rc;

let foo = Rc::new(vec![1.0, 2.0, 3.0]);
// 以下两种写法等价,但更推荐第二种
let a = foo.clone();
let b = Rc::clone(&foo);
// `a` 与 `b` 都是和 `foo` 一样指向内存中相同的值

println!("{}", Rc::strong_count(&foo)); // 3

Rc<T> 会自动解引用为 T(通过 Deref 特征),因此你可以在 Rc<T> 类型的值上直接调用 T 的方法。为了避免与 T 的方法名称冲突,Rc<T> 自身的方法都是关联函数,需要使用下面这样的完全限定语法来调用:

use std::rc::Rc;

let my_rc = Rc::new(());
let my_weak = Rc::downgrade(&my_rc);

借助 cell 实现内部可变性

在 Rust 中,不可变引用默认是禁止修改的,Rc 也不例外:事实上 Rc 是指向底层数据的不可变的引用,因此你无法通过它来修改数据。

如果需要可变性,可以在 Rc 内部放置一个 CellRefCell

use std::cell::{RefCell, RefMut};
use std::collections::HashMap;
use std::rc::Rc;

fn main() {
    let shared_map: Rc<RefCell<_>> = Rc::new(RefCell::new(HashMap::new()));
    // 创建一个代码块来限制可变借用的作用域
    {
        let mut map: RefMut<'_, _> = shared_map.borrow_mut();
        map.insert("africa", 92388);
        map.insert("kyoto", 11837);
        map.insert("piccadilly", 11826);
        map.insert("marbles", 38);
    }
    // 如果没有上面的代码块,下面的语句就会因可变与不可变引用共存而报错
    let total: i32 = shared_map.borrow().values().sum();
    println!("{total}");
}

循环引用的危机与 Weak<T>

Rc 与 cell 的组合技很强大,但强大的外表下可能隐藏着内存泄漏的危机。看这个例子:

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

这里我们定义一个递归的数据结构(就像在 Box 举过的例子)。同时提供一个 tail 方法用于获取所指向的下一个节点。

let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

println!("a 的下一个节点为 {:?}", a.tail().unwrap());
// a 的下一个节点为 RefCell { value: Nil }

// 创建 `b` 到 `a` 的引用
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

println!("b 的下一个节点为 {:?}", b.tail().unwrap());
// b 的下一个节点为 RefCell { value: Cons(5, RefCell { value: Nil }) }

到目前为止一切正常,内存中数据是按图示方式组织的:

a ─────> [  5 | ∙ ] ──────> [ Nil ]
              ↑
              │
              └─┐
                │
b ─────> [ 10 | ∙ ]

现在我们执行下面的代码安装一个“内存起爆器”:

if let Some(link) = a.tail() {
    // 此时 `link` 是 RefCell { value: Nil }
    *link.borrow_mut() = Rc::clone(&b);
    // 使用了 `RefCell` 提供的可变性
    // 把 link 内部值修改为 `b` 指向的节点
}

好像还可以成功运行,现在内存中数据像是这样:

a ─────> [  5 | ∙ ] ──────> [ ∙ ]
              ↑               │
              │               │
              └─┐             │
                │             │
b ─────> [ 10 | ∙ ] <─────────┘

起爆器已被成功安装,现在你只需要按下那个毁灭世界的按钮——

println!("a 的下一个节点为 {:?}", a.tail().unwrap());
// 起爆按钮就是 `tail` 和 打印操作
// 代码能通过编译,在运行时因栈溢出被强制终止:
// thread 'main' has overflowed its stack
// fatal runtime error: stack overflow, aborting

为什么会发生栈溢出?问题就出在循环引用上。

我们使用 RefCell 提供的可变性,将内部本是 List::NilRc 修改为一个指针,这个指针和 b 本质是一样的,它们都指向同一个节点。

当打印 a 调用 tail 的结果时,需要注意操作是通过 Debug 特征进行打印的,而该特征会递归地打印内部数据结构。

因此程序终止前你会看到长长的、不断循环的一串字符:打印操作试图打印 a 的下一个节点(也就是 b 所指节点),但打印 b 时又包含 a 所指节点,如此往复以致最终栈溢出。

事实上这个程序所带来的问题远比看起来严重。即使我们不按下那个终结一切的按钮,程序仍然存在内存泄漏的风险:原因在于,当循环引用形成后,ab 的引用计数均是 2,而在 main 函数结束时,ab 这两个指针因为离开作用域而被释放,但它们各自指向的分配区域的引用计数却无法归零,也就是说内存变成了这样:

[  5 | ∙ ] ──────> [ ∙ ]
     ↑               │
     │               │
     └─┐             │
       │             │
[ 10 | ∙ ] <─────────┘

由于引用计数不为零,Rust 不会释放这两片区域,最终导致了内存泄漏。

循环引用的危机应该如何解决?Rust 给出的方案是使用 Weak


之前提到过,智能指针一般都拥有其数据的所有权。Rc 也不例外。

Rcdowngrade 方法可用于创建 Weak 指针。Weak 不持有所有权,它仅仅保存一份指向数据的弱引用。

反过来 Weak 指针可以升级为 Rc,但如果分配区域中存储的值已经被丢弃,则升级会返回 None。换句话说,Weak 不保证引用关系依然存在,这也就是称其为“弱”引用的原因。

Note

Rc<T> 不同,Weak<T> 没有实现 Deref,不会自动解引用为 T,因为内部值可能已经被丢弃。

基于这一点,Weak<T> 必须先成功升级到 Rc<T>,然后通过 Rc 的解引用才能访问内部值。任何想通过 Weak 直接访问内部值的操作都会失败。

Note

这里回应了之前埋下的伏笔:为什么 Rc 的引用计数方法叫做 strong_count

答案是还有个 weak_count

Weak 不会增加 strong_count,只会增加 weak_count。而 Rc 只在 strong_count == 0 时完全被释放。

use std::rc::Rc;

let five = Rc::new(5);
let _strong_five = Rc::clone(&five);
let _weak_five = Rc::downgrade(&five);

assert_eq!(1, Rc::weak_count(&five));
assert_eq!(2, Rc::strong_count(&five));

由于 Rc 指针之间的循环永远不会被释放,因此 Weak 被设计用于打破循环。例如,树结构中的父节点到子节点可以使用强引用的 Rc 指针,而子节点回到父节点则可以使用弱引用的 Weak 指针。

让我们回到前面循环引用的例子,并探讨如何使用 Weak 改良:

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
enum List {
    // 原本是  RefCell<Rc<List>>
    Cons(i32, RefCell<Weak<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Weak<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

Weak 替换掉 Rc,现在各节点之间的联系都从强引用转为弱引用了。Weak 不会增加 strong_count,因此不影响 Rc 的释放逻辑。

然后创建节点:

let nil = RefCell::new(Rc::downgrade(&Rc::new(Nil)));
let a = Rc::new(Cons(5, nil));

let a_weak = Rc::downgrade(&a);
let b = Rc::new(Cons(10, RefCell::new(a_weak)));

接着再次尝试安装起爆器:

if let Some(link) = a.tail() {
    *link.borrow_mut() = Rc::downgrade(&b);
}

好像此时那个毁灭世界的按钮不再管用了:

println!("a 的下一个节点为 {:?}", a.tail().unwrap());
// a 的下一个节点为 RefCell { value: (Weak) }

让我们复盘一下这样为什么行:由于我们选择在各节点之间使用 Weak 而不是 Rc 建立连接,各 Rc 指针的引用计数均为 1,所以它们离开作用域时都会被完全销毁,无法造成内存泄漏;另外虽然我们依旧人为地创造出循环引用,但由于循环的是 Weak,Rust 故意将 WeakDebug 打印设置为隐藏其内部(前面提到无法通过 Weak 直接访问其内部值),这样就不会产生无限循环打印以致栈溢出的情况了。

参考资料