之前的文章:
本章主要介绍 rust 的空值处理、堆内存分配,以及与内存控制有关的复杂语言特性。
空值
rust 没有真正意义上的空值。所有变量都必须被赋予初始值之后才能使用,所以使用 let 来定义变量的时候通常就给它赋予初始值。
但有时确实需要使用空值来表达一个量“不存在”的情况,此时可以使用标准库中的 Option 。它实际上是一个 enum :
enum Option<T> {Some(T),None,}
其含义是,这个量可能是个 T 类型的值,也可能为空。实际使用时,必须用 if let 或者 match 来判断值是否为空,才能用其中的 T 值,例如:
fn main() {let my_option_value = Some(1.23);match my_option_value {Some(x) => {println!("{}", x); // 输出 1.23}None => {}}// 也可以使用 if letif let Some(x) = my_option_value {println!("{}", x); // 输出 1.23}}
Option 还提供了很多额外的方法来方便使用。其中包括类似于 Result 的 unwrap 和 expect 方法,在 None 分支上调用它们会使得程序中断退出,需要谨慎使用。
栈和堆
作为没有垃圾回收器的编程语言,编写 rust 代码时可以精细控制内存使用方式。其中,变量值位于的内存区域可以分为两类:栈内存和堆内存。
栈内存是一块固定大小的内存区域,只能存放尺寸固定的数据类型,总量也不能太大,但优势是访问时没有额外开销。因而,常见数据类型大多可以使用栈内存;例外是 Vec 和 String 中的内容不能位于栈内存,因为它们的长度不固定。
堆内存没有栈内存的那些限制,但是使用时有额外开销,例如,创建变量值时需要额外调用内存分配算法 dlmalloc 来在堆内存中分配一个片段存放这个值。
注意,虽然 rust 栈和堆的概念与 C 、 C++ 、汇编语言中的相应概念一致,但 rust 具体内存管理方式是不同的,例如,不能随意分配( malloc )和释放( free )堆内存片段,而是由编译器统一处理。
Box
对于 rust 代码而言,使用栈内存和堆内存并没有明显差异。
在代码中可以选择将函数的部分变量或 struct 中的部分字段强制放到堆内存中。比较常见的情形是在 struct 中,把一部分字段强制指定到堆内存中。例如,要实现一个二叉树,二叉树中的每个节点需要有 left (左子树)和 right (右子树)两个字段:
struct BinaryTreeNode {left: Option<BinaryTreeNode>,right: Option<BinaryTreeNode>,}// 这样写是无法编译通过的
但是这样写是无法编译通过的。因为二叉树没有固定的尺寸,它占用的内存总量随节点增多而变大;实际表现在代码中,就是这里有递归的数据类型。
这里的解决方法是使用 Box 将 left 和 right 两个字段包裹起来:
struct BinaryTreeNode {left: Option<Box<BinaryTreeNode>>,right: Option<Box<BinaryTreeNode>>,}
Box 的含义是在堆内存中额外分配一个内存片段来存放 Box 中的数据类型。具体对这段代码而言, left 或 right 两个字段可能是 None 表示没有左子树或右子树,此时不会占用额外的内存空间;也有可能是 Some ,此时需要分配一个额外的堆内存片段来存放对应的子树。
Box 具有 Box::new 方法,调用这个方法时,会在堆内存中分配内存片段;当 Box 被丢弃(不再被直接或间接存在任何变量中)时,分配给它的内存片段就会被释放。完整的例子:
struct BinaryTreeNode {left: Option<Box<BinaryTreeNode>>,right: Option<Box<BinaryTreeNode>>,}impl BinaryTreeNode {fn count_nodes(&self) -> usize {let mut child_count = 0;if let Some(left) = &self.left {// left 是 &Box<BinaryTreeNode> 类型的// 使用解引用运算符 * 可以把它变成 BinaryTreeNodelet left_node: &BinaryTreeNode = &**left;let left_child_count = left_node.count_nodes();// 实际上,大多数时候可以省略解引用运算符,即let left_child_count = left.count_nodes();child_count += left_child_count;}if let Some(right) = &self.right {child_count += right.count_nodes();}child_count + 1}}fn main() {{let root = BinaryTreeNode {left: Some(Box::new(BinaryTreeNode {left: None,right: Some(Box::new(BinaryTreeNode {left: None,right: None,}))})),right: None,};println!("{}", root.count_nodes()); // 输出 3}// 此时 root 下所有的 Box 被丢弃,对应的堆内存被释放}
需要说明的是,使用 Box 可以将其中的内容放入一个额外的堆内存片段中;但反过来,不使用 Box 时数据存放于不一定在栈内存中,例如, Vec 和 String 中的内容必然是存放于堆内存中的。在实践中,通常并不需要关心一个量是位于栈内存中还是堆内存中;要注意的是 Box 应只在必要的时候使用,因为堆内存分配有一点点额外开销。
Vec 和 String 的 Capacity
从原理上说, Vec 和 String 内部拥有一个额外的堆内存片段,数据内容、字符串内容会被放在这个堆内存片段中,这样可以避开栈内存的“固定大小”限制。
在创建 Vec 和 String 时,这个堆内存片段会随之创建。如果 Vec 和 String 内容变得很大,超过这个堆内存片段的大小,此时就需要重新分配一个更大的堆内存片段。这个重新分配的过程可能有比较大的开销。如果知道 Vec 和 String 大概率会用到的最大大小,可以考虑在创建的时候用 with_capacity 方法指定出来:
fn main() {// 初始 Vec 可以存放 4 项let mut fruits = Vec::with_capacity(4);// Capacity 并不是 Vec 内容长度println!("{}", fruits.len()); // 输出 0fruits.push("Apple");fruits.push("Banana");fruits.push("Orange");fruits.push("Watermalon");// 超过 4 项之后,仍然可以继续添加项目,只是可能引起堆内存重新分配fruits.push("Peach");// 获取新的 Capacity 大小println!("{}", fruits.capacity());}
size_of
使用 std::mem::size_of 方法可以获得数据类型占用的内存大小,但 Box 的堆内存片段大小并不包括在内(类似地, Vec 和 String 所具有的堆内存大小也不计算在内)。Box 除了堆内存片段之外,还需要几个字节来记录堆内存片段的地址、以便找到它对应的堆内存片段,这几个字节会作为 std::mem::size_of 的返回值。
// u32 占用 4 字节println!("{}", std::mem::size_of::<u32>());// 16 个 u32 占用 64 字节println!("{}", std::mem::size_of::<[u32; 16]>());// 通常, 在 32 位系统中输出 4 ,在 64 位系统中输出 8println!("{}", std::mem::size_of::<Box<[u32; 16]>>());
换而言之,对于 std::mem::size_of 通常只对 u32 、 f32 这样的简单数值类型才有意义。
struct 的内存布局
struct 占用的内存大体上是它包含的所有字段的总和。
struct Point {x: u32, // 4 字节y: u32, // 4 字节}// 总共 8 字节println!("{}", std::mem::size_of::<Point>());
但由于 CPU 的一些特殊要求,有时需要在字段之间插入几个字节的“空隙”,这样 CPU 运算更快,虽然会浪费几个字节的内存。
struct Point {x: u8, // 1 字节y: u32, // 4 字节}// 整个数据类型依然是 8 字节println!("{}", std::mem::size_of::<Point>());
上面这个例子中,虽然 x 是 u8 ,但 rust 编译器会将 x 占用的内存大小变为和 y 一样,相当于在 x 和 y 之间插入了 3 字节的空隙。这样 CPU 运算会更快。
不过也不需要太担心。编译器会在不影响 CPU 运算速度的前提下尽量减少内存占用。例如:
struct Point {x: u8, // 1 字节y: u32, // 4 字节z: u8, // 1 字节}// 整个数据类型依然是 8 字节println!("{}", std::mem::size_of::<Point>());
上面这个例子中多了一个 z ,这个字段会被放在原本 x 和 y 之间那 3 字节的空隙里,所以整个 struct 的内存大小并没有变化。
注意,这个表现和常见的 C 语言 struct 是不一样的!C 语言的 struct 中各个字段在内存中是按顺序排列的,而 rust 的是“不可预期的”。rust 甚至可能将 struct 之外的变量值存放在 struct 的各个字段之间!所以不要假想将 C 语言的内存布局方式套入 rust 中。
enum 的内存布局
对于 enum 而言,因为任意时刻只有一个分支是生效的,所以它的内存占用主要就是最大的那个分支的内存占用,外加几个字节用来存放当前是第几个分支生效。
enum MyEnum {BranchA(u32), // 4 字节BranchB([u32; 4]), // 16 字节BranchC, // 0 字节}// 分支最大是 16 字节,加上 4 字节用于表示是哪个分支生效println!("{}", std::mem::size_of::<MyEnum>()); // 输出 20
有时,表示第几个分支生效时并不需要额外的字节,例如:
enum MyEnum {BranchA(bool), // 1 字节BranchB, // 0 字节}// 分支最大是 1 字节,表示是哪个分支生效时不占额外的字节println!("{}", std::mem::size_of::<MyEnum>()); // 输出 1
上例中,因为 bool 只可能有 0 和 1 两种取值,却占用了 1 字节;所以表示第几个分支生效的数值也可以放在这同一个字节里面,不需要额外占用内存。
总体而言,内存管理和内存布局是比较复杂的内容。不过在实际编写代码的时候往往并不需要关心具体细节,只需要知道“编译器会处理好这一切”就行了。需要代码控制的一般只有对 Box 的使用。
实际上, Box 还有一些其他使用场景,在之后的文章中将会介绍。




