第8章 常见集合
第8章 常见集合
一、章节定位
本章讨论 Rust 标准库中三类最常用的集合类型:
Vec<T>:按顺序存放多个同类型值的可增长集合。String:用于存放 UTF-8 编码文本的可增长字符串。HashMap<K, V>:用于存放键值映射关系的哈希映射。
与数组、元组这类内建类型不同,这些集合的实际数据存储在堆上,因此:
- 元素数量不必在编译期确定;
- 集合大小可以在运行时增长或缩小;
- 使用时需要额外关注所有权、借用、内存布局和 API 行为差异。
本章的核心不是死记 API,而是理解三件事:
- 这些集合分别适合解决什么问题;
- 它们在 Rust 中为什么这样设计;
- 如何在安全性、性能和表达能力之间做出正确选择。
二、集合的共性与选择原则
1. 集合与普通值的区别
普通数据类型通常表示一个确定的值,而集合表示“一组值”。
例如:
- 一个
i32表示一个整数; - 一个
Vec<i32>表示一组整数; - 一个
String表示一段文本; - 一个
HashMap<String, i32>表示一组“字符串键 -> 整数值”的映射。
2. 集合数据通常位于堆上
本章强调:集合的数据位于堆上。其直接含义是:
- 容量可以动态变化;
- 插入、扩容、移动时可能牵涉重新分配内存;
- 引用失效、借用冲突、所有权转移等问题会更加明显。
3. 选择集合的基本思路
- 需要按顺序存放多个同类型值:优先考虑
Vec<T>。 - 需要处理文本:使用
String或&str。 - 需要通过键查找值:使用
HashMap<K, V>。
进一步说:
Vec<T>强调顺序与连续存储;String本质上是特殊化的字节集合;HashMap<K, V>强调键值检索而不是顺序。
三、Vec<T>:使用 Vector 储存列表
3.1 Vec<T> 的本质与适用场景
Vec<T>(vector)允许在一个数据结构中存放多个同类型的值,并把这些值在内存中彼此相邻地排列起来。
适合的场景:
- 文件中的多行文本;
- 一批商品价格;
- 一组计算结果;
- 任意“数量可变、顺序有意义、类型统一”的数据。
关键特征:
- 元素类型必须统一;
- 大小可动态变化;
- 元素在内存中连续排布;
- 追加元素通常高效,但扩容时可能移动整块数据。
3.2 新建 vector
方式一:Vec::new
let v: Vec<i32> = Vec::new();
要点:
- 这是一个空的 vector;
- 因为里面还没有元素,Rust 无法从值推断元素类型,所以通常需要类型注解;
Vec<T>是泛型类型,T表示元素类型。
方式二:vec! 宏
let v = vec![1, 2, 3];
要点:
- 更常用;
- 直接用初始值创建 vector;
- Rust 会从元素值自动推断类型;
- 这里推断出的类型是
Vec<i32>,因为整数字面量默认类型是i32。
3.3 更新 vector:追加元素
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
要点:
- 想修改 vector,变量本身必须是
mut; push会把元素追加到末尾;- 元素类型必须一致;
- 当已有元素足以推断类型时,通常不再需要显式写出
Vec<i32>。
这里体现了 Rust 的两个基本原则:
- 可变性必须显式声明;
- 类型尽量由编译器推断,但推断必须有依据。
3.4 读取 vector 元素
Rust 提供两种主要方式访问 vector 中的元素。
方式一:索引语法
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");
方式二:get 方法
let v = vec![1, 2, 3, 4, 5];
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
两者差异
&v[index]
- 返回元素引用;
- 如果索引越界,会直接
panic; - 适用于你非常确定索引合法,并且希望程序在逻辑错误时立刻崩溃的场景。
v.get(index)
- 返回
Option<&T>; - 索引合法时得到
Some(&element); - 索引越界时得到
None; - 更安全、更适合处理用户输入或不确定索引。
核心理解
Rust 同时提供这两种方式,不是重复设计,而是让你显式选择错误处理策略:
- 越界是程序错误:用索引;
- 越界是业务上可能发生的正常情况:用
get。
3.5 借用规则与 vector 扩容风险
看下面这段代码:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
这段代码无法通过编译。
为什么?
因为:
first是对 vector 第一个元素的不可变引用;push(6)需要对整个 vector 做可变借用;- 一旦追加元素触发扩容,vector 可能重新分配内存,并把原有元素搬到新位置;
- 那么原先的引用
first就可能指向已经失效的旧内存。
Rust 在编译期直接阻止这种风险。
本质原因
不是“Rust 太严格”,而是 vector 的底层实现决定了:
- 元素连续存储;
- 扩容可能整体搬迁;
- 因而在持有元素引用时修改 vector,可能导致悬垂引用。
这一规则的价值
Rust 通过借用检查器,把其他语言里常见的运行时内存错误,提前提升为编译错误。
这是 Rust 安全性的核心体现之一。
3.6 遍历 vector
不可变遍历
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
含义:
&v产生对元素的不可变引用序列;- 循环体中不能修改元素;
- 适合只读访问。
可变遍历
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
含义:
&mut v产生对元素的可变引用序列;i的类型是&mut i32;- 想修改其指向的值,必须先用
*解引用,再执行+= 50。
为什么必须解引用?
因为 i 不是值本身,而是“指向值的可变引用”。
真正被修改的是引用背后的元素,而不是引用变量本身。
遍历期间的限制
不管是不可变遍历还是可变遍历,遍历过程中都不能同时对整个 vector 做插入、删除等结构性修改。
原因仍然是:遍历持有对元素或集合的借用,而结构性修改可能破坏这些借用的有效性。
3.7 用枚举在 vector 中存放多种类型
vector 只能存放同一种类型的元素,但“同一种类型”不等于“同一种具体数据形态”。
可以借助枚举来实现“逻辑上的异构存储”。
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
为什么这能成立?
因为:
Int、Float、Text都是同一个枚举SpreadsheetCell的不同变体;- 对 Rust 来说,vector 的元素类型仍然是统一的:
SpreadsheetCell。
这背后的设计思想
Rust 必须在编译期知道:
- 每个元素属于什么类型;
- 需要多少内存;
- 可能有哪些情况需要处理。
因此,Rust 不允许 vector 任意塞入“完全未知”的多种类型。
如果你在编译期就知道可能出现哪些种类,枚举是最自然的建模手段。
如果在编写程序时还无法确定将来会放入哪些具体类型,那么这种方式就不适用了,后续通常要借助 trait 对象等机制解决。
3.8 vector 的销毁
{
let v = vec![1, 2, 3, 4];
// 使用 v
} // v 在这里离开作用域并被释放
vector 离开作用域时:
- vector 本身被销毁;
- 它持有的所有元素也会被一起销毁。
这与 Rust 的所有权模型完全一致:容器拥有其内容,容器被丢弃时,内容也随之被丢弃。
小结
Vec<T> 的关键理解可以概括为:
- 适合存放顺序化、同类型、可增长的数据;
- 常用操作包括:创建、追加、读取、遍历;
- 读取时要区分
[]与get的错误语义; - 持有元素引用时,不可再随意修改 vector 结构;
- 如需逻辑异构,可借助枚举统一元素类型。
四、String:使用字符串储存 UTF-8 编码的文本
4.1 为什么字符串在 Rust 中容易“卡人”
本章明确指出:字符串之所以容易让初学者困惑,通常来自三方面叠加:
- Rust 很强调在编译期暴露潜在错误;
- 字符串本身就是比很多人想象中更复杂的数据结构;
- Rust 的字符串基于 UTF-8。
因此,字符串不是“字符数组”那么简单。
在 Rust 中,处理字符串时必须明确:
- 你面对的是字节、字符,还是更高层次的文本单位;
- 你要的是拥有所有权的字符串,还是借用的字符串切片;
- 你的操作是否满足 UTF-8 边界要求。
4.2 String 与 &str 的区别
Rust 核心语言中真正内建的字符串类型是字符串切片 str,它通常以借用形式 &str 出现。
&str
- 表示对 UTF-8 字符串数据的借用引用;
- 数据本身存储在别处;
- 字符串字面值本质上就是
&str。
String
- 由标准库提供,不属于核心语言内建类型;
- 是可增长、可变、可拥有的 UTF-8 编码字符串;
- 底层可以理解为“带额外约束和方法的字节 vector”。
简单理解
&str:借来的文本视图;String:自己拥有、可修改、可扩展的文本对象。
4.3 新建字符串
方式一:String::new
let mut s = String::new();
创建一个空字符串,适合之后逐步追加内容。
方式二:to_string
let data = "initial contents";
let s = data.to_string();
let s = "initial contents".to_string();
方式三:String::from
let s = String::from("initial contents");
这些方式的关系
to_string和String::from在这里都能完成从字符串字面值创建String的任务;- 实际选择更多是风格和可读性问题;
- 在本章语境中,它们没有本质功能差别。
4.4 String 是 UTF-8 编码的
Rust 的字符串是 UTF-8 编码,因此可以存储各种语言的文本。
这意味着:
- 不只支持 ASCII;
- 一个“人眼中的字符”不一定只占一个字节;
- 字符串长度、索引、切片等操作都必须考虑编码边界。
这是理解后续“为什么字符串不能像数组一样按索引取字符”的基础。
4.5 更新字符串:追加内容
push_str:追加字符串切片
let mut s = String::from("foo");
s.push_str("bar");
执行后,s 变成 foobar。
为什么参数是 &str?
因为追加时不需要获取参数的所有权。
例如:
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
push_str 不会夺走 s2 的所有权,所以追加后 s2 仍然可以使用。
push:追加单个字符
let mut s = String::from("lo");
s.push('l');
执行后,s 变成 lol。
区别很清楚:
push_str追加字符串切片;push追加单个char。
4.6 拼接字符串:+ 运算符与 format!
使用 +
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2;
结果:
s3是新的字符串"Hello, world!";s1被移动,之后不能再用;s2没有被移动,仍然可用。
为什么会这样?
因为 + 背后调用的是 add 方法,其核心签名可理解为:
fn add(self, s: &str) -> String
含义是:
- 左操作数
self被拿走所有权; - 右操作数只需要借用为
&str; - 最终返回一个新的
String。
因此:
s1会被消耗;&s2会被借用;- Rust 会通过 deref 强制转换 把
&String转成&str。
连续使用 + 的问题
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
虽然能工作,但可读性差,而且所有权流转不够直观。
更推荐:format!
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
优点:
- 写法清晰;
- 不获取参数所有权;
- 更适合多个字符串拼接。
实践建议
- 简单二元拼接可以理解
+; - 多字符串组合时优先使用
format!。
4.7 为什么不能像数组那样索引字符串
下面这种写法在 Rust 中不成立:
let s1 = String::from("hi");
let h = s1[0];
根本原因一:字符串底层是字节序列
String 本质上是 Vec<u8> 的封装。
一个字符串中的“第几个字节”不一定等于“第几个字符”。
例如:
"Hola"的长度是 4 字节;"Здравствуйте"从人眼看是 12 个字母,但在 UTF-8 下长度是 24 字节。
如果允许 s[0]:
- Rust 就必须决定返回“字节”“字符”“字形簇”中的哪一种;
- 但这三者并不是一回事。
根本原因二:返回值语义不明确
对于字符串索引,你可能想得到:
- 一个字节;
- 一个 Unicode 标量值;
- 一个用户眼中的字符单元;
- 一段子串。
这些语义并不统一,因此 Rust 干脆禁止字符串整数索引。
根本原因三:无法保证常数时间复杂度
数组索引通常意味着 O(1) 访问。
但字符串若按“字符”索引,Rust 往往必须从头遍历 UTF-8 字节流,才能确定某个位置对应的字符边界,因此无法保证 O(1)。
结论
Rust 不支持字符串按整数索引,不是功能缺失,而是为了:
- 避免歧义;
- 避免错误认知;
- 避免隐藏性能陷阱;
- 强迫程序员明确自己的意图。
4.8 字节、标量值与字形簇
从 Rust 的视角看,字符串至少可以按三层来理解:
1. 字节(bytes)
这是计算机实际存储的数据。
2. Unicode 标量值
这是 char 对应的层级。
注意:一个 char 不一定等于“一个人眼中的字符”。
3. 字形簇
这更接近用户直觉中的“一个文字单位”,但处理起来更复杂,标准库不直接提供完整支持。
这意味着什么?
对字符串的“一个字符”的理解,必须先问清楚你到底指的是哪一层。
本章借助印度语示例说明:
- 从字节看,可能有 18 个
u8; - 从 Unicode 标量值看,可能有 6 个
char; - 从字形簇看,可能只有 4 个“用户眼中的字母”。
因此,字符串处理绝不是简单下标访问能正确表达的事情。
4.9 字符串切片
如果你确实想取出字符串的一部分,可以使用范围创建字符串切片:
let hello = "Здравствуйте";
let s = &hello[0..4];
这里得到的是 &str,表示字符串的前 4 个字节。
由于这个例子中每个字母占 2 个字节,所以 [0..4] 对应的是前两个字母。
关键限制:切片边界必须是字符边界
例如:
let hello = "Здравствуйте";
let s = &hello[0..1];
这会在运行时 panic,因为 1 落在某个字符的中间字节位置,不是合法的 UTF-8 字符边界。
结论
字符串切片是合法的,但你必须确保:
- 范围是字节范围;
- 起止位置恰好位于 UTF-8 字符边界上。
4.10 遍历字符串
对字符串做“逐个处理”时,最好的方式不是索引,而是明确你想遍历什么。
遍历字符:chars
for c in "Зд".chars() {
println!("{c}");
}
这会按 Unicode 标量值逐个遍历。
遍历字节:bytes
for b in "Зд".bytes() {
println!("{b}");
}
这会按底层字节逐个遍历。
实践含义
- 处理人类文本逻辑时,通常先考虑
chars(); - 处理底层编码、协议、序列化等问题时,可能使用
bytes(); - 若你真正需要字形簇级别处理,标准库不直接提供,需要额外 crate 支持。
4.11 处理字符串时的正确心态
本章对字符串的结论非常重要:
- 字符串是复杂的;
- Rust 没有刻意把这种复杂性隐藏起来;
- Rust 选择把正确处理 UTF-8 的成本前置到开发期,而不是留到后期变成 bug。
这是一种非常典型的 Rust 风格:
- 学起来更“硬”;
- 但正确性更强;
- 尤其能减少非 ASCII 文本环境下的隐蔽错误。
小结
关于 String,你至少要掌握以下几点:
String是可拥有、可增长、UTF-8 编码的字符串;&str是字符串切片引用;- 可用
new、to_string、String::from创建; - 可用
push_str、push、+、format!更新; - 不能按整数索引;
- 可通过切片、
chars()、bytes()等方式按不同粒度访问内容。
五、HashMap<K, V>:使用哈希映射储存键值对
5.1 HashMap<K, V> 的本质
HashMap<K, V> 用来存储 键 -> 值 的映射关系。
它通过哈希函数决定键值对在内存中的组织方式。
适合的场景:
- 队伍名 -> 分数;
- 单词 -> 出现次数;
- 用户 ID -> 用户信息;
- 配置项名 -> 配置值。
与 vector 的区别在于:
- vector 通过位置索引访问;
- hash map 通过键访问。
5.2 新建哈希 map
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
要点
- 需要先显式
use std::collections::HashMap; - 它不在 prelude 中;
- 数据存储在堆上;
- 键类型必须统一,值类型也必须统一。
这里:
- 键类型是
String; - 值类型是
i32。
5.3 访问哈希 map 中的值
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);
这段代码的含义
get(&team_name)返回Option<&V>;- 若键存在,则得到
Some(&10); - 若键不存在,则得到
None; copied()把Option<&i32>转成Option<i32>;unwrap_or(0)表示:如果没有这个键,就返回默认值0。
访问风格总结
和 vector 的 get 一样,HashMap::get 也体现了 Rust 偏爱的安全访问方式:
- 可能不存在的值,用
Option明确编码; - 不把“缺失”偷偷变成空值或未定义行为;
- 强迫程序员显式处理缺失情况。
5.4 遍历哈希 map
for (key, value) in &scores {
println!("{key}: {value}");
}
注意点
哈希 map 的遍历顺序是任意的,不能依赖插入顺序或固定顺序。
这是哈希结构的自然特征。
所以:
- 如果你只关心“能遍历到所有项”,
for就足够; - 如果你需要有序输出,通常要额外排序。
5.5 哈希 map 中的所有权
插入 Copy 类型
像 i32 这种实现了 Copy trait 的值,插入时可以被复制进去。
插入拥有所有权的值
例如:
use std::collections::HashMap;
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
let mut map = HashMap::new();
map.insert(field_name, field_value);
执行后:
field_name和field_value的所有权被移动进map;- 原来的变量不再可用;
- map 成为这些键和值的所有者。
若插入引用
如果插入的是引用,那么被引用的数据本身不会移动进 map。
但这要求被引用对象的生命周期至少和 map 一样长,否则引用会失效。
这一点的本质
哈希 map 不是“自动复制容器”,而是严格遵守 Rust 所有权规则的普通拥有者。
5.6 更新哈希 map:三种典型策略
本章把更新哈希 map 分成三类,这一点非常值得掌握。
1. 覆盖旧值
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);
println!("{scores:?}");
结果中只会保留:
{"Blue": 25}
含义:
- 同一个键只能对应一个值;
- 对同一键再次
insert,新值会覆盖旧值。
2. 只在键不存在时插入
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);
println!("{scores:?}");
结果:
Yellow原先不存在,所以插入50;Blue原先已有值10,因此保持不变。
entry(...).or_insert(...) 的意义
这是 HashMap 的一个极其重要的 API 组合。
entry(key):
- 表示“我现在想操作这个键对应的位置,不管它是否存在”。
or_insert(value):
- 若键存在,返回现有值的可变引用;
- 若键不存在,插入给定值,并返回新值的可变引用。
这比手写“先查、再判断、再插入”的逻辑更清晰,也更符合借用规则。
3. 基于旧值更新新值
这是哈希 map 最经典的用法之一,例如做频次统计:
use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{map:?}");
结果大致是:
{"world": 2, "hello": 1, "wonderful": 1}
关键理解
or_insert(0) 返回的是某个值的可变引用 &mut V。
所以:
*count += 1;
先对引用解引用,再修改其背后的值。
这一模式为什么重要?
因为它把“如果不存在则初始化,如果存在则在原值基础上更新”合并成了一个统一范式。
这正是哈希 map 在计数、聚合、分组等场景中的核心用法。
5.7 哈希函数与性能 / 安全权衡
HashMap 默认使用一种叫做 SipHash 的哈希函数。
默认选择的意义
- 安全性更强;
- 能更好抵抗针对哈希表的 DoS 攻击;
- 但并不是最快的哈希算法。
这体现了 Rust 的默认价值取向
Rust 标准库默认更偏向:
- 安全;
- 稳健;
- 通用场景下的合理权衡。
如果你经过性能分析确认默认哈希函数太慢,可以换用其他 hasher。
这需要指定实现了 BuildHasher trait 的类型,也可以借助 crates.io 上已有的库。
实践态度
- 默认配置通常先够用;
- 只有在性能分析证明有必要时,再考虑替换哈希器;
- 不要为了“可能更快”而过早复杂化设计。
六、三种常见集合的对比总结
| 集合 | 适合的问题 | 典型访问方式 | 数据特点 | 关键限制 |
|---|---|---|---|---|
Vec<T> | 顺序存放多个同类型值 | 索引、get、遍历 | 连续存储、可增长 | 只能同类型;扩容可能移动数据 |
String | 存放 UTF-8 文本 | 切片、chars、bytes | 可增长文本、本质是字节集合 | 不能按整数索引字符 |
HashMap<K, V> | 通过键查找值 | get、entry、遍历 | 键值映射、无固定顺序 | 键值类型各自必须统一 |
七、本章真正要掌握的能力
学完这一章,真正重要的不是“记住多少 API”,而是形成下面这些判断能力。
1. 能根据问题选择合适集合
- 顺序列表:
Vec<T> - 文本:
String/&str - 键值映射:
HashMap<K, V>
2. 能理解 Rust 为什么对集合操作如此严格
- 因为集合大多在堆上;
- 因为扩容、移动、切片、借用都会影响内存安全;
- 因为 UTF-8 让字符串处理天然复杂;
- 因为 Rust 要在编译期尽可能消灭潜在 bug。
3. 能建立正确的 API 使用直觉
例如:
[]与get的错误语义不同;push_str不拿走参数所有权;+会移动左操作数;format!更适合多字符串拼接;entry(...).or_insert(...)是更新哈希 map 的核心范式。
八、章节精华浓缩
1. Vec<T> 的精华
- 顺序、同类型、可增长;
[]适合“越界即逻辑错误”;get适合“越界是正常情况”;- 引用与扩容不能同时随意发生。
2. String 的精华
String不是“字符数组”,而是 UTF-8 字节集合;String与&str必须区分;- 不能整数索引字符串,是为了正确性、语义清晰和性能一致性;
- 文本处理要明确你面对的是字节、标量值还是更高层单位。
3. HashMap<K, V> 的精华
- 适合键值检索;
- 插入拥有所有权的数据会发生移动;
entry(...).or_insert(...)是最重要的更新接口之一;- 默认哈希函数强调安全而不是极致速度。
九、学习建议
这一章很适合配合小练习巩固。至少应亲手写出下面这些例子:
- 用
Vec<T>完成一组整数的增删改查与遍历; - 用
String尝试push_str、push、+、format!; - 验证字符串索引报错与切片边界
panic; - 用
HashMap<K, V>完成词频统计; - 对比
insert、get、entry().or_insert()的行为差异。
如果这些例子你都能独立写出来,并解释背后的所有权和 UTF-8 原因,那么第 8 章就算真正学扎实了。