在哈希映射中存储键值对

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

当您想通过键而不是像使用向量那样使用索引来查找数据时,哈希映射非常有用,键可以是任何类型。 例如,在游戏中,您可以跟踪哈希映射中每个团队的分数,其中每个键都是团队的名称,值是每个团队的分数。 给定一个团队名称,您可以检索其分数。

我们将在本节中介绍哈希映射的基本 API,但标准库在 HashMap<K, V> 上定义的函数中隐藏着更多好东西。 与往常一样,请查看标准库文档以获取更多信息。

创建新的哈希映射

创建空哈希映射的一种方法是使用 new 并使用 insert 添加元素。 在清单 8-20 中,我们跟踪了两个团队的分数,这两个团队的名称分别为*蓝色*和*黄色*。 蓝队以 10 分开始,黄队以 50 分开始。

fn main() {
    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 的值。 与向量一样,哈希映射是同构的:所有键必须具有相同的类型,并且所有值必须具有相同的类型。

访问哈希映射中的值

我们可以通过将键提供给 get 方法来从哈希映射中获取值,如清单 8-21 所示。

fn main() {
    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 将会拥有与 Blue 团队相关联的值,结果将会是 10get 方法返回一个 Option<&V>;如果哈希 map 中没有该键的值,get 将会返回 None。这个程序通过调用 copied 来处理 Option,以获取 Option<i32> 而不是 Option<&i32>,然后使用 unwrap_orscores 中没有该键的条目时将 score 设置为零。

我们可以使用 for 循环,以类似于向量的方式迭代哈希 map 中的每个键值对。

fn main() {
    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

哈希 Map 和所有权

对于实现了 Copy trait 的类型,例如 i32,值会被复制到哈希 map 中。对于像 String 这样的拥有所有权的值,值将会被移动,并且哈希 map 将会成为这些值的所有者,如代码清单 8-22 所示。

fn main() {
    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:展示了键和值在插入哈希 map 后由哈希 map 所有

在使用 insert 调用将变量 field_namefield_value 移动到哈希 map 中后,我们将无法再使用它们。

如果我们将值的引用插入哈希 map,则值不会被移动到哈希 map 中。引用指向的值必须至少在哈希 map 有效的时间内保持有效。我们将在第 10 章的“使用生命周期验证引用”一节中详细讨论这些问题。

更新哈希 Map

尽管键值对的数量可以增长,但每个唯一键一次只能与一个值相关联(反之则不然:例如,Blue 团队和 Yellow 团队都可以在 scores 哈希 map 中存储值 10)。

当您想要更改哈希 map 中的数据时,您必须决定如何处理键已经分配了值的情况。您可以用新值替换旧值,完全忽略旧值。您可以保留旧值并忽略新值,仅当键没有值时才添加新值。或者您可以组合旧值和新值。让我们看看如何做到这些!

覆盖值

如果我们将一个键和一个值插入哈希 map,然后使用不同的值插入相同的键,则与该键关联的值将被替换。即使代码清单 8-23 中的代码调用了两次 insert,哈希 map 也只包含一对键值对,因为我们两次都插入了 Blue 团队键的值。

fn main() {
    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 已被覆盖。

仅当键不存在时才添加键和值

通常会检查哈希 map 中是否存在特定键的值,然后执行以下操作:如果键存在于哈希 map 中,则现有值应保持原样。如果键不存在,则插入它及其值。

哈希 map 有一个特殊的 API 来实现这一点,称为 entry,它将您要检查的键作为参数。entry 方法的返回值是一个名为 Entry 的枚举,它表示可能存在或不存在的值。假设我们要检查 Yellow 团队的键是否有关联的值。如果没有,我们想插入值 50,Blue 团队也是如此。使用 entry API,代码如下面的代码清单 8-24 所示。

fn main() {
    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 键存在,则返回该值的 mutable reference,否则,将参数作为该键的新值插入,并返回新值的 mutable reference。这种技术比我们自己编写逻辑要简洁得多,而且与 borrow checker 配合得更好。

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

根据旧值更新值

哈希 map 的另一个常见用例是查找键的值,然后根据旧值更新它。例如,代码清单 8-25 显示了计算每个单词在某些文本中出现次数的代码。我们使用一个哈希 map,其中单词作为键,并增加值以跟踪我们看到该单词的次数。如果这是我们第一次看到某个单词,我们将首先插入值 0。

fn main() {
    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:使用存储单词和计数的哈希 map 统计单词出现的次数

这段代码将打印 {"world": 2, "hello": 1, "wonderful": 1}。您可能会看到以不同的顺序打印相同的键值对:回想一下“访问哈希 Map 中的值一节中提到的,迭代哈希 map 的顺序是任意的。

split_whitespace 方法返回一个迭代器,该迭代器遍历 text 中的值的子切片,并以空格分隔。or_insert 方法返回指定键的值的 mutable reference (&mut V)。这里我们将该 mutable reference 存储在 count 变量中,因此为了给该值赋值,我们必须首先使用星号 (*) 解引用 count。mutable reference 在 for 循环结束时超出作用域,因此所有这些更改都是安全的,并且符合借用规则。

哈希函数

默认情况下,HashMap 使用一种名为 *SipHash* 的哈希函数,它可以提供对涉及哈希表的拒绝服务 (DoS) 攻击的抵抗力1。这不是最快的哈希算法,但为了更好的安全性而牺牲性能是值得的。如果您分析代码后发现默认哈希函数对您的目的来说太慢,您可以通过指定不同的哈希器来切换到另一个函数。哈希器是一种实现了 BuildHasher trait 的类型。我们将在第 10 章讨论 trait 以及如何实现它们。您不必从头开始实现自己的哈希器;crates.io上有其他 Rust 用户共享的库,这些库提供了实现了许多常见哈希算法的哈希器。

总结

当您需要存储、访问和修改数据时,向量、字符串和哈希 map 将提供程序中所需的大量功能。以下是一些您现在应该能够解决的练习:

  • 给定一个整数列表,使用向量并返回列表的中位数(排序后,中间位置的值)和众数(出现次数最多的值;哈希 map 在这里会有所帮助)。
  • 将字符串转换为猪拉丁语。每个单词的第一个辅音字母被移到单词的末尾,并添加“ay”,因此“first”变成了“irst-fay”。以元音字母开头的单词的末尾添加“hay”(“apple”变成了“apple-hay”)。请记住有关 UTF-8 编码的详细信息!
  • 使用哈希 map 和向量,创建一个文本界面,允许用户将员工姓名添加到公司的部门中。例如,“将 Sally 添加到工程部”或“将 Amir 添加到销售部”。然后,让用户按部门或公司中的所有人员(按部门排序)检索所有人员的列表。

标准库 API 文档描述了向量、字符串和哈希 map 具有的方法,这些方法对这些练习很有帮助!

我们正在进入更复杂的程序,在这些程序中操作可能会失败,因此,现在是讨论错误处理的最佳时机。我们接下来就来讨论这个问题!