目录

Rust 的包装类型

对于一门对内存控制足够精细的语言来说,值类型与引用类型的区别是十分重要的:值类型通常意味着较低的拷贝成本,通常来说,这样的类型被分配在栈上(当然,对于 C/C++ 来说,我们可以在堆上直接分配一个值类型对象,如 int),而引用类型则通常分配在堆上,我们需要用一个包装过的对象去维护。

在 Rust 中,值类型和引用类型的界限在语言上提供了很明确的区分,而为了避免 C/C++ 中用户可以不受限制使用裸指针的情况,Rust 将很多裸指针操作都包在了 unsafe 块内,用户使用时必须对这种行为有足够的认知。当然,当用户需要使用指针,或者说引用类型的时候,Rust 也提供了 7 种包装类型来帮助用户更好的管理堆上的内存。

然而,Rust 官方教程和文档对这 7 种包装类型的介绍有很多容易混淆之处,同时网上的很多文章也已经完全脱离了最新版 Rust 的功能描述(如很多文章仍然描述 Cell 只接受实现了 Copy 的类型),导致很多初学者学习时容易产生迷惑和误用。这篇文章是我在复习 Rust 时重新学习包装类型相关时做出的笔记,希望能更好的帮助大家理解 Rust 的包装类型。

本文写作时的 Rust 版本为 Stable Channel 1.48.0。

三个重要的 Trait

Send

Send 是一个 Marker,用于标记一个类型可以在线程间安全的移动。对于绝大部分类型,编译器会自动实现 Send Trait,用户也可以手动实现。如果需要标记一个类型是不可以移动的,需要实现 !Send

下面的代码块应该能较好的解释 Send 的作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::vec::Vec;
use std::{thread, time};

struct Job {
    number: i32,
}

// 取消这行注释将告诉编译器这个类型跨线程移动是不安全的
// 不应该自动实现 `Send`,导致编译失败
// impl !Send for Job {}

impl Job {
    fn new(number: i32) -> Job {
        Job { number }
    }
}

fn main() {
    let mut v = Vec::with_capacity(5);
    for i in 0..5 {
        let job = Job::new(i);

        v.push(thread::spawn(move || {
            println!("Running job: {}", job.number);
            thread::sleep(time::Duration::from_secs(5 - job.number as u64));
            println!("Finish job: {}", job.number);
        }));
    }

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

更多关于 Send 的解释,可以参考 The Rustonomicon.

CopyClone

对于 Rust 来说,复制一个对象是一个显式行为(因为默认语义是移动语义),只有一个例外:Copy

Clone trait 的作用很简单,定义了一个对象的拷贝行为。我们可以用 #[derive(Clone)] 来自动实现 Clone,实际上就是对每一个对象调用了 .clone() 拷贝到新对象中,即执行了一次深拷贝。

Copy 则意味着,当我们使用 let x = y; 时,y 会被自动复制一份到 x,而不是移动到 x。默认的 Copy 实现是按位拷贝内存(即 memcpy),而如果我们想要自己实现,由于 Copy 是一个 Marker trait,意味着它本身没有任何可以实现的方法,所以如果你不使用 #[derive(Copy)] 来实现的话,你只能通过实现 Clone trait 来实现 Copy

你可以认为对于 Rust 来说,Copy 语义所描述的值都可以是值类型(Rust 可以做隐式拷贝),而 Clone 对两种类型都有用。关于 CopyClone 的更多相关内容,可以参考标准库文档

Box 开始

Box<T> 是在学习 Rust 中第一个接触到的包装类型,它的职责很简单:在堆上分配一个 T 类型的内存空间,并将其指针包装后返回给用户。它是一个非常简单的指针封装。

对于用户来说,Box<T> 并没有什么特殊的地方:它自身是一个值,有移动语义,当我们需要它维护的内部对象时,需要使用 .as_ref() 或者 .as_mut() 这样的 API 拿出来其中的值进行操作。当 Box<T> 走到作用域末尾时,里面的内部对象会被自动 drop 掉。

为了同 Rc<T> 以及 Arc<T> 区别,我们实际上描述 Box<T> 是一个唯一所有权指针:在任何时刻,这个指针只能被一个对象/函数等持有,而不能同时出现在多个地方。对内部真实值的访问会受到 Borrow Checker 的检查,保证引用是安全的。

要注意的是,Box<T> 并没有实现 Send trait,这意味着我们不能直接跨线程移动 Box<T>

一个简单的 🌰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn test(num: Box<i32>) {
    println!("Box is moved: {}", num);
}

fn main() {
    let mut b = Box::new(15);
    println!("{}", b);

    let mr = b.as_mut();
    *mr = 20;
    println!("{}", b);

    test(b);
    // 这会导致一个编译错误
    // let r = b.as_ref();
}

RcArc

Rc<T> 的全名是 Reference Counting,从名字上就能看出来,这是一个带有引用计数的包装类型。换句话说,它所维护的资源具有多所有权:允许多个对象/函数等持有同一个资源的所有权。为了保证这样的持有是安全的(即对每个持有者来说,所持有的资源不会发生预期之外的改变),Rc<T> 维护的对象是不可变的,这意味着我们没有任何办法拿到一个 &mut 来改变内部的值。

一个简单的 🌰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::rc::Rc;

fn test(num: Rc<i32>) {
    println!(
        "We got an rc: {} with count: {}",
        num,
        Rc::strong_count(&num)
    );
}

fn main() {
    let rc = Rc::new(10);
    println!("{}", rc);

    let r = rc.as_ref();
    println!("{}", *r);

    // 复制了指针,引用计数 +1
    test(rc.clone());
    // 此时借用没有问题
    let _r = rc.as_ref();
}

Rc<T> 的引用计数并不是原子的,这导致 Rc<T> 的跨线程访问是不安全的。为了解决这个问题,官方库提供了 Arc<T> 来维护跨线程的资源共享。A 在这里的意思就是 Atomically,原子化,即 Arc<T> 的引用计数增减操作是原子操作,保证了跨线程可见是安全的。而在其他地方,它和 Rc<T> 没什么不同。

另一个简单的 🌰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::rc::Rc;
use std::sync::Arc;
use std::thread;

fn test(num: Arc<i32>) {
    println!(
        "We got an arc: {} with count: {}",
        num.as_ref(),
        Arc::strong_count(&num)
    );
}

fn main() {
    let rc = Rc::new(10);
    let arc = Arc::new(1);
    let clone = arc.clone();
    let handle = thread::spawn(move || {
        test(clone);
        // 如果取消掉下面的注释会显示编译错误
        // rc.as_ref();
    });

    handle.join().unwrap();
}

CellRefCell

在聊这两个包装类型之前,我们先聊一聊什么叫“内部可变性”。

内部可变性,其实有 C++ 经验的同学应该很熟悉:const 指针。内部可变性指的是,我们所持有的代理对象是不可变的,这通常意味着我们不能指向一个新的代理对象,或者不能直接原地将这个代理对象给释放,而这个代理对象维护的真实数据,是可变的。

在很多场景中,我们确实需要使用一个不可变的对象,但是需要修改内部的值,这就是内部可变性的用途。一个典型例子是,我们将一个正则表达式编译成了一个内部的数据结构,这个数据结构通常会维护在字符串中的指针等信息,这些数据要求可变,然而,我们并不想让这种修改暴露到外界。这种情况下,我们使用不可修改的正则表达式暴露给用户,但是内部的数据则可以使用内部可变性来维护。

在 Rust 中,如果我们想修改一个值(比如通过 &mut 拿到可变引用并修改),我们通常也需要将原始值定义为 mut 的。为了提供内部可变性,Rust 提供了两种类型来满足这个需求,Cell<T>RefCell<T>

我们首先聊一下 Cell<T>。在很多古老的文章中,Cell<T> 都被描述为 T 必须实现了 Copy trait,然而这个限制在 Rust 1.17.0 之后便被移除了,T 的类型目前没有任何特殊的要求。

对于设置操作来说,我们可以使用 set 来设置一个值,使用 replace 对值进行原地替换,使用 into_inner 消费 Cell<T> 并获取内部的值。而对于取值操作,实现了 Default 的对象我们可以使用 take 将值移动出 Cell<T>,而实现了 Copy 的对象我们可以使用 get。如果要获取内部的可变引用,我们可以使用 get_mut 方法,此时的引用可以被编译器做静态分析。

然而,我们并不总是想消费 Cell<T> 拿到值,也许我们只需要一个引用;或者来说,我们需要在运行时执行一些借用操作(例如在多线程环境下),而这些操作不能被静态分析。这个时候,Rust 提供了 RefCell<T>RefCell<T> 并没有什么魔法,它只是使用了一组 Wrapper 对象(Ref/RefMut)来包装引用,同时实现了运行时的借用检查——也就是说,对 RefCell<T> 进行了非法的借用时,可能会导致运行时 panic。

一组混合的 🌰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use std::cell::{Cell, RefCell};
use std::mem::drop;

fn main() {
    let c = Cell::new(10);
    let rc = RefCell::new(15);

    println!("{:?}, {:?}", c, rc);

    // 如果想让下面这一行编译通过,需要对 c 添加 mut
    // let rc = c.get_mut();
    // *rc = 5;
    // 相反,我们可以用 replace
    c.replace(1);
    // 而对于 RefCell 来说,这样的引用是可以的
    let mut rc_mut = rc.borrow_mut();
    *rc_mut = 20;

    // 会显示值被 borrow
    println!("{:?}", rc);
    // 此时直接使用 rc.borrow() 会 panic
    // 更好的做法是使用 rc.try_borrow() 获取一个 Result<>
    // rc.borrow();
    if let Ok(_) = rc.try_borrow() {
        println!("We got an reference!");
    } else {
        println!("Somebody must has got a mutating reference!");
    }

    drop(rc_mut);
    // drop 掉 rc_mut 之后,值就可以被正常显示了
    println!("{:?}", rc);

    // 多个不可变的引用是可以的
    let b1 = rc.borrow();
    let b2 = rc.borrow();
    println!("{:?}, {:?}, {:?}", rc, b1, b2);

    // 它也会 panic,整体借用规则跟 Rust 静态分析的一致 ;)
    // let bm = rc.borrow_mut();

    let _val = c.into_inner();
    // into_inner 之后,c 就被消费了,下面的操作会导致编译错误
    // c.get();
}

Cell<T>RefCell<T> 都没有实现 Send,所以他们也都是不能跨线程访问的。

MutexRwLock

Mutex<T>RwLock<T> 不是锁吗?为什么会出现在包装类型的文章里?

对于 Rust 来说,Mutex<T>RwLock<T> 都与一个内部可变的资源强绑定,并且提供了运行时的锁竞争检查机制,实现了 Send。除此之外,这两类型的语义和约束与 Cell<T> 以及 RefCell<T> 差不了太多,就不再展开说了。

这次的 🌰 可以直接看 Mutex<T>官网样例RwLock<T>官网样例

区别及用途

我们首先可以看到下面这个表格:

类型所有权修改语义引用检查Send
Box<T>唯一可变引用修改编译期未实现
Rc<T>多重可变引用修改编译期未实现
Arc<T>多重可变引用修改编译期实现
Cell<T>唯一内部可变性编译期未实现
RefCell<T>唯一内部可变性运行时(引用计数)未实现
Mutex<T>唯一内部可变性运行时(锁竞争检查)实现
RwLock<T>唯一内部可变性运行时(锁竞争检查)实现

简单来说,你需要考虑下面的问题:

  • 考虑资源的所有权,只允许单一所有权还是多重所有权?
  • 对于可以被修改的资源,提供可变引用还是内部可变性?
  • 引用检查是静态的还是运行时的?
  • 是否需要跨线程?是否提供锁的机制?

搞明白了这些问题,该如何选择就一目了然了。同时,在标准库中,这些包装类型均推荐组合使用来实现更复杂的予以包装,例如多线程中传递 Mutex,我们应该使用 Arc<Mutex<T>>

Rust 的包装类型之旅到这里就结束了。可以看到,Rust 为了解决多种语义的覆盖问题,巧妙的设计了这几种模型并实现成为了标准库,给了用户充分的灵活性的同时避免了来自于 C/C++ 中的语义不清晰甚至是歧义的问题,在默认移动语义的基础上实现了精细化的堆内存管理控制。不过,也有人批评这种标准实现太过繁琐,我们也许只需要一些约定然后提供简单的封装,复杂的需求交给各自或者第三方实现也许会更好。孰是孰非,还是交给语言的使用者决断吧。