暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

写给 Web 开发者的 Rust 语言入门教程(10.内存布局)

最后的叶子的奇妙小屋 2021-07-27
811

之前的文章:

0.引言

1.模块基础

2.变量

3.字符串

4.分支循环

5.数据表示

6.模块化

7.接口抽象

8.泛型

9.错误处理

本章主要介绍 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 let
      if 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> 类型的
            // 使用解引用运算符 * 可以把它变成 BinaryTreeNode
            let 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()); // 输出 0
              fruits.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 位系统中输出 8
                println!("{}", 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 还有一些其他使用场景,在之后的文章中将会介绍。

                          文章转载自最后的叶子的奇妙小屋,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论