Rust 数组、动态数组和切片(Array, Vector and slice)

Rust语言2020-10-25

原文链接:Arrays, vectors and slices in Rust

简介

本文主要讨论Rust中的数组(Array)、动态数组(Vector)和切片(Slice)。 C/C++程序员应该很熟悉数组和动态数组,由于 rust 注重安全性,和其它语言相比这些还是略有区别。 另外切片属于 rust 全新的非常重要的概念。

数组(Arrays)

数组是初级程序员就会学到的数据类型。一个数组是一组相同数据类型元素组成的集合,存储 在连续的内存块中。例如定义如下数组:

let array: [i32; 4] = [42, 10, 5, 2];

所有的i32整形数据都存储在彼此地址相邻的栈内存中:

             stack
            |-----|
            |  42 |
            |-----|
            |  10 |
            |-----|
            |  5  |
            |-----|
            |  2  |
            |-----|

在 Rust 中,数组大小是类型的一部分。如下代码不能通过编译:

// error: expected an array with a fixed size of 4 elements,
// found one with 3 elements
let array: [i32; 4] = [0, 1, 2];

Rust 的严谨性还可以防止 C/C++ 中的数组衰退为指针问题:

// c++ code
#include <iostream>
 
using namespace std;
 
// 别被外表欺骗:`arr` 不是指向5个整形元素的数组指针。
// 它已经衰退为指向整数的指针
void print_array_size(int (*arr)[5]) {
      // 输出8(指针的大小)
      cout << "在 print_array_size 函数中数组大小是: " << sizeof(arr) << endl;
}
 
int main()
{
      int arr[5] = {1, 2, 3, 4, 5};
      // 输出20(5个4字节的整数的大小)
      cout << "main函数中数组的大小:" << sizeof(arr) << endl;
      print_array_size(&arr);
      return 0;
}

print_array_size 函数输出 8 而不是期望的 20(5 个 4 字节整数),原因就是 arr 从 5 个整数的数组指针衰退成了整数的指针。同样的代码在 rust 中就没有问题:

use std::mem::size_of_val;
 
fn print_array_size(arr: [i32; 5]) {
      // 输出 20
      println!("print_array_size 函数中数组大小:{}", size_of_val(&arr));
}
 
fn main() {
      let arr: [i32; 5] = [1, 2, 3, 4, 5];
      // print 20
      println!("main函数中数组大小:{}", size_of_val(&arr));
      print_array_size(arr);
}

C/C++和 Rust 处理数组的另一个不同点是,Rust 访问元素会进行边界检查。如下 c++代码,我们想从长度为 3 的数组获取第 5 个元素。会产生未知行为:

#include <iostream>
 
using namespace std;
 
int main()
{
      int arr[3] = {1, 2, 3};
      const auto index = 5;
      // arr[index] 是未知的行为
      cout << "Integer at index" << index << ": " << arr[index] << endl;
      return 0
}

而同样的代码在 rust 中会报错:

fn main() {
      let arr: [i32; 3] = [1, 2, 3];
      let index = 5;
 
      // arr[index] 提示如下错误信息:
      // index out of bounds: the len is 3 but the index is 5
      println!("Integer at index {}: {}", index, arr[index]);
}

你或许会疑惑,rust 的处理方式怎么就比 C++的好了?好吧,由于 C++可以输出未知的行为,相当于给了编译器一张王牌通行证,可以以优化的名义做任何事。最严重的情况就是泄露信息给攻击者。

相反 Rust 总是会报错。并且,错误会直接终止程序运行,程序员也更方便发现并修复这些 BUG。而 C++无视这种 错误,如果不出错程序还继续使用错误数据正常执行。不管什么时候我都更顷向于选择 Rust 的报错而不是 C/C++的未定义行为。

动态数组(Vectors)

数组最大的限制是它们的大小是固定的,而动态数组可以在运行时增加:

fn main() {
      // 初始化有三个元素的动态数组
      let mut v: Vec<i32> = vec![1, 2, 3];
      // 输出3
      println!("v有 {} 个元素", v.len());
      // 在运行时可以增加更多
      v.push(4);
      v.push(5);
      // 输出5
      println!("v有 {} 个元素", v.len());
}

动态数组是如何实现动态增长的呢?本质上动态数组将数组的所有元素存储在堆内存中。当增加新元素时, 动态数组会检查数组是否还有剩余空间,如果没有,动态数组会重新生成一个更大的数组,复制所有的元素到新数组并释放之前的数组空间。如下代码演示:

fn main() {
      let mut v: Vec<i32> = vec![1, 2, 3, 4];
      // 输出4
      println!("v 的容量是 {}", v.capacity());
      println!("v 中第一个元素的地址:{:p}", &v[0]); // {:p} 打印内存地址
      v.push(5);
      // 输出 8
      println!("v 的容量是 {}", v.capacity());
      println!("v 中第一个元素的地址:{:p}", &v[0]);
}

初始化时 v 背后的数组大小是 4:

     |----------------|         |-------|
     | buffer pointer |-------->|   1   |
     |----------------|         |-------|
v -->|  capacity(4)   |         |   2   |
     |----------------|         |-------|
     |  length(4)     |         |   3   |
     |----------------|         |-------|
                                |   4   |
                                |-------|

随后插入一个新的元素,动态数组会复制所有元素到一个新的容量为 8 的数组中:

            栈内存                 堆内存              堆内存
     |----------------|         |-------|          |-------|
     | buffer pointer |--+      |   1   |      +-->|   1   |
     |----------------|  |      |-------|      |   |-------|
v -->|  capacity(4)   |  |      |   2   |      |   |   2   |
     |----------------|  |      |-------|      |   |-------|
     |  length(4)     |  |      |   3   |      |   |   3   |
     |----------------|  |      |-------|      |   |-------|
                         |      |   4   |      |   |   4   |
                         |      |-------|      |   |-------|
                         |        已回收        |   |   5   |
                         |                     |   |-------|
                         +---------------------+   |       |
                                                   |-------|
                                                   |       |
                                                   |-------|
                                                   |       |
                                                   |-------|
                                                     已分配

在给动态数组插入新元素的前后程序都会打印第一个元素的内存地址,两次输出的地址都是不同的。 第一个元素地址的变化很好的证明了会重新生成大小为 8 的数组。

注意: 如果内存地址没有变化,原因是数组的末尾有足够的空间保存新的数组内容。试着插入足够多的元素就会发现地址变化。可以查看 C 语言的函数 realloc 文档了解更多。

切片(Slices)

切片好比是数组和动态数组的临时视图。如下数组:

let arr: [i32; 4] = [10, 20, 30, 40];

可以创建一个包含第二个和第三个元素的切片如下:

let s = &arr[1..3];

[1..3]语句创建一个从下标 1(包含)到 3(不包含)的区间。如果省略区间的第一个数字([..3])它默认是零,如果省略后面那个数字默认是数组的长度。打印切片[1..3]的元素,就会输出 20 和 30:

// 输出 20
println!("切片的第一个元素:{:}", s[0]);
// 输出 30
println!("切片的第二个元素: {:}", s[1]);

如果获取超出区间范围的元素就会发生 panic 报错:

// panics: index out of bounds
println!("切片的第三个元素: {:}", s[2]);

切片是如何知道它只有两个元素呢?原因就在于切片不是简单的数组指针,它还有一个额外的长度字段保存当前切片的元素个数。

注意: 一个指针除了指向对象的地址外如果还有额外的数据就是胖指针(fat pointer)。切片并不是 rust 中唯一一个胖指针。例如还有特性对象(trait object),除了对象指针还有虚表指针(vtable pointer)。

如下创建一个动态数组的切片:

let v: Vec<i32> = vec![1, 2, 3, 4];
let s = &v[1..3];

除了指向数组v的第二个元素的指针,切片s还有一个 8 字节长的字段值为 2:

           栈                                 堆
      |--------------|                   |--------|
      |buffer pointer|------------------>|    1   |
      |--------------|                   |--------|
v --> | capacity(4)  |       +---------->|    2   |
      |--------------|       |           |--------|
      |  length(4)   |       |           |    3   |
      |--------------|       |           |--------|
                             |           |    4   |
      |--------------|       |           |--------|
      |buffer pointer|-------+
s --> |--------------|
      | length(2)    |
      |--------------|

下面的代码也可以证明长度字段的存在,切片(&[i32])长度是 16 字节(8 字节的缓冲区指针和 8 字节的长度字段):

use std::mem::size_of;
 
fn main() {
      // prints 8
      println!("i32整形引用的大小是: {:}", size_of::<&i32>());
      // prints 16
      println!("i32整形切片大小是: {:}", sizeof::<&[i32]>());
}

数组切片也是一样的,但缓冲区指针不再是指向堆内存而是指向数组的栈内存。

切片借用底层的数据结构,所有的正常借用规则也都适用。如下代码编译器不能编译能过:

fn main() {
      let mut v: Vec<i32> = vec![1, 2, 3, 4];
      let s = &v[..];
      v.push(5);
      println!("切片的第一个元素: {:}", s[0]);
}

为什么? 当切片创建时,它指向的是动态数组的第一个元素地址,而当插入一个新元素时,会重新分配新的内存,老的内存就被释放。这时就导致切片指向无效的内存地址,如果访问无效数据就造成未定义行为。Rust 又一次从灾难拯救了你。

注意:由于数组和动态数组都可以创建切片,它们是非常强大的抽象,因此对于函数参数,默认最好接收切片而不是数组或动态数组。事实上有很多函数如 len, is_empty等,都是处理切片而不是数组或动态数组。

结语

数组和动态数组是新手程序员首要都会学习的数据结构,Rust 原生支持它们也就不足为奇。如我们所见,Rust 的安全性防止我们滥用这些基础数据类型。切片是 rust 的新奇概念,是非常有用的抽象,在 rust 代码库中被广泛使用。