你正在写一个批量导入程序:先把订单编号读进列表,再记住第一个编号,接着继续追加数据。代码看起来没什么问题,编译器却在 push 那一行把你拦住了:
fn main() {
let mut order_ids = vec![1001, 1002, 1003];
let first = &order_ids[0];
order_ids.push(1004);
println!("首个订单:{first}");
}你看到的核心错误会是:
error[E0502]: cannot borrow `order_ids` as mutable because it is also borrowed as immutable第一次遇到它,很容易觉得编译器管得太宽:我只是往末尾加一个元素,为什么连开头的引用也不能留?问题藏在集合的“可增长”三个字里。Vec 的元素连续放在一块内存中;如果原来的空间塞满了,push 可能申请一块更大的空间,把已有元素搬过去,再释放旧空间。这样一来,之前指向旧位置的 first 就会失效。
编译器不知道这一次运行时会不会扩容,也不会拿“当前容量刚好够”当安全承诺。它选择在编译期阻止风险。这个搭档确实严格,但它阻止的正是那类在别的语言里可能偶尔出现、很难复现的悬垂引用问题。
集合是 Rust 所有权规则最容易露出棱角的地方。容器会增长、缩小和搬动元素;元素也会被借用、修改或转移。只记住几个方法名不够,你得同时看清三件事:数据怎么放、谁拥有数据、一次操作会不会让已有引用失效。这一章就沿着这三条线,把最常用的 Vec<T>、String、HashMap<K, V> 和 HashSet<T> 串起来。
Vec<T>:一排可以扩建的储物格如果你需要按顺序保存一批同类型数据,通常先想到 Vec<T> 就对了。它像一排编号连续的储物格:第 0 格、第 1 格、第 2 格紧挨着,所以按下标取元素很快,顺序遍历也很适合处理器缓存。它又比固定长度数组灵活,因为元素数量可以在运行时变化。
“连续”既是它快的原因,也是它在中间插入、删除时要付出代价的原因。你在第 1 格插入新元素,后面的元素就得依次向后挪;删掉第 1 格,后面的元素又要向前补位。集合没有神奇地消除成本,只是把不同操作的成本放在了不同位置。
Vec::new() 创建空向量,vec![] 宏适合直接写出初始内容,collect 则常用于把迭代器产生的值收集起来:
fn main() {
let mut temperatures = Vec::new();
temperatures.push(21);
temperatures.push(24);
let retries = vec![0; 4];
let squares: Vec<i32> = (1..=4
[21, 24]
[0, 0, 0, 0]
[1, 4, 9, 16]空向量里没有元素可供编译器推断类型,因此下面这句单独出现时信息不够:
let values = Vec::new();你可以写成 let values: Vec<u64> = Vec::new(),也可以稍后 push 一个能确定类型的值。类型一旦确定,整个 Vec<T> 只能装同一种 T。如果确实要存储几种形态不同但业务上属于同一类的数据,通常用枚举把差异包起来,而不是期待向量忽略类型。
len 和 capacity 不是一回事len() 是已经放入的元素数量,capacity() 是当前这块内存不重新分配时至少能容纳的元素数量。一个长度为 3、容量为 8 的 Vec<i32>,表示前 3 个位置已有值,还有空间可供后续追加。

fn main() {
let mut events = Vec::with_capacity(8);
events.extend(["login", "view", "logout"]);
println!("len={}", events.len());
println!("capacity_at_least_8={}", events.capacity() >= 8);
}len=3
capacity_at_least_8=trueVec::with_capacity(8) 表示预留至少八个元素的位置,不表示已经有八个元素。此时 events[0] 仍然越界,因为长度是 0,预留出来的空间还不是已经初始化的元素。
当 len == capacity 后继续 push,向量会自动扩容。扩容通常会多申请一些空间,以便把多次末尾追加的平均成本降下来;但具体申请多少属于实现策略,不该写进业务逻辑。不要假设容量一定翻倍,也不要写测试要求每次得到某个固定容量。你真正可以依赖的是:容量不会小于长度,并且在容量足够时,末尾追加不需要因为容量不足而重新分配。

如果你大致知道数据量,可以预留容量:
fn parse_rows(input: &str) -> Vec<&str> {
let estimated = input.lines().count();
let mut rows = Vec::with_capacity(estimated);
rows.extend(input.lines().filter(|line| !
reserve(n) 保证从当前长度算起还能容纳至少 n 个额外元素,try_reserve(n) 则把分配失败作为结果交给你处理,适合不能接受直接中止的服务或工具。shrink_to_fit() 可以请求释放多余容量,但它不是“以后绝不再占更多内存”的承诺;如果马上又追加数据,下一次扩容反而可能把刚省下来的成本重新付一遍。热点路径里频繁收缩通常得不偿失。
下面的交互实验可以调整初始容量与追加次数,直接观察 len、capacity 和重新分配之间的关系。试着让追加数量刚好越过容量边界,你会更容易理解为什么容量只是预留空间。
容量是性能工具,不是正确性工具。即使你提前预留了足够空间,借用检查器也不会允许你一边保留元素引用,一边通过可变借用调用 push。编译器按类型和借用规则证明安全,不会把某次容量观察变成长期不扩容的契约。
Vec<T> 可以把元素看成一个连续片段。按下标读取通常是常数时间,末尾 push 的均摊成本也很低,pop 从末尾取走元素同样直接。连续布局还有一个很实际的好处:从头到尾扫描时,内存访问规律清楚,常常比到处跳指针的数据结构更友好。
代价也很明确:
insert 时,插入点之后的元素需要移动。remove 为了维持顺序,也要把后面的元素向前移动。这并不意味着“中间绝对不能插删”。几十个元素的配置列表,清楚的代码通常比理论上的最优结构更重要。只有数据量、操作频率和延迟真的构成问题时,才值得换结构。先量,再改,比凭感觉躲开 Vec 靠谱。
get 与首尾方法如果索引来自你能证明正确的循环或算法,下标写法简洁:
fn main() {
let ports = vec![8080, 8081, 8082];
println!("{}", ports[1]);
}但下标越界会触发 panic。索引来自用户输入、文件内容或网络请求时,get 更诚实,它返回 Option<&T>:
fn find_port(ports: &[u16], index: usize) -> Option<u16> {
ports.get(index).copied()
}
fn main() {
let ports = vec![8080, 8081, 8082];
println!("{:?}", find_port(
None
Some(8080)
Some(8082)get_mut 返回 Option<&mut T>,可以在位置有效时修改元素。first、last、first_mut 和 last_mut 则把常见的首尾访问意图直接写出来,比手动计算 len() - 1 更稳,空向量也不会发生无符号整数下溢。
末尾是 Vec 最舒服的位置。push 添加,pop 返回 Option<T> 并把值的所有权交给调用者:
fn main() {
let mut queue = vec!["build", "test"];
queue.push("deploy");
while let Some(job) = queue.pop() {
println!("处理:{job}");
}
}处理:deploy
处理:test
处理:build注意,这段代码表现得像栈,后放进去的先取出来。真要频繁从头部取任务,反复 remove(0) 会移动其余所有元素,通常应该考虑双端队列,而不是强行让 Vec 做它不擅长的工作。
中间操作要先决定是否保序:
fn main() {
let mut ids = vec![10, 20, 30, 40];
ids.insert(1, 15); // [10, 15, 20, 30, 40]
let removed = ids.remove(2); // 取走 20,后面的元素前移
let swapped = ids.swap_remove(1
removed=20, swapped=15
[10, 40, 30]swap_remove 不维护原顺序,因此能避免整段搬移。实体组件列表、待处理槽位等不关心顺序的场景很适合它;排名、时间线和界面菜单就不能偷偷使用它,否则结果虽然“元素都在”,语义已经坏了。
批量删除时还有几种更贴近意图的工具:
fn main() {
let mut scores = vec![95, 42, 88, 30, 76, 99];
scores.retain(|score| *score >= 60);
scores.truncate(3);
println!("{scores:?}");
[95, 88, 76]
len=0, reusable=trueretain 保留满足条件的元素;truncate(n) 丢弃下标 n 之后的元素;clear 把长度变为零,但通常保留已分配容量以便复用。若你既要删除一段,又要拿走被删元素,可以用 drain(range)。要用另一批元素替换某个范围,则可以看 splice。这些方法的优势不只是短,它们直接表达“筛选”“截断”“排空一段”,比手写索引循环更少出错。
sort 是稳定排序:比较结果相等的元素保留原来的相对顺序。sort_unstable 不承诺这一点,通常可以减少额外开销。是否选择稳定排序,不取决于名字听起来哪个更可靠,而取决于相等元素的原顺序有没有业务意义。
#[derive(Debug)]
struct Record {
team: &'static str,
created_at: u32,
}
fn main() {
let mut records = vec![
Record { team: "B", created_at: 3 },
Record { team: "A", created_at: 2
A 2
A 1
B 3两个 A 的相对顺序仍是 2 在 1 前。如果你本来就想按团队再按时间排,应把两个条件都写进键:sort_by_key(|r| (r.team, r.created_at)),不要把稳定性误当成第二排序字段。
浮点数有 NaN,普通偏序比较可能没有结果,因此照搬整数的比较写法会碰壁。若业务接受 IEEE 总序,可以使用 total_cmp:
fn main() {
let mut values = vec![3.5_f64, f64::NAN, -1.0, 0.0];
values.sort_by(|a, b| a.total_cmp(b));
println!("{values:?}");
}dedup 只移除相邻的重复元素。vec![1, 2, 1] 直接调用 dedup 什么也不会少,因为两个 1 没挨在一起。若你希望得到排序后的唯一值,可以先排序再去重:
fn main() {
let mut numbers = vec![3, 1, 2, 3, 2, 1];
numbers.sort_unstable();
numbers.dedup();
println!("{numbers:?}");
}[1, 2, 3]这会改变原顺序。如果要求“保留第一次出现的次序”,可以用 HashSet 记录已经见过的值,再配合 retain。这会增加一份哈希集合的内存和哈希计算,没有免费午餐:你是在用空间换顺序语义和平均较快的成员判断。
&[T] 是对连续元素的借用视图。它不拥有数据,也不会复制元素。函数只需要读取一段数据时,参数通常写 &[T],这样调用者既可以传 Vec<T>,也可以传数组或另一个切片:
fn average(values: &[i32]) -> Option<f64> {
if values.is_empty() {
return None;
}
let sum: i64 = values.iter().map(|&value| i64::from(value)).sum();
Some(30.0)切片范围同样会检查边界。&all[1..4] 包含下标 1、2、3,不包含 4。边界不可信时可以用 all.get(1..4) 得到 Option<&[T]>。
切片还提供了不少适合批处理的视图:chunks(n) 按最多 n 个元素分块,windows(n) 查看长度为 n 的滑动窗口,split 按条件分段。它们只借用原数据,不会因为“分组”就复制整批内容。
如果你确实要同时修改两个不同位置,直接写两个下标借用通常过不了:
fn swap_manual(values: &mut [i32], i: usize, j: usize) {
let left = &mut values[i];
let right = &mut values[j];
std::mem::swap(left, right);
}编译器看到的是同一个切片被可变借用两次;它不会凭运行时的 i != j 自动证明两处不重叠。简单交换直接用 values.swap(i, j)。若算法要长期持有两边的可变引用,可以先用 split_at_mut 把切片证明为两个互不重叠的部分:
fn add_first_to_last(values: &mut [i32]) {
if values.len() < 2 {
return;
}
let split = values.len() - 1;
let (head, tail) = values.split_at_mut(split);
tail[0] += head[0];
[2, 4, 10]看到 iter、iter_mut 和 into_iter 时,别把它们当成三套死记硬背的 API。先问:循环结束后,我还要不要这个集合?
fn main() {
let mut prices = vec![10, 20, 30];
for price in prices.iter() {
println!("读取:{price}");
}
for price in prices.iter_mut() {
*price += 1;
}
let
iter() 产生 &T,集合可以继续使用;iter_mut() 产生 &mut T,允许逐项修改;into_iter() 消费集合并产生 T,元素被移动出去,原变量不能再用。对于 for x in &values、for x in &mut values、for x in values,含义分别与这三种意图对应。
许多借用冲突都能靠“缩短引用存活时间”解决。开头的订单示例,如果你只需要复制第一个 u64,可以让引用立刻变成值:
fn main() {
let mut order_ids = vec![1001_u64, 1002, 1003];
let first = order_ids[0];
order_ids.push(1004);
println!("首个订单:{first}");
}如果元素不能廉价复制,就先完成对引用的使用,再修改集合;或者记住索引,修改后重新索引。clone 也能切断借用,但它会复制数据,应当是你确实需要独立所有权后的选择,不是看见借用错误就条件反射般加上的胶带。
实际代码里常见的动作不是单个 push,而是把一批结果并进来。extend 接受任何能产生元素的迭代来源;来源给出拥有值时,值会移动进目标向量,来源给出引用时,能否收集取决于元素是否可以复制或克隆。
append 更直接地把另一个同类型向量里的全部元素移动到当前向量末尾。调用后,来源向量仍然存在,但长度变成零,原来的容量可以继续复用:
fn main() {
let mut ready = vec![String::from("build")];
let mut incoming = vec![String::from("test"), String::from("deploy")];
ready.append(&mut incoming);
println!("ready={ready:?}");
ready=["build", "test", "deploy"]
incoming_len=0这里没有克隆三个字符串。它们的所有权从 incoming 转进 ready。如果后续还要保留来源内容,就不能用 append 假装没有移动成本;你需要借用读取或明确复制。
split_off(at) 从某个下标开始把后半段分成新的向量,原向量保留前半段。它适合把积压任务切成两批,但新向量需要自己的缓冲区。若只想临时查看两段,切片的 split_at 不分配;若要同时修改两段,使用 split_at_mut。先问结果需不需要拥有数据,通常就能在“切片视图”和“新向量”之间做出选择。
drain(range) 则在借用原向量的同时,逐个产生被移除的拥有值:
fn main() {
let mut jobs = vec!["a", "b", "c", "d", "e"];
let archived: Vec<_> = jobs.drain(1..4).collect();
println!("active={jobs:?}");
println!("archived={archived:?}");
active=["a", "e"]
archived=["b", "c", "d"]drain 创建的迭代器持有对向量的可变借用。在它结束之前,你不能同时访问原向量。上例把迭代器立刻 collect 完,因此下一行可以正常打印 jobs。如果把 drain 迭代器存进变量,就要留意它的最后使用位置;又一次看似突然的借用冲突,往往只是“这次批量搬运还没有结束”。
String:UTF-8 字节上的文本约束你从脚本语言转过来后,可能会自然地写出 name[0],想取字符串第一个“字符”。Rust 不允许这样做。它不是故意把文本操作变难,而是在逼你先说明:你要的是第一个字节、第一个 Unicode 标量值,还是用户眼里的第一个完整文字?这三者不总是一回事。
String 拥有一段可增长的 UTF-8 字节序列,&str 是对有效 UTF-8 文本的借用视图。你可以把 String 的底层直觉理解为“带 UTF-8 合法性约束的字节向量”,但不要因此绕开字符串 API 随意改字节;只要破坏了 UTF-8,有关 str 的许多保证就不成立了。

len() 返回字节数,不是“看起来有几个字”。
fn main() {
let text = String::from("A虎🦀");
println!("bytes={}", text.len());
println!("chars={}", text.chars().count());
println!("raw={:?}", text.as_bytes());
}bytes=8
chars=3
raw=[65, 232, 153, 142, 240, 159, 166, 128]A 占一个 UTF-8 字节,虎 占三个,螃蟹表情占四个,所以总长度是八字节。chars() 按 Unicode 标量值遍历,这比按字节遍历更接近“字符”,但仍不等同于屏幕上的一个字形。有些带组合符号的文字、家庭表情或旗帜可能由多个标量值组成。如果产品需求是“输入框最多二十个用户可见字符”,仅靠 chars().count() 可能还不够,需要明确字形簇规则并选择相应的 Unicode 处理方案。
这里没有一个适合所有场景的万能长度:协议字段常按字节限制,解析器可能关心标量值,界面裁切关心视觉字形。先把单位说清楚,再写代码。
UTF-8 是变长编码,某个字节位置可能落在一个字符中间。Rust 允许按字节范围切 str,但范围两端必须都是合法字符边界:
fn main() {
let text = "Rust语言";
let rust = &text[0..4];
let language = &text[4..];
println!("{rust} | {language}");
}Rust | 语言&text[0..5] 会在运行时 panic,因为字节 5 落在“语”的编码中间。边界来自外部输入时,可以用 text.get(range),非法边界或越界会得到 None:
fn prefix_at_byte(text: &str, end: usize) -> Option<&str> {
text.get(..end)
}
fn main() {
let text = "Rust语言";
println!("{:?}", prefix_at_byte(text, 4));
println!("{:?}",
Some("Rust")
None按字符数量截取时,可以让 char_indices() 告诉你对应的字节边界:
fn take_chars(text: &str, count: usize) -> &str {
match text.char_indices().nth(count) {
Some((byte_index, _)) => &text[..byte_index],
None => text,
}
}
fn main() {
println!("{}", take_chars
你好,
🦀R当 count == 0 时,nth(0) 给出首字符的字节位置 0,结果是空切片;当数量超过字符数时,函数返回整个字符串。边界语义明确后,这段代码就没有“到底算字节还是算文字”的暗坑。
下面的工作台允许你输入中文、英文和表情,逐项对比字节位置、Unicode 标量值与合法切片边界。可以故意把切点放进多字节字符中间,看看安全边界为何不能凭肉眼猜测。
String 和 &str 各管什么函数只读取文本时,优先接收 &str,调用者可以传字符串字面量,也可以借用 String:
fn is_rust_file(path: &str) -> bool {
path.ends_with(".rs")
}
fn main() {
let owned = String::from("src/main.rs");
println!("{}", is_rust_file(&owned));
println!("{}", is_rust_file(
true
false如果函数要保存文本、返回独立副本或把它放进长期存在的集合,通常需要 String。从 &str 得到拥有值可以用 to_owned()、to_string() 或 String::from();这通常需要分配和复制。反过来,从 String 借成 &str 不复制内容,只建立临时视图。
这个区分能帮你判断 API:借用表示“我只在这段调用里看一眼”,拥有值表示“这份文本归我管理,调用结束后我还可能留着”。生命周期报错往往不是语法在找茬,而是代码没有说清谁负责让那段文本活得够久。
String::with_capacity(32) 预留的是至少三十二个字节,不是三十二个汉字。capacity()、reserve() 和 shrink_to_fit() 的思路与 Vec 相同,也不要依赖具体扩容倍数。
构建大文本时,预留大致容量可以减少重分配。更重要的是,别在循环里反复用 format! 创建一串中间字符串:
use std::fmt::Write;
fn render_rows(rows: &[(&str, u32)]) -> String {
let mut output = String::with_capacity(rows.len() * 16);
for (name, score) in rows {
writeln!(&mut output,
小林: 92
小周: 87这里的容量估计不必精确。估少了,字符串仍会自动增长;估多了,会暂时多占一些内存。预分配是基于实际数据规模的优化,不该为了追求“零扩容”写出复杂脆弱的长度预测。
push 追加一个 char,push_str 追加 &str:
fn main() {
let mut message = String::from("编译");
message.push('器');
message.push_str("通过了");
message.push('!');
println!("{message}");
}编译器通过了!+ 也能拼接,但它会取得左侧 String 的所有权:
fn main() {
let left = String::from("Rust ");
let right = String::from("集合");
let title = left + &right;
println!("{title}");
println!("{right}");
}left 在拼接后不能再用,right 只是被借用,所以仍然有效。要拼很多片段,format! 往往更好读;在循环中持续构建,则用一个可变 String 配合 push_str 或 write! 更容易控制分配。
按位置修改仍需尊重 UTF-8 边界。insert 和 insert_str 在字节位置插入;remove 移除从某个合法边界开始的一个字符;replace_range 替换一个合法字符串范围;truncate 截到某个合法边界。位置算错时会 panic,因此面向用户的字符位置通常要先转换成字节位置。
fn main() {
let mut path = String::from("api/v1/users");
path.insert_str(0, "/");
path.replace_range(5..7, "v2");
path.push_str("/42");
println!("{path}");
}/api/v2/users/42删除某类字符时,retain 很顺手:
fn main() {
let mut phone = String::from("138-0013 8000");
phone.retain(|ch| ch.is_ascii_digit());
println!("{phone}");
}13800138000bytes() 适合协议、编码和 ASCII 级别的检查;chars() 适合 Unicode 标量值;char_indices() 同时给出字节位置和字符,适合最后还要安全切片的解析逻辑。分词与分行则尽量用更高层的方法,如 split_whitespace、split 和 lines。
fn normalize_whitespace(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn main() {
let raw = " Rust\t集合\n并不神秘 ";
println!("{}", normalize_whitespace
Rust 集合 并不神秘这段写法清楚,但为了 join 暂时收集了一个 Vec<&str>。文本很大、内存很敏感时,可以用迭代器和单个输出字符串逐段追加,避免中间向量。平常别急着为几行配置文本手工重写;你需要的是知道这份中间分配存在,等测量表明它真是瓶颈时再处理。
find 和 rfind 找到匹配文本时,返回匹配起点的字节索引。这个索引可以用于同一份、尚未修改的字符串切片,因为匹配起点一定是合法字符边界:
fn split_once_at_keyword<'a>(text: &'a str, keyword: &str) -> Option<(&'a str, &'a str)> {
let index = text.find(keyword)?;
let after = index + keyword.len
所有权 | 借用这里的 keyword.len() 也是字节数,正好与 find 的单位一致。最危险的情况是先保存字节索引,再修改字符串前面的内容,最后拿旧索引切新字符串。插入或替换会让后续位置整体变化,旧索引即使仍落在合法边界,也可能指向错误片段。字节索引和 Vec 下标一样,是当前位置,不是永久身份。
只判断包含关系可以用 contains,只需要分成左右两半时优先考虑 split_once,反复分隔则用 split。越贴近需求的方法越不容易把位置计算散落在代码里。
大小写转换也可能改变字节长度,甚至改变字符数量。to_lowercase 和 to_uppercase 返回新的 String,不会在原缓冲区里简单逐字节改写。对协议里的 ASCII 标识,可以根据协议明确使用 ASCII 版本;对自然语言文本,“忽略大小写”本身可能需要更细的产品规则。不要因为英文示例看起来是一对一替换,就推断所有 Unicode 文本都如此。
HashMap<K, V>:用业务键找到业务值现在换个场景:你要统计接口返回码出现了多少次。用 Vec<(u16, usize)> 当然能存,但每次更新都得线性寻找对应状态码。HashMap 把“键”和“值”建立关联,正适合按状态码、用户名、配置项或缓存键查找数据。
哈希表的直觉是:先把键经过哈希计算映射到内部位置,再处理可能的碰撞。查找、插入和删除通常有很好的平均表现,但不该把它描述成无条件、最坏情况下也永远是常数时间。哈希质量、碰撞、容量和输入特征都会影响实际成本。
HashMap 位于 std::collections 中,需要显式导入:
use std::collections::HashMap;
fn main() {
let mut limits = HashMap::new();
limits.insert(String::from("free"), 100_u32);
limits.insert(String::from("pro"), 10_000);
Some(10000)
false表里存的是 String 键,查询时却能直接传 &str。标准集合的借用查询允许你用与键一致的借用形式查找,避免为了查一次就临时分配 "pro".to_string()。这种细节在高频查询路径里很实用。
get 返回 Option<&V>,get_mut 返回 Option<&mut V>。如果缺失是正常业务分支,就用 match、if let 或组合方法处理,不要随手 unwrap 把“没找到”变成程序崩溃。
如果键和值是 Copy 类型,插入时复制值;如果是 String 这类拥有堆数据的类型,insert 会把所有权移动进表:
use std::collections::HashMap;
fn main() {
let user = String::from("alice");
let city = String::from("杭州");
let mut profiles = HashMap::new();
profiles.insert(user, city);
println!(
插入后再打印 user 或 city 会得到“使用已移动值”的编译错误。这个行为不难理解:表既然要在当前作用域之后继续保存字符串,就得成为它们的主人。
如果别处还要独立拥有同样的文本,你可以在插入前克隆,但要承认它在复制和分配。若只想保留一份数据,也可以让表存引用;代价是表的可用时间不能超过被引用数据,后续移动或释放源数据都会受限制。短期解析统计适合借用键,长期缓存或跨层返回通常更适合拥有键。
insert 覆盖值,并把旧值还给你同一个键只能对应一个当前值。再次 insert 会替换旧值,返回 Option<V>:
use std::collections::HashMap;
fn main() {
let mut settings = HashMap::new();
let first = settings.insert("timeout", 10);
let replaced = settings.insert("timeout", 30);
println!("first={first:?}"
first=None
replaced=Some(10)
current=Some(30)别忽略这个返回值背后的业务问题:重复键应该覆盖、拒绝、保留旧值,还是把新旧值合并?HashMap 不会替你决定。配置加载常有“后写覆盖前写”,注册表可能要求“重复即报错”,计数器则需要“旧值加一”。先明确语义,再挑方法。
entry 把“查找后更新”合成一次决策词频统计是 entry 最经典也最自然的场景:

use std::collections::HashMap;
fn word_counts(text: &str) -> HashMap<&str, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).
rust=2
safe=1entry(word) 表示“我要处理这个键对应的槽位”。or_insert(0) 在缺失时放入 0,无论原来存在与否,最终都返回值的可变引用,所以前面的 * 是在修改计数本身。
默认值创建昂贵时,用 or_insert_with 延迟计算;已有则改、没有则插,可以连写:
use std::collections::HashMap;
fn main() {
let mut totals = HashMap::from([("alice", 10_u32)]);
totals
.entry("alice")
.and_modify(|total| *total += 5)
.or_insert
alice=15
bob=5更复杂的重复键处理可以匹配 Entry::Occupied 和 Entry::Vacant。这样你能在一次定位结果上分别写“已存在”和“不存在”的逻辑,而不是先 contains_key 再 get_mut 做两遍概念上重复的查询。
下面的交互实验把 Occupied、Vacant、or_insert 和 and_modify 放在同一条流程里。修改键的初始状态与更新动作,可以观察不同组合最终落入哪个分支。
下面的代码会被拦下:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::from([
(String::from("alice"), 90),
(String::from("bob"), 80),
]);
let alice = scores.
alice 是指向表内值的不可变引用,而 insert 需要可变借用整张表。插入可能导致哈希表调整内部存储,所以编译器不会让那个内部引用跨过修改继续存活。解决思路和 Vec 一样:尽快用完引用、复制小值、克隆确实需要独立拥有的大值,或者重组为一次 entry 操作。
对于上例的整数分数,复制值最直接:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::from([
(String::from("alice"), 90),
(String::from("bob"), 80),
]);
let alice = scores.
有时你会尝试在遍历 &map 时直接插入或删除,这同样不行:迭代器正在借用整张表,结构性修改可能让迭代状态失效。常见做法是先收集要修改的键,循环结束后统一修改;只改现有值则用 iter_mut。把读取阶段和结构变更阶段拆开,代码往往也更容易审查。
HashMap 不承诺按插入顺序或键排序遍历。内部顺序可能随数据、容量、哈希状态或版本变化。下面这种测试很脆弱:把整张表 Debug 打印出来,然后要求字符串完全一致。
需要稳定输出时,把条目收集到向量再排序:
use std::collections::HashMap;
fn main() {
let map = HashMap::from([("b", 2), ("a", 1), ("c", 3)]);
let mut entries: Vec<_> = map.iter().collect();
entries
a=1
b=2
c=3如果业务持续需要按键有序遍历、范围查询或找相邻键,每次临时排序也许说明结构选错了,可以考虑树形映射。若只是日志或快照偶尔要求稳定,临时排序往往更简单。
Eq 与 Hash 必须讲同一种“相等”哈希键需要满足相等比较和哈希计算的一致性:两个键如果相等,就必须产生相同的哈希结果。自定义结构通常通过派生实现:
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq, Hash)]
struct UserKey {
tenant_id: u64,
user_id: u64,
}
fn main() {
let mut roles = HashMap::new();
roles.insert
Some("admin")如果你手写 PartialEq 和 Hash,两边使用的字段必须一致。更麻烦的是,键放进表后,不应该通过内部可变性把影响相等或哈希的字段改掉;那会破坏键所在位置与其当前哈希之间的关系,导致逻辑错误。
默认哈希器倾向兼顾通用性与对恶意碰撞输入的防护,不代表它在每一种工作负载里速度最高。只有性能分析确认哈希计算是瓶颈,并且你清楚输入是否可信时,才考虑替换哈希器。更快的基准数字若以削弱抗碰撞能力为代价,对公开接口可能是一笔很差的交易。
容量方面,HashMap::with_capacity(n) 和 reserve(n) 能减少已知规模导入时的重新分配。它们仍然只是容量规划,不改变无序语义,也不会让平均复杂度变成绝对保证。
把一张映射并进另一张看似只是 extend,其实最关键的是重复键怎么办。直接扩展时,来源中重复键对应的值会成为目标里的最终值。这适合“命令行参数覆盖配置文件”一类后者优先的规则,却不适合要求重复即报错的注册信息。
需要相加、拼接或拒绝时,把规则写出来:
use std::collections::HashMap;
fn merge_counts(
target: &mut HashMap<String, usize>,
incoming: HashMap<String, usize>,
) {
for (key, count) in incoming {
*target.entry(key).or_insert(0) +=
incoming 按值传入并在循环中被消费,键可以直接移动进 target,不需要克隆。若调用者之后还要使用来源映射,就让函数借用它,但这样要把键复制进目标,或者让两个映射采用能共享所有权的数据模型。函数参数已经把这项权衡写在了签名里。
拒绝重复键时,可以在循环里匹配 entry,一旦遇到已占用入口就返回错误。还要进一步决定失败前已插入的项目是否允许保留;若要求全有或全无,应先验证全部键或在临时映射中完成合并,确认成功后再替换正式状态。哈希表只提供操作工具,原子性仍是业务层的责任。
HashSet<T>:只关心“有没有”如果数据只有键,没有与之关联的额外值,用 HashMap<T, ()> 可以实现,但意图很别扭。HashSet<T> 直接表达“这些值出现过”。去重、黑名单、权限集合、已访问节点和标签匹配,都属于它的常见用法。
insert 返回 bool:新加入时是 true,原来已经存在时是 false。你不需要先 contains 再 insert:
use std::collections::HashSet;
fn main() {
let mut seen = HashSet::new();
for id in [42, 7, 42, 9, 7] {
if !seen.insert(id) {
println!("重复编号:{id}");
}
重复编号:42
重复编号:7
unique=3contains 判断成员,remove 删除并告诉你之前是否存在,take 则删除后把集合里真正存着的那个值返回。字符串集合也支持借用查询,HashSet<String> 可以用 set.contains("rust"),没必要为查询创建临时 String。
与 HashMap 一样,元素要实现一致的 Eq 和 Hash,遍历顺序也没有保证。如果你只是要对小列表去重后继续按原顺序显示,HashSet 通常是辅助索引,不应直接把最终显示顺序寄托在集合遍历上。
假设 required 是接口要求的权限,granted 是用户实际拥有的权限:

use std::collections::HashSet;
fn main() {
let required = HashSet::from(["read", "write"]);
let granted = HashSet::from(["read", "admin"]);
let mut missing: Vec<_> = required.difference(
missing=["write"]
shared=["read"]
all=["admin", "read", "write"]
only_one_side=["admin", "write"]四种操作的业务含义分别是:
union:任意一边出现过的全部元素。intersection:两边都出现的元素。difference:左边有、右边没有的元素,方向不能写反。symmetric_difference:只出现在其中一边的元素。这些方法返回惰性迭代器,元素是对原集合中值的借用。只想检查、计数或继续过滤时,可以直接在迭代器上做,不必急着 collect。上例收集成向量,是因为还要排序并打印稳定结果。
集合也支持 is_subset、is_superset 和 is_disjoint。权限校验常可以直接写成 required.is_subset(&granted),比手动循环更接近业务句子。
下面的交互实验可以分别增删左右两个集合的元素,并同步查看四种集合运算。尤其注意差集的方向:交换左右集合后,结果通常也会跟着变化。
对集合引用使用 &a | &b、&a & &b、&a - &b 和 &a ^ &b 可以得到新的集合。写法短,但新集合需要拥有结果元素,因此涉及克隆和分配。方法形式返回借用迭代器,适合流式消费;运算符形式适合确实要保留独立结果集合。两者不是谁更“Rust”,只是所有权需求不同。
use std::collections::HashSet;
fn main() {
let left = HashSet::from([1, 2, 3]);
let right = HashSet::from([3, 4]);
let merged = &left | &right;
assert_eq!
HashSet 如何判断重复,完全取决于元素的 Eq 和 Hash。把完整结构直接派生进去,表示所有参与派生的字段都相等时才算同一个元素:
use std::collections::HashSet;
#[derive(Debug, PartialEq, Eq, Hash)]
struct Endpoint {
method: String,
path: String,
}
fn main() {
let endpoints = HashSet::from([
Endpoint { method:
如果业务认为同一路径无论方法是什么都算重复,就不该直接派生完整结构后期待集合猜到规则。可以改用路径字符串当集合元素,也可以定义一个只包含业务身份字段的键类型。让键类型表达身份,通常比手写一套容易不一致的比较和哈希实现更稳。
浮点数也提醒我们“相等”并不总适合作哈希键。普通浮点类型要处理 NaN 等特殊语义,不能直接当作满足常规 Eq 的键。经纬度、金额或测量值需要去重时,先定义业务精度和规范化方式,比如转换成确定单位的整数,或使用明确实现所需相等语义的包装类型。直接问“这两个浮点数是不是同一个业务值”没有统一答案。
集合元素放入后还应保持影响哈希与相等的内容不变。通常安全 Rust 很难直接拿到集合内键的可变引用,这正是为了守住内部索引。如果确实通过内部可变性改变这些字段,可能造成查找失灵等逻辑错误。要修改键,稳妥流程是先 take 或移除拥有值,修改后再插回去。
数据清洗常要求去重,但保留第一次出现的位置。可以让 Vec 负责顺序,让 HashSet 负责快速判断:
use std::collections::HashSet;
fn dedup_preserving_order(values: &mut Vec<String>) {
let mut seen = HashSet::new();
values.retain(|value| seen.insert(value.clone()));
}
fn main() {
["rust", "web", "cli"]这段代码为了让 seen 拥有键,克隆了保留下来的候选字符串。若数据很大,可以重新设计所有权流,例如消费原向量,把第一次出现的元素移动到结果向量,同时用另一种可共享的标识做索引。别急着把示例复杂化,但要知道克隆发生在哪里。
在普通局部变量上理解“一次可变借用或多次不可变借用”还算直观;到了集合里,它突然变得刺手,因为一次结构性修改可能影响许多元素的位置。编译器必须按最坏的合法行为检查,而不是赌这一轮运行碰巧不扩容、不重排。
Vec::push 可能重分配,HashMap::insert 可能调整内部表,String::push_str 可能搬动字节缓冲区。只要你还持有指向容器内部的引用,这类可变操作就可能让引用失效。

处理它时可以按以下顺序想:
copied()。clone,并接受成本。entry、split_at_mut、retain 往往已经替你表达了不重叠或单次定位。不要用“我已经调用了 with_capacity,所以肯定不会扩容”来和借用检查器对赌。预分配能改善运行时行为,却不是类型系统可依赖的不失效证明。今天的追加量、元素类型或实现策略一变,这个假设就可能失效。
for item in &items 在整个循环期间借用了集合。循环体里再 push、remove 或 clear,会与这个借用冲突。更重要的是,即使某种语言允许这么做,“新追加的元素是否也要遍历”“删除后下一个位置是谁”都容易变得含糊。
如果只修改每个现有元素,用 iter_mut:
fn main() {
let mut latencies = vec![100_u32, 250, 80];
for latency in &mut latencies {
*latency = latency.saturating_sub(20);
}
println!("{latencies:?}");
}[80, 230, 60]如果要筛掉元素,用 retain;要把元素逐个取走,用 drain 或消费型迭代;要根据旧数据产生新数据,先 map/filter 后 collect。确实需要对原集合做复杂结构变更时,先收集“修改计划”,退出借用后再应用。两阶段写法多一小段代码,却把规则讲得很清楚。
clone 不是错,但要有理由教程里为了快速绕开所有权,容易到处出现 .clone()。这会制造一个危险错觉:编译错误都能靠复制解决。实际上:
String 会分配并复制字节。Vec 会逐项克隆。正确的问题不是“能不能 clone”,而是“业务上是否需要两个独立所有者”。若答案是需要,克隆很合理;若答案只是“我想让编译器闭嘴”,通常还有更清楚的所有权设计。
四种集合各有布局,但读取、转换和收集时共享一套迭代器思路。你可以先借用数据,经过过滤和映射,再决定最终收进什么集合。这样代码描述的是数据怎么流动,而不是手动维护许多临时下标。
规律可以压缩成一张心里的表:
iter():产生共享引用,原集合保留。iter_mut():产生可变引用,原集合保留且元素可修改。into_iter():产生拥有值,原集合被消费。HashMap 的拥有值是 (K, V),借用项是 (&K, &V);HashSet 的项是 T 或 &T;String 通常通过 bytes、chars、split 等文本语义迭代,而不是把它当普通集合逐项改字符。
下面把一批原始路径清洗成扩展名集合:
use std::collections::HashSet;
fn extensions(paths: &[String]) -> HashSet<String> {
paths
.iter()
.filter_map(|path| path.rsplit_once('.').map(|(_, ext)| ext))
iter() 说明函数只借用路径;filter_map 同时过滤没有扩展名的路径并取出扩展名;小写转换生成新的 String,最终集合拥有这些结果,所以它可以脱离原路径列表存在。
collect 的目标类型决定结果同一条迭代器可以收进 Vec、HashSet 或 HashMap。编译器需要从变量标注或涡轮鱼语法知道目标:
use std::collections::{HashMap, HashSet};
fn main() {
let list: Vec<_> = [3, 1, 3, 2].into_iter().collect();
let unique: HashSet<_> = list.iter().
list_len=4
unique_len=3
label_2=Some("item-2")收进 HashSet 会丢失重复项,也不保留原顺序;收进 HashMap 时如果产生重复键,后出现的值会成为最终关联值。collect 很方便,但集合语义依旧存在,不会因为写成一条链就消失。
大多数迭代器适配器是惰性的。写下 values.iter().map(...) 并不会立刻执行闭包;只有 collect、sum、for_each、count 或 for 循环等消费它时,数据才真正流动。
这让多步操作常能合并为一次遍历,减少中间集合。但长链并不天然更快,也不天然更好读。如果闭包里塞满状态变化和多层分支,拆成命名清楚的循环可能更适合维护。Rust 给你的是组合能力,不是“越链式越高级”的评分规则。
集合 API 很多,逐个背下来既累也不稳。更省力的办法是看方法签名中的 self、参数和返回值。它们像一份交接单:调用时要交出什么,方法能改到哪里,结束后你拿回什么。即使第一次见到某个方法,也能先判断大概行为。
&self、&mut self 和 self 是三种权限接收 &self 的方法只共享借用集合。len、is_empty、get、contains 和 iter 都属于这类操作。它们可以观察结构,但不能通过这次借用改变集合的元素数量。
接收 &mut self 的方法拿到独占的可变借用。push、insert、remove、clear、retain 和 entry 需要这项权限。调用期间,其他还在使用的共享引用或可变引用都不能与它重叠。即使某个方法最终没有触发扩容,它也拥有修改结构的能力,借用检查器就必须按这个能力检查。
接收 self 的方法会取得集合所有权。into_iter 是最常见的例子:它可以把元素一个个移动出去,因为调用后原集合不再可用。另一个常见模式是转换方法返回另一种拥有值。看到签名吃掉 self,你就该问“这行之后原变量还要不要用”。
这三种接收方式也能解释为什么有些代码只差一个 &,结果却完全不同:
fn main() {
let names = vec![String::from("alice"), String::from("bob")];
let borrowed_lengths: Vec<_> = names.iter().map(String::len).collect();
println!("names={names:?}");
println!
第一次遍历只借用,所以还能打印 names。第二次遍历移动并消费它,之后只能使用新收集的 upper。这里的 to_uppercase 会创建新字符串;即使输入元素已被移动进闭包,大小写转换仍然需要构造转换后的文本。
T 还是 &T,决定值会不会被拿走Vec::push(value: T) 接收拥有值,HashMap::insert(key: K, value: V) 接收拥有的键和值,HashSet::insert(value: T) 也接收拥有值。若传入 String,所有权进入集合;若传入整数这类 Copy 值,调用处看起来仍能继续使用,是因为复制了一份。
查询通常接收借用。Vec::contains(&T) 不需要拿走搜索值,哈希集合和哈希映射还能接收键的兼容借用形式。这就是 HashMap<String, V> 可以用 &str 查询的原因。看见查询函数要求 &Q,通常意味着“只拿这个值做比较或哈希,不保存它”。
删除方法的参数与返回值尤其值得细看:
HashMap::remove(&key) 借用键来定位,返回被移除的值 Option<V>。HashSet::remove(&value) 只返回是否删除成功,因为调用者没有要求拿回集合内的拥有值。HashSet::take(&value) 返回 Option<T>,适合你需要取回集合中那个正式对象的场景。Vec::remove(index) 接收下标,返回移动出来的 T,同时维持剩余元素顺序。Vec::swap_remove(index) 也返回 T,但用末尾元素补洞,因此不保证顺序。返回 T 表示所有权从集合移交给你,返回 &T 表示你只拿到集合内部的临时视图。这个区别会直接影响后续能否修改集合,也影响结果能活多久。
Option 不只是为了防 panic集合查询返回 Option,是在类型上表达“缺失是可能发生的”。处理它时,选择应该贴合业务:
use std::collections::HashMap;
fn display_name<'a>(users: &'a HashMap<u64, String>, id: u64) -> &'a str {
users.get(&id).map(String::as_str).unwrap_or
小周
匿名用户默认值合理时,unwrap_or 很清楚;缺失要上报错误时,可以把 Option 转成 Result;缺失代表跳过时,filter_map 或 let Some(...) else 更自然。下标操作相当于“我断言一定存在,否则 panic”,它不是坏 API,只是断言强得多。解析外部数据时,这个断言往往没有依据;处理算法内部已经验证过的索引时,它可能正合适。
retain 的闭包看到元素借用,并通过布尔值决定保留与否;它不允许你在闭包里再随意改变同一集合结构。sort_by 给闭包两个元素引用,闭包返回顺序判断;比较函数应保持一致,不要一会儿说 a < b,换个调用顺序又给出矛盾结果。
entry(...).and_modify(|value| ...) 给闭包现有值的 &mut V,这表示你可以修改值,却不能借着这份引用同时对整张表做另一次结构修改。把权限限制在单个值上,正是 API 能在保证内部结构有效的前提下开放修改能力的方式。
use std::collections::HashMap;
fn apply_delta<'a>(
balances: &mut HashMap<&'a str, i64>,
user: &'a str,
delta: i64,
) {
balances
.entry(user)
.and_modify
签名读法还有一个实际收益:你会更少写“先试一下再看编译器骂什么”的随机代码。编译错误仍然不可避免,尤其生命周期复杂时所有人都会卡住,但你至少能提前判断冲突来自消费、共享借用还是结构修改。
能编译不等于业务语义正确。集合最麻烦的线上问题,常常不是悬垂引用——那类问题已经被编译器挡下——而是顺序、索引、重复键、容量和文本边界这些规则被代码默默误解。下面几类事故很值得提前认识。
索引不是元素的永久身份证。Vec::remove(i) 会让后续元素下标减一,swap_remove(i) 更会把末尾元素放到 i。如果另一个表长期保存了向量下标,删除后它可能指向错误对象,即使访问仍然没有越界。
#[derive(Debug)]
struct Job {
id: u64,
name: &'static str,
}
fn main() {
let mut jobs = vec![
Job { id: 10, name: "build" },
Job { id: 20, name: "test"
deploy原来下标 1 是 test,删除首项后却成了 deploy。编译器无法知道 remembered_index 在业务上承诺指向谁。需要稳定身份时,应保存 id 并建立按 ID 查询的映射,或者使用专门的稳定句柄设计。若索引只在一次短循环里使用,Vec 仍然很好;问题出在把位置误当身份。
哈希表本来无序,但不稳定往往直到测试或生产升级才暴露。你可能在本机看到 a, b, c,便把这个顺序写进快照;也可能直接对哈希迭代器 skip(page * size).take(size) 做分页。下一次运行内部顺序变化,同一页用户突然重复或消失。
分页需要明确排序键,并处理相同排序值的稳定次序。报告需要稳定文本,就收集并排序。若顺序是核心业务属性,应从数据模型里保存它,而不是从一次碰巧的遍历结果里推断。
用户名、标签、文件扩展名和邮箱看起来是字符串,但“业务上相同”未必等于字节完全相同。Rust 与 rust 是否同一个标签?全角空格要不要去掉?路径是否区分大小写?这些不是 HashMap 能替你回答的问题。
常见做法是在进入映射前建立唯一、明确的归一化步骤:
use std::collections::HashMap;
fn normalize_tag(tag: &str) -> String {
tag.trim().to_lowercase()
}
fn count_tags<'a>(tags: impl IntoIterator<Item = &'a str>) -> HashMap
to_lowercase 处理 Unicode 大小写映射,但它仍不等于所有领域里的规范化规则。账号系统、文件系统和自然语言文本的要求不同。关键是把规则集中在入口,而不是一处转小写、另一处只 trim,最后表里出现多套互相找不到的键。
一个常见手写循环是从 0 递增索引,遇到不合格元素就 remove(i)。删除后后一个元素会移动到当前 i,若循环仍然递增,就会漏检它。
fn main() {
let mut values = vec![1, 2, 4, 6, 7];
values.retain(|value| value % 2 != 0);
println!("{values:?}");
}[1, 7]retain 已经把“保留满足条件的项”说清楚,也正确处理连续删除。确实需要手写索引时,可以只在未删除时递增,或从后向前删,但先看看集合有没有直接表达意图的方法。
clear 之后长度为零,容量通常仍然保留。这对反复处理相近大小的批次很有价值:一个缓冲区可以多轮复用,减少分配。但如果某一轮突然读入百万条数据,随后这个集合进入长期空闲,巨大的容量也可能一直跟着对象。
处理方式不是每轮都 shrink_to_fit。你可以在批次结束后根据阈值决定是否收缩,或者直接丢弃异常大的缓冲区并创建新集合。阈值要来自服务内存目标和批次分布,而不是凭空选择。容量复用与及时归还是一组权衡,不能同时把两边成本都抹掉。
假设你把一行配置拆成多个字段,一边解析一边写入多个集合,最后一个字段失败时,前面的写入已经发生。代码内存安全,却留下了业务上的半成品。
更稳的做法通常是先解析并验证成一个临时拥有值,全部成功后再提交到集合:
use std::collections::HashMap;
#[derive(Debug)]
struct Rule {
name: String,
limit: u32,
}
fn parse_rule(line: &str) -> Option<Rule> {
let (name, raw_limit) = line.split_once('='
这是一种小型的“先准备,后提交”。跨多个集合的复杂更新还可能需要显式回滚或事务机制,但至少不要在尚未确认输入完整时到处修改状态。所有权在这里反而很顺手:临时 Rule 独立拥有解析结果,提交时再把字段移动进映射,没有额外克隆。
遇到结果不对或借用错误时,可以按一个固定顺序缩小范围:
先查语义,后查性能,能避免你把一个排序缺失的问题误诊成哈希器问题,也能避免为了消除一次克隆而改出更难维护的生命周期结构。
选集合时不要先问“哪个最快”,这个问题缺少操作。随机访问、末尾追加、按键查询、有序遍历、头尾弹出和去重,快的结构不同。更实用的判断顺序是:我最常做什么?必须保留什么语义?能接受什么成本?

Vec 开始的情况以下需求通常适合 Vec:
即使理论上存在更专门的结构,Vec 的连续布局和简单语义也常让它成为很好的起点。不要因为“链表中间插入是常数时间”就立刻换链表;找到插入位置、内存局部性和节点分配也有成本,真实程序未必获益。
当核心动作是“给我这个业务键对应的值”,用 HashMap。当核心动作只有“这个值出现过吗”,用 HashSet。两者都以无序为前提,也都需要哈希和相等语义。
如果需要持续按键排序、范围查询、最小或最大键,树形映射或树形集合更贴近需求。若只是最后输出一次有序结果,哈希结构加临时排序可能更省事。选择取决于“每次操作都要有序”还是“最终展示要有序”。
频繁从两端加入和取出元素时,双端队列通常比 Vec::remove(0) 合适。需要总是取出优先级最高的任务时,优先队列比每次完整排序更贴近动作。知道标准库还有这些结构,是为了在需求明显偏离时换工具,不是要求你每次都从七八种容器里做一场学术评审。
复杂度能告诉你规模增长的趋势,却不会告诉你全部现实成本。一个几十项的 Vec 线性搜索,可能比哈希表更简单、更紧凑,也足够快;一个巨大的 HashMap 可能把大量时间花在哈希和内存访问上。反过来,数据达到百万级后,每次线性扫描就可能明显拖慢请求。
建议把选择拆成两层:
预分配、sort_unstable、避免克隆都可能有效,但它们必须建立在测量和语义允许之上。为了省一次分配而引入难懂的生命周期,为了快一点而让公开输入失去抗碰撞保护,通常不是好交易。
集合选型最可靠的起点不是背复杂度表,而是把主操作写成一句话:按位置取、按键找、判断出现过、从两端处理,还是始终取最高优先级。句子说清楚后,候选结构往往已经很少了。
我们把四种集合放进同一个真实任务。输入是一批访问日志,每行包含用户、路径和状态码:
alice /docs 200
bob /login 401
alice /docs 200
alice /download 503
bad-line要求是:保留合法记录的输入顺序,统计每个状态码的次数,找出独立用户,并生成稳定排序的摘要。这个任务里没有一种集合包打天下:
Vec<LogEntry> 保留合法记录顺序。HashMap<u16, usize> 统计状态码。HashSet<String> 记录独立用户。String 构建最终报告。#[derive(Debug)]
struct LogEntry {
user: String,
path: String,
status: u16,
}
fn parse_line(line: &str) -> Option<LogEntry> {
let mut parts = line.split_whitespace();
let user =
line 只在解析调用期间有效,而 LogEntry 要被放进结果向量继续保存,所以这里把用户和路径转成拥有的 String。这两次分配不是无脑克隆,它们对应明确的所有权边界:解析结果要独立于输入切片存在。
use std::collections::{HashMap, HashSet};
#[derive(Debug)]
struct LogEntry {
user: String,
path: String,
status: u16,
}
#[derive(Debug)]
struct ReportData {
entries: Vec<LogEntry>,
这里有一处显眼的 clone:用户名既要由 entries 中的记录拥有,也要由 users 集合拥有。两个容器都要在函数结束后独立使用这份键,所以复制有业务理由。若日志量巨大且用户名重复很多,可以进一步做字符串驻留、使用共享所有权或改变数据模型;那是测量后再做的设计,不需要在入门版本里预先堆上复杂度。
entries 根据行数预留容量,即使有坏行导致估计偏大也没关系。状态码和用户数量通常远小于日志行数,没有可靠估计时先让哈希集合自动增长,代码更朴素。
use std::fmt::Write;
fn render(data: &ReportData) -> String {
let mut statuses: Vec<_> = data.status_counts.iter().collect();
statuses.sort_unstable_by_key(|(status, _)| **status);
let
这里没有直接打印 HashMap 或 HashSet,而是借用条目、收集引用并排序。原集合不被消费,报告每次生成的顺序稳定,测试也就不需要猜哈希表内部顺序。
如果还要按输入顺序显示失败请求,可以遍历 entries.iter().filter(|entry| entry.status >= 500);如果生成报告后再也不需要分析数据,可以让渲染函数接收 ReportData 并消费它,减少某些需要克隆的场景。参数用借用还是拥有值,取决于调用之后还需不需要原数据,而不是固定风格。
这个综合例子把集合的核心关系摆在了一起:Vec 管顺序,哈希结构管快速关联和成员资格,String 管最终文本,每一次借用或移动都服务于明确的数据寿命。
下面的练习不追求背方法名,重点是先判断所有权和操作语义。动手时建议先故意写出那个会失败的版本,读完编译错误再改。编译器指出的是引用在哪里仍然活着、哪次操作需要可变借用,这些位置比只看错误编号更有帮助。
Vec 的借用冲突下面的函数想记录第一个任务名,再追加一个任务。请解释为什么失败,并在不克隆整个向量的前提下修复:
fn main() {
let mut tasks = vec![String::from("build"), String::from("test")];
let first = &tasks[0];
tasks.push(String::from("deploy"));
println!("first={first}");
}实现 take_chars(text, count),返回前 count 个 Unicode 标量值对应的 &str。要求 count 超过文本长度时返回完整文本,不能分配新字符串。
entry 做稳定的 Top-K 词频实现一个函数:忽略 ASCII 大小写统计空白分隔的单词,按频次降序返回前 k 个;频次相同则按单词升序,保证输出稳定。
给定 required 和 granted 两个权限集合,返回按字典序排列的缺失权限。不要修改输入集合。
走到这里,你不需要把每个方法都背下来。真正要形成的手感是:看到集合操作时,先问数据是否连续、是否有顺序、谁拥有元素、引用要活多久、修改会不会改变内部结构。带着这些问题去读方法签名,编译器的拒绝就不再像一堵墙,更像一位负责的同事在提醒你:这份数据的去向还没交代清楚。
如果需求是保存“当时那份名称”,并允许以后向量修改甚至删除首项,那么需要独立所有权,可以只克隆第一个字符串:
fn main() {
let mut tasks = vec![String::from("build"), String::from("test")];
let first = tasks[0].clone();
tasks.push(String::from("deploy"));
println!("first={first}");
}两种修复语义不同:索引版本读取修改后的当前位置,克隆版本保存修改前的独立快照。选择哪一个要看业务,不是看哪段更短。
char_indices 给出的索引一定落在合法 UTF-8 边界上,因此可以用于切片。返回值借用自输入,既没有复制,也不能比输入文本活得更久。
哈希表负责统计,向量负责最终排序。若只按频次排序,相同频次的顺序会受到哈希遍历顺序影响,测试可能时好时坏;补上单词作为第二比较条件后,输出才真正稳定。
difference 产出的是对集合元素的借用。这里把缺失权限克隆成拥有的 String,因此返回结果可以独立于两个输入集合继续使用;相应的复制成本也真实存在。如果结果只在输入集合有效期间临时检查,可以直接消费差集迭代器,不必先创建拥有值向量。