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 使用指南 有按迭代器链重写索引循环的重构示例,正好与本篇呼应,能把集合操作读起来更顺。