第3章 | 基本数据类型 | 数组、向量和切片

2024-05-08 15:18:08 浏览数 (2)

3.6 数组、向量和切片

Rust 用 3 种类型来表示内存中的值序列。

  • 类型 [T; N] 表示 N 个值的数组,每个值的类型为 T。数组的大小是在编译期就已确定的常量,并且是类型的一部分,不能追加新元素或缩小数组。
  • 类型 Vec<T> 可称为 T 的向量,它是一个动态分配且可增长的 T 类型的值序列。向量的元素存在于堆中,因此可以随意调整向量的大小:压入新元素、追加其他向量、删除元素等。

笔记 类型 Vec<T> 类似于 JavaScript 中的数组 [],在日常开发中使用频率很高

  • 类型 &[T]&mut [T] 可称为 T 的共享切片T 的可变切片,它们是对一系列元素的引用,这些元素是某个其他值(比如数组或向量)的一部分。可以将切片视为指向其第一个元素的指针,以及从该点开始允许访问的元素数量的计数。可变切片 &mut [T] 允许读取元素和修改元素,但不能共享;共享切片 &[T] 允许在多个读取者之间共享访问权限,但不允许修改元素。

笔记 slice(切片) slice 是一个没有所有权的数据类型,其允许你引用集合中一段连续的元素序列,而不引用整个集合 slice 是一种动态类型DST(Dynamically Sized Types),无法直接使用 slice,都需要将其隐藏在指针后面使用

给定这 3 种类型中任意一种类型的值 v,表达式 v.len() 都会给出 v 中的元素数,而 v[i] 引用的是 v 的第 i 个元素。v 的第一个元素是 v[0],最后一个元素是 v[v.len() - 1]。Rust 总是会检查 i 是否在这个范围内,如果没在,则此表达式会出现 panic。v 的长度可能为 0,在这种情况下,任何对其进行索引的尝试都会出现 panic。i 的类型必须是 usize,不能使用任何其他整型作为索引。

3.6.1 数组

编写数组值的方法有好几种,其中最简单的方法是在方括号内写入一系列值:

代码语言:javascript复制
let lazy_caterer: [u32; 6] = [1, 2, 4, 7, 11, 16];
let taxonomy = ["Animalia", "Arthropoda", "Insecta"];

assert_eq!(lazy_caterer[3], 7);
assert_eq!(taxonomy.len(), 3);

对于要填充一些值的长数组的常见情况,可以写成 [V; N],其中 V 是每个元素的值,N 是长度。例如,[true; 10000] 是一个包含 10 000 个 bool 元素的数组,其内容全为 true

代码语言:javascript复制
let mut sieve = [true; 10000];
for i in 2..100 {
    if sieve[i] {
        let mut j = i * i;
        while j < 10000 {
            sieve[j] = false;
            j  = i;
        }
    }
}

assert!(sieve[211]);
assert!(!sieve[9876]);

你会看到用来声明固定大小缓冲区的语法:[0u8; 1024],它是一个 1 KB 的缓冲区,用 0 填充。Rust 没有任何能定义未初始化数组的写法。(一般来说,Rust 会确保代码永远无法访问任何种类的未初始化值。)

笔记 确保代码永远无法访问任何种类的未初始化的值,这个特性做法看起来不灵活,实际工程化中这样的设计反而更使程序更安全性,并且能够更早发现问题。JavaScript 的程序中经常出现某个值被清空或改动而重新访问导致程序异常或者渲染异常的问题

数组的长度是其类型的一部分,并会在编译期固定下来。如果 n 是变量,则不能写成 [true; n] 以期得到一个包含 n 个元素的数组。当你需要一个长度在运行期可变的数组时(通常都是这样),请改用向量。

你在数组上看到的那些实用方法(遍历元素、搜索、排序、填充、过滤等)都是作为切片而非数组的方法提供的。但是 Rust 在搜索各种方法时会隐式地将对数组的引用转换为切片,因此可以直接在数组上调用任何切片方法:

代码语言:javascript复制
let mut chaos = [3, 5, 4, 1, 2];
chaos.sort();
assert_eq!(chaos, [1, 2, 3, 4, 5]);

在这里,sort 方法实际上是在切片上定义的,但由于它是通过引用获取的操作目标,因此 Rust 会隐式地生成一个引用整个数组的 &mut [i32] 切片,并将其传给 sort 来进行操作。其实前面提到过的 len 方法也是切片的方法之一。3.6.3 节会更详细地介绍切片。

3.6.2 向量

向量 Vec<T> 是一个可调整大小的 T 类型元素的数组,它是在堆上分配的。

创建向量的方法有好几种,其中最简单的方法是使用 vec! 宏,它为我们提供了一个看起来非常像数组字面量的向量语法:

代码语言:javascript复制
let mut primes = vec![2, 3, 5, 7];
assert_eq!(primes.iter().product::<i32>(), 210);

当然,这仍然是一个向量,而不是数组,所以可以动态地向它添加元素:

代码语言:javascript复制
primes.push(11);
primes.push(13);
assert_eq!(primes.iter().product::<i32>(), 30030);

还可以通过将给定值重复一定次数来构建向量,可以再次使用模仿数组字面量的语法:

代码语言:javascript复制
fn new_pixel_buffer(rows: usize, cols: usize) -> Vec<u8> {
    vec![0; rows * cols]
}

vec! 宏相当于调用 Vec::new 来创建一个新的空向量,然后将元素压入其中,这是另一种惯用法:

代码语言:javascript复制
let mut pal = Vec::new();
pal.push("step");
pal.push("on");
pal.push("no");
pal.push("pets");
assert_eq!(pal, vec!["step", "on", "no", "pets"]);

还有一种可能性是从迭代器生成的值构建一个向量:

代码语言:javascript复制
let v: Vec<i32> = (0..5).collect();
assert_eq!(v, [0, 1, 2, 3, 4]);

使用 collect 时,通常要指定类型(正如此处给 v 声明了类型),因为它可以构建出不同种类的集合,而不仅仅是向量。通过指定 v 的类型,我们明确表达了自己想要哪种集合。

与数组一样,可以对向量使用切片的方法:

代码语言:javascript复制
// 回文!
let mut palindrome = vec!["a man", "a plan", "a canal", "panama"];
palindrome.reverse();
// 固然合理,但不符合预期:
assert_eq!(palindrome, vec!["panama", "a canal", "a plan", "a man"]);

在这里,reverse 方法实际上是在切片上定义的,但是此调用会隐式地从此向量中借用一个 &mut [&str] 切片并在其上调用 reverse

Vec 是 Rust 的基本数据类型,它几乎可以用在任何需要动态大小的列表的地方,所以还有许多其他方法可以构建新向量或扩展现有向量。第 16 章会介绍这些方法。

Vec<T> 由 3 个值组成:指向元素在堆中分配的缓冲区(该缓冲区由 Vec<T> 创建并拥有)的指针、缓冲区能够存储的元素数量,以及它现在实际包含的数量(也就是它的长度)。当缓冲区达到其最大容量时,往向量中添加另一个元素需要分配一个更大的缓冲区,将当前内容复制到其中,更新向量的指针和容量以指向新缓冲区,最后释放旧缓冲区。

如果事先知道向量所需的元素数量,就可以调用 Vec::with_capacity 而不是 Vec::new 来创建一个向量,它的缓冲区足够大,可以从一开始就容纳所有元素。然后,可以逐个将元素添加到此向量中,而不会导致任何重新分配。vec! 宏就使用了这样的技巧,因为它知道最终向量将包含多少个元素。请注意,这只会确定向量的初始大小,如果大小超出了你的预估,则向量仍然会正常扩大其存储空间。

许多库函数会寻求使用 Vec::with_capacity 而非 Vec::new 的机会。例如,在 collect 示例中,迭代器 0..5 预先知道它将产生 5 个值,并且 collect 函数会利用这一点以正确的容量来预分配它返回的向量。第 15 章会介绍其工作原理。

向量的 len 方法会返回它现在包含的元素数,而 capacity 方法则会返回在不重新分配的情况下可以容纳的元素数:

代码语言:javascript复制
let mut v = Vec::with_capacity(2);
assert_eq!(v.len(), 0);
assert_eq!(v.capacity(), 2);

v.push(1);
v.push(2);
assert_eq!(v.len(), 2);
assert_eq!(v.capacity(), 2);

v.push(3);
assert_eq!(v.len(), 3);
// 通常会打印出"capacity is now 4":
println!("capacity is now {}", v.capacity());

最后打印出的容量不能保证恰好是 4,但至少大于等于 3,因为此向量包含 3 个值。

可以在向量中任意位置插入元素和移除元素,不过这些操作会将受影响位置之后的所有元素向前或向后移动,因此如果向量很长就可能很慢:

代码语言:javascript复制
let mut v = vec![10, 20, 30, 40, 50];

// 在索引为3的元素处插入35
v.insert(3, 35);
assert_eq!(v, [10, 20, 30, 35, 40, 50]);

// 移除索引为1的元素
v.remove(1);
assert_eq!(v, [10, 30, 35, 40, 50]);

可以使用 pop 方法移除最后一个元素并将其返回。更准确地说,从 Vec<T> 中弹出一个值会返回 Option<T>:如果向量已经为空则为 None,如果其最后一个元素为 v 则为 Some(v)

代码语言:javascript复制
let mut v = vec!["Snow Puff", "Glass Gem"];
assert_eq!(v.pop(), Some("Glass Gem"));
assert_eq!(v.pop(), Some("Snow Puff"));
assert_eq!(v.pop(), None);

笔记 注意!这里对比时使用了 Some(),而不像 JavaScript 中的直接比较字符串 这个设计就是为了避免其它语言经常出现的忘记检查null/none 的错误 根据Rust本身的设计哲学, 建议在设计某个变量时, 如果预计该变量某时刻可能会是空值(null/None)的话, 那么尽量用Option/Result来包裹它, 反过来说, 只有你可以肯定该变量不可能为空值时, 才无须这么搞

可以使用 for 循环遍历向量:

代码语言:javascript复制
// 将命令行参数作为字符串的向量
let languages: Vec<String> = std::env::args().skip(1).collect();
for l in languages {
    println!("{}: {}", l,
             if l.len() % 2 == 0 {
                 "functional"
             } else {
                 "imperative"
             });
}

以编程语言列表为参数运行本程序就可以说明这个问题:

代码语言:javascript复制
$ cargo run Lisp Scheme C C   Fortran
   Compiling proglangs v0.1.0 (/home/jimb/rust/proglangs)
    Finished dev [unoptimized   debuginfo] target(s) in 0.36s
     Running `target/debug/proglangs Lisp Scheme C C   Fortran`
Lisp: functional
Scheme: functional
C: imperative
C  : imperative
Fortran: imperative
$

终于可以对术语函数式语言做一个令人满意的定义了。

虽然扮演着基础角色,但 Vec 仍然是 Rust 中定义的普通类型,而没有内置在语言中。第 22 章会介绍实现这些类型所需的技术。

笔记 这里的向量操作和 JavaScript 中的数组类似

3.6.3 切片

切片(写作不指定长度的 [T])是数组或向量中的一个区域。由于切片可以是任意长度,因此它不能直接存储在变量中或作为函数参数进行传递。切片总是通过引用传递。

对切片的引用是一个胖指针:一个双字值,包括指向切片第一个元素的指针和切片中元素的数量。

假设你运行以下代码:

代码语言:javascript复制
let v: Vec<f64> = vec![0.0,  0.707,  1.0,  0.707];
let a: [f64; 4] =     [0.0, -0.707, -1.0, -0.707];

let sv: &[f64] = &v;
let sa: &[f64] = &a;

在最后两行中,Rust 自动把 &Vec<f64> 的引用和 &[f64; 4] 的引用转换成了直接指向数据的切片引用。

最后,内存布局如图 3-2 所示。

图 3-2:内存中的向量 v 和数组 a 分别被切片 sasv 引用

普通引用是指向单个值的非拥有型指针,而对切片的引用是指向内存中一系列连续值的非拥有型指针。如果要写一个对数组或向量进行操作的函数,那么切片引用就是不错的选择。例如,下面是打印一段数值的函数,每行一个:

代码语言:javascript复制
fn print(n: &[f64]) {
    for elt in n {
        println!("{}", elt);
    }
}

print(&a);  // 打印数组
print(&v);  // 打印向量

因为此函数以切片引用为参数,所以也可以给它传入向量或数组。事实上,你以为属于向量或数组的许多方法其实是在切片上定义的,比如会对元素序列进行排序或反转的 sort 方法和 reverse 方法实际上是切片类型 [T] 上的方法。

你可以使用范围值对数组或向量进行索引,以获取一个切片的引用,该引用既可以指向数组或向量,也可以指向一个既有切片:

代码语言:javascript复制
print(&v[0..2]);    // 打印v的前两个元素
print(&a[2..]);     // 打印从a[2]开始的元素
print(&sv[1..3]);   // 打印v[1]和v[2]

与普通数组访问一样,Rust 会检查索引是否有效。尝试借用超出数据末尾的切片会导致 panic。

由于切片几乎总是出现在引用符号之后,因此通常只将 &[T]&str 之类的类型称为“切片”,使用较短的名称来表示更常见的概念。

笔记 Rust中对于数组的一些操作,使用切片的形式,这点和 JavaScript 语法有所不同

0 人点赞