Rust学习笔记6 - 常用集合类型

本文是 Rust 学习笔记系列第六篇,参考 Rust 指南的第 8 章,涉及向量(vector)、字符串(string)和哈希表(hash map)。

使用向量储存一组数据

首先我们介绍向量类型 Vec<T>。向量允许我们在一个数据结构中存储多个值,并且多个值存储在邻接的内存中。向量只能存储相同类型的值。

创建新的向量

1
2
3
4
5
// 因为无法推断元素的类型,要注明类型
let vec1: Vec<i32> = Vec::new();

// 可以用 vec! 来创建含有元素的向量
let vec2: vec![1, 2, 3];

修改向量

1
2
3
4
5
6
7
8
// 可以从下面的方法调用推导元素类型,所以此处可以省略类型标注
let vec1 = Vec::new();

vec1.push(5);
vec1.push(6);
vec1.push(7);
vec1.push(8);
vec1.push(9);

销毁向量的同时会销毁元素

和其他的结构体一样,向量在脱离作用域时,会被销毁。

当向量被销毁时,存储在里面的值也会被销毁,当你引用了一些元素时,这会变的比较复杂。

读取向量的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let v = vec![1, 2, 3, 4, 5];

// 当下标超出范围时,会产生panic
let third: &i32 = &v[2];
println!("The third element is {}", third);

// 可以安全地判断元素是否存在
match v.get(2) {
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
};

if let Some(third) = v.get(2) {
println!("The third element is {}", third);
}

对向量元素的引用仍然满足之前所介绍的所有权与借用规则。

1
2
3
4
5
6
7
8
9
10
let mut v = vec![1, 2, 3, 4, 5];

// 不可变借用
let first = &v[0];

// 可变借用
v.push(6);

// 不可变借用的作用域跨越了可变借用,产生编译错误
println!("The first element is: {}", first);

这段代码看起来好像没有问题,向向量中添加一个元素,怎么会影响第一个元素呢?这与向量的实现方式有关系,向量添加元素时,可能会需要分配新的内存地址,并且将所有元素都拷贝过去,这时候对任意元素的引用都将会出问题。

遍历向量的元素

使用 for 循环可以获取向量每个元素的不可变引用:

1
2
3
4
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}

也可以对可变向量使用 for 循环,获取可变引用:

1
2
3
4
5
6
7
8
// 需要是可变向量
let mut v = vec![100, 32, 57];

// 要对向量的可变引用遍历
for i in &mut v {
// 要修改元素,需要使用解引用
*i += 50;
}

配合枚举来存储多种类型的数据

1
2
3
4
5
6
7
8
9
10
11
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];

使用字符串存储 UTF-8 编码的文本

Rust 新手通常会卡在字符串上,因为:Rust 倾向于暴露所有可能的问题,字符串类型比预期更加复杂,以及 UTF-8 编码,这三个问题揉在一起,会让问题看起来很复杂。

我们在介绍集合类型时介绍字符串,因为字符串被设计成是字节的数组,加上一些以将字节解释为文本为基础来提供功能的方法。

什么是字符串

首先我们需要定义清楚什么是“字符串”。Rust 在语言核心中只有一种字符串类型:字符串切片 str,通常以借用的形式 &str 出现。之前我们介绍了字符串切片,它是对储存在其他地方的 UTF-8 编码的字符串数据的引用。字符串字面量是字符串切片的一种,存储在程序的二进制文件中。Rust 的标准库中的 String 类型,是一种可变的、有所有权的 UTF-8 编码的字符串类型。Rust 开发者提及字符串时,通常指这两者,而不是其中一种。

Rust 标准库中也有一些其他的字符串类型,比如 OsStringOsStrCStringCStr。其他的库可能会提供更多的字符串存储方案。

创建字符串

1
2
3
4
5
6
7
8
9
10
11
12
// 创建空的可变字符串
let mut s = String::new();

// 使用已有的切片创建String
let data = "initial contents";
let s = data.to_string();

// 字符串字面量也可以直接创建String
let s = "initial contents".to_string();

// 也可以String::from方法创建
let s = String::from("initial contents");

因为字符串是 UTF-8 编码的,我们可以放入任何正确编码的文本数据,比如中文、Unicode 表情符号等。

修改字符串

使用 push_strpush 追加文本

1
2
3
4
5
6
7
8
let mut s = String::from("foo");
s.push_str("bar"); // 追加字符串
s.push('l'); // 追加字符

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2); // 不需要所有权
println!("s2 is {}", s2); // 可以正常借用

使用 + 操作符或者 format! 宏拼接文本

1
2
3
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1产生了移动,之后就不可用了

s1 要用移动、s2 要用引用的原因在于 + 操作符使用了字符串的 add 方法,而该方法的签名是:

1
fn add(self, s: &str) -> String {}

实际在标准库中,add 方法是使用范型定义的。

1
2
3
4
5
6
7
8
9
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

// 对于更多元素的拼接,就比较复杂了
let s = s1 + "-" + &s2 + "-" + &s3;

// 使用 format! 宏可以简化
let s = format!("{}-{}-{}", s1, s2, s3);

使用下标访问字符串

在很多其他语言中,使用下标访问字符串的字符是有效的且很常见的。但是在 Rust 中,无法使用数字下标获取字符串中的字符。

字符串的内部表示

String 类型是对 Vec<u8> 类型的封装。

1
2
3
4
5
// 英文字母由单字节组成,所以看起来还很正常
let hello = String::from("Hola"); // [b'H', b'o', b'l', b'a'];

// 中文字符由多字节组成,一个字符并不对应一个字节
let hello = String::from("你好");

所以对应下标位置的字节并不对应字符。

字节、字符、字形簇

另一个问题是 UTF-8 编码在 Rust 的角度下有三个不同的视图:字节、字符(Unicode 标量)、字形簇(字形簇最接近所谓的字母)。

比如印度的梵文词「नमस्ते」:

1
2
3
4
5
6
7
8
9
// 字节表示
let bytes = [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135];

// 字符表示
let chars = ['न', 'म', 'स', '्', 'त', 'े'];

// 字形簇表示
let graphs = ["न", "म", "स्", "ते"];

而最终 Rust 不允许使用下标访问字符串的原因在于,下标访问应该具有 O(1) 的复杂度。而在字符串中这是不可能保证的,因为必须从头开始遍历字符串来数有多少个有效字符。

字符串切片

虽然不允许下标访问,Rust 还是允许我们使用切片语法获取字符串的切片,只是如果我们的切片的开始或结束位置不在字符边界上,就会造成 panic(导致程序崩溃)。

遍历字符串的方法

幸运的是,我们仍然可以通过其他方式来遍历字符串:

1
2
3
4
5
6
7
8
9
10
11
// 可以遍历所有2个字符
for c in "你好".chars() {
println!("{}", c);
}

// 可以遍历所有6个字节
for c in "你好".bytes() {
println!("{}", c);
}

// Rust标准库中不提供字形簇的遍历方法

使用哈希映射存储关联的键与值

最后一个要介绍的常用集合是哈希映射(hash map)。HashMap<K, V> 类型存储 K 类型的键与 V 类型的值的映射。哈希映射使用哈希函数来决定键与值如何存放在内存中。这种数据结构在很多语言中都有实现,只是名称有所不同:哈希、映射、对象、哈希表、字典、关联数组等。

哈希映射通常用于希望通过一个标记而不是下标来寻找数据的场合。

创建新的哈希映射

1
2
3
4
5
6
7
8
9
10
// 由于哈希映射并不如字符串或者向量那样常用,所以并没有自动引入到上下文中
// 需要手动进行引入
use std::collections::HashMap;

// 与向量类似,哈希映射所有的键需要是相同类型,所有的值需要是相同类型
// 与向量类似,此处可以通过下面的方法调用来推断键与值的类型
let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

还可以通过迭代其他集合的方式来创建哈希映射:

1
2
3
4
5
6
7
8
use std::collections::HashMap;

let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

// 这里需要注明类型,因为 collect() 方法可以返回多种类型的结果
let mut scores: HashMap<_, _> =
teams.into_iter().zip(initial_scores.into_iter()).collect();

哈希映射与所有权

对于 i32 这种实现了 Copy 特性的类型,值可以复制到哈希映射中。而对于其他的类型,比如String,值会移动到哈希映射中。

1
2
3
4
5
6
7
8
9
10
11
use std::collections::HashMap;

let field_name = String::from("Color");
let field_value = String::from("Blue");

let mut map = HashMap::new();

map.insert(field_name, field_value);

// 这里会产生编译错误,因为field_name, field_value已经失去了所有权
println!("{} -> {}", field_name, field_value);

如果我们将引用插入到哈希映射中,就不会产生移动。但是被引用的数据必须在哈希映射有效时始终保持有效(有关 Rust 的 “生命周期” 特性)。

访问哈希映射中的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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("Red");

// get() 方法返回一个 Option,需要手动判断里面有没有值
if let Some(score) = scores.get(&team_name) {
println!("{} -> {}", team_name, score);
} else {
println!("{} -- None", team_name);
}

// 可以使用 for 循环来遍历哈希映射的每一对数据
for (key, value) in &scores {
println!("{} -> {}", key, value);
}

更新哈希映射中的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::collections::HashMap;

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

// insert会覆盖已有的值
scores.insert(String::from("Blue"), 25);

// entry() 方法返回一个 Entry 枚举,表示对于指定的键有没有对应的数据
// 同时其 or_insert() 方法,可以让我们在值不存在时,插入一个数据
scores.entry(String::from("blue"))
.or_insert(50);


// 基于已有值更新的方法:

let text = "hello world wonderful world";

let mut map = HashMap::new();

for word in text.split_whitespace() {
// 读取单词个数,如果没有就插入0,并返回个数
let count = map.entry(word).or_insert(0);

// 解引用,并更新数据
*count += 1;
}

哈希函数

默认情况下,HashMap 使用一种密码学安全的哈希函数(siphash),可以帮我们抵御 DoS 攻击。这并不是最快的哈希函数,但是它带来的安全性是值得通过一部分性能去换取的。如果觉得默认的哈希函数太慢,可以通过实现 BuildHasher 特性来自定义哈希函数,或者使用成熟的第三方库。

总结

向量、字符串、哈希映射可以提供关于存储、访问、修改数据的大部分功能。我们只了解了其一部分的用法,更多的用法需要通过标准库 API 文档来深入学习。

# Rust
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×