news 2026/10/6 1:47:58

Rust By Example 精讲:HashMap 与 HashSet 键值容器实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rust By Example 精讲:HashMap 与 HashSet 键值容器实战指南
  • 文档
  • 教程

【免费下载链接】rust-by-example

Learn Rust with examples (Live code editor included)

项目地址:https://gitcode.com/gh_mirrors/ru/rust-by-example
点击查看免费下载

HashMap是 Rust 标准库中最常用的键值存储容器,它以哈希表为底层实现,允许以任意实现Eq与Hashtrait 的类型(如布尔值、整数、字符串)作为键来存取数据。本文以 rust-by-example 仓库中 HashMap 章节 为主体,结合其下的 自定义键类型 与 HashSet 章节,完整讲解 HashMap 的创建、增删查改、迭代与自定义键实战,并延伸介绍基于 HashMap 实现的去重集合HashSet及其四大集合运算,帮助你掌握在 Rust 项目中正确使用哈希容器的完整方法。

HashMap:以键取值,而不是以整数索引取值

向量(Vec)通过整数下标存取元素,而HashMap通过键(key)来存取值(value)。这是两者最本质的区别:

  • Vec的下标是连续的整数,元素在内存中按顺序排列;
  • HashMap的键可以是布尔值、整数、字符串,或者任何实现了Eq和Hash两个 trait 的类型。

与向量一样,HashMap也是可增长的(growable);不同的是,HashMap在拥有多余空间时还可以自动收缩(shrink),从而更高效地利用内存。这一点在标准库文档中亦有说明,关于底层实现可进一步查阅 Rust 官方std::collections文档。

在 Rust By Example 的目录结构 中,HashMap 位于 标准库类型章节 之下,紧随Box、Vec、String等标准容器之后,与其并列的子章节还包括 自定义键类型 与 HashSet。

键的约束:Eq+Hash

HashMap之所以要求键实现Eq和Hash,是因为哈希表的工作方式:

  1. Hash:把键映射为一个哈希值,用于快速定位桶(bucket)的位置;
  2. Eq:在哈希值相同的碰撞场景下,用相等性判断确认两个键是否真的是同一个键。

本仓库原文明确指出:HashMap的键可以是布尔值、整数、字符串,或任何实现了Eq和Hashtrait 的类型,并提示"更多内容见下一节"(即 自定义键类型)。

创建 HashMap:new与with_capacity

创建HashMap有两种典型方式:

  • HashMap::new():使用默认初始容量创建,官方推荐使用;
  • HashMap::with_capacity(uint):以指定容量(usize类型)创建,适合在预先知道元素规模时使用,可减少扩容带来的重哈希开销。

两者的共同点是都返回一个可增长的HashMap。与Vec的 vec! 宏与动态扩容机制 类似,HashMap也采用"容量-长度"管理:当元素数量逼近容量时触发扩容,with_capacity正是通过提前分配空间来摊平这类开销。

实战示例:用 HashMap 实现通讯录

下面这段完整示例来自 src/std/hash.md,实现了按人名存储电话号码并拨打的通讯录逻辑:

use std::collections::HashMap; fn call(number: &str) -> &str { match number { "798-1364" => "We're sorry, the call cannot be completed as dialed. Please hang up and try again.", "645-7689" => "Hello, this is Mr. Awesome's Pizza. My name is Fred. What can I get for you today?", _ => "Hi! Who is this again?" } } fn main() { let mut contacts = HashMap::new(); contacts.insert("Daniel", "798-1364"); contacts.insert("Ashley", "645-7689"); contacts.insert("Katie", "435-8291"); contacts.insert("Robert", "956-1745"); // Takes a reference and returns Option<&V> match contacts.get(&"Daniel") { Some(&number) => println!("Calling Daniel: {}", call(number)), _ => println!("Don't have Daniel's number."), } // `HashMap::insert()` returns `None` // if the inserted value is new, `Some(value)` otherwise contacts.insert("Daniel", "164-6743"); match contacts.get(&"Ashley") { Some(&number) => println!("Calling Ashley: {}", call(number)), _ => println!("Don't have Ashley's number."), } contacts.remove(&"Ashley"); // `HashMap::iter()` returns an iterator that yields // (&'a key, &'a value) pairs in arbitrary order. for (contact, &number) in contacts.iter() { println!("Calling {}: {}", contact, call(number)); } }

这段代码涵盖了HashMap最核心的四个操作,值得逐点拆解:

get:只取引用,返回Option<&V>

contacts.get(&"Daniel")接收的是键的引用,返回Option<&V>:

  • 键存在时返回Some(&value);
  • 键不存在时返回None。

因为返回的是引用而非所有权,所以示例中用match+ 解引用模式Some(&number)把&str解出来传给call。

insert:插入并返回旧值

insert的返回值揭示了键是否已存在:

  • 插入的是一个新键时,返回None;
  • 插入的键已存在时,返回Some(旧值)(旧值被新值替换)。

示例中第二次contacts.insert("Daniel", "164-6743")正是因为此前已存在Daniel键,所以会覆盖旧号码——这正是"以键取值"语义的体现。

remove:按键删除

contacts.remove(&"Ashley")接收键的引用,将Ashley及其号码一并从表中移除。此后若再对该键get,将得到None。

iter:按任意顺序遍历

contacts.iter()返回一个迭代器,逐个产出(&'a K, &'a V)形式的键值引用对,顺序是任意的(哈希表不保证插入顺序)。示例中通过for (contact, &number)同时解构出键和值:

for (contact, &number) in contacts.iter() { println!("Calling {}: {}", contact, call(number)); }

对于需要保证插入顺序的场景,可以参考仓库中提到的替代方案(如BTreeSet/BTreeMap等有序容器)。

常用操作速查

综合原文档与标准库行为,HashMap的常用操作可归纳如下:

方法签名要点行为与返回值
insert(k, v)传入键值新键返回None,已存在返回Some(旧值)
get(&k)传键引用返回Option<&V>
remove(&k)传键引用移除并返回Option<V>(被移除的值)
iter()借出按任意顺序产出(&K, &V)对
contains_key(&k)传键引用返回bool,判断键是否存在
len()借出返回当前存储的键值对数量
with_capacity(n)传入容量以指定初始容量创建

需要说明的是:HashMap的迭代顺序任意,因此依赖顺序的展示应先收集排序;其容量管理(增长与收缩)由标准库自动完成,开发者一般只需关注with_capacity的初始预留。

自定义/替代键类型:不限于字符串和整数

原文档明确指出,任何实现Eq与Hash的类型都可以作为HashMap的键,详见 自定义键类型。可用的键类型包括:

  • bool:可行但意义有限(只有两个可能的键);
  • int、uint及所有整型变体;
  • String和&str:实用技巧——可以用String作键、用&str调用.get()(String与&str的哈希/相等实现兼容,从而避免在查找时分配新的String)。

浮点数为什么不能作键?

f32和f64没有实现Hash。原因正如原文档所述:浮点数存在精度误差(floating-point precision errors),直接以浮点值作为哈希键会极其容易出错——两个在数学上"相等"的浮点数可能因为舍入差异而哈希不同,或反之。

容器类型的可哈希性

所有集合类,只要其内部元素类型分别实现了Eq和Hash,则集合本身也会实现Eq和Hash。例如Vec<T>在T实现Hash时也会实现Hash,因此Vec<T>这类复合结构也能成为键。

一行代码让自定义类型成为键

对自定义类型而言,只需一行派生:

#[derive(PartialEq, Eq, Hash)]

编译器会为你生成完整的实现(其中Eq要求类型先实现PartialEq,所以两者需要一起派生)。这一机制与仓库 derive 章节 中"编译器可通过#[derive]为部分 trait 提供基础实现"的描述一致——可派生的 trait 包括Eq、PartialEq、Clone、Copy、Hash、Default、Debug等。如果需要更精细的控制,也可以手写Eq和/或Hash的实现。

完整实战:用 struct 作为键的登录系统

下面这段来自 alt_key_types.md 的示例,演示了如何用自定义struct作为键,构建一个简单的用户登录校验系统:

use std::collections::HashMap; // Eq requires that you derive PartialEq on the type. #[derive(PartialEq, Eq, Hash)] struct Account<'a>{ username: &'a str, password: &'a str, } struct AccountInfo<'a>{ name: &'a str, email: &'a str, } type Accounts<'a> = HashMap<Account<'a>, AccountInfo<'a>>; fn try_logon<'a>(accounts: &Accounts<'a>, username: &'a str, password: &'a str){ println!("Username: {}", username); println!("Password: {}", password); println!("Attempting logon..."); let logon = Account { username, password, }; match accounts.get(&logon) { Some(account_info) => { println!("Successful logon!"); println!("Name: {}", account_info.name); println!("Email: {}", account_info.email); }, _ => println!("Login failed!"), } } fn main(){ let mut accounts: Accounts = HashMap::new(); let account = Account { username: "j.everyman", password: "password123", }; let account_info = AccountInfo { name: "John Everyman", email: "j.everyman@email.com", }; accounts.insert(account, account_info); try_logon(&accounts, "j.everyman", "psasword123"); try_logon(&accounts, "j.everyman", "password123"); }

这个例子的核心价值在于:

  1. 组合键:Account { username, password }作为一个整体参与哈希与相等比较,登录时构造同构的临时Account去查询;
  2. 类型别名:type Accounts<'a> = HashMap<Account<'a>, AccountInfo<'a>>让复杂签名变得可读;
  3. 两次调用对比:第一次密码拼错(psasword123)查询失败,第二次密码正确(password123)查询成功,直观展示get基于Eq精确匹配的语义。

HashSet:只关心键的去重集合

如果说HashMap存储键值对,那么HashSet就是"只关心键、不关心值"的集合。正如 HashSet 章节 所说:HashSet<T>实质上只是HashMap<T, ()>的包装——值类型被占位为单元类型()。

这带来的核心契约是:HashSet保证集合内没有重复元素,这是任何集合(set)类型都要满足的约定。当插入一个已存在的值(新旧值相等且哈希相同)时,新值会替换旧值。

"为什么不用Vec存键?"——因为去重是HashSet的天然能力,你无需自己写contains检查,它特别适合"每样东西只保留一份"或"判断是否已拥有某物"的场景。仓库原文同时提示,Rust 还提供有序替代实现BTreeSet。

四大集合运算

HashSet有四种主要操作,全部返回迭代器(需配合.collect()收集为Vec等容器):

操作语义
union两个集合中所有不重复元素(并集)
difference只在第一个集合、不在第二个集合的元素(差集)
intersection同时出现在两个集合中的元素(交集)
symmetric_difference出现在其中一个集合、但不同时出现在两个集合的元素(对称差)

完整示例:集合运算演练

下面的示例改编自标准库文档,完整演示上述四种运算(摘自 hashset.md):

use std::collections::HashSet; fn main() { let mut a: HashSet<i32> = vec![1i32, 2, 3].into_iter().collect(); let mut b: HashSet<i32> = vec![2i32, 3, 4].into_iter().collect(); assert!(a.insert(4)); assert!(a.contains(&4)); // `HashSet::insert()` returns false if // there was a value already present. assert!(b.insert(4), "Value 4 is already in set B!"); // FIXME ^ Comment out this line b.insert(5); // If a collection's element type implements `Debug`, // then the collection implements `Debug`. // It usually prints its elements in the format `[elem1, elem2, ...]` println!("A: {:?}", a); println!("B: {:?}", b); // Print [1, 2, 3, 4, 5] in arbitrary order println!("Union: {:?}", a.union(&b).collect::<Vec<&i32>>()); // This should print [1] println!("Difference: {:?}", a.difference(&b).collect::<Vec<&i32>>()); // Print [2, 3, 4] in arbitrary order. println!("Intersection: {:?}", a.intersection(&b).collect::<Vec<&i32>>()); // Print [1, 5] println!("Symmetric Difference: {:?}", a.symmetric_difference(&b).collect::<Vec<&i32>>()); }

这个例子值得注意的细节:

  1. 从迭代器收集:vec![1i32, 2, 3].into_iter().collect()演示了Vec到HashSet的经典转换(collect依赖目标类型推断,因此a、b必须显式标注HashSet<i32>);
  2. insert 的返回值:a.insert(4)因 4 是新元素返回true;b.insert(4)因 4 已存在返回false,示例中用assert!演示了这一行为(注释掉该行即可修复);
  3. Debug 输出:只要元素类型实现Debug,集合就能以[elem1, elem2, ...]格式打印——这与仓库 print_debug 章节 中"所有std类型都可用{:?}打印"的说明一致;
  4. 运算结果需收集:四种运算返回迭代器(产出&i32引用),用.collect::<Vec<&i32>>()收集成Vec后再println!打印;并集、交集与对称差的元素顺序都是任意的,只有差集[1]是确定结果。

在 RBE 项目中的学习路径与延伸阅读

在 SUMMARY.md 中,与哈希容器直接相关的内容编排如下,可作为完整学习路径:

  • 标准库类型总览:了解Box、Vec、String、Option、Result等标准容器的定位;
  • HashMap(本文主体)→ 自定义键类型 → HashSet:从键值存储到去重集合的递进;
  • 向量 Vec:对比"按索引取值"与"按键取值"两种容器模型的差异;
  • derive 派生:理解Eq、Hash、Debug等 trait 自动实现的机制;
  • Debug 格式化:掌握{:?}与{:#?}的打印方式,便于调试HashMap/HashSet。

值得一提的是,本仓库所有rust,editable代码块都可在在线编辑器中直接运行(见 book.toml 中[output.html.playpen]的editable = true配置),建议在阅读本文时亲手运行并修改示例,观察insert返回值、get的Option结果以及四种集合运算输出的变化,这是巩固哈希容器知识最快的方式。

小结

本文以 HashMap 章节 为骨架,系统梳理了 Rust 哈希容器的核心要点:

  • HashMap以键取值,键必须实现Eq与Hash;
  • 通过new/with_capacity创建,通过insert/get/remove/iter完成增删查遍历;
  • 浮点数因精度问题不适合作键,Vec等容器在元素可哈希时可作复合键;
  • 自定义类型只需#[derive(PartialEq, Eq, Hash)]一行即可作为键;
  • HashSet<T>是HashMap<T, ()>的包装,天然去重,并提供union、difference、intersection、symmetric_difference四种集合运算。

掌握这些能力,你就能够在 Rust 项目中安全、高效地组织键值数据与去重集合,并清楚知道何时用HashMap、何时用HashSet、何时改用有序的BTree系列容器。

  • 文档
  • 教程

【免费下载链接】rust-by-example

Learn Rust with examples (Live code editor included)

项目地址:https://gitcode.com/gh_mirrors/ru/rust-by-example
点击查看免费下载
上一篇:doocs/leetcode 题解精讲:面试题 02.05 链表求和(逆序存储数位的模拟加法实现)
下一篇:Pegdown完全指南:Java开发者必备的Markdown解析神器

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 1:45:34

PCB制造全流程解析:从设计到量产的关键工艺与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:45:03

RDK X5部署YOLOv5实战:从pt到bin的完整转换链详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:45:02

MPQ82D00GQT寄存器配置烧录原理与三重验证实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:44:24

Versal VD100实战:PL基础工程、CIPS集成与AXI NoC调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华