Rust 字符串、集合与迭代器实战
更新时间:2026-08-31。本文回答:
String和&str到底什么区别?Vec扩容会不会很慢?为什么迭代器写法比手写循环快? 这些是写真实 Rust 程序(解析日志、统计词频、处理数据)的日常基本功。
一、先想一个问题:集合为什么值得单独讲
所有权规则讲了"值怎么移动",但真实程序里数据很少是单个 String,更多是"一堆字符串""一组数字""键值对"。集合类型是所有权的集中演练场:插入、读取、遍历、传参,每个操作都在和借用检查器打交道。这一篇把最常用的三种集合(String/Vec/HashMap)过一遍,顺带讲清迭代器这个"看起来是语法糖、其实是零成本"的东西。
二、String 与 &str:谁拥有数据
String 是拥有的、可变的、堆上的 UTF-8 字符串;&str 是对字符串的不可变借用视图(可以指向字符串字面量、String 的一部分或 &[u8] 的一段)。这个区别决定了两件事:谁负责释放、能不能原地修改。
let s1 = String::from("hello"); // 拥有数据,堆上分配
let s2 = &s1[..]; // 借用整个,视图
let s3 = "literal"; // &'static str,编译期常量,零分配
// 转换
let a: &str = &s1; // String -> &str(自动 deref)
let b: String = s3.to_string(); // &str -> String(拷贝到堆)| 操作 | String | &str |
|---|---|---|
| 拥有数据 | ✅ 是,drop 时释放堆 | ❌ 借用,不负责释放 |
| 原地修改 | ✅ push/insert/replace | ❌ 只读 |
| 拼接 | +/push_str | 不可直接拼 |
| 传参默认偏好 | 函数参数多用 &str | 更通用(接受两者) |
为什么函数参数默认写 &str:因为 &str 能同时接受 String(自动转借用)和字面量,调用方不用先 to_string()。这是 Rust 常见的"参数用借用、数据用拥有"设计模式。
fn count_chars(s: &str) -> usize {
s.chars().count() // 注意:s.len() 是字节数,中文会"数错"
}
count_chars(&String::from("hello")); // 传 String 也 OK
count_chars("world"); // 传字面量也 OK中文坑:len() 返回字节数,不是字符数。"你好" 的 len() 是 6(UTF-8 每个汉字 3 字节)。要数"人眼看到的字符"用 .chars().count()。
三、Vec:连续内存,按索引访问
Vec<T> 是堆上的连续数组,类似 C++ 的 std::vector。随机访问 O(1),末尾追加摊还 O(1)(偶尔扩容时整体搬移)。
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
v.push(3);
// 索引访问有边界检查:越界会 panic(不是 UB!)
let x = v[0]; // 1
// let y = v[99]; // panic: index out of bounds
// 安全获取:返回 Option
if let Some(y) = v.get(99) {
println!("{}", y);
} else {
println!("越界了,但没崩");
}索引越界是 panic 而不是 UB——这是 Rust 和 C++ 的关键差异:C++ v[99] 是未定义行为(可能读到垃圾甚至崩溃),Rust v[99] 直接 panic,v.get(99) 返回 None。代价是每次索引多一次边界比较,但编译器通常能优化掉(release 模式 + 可证明范围内)。
扩容策略:和 std::vector 一样按倍数扩容(通常翻倍),摊还 O(1)。可以用 Vec::with_capacity(n) 预分配,避免频繁 realloc:
let mut v = Vec::with_capacity(10_000); // 直接一次分配够
for i in 0..10_000 {
v.push(i);
}四、HashMap:键值对与所有权
HashMap<K, V> 是哈希表,key 唯一,O(1) 平均查找。
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Alice"), 90); // key 是 String,插入时所有权转移
scores.insert(String::from("Bob"), 85);
// entry API:不存在才插入(比 contains_key + insert 更高效)
scores.entry(String::from("Alice")).or_insert(0);
// 遍历(顺序不确定)
for (name, score) in &scores {
println!("{}: {}", name, score);
}所有权细节:insert 的 key 如果是 String,所有权会转移进 map——之后原变量不能再使用(除非用 &str 作 key)。这正是所有权规则在集合里的体现:
let name = String::from("Carol");
scores.insert(name, 88);
// println!("{}", name); // 错误:name 的所有权已移入 map五、迭代器:链式处理的零成本抽象
迭代器(Iterator trait)把"遍历 + 转换 + 聚合"写成链式调用。关键点:它本身是惰性的——不调用 collect()/for 就不实际执行;而且绝大多数操作在编译期被内联优化,跑起来和手写循环等价(甚至更快,因为更容易向量化)。
let nums = vec![1, 2, 3, 4, 5, 6];
// 传统命令式写法
let mut even_squares = Vec::new();
for &n in &nums {
if n % 2 == 0 {
even_squares.push(n * n);
}
}
// 迭代器写法:filter + map + collect
let even_squares: Vec<i32> = nums.iter()
.filter(|&&n| n % 2 == 0)
.map(|&n| n * n)
.collect();
assert_eq!(even_squares, vec![4, 16, 36]);为什么迭代器写法通常更快:手写循环每次迭代都做"边界检查 + 分支",而迭代器的 filter/map 都是薄封装,collect 会用 size_hint 预分配容量,编译器还能做循环融合(多个 adapter 合成一轮循环)。零成本抽象(zero-cost abstraction)在这里的意思就是:抽象的代码,跑起来和手写的一样快,不付运行时税。
量级对比(示意:cargo run --release,对 1000 万元素做 filter+map+sum;具体数值随硬件/编译器波动,量级关系稳定):
| 写法 | 相对耗时 | 说明 |
|---|---|---|
手写 for 循环 | 1× | 基准参考 |
迭代器链 iter().filter().map().sum() | ≈1× | 与手写相当(零成本) |
| Python 列表推导(同机对比参考) | 几十~百倍 | 解释执行 + 每元素装箱 |
关键结论不是具体毫秒数,而是相对量级:迭代器链在 release 下和手写循环打平,Python 因解释执行慢两个数量级。想自己验证,把上面
even_squares的例子换成sum()用time计时即可。
六、从日志里统计词频:综合实战
把上面三个集合串起来——统计一段文本里出现最多的单词(去掉标点和大小写差异):
use std::collections::HashMap;
fn word_freq(text: &str) -> HashMap<String, usize> {
let mut freq = HashMap::new();
for word in text.split(|c: char| !c.is_alphabetic()) {
let w = word.to_lowercase();
if !w.is_empty() {
*freq.entry(w).or_insert(0) += 1; // entry 不存在则插入 0,再自增
}
}
freq
}
fn main() {
let text = "Rust is fast, Rust is safe. Rust owns its memory!";
let mut freq = word_freq(text);
let mut items: Vec<_> = freq.drain().collect();
items.sort_by(|a, b| b.1.cmp(&a.1)); // 按出现次数降序
for (word, count) in items.iter().take(5) {
println!("{:>8} {}", count, word);
}
}输出:
3 rust
2 is
1 safe
1 fast
1 owns拆解这 20 行代码里用了哪些知识点:
split(|c| !c.is_alphabetic()):按非字母切分,一次处理掉标点;to_lowercase():String方法,统一大小写;entry(w).or_insert(0) += 1:HashMap 计数标准写法,避免"先查再插"两次哈希;freq.drain():把键值对取出来变成Vec排序。
这个模式在真实场景(日志统计、词频分析、指标聚合)里每天出现,值得记牢。
七、常见坑速查
| 坑 | 现象 | 解法 |
|---|---|---|
String 用 len() 数"字符" | 中文文本数字数不对 | 用 .chars().count() |
| 越界索引 | 运行时 panic | get() 返回 Option 或先 len() 检查 |
insert 后原 String 失效 | 编译错误 | 用 &str 作 key,或 clone() |
迭代器没 collect | 代码"没执行" | 链式操作要 collect()/for/sum() 等消费 |
for 循环里 map 有副作用 | 逻辑怪 | for_each 或直接用 for |
| 字符串拼接性能差 | 大量 + 慢 | 用 push_str 或 format!(循环里别用 format!) |
八、与本站主线的衔接
| Rust 集合概念 | 对应本站知识 | 衔接文档 |
|---|---|---|
Vec 扩容翻倍 | 连续内存与 cache | L3 内存子系统 |
String 堆分配 | 堆与分配器 | L5 内存布局 |
HashMap 哈希冲突 | 哈希表与开放寻址 | 数据结构与算法 |
| 迭代器零成本 | 编译器内联与优化 | 编译器选项与优化 |
实际用 perf 给 Rust 程序出火焰图时,你看到的顶层函数往往是 HashMap::insert、String 的分配、memcpy——集合的实现细节直接决定热点。这也是为什么"集合选型"在性能排查里排第一。
一句话总结
Rust 的 String/Vec/HashMap 是"拥有"数据的容器、迭代器是"零成本"的遍历抽象——记住 &str 借用来传参、Vec::with_capacity 预分配、entry().or_insert() 做计数,日常数据处理就顺了。
继续:所有权与借用 → Cargo 与编译模型 → 本篇 → 生命周期、trait 与泛型。