Rust语言圣经33 - HashMap

Posted 编程学院

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Rust语言圣经33 - HashMap相关的知识,希望对你有一定的参考价值。

本文节选自<<Rust语言圣经>>一书
欢迎大家加入Rust编程学院,一起学习交流:
QQ群:1009730433

KV存储HashMap

和动态数组一样,HashMap也是Rust标准库中提供的集合类型,但是又与动态数组不同,HashMap中存储的是一一映射的KV键值对,并提供了平均复杂度为O(1)的查询方法,当我们希望通过一个Key去查询值时,该类型非常有用,以致于Go语言将该类型设置成了语言级别的内置特性。

Rust中哈希类型为HashMap<K,V>, 在其它语言中,也有类似的数据结构,例如hash mapmapobject,hash table,字典等等,引用小品演员孙涛的一句台词:大家都是本地狐狸,别搁那装貂:)。

创建HashMap

跟创建动态数组Vec的方法类似, 可以使用new方法来创建HashMap,然后通过insert方法插入键值对.

使用new方法创建

use std::collections::HashMap;

// 创建一个HashMap,用于存储宝石种类和对应的数量
let mut my_gems = HashMap::new();

// 将宝石类型和对应的数量写入表中
my_gems.insert("红宝石", 1);
my_gems.insert("蓝宝石", 2);
my_gems.insert("河边捡的误以为是宝石的破石头", 18);

很简单对吧?跟其它语言没有区别,聪明的同学甚至能够猜到该HashMap的类型:HashMap<&str,i32>.

但是还有一点,你可能没有注意,那就是使用HashMap需要手动通过use ...从标准库中引入到我们当前的作用域中来,仔细回忆下,之前使用另外两个集合类型StringVec时,我们是否有手动引用过?答案是No, 因为HashMap并没有包含在Rust的prelude中(Rust为了简化用户使用,提前将最常用的类型自动引入到作用域中)。

所有的集合类型都是动态的,意味着它们没有固定的内存大小,因此它们底层的数据都存储在内存堆上,然后通过一个存储在栈中的引用类型来访问。同时,跟其它集合类型一致,HashMap也是内聚性的, 即所有的K必须拥有同样的类型,V也是如此。

使用迭代器和collect方法创建

在实际使用中,不是所有的场景都能new一个哈希表后,然后悠哉悠哉的依次插入对应的键值对, 而是可能会从另外一个数据结构中,获取到对应的数据,最终生成HashMap.

例如考虑一个场景,有一张表格中记录了足球联赛中各队伍名称和积分的信息,这张表如果被导入到Rust项目中,一个合理的数据结构是Vec<(String,u32)>类型,该数组中的元素是一个个元组,该数据结构跟表格数据非常契合:表格中的数据都是逐行存储,每一个行都存有一个(队伍名称,积分)的信息。

但是在很多时候,又需要通过队伍名称来查询对应的积分,此时动态数组又不适用了,因此可以用HashMap来保存相关的队伍名称 -> 积分映射关系。 理想很骨感,现实很丰满,如果将Vec<(String, u32)>中的数据快速写入到HashMap<String, u32>中?

一个动动脚趾头就能想到的笨方法如下:

fn main() 
    use std::collections::HashMap;

    let teams_list = vec![
        ("中国队".to_string(), 100),
        ("美国队".to_string(),10),
        ("日本队".to_string(),50),
    ];

    let mut teams_map = HashMap::new();
    for team in &teams_list 
        teams_map.insert(&team.0, team.1);
    
    
    println!(":?",teams_map)

遍历列表,将每一个元组作为一对KV插入到HashMap中,很简单,但是。。。也不太聪明的样子, 换个词说就是 - 不够rusty.

好在,Rust为我们提供了一个非常精妙的解决办法:先将Vec转为迭代器,接着通过collect方法,将迭代器中的元素收集后,转成HashMap:

fn main() 
    use std::collections::HashMap;

    let teams_list = vec![
        ("中国队".to_string(), 100),
        ("美国队".to_string(),10),
        ("日本队".to_string(),50),
    ];

    let teams_map: HashMap<_,_> = teams_list.into_iter().collect();
    
    println!(":?",teams_map)

代码很简单,into_iter方法将列表转为迭代器,接着通过collect进行收集,不过需要注意的是,collect方法在内部实际上支持生成多种类型的目标集合,因为我们需要通过类型标注HashMap<_,_>来告诉编译器:请帮我们收集为HashMap集合类型,具体的KV类型,麻烦编译器你老人家帮我们推导。

由此可见,Rust中的编译器时而小聪明,时而大聪明,不过好在,它大聪明的时候,会自家人知道自己事,总归会通知你一声:

error[E0282]: type annotations needed 需要类型标注
  --> src/main.rs:10:9
   |
10 |     let teams_map = teams_list.into_iter().collect(); 
   |         ^^^^^^^^^ consider giving `teams_map` a type 给予teams_map一个具体的类型

所有权转移

HashMap的所有权规则与其它Rust类型没有区别:

  • 若类型实现Copy特征,该类型会被复制进HashMap, 因此无所谓所有权
  • 若没实现Copy特征,所有权将被转移给HashMap

例如我参选帅气男孩时的场景再现:

fn main() 
    use std::collections::HashMap;

    let name = String::from("Sunface");
    let age = 18;

    let mut handsome_boys = HashMap::new();
    handsome_boys.insert(name, age);

    println!("因为过于无耻,已经被从帅气男孩名单中除名", name);
    println!("还有,他的真实年龄远远不止岁",age);

运行代码,报错如下:

error[E0382]: borrow of moved value: `name`
  --> src/main.rs:10:32
   |
4  |     let name = String::from("Sunface");
   |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
...
8  |     handsome_boys.insert(name, age);
   |                          ---- value moved here
9  | 
10 |     println!("因为过于无耻,已经被除名", name);
   |                                            ^^^^ value borrowed here after move

提示很清晰,nameString类型,因此它受到所有权的限制,在insert时,它的所有权被转移给handsome_boys,最后在使用时,无情但是意料之中的报错。

如果你使用引用类型放入HashMap中, 请确保该类型至少跟HashMap获得一样久:

fn main() 
    use std::collections::HashMap;

    let name = String::from("Sunface");
    let age = 18;

    let mut handsome_boys = HashMap::new();
    handsome_boys.insert(&name, age);

    std::mem::drop(name);
    println!("因为过于无耻,:?已经被除名", handsome_boys);
    println!("还有,他的真实年龄远远不止岁",age);

上面代码,我们借用name获取了它的引用,然后插入到handsome_boys中,至此一切都很完美。但是紧接着,就通过drop函数手动将name字符串从内存中移除,再然后就报错了:

 handsome_boys.insert(&name, age);
   |                          ----- borrow of `name` occurs here // name借用发生在此处
9  | 
10 |     std::mem::drop(name);
   |                    ^^^^ move out of `name` occurs here // name的所有权被转移走
11 |     println!("因为过于无耻,:?已经被除名", handsome_boys);
   |                                              ------------- borrow later used here // 所有权转移后,还试图使用name

最终,某人因为过于无耻,真正的被除名了:)

查询HashMap

通过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: Option<&i32> = scores.get(&team_name);

上面有几点需要注意:

  • get方法返回一个Option<&i32>类型:当查询不到时,会返回一个None,查询到时返回Some(&i32)
  • &32是对HashMap中值的借用,如果不使用借用,可能会发生所有权的转移

还可以通过循环的方式依次遍历KV对:

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

更新HashMap中的值

更新值的时候,涉及多种情况,咱们在代码中一一进行说明:

fn main() 
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert("Blue", 10);

    // 覆盖已有的值
    let old = scores.insert("Blue", 20);
    assert_eq!(old, Some(10));

    // 查询新插入的值
    let new = scores.get("Blue");
    assert_eq!(new, Some(&20));
    
    // 查询Yellow对应的值,若不存在则插入新值
    let v = scores.entry("Yellow").or_insert(5);
    assert_eq!(*v, 5); // 不存在,插入5

    // 查询Yellow对应的值,若不存在则插入新值
    let v = scores.entry("Yellow").or_insert(50);
    assert_eq!(*v, 5); // 已经存在,因此50没有插入

具体的解释在代码注释中已有,这里不再进行赘述。

在已有值的基础上更新

另一个常用场景如下:查询某个key对应的值,若不存在则插入新值,若存在则对已有的值进行更新,例如在文本中统计词语出现的次数:

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);

上面代码中,新建一个map用于保存词语出现的次数,插入一个词语时会进行判断:若之前没有插入过,则使用该词语作Key,插入次数0,若之前插入过则取出之前统计的该词语出现的次数。 最后,对该词语出现的次数进行加一。

有两点值得注意:

  • or_insert返回了&mut v引用,因此可以通过该可变引用直接修改map中对应的值
  • 使用count引用时,需要先进行解引用*count,否则会出现类型不匹配

哈希函数

你肯定比较好奇,为何叫哈希表,到底什么是哈希。

先来设想下,如果要实现KeyValue的一一对应,是不是意味着我们要能比较两个Key的相等性?例如"a"和"b",1和2,当这些做Key且能比较时,可以很容易知道1对应的值不会错误的映射到2上,因为1不等于2. 因此,一个类型能否作为Key的关键就是是否能进行相等比较, 或者说该类型是否实现了std::cmp::Eq特征。

f32和f64浮点数,没有实现std::cmp::Eq特征,因此不可以用作HashMap的Key

好了,理解完这个,再来设想一点,若一个复杂点的类型作为Key,那怎么在底层对它进行存储,怎么使用它进行查询和比较? 是不是很棘手?好在我们有哈希函数:通过它把Key计算后映射为哈希值,然后使用该哈希值来进行存储、查询、比较等操作。

但是问题又来了,如何保证不同Key通过哈希后的两个值不会相同?如果相同,那意味着我们使用不同的·,却查到了同一个结果,这种明显是错误的行为。
此时,就涉及到安全性跟性能的取舍了。

若要追求安全,尽可能减少冲突,同时防止拒绝服务(Denial of Service, DoS)攻击,就要使用密码学安全的哈希函数,HashMap就是使用了这样的哈希函数。反之若要追求性能,就需要使用没有那么安全的算法。

因此若性能测试显示当前标准库默认的哈希函数不能满足你的性能需求,就需要去crates.io上寻找其它的哈希函数实现, 使用方法很简单:

use std::hash::BuildHasherDefault;
use std::collections::HashMap;
// 引入第三方的哈希函数
use twox_hash::XxHash64;

// 指定HashMap使用第三方的哈希函数XxHash64
let mut hash: HashMap<_, _, BuildHasherDefault<XxHash64>> = Default::default();
hash.insert(42, "the answer");
assert_eq!(hash.get(&42), Some(&"the answer"));

在1.36版本前,Rust默认哈希函数使用的是SipHash算法,性能较为低下,但是从1.36版本开始, 替换为AHash算法,要快得多,但是在安全性上确实不及老版本,因此在你决定替换哈希算法或者哈希库之前,请务必进行性能测试,现在的标准库性能着实相当不错!

最后,如果你想要了解HashMap更多的用法,请参见本书的标准库解析章节:HashMap常用方法

以上是关于Rust语言圣经33 - HashMap的主要内容,如果未能解决你的问题,请参考以下文章

Rust学习教程33 - HashMap

Rust学习教程33 - HashMap

Rust学习教程33 - HashMap

Rust语言圣经28 - 深入类型转换

Rust语言圣经26 - 特征对象

Rust语言圣经03 - 安装Rust环境