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
的作用:
|
|
更多关于 Send
的解释,可以参考 The Rustonomicon.
Copy
与 Clone
对于 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
对两种类型都有用。关于 Copy
和 Clone
的更多相关内容,可以参考标准库文档。
从 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>
。
一个简单的 🌰:
|
|
Rc
与 Arc
Rc<T>
的全名是 Reference Counting,从名字上就能看出来,这是一个带有引用计数的包装类型。换句话说,它所维护的资源具有多所有权:允许多个对象/函数等持有同一个资源的所有权。为了保证这样的持有是安全的(即对每个持有者来说,所持有的资源不会发生预期之外的改变),Rc<T>
维护的对象是不可变的,这意味着我们没有任何办法拿到一个 &mut
来改变内部的值。
一个简单的 🌰:
|
|
Rc<T>
的引用计数并不是原子的,这导致 Rc<T>
的跨线程访问是不安全的。为了解决这个问题,官方库提供了 Arc<T>
来维护跨线程的资源共享。A 在这里的意思就是 Atomically,原子化,即 Arc<T>
的引用计数增减操作是原子操作,保证了跨线程可见是安全的。而在其他地方,它和 Rc<T>
没什么不同。
另一个简单的 🌰:
|
|
Cell
与 RefCell
在聊这两个包装类型之前,我们先聊一聊什么叫“内部可变性”。
内部可变性,其实有 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。
一组混合的 🌰:
|
|
Cell<T>
和 RefCell<T>
都没有实现 Send
,所以他们也都是不能跨线程访问的。
Mutex
与 RwLock
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++ 中的语义不清晰甚至是歧义的问题,在默认移动语义的基础上实现了精细化的堆内存管理控制。不过,也有人批评这种标准实现太过繁琐,我们也许只需要一些约定然后提供简单的封装,复杂的需求交给各自或者第三方实现也许会更好。孰是孰非,还是交给语言的使用者决断吧。