返回 Rust
Rust 2026-05-19

第8章 常见集合

第8章 常见集合

一、章节定位

本章讨论 Rust 标准库中三类最常用的集合类型:

  • Vec<T>:按顺序存放多个同类型值的可增长集合。
  • String:用于存放 UTF-8 编码文本的可增长字符串。
  • HashMap<K, V>:用于存放键值映射关系的哈希映射。

与数组、元组这类内建类型不同,这些集合的实际数据存储在堆上,因此:

  • 元素数量不必在编译期确定;
  • 集合大小可以在运行时增长或缩小;
  • 使用时需要额外关注所有权、借用、内存布局和 API 行为差异。

本章的核心不是死记 API,而是理解三件事:

  1. 这些集合分别适合解决什么问题
  2. 它们在 Rust 中为什么这样设计
  3. 如何在安全性、性能和表达能力之间做出正确选择

二、集合的共性与选择原则

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 的两个基本原则:

  1. 可变性必须显式声明
  2. 类型尽量由编译器推断,但推断必须有依据

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),
];

为什么这能成立?

因为:

  • IntFloatText 都是同一个枚举 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 中容易“卡人”

本章明确指出:字符串之所以容易让初学者困惑,通常来自三方面叠加:

  1. Rust 很强调在编译期暴露潜在错误;
  2. 字符串本身就是比很多人想象中更复杂的数据结构;
  3. 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_stringString::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 是字符串切片引用;
  • 可用 newto_stringString::from 创建;
  • 可用 push_strpush+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_namefield_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 文本切片、charsbytes可增长文本、本质是字节集合不能按整数索引字符
HashMap<K, V>通过键查找值getentry、遍历键值映射、无固定顺序键值类型各自必须统一

七、本章真正要掌握的能力

学完这一章,真正重要的不是“记住多少 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(...) 是最重要的更新接口之一;
  • 默认哈希函数强调安全而不是极致速度。

九、学习建议

这一章很适合配合小练习巩固。至少应亲手写出下面这些例子:

  1. Vec<T> 完成一组整数的增删改查与遍历;
  2. String 尝试 push_strpush+format!
  3. 验证字符串索引报错与切片边界 panic
  4. HashMap<K, V> 完成词频统计;
  5. 对比 insertgetentry().or_insert() 的行为差异。

如果这些例子你都能独立写出来,并解释背后的所有权和 UTF-8 原因,那么第 8 章就算真正学扎实了。