Rust标准库中有很多非常有用的集合类的数据结构。大部分其他标量类型或复合类型只能含有一个值,但是集合类型的数据结构可以包含多个值,且可以自动扩展或缩小,这些集合类的数据保存在堆内存上,这就意味着在编译器可以不用知道数据量的大小,在程序运行时可以进行扩展或缩小。每种集合都有不同的能力和所需消耗的代价,这就要求你需要在开发阶段需要知道到底哪种集合适合你的场景。之后,我们会讨论最常用的三种集合:
向量(vector):允许你一个接一个的保存数据。
字符串(string):字符的集合。
哈希map(hash map):允许你将值和一个键关联起来,它是map的特殊实现。
更多关于Rust集合的文档参见这篇文档。
10.1 向量
向量是Vec<T>类型的数据,是Rust标准库体统的动态可增长的数组。它存储在堆内存中,长度可以在运行时改变,数据类型可以在初始化时根据T的值进行设置。
关键特性:
- 同质存储:只能存储相同类型(T)的元素。
- 连续内存:元素在内存中时连续紧挨着存放的,因此访问速度快。
- 使用场景:替代了固定长度的数组,用于存储未知数量或数量会变化的数据(例如:文件行、购物车的商品)
10.1.1 创建
rust
fn main() {
//方式一:创建一个空的Vec,通常需要显示的注明要保存数据的类型(因为编译器无法推断)
let mut v:Vec<i32> = Vec::new();
//方式二:使用语法糖宏vec!(最常用,编译器会自行推断它的类型)
let v = vec![1,2,3];
//方式三:创建指定容量的Vec(性能优化,避免频繁扩容)
let mut v:Vec<f32> = Vec::with_capacity(10);
}
示例中展示了三种方式创建新的向量,分别是:
使用向量new函数创建,必须指定向量类型;
使用语法糖宏来创建向量,数据类型自动推断。
使用指定容量的函数with_capcity进行创建,同样必须指定类型,而且还必须指定容量,这样会优化性能,避免了频繁扩容。
10.1.2 元素的添加与删除
rust
fn main() {
let mut v = vec![1,2,3];
//添加元素
v.push(5);
println!("添加元素5后的向量是:{v:?}");
//删除并返回最后一个元素
let last = v.pop();
println!("删除的元素是:{}",last.unwrap());
println!("删除后的向量内容是:{v:?}");
}
添加和删除元素分别是push和pop。
10.1.3 访问向量中的某个元素
rust
fn main() {
let v = vec![10, 20, 30];
//方法一: 使用索引值访问;如果索引值越界,程序会panic,并退出运行。
let second = v[1];
println!("使用索引值访问的元素值是:{second}");
//方式二:安全访问(使用.get(index);返回Option(&T),越界时返回None,程序不会崩溃
match v.get(2) {
Some(value) => println!("使用get方法返回的数值是:{value}"),
None => println!("索引越界!"),
}
//索引越界,程序崩溃并退出
let second = v[10];
println!("使用索引值访问的元素值是:{second}");
}
访问元素有两种方式,分别是使用索引值直接访问;另一种是使用get方法进行访问。推荐使用get方法进行访问,这种方法更加安全。
10.1.4 遍历
rust
fn main() {
let v = vec![100, 200, 300];
//不可变遍历(只读)
for i in &v {
println!("{i}");
}
//可变遍历(可以修改元素中的值)
let mut v = vec![1, 2, 3];
for i in &mut v {
*i += 50; //注意:需要使用解引用操作符*来修改值
}
println!("修改后");
for i in &v {
println!("{i}");
}
}
变量有两种,一种是可变借用,另一种是不可变借用,具体使用哪个根据用户的场景来决定。可变遍历中需要使用解引用操作符来操作它的值("*")。
10.1.5 使用枚举类型来保存多种类型的值
rust
fn main() {
#[derive(Debug)]
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text("曹操".into()),
SpreadsheetCell::Float(3.14),
];
for item in &row {
println!("{item:?}");
}
}
虽然向量类型要求类型相同,但是可以利用Enum类型的变体形式来存储多类型数据。
10.1.6 删除向量或向量中的值
rust
fn main() {
{
let v = vec![1,3,5];
}
println!("{}",v[0]);
}
执行这段程序,编译器会报错,错误信息如下所示:
rust
--> src\main.rs:10:19
|
10 | println!("{}",v[0]);
| ^
|
help: the binding `v` is available in a different scope in the same function
--> src\main.rs:8:13
|
8 | let v = vec![1,3,5];
| ^
当向量离开了自己的作用域,它会删除掉自己所用的元素,和自己本身。
10.1.7 所用权与借用检查
在一个作用域内不能同时存在可变借用和不可变借用。
rust
fn main() {
let mut v = vec![1,2,3];
let first = &v[0]; //获取一个不可变借用
v.push(4); //❌️使用可变引用来修改向量的值,可能会导致向量从新分配
println!("{first}");
}
编译器报错:
rust
--> src\main.rs:9:5
|
8 | let first = &v[0];
| - immutable borrow occurs here
9 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
10 | println!("{first}");
|
但是如果先是使用可变借用,在使用不可变借用,系统不会报错
rust
fn main() {
let mut v = vec![1, 2, 3];
v.push(4); //使用可变引用来修改向量的值,可能会导致向量从新分配
let first = &v[0]; //获取一个不可变借用
println!("{first}");
}
最佳实践:
- 访问数据:优先使用.get()方法而不是[index],除非索引值绝对安全。
- 内存预分配:如果你可以预估数据量,使用Vec::with_capacity(n)可以显著减少内存分配,提高性能
- 迭代优于使用索引:尽量使用for element in &vec进行遍历,而不是通过索引ve[index]访问,这样会更安全。
- 注意作用域:向量离开作用域后,所有元素会被自动清理(Drop),无需手动释放内存。
10.1.8 总结对比表
|------------|---------------------------------------------|-------------------------------------------|
| 特性 | Vector ( Vec<T> ) | 普通数组 ( [T; N] ) |
| 长度 | 动态可变 | 编译时固定 |
| 内存 | 堆 (Heap) | 栈 (Stack) |
| 存储 | 必须同类型 | 必须同类型 |
| 性能 | 稍慢(需间接访问) | 极快(直接访问) |
10.2 字符串
10.2.1 核心概念:两种字符串类型
Rust 的字符串设计非常严谨,这也是新手最容易"劝退"的地方。你需要明确区分两种核心类型:
|------------|------------|--------------|-------------|----------------------|
| 类型 | 名称 | 内存位置 | 可变性 | 来源 |
| String | 字符串对象 | 堆 (Heap) | 可增长、可变 | 标准库提供,Vec<u8>的封装 |
| &str | 字符串切片 | 栈或二进制程序 | 不可变引用 | 核心语言,通常是 &String的视图 |
深度理解:
- String:就像你拥有一本可写的笔记本(可以修改,添加内容,也可以删除内容)。
- &str:就像借阅图书馆的一本书(只能看,不能改,且需要保证来源有效)。
10.2.2 Rust字符串的特点
Rust字符串主要有三点区别于其他语言:
- 使用UTF-8编码:Rust强制所有的字符串都使用UTF-8编码。这意味着每个字符的长度是不一致的,有可能1、2、3个字节。
- 索引访问的禁止:不能使用下标索引来获取字符"[0]",因为每个字符不一定正好对应着该字符的索引,可能取到的不是一个合法的字符。
- 所有权系统的接入:字符串拼接操作会涉及到所有权的转移,这与其他语言的"无感复制"截然不同。
10.2.3 创建字符串
rust
fn main() {
//1.创建空的字符串
let mut s = String::new();
s.push_str("Hello, world!");
println!("{s}");
//2.从字面量创建
let s1 = "曹操".to_string();
let s2 = String::from("孙权");
let s3:String = "刘备".into();
println!("{s1}-{s2}-{s3}");
//3.新建并赋值,包含多种字符
let hello = String::from("你好,主人,��");
println!("{hello}");
}
创建字符串有多种方式,可以创建空字符串,然后添加字符串。亦可以直接创建字符串切片,然后转化为字符串。也可以在创建的同时进行赋值操作。
10.2.3 更新字符串
rust
fn main() {
let mut s = String::from("你好,");
// 追加字符串切片(不会夺取所有权)
s.push_str("主人");
// 追加单个字符,注意要使用单引号,单引号表示为单个字符。
s.push('!');
println!("{s}");
// 使用"+"运算符连接字符串(注意有所有权的转移)
let s1 = String::from("你好,");
let s2 = String::from("曹操!");
let s3 = s1 + &s2; //s1的所有权被转移到s3了,s2只是借用,所有权没有转移
//println!("{s1}"); //系统报错
println!("{s2}"); //正常显示
println!("{s3}"); //正常显示
}
上面的示例演示了字符串的添加和字符串的连接,添加字符串使用函数push_str,添加单个字符使用push,注意参数要使用单引号。字符串的连接使用符号"+",它的签名函数如下所示:
rust
fn add(self,s:&str) -> String
这个签名函数会拿走第一个参数的所有权,并借用第二个参数。这非常高效,避免了深拷贝。对一个复杂的多个字符串的拼接,推荐使用farmat!宏(它不会夺取任何参数的所有权):
rust
fn main() {
let s1 = "曹操";
let s2 = "孙权";
let s3 = "刘备";
let s = format!("{s1}-{s2}-{s3}");
println!("{s}");
}
10.2.4 核心难点
索引和UTF-8的处理是Rust字符串最独特的部分,也是安全性的体现。
- 为什么禁止索引?
Rust是字符串的字节的集合(Vec<u8>)。由于UTF-8是可变长编码:
英文字符是1个字节
中文支付"你"占三个字节
表情符号��占4个字节
如果尝试let c=&s[0],Rust无法保证返回的是一个完整的Unicode标量值(可能取到的是中文字符的1/3),因此编译器直接禁止此操作。
- 正确的遍历与切片
你不能按索引随机访问,但是可以按字节范文进行切片,或者按字符进行遍历。
rust
fn main() {
let s = String::from("你好English!");
//1.按字符进行遍历(推荐)
for c in s.chars() {
println!("{c}");
}
//2.按字节遍历(用于底层协议处理)
for b in s.bytes() {
println!("{b}");
}
//3.字符串切片(必须严格在字符边界上!)
let slice = &s[0..6];
println!("{slice}");
//4.字符串切片(没有字符边界上!系统报错)
// let slice = &s[0..5];
println!("{slice}");
//5.安全获取(返回Option类型,系统不会崩溃)
if let Some(safe_slice) = s.get(0..5) {
println!("安全切片:{safe_slice}");
} else {
println!("切片的位置有误!");
}
}
中文处理法则:
如果你需要处理"字",永远使用.chars()函数。
如果你需要处理字节(如网络传输),使用.bytes()函数。
经理避免手动切片,除非你非常清楚字符的编辑位置。
- 总结与最佳实践
类型选择:
- 需要修改、拼接、构建字符串使用String。
- 函数参数、只读的字符串使用&str。
拼接选择:
- 两个字符串拼接使用操作符"+"。
- 多个字符串拼接使用format!宏。
访问选择:
永远不要使用s[i]下标索引的访问方式。
- 遍历使用for c in s.chars()。
- 获取子串有限使用s.get(...)或&s[...]
10.3 哈希映射(HashMap)
10.3.1 定义
HashMap<K,V>是一种键值对(Key-Value)映射的集合类型。它通过哈希函数决定键值的保存位置,因此查找效率高(平均O(1)的时间复杂度)。
10.3.2 为什么要用HashMap
使用场景:当你通过一个键来查找数据,而不是通过索引下标时。
示例:记录游戏队伍的得分时。键是队伍的名称(String类型),值是份数(i32类型)。
10.3.3 创建
rust
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
//插入键值对
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
println!("{scores:?}");
}
关键特性:
同质类型:所有的键必须是同一类型,所有的值必须是同一类型。
堆分配:数据存储在堆(Heap)内存上。
10.3.4 查询与空值处理
rust
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("Bule".to_string(), 10);
let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);
println!("{score}");
}
安全机制:Rust强制你处理键不存在的情况。get函数返回Option<&V>,你需要通过copied()和unwrap_or(default)来安全的获取值。
注意:HashMap在初始化的时候一定要定义为mut,否则它是只读的,不能保存新值。
10.3.5 遍历
rust
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
//插入键值对
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
println!("{scores:?}");
//遍历
for (key, value) in &scores {
println!("{key}:{value}");
}
}
无序性:遍历的顺序是不确定的(取决于哈希函数)。
上面的示例遍历时取值是只读借用,因此不影响原有的哈希函数的所有权。
10.3.6 所有权规则
HashMap堆数据的所有权处理是容易出错的地方:
|---------------|----------------------------|
| 数据类型 | 插入 HashMap 后的行为 |
| i32(实现 Copy) | 值会被复制进 HashMap,原变量仍可用 |
| String(拥有所有权) | 值会被移动 (Move),原变量失效 |
rust
fn main() {
use std::collections::HashMap;
let field_name = String::from("魏国");
let field_value = String::from("曹操");
let mut map = HashMap::new();
map.insert(field_name, field_value);
//println!("{field_name}:{field_value}");
println!("{map:?}");
}
如果想要保留所有权,可以插入引用符号"&",但必须处理生命周期问题。亦可以使用clone()函数,复制原有的数据,原有的数据所有权不变。
10.3.7 更新策略
场景一:键存在则不更新(仅插入新键)
使用entry().or_insert()模式:
rust
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
//插入键值对
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
// 如果 Yellow 键不存在,则插入 88;否则不做任何操作
scores.entry(String::from("Yellow")).or_insert(88);
println!("{scores:?}");
}
程序运行结果显示:新值没有加进去。
rust
{"Blue": 10, "Yellow": 50}
场景二:基于旧值更新
rust
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("Blue".to_string(), 10);
scores.insert("Blue".to_string(), 20);
scores.insert("Blue".to_string(), 30);
println!("{scores:?}");
}
使用同一个键名,就可以更新键值。也可以使用entry()函数来对键值进行更新。
rust
fn main() {
use std::collections::HashMap;
let text = "hello world wenderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
//or_insert(0)返回一个值的可变引用(&mut V)
let count = map.entry(word).or_insert(0);
//必须使用解引用(*)才可以修改值。
*count += 1;
}
println!("{map:?}");
}
上面的代码展示了使用返回值的引用来对保存的值进行修改。
10.3.8 性能提升
- 默认的哈希算法:Rust默认使用了SipHash,虽然这个算法有点慢,但是可以有效的防止哈希碰撞攻击(HashDoS)。
- 更换算法:如果你认为这个算法慢不适合你的项目,你可以指定别的hasher(Hash算法因子)。
10.3.9 常见疑惑与解答
- 为什么HashMap没有在Prelude中自动导入?
因为和Vec和String相比,它的使用频率稍低,且标准库对其支持也比较少(例如没有内置的构造宏)。
- 一个键可以对应多个值吗?
不可以。一个键只能对应一个值(但是多个键可以对应一个值)。如果需要一对多,可以使用Vec作为值类型(例如:HashMap<String,Vec<i32>>)。
- 如何安全的更新值?
利用Entry的API(entry().and_modify()或entry().or_insert())是官方推荐的做法,它比手动检查contains_key更加高效且安全。