集合

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

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

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

trap

HashMap与HashSet不承诺迭代顺序,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/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>标注确定。

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的闭包接收对候选元素的引用,所以条件中解引用了value。 map随后取得通过筛选的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的范围边界。 下面只读取编号20到30的作业,并展示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实现,不依赖插入次序。 范围的开始与结束都包含在内,所以编号20和30都会出现。

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(),而不是依赖内部布局。

陷阱

修复方法: 使用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道找错题

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