Rust 学习笔记(9/21):常见集合——Vec、String 与 HashMap “三件套”

本系列基于官方《Rust 程序设计语言》(TRPL)逐章学习。前几章的主角是”单数”:一个变量、一个结构体、一个枚举值。但真实程序处理的几乎都是”复数”——一份文件有好多行、一个班级有好多学生、一个词在文本里出现好多次。怎么存、怎么取、怎么改多个值? 这就要请出本章主角:Rust 标准库里的集合(collections)三兄弟——Vec<T>(动态数组)、String(UTF-8 文本)、HashMap<K, V>(键值表)。

开篇:三个”容器”的类比

如果说结构体是”给一组固定字段起名字”,那集合就是能装很多同种东西的”可变容器”。它和数组/元组的最大区别是:集合的数据放在堆上,数量不必在编译期定死,可以随程序运行长大或缩小

  • Vec<T> ≈ 能增删的”货架”:同种货品按顺序排成一排(内存相邻),只能放同一种货。
  • String ≈ 能拼长的”纸条”:本质是一串字节,只是人们把它读成文字——所以它比想象中复杂。
  • HashMap<K,V> ≈ 带索引贴签的”快递柜”:给定一个键(取件码),立刻翻出对应的值(包裹)。

三者数据都在堆上、都有所有权语义,这是它们与 C 系语言最不同的地方。下面逐个拆解。

Vec:一排”同质”的序列

创建与更新

1
2
3
4
let v: Vec<i32> = Vec::new();   // 空表:不插入就无法推断元素类型,必须标注
let v = vec![1, 2, 3];          // 有初值:类型可推断,常用 vec! 宏
let mut v = Vec::new();         // 想 push 就得 mut
v.push(5);                      // 尾部追加

读取:[] 索引 vs get 方法

两种读法对应两种越界哲学

1
2
3
4
5
let third: &i32 = &v[2];              // 越界 → panic,程序崩溃
match v.get(2) {                      // 越界 → 返回 None,交给调用方处理
    Some(third) => println!("{third}"),
    None => println!("没有第三个元素"),
}

一句话选型:当”越界”意味着程序 bug(必须崩溃)用 [];当”越界”是正常业务(比如用户乱输数字)用 get,后者能把错误变成一条友好提示而不是让整个程序崩掉。

借用规则照进 vector:E0502 的”元凶”

看一段”看起来没问题”的代码:

1
2
3
4
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];    // 不可变借用
v.push(6);            // 可变借用 → 编译错误 E0502!
println!("{first}");

为什么在尾部追加元素也要管第一个元素的引用?因为 push 可能触发重新分配内存:当数组没有足够的连续空间时,Rust 会把整排元素搬到一块更大的新内存——于是 first 指向的旧内存被释放了。借用规则在编译期就挡住了这种悬垂引用,你连写 bug 的机会都没有。这正是第 4 章所有权在真实数据结构的落地。

遍历与”混合类型”的破解

1
2
for i in &v { println!("{i}"); }        // 只读遍历
for i in &mut v { *i += 50; }           // 改值遍历:需要 * 解引用

vector 只能存同种类型?用枚举包一层就破解了——SpreadsheetCell::Int(3)Float(10.12)Text("blue") 三种不同”内芯”在枚举里被统一成同一种类型,放进同一个 Vec<SpreadsheetCell>。编译期就知道每个元素占多少字节、match 时能穷尽所有分支。若连”运行时会有什么类型”都事先未知,就需要第 17 章的 trait 对象了。

读法 越界行为 适用场景
&v[i] panic(崩溃) 越界 = 程序 bug,应尽早暴露
v.get(i) 返回 None 越界 = 正常业务,需容错提示

String:远看是文本,近看是字节

字符串常是新手第一道坎,书中归因于三点:Rust 爱”提前暴露错误”、字符串数据结构比想象复杂、以及 UTF-8。先记住核心事实:String 就是一个 Vec<u8> 的封装——文本在内存里不过是一串字节。

创建与拼接

1
2
3
4
5
6
7
8
9
let s = String::from("initial contents");   // from / to_string 二选一,风格问题
let s = "initial contents".to_string();

let mut s = String::from("foo");
s.push_str("bar");        // 追加 &str(不夺取参数所有权,s2 之后还能用)
s.push('l');              // 追加单个 char

let s3 = s1 + &s2;        // ⚠️ add(self, s: &str):s1 被"吃掉"(所有权被移动),不能再使用
let s = format!("{}-{}-{}", s1, s2, s3);    // 多个拼接用 format!,不夺取所有权,更清晰

+ 拼接里藏着所有权彩蛋:add 函数签名是 fn add(self, s: &str) -> String——self 没有 &,所以左边的 s1 被整体移动进调用,之后失效&s2 能传进去是因为 &String 会被”解引用强转”成 &str,且参数不带所有权,所以 s2 安然无恙。多段拼接别用 + 长链,用 format! 吧。

为什么不能 s[0]

1
let h = s1[0];   // error[E0277]: String 不支持按索引取值

三个理由:

  1. UTF-8 是变长编码:”Hola” 占 4 字节(每字母 1 字节),而西里尔文的 “Здравствуйте” 12 个字符占 24 字节——按字节索引会切在字符中间,返回一个孤零零的无效字节;
  2. “一个字符”该返回什么说不清:同一串文本有三种解读层次。以梵文 “नमस्ते” 为例:
    • 字节(bytes):18 个 u8,计算机真正存的;
    • 标量值(chars):6 个 char,其中俩是孤立的发音符号;
    • 字形簇(graphemes):4 个”肉眼可见的字母”。
  3. O(1) 承诺做不到:要数出第 N 个有效字符必须从头遍历。

Rust 的回应是:不让你索引,把选择权交回你手里——要字节用 .bytes(),要字符用 .chars(),要切片就用带 range 的形式 &hello[0..4]必须落在字符边界上,否则运行期 panic,如 &hello[0..1] 会切进 “З” 中间而崩溃)。

1
2
for c in "नमस्ते".chars() { ... }   // 6 个 char
for b in "नमस्ते".bytes() { ... }   // 18 个 u8
解读层次 例子 “नमस्ते” 方法/类型 说明
字节 18 个 u8 .bytes() 计算机真正存储的形式
标量值 6 个 char .chars() 含孤立发音符号
字形簇 4 个”字母” 第三方 crate 最接近人眼,标准库未内置

一句话总结:Rust 只是把 UTF-8 的复杂性诚实地摆到了你面前(其他语言替你藏着,藏到后期变成乱码 bug);多做点功课,换来的是对非 ASCII 文本的彻底免疫。

HashMap:键值对”快递柜”

创建:两条路

1
2
3
4
5
6
7
8
9
use std::collections::HashMap;          // 不在 prelude 里,必须手动 use!

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);   // 路 1:new + insert

// 路 2:zip 配对 + collect 收集(注意类型标注,collect 可能收集成很多种结构)
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

所有权规则依旧严格i32Copy 类型插入时被拷贝;String 等拥有所有权的值插入时被移动进 map(field_name 从此失效)。若只存引用,则引用指向的数据必须活得比 map 长(第 10 章生命周期)。

读与写:四种姿势

1
2
3
4
5
let score = scores.get("Blue");              // 读:返回 Option<&V>
for (key, value) in &scores { ... }          // 遍历:顺序任意

scores.insert("Blue", 25);                   // 覆盖:同键二次 insert 顶掉旧值
scores.entry("Yellow").or_insert(50);        // 只在键不存在时插入(entry API 更符合借用检查)

最漂亮的模式是”根据旧值更新”——单词计数:

1
2
3
4
for word in text.split_whitespace() {
    let count = map.entry(word).or_insert(0);  // 返回 &mut V
    *count += 1;                                // 解引用后自增
}

or_insert 在键不存在时插入 0 并返回其可变引用,存在时直接返回旧值的可变引用——一个方法搞定”有则加一、无则建项”,而且天然安全(引用在循环末尾释放,不违反借用规则)。

默认哈希:为安全,不为速度

HashMap 默认用 SipHash(密码学级哈希),能抵抗构造恶意键拖垮程序的 DoS 攻击,代价是比最快的哈希慢。若性能监测表明它成了瓶颈,可以通过实现 BuildHasher trait 的第三方 hasher 替换(第 10 章讲 trait)。这是一次典型的”Rust 默认站在安全这边”的取舍。

实践建议与总结

  1. 选型口诀有顺序的同类清单 → Vec<T>;要当文本处理的字节串 → String;按名字/键查找 → HashMap<K,V>。需要”同一清单装不同类型”?枚举包一层;类型未知?才轮到 trait 对象。
  2. 读集合牢记 Optionget 系方法返回 Option,顺手用 match/if let 处理,别急着 unwrap
  3. 别跟借用规则搏斗,要顺势:vector 的 E0502 挡住的是真实的悬垂内存 bug;entry API 就是为”检查再插入”这类操作设计的合法通道——用对 API,借用检查器从”拦路虎”变”安全气囊”。

三个集合只是标准库 std::collections 的冰山一角(还有 VecDequeHashSetBTreeMap 等),但存、取、改、遍历 + 所有权交互这套心智模型完全通用。集合上手之后,代码就开始处理”可能失败的操作”了——下一章正是 Rust 里绕不开的错误处理Resultpanic)。