在哈希映射中存储键值对
我们常用集合的最后一个是 *哈希映射*。`HashMap<K, V>` 类型使用 *哈希函数* 存储类型为 `K` 的键到类型为 `V` 的值的映射,该哈希函数决定了它如何将这些键和值放入内存中。许多编程语言都支持这种数据结构,但它们通常使用不同的名称,例如 *hash*、*map*、*object*、*哈希表*、*dictionary* 或 *关联数组*,仅举几例。
当你想查找数据时,哈希映射非常有用,不是像向量那样使用索引,而是使用可以是任何类型的键。例如,在游戏中,你可以用哈希映射来跟踪每个团队的分数,其中每个键是团队的名称,值是每个团队的分数。给定一个团队名称,你可以检索它的分数。
在本节中,我们将介绍哈希映射的基本 API,但在标准库中 `HashMap<K, V>` 上定义的函数中隐藏着更多好东西。与往常一样,请查看标准库文档以获取更多信息。
创建一个新的哈希映射
创建空哈希映射的一种方法是使用 `new` 并使用 `insert` 添加元素。在列表 8-20 中,我们正在跟踪名为 *Blue* 和 *Yellow* 的两支队伍的分数。蓝色队伍从 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`。在我们三个常用集合中,这个集合是最不常用的,因此它不包含在 prelude 中自动引入作用域的功能中。哈希映射也较少受到标准库的支持;例如,没有内置的宏来构造它们。
就像向量一样,哈希映射将其数据存储在堆上。这个 `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` 将具有与蓝色队伍关联的值,结果将是 `10`。`get` 方法返回一个 `Option<&V>`;如果哈希映射中没有该键的值,`get` 将返回 `None`。此程序通过调用 `copied` 来处理 `Option` 以获得 `Option<i32>` 而不是 `Option<&i32>`,然后使用 `unwrap_or` 将 `score` 设置为零,如果 `scores` 没有该键的条目。
我们可以像使用向量一样,使用 `for` 循环来迭代哈希映射中的每个键值对
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
哈希映射和所有权
对于实现 `Copy` trait 的类型,例如 `i32`,这些值会被复制到哈希映射中。对于像 `String` 这样的拥有值,这些值将被移动,哈希映射将成为这些值的所有者,如列表 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:展示键和值一旦插入就由哈希映射拥有
在变量 `field_name` 和 `field_value` 通过调用 `insert` 移动到哈希映射后,我们就无法再使用它们。
如果我们将值的引用插入到哈希映射中,这些值将不会被移动到哈希映射中。引用指向的值必须至少在哈希映射有效的时间内保持有效。我们将在 “使用生命周期验证引用”第 10 章的章节中详细讨论这些问题。
更新哈希映射
尽管键值对的数量是可增长的,但每个唯一的键一次只能关联一个值(但反之则不然:例如,蓝色队伍和黄色队伍都可以在 `scores` 哈希映射中存储值 `10`)。
当你想更改哈希映射中的数据时,你必须决定如何处理键已分配值的情况。你可以用新值替换旧值,完全忽略旧值。你可以保留旧值并忽略新值,仅当键 *不* 已经有值时才添加新值。或者你可以将旧值和新值组合起来。让我们看看如何执行这些操作!
覆盖值
如果我们将键和值插入到哈希映射中,然后使用不同的值插入相同的键,则与该键关联的值将被替换。即使列表 8-23 中的代码调用 `insert` 两次,哈希映射也只会包含一个键值对,因为我们两次都在插入蓝色队伍的键的值。
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` 已被覆盖。
仅当键不存在时才添加键和值
通常需要检查哈希映射中是否已存在具有值的特定键,然后采取以下操作:如果键确实存在于哈希映射中,则现有值应保持不变;如果键不存在,则插入它及其值。
哈希映射为此提供了一个特殊的 API,称为 `entry`,它将你要检查的键作为参数。`entry` 方法的返回值是一个名为 `Entry` 的枚举,它表示可能存在或可能不存在的值。假设我们要检查黄色队伍的键是否有关联的值。如果没有,我们想插入值 `50`,蓝色队伍也是如此。使用 `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` 键值的可变引用;如果键不存在,则将参数作为此键的新值插入并返回对新值的可变引用。这种技术比我们自己编写逻辑要简洁得多,而且与借用检查器配合得更好。
运行列表 8-24 中的代码将打印 `{"Yellow": 50, "Blue": 10}`。第一次调用 `entry` 将插入黄色队伍的键和值 `50`,因为黄色队伍尚无值。第二次调用 `entry` 不会更改哈希映射,因为蓝色队伍已经有值 `10`。
基于旧值更新值
哈希映射的另一个常见用例是查找键的值,然后根据旧值更新它。例如,列表 8-25 显示了计算文本中每个单词出现次数的代码。我们使用哈希映射,以单词作为键,并递增值来跟踪我们看到该单词的次数。如果是我们第一次看到一个单词,我们将首先插入值 `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:使用存储单词和计数次数的哈希映射来计算单词出现次数
此代码将打印 `{"world": 2, "hello": 1, "wonderful": 1}`。你可能会看到相同的键值对以不同的顺序打印:回想一下 “访问哈希映射中的值”节,其中提到迭代哈希映射以任意顺序发生。
`split_whitespace` 方法返回一个迭代器,它迭代 `text` 值中由空格分隔的子切片。`or_insert` 方法返回对指定键值的可变引用 (&mut V
)。在这里,我们将该可变引用存储在 `count` 变量中,因此为了赋值给该值,我们必须首先使用星号 (*
) 解引用 `count`。可变引用在 `for` 循环结束时超出作用域,因此所有这些更改都是安全的,并被借用规则允许。
哈希函数
默认情况下,`HashMap` 使用名为 *SipHash* 的哈希函数,它可以提供对涉及哈希表的拒绝服务 (DoS) 攻击的抵抗能力1。这不是最快的可用哈希算法,但为了更好的安全性而牺牲性能是值得的。如果你分析你的代码并发现默认哈希函数对你的目的来说太慢,你可以通过指定不同的 hasher 来切换到另一个函数。*hasher* 是一种实现 `BuildHasher` trait 的类型。我们将在 第 10 章 中讨论 trait 以及如何实现它们。你不一定需要从头开始实现你自己的 hasher;crates.io有其他 Rust 用户共享的库,这些库提供了实现许多常见哈希算法的 hasher。
总结
当你需要存储、访问和修改数据时,向量、字符串和哈希映射将提供程序中所需的大量功能。以下是你现在应该能够解决的一些练习
- 给定一个整数列表,使用向量并返回列表的中位数(排序后,中间位置的值)和众数(出现次数最多的值;哈希映射在这里会很有帮助)。
- 将字符串转换为猪拉丁语。每个单词的第一个辅音被移到单词的末尾并添加 *ay*,所以 *first* 变成 *irst-fay*。以元音开头的单词在末尾添加 *hay*(*apple* 变成 *apple-hay*)。请记住关于 UTF-8 编码的细节!
- 使用哈希映射和向量,创建一个文本界面,允许用户将员工姓名添加到公司的部门;例如,“将 Sally 添加到 Engineering”或“将 Amir 添加到 Sales”。然后让用户按部门检索部门中的所有人列表或公司中的所有人列表,并按字母顺序排序。
标准库 API 文档描述了向量、字符串和哈希映射具有的方法,这些方法对这些练习很有帮助!
我们正在进入更复杂的程序,其中操作可能会失败,因此现在是讨论错误处理的最佳时机。我们接下来将这样做!