集合

根据顺序、唯一性、键查找与范围查询选择 Rust 标准集合,并正确处理所有权、Entry API 和迭代顺序。

难度 进阶 时长 标准深度约 10分钟
版本 Rust 1.98
what

Rust 的标准集合分别表达连续序列、双端队列、唯一值和键值映射;选择时先确定是否需要顺序、唯一性或范围查询。

trap

HashMapHashSet 不承诺迭代顺序,Vec 的索引访问会在越界时 panic,集合修改还会与现有借用发生冲突。

fix

对不可信索引使用 get,更新映射使用 entry,对外输出需要稳定顺序时选用 B 树集合或在边界处显式排序。

是什么,为什么存在

集合(collection)把数量在运行时才能确定的同类值组织起来。 数组把长度写进类型,元组可以容纳不同类型;标准集合则为增长、删除、按键查找或排队等操作提供拥有型容器。 它们管理自己的元素,并在容器离开作用域时释放这些元素。

集合类型表达的不只是存储方式,也表达调用方依赖的语义。 Vec<T> 保留插入顺序并提供整数索引,HashSet<T> 表达唯一成员,HashMap<K, V> 表达从键到值的关系。 如果键顺序或范围查询属于契约,应选择 BTreeSet<T>BTreeMap<K, V>;队列通常使用 VecDeque<T>

String 也拥有可增长的缓冲区,但它维护 UTF-8 不变量,不属于 std::collections 模块。 文本索引与切片规则应单独学习,相关主题见 rust/stringsBinaryHeap<T> 用于按优先级取出元素,本页只把它放进选择表,不展开其堆操作。

你会在解析输入、按标识符查找记录、去重、调度任务和构造响应时遇到集合。 先把所需语义说清楚,再选类型;仅仅因为 HashMap 看起来通用就默认使用它,往往会把顺序要求藏到输出阶段。

需求首选类型类型表达的保证
保留顺序的序列Vec<T>元素按序排列,可用索引或切片访问
从两端进出VecDeque<T>具有逻辑上的队首与队尾
唯一成员HashSet<T>每个相等值最多出现一次
按键取得值HashMap<K, V>每个相等键最多关联一个值
有序键或键范围BTreeMap<K, V> / BTreeSet<T>Ord 顺序迭代并支持范围
每次取最高优先级BinaryHeap<T>堆顶是当前最大元素

工作原理

Vec<T> 拥有一段连续的元素缓冲区,并分别记录长度与容量。 长度是已经初始化的元素数,容量是当前分配在重新分配前至少可以容纳的元素数。 push 增加长度;空间不足时,向量可以申请更大的缓冲区并搬移元素。

映射与集合围绕键的相等关系工作。 HashMapHashSet 使用 哈希表(hash table) ,键要实现 EqHash,而且相等的键必须产生相同哈希值。 BTreeMapBTreeSet 使用 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_insertor_defaultand_modify 都在这项状态判断之上工作,并返回或操作映射中的值。 因此,计数和分组不必先调用 contains_key 再做第二次查找。

示例

Vec 完成一条数据流水线

第一个例子丢弃无效读数,再把摄氏度转换成十分之一华氏度。 into_iter() 消费原向量,collect() 的目标类型由 Vec<i32> 标注确定。

normalize_readings.rs
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 的闭包接收对候选元素的引用,所以条件中解引用了 valuemap 随后取得通过筛选的 i32,结果仍按输入中有效读数的相对顺序排列。

get(1) 返回 Option<&i32>,不会因索引越界而 panic。 这里的元素实现 Copy,所以 copied() 把结果变成 Option<i32>;对 String 等非 Copy 类型,应根据需要保留借用或显式克隆。

HashMap::entry 计数

事件名作为借用的 &str 插入映射,因为它们都来自程序内的静态字符串。 每次 entry 调用只描述一次键,并直接取得对应计数器的可变引用。

count_events.rs
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: 3

or_insert(0) 在键缺失时插入 0,随后无论原来是否存在,都返回 &mut usize。 解引用这个引用后,+= 1 更新的是映射里的值,而不是临时副本。

排序发生在输出边界,而不是假装 HashMap 自带顺序。 如果整个程序都依赖键顺序,下一例中的 BTreeMap 通常能更直接地表达契约。

BTreeMap 查询键范围

BTreeMap 的迭代器按键排序,range 接受 Rust 的范围边界。 下面只读取编号 2030 的作业,并展示 insert 返回被替换的旧值。

job_ranges.rs
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 实现,不依赖插入次序。 范围的开始与结束都包含在内,所以编号 2030 都会出现。

insert 会移动新值并返回 Option<V>。 调用方可以区分首次插入与覆盖已有值;忽略返回值则表示业务逻辑不关心被替换的内容。

VecDeque 表达队列

队列从尾部接收任务,从头部取出任务。 VecDeque 直接提供这两个方向的操作,不需要把 Vec 的首元素反复删除。

task_queue.rs
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: true

pop_front() 返回 Option<T>,同时把元素所有权移出队列。 while let 在队列变空时自然结束,不需要先检查长度再弹出。

队列的逻辑顺序不意味着底层内存始终是一段连续切片。 需要把内容交给要求连续切片的 API 时,应使用 make_contiguous(),而不是依赖内部布局。

陷阱

修复方法: 使用 getget_mut,并在 None 分支返回领域错误或跳过记录。 只有程序不变量已经证明索引有效,而且违反不变量确实应当终止当前执行时,才使用直接索引。

修复方法: 若顺序贯穿整个业务逻辑,使用 B 树集合;若只在边界需要稳定输出,则先收集并按明确键排序。 测试映射本身时,可以比较键值关系,不要比较偶然的调试字符串。

修复方法: 只读遍历使用 iter(),消费容器才使用 into_iter();字符串键通常可以直接用 &str 查找。 确实需要让新容器长期拥有独立数据时再克隆,并在类型签名中表达这个所有权边界。

修复方法: 使用 entry(key),在 OccupiedVacant 状态上完成一次更新。 简单计数使用 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::insertremoveswap_remove 会改变部分元素的索引;保存索引虽然避开悬垂引用,却不能保证该索引仍代表同一业务实体。 若身份由记录编号决定,应保存编号并重新查找,不能把当前位置当成永久身份。

reserve 用于提前保证附加容量,reserve_exact 只表达“不刻意多预留”的请求,并不承诺分配器返回精确字节数。 预留容量应来自已知的输入上限或格式长度,不能用未经验证的外部数量直接驱动巨大分配。

键的约束

HashMap 要求 k1 == k2hash(k1) == hash(k2)。 自定义键若让 EqHash 使用不同字段,相等键可能落入不一致的哈希路径;这属于逻辑错误。 通常应让同一次 derive 同时生成 PartialEqEqHash,或逐字段审查手写实现。

BTreeMap 依赖 Ord 给出全序。 如果 cmp 判断两个键相等,Eq 也应把它们视为相等;在键存入集合后改变参与比较或哈希的内容,同样会破坏集合的逻辑。 安全 Rust 会限制常见的直接修改路径,但内部可变性仍可能制造这类错误。

拥有型字符串键不要求每次查询都创建拥有型字符串。 标准映射的查找方法支持借用形式:HashMap<String, V> 通常可用 &str 调用 getBTreeMap<String, V> 也可按兼容的借用键查询。 这能让存储保持拥有型,而读取接口接收轻量借用。

不要丢掉修改结果

许多集合修改方法会返回业务上有用的信息。 HashSet::insert 返回布尔值,说明值是否原本不存在;HashMap::insert 返回被替换的 Option<V>,而 remove 返回被移出的值。

这些返回值能区分新增、重复与替换,无需提前再查一次集合。 如果调用方确实不关心结果,可以显式忽略;但审查生成代码时,应先确认被丢掉的信息不属于业务规则。

采用返回值还能让状态转换留在一次方法调用附近。 与“先检查、再修改”相比,这种写法更容易看出重复输入、缺失键和覆盖旧值分别走哪条路径。

有序与无序边界

“无序”不等于随机打乱,也不等于每次一定变化。 它表示 API 没有承诺观察顺序,所以调用方不能据此赋予第一个或最后一个元素业务含义。 这一区别对测试很重要:偶然稳定仍然不是保证。

BTreeMap::range 根据键顺序返回指定边界内的条目。 半开范围、闭区间和无界端点使用标准范围语法或 Bound 表示;边界若本身不合法,某些组合会 panic,所以动态构造边界时也要验证顺序。

只在展示层需要稳定顺序时,把哈希映射条目收集进 Vec 再排序,通常比让整个内部模型承担顺序语义更清楚。 反过来,如果调用方频繁询问键范围或最小键,BTreeMap 能直接表达这些操作。 选择依据是契约,不是没有基准数据支撑的速度判断。

Entry 是一次状态分支

entry(key) 会取得映射的可变借用,并返回 OccupiedVacant。 已占用条目可以读取、修改或移除当前值,空缺条目可以插入值;两种状态都持有完成相应操作所需的上下文。 这就是 entry 能在一次状态分支中完成更新的原因。

and_modify(f).or_insert(v) 先在键存在时运行 f,否则插入 v。 不过,v 仍是普通实参,会在调用前求值;需要惰性初始化时应改用 or_insert_with。 如果插入值的构造依赖条目自己的键,可使用提供键引用的相应惰性方法。

Entry 只解决单个普通映射借用期间的状态分支,不会让跨线程更新自动变成原子操作。 共享可变映射仍需要互斥锁、分片并发容器或其他同步设计;检查与更新的原子边界由外层同步机制决定。

VecDeque 的逻辑连续性

VecDeque 对调用方呈现一个连续的逻辑顺序,但环形缓冲区可能在物理内存中分成前后两段。 as_slices() 因而返回两个切片,它们按逻辑顺序拼接后才是完整内容。 代码不能假定第二个切片总为空。

make_contiguous() 会重新排列内容,使当前元素可作为单个可变切片访问。 只有确实要调用切片 API、原地排序或传递连续区域时才需要这样做;普通队列操作直接使用 push_backpop_front 即可。

collect 的目标决定语义

迭代器只描述如何逐项产生值,不决定最终容器。 同一串项目收集到 Vec 时保留每一项和产出顺序,收集到 HashSet 时相等项会合并且不承诺迭代顺序,收集到 BTreeSet 时相等项会合并并按 Ord 排列。

编译器无法从后续使用推断出唯一容器时,会要求更多类型信息。 可以在变量上写完整类型,也可以使用 collect::<Vec<_>>() 形式;下划线只让编译器推断元素类型,不会省略容器选择。

这也是审查生成代码时容易漏掉的语义变化。 把 collect::<Vec<_>>() 改成 collect::<HashSet<_>>(),会同时改变存储方式、重复项处理与顺序契约。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 所有权借用规则
下一篇 迭代器 字符串 From into 即将上线 闭包
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?