Skip to content

필사 모드: Rust 智能指针:Box、Rc、Arc、RefCell 与内部可变性

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 什么是智能指针

Rust 的所有权系统很强大,但有时会让人觉得过于严格。一个值只能有一个所有者,借用要遵循规则,大小必须在编译期确定。仅凭这些规则,很难表达递归数据结构,或是在多处共享的状态。填补这一空白的,正是智能指针(smart pointer)。

智能指针不是只装着地址的原始指针,而是一种在指向数据的同时,还携带额外元数据和行为的数据结构。它们大多拥有所指向的值,并在离开作用域时负责清理这个值。事实上,你每天都在用的 StringVec<T>,因为拥有并管理堆内存,本身就已经是智能指针。

本文将依次梳理标准库中五个核心智能指针:BoxRcArcRefCellMutex。我们会通过示例了解每一个是为了解决什么问题而存在、什么时候该伸手去用它,以及用错了会掉进什么陷阱。

Box — 把值放到堆上

最简单的智能指针是 Box<T>。它做的事情正如其名:把值放到(heap)而不是栈上,栈上只留一个指向该堆地址的指针。所有权依然只有一个,规则也就很简单。

fn main() {
    let boxed: Box<i32> = Box::new(5);
    println!("{}", boxed); // 5 —— 多亏 Deref,可以像值一样使用
    // boxed 离开作用域时,堆内存也会自动释放
}

只把一个整数放到堆上,本身几乎没有实际收益。Box 真正大显身手的场景另有其他。

第一,递归类型。Rust 需要在编译期知道类型的大小,而直接包含自身的类型,其大小会变成无穷大。下面的代码无法编译:

// 编译错误:recursive type has infinite size
enum List {
    Cons(i32, List),
    Nil,
}

List 里又完整地装了一个 List,大小自然无法确定。这时插入一个 Box,内层的值就放在堆上,栈上只留下指针(大小固定的值),大小便随之确定。

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    // 现在大小已确定,可以编译
}

第二,移动大值时。移动一个大结构体,需要在栈上整体复制字节,而用 Box 包一层后,值留在堆上,只需移动指针。

第三,trait 对象。要用同一个 trait 统一处理不同的具体类型,因为大小未知,所以要以 Box<dyn Trait> 的形式放到堆上、通过指针处理。这一点会在 Deref 一节继续展开。

Rc — 分享所有权

Box 建立在“只有一个所有者”的前提之上。但有时候,同一份数据需要被多处同时拥有——比如图中的一个节点被多个节点指向,或者多个数据结构共享同一个配置值。这时要用的就是 Rc<T>引用计数(reference counting)指针。

Rc 会在值旁边放一个计数器,用来记录“现在这个值有多少个所有者”。调用 Rc::clone 并不会复制数据,只会把计数器加 1;当某个所有者离开作用域,计数器就减 1。计数器归零的那一刻,值才真正被释放。

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("shared config"));
    println!("count = {}", Rc::strong_count(&a)); // 1

    let b = Rc::clone(&a); // 不是复制数据,只是计数 +1
    println!("count = {}", Rc::strong_count(&a)); // 2

    {
        let c = Rc::clone(&a);
        println!("count = {}", Rc::strong_count(&a)); // 3
    } // c 在这里离开作用域,计数 -1

    println!("count = {}", Rc::strong_count(&a)); // 2
}

按照惯例,我们写 Rc::clone(&a) 而不是 a.clone(),为的是与深拷贝整个值的一般 clone 区分开来。这个调用不动值本身,只碰计数器,所以代价非常低。

有一点重要的限制:Rc 仅限单线程使用。因为它不是原子地更新计数器,如果多个线程同时操作计数,值就可能被破坏。所以 Rc 既不是 Send 也不是 Sync,一旦试图跨线程传递,编译器就会拦下来。如果需要跨线程共享,就要用下面的 Arc

Arc — 跨线程的共享所有权

Arc<T>Atomic Reference Counted 的缩写,其 API 与 Rc 几乎相同,只是用原子操作来更新计数器。因此可以在多个线程间安全共享。

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    let mut handles = vec![];

    for i in 0..3 {
        let data = Arc::clone(&data); // 分给每个线程一份所有权
        let handle = thread::spawn(move || {
            let sum: i32 = data.iter().sum();
            println!("thread {i}: sum = {sum}");
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

那为什么不干脆一直用 Arc 呢?原子操作比普通整数运算更昂贵。如果只在单线程内共享,就没有理由付出这笔成本,所以 Rc 更快。原则是只在需要时用 Arc,其余情况用 Rc。由于编译器会强制线程安全,在需要 Arc 的地方误用 Rc,代码根本编译不过,出错的空间也就很小。

另外,无论是 Rc 还是 Arc,共享的值默认都是不可变的。因为如果有多个所有者,其中一个又能修改值,就会破坏借用规则(存在共享引用期间不可修改)。可实际中,人们常常既想共享又想修改值。这正是内部可变性登场的地方。

RefCell 与内部可变性 — 把规则搬到运行期

Rust 的借用规则通常在编译期检查:“可以有多个不可变引用,或者只有一个可变引用,二者不能同时存在。”而 RefCell<T> 把这项检查推迟到运行期。这就是内部可变性(interior mutability)——一种即便外部只持有不可变引用,也能修改内部值的模式。

RefCell 通过 borrow() 借出不可变引用,通过 borrow_mut() 借出可变引用。规则的检查不在编译器手上,而是放到运行期;违反规则时不会是编译错误,而是panic

use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);

    {
        let mut m = cell.borrow_mut(); // 可变借用
        *m += 10;
    } // m 在这里被归还

    println!("{}", cell.borrow()); // 15

    // 违反规则的例子 —— 运行时会 panic
    let _a = cell.borrow_mut();
    let _b = cell.borrow_mut(); // panic: already borrowed
}

编译器本来就能抓住的问题,为什么非要推迟到运行期?编译期检查虽然安全,却偏保守,会拒绝一些实际上安全的代码。RefCell 是在“我知道自己遵守了规则,但说服不了编译器”这种处境下的一条出路。代价是,安全检查挪到了运行期,一旦违反规则,就会以 panic 的形式暴露出来。

RefCell 真正的价值,要和 Rc 组合起来才能体现。Rc<RefCell<T>> 是“多个所有者共享,同时还能修改值”这种常见模式。

use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

    let clone = Rc::clone(&shared);
    clone.borrow_mut().push(4); // 修改共享的值

    println!("{:?}", shared.borrow()); // [1, 2, 3, 4]
}

有一点要注意,RefCell 同样仅限单线程使用。如果需要跨线程的内部可变性,就要转向下面的 Mutex

Mutex — 线程安全的内部可变性

要修改多个线程共享的值,需要用 Mutex<T>(互斥锁,mutual exclusion)。Mutex(lock)保护值,确保同一时刻只有一个线程能访问它。调用 lock() 会一直等待,直到获得锁为止,拿到锁后会返回一个可以访问值的守卫(guard)。守卫离开作用域时,锁会自动释放。

Rc<RefCell<T>> 的线程安全版本就是 Arc<Mutex<T>>

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // 获取锁
            *num += 1;
        }); // 守卫消失,锁被释放
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("result = {}", *counter.lock().unwrap()); // 10
}

Arc 把所有权分给多个线程,用 Mutex 把值的修改序列化。这一组合是 Rust 中处理线程间共享可变状态的标准写法。

如果读远多于写,还有 RwLock<T> 可用——读锁可以被多个线程同时持有,只有写锁是排他的。最后,互斥锁还有一个陷阱叫死锁(deadlock)——如果两个线程各自等待对方持有的锁,就会永远卡住。保持锁获取顺序的一致性,是最基本的防御手段。

Deref 强制转换 — 把智能指针当值来用

前面的例子里,直接把 Box<i32> 传给 println!,它也像整数一样被打印出来。让这一切成立的,是 Deref trait 和 Deref 强制转换(deref coercion)。

实现了 Deref 的类型,可以用 * 解引用运算符访问内部的值。不仅如此,编译器还会在需要时自动插入这种转换。比如把 &Box<String> 传给一个接收 &str 的函数,编译器会按 Box<String>Stringstr 的顺序自动串联解引用。

fn greet(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let boxed = Box::new(String::from("Rust"));
    greet(&boxed); // &Box<String> 会自动转换为 &str
}

有了这种自动转换,智能指针几乎可以像内部值本身一样自然地使用。前面提到的 trait 对象 Box<dyn Trait>Vec<Box<dyn Trait>>,也因此不需要额外的语法负担——一次解引用,就能直接调用 trait 的方法。

Rc 循环引用与 Weak — 防止内存泄漏

Rust 以内存安全著称,但如果用错 Rc,同样可能造成内存泄漏。当两个 Rc 相互指向,形成循环引用(reference cycle)时,两个值的引用计数会因为彼此的存在而永远降不到零。于是两者都不会被释放,一直留在内存里。

想象一棵父子树。父节点用 Rc 拥有子节点,如果子节点也需要认得父节点,并用 Rc 指回父节点,就会形成循环。父→子、子→父两个方向都挂着强引用,计数永远不会归零。

解决办法是 Weak<T>弱引用。用 Rc::downgrade 创建出的 Weak,虽然指向该值,但不主张所有权。也就是说它不会增加强引用计数(strong_count),因此能够切断循环。原则是只在一个方向(父→子)挂强引用,反方向(子→父)挂弱引用。

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,      // 父节点弱引用(不拥有)
    children: RefCell<Vec<Rc<Node>>>, // 子节点强引用(拥有)
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    // 子节点弱引用父节点 —— 不会形成循环
    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    // 弱引用要用 upgrade 取出来使用(已消失则为 None)
    if let Some(parent) = leaf.parent.borrow().upgrade() {
        println!("leaf's parent value = {}", parent.value); // 5
    }
}

Weak 指向的值可能已经被释放,所以要访问它,得用 upgrade() 拿到 Option<Rc<T>> 来确认是否还存活。存活则是 Some,已经消失则是 None。这一层确认,正是在没有循环的前提下安全处理双向引用的关键。

该用什么、什么时候用 — 小结

把目前看到的智能指针的选择标准压缩一下,大致如下:

  • Box<T>:想让值以单一所有者的身份放在堆上时。递归类型、大值的移动、trait 对象。
  • Rc<T>:在单线程内让多个所有者共享一个值。只读共享。
  • Arc<T>:需要跨多个线程共享所有权时。Rc 的线程安全版本。
  • RefCell<T>:在单线程内想在不可变引用背后修改值(内部可变性)。常见形式是 Rc<RefCell<T>>
  • Mutex<T> / RwLock<T>:需要跨多个线程共享可变状态时。常见形式是 Arc<Mutex<T>>
  • Weak<T>:切断 Rc/Arc 的循环引用、防止泄漏时。不拥有所有权的反向引用。

核心感觉可以归结为三个维度:所有者是一个还是多个(Box 对比 Rc/Arc),单线程还是多线程(Rc/RefCell 对比 Arc/Mutex),以及是不可变还是需要内部可变性(普通 Rc 对比 Rc/RefCell)。回答清楚这三个问题,答案通常就会收窄到一个。

结语

智能指针不是绕开 Rust 所有权规则的后门,而是把这些规则扩展到更广泛情形的工具。Box 安全地解开了大小与位置的约束,RcArc 解开了单一所有权的约束,RefCellMutex 解开了不可变共享的约束。而为了这份安全,还配备了 Weak 这样的安全阀。

刚开始接触 Rc<RefCell<T>> 这类嵌套时会觉得陌生,但一旦弄清楚每一层各自解决什么问题,其中的意图反而会读得很清楚。只挑选真正需要的最少工具来用的这种克制,正是在 Rust 里写出既安全又灵活的数据结构的路径。

参考资料

현재 단락 (1/166)

Rust 的所有权系统很强大,但有时会让人觉得过于严格。一个值只能有一个所有者,借用要遵循规则,大小必须在编译期确定。仅凭这些规则,很难表达递归数据结构,或是在多处共享的状态。填补这一空白的,正是**...

작성 글자: 0원문 글자: 7,109작성 단락: 0/166