Rust 的标准集合分别表达连续序列、双端队列、唯一值和键值映射;选择时先确定是否需要顺序、唯一性或范围查询。
HashMap 与 HashSet 不承诺迭代顺序,Vec 的索引访问会在越界时 panic,集合修改还会与现有借用发生冲突。
对不可信索引使用 get,更新映射使用 entry,对外输出需要稳定顺序时选用 B 树集合或在边界处显式排序。
是什么,为什么存在
集合(collection)把数量在运行时才能确定的同类值组织起来。 数组把长度写进类型,元组可以容纳不同类型;标准集合则为增长、删除、按键查找或排队等操作提供拥有型容器。 它们管理自己的元素,并在容器离开作用域时释放这些元素。
集合类型表达的不只是存储方式,也表达调用方依赖的语义。
Vec<T> 保留插入顺序并提供整数索引,HashSet<T> 表达唯一成员,HashMap<K, V> 表达从键到值的关系。
如果键顺序或范围查询属于契约,应选择 BTreeSet<T> 或 BTreeMap<K, V>;队列通常使用 VecDeque<T>。
String 也拥有可增长的缓冲区,但它维护 UTF-8 不变量,不属于 std::collections 模块。
文本索引与切片规则应单独学习,相关主题见 rust/strings。
BinaryHeap<T> 用于按优先级取出元素,本页只把它放进选择表,不展开其堆操作。
你会在解析输入、按标识符查找记录、去重、调度任务和构造响应时遇到集合。
先把所需语义说清楚,再选类型;仅仅因为 HashMap 看起来通用就默认使用它,往往会把顺序要求藏到输出阶段。
| 需求 | 首选类型 | 类型表达的保证 |
|---|---|---|
| 保留顺序的序列 | Vec<T> | 元素按序排列,可用索引或切片访问 |
| 从两端进出 | VecDeque<T> | 具有逻辑上的队首与队尾 |
| 唯一成员 | HashSet<T> | 每个相等值最多出现一次 |
| 按键取得值 | HashMap<K, V> | 每个相等键最多关联一个值 |
| 有序键或键范围 | BTreeMap<K, V> / BTreeSet<T> | 按 Ord 顺序迭代并支持范围 |
| 每次取最高优先级 | BinaryHeap<T> | 堆顶是当前最大元素 |
工作原理
Vec<T> 拥有一段连续的元素缓冲区,并分别记录长度与容量。
长度是已经初始化的元素数,容量是当前分配在重新分配前至少可以容纳的元素数。
push 增加长度;空间不足时,向量可以申请更大的缓冲区并搬移元素。
映射与集合围绕键的相等关系工作。
HashMap 与 HashSet 使用 哈希表(hash table) ,键要实现 Eq 和 Hash,而且相等的键必须产生相同哈希值。
BTreeMap 与 BTreeSet 使用 B 树(B-tree) 组织有序键,所以键要实现 Ord。
哈希集合不提供稳定的遍历顺序。
相同输入在另一次运行、换一个集合实例或发生修改后,都不应假定得到相同顺序。
B 树集合按键的 Ord 顺序遍历,因此更适合范围查询和必须可重复的键序列。
集合拥有插入其中的值。
把 String 插入 Vec<String> 会移动这个 String,除非代码插入的是克隆值;调用 iter() 则借用元素,调用 into_iter() 会消费容器并产出拥有型元素。
具体产出类型还取决于接收者是 T、&T 还是 &mut T。
从迭代器构造集合时,collect() 需要知道目标类型。
有时赋值左侧已经提供类型,有时要写 collect::<HashSet<_>>()。
这个类型信息决定重复项是否保留、键值对如何组织,以及结果是否保留顺序。
HashMap::entry 把“键已存在”和“键不存在”表示成同一个 条目 API(Entry API) 入口。
or_insert、or_default 与 and_modify 都在这项状态判断之上工作,并返回或操作映射中的值。
因此,计数和分组不必先调用 contains_key 再做第二次查找。
示例
用 Vec 完成一条数据流水线
第一个例子丢弃无效读数,再把摄氏度转换成十分之一华氏度。
into_iter() 消费原向量,collect() 的目标类型由 Vec<i32> 标注确定。
fn main() {
let readings = vec![20, -99, 24, 18];
let normalized: Vec<i32> = readings
.into_iter()
.filter(|value| *value >= 0)
.map(|celsius| celsius * 18 + 320)
.collect();
println!("tenths Fahrenheit: {normalized:?}");
let second = normalized.get(1).copied();
println!("second: {second:?}");
}tenths Fahrenheit: [680, 752, 644]
second: Some(752)filter 的闭包接收对候选元素的引用,所以条件中解引用了 value。
map 随后取得通过筛选的 i32,结果仍按输入中有效读数的相对顺序排列。
get(1) 返回 Option<&i32>,不会因索引越界而 panic。
这里的元素实现 Copy,所以 copied() 把结果变成 Option<i32>;对 String 等非 Copy 类型,应根据需要保留借用或显式克隆。
用 HashMap::entry 计数
事件名作为借用的 &str 插入映射,因为它们都来自程序内的静态字符串。
每次 entry 调用只描述一次键,并直接取得对应计数器的可变引用。
use std::collections::HashMap;
fn main() {
let events = ["view", "click", "view", "view", "click"];
let mut counts: HashMap<&str, usize> = HashMap::new();
for event in events {
*counts.entry(event).or_insert(0) += 1;
}
// HashMap 不承诺顺序;输出前显式排序。
let mut summary: Vec<_> = counts.into_iter().collect();
summary.sort_by_key(|(event, _)| *event);
for (event, count) in summary {
println!("{event}: {count}");
}
}click: 2
view: 3or_insert(0) 在键缺失时插入 0,随后无论原来是否存在,都返回 &mut usize。
解引用这个引用后,+= 1 更新的是映射里的值,而不是临时副本。
排序发生在输出边界,而不是假装 HashMap 自带顺序。
如果整个程序都依赖键顺序,下一例中的 BTreeMap 通常能更直接地表达契约。
用 BTreeMap 查询键范围
BTreeMap 的迭代器按键排序,range 接受 Rust 的范围边界。
下面只读取编号 20 到 30 的作业,并展示 insert 返回被替换的旧值。
use std::collections::BTreeMap;
fn main() {
let mut jobs = BTreeMap::from([
(30, "running"),
(10, "done"),
(20, "queued"),
(40, "blocked"),
]);
for (id, state) in jobs.range(20..=30) {
println!("job {id}: {state}");
}
let previous = jobs.insert(20, "running");
println!("replaced: {previous:?}");
}job 20: queued
job 30: running
replaced: Some("queued")输出次序来自键的 Ord 实现,不依赖插入次序。
范围的开始与结束都包含在内,所以编号 20 和 30 都会出现。
insert 会移动新值并返回 Option<V>。
调用方可以区分首次插入与覆盖已有值;忽略返回值则表示业务逻辑不关心被替换的内容。
用 VecDeque 表达队列
队列从尾部接收任务,从头部取出任务。
VecDeque 直接提供这两个方向的操作,不需要把 Vec 的首元素反复删除。
use std::collections::VecDeque;
fn main() {
let mut queue = VecDeque::from(["parse", "index", "publish"]);
if let Some(task) = queue.pop_front() {
println!("running: {task}");
}
queue.push_back("notify");
while let Some(task) = queue.pop_front() {
println!("next: {task}");
}
println!("empty: {}", queue.is_empty());
}running: parse
next: index
next: publish
next: notify
empty: truepop_front() 返回 Option<T>,同时把元素所有权移出队列。
while let 在队列变空时自然结束,不需要先检查长度再弹出。
队列的逻辑顺序不意味着底层内存始终是一段连续切片。
需要把内容交给要求连续切片的 API 时,应使用 make_contiguous(),而不是依赖内部布局。
陷阱
修复方法: 使用 get 或 get_mut,并在 None 分支返回领域错误或跳过记录。
只有程序不变量已经证明索引有效,而且违反不变量确实应当终止当前执行时,才使用直接索引。
修复方法: 若顺序贯穿整个业务逻辑,使用 B 树集合;若只在边界需要稳定输出,则先收集并按明确键排序。 测试映射本身时,可以比较键值关系,不要比较偶然的调试字符串。
修复方法: 只读遍历使用 iter(),消费容器才使用 into_iter();字符串键通常可以直接用 &str 查找。
确实需要让新容器长期拥有独立数据时再克隆,并在类型签名中表达这个所有权边界。
修复方法: 使用 entry(key),在 Occupied 与 Vacant 状态上完成一次更新。
简单计数使用 or_insert,分组容器常可使用 or_default,存在时修改则可组合 and_modify。
修复方法: 延迟构造使用 or_insert_with(build_value),默认值就是 Default::default() 时使用 or_default()。
审查时要区分“传入一个已经算好的值”和“传入一个只在缺失时调用的闭包”。
分配与引用失效
空 Vec<T> 不必为元素分配内存,Vec::with_capacity(n) 则请求至少能容纳 n 个元素的空间。
容量可能大于请求值,扩容策略也不是调用方可以依赖的接口保证。
因此,不应把某次运行观察到的容量序列写成业务逻辑。
当 len() < capacity() 时,继续 push 不会因容量不足而重新分配。
但借用检查依据的是程序必须始终安全的条件,不会因为一次测试“刚好没有搬移”就允许在 push 后继续使用元素引用。
需要修改集合时,应结束旧借用,并在修改后重新取得引用。
重新分配不是唯一的身份问题。
Vec::insert、remove 和 swap_remove 会改变部分元素的索引;保存索引虽然避开悬垂引用,却不能保证该索引仍代表同一业务实体。
若身份由记录编号决定,应保存编号并重新查找,不能把当前位置当成永久身份。
reserve 用于提前保证附加容量,reserve_exact 只表达“不刻意多预留”的请求,并不承诺分配器返回精确字节数。
预留容量应来自已知的输入上限或格式长度,不能用未经验证的外部数量直接驱动巨大分配。
键的约束
HashMap 要求 k1 == k2 时 hash(k1) == hash(k2)。
自定义键若让 Eq 与 Hash 使用不同字段,相等键可能落入不一致的哈希路径;这属于逻辑错误。
通常应让同一次 derive 同时生成 PartialEq、Eq 和 Hash,或逐字段审查手写实现。
BTreeMap 依赖 Ord 给出全序。
如果 cmp 判断两个键相等,Eq 也应把它们视为相等;在键存入集合后改变参与比较或哈希的内容,同样会破坏集合的逻辑。
安全 Rust 会限制常见的直接修改路径,但内部可变性仍可能制造这类错误。
拥有型字符串键不要求每次查询都创建拥有型字符串。
标准映射的查找方法支持借用形式:HashMap<String, V> 通常可用 &str 调用 get,BTreeMap<String, V> 也可按兼容的借用键查询。
这能让存储保持拥有型,而读取接口接收轻量借用。
不要丢掉修改结果
许多集合修改方法会返回业务上有用的信息。
HashSet::insert 返回布尔值,说明值是否原本不存在;HashMap::insert 返回被替换的 Option<V>,而 remove 返回被移出的值。
这些返回值能区分新增、重复与替换,无需提前再查一次集合。 如果调用方确实不关心结果,可以显式忽略;但审查生成代码时,应先确认被丢掉的信息不属于业务规则。
采用返回值还能让状态转换留在一次方法调用附近。 与“先检查、再修改”相比,这种写法更容易看出重复输入、缺失键和覆盖旧值分别走哪条路径。
有序与无序边界
“无序”不等于随机打乱,也不等于每次一定变化。 它表示 API 没有承诺观察顺序,所以调用方不能据此赋予第一个或最后一个元素业务含义。 这一区别对测试很重要:偶然稳定仍然不是保证。
BTreeMap::range 根据键顺序返回指定边界内的条目。
半开范围、闭区间和无界端点使用标准范围语法或 Bound 表示;边界若本身不合法,某些组合会 panic,所以动态构造边界时也要验证顺序。
只在展示层需要稳定顺序时,把哈希映射条目收集进 Vec 再排序,通常比让整个内部模型承担顺序语义更清楚。
反过来,如果调用方频繁询问键范围或最小键,BTreeMap 能直接表达这些操作。
选择依据是契约,不是没有基准数据支撑的速度判断。
Entry 是一次状态分支
entry(key) 会取得映射的可变借用,并返回 Occupied 或 Vacant。
已占用条目可以读取、修改或移除当前值,空缺条目可以插入值;两种状态都持有完成相应操作所需的上下文。
这就是 entry 能在一次状态分支中完成更新的原因。
and_modify(f).or_insert(v) 先在键存在时运行 f,否则插入 v。
不过,v 仍是普通实参,会在调用前求值;需要惰性初始化时应改用 or_insert_with。
如果插入值的构造依赖条目自己的键,可使用提供键引用的相应惰性方法。
Entry 只解决单个普通映射借用期间的状态分支,不会让跨线程更新自动变成原子操作。
共享可变映射仍需要互斥锁、分片并发容器或其他同步设计;检查与更新的原子边界由外层同步机制决定。
VecDeque 的逻辑连续性
VecDeque 对调用方呈现一个连续的逻辑顺序,但环形缓冲区可能在物理内存中分成前后两段。
as_slices() 因而返回两个切片,它们按逻辑顺序拼接后才是完整内容。
代码不能假定第二个切片总为空。
make_contiguous() 会重新排列内容,使当前元素可作为单个可变切片访问。
只有确实要调用切片 API、原地排序或传递连续区域时才需要这样做;普通队列操作直接使用 push_back 与 pop_front 即可。
collect 的目标决定语义
迭代器只描述如何逐项产生值,不决定最终容器。
同一串项目收集到 Vec 时保留每一项和产出顺序,收集到 HashSet 时相等项会合并且不承诺迭代顺序,收集到 BTreeSet 时相等项会合并并按 Ord 排列。
编译器无法从后续使用推断出唯一容器时,会要求更多类型信息。
可以在变量上写完整类型,也可以使用 collect::<Vec<_>>() 形式;下划线只让编译器推断元素类型,不会省略容器选择。
这也是审查生成代码时容易漏掉的语义变化。
把 collect::<Vec<_>>() 改成 collect::<HashSet<_>>(),会同时改变存储方式、重复项处理与顺序契约。
4个问题 · 1 道输出预测题 · 1 道找错题