上一篇: 07-使用Package、Crates、Modules管理项目


        Rust 的标准库包含许多非常有用的数据结构,称为集合。大多数其他数据类型表示一个特定值,但集合可以包含多个值。与内置的数组和元组类型不同,这些集合指向的数据存储在堆上,这意味着数据量不需要在编译时就知道,可以随着程序的运行而增减。每种集合都有不同的功能和代价,根据当前情况选择合适的集合是一项需要长期积累的技能。在本章中,我们将讨论 Rust 程序中经常使用的三种集合:

        ①. 矢量允许你将数量可变的数值相邻存储;

        ②. 字符串是字符的集合。我们之前提到过 String 类型,本章我们将深入讨论它;

        ③. Hash Map允许你将一个值与一个特定的键关联起来。它是更通用的数据结构"Map"的一种特殊实现;

        要了解标准库提供的其他类型的集合,请参阅文档

1. 用Vector存储值列表

        我们要了解的第一种集合类型是 Vec<T> ,也称为vector。vector允许你在一个数据结构中存储多个值,并将所有值放在内存中的相邻位置vector只能存储相同类型的值。当你有一个项目列表时,比如文件中的文本行或购物车中的项目价格,vector就非常有用。

1.1 创建vector

        要创建一个新的空向量,我们需要调用 Vec::new 函数, 如下代码所示:

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

        请注意,我们在这里添加了一个类型注解。因为我们没有在这个vector中插入任何值,所以 Rust 不知道我们打算存储什么样的元素。这一点很重要。vector是使用泛型实现的,我们将在后面的章节介绍如何在自己的类型中使用泛型。现在,我们要知道标准库提供的 Vec<T> 类型可以容纳任何类型。当我们创建一个存放特定类型的向量时,我们可以在角括号中指定该类型。上面代码,我们告诉 Rust 变量v 中的 Vec<T> 将保存 i32 类型的元素。

        更常见的情况是,你会创建一个带有初始值的 Vec<T> ,Rust 会推断出你想要存储的值的类型,所以你很少需要做这种类型注解。Rust 方便地提供了 vec! 宏,它将创建一个新的vector来保存你给它的值。下面示例代码创建了一个新的 Vec<i32> ,其中包含 1 、 2 和 3 。整数类型是 i32 ,因为这是默认的整数类型:

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

        因为我们已经给出了 i32 的初始值,所以 Rust 可以推断 v 的类型是 Vec<i32> ,而不需要类型注解。接下来,我们来看看如何修改向量。

1.2 更新Vector

        要创建vector并向其中添加元素,我们可以使用 push 方法,如下代码所示:

let mut v = Vec::new();

v.push(5);
v.push(6);
v.push(7);
v.push(8);

        与任何变量一样,如果我们想改变它的值,就需要使用 mut 关键字使其可变。我们放在里面的数字都是 i32 类型的,Rust 从数据中推断出了这一点,所以我们不需要 Vec<i32> 注解。

1.3 获取vector中的元素

        有两种方法可以引用存储在向量中的值:通过索引或使用 get 方法。在下面的示例中,我们注释了这些函数返回值的类型,以便更加清晰。

      下列代码展示了访问向量中值的两种方法:索引语法和 get 方法。

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

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

let third: Option<&i32> = v.get(2);
match third {
    Some(third) => println!("The third element is {third}"),
    None => println!("There is no third element."),
}

        请注意这里的一些细节。我们使用 2 的索引值来获取第三个元素,因为矢量是按数字索引的,从 0 开始。使用 & 和 [] 可以得到索引值处元素的引用。当我们使用 get 方法并将索引作为参数传递时,我们会得到一个 Option<&T> ,可以与 match 一起使用

        Rust 提供这两种引用元素的方法,是为了让你在尝试使用现有元素范围之外的索引值时,可以选择程序的行为方式。举例来说,当我们有一个包含五个元素的向量,并尝试使用每种技术访问索引值为 100 的元素时,会发生什么情况,如下代码所示:

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

let does_not_exist = &v[100];
let does_not_exist = v.get(100);

        当我们运行这段代码时,第一个 [] 方法会导致程序崩溃,因为它引用了一个不存在的元素。如果你想让程序在试图访问超过向量末尾的元素时崩溃,最好使用这种方法。

        当 get 方法传入一个超出向量范围的索引时,它会返回 None 而不会引起恐慌。如果在正常情况下偶尔会出现访问超出向量范围的元素的情况,则可以使用该方法。然后,你的代码将包含处理 Some(&element) 或 None 的逻辑。例如,索引可能来自输入数字的人。如果用户不小心输入了一个过大的数字,导致程序得到一个 None 的值,你可以告诉用户当前向量中有多少个项目,然后再给他们一次机会输入一个有效值。这比因为输入错误而导致程序崩溃更方便用户使用!

        当程序有了一个有效的引用时,借用检查程序就会执行所有权和借用规则,以确保这个引用和其他对向量内容的引用保持有效。请回想一下不能在同一作用域中使用可变引用和不可变引用的规则,该规则适用于下列示例代码。在该代码中,我们持有对向量中第一个元素的不可变引用,并尝试在末尾添加一个元素。如果我们在函数的稍后部分也尝试引用该元素,那么该程序将无法运行:

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

let first = &v[0];

v.push(6);

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

        编译此代码将导致此错误:

PS E:\rustProj\collections> cargo.exe build
   Compiling collections v0.1.0 (E:\rustProj\collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src\main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to previous error

        上述代码中看起来似乎应该有效:为什么第一个元素的引用要关心vector末尾的变化呢?这个错误是由于vector的工作方式造成的:由于vector将值放在内存中的相邻位置,如果没有足够的空间将所有元素放在vector当前存储位置的相邻位置,那么在vector末尾添加新元素可能需要分配新内存,并将旧元素复制到新空间。在这种情况下,对第一个元素的引用将指向已取消分配的内存。借用规则可以防止程序出现这种情况。

1.4 遍历vector中的值

        要依次访问vector中的每个元素,我们需要遍历所有元素,而不是使用索引一次访问一个元素。下列代码展示了如何使用 for 循环获取 i32 值向量中每个元素的不可变引用并打印出来。

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

        我们还可以遍历可变vector中每个元素的可变引用,以便对所有元素进行更改。下列代码中的 for 循环将为每个元素添加 50 。

let mut v = vec![100, 32, 57];
for i in &mut v {
    *i += 50;
}

        要更改可变引用所指向的值,我们必须先使用 * 解除引用操作符获取 i 中的值,然后才能使用 += 操作符。我们将在后面章节中详细介绍解除引用操作符。

        对vector进行迭代,无论是不变迭代还是可变迭代,都是安全的,因为有借用检查器的规则。如果我们试图在上述代码中的 for 循环体中插入或删除项,我们将收到类似的编译器错误。 for 循环对vector的引用可以防止同时修改整个vector。

1.5 使用枚举存储多种类型

        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允许包含哪些类型。如果 Rust 允许vector容纳任何类型,那么在对vector元素进行操作时,其中一种或多种类型就有可能导致错误。使用枚举和 match 表达式意味着 Rust 将在编译时确保处理每一种可能的情况。

        如果你不知道程序运行时会获得哪些详尽的类型来存储在vector中,那么枚举技术就不会起作用。相反,你可以使用特质对象,我们将在后面章介绍。

        现在我们已经讨论了使用vector的一些最常见方法,请务必查看 API 文档,了解标准库在 Vec<T> 上定义的所有实用方法。例如,除了 push 之外,还有一个 pop 方法可以删除并返回最后一个元素。

1.6 丢弃vector会丢弃其元素

        与任何其他 struct 一样,当vector退出作用域时,它将被释放,如下代码所示:

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

    // do stuff with v
} // <- v goes out of scope and is freed here

        当vector被丢弃时,它的所有内容也会被丢弃,这意味着它所保存的整数将被清理。借用检查器确保只有在vector本身有效时,才会使用对vector内容的引用。

2. 用String存储 UTF-8 编码文本

        我们在第 4 章中讨论过字符串,现在我们将对其进行更深入的研究。

        新的 Rustaceans 通常会被字符串卡住,这有三个原因:Rust 有暴露可能错误的倾向,字符串是一种比许多程序员认为的更复杂的数据结构,以及 UTF-8。当你来自其他编程语言时,这些因素结合在一起就会显得很困难。

        我们在集合的上下文中讨论String,是因为String是作为字节集合实现的,另外还有一些方法在这些字节被解释为文本时提供有用的功能。在本节中,我们将讨论每种集合类型都具有的对 String 的操作,如创建、更新和读取。我们还将讨论 String 与其他集合的不同之处,即由于人们和计算机对 String 数据的解释方式不同,在 String 中建立索引会变得复杂。

2.1 什么是String

        我们首先定义一下字符串的含义。Rust 的核心语言中只有一种字符串类型,那就是字符串片 str ,它的借用形式通常是 &str 。在第 4 章中,我们谈到了字符串片,它是对存储在其他地方的UTF-8 编码字符串数据的引用。例如,字符串字面量存储在程序的二进制文件中,因此是字符串片。

        String 类型是由 Rust 标准库提供的,而不是编入核心语言的,它是一种可增长、可变、自有、UTF-8 编码的字符串类型。当 Rustaceans 提及 Rust 中的 "字符串 "时,他们可能指的是 String 或字符串片 &str 类型,而不仅仅是其中一种类型。虽然本节主要讨论的是 String ,但这两种类型在 Rust 的标准库中都被大量使用,而且 String 和字符串切片(str)都是 UTF-8 编码的

2.2 创建新字符串

        与 Vec<T> 相同的许多操作在 String 中也可以使用,因为 String 实际上是作为字节Vector的包装器来实现的,并带有一些额外的保证、限制和功能。与 Vec<T> 和 String 运作方式相同的函数示例是用于创建实例的 new 函数,如清单 8-11 所示。

let mut s = String::new();

(清单 8-11:创建一个新的空 String)

        这一行将创建一个名为 s 的新空字符串,然后我们就可以向其中加载数据了。通常情况下,我们会有一些初始数据,希望以此作为字符串的开头。为此,我们使用 to_string 方法,该方法适用于任何实现 Display 特性的类型,字符串字面量也是如此。清单 8-12 展示了两个示例。

let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();

(清单 8-12:使用 to_string 方法从字符串字面量创建 String)

        这段代码将创建一个包含 initial contents 的字符串。

        我们还可以使用函数 String::from 从字符串字面量创建 String 。清单 8-13 中的代码等同于清单 8-12 中使用 to_string 的代码。

let s = String::from("initial contents");

(清单 8-13:使用 String::from 函数从字符串字面量创建 String)

        由于字符串的用途非常广泛,我们可以使用许多不同的字符串通用应用程序接口,从而为我们提供了大量选择。其中有些看起来是多余的,但它们都有自己的用武之地!在本例中, String::from 和 to_string 做的是同一件事,所以选择哪一个只是风格和可读性的问题。

        请记住,字符串是 UTF-8 编码的,因此我们可以在其中包含任何正确编码的数据,如清单 8-14 所示。

let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

(清单 8-14:用字符串存储不同语言的问候语)

        所有这些都是有效的 String 值。

2.2 更新字符串

        String 的大小可以增大,其内容也可以改变,就像 Vec<T> 的内容一样。此外,您还可以方便地使用 + 运算符format! 宏来连接 String 的值。

2.2.1 使用 push_str 和 push向String追加数据

        如清单 8-15 所示,我们可以使用 push_str 方法追加字符串片段,从而增长 String 。


let mut s = String::from("foo");
s.push_str("bar");

(清单 8-15:使用 push_str 方法向 String 追加字符串片段)

        这两行之后, s 将包含 foobar 。 push_str 方法使用一个字符串片段,因为我们并不一定要掌握参数的所有权。例如,在清单 8-16 中的代码中,我们希望在将 s2 的内容追加到 s1 之后能够使用 。

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");

(清单 8-16:将字符串切片的内容追加到 String)

        如果 push_str 方法拥有 s2 的所有权,我们就无法在最后一行打印它的值。然而,这段代码的工作原理正如我们所料!

        push 方法将单个字符作为参数,并将其添加到 String 中。清单 8-17 使用 push 方法将字母 "l "添加到 String 。

let mut s = String::from("lo");
s.push('l');

(清单 8-17:使用 String 值添加一个字符 push)

        因此, s 将包含 lol 。

2.2.2 使用 + 运算符或 format! 宏进行连接

        通常情况下,您需要合并两个现有字符串。一种方法是使用 + 操作符,如清单 8-18 所示。

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used

(清单 8-18:使用 + 运算符将两个 String 值合并为一个新的 String 值)

        字符串 s3 将包含 Hello, world! 。 s1 在添加后不再有效,而我们之所以使用 s2 的引用,与我们使用 + 操作符时调用的方法的签名有关。 + 操作符使用 add 方法,其签名如下:

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

        在标准库中,您会看到使用泛型和关联类型定义的 add 。在这里,我们代入了具体类型,这就是使用 String 值调用此方法时的情况。我们将在第 10 章讨论泛型。这个签名为我们提供了理解 + 操作符的棘手之处所需的线索。

        首先, s2 有一个 & ,这意味着我们要将第二个字符串的引用添加到第一个字符串。这是因为 add 函数中的 s 参数:我们只能将 &str 添加到 String 中;而不能将两个 String 值添加到一起。但是等等, &s2 的类型是 &String ,而不是 add 的第二个参数中指定的 &str 。那么为什么清单 8-18 可以编译呢?

        之所以能够在调用 add 时使用 &s2 ,是因为编译器可以将 &String 参数强转为 &str 。当我们调用 add 方法时,Rust 使用了 deref 强制,在这里将 &s2 变成了 &s2[..] 。我们将在第 15 章更深入地讨论 deref 强制。由于 add 并不拥有 s 参数的所有权,因此 s2 在执行此操作后仍将是一个有效的 String 。

        其次,我们可以从签名中看到 add 拥有 self 的所有权,因为 self 没有 & 。这意味着清单 8-18 中的 s1 将被移到 add 调用中,之后将不再有效。因此,虽然 let s3 = s1 + &s2; 看起来像是复制了两个字符串并创建了一个新字符串,但实际上该语句获取了 s1 的所有权,追加了 s2 内容的副本,然后返回结果的所有权。换句话说,这条语句看起来像是复制了很多内容,但实际上并没有;它的实现比复制更有效率。

        如果我们需要连接多个字符串,那么 + 操作符的行为就会变得非常复杂:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

        此时, s 将变为 tic-tac-toe 。有了 + 和 " 这些字符,就很难看出发生了什么。对于更复杂的字符串组合,我们可以使用 format! 宏:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

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

        这段代码还将 s 设置为 tic-tac-toe 。 format! 宏的工作原理与 println! 类似,但它不是将输出结果打印到屏幕上,而是返回一个包含内容的 String 。使用 format! 的代码版本更易于阅读,而且 format! 宏生成的代码使用了引用,因此该调用不会占用任何参数的所有权

2.3 索引字符串

        在许多其他编程语言中,通过索引来访问字符串中的单个字符是一种有效且常见的操作。但是,如果在 Rust 中尝试使用索引语法访问 String 的部分内容,则会出现错误。请看清单 8-19 中的无效代码。

let s1 = String::from("hello");
let h = s1[0];

(清单 8-19:尝试对字符串使用索引语法)

        该代码将导致以下错误:

cargo build
   Compiling string v0.1.0 (/home/rustProj/string)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`
  = help: the following other types implement trait `Index<Idx>`:
            <String as Index<RangeFull>>
            <String as Index<std::ops::Range<usize>>>
            <String as Index<RangeFrom<usize>>>
            <String as Index<RangeTo<usize>>>
            <String as Index<RangeInclusive<usize>>>
            <String as Index<RangeToInclusive<usize>>>

For more information about this error, try `rustc --explain E0277`.
error: could not compile `string` (bin "string") due to previous error

        错误和注释说明了问题所在:Rust 字符串不支持索引。但为什么不支持呢?要回答这个问题,我们需要讨论一下 Rust 如何在内存中存储字符串。

2.4 内部代表

        tring 是对 Vec<u8> 的封装。让我们看看清单 8-14 中正确编码的 UTF-8 示例字符串。首先是这个:

let hello = String::from("Hola");

        在这种情况下, len 将是 4,这意味着存储字符串 "Hola "的向量长度为 4 个字节。用 UTF-8 编码时,每个字母需要 1 个字节。不过,下面一行可能会让你大吃一惊。(请注意,该字符串以大写西里尔字母 Ze 开头,而不是数字 3)。

let hello = String::from("Здравствуйте");

        如果问这个字符串有多长,你可能会说是 12。事实上,Rust 的答案是 24:这是用 UTF-8 编码 "Здравствуйте "所需的字节数,因为该字符串中的每个 Unicode 标量值需要 2 个字节的存储空间。因此,字符串字节索引并不总是与有效的 Unicode 标量值相关联。请看这段无效的 Rust 代码,以作说明:

let hello = "Здравствуйте";
let answer = &hello[0];

        您已经知道, answer 不会是 З ,即第一个字母。用 UTF-8 编码时, З 的第一个字节是 208 ,第二个字节是 151 ,因此 answer 似乎实际上应该是 208 ,但 208 本身并不是一个有效字符。如果用户要求得到这个字符串的第一个字母,那么返回 208 很可能不是他们想要的结果;然而,这是 Rust 在字节索引 0 处拥有的唯一数据。用户通常不希望返回字节值,即使字符串只包含拉丁字母:如果 &"hello"[0] 是返回字节值的有效代码,它将返回 104 ,而不是 h 。

        那么答案就是,为了避免返回一个意外的值并导致可能无法立即发现的错误,Rust 完全不编译这段代码,并在开发过程的早期防止误解

2.5 字节、标量值和字形集群!噢我的天!

        关于 UTF-8 的另一点是,从 Rust 的角度来看,字符串实际上有三种相关的方式:字节、标量值和字形集群(最接近我们所说的字母)。

        如果我们看一下用梵文书写的印地语单词 "नमस्ते",它是以 u8 值的矢量形式存储的,看起来像这样:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

        这就是 18 个字节,也是计算机最终存储这些数据的方式。如果我们把它们看作 Unicode 标量值(Rust 的 char 类型),这些字节看起来就像这样:

['न', 'म', 'स', '्', 'त', 'े']

        Rust 提供了不同的方式来解释计算机存储的原始字符串数据,这样,无论数据使用的是哪种人类语言,每个程序都可以选择自己需要的解释方式。

        Rust 不允许我们对 String 进行索引来获取字符的最后一个原因是,索引操作预计总是需要恒定的时间(O(1))。但 String 无法保证这样的性能,因为 Rust 必须从头到尾走一遍索引内容,才能确定有多少个有效字符。

2.6 分割字符串

        向字符串建立索引通常不是个好主意,因为不清楚字符串索引操作的返回类型应该是什么:字节值、字符、字符串簇还是字符串片。因此,如果你真的需要使用索引来创建字符串片段,Rust 会要求你提供更具体的信息。

        与使用 [] 和单个数字编制索引不同,您可以使用 [] 和范围来创建包含特定字节的字符串片段:

let hello = "Здравствуйте";

let s = &hello[0..4];

        在这里, s 将是包含字符串前 4 个字节的 &str 。前面我们提到,每个字符都是 2 个字节,这意味着 s 将成为 Зд 。

        如果我们尝试使用 &hello[0..1] 这样的方法只切分字符的部分字节,Rust 就会在运行时发生恐慌,就像在向量中访问无效索引一样:

cargo build
   Compiling string v0.1.0 (/home/rustProj/string)
error: expected one of `!`, `.`, `::`, `;`, `?`, `{`, `}`, or an operator, found `hello`
 --> src/main.rs:2:8
  |
2 |     et hello = "Здравствуйте";
  |        ^^^^^ expected one of 8 possible tokens

error: could not compile `string` (bin "string") due to previous error

        你应该谨慎使用范围来创建字符串切片,因为这样做可能会导致程序崩溃。

2.7 对字符串进行迭代的方法

        对字符串片段进行操作的最佳方法是明确说明您需要的是字符还是字节。对于单个 Unicode 标量值,请使用 chars 方法。在 "Зд "上调用 chars 会分离并返回两个 char 类型的值,您可以遍历结果以访问每个元素:

for c in "Зд".chars() {
    println!("{c}");
}

        这段代码将打印如下内容:

З
д

        另外, bytes 方法会返回每个原始字节,这可能适合您的领域:

for b in "Зд".bytes() {
    println!("{b}");
}

        这段代码将打印组成这个字符串的四个字节:

208
151
208
180

        但请务必记住,有效的 Unicode 标量值可能由一个以上的字节组成。

        从字符串中获取字素簇(如梵文脚本)非常复杂,因此标准库不提供此功能。如果您需要这种功能,可在 crates.io 上下载 Crates。

2.8 String并不简单

        总而言之,字符串是复杂的。对于如何向程序员展示这种复杂性,不同的编程语言有不同的选择。Rust 选择将正确处理 String 数据作为所有 Rust 程序的默认行为,这意味着程序员必须在处理 UTF-8 数据时花费更多心思。与其他编程语言相比,这种权衡暴露了字符串的更多复杂性,但却避免了在开发生命周期的后期处理涉及非 ASCII 字符的错误。

        好消息是,标准库提供了大量基于 String 和 &str 类型的功能,可帮助正确处理这些复杂情况。请务必查看文档,了解有用的方法,如 contains 用于在字符串中搜索, replace 用于用另一个字符串替换字符串的部分内容。

3. 在hasp map中存储带有相关值的键

        最后一个常用的集合是散列映射。 HashMap<K, V> 类型使用散列函数将 K 类型的键映射到 V 类型的值,该函数决定了如何将这些键和值放入内存。许多编程语言都支持这种数据结构,但它们通常使用不同的名称,例如散列、映射、对象、散列表、字典或关联数组等。

        散列映射在查找数据时非常有用,它不像矢量那样使用索引,而是使用可以是任何类型的键。例如,在一场比赛中,你可以在哈希图中记录每支球队的得分,其中每个键都是一支球队的名称,而值则是每支球队的得分。给定一个队名,就可以检索到它的得分。

        我们将在本节中介绍散列映射的基本 API,但标准库在 HashMap<K, V> 上定义的函数中还隐藏着更多精彩内容。如需了解更多信息,请查看标准库文档。

3.1 创建新的哈希图

        创建空散列映射的一种方法是使用 new ,然后使用 insert 添加元素。在清单 8-20 中,我们要记录蓝队和黄队这两支队伍的得分。蓝队从 10 分开始,黄队从 50 分开始。

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

(清单 8-20:创建新的哈希映射并插入一些键和值)

        请注意,我们首先需要从标准库的集合部分 use HashMap在我们常用的三个集合中,这个集合是最不常用的,所以它并不包含在前奏中自动纳入范围的特性中。标准库对哈希映射的支持也较少;例如,没有内置宏来构造哈希映射。

        与向量一样,散列映射也将数据存储在堆上。 HashMap 的键类型为 String ,值类型为 i32 。与向量一样,散列表也是同质的:所有键的类型必须相同,所有值的类型也必须相同。

3.2 访问哈希映射表中的值

        如清单 8-21 所示,我们可以通过向 get 方法提供键值,从散列映射中获取一个值

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);

(清单 8-21:访问散列映射中存储的蓝队得分)

        在这里, score 将具有与蓝队相关联的值,结果将是 10 。 get 方法返回 Option<&V> ;如果哈希图中没有该键的值, get 将返回 None 。本程序在处理 Option 时,先调用 copied 获得 Option<i32> 而不是 Option<&i32> ,然后调用 unwrap_or ,如果 scores 没有该键的条目,则将 score 设置为零

        我们可以使用 for 循环,以类似于处理向量的方式遍历哈希映射中的每个键/值对:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
    println!("{key}: {value}");
}

        这段代码将以任意顺序打印每个键值对:

Yellow: 50
Blue: 10

3.3 Hash map和所有权

        对于实现 Copy 特质的类型,如 i32 ,值将被复制到哈希映射中。对于自有值(如 String ),值将被移动,哈希映射将成为这些值的所有者,如清单 8-22 所示。

use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// field_name and field_value are invalid at this point, try using them and
// see what compiler error you get!

(清单 8-22: 显示键和值插入后归哈希映射所有)

        在调用 insert 将变量 field_name 和 field_value 移入散列映射后,我们就无法使用它们了。

        如果我们在哈希映射中插入对值的引用,这些值就不会被移入哈希映射。引用指向的值必须至少在哈希映射有效期内有效。我们将在第 10 章 "使用生命周期验证引用 "一节中详细讨论这些问题。

3.4 更新哈希映射表

        虽然键和值对的数量可以增长,但每个唯一键一次只能有一个值与之关联(反之亦然:例如,蓝队和黄队都可以在 scores 哈希图中存储值 10)。

        要更改哈希映射中的数据时,必须决定如何处理键已赋值的情况。可以用新值替换旧值,完全忽略旧值。可以保留旧值,忽略新值,只有在键还没有值的情况下才添加新值。或者,也可以将旧值和新值合并。让我们分别看看如何操作!

3.4.1 重写数值

        如果我们在哈希映射中插入一个键和一个值,然后再插入一个带有不同值的相同键,那么与该键相关的值就会被替换。尽管清单 8-23 中的代码调用了两次 insert ,但散列映射只包含一个键/值对,因为我们两次插入的都是蓝队的键值。

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

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

(清单 8-23:替换以特定键存储的值)

        这段代码将打印 {"Blue": 25} 。 10 的原始值已被覆盖。

3.4.2 仅在键不存在时添加键和值

        通常的做法是检查散列映射中是否已经存在某个键和某个值,然后采取以下措施:如果散列映射中确实存在该键,则保持现有值不变。如果键不存在,则插入该键及其值。

        哈希映射有一个特殊的应用程序接口,名为 entry ,它把要检查的键作为参数。 entry 方法的返回值是一个名为 Entry 的枚举,代表一个可能存在也可能不存在的值。比方说,我们要检查 "黄队 "的关键字是否有相关的值。如果没有,我们就插入值 50,蓝队也一样。使用 entry API,代码看起来像清单 8-24。        

use std::collections::HashMap;

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

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

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

(清单 8-24使用 entry 方法,仅在键还没有值时插入)

        Entry 上的 or_insert 方法是这样定义的:如果相应的 Entry 关键字存在,则返回该关键字值的可变引用;如果不存在,则插入参数作为该关键字的新值,并返回新值的可变引用。这种技术比我们自己编写逻辑要简洁得多,而且与借用检查器的配合也更加默契。

        运行清单 8-24 中的代码将打印 {"Yellow": 50, "Blue": 10} 。第一次调用 entry 将插入值为 50 的黄队键,因为黄队还没有值。第二次调用 entry 不会更改哈希图,因为蓝队已经有了值 10。

3.4.3 根据旧值更新数值

        散列映射的另一个常见用例是查找键值,然后根据旧值更新键值。例如,清单 8-25 显示了计算每个单词在某些文本中出现次数的代码。我们使用一个以单词为键的哈希映射,并递增键值来记录我们看到该单词的次数。如果我们第一次看到一个单词,我们会首先插入值 0。

use std::collections::HashMap;

let text = "hello world wonderful world";

let mut map = HashMap::new();

for word in text.split_whitespace() {
    let count = map.entry(word).or_insert(0);
    *count += 1;
}

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

(清单 8-25:使用存储单词和计数的哈希映射计算单词的出现次数)

        这段代码将打印 {"world": 2, "hello": 1, "wonderful": 1} 。您可能会看到以不同顺序打印的相同键/值对:请回顾 "访问哈希映射中的值 "部分,对哈希映射的迭代是以任意顺序进行的。

        split_whitespace 方法返回 text 中值的子片段(用空白分隔)的迭代器 or_insert 方法返回指定键值的可变引用( &mut V )。在这里,我们将该可变引用存储在 count 变量中,因此要对该值赋值,我们必须首先使用星号( * )取消引用 count 。在 for 循环结束时,该可变引用将退出作用域,因此所有这些更改都是安全的,也是借用规则所允许的。

3.5  散列函数

        默认情况下, HashMap 使用名为 SipHash 的散列函数,可以抵御涉及散列表的拒绝服务(DoS)攻击 这不是目前最快的散列算法,但性能下降带来的更好安全性是值得的。如果你在分析代码时发现默认散列函数的速度太慢,可以指定不同的散列函数来切换到其他函数。散列是一种实现 BuildHasher 特质的类型。我们将在第 10 章讨论特质以及如何实现特质。你不一定要从头开始实现自己的散列器;crates.io 提供了由其他 Rust 用户共享的库,这些库提供了实现许多常见散列算法的散列器。


下一篇: 09-错误处理

02-05 11:46