智能指针(二)Box 对象分配
Box<T>
是 Rust 中最常见的智能指针,功能是将一个值分配到堆上,然后在栈上保留一个智能指针指向堆上的数据。
要想用好 Box,需要深入了解计算机堆栈概念。
栈
栈内存从高位地址向下增长,且栈内存是连续分配的,一般来说操作系统对栈内存的大小都有限制,因此 C 语言中无法创建任意长度的数组(存储在栈)。
在 Rust 中,main 线程的栈大小是 8MB,普通线程是 2MB,在函数被调用时 Rust 会在线程内存中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的所有对象自动进入 Drop 流程,最后栈顶指针自动移动到上一个调用栈顶,无需程序员手动干预,因而栈内存申请和释放是非常高效的。
堆
与栈相反,堆上内存则是从低位地址向上增长,堆内存通常只受物理内存限制,而且通常是不连续的,因此从性能的角度看,栈往往比堆更高。
相比其它语言,Rust 堆上对象还有一个特殊之处,它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可)
1 | fn foo(x: &str) -> String { |
在 foo 函数中,s 是一个 String 类型,它是由存储在堆中的实际类型数据和存储在栈中的智能指针结构体(指向堆数据)共同组成的。
当 s 被从 foo 函数转移给 x 变量时,只需要将 s 栈上的智能指针复制一份赋予给 x,而底层数据不发生改变即可完成堆数据的所有权从 foo 函数内部到 x 的转移。
栈与堆的性能
很多人可能会觉得栈的性能肯定比堆高,其实未必,这里有一个大概:
- 小型数据,在栈上的分配性能和读取性能都要比堆上高
- 中型数据,栈上分配性能高,但是读取性能和堆上并无区别,因为无法利用寄存器或 CPU 高速缓存(空间非常小),最终还是要经过一次内存寻址
- 大型数据,只建议在堆上分配和使用
总结:栈的分配速度比堆快,但是读取速度往往取决于数据能不能放入寄存器或 CPU 高速缓存。因此不要因为堆的性能不如栈这个印象,就总是优先选择使用栈,导致代码更复杂的实现。
Box 的使用场景
由于 Box 是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗。而性能和功能往往是鱼和熊掌,因此 Box 相比其它智能指针,功能较为单一,可以在以下场景中使用它:
- 特意的将数据分配在堆上
- 数据较大时,又不想在转移所有权时进行数据拷贝
- 类型的大小在编译期无法确定,但是我们又需要固定大小的类型时(递归对象,切片等)
- 特征对象,用于说明对象实现了一个特征,而不是某个特定的类型
使用 Box<T>
将数据存储在堆上
如果一个变量拥有一个数值,即直接声明变量 let a = 3
,那变量 a 必然是存储在栈上的,如果想要 a 的值存储在堆上就需要使用 Box<T>
:
1 | let a = Box::new(2); |
这样就可以创建一个智能指针指向了存储在堆上的 3,并且 a 持有了该智能指针,而智能指针往往都实现了 Deref 和 Drop 特征,因此:
- println! 可以正常打印出 a 的值,是因为它隐式地调用了 Deref 对智能指针 a 进行了解引用
*a
,即println!("{}", *a);
- 最后一行代码
let b = a + 1
报错,是因为在表达式中不能自动地执行隐式 Deref 解引用操作,需要手动使用*
操作符来显式的进行解引用let b = *a + 1
- a 持有的智能指针将在作用域结束(main 函数结束)时,被释放掉,这是因为
Box<T>
实现了 Drop 特征
Rust 会在方法调用和字段访问时自动应用解引用强制多态(deref coercions),这意味着如果类型实现了 Deref trait,Rust 会自动将引用类型转换为目标类型。
在一些其他情况下,如在标准比较操作或赋值中,Rust 不会自动应用解引用:在表达式中不能自动地执行隐式 Deref 解引用操作,需要手动使用*
操作符解引用。
避免栈上数据的拷贝
当栈上数据转移所有权时,实际上是把底层数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权未转移。
而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权转移:
1 | let arr = [0;1000]; // 在栈上创建一个长度为1000的数组 |
将动态大小类型变为 Sized 固定大小类型
Rust 需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST。
在闭包作为函数返回值(特征对象)和不定长类型(切片)章节中就曾使用 Box
将动态大小类型 DST 转化为定长类型(Sized)。
除了特征对象和切片外,这里还有一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值。
这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间,以函数式语言中常见的 Cons List 为例,它的每个节点包含一个 i32 值,还包含了一个新的 List,递归类型声明:
1 | enum List { |
但是上面这段代码声明是错误的,因为这种嵌套可以无限进行下去,Rust 认为该类型是一个 DST 类型:
1 | recursive type `List` has infinite size //递归类型 `List` 拥有无限长的大小 |
该数据类型可以无限拓展,因此要将 List 改成存储在堆上,可使用 Box
, Rc
, &
阻断该数据类型在栈上的无限拓展的可能,即变为在栈上存储指针(固定大小),堆存储实际数据:
1 | enum List { |
特征对象
特征是一种动态尺寸类型(Dynamically Sized Types,DST),即特征本身不具有固定的大小,因此不能直接实例化为对象。
在 Rust 中,特征通常通过指针(如 Box<T>、&T
)来使用,这些指针指向实现了该特征的具体类型的实例。
这些对动态尺寸类型的一种封装,使其可以通过具体的、已知大小的指针类型(如 Box<dyn Trait>
或 &dyn Trait
)来使用,这种封装类型就是一个特征对象。因此特征对象可以被视为具体的、已知大小的类型。
在这里需要更新前几章的描述:特征对象是动态尺寸类型,这是有误的。正确的认识是:特征是动态尺寸类型,而特征对象是对特征的一种封装,使特征可以通过具体的,已知大小的指针类型来描述,因此特征对象是一个定长类型(Sized)。
Box 内存布局
前面提到过:
不能简单的将变量与类型视为只是一块栈内存或一块堆内存数据,比如 Vec 类型,rust 将其分成两部分数据:存储在堆中的实际类型数据与存储在栈上的管理信息数据。
其中存储在栈上的管理信息数据是引用类型,包含实际类型数据的地址、元素的数量,分配的空间等信息,rust 通过栈上的管理信息数据掌控实际类型数据的信息。
因此来看一下几种常见的类型的内存模型,首先是 Vec<i32>
的内存布局:
1 | (stack) (heap) |
智能指针存储在栈中,然后指向堆上的数组数据,String 类型与 Vec 类型内存布局是类似的,栈上存储智能指针,堆上存储实际类型数据。
那如果数组中每个元素都是一个 Box 对象呢?来看看 Vec<Box<i32>>
的内存布局:
1 | (heap) |
看出智能指针 vec2 依然是存储在栈上,然后指针指向一个存储在堆上的数组,该数组中每个元素都是一个 Box 智能指针,Box 智能指针又指向了存储在堆上的实际值。
因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box,需要对该智能指针进行解引用,才能取出最终的值,以 B1 为例:B1 代表被 Box 分配到堆上的值 1。
Rust 会在方法调用和字段访问时自动应用解引用强制多态(deref coercions),在一些其他情况下,如在标准比较操作或赋值中,Rust 不会自动应用解引用:在表达式中不能自动地执行隐式 Deref 解引用操作。
println! 实际上调用的就是 Display 特征的方法,所以 println 时存在自动解引用
1 | let arr = vec![Box::new(1), Box::new(2)]; |
以上代码有几个值得注意的点:
- 使用 & 借用数组中的元素,否则会报所有权错误
- 表达式不能隐式的解引用,因此必须使用 ** 做两次解引用,第一次将
&Box<i32>
类型转成Box<i32>
,第二次将Box<i32>
转成 i32
Box::leak
需要一个在运行期初始化的值,变成可以全局有效(即和整个程序活得一样久),那么就可以使用 Box::leak
,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc
也可以实现此功能,但是 Box::leak
是性能最高的。
总结
Box 背后是调用 jemalloc 来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC 的语言中的对象也是借助于 Box 概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box 罢了。
Code
1 | fn main() { |