数据结构:Vec<T>、&[T]、Box<[T]>,这些你都了解吗

楔子

任何一门语言,原生的数据类型就那么几个,剩下的都是容器,容器占据了数据结构的半壁江山。当然提到容器,你首先想到的就是数组、列表这些可以遍历的容器,但其实只要把某种特定的数据封装在某个数据结构中,这个数据结构就是一个容器。比如 Option,它是一个包裹了 T 的容器,而 Cow 是一个封装了内部数据 B 被借用或拥有所有权的容器。

那么本次就来聊一聊容器,而容器也可以大致分为两种:

  • 集合容器:把一系列拥有相同类型的数据放在一起,统一处理
  • 特定容器:为特定目的而产生的容器,比如 Box、Rc、Arc、RefCell 等等

我们今天要聊的就是集合容器,集合容器可以说非常多了:

  • 字符串 String、数组 [T; n]、列表 Vec<T> 和哈希表 HashMap<K, V> 等。
  • 还有一种最重要的结构:切片。

这些集合容器有很多共性,比如可以被遍历、可以进行 map-reduce 操作、可以从一种类型转换成另一种类型等等。今天我们选择一个最具代表性的容器,切片,看看它是怎么设计的。因为像数组、列表、哈希表这些具体的数据结构其实并不复杂,但切片由于涉及到一些概念,相对要更难理解一些。

切片究竟是什么

在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)。然后切片一般只出现在数据结构的定义中,不能直接访问,在使用中主要用以下形式:

  • &[T]:表示一个只读的切片引用
  • &mut [T]:表示一个可写的切片引用
  • Box<[T]>:一个在堆上分配的切片

切片之于具体的数据结构,就像数据库中的视图之于表。你可以把它看成一种工具,让我们可以统一访问行为相同、结构类似但有些许差异的类型。

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let vec = vec![1, 2, 3, 4, 5];
    let s1 = &arr[1..3];
    let s2 = &vec[1..3];
    println!("s1: {:?}, s2: {:?}", s1, s2);  // s1: [2, 3], s2: [2, 3]

    // &[T] 和 &[T] 是否相等取决于长度和内容是否相等
    assert_eq!(s1, s2);
    // &[T] 可以和 Vec<T>/[T;n] 比较,也会看长度和内容
    assert_eq!(&arr[..], vec);
    assert_eq!(&vec[..], arr);
}

对于 array 和 vector,虽然是不同的数据结构,一个放在栈上,一个放在堆上,但它们的切片是类似的;而且对于相同内容数据的相同切片,比如 &arr[1…3] 和 &vec[1…3],这两者是等价的。除此之外,切片和对应的数据结构也可以直接比较,这是因为它们之间实现了 PartialEq trait。

所以我们看到切片 &[T] 本质上也是一个分配在栈上的胖指针,并且从图中可以看出,不管是基于 array 还是基于 vector,它们创建出的切片是等价的。都是包含两个字段:指向具体数据的指针,以及长度。并且需要注意的是,我们使用切片一定是以引用的形式,也就是说我们不能这么做。

    let arr = [1, 2, 3, 4, 5];
    let vec = vec![1, 2, 3, 4, 5];
    let s1 = arr[1..3];
    let s2 = vec[1..3];

这么做是不允许的。然后再来看一下 &[T] 和 &Vec<T> 之间的关系,这也是容易混淆的点。

非常简单,对于任何一个支持转成切片的数据结构来说,都可以通过 &v[..] 生成切片。

fn main() {
    let vec = vec![1, 2, 3, 4, 5];
    let s1 = &vec[1..3];
    let s2 = &s1;
    let s3 = &s2;
    let s4: &&&&[i32] = &s3;
    // Rust 的切片很智能,不管嵌套了多少层引用,都会自动访问到具体的值
    println!("{} {}", s4[0], s4[1]);  // 2 3

    // 此时的 s4 的类型是 &&&&[i32],如果再基于 s4 获取切片的话,返回的仍是 &[i32]
    let s5: &[i32] = &s4[..];
    println!("{} {}", s5[0], s5[1]);  // 2 3
}

创建一个切片,其实就是创建一个结构体实例。所以 &s4[..] 会返回指向具体数据的引用和一个长度,因此返回的结果仍是 &[i32]。

切片和迭代器 Iterator

迭代器可以说是切片的孪生兄弟,切片是集合数据的视图,而迭代器定义了对集合数据的各种各样的访问操作。通过切片的 iter() 方法,我们可以生成一个迭代器(实现了 Iterator trait 的数据结构),对切片进行迭代。

Rust 的迭代器非常方便,里面定义了大量的方法,我们前面的文章已经详细介绍过了。下面看一个例子,对 Vec 使用 iter() 方法,并进行各种 map / filter / take 操作。在函数式编程语言中,这样的写法很常见,代码的可读性很强,Rust 也支持这种写法(代码):

fn main() {
    // 这里 Vec<T> 在调用 iter() 时被解引用成 &[T],所以可以访问 iter()
    let result = vec![1, 2, 3, 4]
        .iter()
        .map(|v| v * v)
        .filter(|v| *v < 16)
        .take(1)
        .collect::<Vec<_>>();

    println!("{:?}", result);
}

需要注意的是 Rust 下的迭代器是个懒接口(lazy interface),也就是说这段代码直到运行到 collect 时才真正开始执行,之前的部分不过是在不断地生成新的结构,来累积处理逻辑而已。你可能好奇,这是怎么做到的呢?

Iterator 大部分方法都返回一个实现了 Iterator 的数据结构,所以可以这样一路链式下去,在 Rust 标准库中,这些数据结构被称为 Iterator Adapter。比如上面的 map 方法,它返回 Map 结构,而 Map 结构实现了 Iterator。

所以迭代器是惰性的,只有在 collect() 时,才触发代码一层层调用下去,并且调用会根据需要随时结束。这段代码中我们使用了 take(1),整个调用链循环一次,就能满足 take(1) 以及所有中间过程的要求,所以它只会循环一次。

你可能会有疑惑:这种函数式编程的写法,代码是漂亮了,然而这么多无谓的函数调用,性能肯定很差吧?毕竟,函数式编程语言的一大恶名就是性能差。事实上你完全不用担心, Rust 大量使用了 inline 等优化技巧,这样非常清晰友好的表达方式,性能和 C 语言的 for 循环差别不大。

特殊的切片 &str

学完了普通的切片 &[T],我们来看一种特殊的切片:&str。在 Vec<T> 的基础上进行 &[..] 即可得到 &[T],在 String 的基础上进行 &[..] 即可得到 &str,因此 String 和 &str 的关系类似于 Vec<T> 和 &[T] 的关系。

fn main() {
    let name = "satori".to_string();
    println!("{:?}", &name[..]);  // "satori"
}

而 String 是在 Vec<u8> 的基础上包了一层,所以 &str 也可以看作是在 &[u8] 的基础上包了一层。因为 &str 实际上是对 UTF-8 编码字符串的引用,而 UTF-8 字符串可以被视为字节数组。但 &str 必须是有效的 UTF-8 编码,这意味着并非所有的 &[u8] 都可以安全地转换为 &str,因为字节序列可能不符合 UTF-8 编码规则。

fn main() {
    // String 就是在 Vec<u8> 的基础上包了一层,确保这些 u8 序列是合法的 UTF-8 编码
    // 所以 String 到 Vec 的转换总是有效的,但反过来则不一定
    let vec = Vec::from("satori".to_string());
    println!("{:?}", vec);  // [115, 97, 116, 111, 114, 105]
    let word = String::from_utf8(vec![97, 98, 99]);
    // 因为无法保证我们自己创建 Vec<u8> 一定是合法的 UTF-8 字符序列,所以转换时可能会失败
    println!("{:?}", word);  // Ok("abc")
    // 如果你能保证一定成功,那么可以调用 from_utf8_unchecked 方法,但需要放在 unsafe 里面
    unsafe {
        println!("{:?}", String::from_utf8_unchecked(vec![97, 98, 99]));  // "abc"
    }

    // &str 也是同理,通过 as_bytes 即可转成 &[u8] 切片
    let bytes = "hello".as_bytes();
    println!("{:?}", bytes);  // [104, 101, 108, 108, 111]
    // 如果是 &[..],那么得到的还是 &str
    let bytes = &"hello"[..];
}

&str 本身就是一种特殊的切片。

当然我们也可以创建可变引用:

fn main() {
    let mut vec = vec![1, 2, 3];
    // 创建可变引用
    let slice: &mut [u8] = &mut vec[..];
    slice[0] = 99;
    println!("{:?}", vec);  // [99, 2, 3]
}

需要注意是:可变引用指的是能否通过引用去修改原有的值,但保存可变引用的变量本身不需要是可变的。所以这里的 slice[0] = 99 没有任何问题,不要好奇为什么它没有声明为 mut,还能修改元素。而 slice 没有声明为 mut,只是说我们无法再让它保存别的变量的可变引用。

切片的引用和堆上的切片,它们是一回事么?

开头我们讲过,切片主要有三种使用方式:切片的只读引用 &[T]、切片的可变引用 &mut [T] 以及 Box<[T]>。刚才已经学习了只读切片 &[T],和可变切片 &mut [T],现在我们来看看 Box<[T]>。

Box<[T]> 和切片的引用 &[T] 也很类似:它们都是在栈上有一个包含长度的胖指针,指向存储数据的内存位置。区别是:Box<[T]> 只会指向堆,&[T] 指向的位置可以是栈也可以是堆;此外,Box<[T]> 对数据具有所有权,而 &[T] 只是一个借用。

本质上是说&[T] 这个是个抽象概念。 他是个切片,就是将管子里面的数据随便切一刀。 那么这个管子本身存放的数据可能是在堆上,也可能是在栈上。

那么如何产生 Box<[T]> 呢?目前可用的接口就只有一个:从已有的 Vec 中转换,我们看代码:

use std::ops::Deref;

fn main() {
    let mut v1 = vec![1, 2, 3, 4];
    v1.push(5);
    println!("cap should be 8: {}", v1.capacity());

    // 从 Vec<T> 转换成 Box<[T]>,此时会丢弃多余的 capacity
    let b1 = v1.into_boxed_slice();
    let mut b2 = b1.clone();

    let v2 = b1.into_vec();
    println!("cap should be exactly 5: {}", v2.capacity());

    assert!(b2.deref() == v2);

    // Box<[T]> 可以更改其内部数据,但无法 push
    b2[0] = 2;
    // b2.push(6);
    println!("b2: {:?}", b2);

    // 注意 Box<[T]> 和 Box<[T; n]> 并不相同
    let b3 = Box::new([2, 2, 3, 4, 5]);
    println!("b3: {:?}", b3);

    // b2 和 b3 相等,但 b3.deref() 和 v2 无法比较
    assert!(b2 == b3);
    // assert!(b3.deref() == v2);
}

运行代码可以看到,Vec 可以通过 into_boxed_slice() 转换成 Box<[T]>,Box<[T]> 也可以通过 into_vec() 转换回 Vec。

这两个转换都是很轻量的转换,只是变换一下结构,不涉及数据的拷贝。区别是,当 Vec 转换成 Box<[T]> 时,没有使用到的容量就会被丢弃,所以整体占用的内存可能会降低。而且 Box<[T]> 有一个很好的特性是,不像 Box<[T;n]> 那样在编译时就要确定大小,它可以在运行期生成,以后大小不会再改变。

所以,当我们需要在堆上创建固定大小的集合数据,且不希望自动增长,那么,可以先创建 Vec,再转换成 Box<[T]>。

小结

我们讨论了切片以及和切片相关的主要数据类型,切片是一个很重要的数据类型,你可以着重理解它存在的意义,以及使用方式。围绕着切片有很多数据结构,而切片将它们抽象成相同的访问方式,实现了在不同数据结构之上的同一抽象,这种方法很值得我们学习。此外,当我们构建自己的数据结构时,如果它内部也有连续排列的等长的数据结构,可以考虑使用切片。

本文来自于:极客时间《Rust 编程第一课》

posted @ 2023-11-15 13:02  古明地盆  阅读(214)  评论(0编辑  收藏  举报