Vec 与 HashMap:集合实战
单个变量装一个值,集合装一组值。Rust 标准库的集合不多但分工明确:Vec 是默认容器,HashMap 是键值查询,其间的取舍各有场景。这篇讲 Vec 的增长与切片关系、HashMap 的插入与查询惯用法、以及两者所有权与性能层面的实际差异。读完你能为绝大多数任务选对容器。
一、Vec:默认的黑板
Vec 是连续堆数组,几乎所有「一组东西」的需求都先用它。增长策略是分摊扩容:容量满时按约两倍重新分配,均摊下来 push 是常数时间。预知规模时 with_capacity 一次到位,能避免反复拷贝。
let mut v: Vec<i32> = Vec::with_capacity(3);
v.push(1);
v.push(2);
let third = v.get(2); // Option<&i32>,越界返回 None
let first = v[0]; // 直接索引,越界则 panic
// 遍历时修改要重建新集合
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
get 与索引两种取法的差别正是 panic 与 Result 分界的教科书例子:能预期越界就 get 配合 Option,逻辑保证不越界才直接索引。
再补两个高频技巧。其一是 vec![值; n] 快速造一个 n 个相同元素的 Vec,初始化数组比循环 push 简洁得多;其二是 extend_from_slice 或 extend 吸收另一个迭代器,比逐个 push 表意更清楚。两者都不改变所有权规则,只是把常见模式变成了方法调用。调试集合时 {:#?} 的多行调试格式比默认 {:?} 的单行更可读,元素一多就能体会到差别。
一个常见疑问是 Vec 与 Box<[T]> 的取舍:后者容量固定不可增长,换来的是少一个容量字段。尾部追加是你需要的能力就别犹豫选 Vec;一组数据从构建完成起再也不变,Box<[T]> 能省那点字段。日常代码里九成九选 Vec,特殊场景遇到再切换即可。
二、Vec 与切片的共生
Vec 是拥有所有权的载体,&Vec 自动退化成 &[T] 切片传参。函数签名永远写 &[T] 而非 &Vec<T>——这样数组、切片、Vec 全能传进来。这是最实用的设计惯例之一,初学者常因为写错它而反复改签名。
// 推荐:收一切切片来源
fn total(xs: &[i32]) -> i32 {
xs.iter().sum()
}
fn main() {
let arr = [1, 2, 3];
let v = vec![4, 5];
println!("{}", total(&arr)); // 数组也行
println!("{}", total(&v)); // Vec 也行
}
Vec 的另一个高频操作是截断与清空:truncate 保留前 n 个元素并释放尾部,clear 等于 truncate(0),drain 则在删除时把元素还给你,适合「移出并另行处理」的转移语义。
三、HashMap:键值查询
HashMap 默认用 SipHash 抗碰撞攻击,普通场景性能足够,只在 profile 显示哈希为热点时才考虑换 FxHashMap 之类。插入用 insert、查询用 get(返回 Option)、不存在时插入用 entry。
use std::collections::HashMap;
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert(String::from("林"), 10);
// entry:不存在才插入,避免「查一下再插」的两步
scores.entry(String::from("陈")).or_insert(5);
or_insert 的返回值是可变引用,配合 *e += 1 完成计数器模式,这是统计词频等任务的标准写法之一。
// 经典计数器:逐词累加
let text = "苹果 香蕉 苹果 橘子";
let mut counts: HashMap<&str, u32> = HashMap::new();
for w in text.split_whitespace() {
let e = counts.entry(w).or_insert(0);
*e += 1;
}
entry API 的价值在于一次哈希查找完成「检查插入」两件事。新手常写成 if !map.contains_key(k) { map.insert(k, v) },两次哈希且在借用规则下写得别扭;entry 把这个模式固化成了 API,记住了就不必再自己拼。
四、所有权与集合
集合默认拥有其元素:把 String push 进 Vec 后,原 String 不能再使用(move 语义)。想多个集合共享数据,要么 clone,要么用引用加生命周期,要么用 Rc/Arc 引用计数——三种办法的成本与适用场合在 Copy 与 Clone 一章有对比。
let mut names = Vec::new();
let s = String::from("林");
names.push(s); // s 被移动
// println!("{}", s); // 编译失败:use of moved value
这条限制换来的好处是集合销毁时元素被确定性释放,不存在悬挂引用。数据流需要多端共享时,明确选出共享方案比为每个集合各存一份副本更清晰。
集合与生命周期的另一层典型配合是「集合里放引用」:Vec<&str> 要求引用活得比 Vec 久,编译器会逐点检查。能用引用就先别 clone,借用检查通过即零成本;编译不过再退回分配所有权。这套「先借后 clone」的决策节奏,是写性能合格的解析与处理代码的基本功。
五、选型速查
按需求选集合:顺序可重复用 Vec;键值映射用 HashMap;需要有序键或范围查询用 BTreeMap;频繁前后端操作用 VecDeque;栈语义 push/pop 直接用 Vec 尾部。
| 需求 | 容器 | 备注 |
|---|---|---|
| 无序一组值 | Vec | 默认首选 |
| 键到值映射 | HashMap | 查询均摊常数 |
| 有序键映射 | BTreeMap | 按键排序 |
| 双端队列 | VecDeque | 头尾均摊 |
| 去重集合 | HashSet | 等价 HashMap 无值 |
| 栈 | Vec | push/pop 尾部 |
一个实践提醒:HashMap 的键如果是自定义 struct,需派生 Eq 与 Hash(String 等已自带)。忘了派生会报错说找不到 Hash 实现,照报错加上即可。
批量插入与预分配还有些讲究,集中列在下面备用:
| 操作 | 惯用法 | 说明 |
|---|---|---|
| 已知大小建 Vec | with_capacity | 避免多次扩容拷贝 |
| 批量插入 HashMap | with_capacity 加 extend | 每次插入不触发 resize |
| 合并两个 HashMap | extend | 后者覆盖同键 |
| 逐个检查再插入 | entry | 一次哈希完成检查插入 |
| 批量过滤 | retain | 原地去留一句话表达 |
另一条关于迭代删除的提醒:for 循环里直接删集合元素会引发借用冲突甚至迭代失效,正确写法是 retain(保留满足条件的)或先收集要删的键再统一移除。retain 的闭包对每个元素只调用一次,语义清晰且没有索引错位风险,是过滤_map_首选。
六、性能直觉与陷阱
Vec 与 HashMap 的批量操作差别在常数因子:HashMap 每次插入要算哈希,处理万级以上元素且只需顺序遍历时 Vec 明显更快。别因为「看着高级」就一律 HashMap,键不存在场景频率高时才值得哈希开销。
几个具体陷阱值得记下:迭代 Vec 时按索引修改会报借用冲突,用迭代器方法或先 collect 出索引集合;HashMap 的迭代顺序不确定,测试里别依赖固定顺序;Drain 与 retain 会触发析构,元素 Drop 有副作用时要注意先后。做文本工具时这些集合几乎天天见,正则表达式精进 的示例里 Vec、HashMap 与迭代器的配合是最典型的工程级用法。
内存层面还有一条经验:大 Vec 临时变量在作用域结束才释放,若某个中间结果占了几百兆又只用片刻,明确地 drop 它能立刻归还内存,而不是等到函数尾。写处理大文件的工具时这个手法立竿见影,比调整算法见效快。
延伸阅读
- 下一篇「迭代器」把集合的遍历与变换统一成一套链式语法,Vec 与 HashMap 都是支持者。
- 并发场景下的集合方案在多线程一章展开,Send 与 Sync 是那时绕不开的关键词,共享集合是最常见的并发冲突来源。
- Claude Code 使用指南 有按迭代器链重写索引循环的重构示例,正好与本篇呼应,能把集合操作读起来更顺。
