【Rust 学习记录】8. 通用集合类型

TwoSix Lv3

动态数组

定义

1
let a:Vec<i32> = Vec::new();

定义非常简单,是Vec的格式,Vec 也就是 vector,动态数组类型的关键字,用两个尖括号括住动态数组所存放的数据类型 T,代码里就是存放 i32 类型的数据。再使用new方法分配一片空间。

上面是一个创建一个指定类型的空数组,因为是空数组,所以编译器没法推理出我们数组的类型,所以要显示定义类型,如果给定数据,就不需要显示指定类型了,如下

1
let b = vec![1,2,3];

这里我们用到了Rust官方提供的宏vec!用来创建一个动态数组,用方括号给定初始值,因为给的都是整数值,所以编译器会默认推理得 b 是 i32 类型的动态数组

动态数组的所有权:动态数组的所有元素的所有权和变量绑定,当变量被销毁时,所有元素被销毁

使用

末尾添加元素

使用push方法即可在末尾添加元素

1
2
3
4
5
6
fn main() {
let mut a:Vec<i32> = Vec::new();
a.push(1);
a.push(2);
println!("{:?}", a);
}

读取元素

Rust 提供了两种 vec 的访问方法

  1. [] 运算符访问
  2. get() 方法访问
1
2
3
4
5
6
7
let e1 = &a[0];
println!("{:?}", e1);
let e2 = a.get(1);
match e2{
Some(e) => println!("{}", e),
None => println!("None"),
}

e1 通过下标访问获取 a 中的元素,而 e2 则是通过 get 方法访问获取 a 的元素,二者主要有以下区别:

  1. &[]会返回元素的引用
  2. get会返回一个Option<&T>类型

所以作用也很明显了,get 方法可以有效避免访问越界的问题,当你访问了一个不存在的元素时,它会给你返回一个 None 值,而 [] 则不会,若访问越界,会直接导致程序崩溃。两者可以视情况使用。

值得一提的是,这里使用引用借用了一下数组的元素,而之前我们说过,不可变引用不能和可变引用一起定义,所以当我们借用了数组元素后,是没有办法向数组末尾添加元素的

1
2
3
4
let mut a = vec![1, 2, 3];
let e1 = &a[0];
a.push(4);
println!("{:?}", e1);

因为push的时候,相当于传入了一个可变的引用,用于修改数组,而之前已经定义了一个不可别引用,两者无法同时存在,所以编译器会报错。

可能会有人问,我只是引用了数组的第一个元素而已,和插入的元素有什么关系?

这是因为动态数组本质上还是存储在一片堆上的连续空间,正因为是连续的,所以你变动了前面的元素的时候,就有可能会影响到后面的元素,这就是另类的数据竞争,Rust 的所有权机制杜绝了这种情况的发生(同理,你后面不会用到 e1,不存在修改前面数据情况的话,其实是可以编译通过的)

PS:

书上只提到了借用数组元素的访问方法,但实际上不用借用也是可以访问的,会通过创建一个副本的方式来进行返回

1
2
3
4
5
6
fn main() {
let mut a = vec![1, 2, 3];
let e = a[0]; // 返回的是值的副本
a.push(4); // 可以传入可变引用来修改数组
println!("{}", e);
}

记得我们在前面提过,基本类型都是存储在栈里的,所以默认赋值方式都是深拷贝返回副本。而存储在堆里的数据就不一样了,默认都是浅拷贝,所以当我们使用字符串的时候就没办法不借用访问了,编译器会报错提示我们需要使用copy方法,手动拷贝。

1
2
3
4
5
6
fn main() {
let mut a = vec![String::from("hello"), String::from("world")];
let e = a[0]; // 需要返回拷贝,但默认赋值不拷贝,报错。
a.push(String::from("!!!"));
println!("{}", e);
}

遍历数组

我们可以通过for循环来遍历动态数组

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let a = vec![1, 2, 3];
for i in &a {
println!("{}", i);
}
let mut b = a;
for i in &mut b{
*i += 5;
}
println!("{:?}", b);
}
  1. 对于不可变的动态数组a,我们用了不可变引用来遍历数组,并打印输出
  2. 对于可变数组b,我们用了可变引用来遍历数组,再进行解引用访问到对应的值,把他们每一个值都+5,再输出数组b

值得一提的是,这里遍历不用iter()方法,估计是通过某种方法内置了

用枚举类型让动态数组存储多个类型的值

一个动态数组只能存储一个类型的值,那我们需要存储多个类型的时候怎么办?记得之前介绍枚举类型的时候,我们提到过可以利用枚举类型,方便的向函数传入多个类型的参数,,那么我们可以利用一下这个特性,用于在动态数组里存储多个类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum MyType{
Int(i32),
Float(f32),
Text(String),
}

fn main() {
let a = vec![MyType::Int(1), MyType::Float(2.0), MyType::Text(String::from("Hello"))];
let e = &a[0];
match e {
MyType::Int(value)=> println!("Int: {}", value),
MyType::Float(value)=> println!("Float: {}", value),
MyType::Text(value)=> println!("Text: {}", value),
};
}

在这个例子里我们就把三个类型的值都定义为同一个枚举类型的值,让动态数组能够方便的存储。

同时,因为是枚举类型,所以理所当然的我们就需要在访问的时候穷举所有可能性。

字符串

我们已经在代码里用过无数次的String了,在这一节里,我们就需要更加深入的理解以下Rust里的字符串原理。

什么是字符串

Rust 核心部分只有一种字符串类型:字符串切片str,通常以&str的形态出现,用于指向别人的一段UTF-8编码的字符串引用

而我们常用的字符串String是实现在标准库里的。但这两种字符串的应用都非常广泛,我们既需要一个结构来存储完整的字符串,也很经常需要用字符串切片来引用其中一段字符串,人们一般把这两种类型都称作字符串,毕竟他们表现出来的样子就是一个人类所理解的字符串。另外再强调一下,Rust的字符串编码是UTF-8

当然标准库里还有很多乱七八糟的字符串,比如OsStringOsStrCStringCStr,后面的结尾是StringStr也表明了这个结构到底是所有权版本还是借用的版本,这些都是不同编码或者不同内存布局的字符串,书上没讲好像也不会讲,感兴趣可以自行查询官方API文档学习。

字符串的使用

很多在Vec能用的方法在String也能用

创建

首先我们就可以使用new的方法来创建空字符串:let s = String::new();

对于有初始值的情况,我们也可以用我们所熟知的String::from(),也可以用to_string的方法。

1
2
let data = "hello, world!"; // 创建一个字符串切片 &str
let s = data.to_string(); // 把&str转为String类型,获取所有权

这里我比较疑惑的是,用双引号定义的数据值在没有用to_string的时候所有权归谁?在什么时候被回收?书上说的是,这种类型叫Display trait,但没有详细说明,暂且放下。

fromto_string的效果是一样的,可以根据个人喜好使用。

更新

使用pushpush_str都能向字符串的末尾添加内容,其中push是添加字符,而push_str是添加字符串

1
2
3
4
5
6
fn main() {
let mut s = String::new();
s.push_str("hello world");
s.push('!'); // 单引号表示字符
println!("{}", s);
}

另外,push_strpush都是不取得所有权的,所以我们可以传入字符串切片就能实现添加,同时如果传入其他变量的话,也不会使得其他字符串失效。

同时,我们也可以使用+运算符来实现字符串的拼接

1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = String::from(" world");
let s3 = s1 + &s2;
println!("{}", s3);
}

但需要注意的是,+运算符只能实现String&str的拼接,也就是说以上代码会夺取s1的所有权,然后返回给s3,然后s1就不能再使用了,+运算符的大概定义是这样的:

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

所以+运算符似乎看起来用着不是很方便,首先1. 所有权会有影响,2. 在多字符串拼接的时候其实不是很方便。

所以我们还有一个宏函数,format!

1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = String::from(" world");
let s3 = format!("{}{}", s1, s2);
println!("{}", s3);
}

format!println!的用法是一样的,不同的是println是格式化输出到屏幕上,format是格式化输入到变量里存起来,而且format不会夺取任何变量的所有权

访问字符串

为什么不能索引访问?

大部分语言都是支持通过[]运算符,利用索引访问字符串的,我们可以试试

1
2
3
4
fn main() {
let s1 = String::from("hello");
let s = s1[0];
}

报错:error[E0277]: the type String cannot be indexed by {integer} 很简单,就是说String不支持索引访问。

至于为什么,就要从Rust实现String的底层来解释了。

我们可以先来看一下这个例子

1
2
3
4
5
fn main() {
let len1 = String::from("hello").len();
let len2 = String::from("你好").len();
println!("len1: {}, len2: {}", len1, len2);
}

这段代码里len1的输出值是5,这很正常,每个英文字母占用1个字节,代表一个位置,但len2却输出的是6,每个字符占用了3个字节,也就是3个位置,这意味着我们并不能通过索引,访问到我们真正想访问到的字符。

因为在Rust里,String其实是通过一个Vec的动态数组来实现的,其中u8就是对应着UTF-8编码的字节值。一个Unicode字符可以由多个UTF-8编码来表达,所以当用户访问索引0的时候,得到的只是“你”的编码的其中一部分,而不是完整“你”,是一个没有任何含义的整数值,这是没有意义的。所以为了避免返回一个不是用户期待的值,所以Rust禁用了索引访问,不进行编译。

当然,还有另外一个禁用索引访问的理由,那就是用户往往觉得索引访问的时间复杂度理所当然是O(1),但在字符串里,就需要视情况而定,不能保证,所以不用也好。

同理还有字符串切片

1
2
let s = String::from("你好");
println!("{}", &s[0..3]);

我们之前知道了你好里面每个字符占3个字节,所以我们可以通过一次性切3个字节,来得到一个“你”,那我是不是可以通过曲线救国的方式,利用切片来实现索引访问0?很不幸,Rust也掐死了这一条路,当你视图使用&s[0..1]的方式访问索引0时,程序会发生崩溃,告诉你不能这么写。

怎么访问字符串?

既然不能索引访问,那我们究竟要怎么访问字符串呢?

  1. chars()方法
1
2
3
4
5
6
fn main() {
let s = String::from("你好");
for c in s.chars() {
println!("{}", c);
}
}

chars方法能把字符串里的字节值凑成一个char类型的值再作为一个结果数组返回给你,这样你就可以放心的访问得到你所希望的字符了。

  1. bytes() 方法

可能就有人说了,那我就是想访问到索引0,我想知道这里存的字节值是什么!放心,也有办法,bytes() 方法就可以把字符串转成字节值的数组,让你访问到每一个字节值

1
2
3
4
5
6
fn main() {
let s = String::from("你好");
for c in s.bytes() {
println!("{}", c);
}
}

总的来说,Rust为了考虑字符串中的使用安全,把很多字符串内部实现的复杂性给暴露了出来,你不得不去考虑更多底层的东西。这也是一个权衡,为了安全,你不得不付出一些便利性的代价。

哈希映射

哈希也是一个非常常用的数据结构了,它存储了一个键(Key)到值(Value)的映射关系,很多时候我们并不满足于普通数组的索引下标-值的映射关系,这时候就可以用哈希映射。

创建和使用

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

fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("blue"), 1);
scores.insert(String::from("red"), 2);
}

哈希映射(HashMap)被定义在标准库的collections中,不是默认导入的,所以我们要用use来引入。

我们通过常规的new方法建立了一个哈希映射,然后通过insert方法插入了一对映射关系,其中第一个值是key,第二个值value,这里的意思就是,我们建立了一个比分映射关系,key是队伍,用字符串代表红队蓝队,value是值,代表比分。建立这么一个映射关系,我们就可以很轻松的知道队伍的对应得分。

值得一提的是,哈希映射也要求所有的键是一个类型,所有的值是一个类型

我们也可以通过动态数组来作为初始值构建哈希映射

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

fn main() {
let team = vec![String::from("blue"), String::from("red")];
let score = vec![1, 2];
let scores: HashMap<_,_> = team.iter().zip(score.iter()).collect();
println!("{:?}", scores);
}

这段代码里,我们首先定义了一个动态数组存储队伍,一个动态数组存储比分。然后我们使用zip方法,把队伍和比分创建一个元组一一对应起来,这里得到的结果大概是这样的:[(blue, 1), (red, 2)],然后,我们再通过collect()方法,以第一个值为key,第二个值为value,把这么一个元组数组转成哈希映射。

这里需要显示的给出类型,因为collect方法可以作用于不同的数据结构,不止是哈希表,所以我们要告诉编译器,这里我们是collect成了一个哈希表,但对于哈希表内的元素类型,我们可以写成通配符,让编译器去推理得到。

哈希映射的所有权

和之前说的差不多,对于基本类型这种存储在栈上的数据,会深复制一份进入哈希映射,而存储在堆上的数据,像字符串,会把所有权一并传入哈希映射,导致原来的变量被销毁。

当然我们可以把字符串的引用传进哈希映射,但这种做法就需要保证哈希映射的作用域结束之前字符串是一直有效的。这一部分的知识需要到后面的生命周期部分详细解释。

访问元素

和动态数组的访问一样,我们可以通过[]访问,也可以通过get访问,里面不同点和需要注意的地方也是相同的。

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let teama = String::from("blue");
let mut scores = HashMap::new();
scores.insert(teama, 1);
scores.insert(String::from("red"), 2);
println!("{:?}", scores["blue"]);
match scores.get("blue") {
Some(&score) => println!("blue team score is {}", score),
None => println!("blue team score is not found"),
}
}

遍历访问同理

1
2
3
4
5
6
7
8
9
fn main() {
let teama = String::from("blue");
let mut scores = HashMap::new();
scores.insert(teama, 1);
scores.insert(String::from("red"), 2);
for (key, value) in &scores { //注意借用,不然for完所有权被没收了
println!("{}: {}", key, value);
}
}

更新

覆盖旧值

哈希映射的一个键只能对应一个值,所以当我们往哈希映射里插入已有的键值,会覆盖原有的键的值。

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

fn main() {
let teama = String::from("blue");
let mut scores = HashMap::new();
scores.insert(teama, 1);
scores.insert(String::from("blue"), 2); // 把原本blue的1覆盖为2
println!("{:?}", scores);
}

有时我们不想覆盖掉旧值,只想在没有对应的key的时候才插入数据,这时候可以用entry方法

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

fn main() {
let teama = String::from("blue");
let mut scores = HashMap::new();
scores.insert(teama, 1);
scores.entry(String::from("blue")).or_insert(3);
scores.entry(String::from("red")).or_insert(3);
println!("{:?}", scores);
}

entry方法会返回一个Entry的枚举,表面这个键的值是否存在,or_insert方法返回Entry所指向的值的引用,如果值不存在,就把传入的值插入到哈希映射里,返回这个值的引用。

知道了or_insert后,我们就可以利用它做一些更灵活的应用。

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

fn main() {
let text = String::from("aaaabbbbbcc");
let mut char_count = HashMap::new();
for c in text.chars() {
let count = char_count.entry(c).or_insert(0);
*count += 1;
}
println!("{:?}", char_count);
}

这段代码就是简单的数字符串里的英文字母有多少个。我们利用entry+or_insert在字母不存在的时候,赋初值为0,然后对这个访问到的字母加1次数。之前说了,or_insert会返回一个值的引用,所以我们可以利用这个返回,轻松的访问到对应的值的空间,只需要一次解引用就可以了。并且这个可变引用会在for循环结尾就离开作用域,也满足安全的规则

值得一提的是,似乎不能通过索引的方式对哈希表进行值更改,但报错提示里提到了get_mut()方法,书上没说,或许感兴趣可以后续看看。

所使用的哈希函数

Rust 默认使用的是一个比较安全的哈希加密算法,但并不是最高效的一个算法,如果你觉得这样效率太低,当然你也可以通过 trait 的方式使用自己的哈希算法,这会在后续的章节里有讲。


结束!这一章里面主要就是学了动态数组/字符串/哈希映射三个集合结构,介绍了一下基本的使用,还有他们的所有权规则,为了满足这个所有权,很多结构的使用方式都变得有点麻烦了,放python里,基本都是一个索引就能解决的事,在这里要考虑一大堆所有权问题,肉眼可见的麻烦起来了。

下一节学错误处理,学完就到trait和生命周期了,搞完这些个rust独有的概念,也就差不多可以上手使用了。

  • 标题: 【Rust 学习记录】8. 通用集合类型
  • 作者: TwoSix
  • 创建于 : 2023-04-02 22:51:57
  • 更新于 : 2024-07-04 23:52:28
  • 链接: https://twosix.page/2023/04/02/【Rust-学习记录】8-通用集合类型/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论