跳转至

0x08 Common Collections*

Rust 标准库中包含一系列被称为集合(collections)的非常有用的数据结构。大部分其他数据类型都代表一个特定的值,集合可以包含多个值。不同于内建的数组和元组类型,这些集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就已知,可以随着程序的运行增长或缩小。每个集合都有不同的功能和成本,可以根据使用情景选择合适的集合。本章将详细了解三个 Rust 中被广泛使用的集合:

  • vector 允许一个挨着一个地储存一系列数量可变的值。
  • 字符串(string)是字符的集合,之前使用过 String 类型,本章将深入了解。
  • hashmap 允许将值与一个特定的键(key)相关联,是 map 的特定实现。

对于标准库提供的其他类型的集合,可以查看文档 std::collections - Rust (rust-lang.org)

Vectors*

首先讲到的第一个类型是 Vec<T>,也称为 vector。vector 允许储存多个值,这些值在内存中彼此相邻地排列。vector 只能储存相同类型的值,在拥有一系列项的场景下非常实用。

新建 vector*

使用 Vec::new 创建一个空的 vector。

let v: Vec<i32> = Vec::new();

vector 使用泛型实现的,而这里没有像 vector 中插入任何值,所以需要有一个类型注解,否则 Rust 无法推断类型。(其实你只要在之后向 vector 增加一个元素(当然需要把它声明为可变) Rust 就可以作出类型推断,也就不需要必须加类型注解了,参见后面更新 vector 中举的例子。)

实际使用中,只要插入值 Rust 就可以推断类型,一般是用初始值来创建 vector,Rust 提供了 vec! 宏,可以根据提供的值创建一个新的 vector。

let v = vec![1, 2, 3];

更新 vector*

使用 push 方法向 vector 中增加元素。

let mut v = Vec::new();

v.push(5);

这里可以看到,如果把 vector 声明为可变,再在后面增加元素,Rust 就会作出类型推断,不需要注解。

丢弃 vector 时也会丢弃其所有元素*

类似结构体,在 vector 离开作用域时会被释放,当 vector 被丢弃时,所有内容也会被丢弃。这看起来比较直观,但一旦使用 vector 元素的引用,情况就变得复杂了。

获取 vector 元素*

可以使用索引方法或 get 方法来访问 vector 中的项。

let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
println!("The third element is {}", third);

match v.get(2) {
    Some(third) => println!("The third element is {}", third),
    None => println!("There is no third element."),
}

使用 &[] 返回一个引用,或者使用 get 方法以索引作用参数来返回一个 Option<&T>。(再次注意,对于 i32 这种栈上的(或者说有 Copy trait)元素,直接获取值,当然无所谓。但是回顾所有权那一章,元素是在堆上的类型,不使用引用所有权直接转移了,就会报错。)

Rust 提供两个方法来引用元素主要是可以选择如何处理索引值越界之类的问题。如果是 &[] 方法的索引值不存在,引用不存在的元素会直接造成 Rust panic。而 get 方法的索引值越界时,会返回 None,接下来的代码就要有处理 Some/None 的逻辑。

如果获取了 vector 元素的引用,回顾相同作用域中可变引用的冲突性。

let mut v = vec![1, 2, 3, 4, 5];

let first = &v[0];

v.push(6);

println!("The first element is: {}", first);

首先获取 vector 元素的引用,然后尝试修改 vector,就会报错。因为使用 push 在 vector 末尾增加新元素时,如果没有足够空间,可能会要求重新分配内存并将原来的元素复制到新的空间。如果出现这种情况,原有的元素引用就指向被释放的内存。借用规则会阻止这种情况发生。

关于 Vec<T> 的更多实现细节,可以看 Implementing Vec - The Rustonomicon (rust-lang.org)。这本书应该是讲得更深,类似 Rust 高级编程?

遍历 vector 中的元素*

使用 for 遍历 vector。

let v = vec![100, 32, 57];
for i in &v {
    println!("{}", i);
}

获取引用并输出值。对于可变 vector,还可以遍历修改值,相应 for 循环里也要改成可变引用,同时修改时需要解引用。

使用枚举来储存多种类型*

本章开始就提到 vector 只能储存相同类型的值,但是回顾之前学的,枚举成员被定义为相同的类型,而不同的成员可以附加不同类型的值!所以我们可以把值放进枚举成员,然后放在 vector 中。

enum SpreadsheetCell {
    Int(i32),
    Float(f64),
    Text(String),
}

let row = vec![
    SpreadsheetCell::Int(3),
    SpreadsheetCell::Text(String::from("blue")),
    SpreadsheetCell::Float(10.12),
];

Rust 要在编译时知道 vector 中元素类型的原因在于它需要知道储存每个元素到底需要多少内存。还有个好处就是准确地知道这个 vector 中允许什么类型。如果 Rust 允许 vector 存放任意类型,那么当对 vector 元素执行操作时一个或多个类型的值就有可能会造成错误。使用枚举外加 match 意味着 Rust 能在编译时保证总是会处理所有可能的情况。

如果枚举时不能确定运行时会储存进 vector 的所有类型,枚举就不太行。相反可以使用 trait 对象,第十七章会讲到它。

这里只介绍了使用 vector 最常见的方式,具体的还需要去看文档 std::vec::Vec - Rust (rust-lang.org)

Strings*

第四章将所有权的时候使用 String 类型举例,这里将深入介绍。Rust 新手(就是我这种菜鸡)在这个地方可能会遇到很多坑,比如 UTF-8 的处理。放到集合这里讨论,其实字符串就是作为字节的集合外接一些方法实现的,当这些字节被解释为文本时,String 的一些方法提供了实用的功能。下面会讲到 String 中任何集合都有的操作,以及与其他集合不一样的方法,比如索引 String 其实是很复杂的。

字符串是啥?*

Rust 核心语言中只有一种字符串类型 str,字符串 slice,它常以被借用的形式 &str 出现。第四章讲到了字符串 slice,它是一些储存在别处的 UTF-8 编码字符串数据的引用,比如字符串字面值。

称作 String 的类型是由标准库提供的,而没有写进核心语言部分,它是可增长、可变的、有所有权的、UTF-8 编码的字符串类型。通常谈到的字符串是指 String 和字符串 slice &str,而不是其中之一,二者都是 UTF-8 编码的。

Rust 标准库还包含一系列其他字符串类型,比如 OsString/OsStr/CString/CStr,这些字符串类型能够以不同的编码,或以内存表现形式上,来存储文本内容。

新建字符串*

很多 vector 可用的操作在 String 中同样可用。

使用 new 函数创建一个空字符串。使用 to_string 方法或 from 函数从字符串字面值创建 String

更新字符串*

String 的大小可以增加,其内容也可以改变。

可以通过 push_str 附加字符串 slice,不获取参数的所有权。使用 push 方法附加单独字符。

使用 + 运算符实现 String 拼接。

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2;

执行之后 s3 的内容是 Hello world!,s1 的所有权被移走,s2 使用引用还可以使用,这是因为 + 运算符调用的 add 函数有关。

fn add(self, s: &str) -> String {

当然,标准库中的 add 使用泛型定义,这里就以 String 为例。add 函数只能将 String&str 相加,而不能将两个 String 相加。要注意的是, &s2 明明是 &String 而不是 &str,但没有报错,可以正常运行。

这里的 &String 被强制转换(coerced)成 &str,此处 Rust 使用了一个被称为解引用强制多态(deref coercion)的技术,可以理解成把 &s2 变成 &s2[..]。第十五章会更深入的讨论解引用强制多态。引用未获取参数的所有权,所以 s2 这个操作后仍然是有效的 String

函数签名中 add 获取了 self 的所有权,这意味着 s1 的所有权将被移动到 add 调用中,因此这个 + 运算先获取 s1 的所有权,再附加上从 s2 复制的内容,返回结果的所有权。比单纯复制两个字符串并创建一个字符串更高效。

还可以使用 format! 宏拼接字符串,与 println! 工作原理相同,且不会获取任何参数的所有权。

let s3 = format!("{}{}", s1, s2);

索引字符串*

Rust 不支持使用 [] 索引 String

let s1 = String::from("hello");
let h = s1[0];
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:5:9
  |
5 | let h = s1[0];
  |         ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

编译报错,问题来了,这是为啥呢。首先要了解 Rust 是如何在内存中储存字符串的。

内部表现*

String 实际是 Vec<u8> 的封装,如果储存的是 ASCII 字符,那当然就是一个字节对应一个字符。但对于 UTF-8 编码的 Unicode 字符,一个字符可能需要多个字节存储。(这里就要了解 UTF-8 编码的原理 encoding - What is the difference between UTF-8 and Unicode? - Stack Overflow)这意味着字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。当用户使用 String[0] 时,通常希望返回的是第一个字符,但实际只能返回第一个字节。所以 Rust 干脆不编译这种代码。

字节、标量值和字形簇!*

从 Rust 的角度来讲,有三种方式可以理解字符串:字节、标量值和字形簇。

这里以一个梵文单词 नमस्ते 为例,它对应的 Vec<u8> 值、Unicode 标量值和字形簇分别为。

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
['न', 'म', 'स', '्', 'त', 'े']
["न", "म", "स्", "ते"]

Rust 提供多种方式来解释计算机储存的原始字符串数据,程序可以选择它的表现方式,而无所谓是何种人类语言。

最后一个 Rust 不允许使用索引获取 String 字符的原因是,这种下标索引操作预期通常是 O(1) 时间,但是 String 不可能保证这样的性能,因为 Rust 必须从开头遍历来确定有效字符。

字符串 slice*

到现在就明白了,在 Rust 中索引字符串时应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。如果真的希望使用索引创建字符串 slice,Rust 要求明确索引并表明需要一个字符串 slice。

let hello = "测试slice";
let s = &hello[0..3];

s 会是个 &str,包含字符串的头三个字节,即 ,汉字在 UTF-8 中是占 3 字节的。但是,如果索引下标错误,没对齐字符边界,如使用 &hello[0..1],可以编译,但运行时会 panic,跟 vector 的无效索引一样。

遍历字符串的方法*

作为合格的编程语言,Rust 提供了其他获取字符串元素的方法。

如果要操作单独的 Unicode 标量值,使用 chars 方法。使用 bytes 方法返回所有原始字节。获取字形簇比较复杂,标准库并没有提供这个功能。

for i in hello.chars() {
    println!("{}", i);
}
for i in hello.bytes() {
    println!("{}", i);
}
// 测
// 试
// 230
// 181
// 139
// 232
// 175
// 149

总而言之,字符串还是很复杂的。不同语言选择了不同的向程序展示其复杂性的方式。Rust 选择以准确的方式处理 String 数据作为默认行为,这就要求程序员更多地思考如何预先处理 UTF-8 数据,这使得在开发后免于处理非 ASCII 字符的错误。

Hash Maps*

最后介绍的常用集合类型是 hash map。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。通过哈希函数来实现映射,决定如何将键值对放入内存中。

下面介绍一些基本的 API,更多功能可以查看文档 std::collections::HashMap - Rust (rust-lang.org)

新建一个 hashmap*

HashMap 并没有被 prelude 自动引用,标准库对 HashMap 的支持也相对较少。

可以使用 new 创建一个空的 HashMap,并使用 insert 方法增加元素。

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Red"), 50);

vector 一样,hashmap 数据也存储在堆上,hashmap 的所有键值对也必须是同一类型的。

另一个构建 hashmap 的方法是使用一个元组的 vector 的 collect 方法,每个元组包含一个键值对。collect 方法可以将数据收集进一系列的集合类型,包括 HashMap

use std::collections::HashMap;

let teams  = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

使用 zip 方法创建一个元组的 vector,然后使用 collect 转换成 HashMap。这里的类型注解 HashMap<_, _> 是必要的,因为 collect 很多不同的数据结构,所以需要显式地指定类型。但对于键值对的类型而言,可以使用下划线占位,因为 Rust 可以根据 vector 中的数据类型推断 HashMap 所包含的类型。

hashmap 和所有权*

对于实现了 Copy trait 的类型,其值可以复制到 hashmap 中。而对于 String 这样拥有所有权的值,其值被移动而 hashmap 成为值的所有者。

如果将值的引用插入 hashmap,这些值本身不会被移动进 hashmap。但这些引用指向的值必须至少在 hashmap 有效时也是有效的。第十章生命周期与引用有效性的部分会详细讨论。

访问 hashmap 中的值*

可以通过 get 方法并提供对应的键来从 hashmap 中获取值,和 vector 的 get 方法类似,这里也会返回 Option<V> 枚举,也就需要处理可能的情况。

let team_name = String::from("Blue");
scores.get(&team_name);

适用于 vector 类似的方式遍历 hashmap 的键值对。

for (key, value) in &scores {}

不过这样遍历并不会按照键的顺序,而是任意顺序。

更新 hashmap*

当需要修改键关联的值时,使用 insert 方法覆盖旧值即可。

使用 entry 方法可以检查 hashmap 中键是否有对应值,方法会返回 Entry 枚举。

Entryor_insert 方法会键对应值存在时返回值的可变引用,如果不存在值则将参数插入 hashmap 并返回新值的可变引用。

另一个应用场景是找到一个键对应的值并根据旧值更新,正好 or_insert 返回的是可变引用,可以直接对返回值进行修改,当然需要先解引用。

哈希函数*

HashMap 自带的哈希函数出于安全性的考虑性能不好,可以执行一个不同的 hasher 切换为其他函数。hasher 是一个实现了 BuildHasher trait 的类型。第十章会讨论 trait 及其实现。

总结*

vector、字符串和 hashmap 可以满足常用的储存、访问和修改数据的需求。

更多的方法需要去看文档。


最后更新: June 13, 2021