自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

Rust

  1. 01Rust入门
  2. 02语句与表达式
  3. 03基本类型
  4. 04集合类型
  5. 05函数
  6. 06方法与 impl
  7. 07输入输出:文件、控制台与错误边界
  8. 08迭代器
  9. 09Cargo 与 crates.io
  10. 10智能指针
  11. 11并发
正在加载课程章节内容
课程编程Rust集合类型

集合类型

你正在写一个批量导入程序:先把订单编号读进列表,再记住第一个编号,接着继续追加数据。代码看起来没什么问题,编译器却在 push 那一行把你拦住了:

rust
fn main() {
    let mut order_ids = vec![1001, 1002, 1003];
    let first = &order_ids[0];
 
    order_ids.push(1004);
 
    println!("首个订单:{first}");
}

你看到的核心错误会是:

text
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 则常用于把迭代器产生的值收集起来:

rust
fn main() {
    let mut temperatures = Vec::new();
    temperatures.push(21);
    temperatures.push(24);
 
    let retries = vec![0; 4];
    let squares: Vec<i32> = (1..=4).map(|n| n * n).collect();
 
    println!("{temperatures:?}");
    println!("{retries:?}");
    println!("{squares:?}");
}
text
[21, 24]
[0, 0, 0, 0]
[1, 4, 9, 16]

空向量里没有元素可供编译器推断类型,因此下面这句单独出现时信息不够:

rust
let values = Vec::new();

你可以写成 let values: Vec<u64> = Vec::new(),也可以稍后 push 一个能确定类型的值。类型一旦确定,整个 Vec<T> 只能装同一种 T。如果确实要存储几种形态不同但业务上属于同一类的数据,通常用枚举把差异包起来,而不是期待向量忽略类型。

len 和 capacity 不是一回事

len() 是已经放入的元素数量,capacity() 是当前这块内存不重新分配时至少能容纳的元素数量。一个长度为 3、容量为 8 的 Vec<i32>,表示前 3 个位置已有值,还有空间可供后续追加。

Vec 长度与容量的连续内存布局示意图

rust
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);
}
text
len=3
capacity_at_least_8=true

Vec::with_capacity(8) 表示预留至少八个元素的位置,不表示已经有八个元素。此时 events[0] 仍然越界,因为长度是 0,预留出来的空间还不是已经初始化的元素。

当 len == capacity 后继续 push,向量会自动扩容。扩容通常会多申请一些空间,以便把多次末尾追加的平均成本降下来;但具体申请多少属于实现策略,不该写进业务逻辑。不要假设容量一定翻倍,也不要写测试要求每次得到某个固定容量。你真正可以依赖的是:容量不会小于长度,并且在容量足够时,末尾追加不需要因为容量不足而重新分配。

Vec 容量不足时重新分配并搬移元素的过程

如果你大致知道数据量,可以预留容量:

rust
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| !line.is_empty()));
    rows
}

reserve(n) 保证从当前长度算起还能容纳至少 n 个额外元素,try_reserve(n) 则把分配失败作为结果交给你处理,适合不能接受直接中止的服务或工具。shrink_to_fit() 可以请求释放多余容量,但它不是“以后绝不再占更多内存”的承诺;如果马上又追加数据,下一次扩容反而可能把刚省下来的成本重新付一遍。热点路径里频繁收缩通常得不偿失。

下面的交互实验可以调整初始容量与追加次数,直接观察 len、capacity 和重新分配之间的关系。试着让追加数量刚好越过容量边界,你会更容易理解为什么容量只是预留空间。

容量是性能工具,不是正确性工具。即使你提前预留了足够空间,借用检查器也不会允许你一边保留元素引用,一边通过可变借用调用 push。编译器按类型和借用规则证明安全,不会把某次容量观察变成长期不扩容的契约。

连续存储带来的取舍

Vec<T> 可以把元素看成一个连续片段。按下标读取通常是常数时间,末尾 push 的均摊成本也很低,pop 从末尾取走元素同样直接。连续布局还有一个很实际的好处:从头到尾扫描时,内存访问规律清楚,常常比到处跳指针的数据结构更友好。

代价也很明确:

  • 在中间 insert 时,插入点之后的元素需要移动。
  • remove 为了维持顺序,也要把后面的元素向前移动。
  • 扩容可能重新分配并搬动全部元素。
  • 长期保留远大于长度的容量会占住暂时不用的内存。

这并不意味着“中间绝对不能插删”。几十个元素的配置列表,清楚的代码通常比理论上的最优结构更重要。只有数据量、操作频率和延迟真的构成问题时,才值得换结构。先量,再改,比凭感觉躲开 Vec 靠谱。

读取:下标、get 与首尾方法

如果索引来自你能证明正确的循环或算法,下标写法简洁:

rust
fn main() {
    let ports = vec![8080, 8081, 8082];
    println!("{}", ports[1]);
}

但下标越界会触发 panic。索引来自用户输入、文件内容或网络请求时,get 更诚实,它返回 Option<&T>:

rust
fn find_port(ports: &[u16], index: usize) -> Option<u16> {
    ports.get(index).copied()
}
 
fn main() {
    let ports = vec![8080, 8081, 8082];
    println!("{:?}", find_port(&ports, 5));
    println!("{:?}", ports.first());
    println!("{:?}", ports.last());
}
text
None
Some(8080)
Some(8082)

get_mut 返回 Option<&mut T>,可以在位置有效时修改元素。first、last、first_mut 和 last_mut 则把常见的首尾访问意图直接写出来,比手动计算 len() - 1 更稳,空向量也不会发生无符号整数下溢。

末尾操作和中间插删

末尾是 Vec 最舒服的位置。push 添加,pop 返回 Option<T> 并把值的所有权交给调用者:

rust
fn main() {
    let mut queue = vec!["build", "test"];
    queue.push("deploy");
 
    while let Some(job) = queue.pop() {
        println!("处理:{job}");
    }
}
text
处理:deploy
处理:test
处理:build

注意,这段代码表现得像栈,后放进去的先取出来。真要频繁从头部取任务,反复 remove(0) 会移动其余所有元素,通常应该考虑双端队列,而不是强行让 Vec 做它不擅长的工作。

中间操作要先决定是否保序:

rust
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); // 取走 15,用末尾元素补位
 
    println!("removed={removed}, swapped={swapped}");
    println!("{ids:?}");
}
text
removed=20, swapped=15
[10, 40, 30]

swap_remove 不维护原顺序,因此能避免整段搬移。实体组件列表、待处理槽位等不关心顺序的场景很适合它;排名、时间线和界面菜单就不能偷偷使用它,否则结果虽然“元素都在”,语义已经坏了。

批量删除时还有几种更贴近意图的工具:

rust
fn main() {
    let mut scores = vec![95, 42, 88, 30, 76, 99];
 
    scores.retain(|score| *score >= 60);
    scores.truncate(3);
    println!("{scores:?}");
 
    scores.clear();
    println!("len={}, reusable={}", scores.len(), scores.capacity() >= 3);
}
text
[95, 88, 76]
len=0, reusable=true

retain 保留满足条件的元素;truncate(n) 丢弃下标 n 之后的元素;clear 把长度变为零,但通常保留已分配容量以便复用。若你既要删除一段,又要拿走被删元素,可以用 drain(range)。要用另一批元素替换某个范围,则可以看 splice。这些方法的优势不只是短,它们直接表达“筛选”“截断”“排空一段”,比手写索引循环更少出错。

排序和去重不是同一件事

sort 是稳定排序:比较结果相等的元素保留原来的相对顺序。sort_unstable 不承诺这一点,通常可以减少额外开销。是否选择稳定排序,不取决于名字听起来哪个更可靠,而取决于相等元素的原顺序有没有业务意义。

rust
#[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 },
        Record { team: "A", created_at: 1 },
    ];
 
    records.sort_by_key(|record| record.team);
 
    for record in records {
        println!("{} {}", record.team, record.created_at);
    }
}
text
A 2
A 1
B 3

两个 A 的相对顺序仍是 2 在 1 前。如果你本来就想按团队再按时间排,应把两个条件都写进键:sort_by_key(|r| (r.team, r.created_at)),不要把稳定性误当成第二排序字段。

浮点数有 NaN,普通偏序比较可能没有结果,因此照搬整数的比较写法会碰壁。若业务接受 IEEE 总序,可以使用 total_cmp:

rust
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 没挨在一起。若你希望得到排序后的唯一值,可以先排序再去重:

rust
fn main() {
    let mut numbers = vec![3, 1, 2, 3, 2, 1];
    numbers.sort_unstable();
    numbers.dedup();
    println!("{numbers:?}");
}
text
[1, 2, 3]

这会改变原顺序。如果要求“保留第一次出现的次序”,可以用 HashSet 记录已经见过的值,再配合 retain。这会增加一份哈希集合的内存和哈希计算,没有免费午餐:你是在用空间换顺序语义和平均较快的成员判断。

切片是借来的窗口

&[T] 是对连续元素的借用视图。它不拥有数据,也不会复制元素。函数只需要读取一段数据时,参数通常写 &[T],这样调用者既可以传 Vec<T>,也可以传数组或另一个切片:

rust
fn average(values: &[i32]) -> Option<f64> {
    if values.is_empty() {
        return None;
    }
 
    let sum: i64 = values.iter().map(|&value| i64::from(value)).sum();
    Some(sum as f64 / values.len() as f64)
}
 
fn main() {
    let all = vec![10, 20, 30, 40, 50];
    println!("{:?}", average(&all[1..4]));
}
text
Some(30.0)

切片范围同样会检查边界。&all[1..4] 包含下标 1、2、3,不包含 4。边界不可信时可以用 all.get(1..4) 得到 Option<&[T]>。

切片还提供了不少适合批处理的视图:chunks(n) 按最多 n 个元素分块,windows(n) 查看长度为 n 的滑动窗口,split 按条件分段。它们只借用原数据,不会因为“分组”就复制整批内容。

如果你确实要同时修改两个不同位置,直接写两个下标借用通常过不了:

rust
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 把切片证明为两个互不重叠的部分:

rust
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];
}
 
fn main() {
    let mut values = [2, 4, 8];
    add_first_to_last(&mut values);
    println!("{values:?}");
}
text
[2, 4, 10]

三种迭代方式其实在讨论所有权

看到 iter、iter_mut 和 into_iter 时,别把它们当成三套死记硬背的 API。先问:循环结束后,我还要不要这个集合?

rust
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 formatted: Vec<String> = prices
        .into_iter()
        .map(|price| format!("¥{price}"))
        .collect();
 
    println!("{formatted:?}");
}

iter() 产生 &T,集合可以继续使用;iter_mut() 产生 &mut T,允许逐项修改;into_iter() 消费集合并产生 T,元素被移动出去,原变量不能再用。对于 for x in &values、for x in &mut values、for x in values,含义分别与这三种意图对应。

许多借用冲突都能靠“缩短引用存活时间”解决。开头的订单示例,如果你只需要复制第一个 u64,可以让引用立刻变成值:

rust
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 更直接地把另一个同类型向量里的全部元素移动到当前向量末尾。调用后,来源向量仍然存在,但长度变成零,原来的容量可以继续复用:

rust
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:?}");
    println!("incoming_len={}", incoming.len());
}
text
ready=["build", "test", "deploy"]
incoming_len=0

这里没有克隆三个字符串。它们的所有权从 incoming 转进 ready。如果后续还要保留来源内容,就不能用 append 假装没有移动成本;你需要借用读取或明确复制。

split_off(at) 从某个下标开始把后半段分成新的向量,原向量保留前半段。它适合把积压任务切成两批,但新向量需要自己的缓冲区。若只想临时查看两段,切片的 split_at 不分配;若要同时修改两段,使用 split_at_mut。先问结果需不需要拥有数据,通常就能在“切片视图”和“新向量”之间做出选择。

drain(range) 则在借用原向量的同时,逐个产生被移除的拥有值:

rust
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:?}");
}
text
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 的许多保证就不成立了。

String 的 UTF-8 字节编码与合法字符边界示意图

字节数、字符数和视觉文字数

len() 返回字节数,不是“看起来有几个字”。

rust
fn main() {
    let text = String::from("A虎🦀");
 
    println!("bytes={}", text.len());
    println!("chars={}", text.chars().count());
    println!("raw={:?}", text.as_bytes());
}
text
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,但范围两端必须都是合法字符边界:

rust
fn main() {
    let text = "Rust语言";
    let rust = &text[0..4];
    let language = &text[4..];
 
    println!("{rust} | {language}");
}
text
Rust | 语言

&text[0..5] 会在运行时 panic,因为字节 5 落在“语”的编码中间。边界来自外部输入时,可以用 text.get(range),非法边界或越界会得到 None:

rust
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!("{:?}", prefix_at_byte(text, 5));
}
text
Some("Rust")
None

按字符数量截取时,可以让 char_indices() 告诉你对应的字节边界:

rust
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("你好,Rust", 3));
    println!("{}", take_chars("🦀Rust", 2));
}
text
你好,
🦀R

当 count == 0 时,nth(0) 给出首字符的字节位置 0,结果是空切片;当数量超过字符数时,函数返回整个字符串。边界语义明确后,这段代码就没有“到底算字节还是算文字”的暗坑。

下面的工作台允许你输入中文、英文和表情,逐项对比字节位置、Unicode 标量值与合法切片边界。可以故意把切点放进多字节字符中间,看看安全边界为何不能凭肉眼猜测。

所有权:String 和 &str 各管什么

函数只读取文本时,优先接收 &str,调用者可以传字符串字面量,也可以借用 String:

rust
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("README.md"));
}
text
true
false

如果函数要保存文本、返回独立副本或把它放进长期存在的集合,通常需要 String。从 &str 得到拥有值可以用 to_owned()、to_string() 或 String::from();这通常需要分配和复制。反过来,从 String 借成 &str 不复制内容,只建立临时视图。

这个区分能帮你判断 API:借用表示“我只在这段调用里看一眼”,拥有值表示“这份文本归我管理,调用结束后我还可能留着”。生命周期报错往往不是语法在找茬,而是代码没有说清谁负责让那段文本活得够久。

容量同样按字节计算

String::with_capacity(32) 预留的是至少三十二个字节,不是三十二个汉字。capacity()、reserve() 和 shrink_to_fit() 的思路与 Vec 相同,也不要依赖具体扩容倍数。

构建大文本时,预留大致容量可以减少重分配。更重要的是,别在循环里反复用 format! 创建一串中间字符串:

rust
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, "{name}: {score}").expect("writing to String cannot fail");
    }
 
    output
}
 
fn main() {
    let report = render_rows(&[("小林", 92), ("小周", 87)]);
    print!("{report}");
}
text
小林: 92
小周: 87

这里的容量估计不必精确。估少了,字符串仍会自动增长;估多了,会暂时多占一些内存。预分配是基于实际数据规模的优化,不该为了追求“零扩容”写出复杂脆弱的长度预测。

追加、拼接与修改

push 追加一个 char,push_str 追加 &str:

rust
fn main() {
    let mut message = String::from("编译");
    message.push('器');
    message.push_str("通过了");
    message.push('!');
 
    println!("{message}");
}
text
编译器通过了!

+ 也能拼接,但它会取得左侧 String 的所有权:

rust
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,因此面向用户的字符位置通常要先转换成字节位置。

rust
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}");
}
text
/api/v2/users/42

删除某类字符时,retain 很顺手:

rust
fn main() {
    let mut phone = String::from("138-0013 8000");
    phone.retain(|ch| ch.is_ascii_digit());
    println!("{phone}");
}
text
13800138000

遍历策略取决于你要处理的层级

bytes() 适合协议、编码和 ASCII 级别的检查;chars() 适合 Unicode 标量值;char_indices() 同时给出字节位置和字符,适合最后还要安全切片的解析逻辑。分词与分行则尽量用更高层的方法,如 split_whitespace、split 和 lines。

rust
fn normalize_whitespace(input: &str) -> String {
    input.split_whitespace().collect::<Vec<_>>().join(" ")
}
 
fn main() {
    let raw = "  Rust\t集合\n并不神秘  ";
    println!("{}", normalize_whitespace(raw));
}
text
Rust 集合 并不神秘

这段写法清楚,但为了 join 暂时收集了一个 Vec<&str>。文本很大、内存很敏感时,可以用迭代器和单个输出字符串逐段追加,避免中间向量。平常别急着为几行配置文本手工重写;你需要的是知道这份中间分配存在,等测量表明它真是瓶颈时再处理。

查找返回的是字节位置

find 和 rfind 找到匹配文本时,返回匹配起点的字节索引。这个索引可以用于同一份、尚未修改的字符串切片,因为匹配起点一定是合法字符边界:

rust
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();
    Some((&text[..index], &text[after..]))
}
 
fn main() {
    let (left, right) = split_once_at_keyword("所有权 -> 借用", " -> ").unwrap();
    println!("{left} | {right}");
}
text
所有权 | 借用

这里的 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 中,需要显式导入:

rust
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);
 
    println!("{:?}", limits.get("pro"));
    println!("{}", limits.contains_key("team"));
}
text
Some(10000)
false

表里存的是 String 键,查询时却能直接传 &str。标准集合的借用查询允许你用与键一致的借用形式查找,避免为了查一次就临时分配 "pro".to_string()。这种细节在高频查询路径里很实用。

get 返回 Option<&V>,get_mut 返回 Option<&mut V>。如果缺失是正常业务分支,就用 match、if let 或组合方法处理,不要随手 unwrap 把“没找到”变成程序崩溃。

插入时谁拿走所有权

如果键和值是 Copy 类型,插入时复制值;如果是 String 这类拥有堆数据的类型,insert 会把所有权移动进表:

rust
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!("{:?}", profiles.get("alice"));
}

插入后再打印 user 或 city 会得到“使用已移动值”的编译错误。这个行为不难理解:表既然要在当前作用域之后继续保存字符串,就得成为它们的主人。

如果别处还要独立拥有同样的文本,你可以在插入前克隆,但要承认它在复制和分配。若只想保留一份数据,也可以让表存引用;代价是表的可用时间不能超过被引用数据,后续移动或释放源数据都会受限制。短期解析统计适合借用键,长期缓存或跨层返回通常更适合拥有键。

insert 覆盖值,并把旧值还给你

同一个键只能对应一个当前值。再次 insert 会替换旧值,返回 Option<V>:

rust
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:?}");
    println!("replaced={replaced:?}");
    println!("current={:?}", settings.get("timeout"));
}
text
first=None
replaced=Some(10)
current=Some(30)

别忽略这个返回值背后的业务问题:重复键应该覆盖、拒绝、保留旧值,还是把新旧值合并?HashMap 不会替你决定。配置加载常有“后写覆盖前写”,注册表可能要求“重复即报错”,计数器则需要“旧值加一”。先明确语义,再挑方法。

entry 把“查找后更新”合成一次决策

词频统计是 entry 最经典也最自然的场景:

HashMap entry 在键存在与缺失时的处理流程

rust
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).or_insert(0) += 1;
    }
 
    counts
}
 
fn main() {
    let counts = word_counts("rust safe rust fast");
    println!("rust={}", counts["rust"]);
    println!("safe={}", counts["safe"]);
}
text
rust=2
safe=1

entry(word) 表示“我要处理这个键对应的槽位”。or_insert(0) 在缺失时放入 0,无论原来存在与否,最终都返回值的可变引用,所以前面的 * 是在修改计数本身。

默认值创建昂贵时,用 or_insert_with 延迟计算;已有则改、没有则插,可以连写:

rust
use std::collections::HashMap;
 
fn main() {
    let mut totals = HashMap::from([("alice", 10_u32)]);
 
    totals
        .entry("alice")
        .and_modify(|total| *total += 5)
        .or_insert(5);
 
    totals
        .entry("bob")
        .and_modify(|total| *total += 5)
        .or_insert(5);
 
    println!("alice={}", totals["alice"]);
    println!("bob={}", totals["bob"]);
}
text
alice=15
bob=5

更复杂的重复键处理可以匹配 Entry::Occupied 和 Entry::Vacant。这样你能在一次定位结果上分别写“已存在”和“不存在”的逻辑,而不是先 contains_key 再 get_mut 做两遍概念上重复的查询。

下面的交互实验把 Occupied、Vacant、or_insert 和 and_modify 放在同一条流程里。修改键的初始状态与更新动作,可以观察不同组合最终落入哪个分支。

借用冲突常出现在“先看再改”

下面的代码会被拦下:

rust
use std::collections::HashMap;
 
fn main() {
    let mut scores = HashMap::from([
        (String::from("alice"), 90),
        (String::from("bob"), 80),
    ]);
 
    let alice = scores.get("alice").unwrap();
    scores.insert(String::from("carol"), 70);
    println!("alice={alice}");
}

alice 是指向表内值的不可变引用,而 insert 需要可变借用整张表。插入可能导致哈希表调整内部存储,所以编译器不会让那个内部引用跨过修改继续存活。解决思路和 Vec 一样:尽快用完引用、复制小值、克隆确实需要独立拥有的大值,或者重组为一次 entry 操作。

对于上例的整数分数,复制值最直接:

rust
use std::collections::HashMap;
 
fn main() {
    let mut scores = HashMap::from([
        (String::from("alice"), 90),
        (String::from("bob"), 80),
    ]);
 
    let alice = scores.get("alice").copied().unwrap_or(0);
    scores.insert(String::from("carol"), 70);
    println!("alice={alice}");
}

有时你会尝试在遍历 &map 时直接插入或删除,这同样不行:迭代器正在借用整张表,结构性修改可能让迭代状态失效。常见做法是先收集要修改的键,循环结束后统一修改;只改现有值则用 iter_mut。把读取阶段和结构变更阶段拆开,代码往往也更容易审查。

遍历顺序不要拿来做协议

HashMap 不承诺按插入顺序或键排序遍历。内部顺序可能随数据、容量、哈希状态或版本变化。下面这种测试很脆弱:把整张表 Debug 打印出来,然后要求字符串完全一致。

需要稳定输出时,把条目收集到向量再排序:

rust
use std::collections::HashMap;
 
fn main() {
    let map = HashMap::from([("b", 2), ("a", 1), ("c", 3)]);
    let mut entries: Vec<_> = map.iter().collect();
    entries.sort_unstable_by_key(|(key, _)| *key);
 
    for (key, value) in entries {
        println!("{key}={value}");
    }
}
text
a=1
b=2
c=3

如果业务持续需要按键有序遍历、范围查询或找相邻键,每次临时排序也许说明结构选错了,可以考虑树形映射。若只是日志或快照偶尔要求稳定,临时排序往往更简单。

键的 Eq 与 Hash 必须讲同一种“相等”

哈希键需要满足相等比较和哈希计算的一致性:两个键如果相等,就必须产生相同的哈希结果。自定义结构通常通过派生实现:

rust
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(
        UserKey { tenant_id: 7, user_id: 42 },
        "admin",
    );
 
    let key = UserKey { tenant_id: 7, user_id: 42 };
    println!("{:?}", roles.get(&key));
}
text
Some("admin")

如果你手写 PartialEq 和 Hash,两边使用的字段必须一致。更麻烦的是,键放进表后,不应该通过内部可变性把影响相等或哈希的字段改掉;那会破坏键所在位置与其当前哈希之间的关系,导致逻辑错误。

默认哈希器倾向兼顾通用性与对恶意碰撞输入的防护,不代表它在每一种工作负载里速度最高。只有性能分析确认哈希计算是瓶颈,并且你清楚输入是否可信时,才考虑替换哈希器。更快的基准数字若以削弱抗碰撞能力为代价,对公开接口可能是一笔很差的交易。

容量方面,HashMap::with_capacity(n) 和 reserve(n) 能减少已知规模导入时的重新分配。它们仍然只是容量规划,不改变无序语义,也不会让平均复杂度变成绝对保证。

从两个映射合并数据时先定义冲突规则

把一张映射并进另一张看似只是 extend,其实最关键的是重复键怎么办。直接扩展时,来源中重复键对应的值会成为目标里的最终值。这适合“命令行参数覆盖配置文件”一类后者优先的规则,却不适合要求重复即报错的注册信息。

需要相加、拼接或拒绝时,把规则写出来:

rust
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) += count;
    }
}
 
fn main() {
    let mut total = HashMap::from([
        (String::from("ok"), 10),
        (String::from("error"), 1),
    ]);
    let next = HashMap::from([
        (String::from("ok"), 4),
        (String::from("timeout"), 2),
    ]);
 
    merge_counts(&mut total, next);
    assert_eq!(total.get("ok"), Some(&14));
    assert_eq!(total.get("timeout"), Some(&2));
}

incoming 按值传入并在循环中被消费,键可以直接移动进 target,不需要克隆。若调用者之后还要使用来源映射,就让函数借用它,但这样要把键复制进目标,或者让两个映射采用能共享所有权的数据模型。函数参数已经把这项权衡写在了签名里。

拒绝重复键时,可以在循环里匹配 entry,一旦遇到已占用入口就返回错误。还要进一步决定失败前已插入的项目是否允许保留;若要求全有或全无,应先验证全部键或在临时映射中完成合并,确认成功后再替换正式状态。哈希表只提供操作工具,原子性仍是业务层的责任。


HashSet<T>:只关心“有没有”

如果数据只有键,没有与之关联的额外值,用 HashMap<T, ()> 可以实现,但意图很别扭。HashSet<T> 直接表达“这些值出现过”。去重、黑名单、权限集合、已访问节点和标签匹配,都属于它的常见用法。

插入的返回值很有信息量

insert 返回 bool:新加入时是 true,原来已经存在时是 false。你不需要先 contains 再 insert:

rust
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}");
        }
    }
 
    println!("unique={}", seen.len());
}
text
重复编号:42
重复编号:7
unique=3

contains 判断成员,remove 删除并告诉你之前是否存在,take 则删除后把集合里真正存着的那个值返回。字符串集合也支持借用查询,HashSet<String> 可以用 set.contains("rust"),没必要为查询创建临时 String。

与 HashMap 一样,元素要实现一致的 Eq 和 Hash,遍历顺序也没有保证。如果你只是要对小列表去重后继续按原顺序显示,HashSet 通常是辅助索引,不应直接把最终显示顺序寄托在集合遍历上。

并集、交集、差集和对称差集

假设 required 是接口要求的权限,granted 是用户实际拥有的权限:

HashSet 并集交集差集与对称差集示意图

rust
use std::collections::HashSet;
 
fn main() {
    let required = HashSet::from(["read", "write"]);
    let granted = HashSet::from(["read", "admin"]);
 
    let mut missing: Vec<_> = required.difference(&granted).copied().collect();
    let mut shared: Vec<_> = required.intersection(&granted).copied().collect();
    let mut all: Vec<_> = required.union(&granted).copied().collect();
    let mut only_one_side: Vec<_> = required
        .symmetric_difference(&granted)
        .copied()
        .collect();
 
    missing.sort_unstable();
    shared.sort_unstable();
    all.sort_unstable();
    only_one_side.sort_unstable();
 
    println!("missing={missing:?}");
    println!("shared={shared:?}");
    println!("all={all:?}");
    println!("only_one_side={only_one_side:?}");
}
text
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”,只是所有权需求不同。

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!(merged.len(), 4);
    assert!(merged.contains(&4));
}

自定义元素的相等语义就是去重语义

HashSet 如何判断重复,完全取决于元素的 Eq 和 Hash。把完整结构直接派生进去,表示所有参与派生的字段都相等时才算同一个元素:

rust
use std::collections::HashSet;
 
#[derive(Debug, PartialEq, Eq, Hash)]
struct Endpoint {
    method: String,
    path: String,
}
 
fn main() {
    let endpoints = HashSet::from([
        Endpoint { method: String::from("GET"), path: String::from("/users") },
        Endpoint { method: String::from("POST"), path: String::from("/users") },
    ]);
 
    assert_eq!(endpoints.len(), 2);
}

如果业务认为同一路径无论方法是什么都算重复,就不该直接派生完整结构后期待集合猜到规则。可以改用路径字符串当集合元素,也可以定义一个只包含业务身份字段的键类型。让键类型表达身份,通常比手写一套容易不一致的比较和哈希实现更稳。

浮点数也提醒我们“相等”并不总适合作哈希键。普通浮点类型要处理 NaN 等特殊语义,不能直接当作满足常规 Eq 的键。经纬度、金额或测量值需要去重时,先定义业务精度和规范化方式,比如转换成确定单位的整数,或使用明确实现所需相等语义的包装类型。直接问“这两个浮点数是不是同一个业务值”没有统一答案。

集合元素放入后还应保持影响哈希与相等的内容不变。通常安全 Rust 很难直接拿到集合内键的可变引用,这正是为了守住内部索引。如果确实通过内部可变性改变这些字段,可能造成查找失灵等逻辑错误。要修改键,稳妥流程是先 take 或移除拥有值,修改后再插回去。

保留输入顺序的去重

数据清洗常要求去重,但保留第一次出现的位置。可以让 Vec 负责顺序,让 HashSet 负责快速判断:

rust
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() {
    let mut tags = vec![
        String::from("rust"),
        String::from("web"),
        String::from("rust"),
        String::from("cli"),
        String::from("web"),
    ];
 
    dedup_preserving_order(&mut tags);
    println!("{tags:?}");
}
text
["rust", "web", "cli"]

这段代码为了让 seen 拥有键,克隆了保留下来的候选字符串。若数据很大,可以重新设计所有权流,例如消费原向量,把第一次出现的元素移动到结果向量,同时用另一种可共享的标识做索引。别急着把示例复杂化,但要知道克隆发生在哪里。


集合上的借用冲突:编译器到底在防什么

在普通局部变量上理解“一次可变借用或多次不可变借用”还算直观;到了集合里,它突然变得刺手,因为一次结构性修改可能影响许多元素的位置。编译器必须按最坏的合法行为检查,而不是赌这一轮运行碰巧不扩容、不重排。

持有元素引用时修改容器结构

Vec::push 可能重分配,HashMap::insert 可能调整内部表,String::push_str 可能搬动字节缓冲区。只要你还持有指向容器内部的引用,这类可变操作就可能让引用失效。

Vec 持有元素引用时调用 push 的借用冲突示意图

处理它时可以按以下顺序想:

  1. 这个引用能不能更早用完?现代 Rust 会根据最后一次使用缩短借用,调整语句顺序常常就够了。
  2. 我真正需要的是引用,还是一个小型可复制值?整数、布尔值和许多轻量标识可以 copied()。
  3. 能不能记住索引或键,修改后再查一次?这避免长期持有内部地址。
  4. 是否真的需要独立拥有一份数据?需要时再 clone,并接受成本。
  5. 操作能否改写成集合提供的一次性 API?entry、split_at_mut、retain 往往已经替你表达了不重叠或单次定位。

不要用“我已经调用了 with_capacity,所以肯定不会扩容”来和借用检查器对赌。预分配能改善运行时行为,却不是类型系统可依赖的不失效证明。今天的追加量、元素类型或实现策略一变,这个假设就可能失效。

遍历时修改集合

for item in &items 在整个循环期间借用了集合。循环体里再 push、remove 或 clear,会与这个借用冲突。更重要的是,即使某种语言允许这么做,“新追加的元素是否也要遍历”“删除后下一个位置是谁”都容易变得含糊。

如果只修改每个现有元素,用 iter_mut:

rust
fn main() {
    let mut latencies = vec![100_u32, 250, 80];
 
    for latency in &mut latencies {
        *latency = latency.saturating_sub(20);
    }
 
    println!("{latencies:?}");
}
text
[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 等文本语义迭代,而不是把它当普通集合逐项改字符。

下面把一批原始路径清洗成扩展名集合:

rust
use std::collections::HashSet;
 
fn extensions(paths: &[String]) -> HashSet<String> {
    paths
        .iter()
        .filter_map(|path| path.rsplit_once('.').map(|(_, ext)| ext))
        .map(str::to_ascii_lowercase)
        .collect()
}
 
fn main() {
    let paths = vec![
        String::from("src/main.RS"),
        String::from("assets/logo.png"),
        String::from("README"),
        String::from("tests/api.rs"),
    ];
 
    let exts = extensions(&paths);
    assert_eq!(exts.len(), 2);
    assert!(exts.contains("rs"));
    assert!(exts.contains("png"));
    println!("原路径仍有 {} 条", paths.len());
}

iter() 说明函数只借用路径;filter_map 同时过滤没有扩展名的路径并取出扩展名;小写转换生成新的 String,最终集合拥有这些结果,所以它可以脱离原路径列表存在。

collect 的目标类型决定结果

同一条迭代器可以收进 Vec、HashSet 或 HashMap。编译器需要从变量标注或涡轮鱼语法知道目标:

rust
use std::collections::{HashMap, HashSet};
 
fn main() {
    let list: Vec<_> = [3, 1, 3, 2].into_iter().collect();
    let unique: HashSet<_> = list.iter().copied().collect();
    let labels: HashMap<_, _> = unique
        .iter()
        .copied()
        .map(|n| (n, format!("item-{n}")))
        .collect();
 
    println!("list_len={}", list.len());
    println!("unique_len={}", unique.len());
    println!("label_2={:?}", labels.get(&2));
}
text
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,你就该问“这行之后原变量还要不要用”。

这三种接收方式也能解释为什么有些代码只差一个 &,结果却完全不同:

rust
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!("lengths={borrowed_lengths:?}");
 
    let upper: Vec<_> = names
        .into_iter()
        .map(|name| name.to_uppercase())
        .collect();
    println!("upper={upper:?}");
}

第一次遍历只借用,所以还能打印 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,是在类型上表达“缺失是可能发生的”。处理它时,选择应该贴合业务:

rust
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("匿名用户")
}
 
fn main() {
    let users = HashMap::from([(7, String::from("小周"))]);
    println!("{}", display_name(&users, 7));
    println!("{}", display_name(&users, 9));
}
text
小周
匿名用户

默认值合理时,unwrap_or 很清楚;缺失要上报错误时,可以把 Option 转成 Result;缺失代表跳过时,filter_map 或 let Some(...) else 更自然。下标操作相当于“我断言一定存在,否则 panic”,它不是坏 API,只是断言强得多。解析外部数据时,这个断言往往没有依据;处理算法内部已经验证过的索引时,它可能正合适。

闭包的参数也暴露了修改边界

retain 的闭包看到元素借用,并通过布尔值决定保留与否;它不允许你在闭包里再随意改变同一集合结构。sort_by 给闭包两个元素引用,闭包返回顺序判断;比较函数应保持一致,不要一会儿说 a < b,换个调用顺序又给出矛盾结果。

entry(...).and_modify(|value| ...) 给闭包现有值的 &mut V,这表示你可以修改值,却不能借着这份引用同时对整张表做另一次结构修改。把权限限制在单个值上,正是 API 能在保证内部结构有效的前提下开放修改能力的方式。

rust
use std::collections::HashMap;
 
fn apply_delta<'a>(
    balances: &mut HashMap<&'a str, i64>,
    user: &'a str,
    delta: i64,
) {
    balances
        .entry(user)
        .and_modify(|balance| *balance += delta)
        .or_insert(delta);
}
 
fn main() {
    let mut balances = HashMap::new();
    apply_delta(&mut balances, "alice", 30);
    apply_delta(&mut balances, "alice", -8);
    assert_eq!(balances.get("alice"), Some(&22));
}

签名读法还有一个实际收益:你会更少写“先试一下再看编译器骂什么”的随机代码。编译错误仍然不可避免,尤其生命周期复杂时所有人都会卡住,但你至少能提前判断冲突来自消费、共享借用还是结构修改。


集合代码里常见的隐蔽事故

能编译不等于业务语义正确。集合最麻烦的线上问题,常常不是悬垂引用——那类问题已经被编译器挡下——而是顺序、索引、重复键、容量和文本边界这些规则被代码默默误解。下面几类事故很值得提前认识。

删除后继续使用旧索引

索引不是元素的永久身份证。Vec::remove(i) 会让后续元素下标减一,swap_remove(i) 更会把末尾元素放到 i。如果另一个表长期保存了向量下标,删除后它可能指向错误对象,即使访问仍然没有越界。

rust
#[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" },
        Job { id: 30, name: "deploy" },
    ];
 
    let remembered_index = 1;
    jobs.remove(0);
 
    println!("{}", jobs[remembered_index].name);
}
text
deploy

原来下标 1 是 test,删除首项后却成了 deploy。编译器无法知道 remembered_index 在业务上承诺指向谁。需要稳定身份时,应保存 id 并建立按 ID 查询的映射,或者使用专门的稳定句柄设计。若索引只在一次短循环里使用,Vec 仍然很好;问题出在把位置误当身份。

把哈希遍历顺序写进快照和分页

哈希表本来无序,但不稳定往往直到测试或生产升级才暴露。你可能在本机看到 a, b, c,便把这个顺序写进快照;也可能直接对哈希迭代器 skip(page * size).take(size) 做分页。下一次运行内部顺序变化,同一页用户突然重复或消失。

分页需要明确排序键,并处理相同排序值的稳定次序。报告需要稳定文本,就收集并排序。若顺序是核心业务属性,应从数据模型里保存它,而不是从一次碰巧的遍历结果里推断。

在归一化之前把文本当键

用户名、标签、文件扩展名和邮箱看起来是字符串,但“业务上相同”未必等于字节完全相同。Rust 与 rust 是否同一个标签?全角空格要不要去掉?路径是否区分大小写?这些不是 HashMap 能替你回答的问题。

常见做法是在进入映射前建立唯一、明确的归一化步骤:

rust
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<String, usize> {
    let mut counts = HashMap::new();
 
    for tag in tags {
        let key = normalize_tag(tag);
        *counts.entry(key).or_insert(0) += 1;
    }
 
    counts
}
 
fn main() {
    let counts = count_tags([" Rust ", "rust", "RUST"]);
    assert_eq!(counts.get("rust"), Some(&3));
}

to_lowercase 处理 Unicode 大小写映射,但它仍不等于所有领域里的规范化规则。账号系统、文件系统和自然语言文本的要求不同。关键是把规则集中在入口,而不是一处转小写、另一处只 trim,最后表里出现多套互相找不到的键。

边遍历边靠索引删除会跳项

一个常见手写循环是从 0 递增索引,遇到不合格元素就 remove(i)。删除后后一个元素会移动到当前 i,若循环仍然递增,就会漏检它。

rust
fn main() {
    let mut values = vec![1, 2, 4, 6, 7];
    values.retain(|value| value % 2 != 0);
    println!("{values:?}");
}
text
[1, 7]

retain 已经把“保留满足条件的项”说清楚,也正确处理连续删除。确实需要手写索引时,可以只在未删除时递增,或从后向前删,但先看看集合有没有直接表达意图的方法。

把容量长期当成内存用量

clear 之后长度为零,容量通常仍然保留。这对反复处理相近大小的批次很有价值:一个缓冲区可以多轮复用,减少分配。但如果某一轮突然读入百万条数据,随后这个集合进入长期空闲,巨大的容量也可能一直跟着对象。

处理方式不是每轮都 shrink_to_fit。你可以在批次结束后根据阈值决定是否收缩,或者直接丢弃异常大的缓冲区并创建新集合。阈值要来自服务内存目标和批次分布,而不是凭空选择。容量复用与及时归还是一组权衡,不能同时把两边成本都抹掉。

解析一半后才发现错误,留下半更新状态

假设你把一行配置拆成多个字段,一边解析一边写入多个集合,最后一个字段失败时,前面的写入已经发生。代码内存安全,却留下了业务上的半成品。

更稳的做法通常是先解析并验证成一个临时拥有值,全部成功后再提交到集合:

rust
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('=')?;
    let name = name.trim();
    let limit = raw_limit.trim().parse().ok()?;
 
    if name.is_empty() {
        return None;
    }
 
    Some(Rule { name: name.to_owned(), limit })
}
 
fn apply_rule(rules: &mut HashMap<String, u32>, line: &str) -> bool {
    let Some(rule) = parse_rule(line) else {
        return false;
    };
 
    rules.insert(rule.name, rule.limit);
    true
}

这是一种小型的“先准备,后提交”。跨多个集合的复杂更新还可能需要显式回滚或事务机制,但至少不要在尚未确认输入完整时到处修改状态。所有权在这里反而很顺手:临时 Rule 独立拥有解析结果,提交时再把字段移动进映射,没有额外克隆。

排查集合问题时按语义顺序走

遇到结果不对或借用错误时,可以按一个固定顺序缩小范围:

  1. 确认集合是否承诺你依赖的顺序、唯一性和索引稳定性。
  2. 标出拥有值和借用值,看看哪一个引用跨过了结构修改。
  3. 区分长度与容量、字节位置与字符位置、位置与业务身份。
  4. 检查重复键、缺失键和错误输入分别采取了什么策略。
  5. 最后再看复杂度、分配次数和哈希成本。

先查语义,后查性能,能避免你把一个排序缺失的问题误诊成哈希器问题,也能避免为了消除一次克隆而改出更难维护的生命周期结构。


怎么选择集合:先看操作,再看数据量

选集合时不要先问“哪个最快”,这个问题缺少操作。随机访问、末尾追加、按键查询、有序遍历、头尾弹出和去重,快的结构不同。更实用的判断顺序是:我最常做什么?必须保留什么语义?能接受什么成本?

Rust 常用集合按操作需求进行选型的决策图

默认从 Vec 开始的情况

以下需求通常适合 Vec:

  • 数据有明确顺序,且经常顺序遍历。
  • 需要按整数下标访问。
  • 主要在末尾追加或删除。
  • 数据规模不大,偶尔的中间插删并不构成瓶颈。
  • 需要方便地借成切片交给其他 API。

即使理论上存在更专门的结构,Vec 的连续布局和简单语义也常让它成为很好的起点。不要因为“链表中间插入是常数时间”就立刻换链表;找到插入位置、内存局部性和节点分配也有成本,真实程序未必获益。

选择映射或集合的情况

当核心动作是“给我这个业务键对应的值”,用 HashMap。当核心动作只有“这个值出现过吗”,用 HashSet。两者都以无序为前提,也都需要哈希和相等语义。

如果需要持续按键排序、范围查询、最小或最大键,树形映射或树形集合更贴近需求。若只是最后输出一次有序结果,哈希结构加临时排序可能更省事。选择取决于“每次操作都要有序”还是“最终展示要有序”。

频繁从两端加入和取出元素时,双端队列通常比 Vec::remove(0) 合适。需要总是取出优先级最高的任务时,优先队列比每次完整排序更贴近动作。知道标准库还有这些结构,是为了在需求明显偏离时换工具,不是要求你每次都从七八种容器里做一场学术评审。

性能判断别漏掉常数、分配和缓存

复杂度能告诉你规模增长的趋势,却不会告诉你全部现实成本。一个几十项的 Vec 线性搜索,可能比哈希表更简单、更紧凑,也足够快;一个巨大的 HashMap 可能把大量时间花在哈希和内存访问上。反过来,数据达到百万级后,每次线性扫描就可能明显拖慢请求。

建议把选择拆成两层:

  1. 先用语义正确、实现清楚的结构。
  2. 再用真实工作负载测量热点,针对容量、哈希器、排序策略或结构做调整。

预分配、sort_unstable、避免克隆都可能有效,但它们必须建立在测量和语义允许之上。为了省一次分配而引入难懂的生命周期,为了快一点而让公开输入失去抗碰撞保护,通常不是好交易。

集合选型最可靠的起点不是背复杂度表,而是把主操作写成一句话:按位置取、按键找、判断出现过、从两端处理,还是始终取最高优先级。句子说清楚后,候选结构往往已经很少了。


综合场景:把访问日志整理成报告

我们把四种集合放进同一个真实任务。输入是一批访问日志,每行包含用户、路径和状态码:

text
alice /docs 200
bob /login 401
alice /docs 200
alice /download 503
bad-line

要求是:保留合法记录的输入顺序,统计每个状态码的次数,找出独立用户,并生成稳定排序的摘要。这个任务里没有一种集合包打天下:

  • Vec<LogEntry> 保留合法记录顺序。
  • HashMap<u16, usize> 统计状态码。
  • HashSet<String> 记录独立用户。
  • String 构建最终报告。

定义数据并解析单行

rust
#[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 = parts.next()?;
    let path = parts.next()?;
    let status = parts.next()?.parse().ok()?;
 
    if parts.next().is_some() {
        return None;
    }
 
    Some(LogEntry {
        user: user.to_owned(),
        path: path.to_owned(),
        status,
    })
}

line 只在解析调用期间有效,而 LogEntry 要被放进结果向量继续保存,所以这里把用户和路径转成拥有的 String。这两次分配不是无脑克隆,它们对应明确的所有权边界:解析结果要独立于输入切片存在。

一次遍历完成收集

rust
use std::collections::{HashMap, HashSet};
 
#[derive(Debug)]
struct LogEntry {
    user: String,
    path: String,
    status: u16,
}
 
#[derive(Debug)]
struct ReportData {
    entries: Vec<LogEntry>,
    status_counts: HashMap<u16, usize>,
    users: HashSet<String>,
    invalid_lines: usize,
}
 
fn parse_line(line: &str) -> Option<LogEntry> {
    let mut parts = line.split_whitespace();
    let user = parts.next()?;
    let path = parts.next()?;
    let status = parts.next()?.parse().ok()?;
 
    if parts.next().is_some() {
        return None;
    }
 
    Some(LogEntry {
        user: user.to_owned(),
        path: path.to_owned(),
        status,
    })
}
 
fn analyze(input: &str) -> ReportData {
    let estimated = input.lines().count();
    let mut data = ReportData {
        entries: Vec::with_capacity(estimated),
        status_counts: HashMap::new(),
        users: HashSet::new(),
        invalid_lines: 0,
    };
 
    for line in input.lines() {
        let Some(entry) = parse_line(line) else {
            data.invalid_lines += 1;
            continue;
        };
 
        *data.status_counts.entry(entry.status).or_insert(0) += 1;
        data.users.insert(entry.user.clone());
        data.entries.push(entry);
    }
 
    data
}

这里有一处显眼的 clone:用户名既要由 entries 中的记录拥有,也要由 users 集合拥有。两个容器都要在函数结束后独立使用这份键,所以复制有业务理由。若日志量巨大且用户名重复很多,可以进一步做字符串驻留、使用共享所有权或改变数据模型;那是测量后再做的设计,不需要在入门版本里预先堆上复杂度。

entries 根据行数预留容量,即使有坏行导致估计偏大也没关系。状态码和用户数量通常远小于日志行数,没有可靠估计时先让哈希集合自动增长,代码更朴素。

生成顺序稳定的文本

rust
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 mut users: Vec<_> = data.users.iter().collect();
    users.sort_unstable();
 
    let mut output = String::new();
    writeln!(&mut output, "合法记录:{}", data.entries.len()).unwrap();
    writeln!(&mut output, "非法行:{}", data.invalid_lines).unwrap();
    writeln!(&mut output, "独立用户:{}", data.users.len()).unwrap();
 
    for (status, count) in statuses {
        writeln!(&mut output, "状态 {status}: {count}").unwrap();
    }
 
    for user in users {
        writeln!(&mut output, "用户:{user}").unwrap();
    }
 
    output
}

这里没有直接打印 HashMap 或 HashSet,而是借用条目、收集引用并排序。原集合不被消费,报告每次生成的顺序稳定,测试也就不需要猜哈希表内部顺序。

如果还要按输入顺序显示失败请求,可以遍历 entries.iter().filter(|entry| entry.status >= 500);如果生成报告后再也不需要分析数据,可以让渲染函数接收 ReportData 并消费它,减少某些需要克隆的场景。参数用借用还是拥有值,取决于调用之后还需不需要原数据,而不是固定风格。

这个综合例子把集合的核心关系摆在了一起:Vec 管顺序,哈希结构管快速关联和成员资格,String 管最终文本,每一次借用或移动都服务于明确的数据寿命。


练习:让集合规则变成手感

下面的练习不追求背方法名,重点是先判断所有权和操作语义。动手时建议先故意写出那个会失败的版本,读完编译错误再改。编译器指出的是引用在哪里仍然活着、哪次操作需要可变借用,这些位置比只看错误编号更有帮助。

修复 Vec 的借用冲突

下面的函数想记录第一个任务名,再追加一个任务。请解释为什么失败,并在不克隆整个向量的前提下修复:

rust
fn main() {
    let mut tasks = vec![String::from("build"), String::from("test")];
    let first = &tasks[0];
    tasks.push(String::from("deploy"));
    println!("first={first}");
}

first 借用了向量内部的 String,而 push 需要可变借用向量,并可能在扩容时搬动全部元素。若追加后只需知道原先第一个位置,可以记住索引,修改后重新借用:

rust
fn main() {
    let mut tasks = vec![String::from("build"), String::from("test")];
    let first_index = 0;
 
    tasks.push(String::from("deploy"));
 
    println!("first={}", tasks[first_index]);
}

如果需求是保存“当时那份名称”,并允许以后向量修改甚至删除首项,那么需要独立所有权,可以只克隆第一个字符串:

rust
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}");
}

两种修复语义不同:索引版本读取修改后的当前位置,克隆版本保存修改前的独立快照。选择哪一个要看业务,不是看哪段更短。

安全截取 UTF-8 文本

实现 take_chars(text, count),返回前 count 个 Unicode 标量值对应的 &str。要求 count 超过文本长度时返回完整文本,不能分配新字符串。

rust
fn take_chars(text: &str, count: usize) -> &str {
    text.char_indices()
        .nth(count)
        .map(|(byte_index, _)| &text[..byte_index])
        .unwrap_or(text)
}
 
fn main() {
    assert_eq!(take_chars("你好Rust", 0), "");
    assert_eq!(take_chars("你好Rust", 2), "你好");
    assert_eq!(take_chars("你好Rust", 4), "你好Ru");
    assert_eq!(take_chars("你好Rust", 99), "你好Rust");
}

char_indices 给出的索引一定落在合法 UTF-8 边界上,因此可以用于切片。返回值借用自输入,既没有复制,也不能比输入文本活得更久。

用 entry 做稳定的 Top-K 词频

实现一个函数:忽略 ASCII 大小写统计空白分隔的单词,按频次降序返回前 k 个;频次相同则按单词升序,保证输出稳定。

rust
use std::collections::HashMap;
 
fn top_k(text: &str, k: usize) -> Vec<(String, usize)> {
    let mut counts: HashMap<String, usize> = HashMap::new();
 
    for word in text.split_whitespace() {
        let normalized = word.to_ascii_lowercase();
        *counts.entry(normalized).or_insert(0) += 1;
    }
 
    let mut result: Vec<_> = counts.into_iter().collect();
    result.sort_unstable_by(|a, b| {
        b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))
    });
    result.truncate(k);
    result
}
 
fn main() {
    let result = top_k("Rust rust SAFE safe fast", 2);
    assert_eq!(
        result,
        vec![(String::from("rust"), 2), (String::from("safe"), 2)]
    );
}

哈希表负责统计,向量负责最终排序。若只按频次排序,相同频次的顺序会受到哈希遍历顺序影响,测试可能时好时坏;补上单词作为第二比较条件后,输出才真正稳定。

用集合运算检查权限

给定 required 和 granted 两个权限集合,返回按字典序排列的缺失权限。不要修改输入集合。

rust
use std::collections::HashSet;
 
fn missing_permissions(
    required: &HashSet<String>,
    granted: &HashSet<String>,
) -> Vec<String> {
    let mut missing: Vec<_> = required
        .difference(granted)
        .cloned()
        .collect();
    missing.sort_unstable();
    missing
}
 
fn main() {
    let required = HashSet::from([
        String::from("read"),
        String::from("write"),
        String::from("delete"),
    ]);
    let granted = HashSet::from([
        String::from("read"),
        String::from("admin"),
    ]);
 
    assert_eq!(
        missing_permissions(&required, &granted),
        vec![String::from("delete"), String::from("write")]
    );
}

difference 产出的是对集合元素的借用。这里把缺失权限克隆成拥有的 String,因此返回结果可以独立于两个输入集合继续使用;相应的复制成本也真实存在。如果结果只在输入集合有效期间临时检查,可以直接消费差集迭代器,不必先创建拥有值向量。

1
一个 Vec 已经预留了足够容量。持有其中某个元素的引用时,能否同时调用 push?
2
哪些做法能让哈希集合或哈希映射的文本输出保持稳定顺序?
3
String::len() 返回 Unicode 字符数量。

走到这里,你不需要把每个方法都背下来。真正要形成的手感是:看到集合操作时,先问数据是否连续、是否有顺序、谁拥有元素、引用要活多久、修改会不会改变内部结构。带着这些问题去读方法签名,编译器的拒绝就不再像一堵墙,更像一位负责的同事在提醒你:这份数据的去向还没交代清楚。

上一章基本类型下一章函数