❝
如果长期不做选择,大脑会变得很消极。 《向上生长》
❞
大家好,我是**「柒八九」**。
今天,我们继续**「Rust学习笔记」的探索。我们来谈谈关于「集合」**的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
你能所学到的知识点
❝
- 如何使用
vector
「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️- 字符串存储
UTF-8
编码的文本 「推荐阅读指数」 ⭐️⭐️⭐️⭐️- 哈希 map 储存键值对 「推荐阅读指数」 ⭐️⭐️⭐️⭐️
❞
好了,天不早了,干点正事哇。
Rust
标准库中包含一系列被称为 集合collections的非常有用的数据结构。大部分其他数据类型都代表一个特定的值,不过集合可以包含多个值。「不同于内建的数组和元组类型,这些集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就已知,并且还可以随着程序的运行增长或缩小」。
vector
允许我们一个挨着一个地储存一系列数量可变
的值- 字符串string是字符的集合。
- 哈希 maphash map允许我们将值与一个特定的键key相关联。
vector: 用来储存一系列的值
Vec<T>
,也被称为 vector
。vector
允许我们在一个**「单独的数据结构」中储存多个值,「所有值在内存中彼此相邻排列」**。vector
「只能储存相同类型的值」。
新建 vector
为了创建一个新的空 vector
,可以调用 Vec::new
函数
let v: Vec<i32> = Vec::new(); 复制代码
这里我们增加了一个**「类型标注」。因为没有向这个 vector
中插入任何值,Rust
并不知道我们想要储存什么类型的元素。vector
是用泛型实现
的。现在,我们知道 Vec
是一个由标准库提供的类型,它可以「存放任何类型」,而当 Vec
存放某个特定类型
时,那个「类型位于尖括号中」**。上面的代码告诉 Rust
v
这个 Vec
将存放 i32
类型的元素。
在更实际的代码中,一旦插入值 Rust
就可以**「推断出想要存放的类型」,所以你很少会需要这些类型标注。更常见的做法是使用「初始值」**来创建一个 Vec
,而且为了方便 Rust
提供了 vec! 宏
。这个宏
会根据我们提供的值来创建一个新的 Vec
。
let v = vec![1, 2, 3]; 复制代码
提供了 i32
类型的初始值,Rust
可以推断出 v
的类型是 Vec<i32>
,因此类型标注
就不是必须的。
更新 vector
对于新建一个 vector
并向其增加元素,可以使用 push
方法
let mut v = Vec::new(); v.push(5); v.push(6); v.push(7); v.push(8); 复制代码
❝
如果想要能够改变它的值,必须使用
mut
关键字使其可变。❞
丢弃 vector 时也会丢弃其所有元素
类似于任何其他的 struct
,vector
在其**「离开作用域时会被释放」**。
{ let v = vec![1, 2, 3, 4]; // 处理变量 v } // <- 这里 v 离开作用域并被丢弃 复制代码
当 vector
被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。
读取 vector 的元素
有两种方法**「引用」** vector
中储存的值。
let v = vec![1, 2, 3, 4, 5]; let third: &i32 = &v[2]; println!("第三个元素是 {}", third); match v.get(2) { Some(third) => println!("第三个元素是 {}", third), None => println!("不存在第三个元素"), } 复制代码
- 首先,我们使用**「索引值」** 2 来获取第三个元素,索引是
从 0 开始的
。 - 其次,这两个不同的获取第三个元素的方式分别为:
使用 &
和[]
返回一个**「引用」**;- 或者使用
get
方法以索引作为参数
来返回一个Option<&T>
。
Rust
有两个引用元素的方法的原因是程序可以选择如何处理当索引值在 vector
中没有对应值的情况。
let v = vec![1, 2, 3, 4, 5]; let does_not_exist = &v[100]; let does_not_exist = v.get(100); 复制代码
当运行这段代码,你会发现对于第一个 []
方法,当引用一个不存在的元素时 Rust
会造成 panic
。
当 get
方法被传递了一个**「数组外的索引」**时,它不会 panic
而是返回 None
。当偶尔出现超过 vector
范围的访问属于正常情况的时候可以考虑使用它。
❝
一旦程序获取了一个有效的引用,**「借用检查器」将会「执行所有权」和「借用规则」**来确保
vector
内容的这个引用和任何其他引用保持有效。❞
「不能在相同作用域中同时存在可变和不可变引用的规则」。这个规则适用于如下代码,当我们获取了 vector
的第一个元素的**「不可变引用」**并尝试在 vector
末尾增加一个元素的时候,这是行不通的:
let mut v = vec![1, 2, 3, 4, 5]; let first = &v[0]; v.push(6); 复制代码
不能这么做的原因是由于 vector
的工作方式:在 vector
的结尾增加新元素时,在**「没有足够空间」将所有所有元素依次相邻存放的情况下,可能会「要求分配新内存并将老的元素拷贝到新的空间中」**。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历 vector 中的元素
想要依次访问 vector
中的每一个元素,我们可以**「遍历其所有的元素」**而无需通过索引一次一个的访问。
let v = vec![100, 32, 57]; for i in &v { println!("{}", i); } 复制代码
也可以遍历可变 vector
的每一个元素的可变引用以便能改变他们
let mut v = vec![100, 32, 57]; for i in &mut v { *i += 50; } 复制代码
for
循环会给每一个元素加 50
。为了修改可变引用所指向的值,在使用 +=
运算符之前必须使用**「解引用运算符」**(*
)获取 i
中的值。
使用枚举来储存多种类型
提到 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), ]; 复制代码
使用字符串存储 UTF-8 编码的文本
字符串就是作为**「字节的集合」**外加一些方法实现的,当这些字节被解释为文本时,这些方法提供了实用的功能。
什么是字符串?
Rust
的核心语言中只有一种字符串类型:str
,字符串 slice
,它通常以**「被借用的形式出现」**,&str
。
❝
字符串 slice
:它们是一些储存在别处的UTF-8
编码字符串数据的**「引用」**。❞
称作 String
的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8
编码的字符串类型。
新建字符串
很多 Vec
可用的操作在 String
中同样可用,从 new
函数创建字符串开始。
let mut s = String::new(); 复制代码
这新建了一个叫做 s
的空的字符串,接着我们可以向其中装载数据。通常字符串会有**「初始数据」**,因为我们希望一开始就有这个字符串。为此,可以使用 to_string
方法,它能用于任何实现了 Display
trait
的类型,字符串字面量也实现了它。
let data = "前端柒八九"; let s = data.to_string(); // 该方法也可直接用于字符串字面量: let s = "initial contents".to_string(); 复制代码
也可以使用 String::from
函数来从字符串字面量创建 String
。
let s = String::from("initial contents"); 复制代码
请记住,字符串是 UTF-8
编码的,所以可以包含任何正确编码的数据
let hello = String::from("你好"); let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); 复制代码
更新字符串
String
的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec
的内容一样。另外,可以方便的使用 + 运算符
或 format! 宏
来拼接 String
值。
使用 push_str 和 push 附加字符串
可以通过 push_str
方法来附加字符串 slice
,从而使 String
变长
let mut s = String::from("foo"); s.push_str("bar"); 复制代码
push_str
方法采用字符串 slice
,因为并**「不需要获取参数的所有权」**。
let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {}", s2); 复制代码
s2
正常打印。
push
方法被定义为获取一个**「单独的字符作为参数」**,并附加到 String
中。
let mut s = String::from("lo"); s.push('l'); 复制代码
使用 + 运算符或 format! 宏拼接字符串
通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符
。
let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用 复制代码
执行完这些代码之后,字符串 s3
将会包含 Hello, world!
。s1
在相加后**「不再有效」。而s2
由于使用了「引用」**,在进行操作完,还是有效的。
对于更为复杂的字符串链接,可以使用 format! 宏
:
let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{}-{}-{}", s1, s2, s3); 复制代码
这些代码也会将 s
设置为 “tic-tac-toe”
。format!
与 println!
的工作原理相同,不过不同于将输出打印到屏幕上,它**「返回一个带有结果内容」**的 String
。
索引字符串
❝
Rust
的字符串不支持索引。❞
内部表现
String
是一个 Vec<u8>
的封装
let len = String::from("Hola").len(); 复制代码
在这里,len
的值是 4 ,这意味着储存字符串 “Hola” 的 Vec
的长度是 4 个字节
:这里每一个字母的 UTF-8
编码都占用 1 个字节。
let len = String::from("Здравствуйте").len(); 复制代码
当问及这个字符长度是多少?Rust
的回答是 24。这是使用 UTF-8
编码 “Здравствуйте” 所需要的字节数,这是因为**「每个 Unicode
标量值需要 2 个字节存储」。因此「一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值」**。
字节、标量值和字形簇!
从 Rust
的角度来讲,事实上有三种相关方式可以理解字符串:字节
、标量值
和字形簇
(最接近人们眼中**「字母」**的概念)。
比如这个用梵文书写的印度语单词 “नमस्ते”
,最终它储存在 vector
中的 u8
值看起来像这样:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135] 复制代码
这里有 18 个字节
,也就是**「计算机最终会储存的数据」**。如果从 Unicode
标量值的角度理解它们,也就像 Rust
的 char
类型那样,这些字节看起来像这样:
['न', 'म', 'स', '्', 'त', 'े'] 复制代码
这里有六个 char
,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。
最后,如果以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:
["न", "म", "स्", "ते"] 复制代码
Rust
提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。
最后一个 Rust
不允许使用索引获取 String
字符的原因是,索引操作预期总是需要常数时间 (O(1)
)。但是对于 String
不可能保证这样的性能,因为 Rust
「必须从开头到索引位置遍历来确定有多少有效的字符」。
字符串 slice
为了更明确索引并表明你需要一个字符串 slice
,相比使用 []
和单个值的索引,可以使用 []
和一个 range
来创建含特定字节的字符串 slice
:
let hello = "Здравствуйте"; let s = &hello[0..4]; 复制代码
s
会是一个 &str
,它包含字符串的头 4 个字节
遍历字符串的方法
如果你需要操作单独的 Unicode
标量值,最好的选择是使用 chars
方法。对 “नमस्ते” 调用 chars
方法会将其分开并返回六个 char
类型的值,接着就可以遍历其结果来访问每一个元素了:
for c in "नमस्ते".chars() { println!("{}", c); } 复制代码
输出结果为
न म स ् त े 复制代码
bytes
方法返回每一个原始字节。
for b in "नमस्ते".bytes() { println!("{}", b); } 复制代码
代码会打印出组成 String
的 18 个字节:
224 164 // --snip-- 165 135 复制代码
哈希 map 储存键值对
HashMap<K, V>
类型储存了一个键类型 K
对应一个值类型 V
的映射。它通过一个哈希函数hashing function来实现映射,决定如何将键和值放入内存中。
哈希 map 可以用于需要**「任何类型作为键」**来寻找数据的情况,而不是像 vector
那样通过索引。
新建一个哈希 map
可以使用 new
创建一个空的 HashMap
,并使用 insert
增加元素。
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); 复制代码
必须首先 use
标准库中集合部分的 HashMap
。在这三个常用集合中,HashMap
是最不常用的,所以并没有被 prelude
自动引用。
像 vector
一样,哈希 map
将它们的**「数据储存在堆上」,这个 HashMap
的键类型是 String
而值类型是 i32
。类似于 vector
,「哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型」**。
构建哈希 map
的方法是使用一个元组的 vector
的 collect
方法,其中**「每个元组包含一个键值对」**。collect
方法可以将数据收集进一系列的集合类型,包括 HashMap
。
use std::collections::HashMap; let teams = vec![String::from("Blue"), String::from("Yellow")]; let initial_scores = vec![10, 50]; let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect(); 复制代码
这里 HashMap<_, _>
类型标注是必要的,因为 collect
有可能当成多种不同的数据结构,而除非显式指定否则 Rust
无从得知你需要的类型。但是对于键和值的类型参数来说,可以**「使用下划线占位」**,而 Rust
能够根据 vector
中数据的类型推断出 HashMap
所包含的类型。
哈希 map 和所有权
对于像 i32
这样的实现了 Copy trait
的类型,其值可以**「拷贝」进哈希 map。对于像 String
这样拥有所有权的值,其值将被「移动」而哈希 map
会「成为这些值的所有者」**.
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 不再有效, 复制代码
当 insert
调用将 field_name
和 field_value
移动到哈希 map
中后,将不能使用这两个绑定。
访问哈希 map 中的值
可以通过 get
方法并提供对应的键来从哈希 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); 复制代码
这里,score
是与蓝队分数相关的值,应为 Some(10)
。因为 get
返回 Option<V>
,所以结果被装进 Some
;如果某个键在哈希 map 中没有对应的值,get
会返回 None
。
可以使用与 vector
类似的方式来遍历哈希 map
中的每一个键值对,也就是 for
循环:
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); for (key, value) in &scores { println!("{}: {}", key, value); } 复制代码
以**「任意顺序」**打印出每一个键值对:
Yellow: 50 Blue: 10 复制代码
更新哈希 map
尽管键值对的数量是可以增长的,不过任何时候,「每个键只能关联一个值」。当我们想要改变哈希 map 中的数据时,「必须决定如何处理一个键已经有值了的情况」。
- 可以选择**「完全无视旧值」**并用新值代替旧值。
- 可以选择**「保留旧值」**而忽略新值,并只在键 没有 对应值时增加新值。
- 或者可以**「结合新旧两值」**。
覆盖一个值
如果我们插入了一个键值对,接着用**「相同的键插入一个不同的值」,与这个键相关联的「旧值将被替换」**。
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}
。原始的值 10
则被覆盖了。
只在键没有对应值时插入
会检查某个特定的键是否有值,如果没有就插入一个值。为此哈希 map
有一个特有的 API,叫做 entry
,它**「获取我们想要检查的键作为参数」。entry
函数的返回值是一个「枚举」,Entry
,它「代表了可能存在也可能不存在的值」**。
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); 复制代码
Entry
的 or_insert
方法在键对应的值**「存在时就返回这个值的可变引用」,如果「不存在则将参数作为新值插入并返回新值的可变引用」**。
运行上面的的代码会打印出 {"Yellow": 50, "Blue": 10}
。第一个 · 调用会插入黄队的键和值 50,因为黄队并没有一个值。第二个 entry
调用**「不会改变哈希 map」** 因为蓝队已经有了值 10。
根据旧值更新一个值
另一个常见的哈希 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
方法事实上会返回**「这个键的值的一个可变引用」**(&mut V
)。这里我们将这个可变引用储存在 count
变量中,所以为了赋值必须首先使用星号(*
)解引用 count
。
哈希函数
HashMap
默认使用一种 密码学安全cryptographically strong的哈希函数,它可以抵抗拒绝服务Denial of Service( DoS)攻击。
后记
「分享是一种态度」。
参考资料:《Rust权威指南》
「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」