阶段四 · 集合与函数式

常用集合:Vec,HashMap,String

一句话总结

三个最常用的标准库集合:Vec(可增长数组,对应 Go slice),HashMap(键值映射,对应 Go map),String(可增长 UTF-8 字符串).
它们都在堆上分配,自动管理内存.Rust 在 API 上比 Go 更显式--查询返回 Option 而非零值,且区分 String&str.

Vec:可增长的数组

Vec<T> 是堆上分配,可动态增长的序列,等价于 Go 的 []T:

let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);

let v2 = vec![10, 20, 30]; // vec! 宏,类比 Go 的 []int{10,20,30}

v2.iter().for_each(|x| println!("{x}"));
println!("长度 {}", v2.len());

访问元素有两种方式,体现 Rust 的"显式处理失败"哲学:

let third = v2[2];          // 索引访问:越界会 panic
let maybe = v2.get(99); // get 返回 Option<&T>:越界得到 None(更安全)

match v2.get(1) {
Some(x) => println!("第二个是 {x}"),
None => println!("没有第二个"),
}

最佳实践:不可信索引用 get

索引来自用户输入,计算结果等不确定来源时,用 v.get(i) 把"可能越界"交给 Option 处理,而非裸用 v[i] 冒 panic 风险.

只有当确信索引合法时才用 [].这又是一次"用 Option 表达可能失败"(第 06 篇).

遍历 Vec 的三种方式

遍历时要留意所有权--直接呼应第 03,04 篇:

let v = vec![1, 2, 3];

for x in &v { print!("{x} "); } // &i32,只读借用,之后 v 仍可用
for x in &mut v2 { *x += 1; } // &mut i32,可修改(v2 需为 mut)
for x in v { print!("{x} "); } // i32,取得所有权,之后 v 不可再用

默认用 &v 遍历

大多数情况只是想读一遍,用 for x in &v 借用即可,别误用 for x in v--后者会消耗掉整个 Vec,循环后它就不能再用了.

这是新手常犯的"遍历后变量没了"困惑.

HashMap:键值映射

HashMap<K, V> 对应 Go 的 map[K]V,但 API 风格不同--查询返回 Option,从根上避免了"零值歧义":

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("alice"), 90);
scores.insert(String::from("bob"), 85);

match scores.get("alice") {
Some(&s) => println!("alice: {s}"),
None => println!("无记录"),
}

Go 里 m["missing"] 返回零值,得用 v, ok := m[k] 区分"真的是 0"还是"不存在".
Rust 的 get 直接返回 Option:None 就是不存在,Some(0) 就是值为 0,语义天然无歧义,不会再有"零值陷阱".

entry API:优雅处理"有则更新,无则插入"

这是 HashMap 最该掌握的便利."统计词频""累加计数"这类需求,Go 要写 if _, ok := m[k]; !ok {...},Rust 用 entry 一行搞定:

let text = "a b a c b a";
let mut counts: HashMap<&str, i32> = HashMap::new();

for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1; // 没有则插 0,拿到可变引用再 +1
}
// {"a": 3, "b": 2, "c": 1}

entry(key) 返回该键的"入口",or_insert(default) 在键不存在时插入默认值,并统一返回值的可变引用供修改.比"先查再改"既简洁又少一次哈希查找.

String 与 &str

Rust 的字符串有两个核心类型,初学者必须分清:

类型 本质 类比
String 拥有所有权,可增长,堆分配 Go 里可变的字符串构建(strings.Builder 的角色)
&str 字符串切片,借用一段 UTF-8 数据,不拥有 Go 的 string(只读视图)
let literal: &str = "hello";          // 字面量是 &str
let mut owned: String = String::from("hello"); // 拥有所有权
owned.push_str(", world"); // 可增长
owned.push('!');

let slice: &str = &owned[0..5]; // 从 String 借出 &str
let n: i32 = "42".parse().unwrap(); // 字符串解析为数字

最佳实践回顾:参数用 &str

呼应第 04 篇:函数接收字符串参数时优先用 &str,它能同时接受 String 的引用和字面量,通用性最好.

只有当函数需要拥有修改字符串时,才接收 String&mut String.

UTF-8 带来的两个注意点

Rust 字符串严格按 UTF-8 存储:

  1. 不能用整数下标 s[0](回顾第 02 篇),要按字符用 .chars(),按字节用 .bytes()
  2. .len() 返回的是字节数而非字符数

字符串的完整细节(还有 OsString,Cow 等)在进阶分类有专题.

容量与性能:with_capacity

VecString 增长时,若容量不足会重新分配内存并搬移数据.若能预估规模,用 with_capacity 预分配可避免多次重分配--这点与 Go 的 make([]T, 0, n) 思路完全一致:

let mut v = Vec::with_capacity(1000); // 预分配,避免反复扩容
for i in 0..1000 {
v.push(i); // 不会触发重新分配
}

let mut s = String::with_capacity(256); // 字符串同理

最佳实践:已知规模就预分配

在循环里往 Vec/String 大量 push 且能预估数量时,用 with_capacity 预分配.这是低成本的性能优化,尤其在热路径上.

容量(capacity)与长度(len)是两个概念:len 是当前元素数,capacity 是已分配空间.

其他常用集合(了解即可)

集合 用途
HashSet<T> 无序去重集合,判断元素是否存在
BTreeMap<K,V> 按键有序的映射(Go 无内置对应,需自己排序)
VecDeque<T> 双端队列,头尾都能高效增删

快速回顾

  • Vec:可增长数组;[] 越界 panic,get 返回 Option(不可信索引用它).
  • 遍历:默认 for x in &v 借用;for x in v 会消耗集合.
  • HashMap:get 返回 Option,无零值歧义;entry().or_insert() 优雅处理插入更新.
  • String vs &str:前者拥有可增长,后者是借用视图;参数优先 &str.
  • UTF-8:不能整数下标,.len() 是字节数,按字符用 .chars().
  • 预分配:已知规模用 with_capacity 避免反复扩容;len 与 capacity 是两个概念.

动手练习

  1. 越界对比:用 vec! 创建整数向量,分别用 v[i]v.get(i) 访问一个越界索引,对比行为.
  2. 词频统计:用 HashMap + entry API 统计一句话里每个单词出现的次数.
  3. String 拼接:构造一个 String,用 push_str/push 拼接,再借出一个 &str 切片打印.
  4. UTF-8 验证:对一个含中文或 emoji 的字符串,打印它的 .len().chars().count(),解释差异.
  5. 预分配对比:分别用 Vec::new()Vec::with_capacity(n) 各 push 大量元素,思考后者好在哪.