文章目录
- [1. 前言](#1. 前言)
- [2. Vector](#2. Vector)
-
- [2.1. 构建一个 vector](#2.1. 构建一个 vector)
- [2.2. 获取 vector 中的元素](#2.2. 获取 vector 中的元素)
- [2.3. 遍历 vector](#2.3. 遍历 vector)
- [2.4. 使用枚举来储存多种类型](#2.4. 使用枚举来储存多种类型)
- [3. String](#3. String)
-
- [3.1. 新建字符串](#3.1. 新建字符串)
- [3.2. 更新字符串](#3.2. 更新字符串)
- [3.3. 字符串的内部结构](#3.3. 字符串的内部结构)
-
- [3.3.1. 字符串如何访问内部元素?](#3.3.1. 字符串如何访问内部元素?)
- [3.3.2. 字节、标量值和字形簇](#3.3.2. 字节、标量值和字形簇)
- [3.4. 字符串 slice](#3.4. 字符串 slice)
- [3.5. 字符串遍历](#3.5. 字符串遍历)
- [4. Hash Map](#4. Hash Map)
-
- [4.1. 新建一个 Hash Map](#4.1. 新建一个 Hash Map)
- [4.2. 获取 map 中值](#4.2. 获取 map 中值)
- [4.4. 哈希 map 和所有权](#4.4. 哈希 map 和所有权)
- [4.5. 覆盖关键字的值](#4.5. 覆盖关键字的值)
- [4.6. 当关键字不存在时才插入键值对](#4.6. 当关键字不存在时才插入键值对)
- [4.7. 根据旧值更新一个值](#4.7. 根据旧值更新一个值)
1. 前言
看过上一篇博客的长篇大论一定很疲惫,不过笔者保证这一篇我们将会很轻松的度过~
在其它编程语言中也会有集合这样的类似概念,例如在 C++
中将这种数据结构称为容器 ,而在 Rust
中则将这些数据结构称为集合 (collections
)。集合是一种非常有用的数据结构,当有一类具有相同属性或者共同点的东西归类或统计时,集合就会派上大用处。
在 Rust
中常见的几个集合:
- vector :存储一系列同类型数量可变的值;
- 字符串(string):这时字符的集合,本文将会着重介绍它;
- 哈希 map(hash map) :每个成员是由一个唯一存在的关键字(
key
)和其所一一对应的值(value
) 所组成。
接下来让我们一个个的认识并掌握!
2. Vector
Vec<T>
,也被称为 vector
。vector
允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值。vector
只能储存相同类型的值。
正如所看到的一样这里的 <>
表示这是一个泛型实现的类型,到目前为止可以不用过于深入理解泛型,只要大概明白它的概念即可,这里的 T
可以被任何类型替换,表示该向量将会且只能存储这种类型的值。
2.1. 构建一个 vector
创建一个新的空 vector
,可以调用 Vec::new
函数:
rust
let v: Vec<i32> = Vec::new();
可以看到这里创建的 vector
并没有初始化,因此我们在声明的时候需要指明其具体类型。
当有初始值时,创建的 vector
允许不显示声明其类型:
rust
let v = vec![1, 2, 3];
这里虽然没有显示的注明 vector
的类型,但是编译器通过其初始值可以推断出 v
的类型是 Vec<i32>
。
当我们新建完一个空的 vector
后可以用 push
方法向其添加元素:
rust
// 注意,此时则需要创建可变类型
let mut v = Vec::new();
// 向最后插入一个新的元素
v.push(5);
v.push(6);
v.push(7);
v.push(8);
// 删除末尾的一个元素
v.pop();
[注]:Rust 编译器也可以通过后面所添加的值来推断 vector 类型。
2.2. 获取 vector 中的元素
有两种方式可以获取 vector
中的元素:
- 通过索引的方式访问获取;
- 使用
get
方法获取。
rust
let v = vec![1, 2, 3, 4, 5];
// 通过索引的方式访问
let third: &i32 = &v[2];
println!("The third element is {third}");
// 通过 vector 的 get 方法访问
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
这里需要注意的一个点是 get
方法返回的是 Option<&T>
类型并非元素值本身,因此我们需要通过 match
提取具体的值。
越界问题
访问越界这是一个很常见的问题,我们来看看 Rust
是如何处理的:
rust
let v = vec![32, 26, 48];
let unexist = &v[10]; // 索引
let unexist = v.get(10); // get
首先这段代码是可以编译通过的,因为语法没有任何问题,但是在运行时将会使得程序崩溃,由于 Rust
发现试图去访问一个超过 vector
范围的内存,直接会终止程序运行。
那么当我们注释掉 "索引" 那行代码后,这段程序时可以正常运行的,这是何原因呢?
由于 get
返回的是 Option<&T>
类型,也就是当有值的时候会返回 Some(i)
,没有值时候返回 None
,这两个都不会导致程序崩溃。这样设计的原因在于 Rust
考虑到这种越界特殊情况的处理工作也可以交给开发者自己处理,而不是一味的终止代码运行,这是很不好的体验。
再看另外一种情况:
rust
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
在编译这段代码时会报错:
shell
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("The first element is: {first}");
| ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error
编译器告诉我们,因为 first
这里用的是 v
的不可变引用,而下面尝试调用 push
方法时的 v
应该是可变变量,而在其后又希望再继续使用 first
这样的操作是不被允许的,其实当我们注释掉最后一行的打印这段代码也是没问题的。
那么为什么呢?这个看起来是之前我们学习引用那块学习到的一个规则 "不能在相同作用域中同时存在可变和不可变引用"。
这里就涉及到 vector
的工作原理,在 vector
的结尾增加新元素时,在没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。
2.3. 遍历 vector
Rust
提供了非常便利的遍历方式,无需我们自己通过索引一个个的访问:
rust
let v = vec![32, 26, 48];
for i in &v {
println!("{i}");
}
也可以遍历过程中使用元素的值:
rust
let mut v = vec![32, 26, 48];
for i in &mut v {
*i += 50;
}
修改可变引用所指向的值,需要在使用 +=
运算符之前必须使用解引用运算符(*
)获取 i
中的值。
2.4. 使用枚举来储存多种类型
上文提到了 vector
只能够存储同一类型的值,不过这一点概念是相对的。还记得之前我们学习过一种叫做枚举的类型吗?
没错!你知道我想说什么了,是的,枚举变量中可以包含各种各样的类型,而对外这一个整体只会被认作同一个类型那就是枚举本身。
rust
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
这样一来 row
的类型就是 Vec<enum SpreadsheetCell>
,再配合 match
,我们就可以利用同一个 vector
存储不同类型的值:
rust
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
for i in &row {
match i {
SpreadsheetCell::Int(i) => println!("{}", i),
SpreadsheetCell::Float(f) => println!("{}", f),
SpreadsheetCell::Text(s) => println!("{}", s),
}
}
同样的当 vector
变量离开自己所属的作用域时将会被丢弃,所有的内容将会清空,同时变量本身也会清理变得无效。
3. String
这一小节是本文的重头戏,请各位同学打起精神仔细阅读本小节。
其实之前我们已经使用过 String
类型,因此它对于我们并不算陌生,而 String
类型在初学者一路学习过来很容易与另一个类型 str
搞混,这个类型值之前有解释过,str
是 String
中的一部分值的引用,这是两种类型。
在 Rust
中 String
类型实际上是对 Vec<u8>
类型的封装实现的。且字符串是 UTF-8 编码的。
3.1. 新建字符串
使用 new
方法创建一个空的字符串。
rust
let mut s = String::new();
有两种方式可以让我们为空的字符串填充数据:
to_string()
方法;String::from()
函数。
rust
let data = "initial contents";
let s = data.to_string();
// 效果与上行代码相同
//let s = String::from("initial contents");
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
正因为 String
类型是 UTF-8
编码的,因此可以使用任何符合 UTF-8
编码的数据:
rust
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
let hello = String::from("🌟");
3.2. 更新字符串
String
的大小可以增加,其内容也可以改变。可以方便的使用 +
运算符或 format!
宏来拼接 String
值。
[注]:还记得吧,带有 ! 表示一个宏。忘了的同学罚款3000w。
可以通过 push_str
函数为 String
类型追加 str
类型而使得 String
变长。
rust
let mut s = String::from("hello,");
s.push_str("world!");
执行这两行代码之后,s
将会包含 hello,world!
。push_str
方法采用字符串 slice
,因为该方法并不需要获取参数的所有权。
rust
let mut s1 = String::from("hello,");
let s2 = "world!";
s1.push_str(s2);
println!("s2 is {s2}");
例如,这里希望在将 s2 的内容附加到 s1 之后还能使用它。是的,由于 push_str
使用的字符串 slice
,还记得吧,str
只是 String
内容的一部分的引用,是的它是引用,因此不会获取所有权,因此这里在后面是可以继续用 s2
。
还可以通过使用 push
方法为末尾添加一个字符。
rust
let mut s = String::from("Ma");
s.push('x');
通过使用 +
组合两个字符串。
rust
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
执行完这些代码之后,字符串 s3
将会包含 Hello, world!
。s1
在相加后不再有效的原因,和使用 s2
的引用的原因,与使用 +
运算符时调用的函数签名有关。+
运算符使用了 add
函数,这个函数签名看起来像这样:
rust
fn add(self, s: &str) -> String {
在标准库中你会发现,add
的定义使用了泛型和关联类型。在这里我们替换为了具体类型,这也正是当使用 String
值调用这个方法会发生的。这个签名提供了理解 +
运算那微妙部分的线索。
首先,s2
使用了 &
,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为 add
函数的 s
参数:只能将 String
和 &str
相加,不能将两个 String
值相加。&s2
的类型是 &String
, 而不是 add
第二个参数所指定的 &str
。但为什么编译器没有报错呢?
是的,你想的没错,这里的确发生了隐式的强制类型转换,将 &String
转为 &str
,这是 Rust
替我们完成的工作。而由于 s2
这里用的是引用,因此在 add
之后还是可以正常使用的。而这里的 s1
可就惨了,被 add
获取的所有权,在 add
之后就不能在使用了。
再来看 format!
这个宏的使用:
rust
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
。
3.3. 字符串的内部结构
3.3.1. 字符串如何访问内部元素?
在大多数编程语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust
中,如果你尝试使用索引语法访问 String
的一部分,会出现一个错误。
rust
let s1 = String::from("hello");
let h = s1[0];
这样的访问编译器将会报错:
shell
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
错误和提示说明了全部问题:Rust
的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust
是如何在内存中储存字符串的。
上文提到了 String
其实是一个 Vec<u8>
的封装,让我们来看一个例子:
rust
let hello = String::from("Hello");
在这里,len
的值是 5,这意味着储存字符串 "Hello" 的 Vec
的长度是五个字节:这里每一个字母的 UTF-8
编码都占用一个字节。那下面这个例子又如何呢?(注意这个字符串中的首字母是西里尔字母的 Ze
而不是数字 3
。)
rust
let hello = String::from("Здравствуйте");
当问及这个字符是多长的时候有人可能会说是 12
。然而,Rust
的回答是 24
。这是使用 UTF-8
编码 "Здравствуйте"
所需要的字节数,这是因为每个 Unicode
标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode
标量值。
rust
let hello = "Здравствуйте";
let answer = &hello[0];
[注]:这段代码无法编译通过,在这里仅为说明问题。
我们已经知道 answer
不是第一个字符 3
。当使用 UTF-8
编码时,(西里尔字母的 Ze
)З
的第一个字节是 208
,第二个是 151
,所以 answer
实际上应该是 208
,不过 208
自身并不是一个有效的字母。返回 208
可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust
在字节索引 0
位置所能提供的唯一数据。用户通常不会想要一个字节值被返回。即使这个字符串只有拉丁字母,如果 &"hello"[0]
是返回字节值的有效代码,它也会返回 104
而不是 h
。
为了避免返回意外的值并造成不能立刻发现的 bug
,Rust
根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
3.3.2. 字节、标量值和字形簇
这引起了关于 UTF-8
的另外一个问题:从 Rust
的角度来讲,事实上有三种相关方式可以理解字符串:字节 、标量值 和字形簇 (最接近人们眼中字符的概念)。
比如这个用梵文书写的印度语单词 "नमस्ते"
,最终它储存在 vector
中的 u8
值看起来像这样:
rust
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
这里有 18
个字节,也就是计算机最终会储存的数据。如果从 Unicode
标量值的角度理解它们,也就像 Rust
的 char
类型那样,这些字节看起来像这样:
rust
['न', 'म', 'स', '्', 'त', 'े']
这里有六个 char
,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:
rust
["न", "म", "स्", "ते"]
Rust
提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。
最后一个 Rust
不允许使用索引获取 String
字符的原因是,索引操作预期总是需要常数时间(O(1))
。但是对于 String
不可能保证这样的性能,因为 Rust
必须从开头到索引位置遍历来确定有多少有效的字符。
3.4. 字符串 slice
索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice
。因此,如果你真的希望使用索引创建字符串 slice
时,Rust
会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice
,相比使用 []
和单个值的索引,可以使用 []
和一个 range
来创建含特定字节的字符串 slice
:
rust
let hello = "Здравствуйте";
let s = &hello[0..4];
这里,s
会是一个 &str
,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s
将会是 "Зд"
。
如果获取 &hello[0..1]
会发生什么呢?答案是,在运行中 Rust
将会报错,不允许将一个编码的字符拆开输出,因为没有与之对应的字符。
3.5. 字符串遍历
操作字符串每一部分的最好的方法是明确表示需要字符还是字节。对于单独的 Unicode
标量值使用 chars
方法。对 "Зд"
调用 chars
方法会将其分开并返回两个 char
类型的值,接着就可以遍历其结果来访问每一个元素了:
rust
for c in "Зд".chars() {
println!("{c}");
}
运行结果:
shell
З
д
另外 bytes
方法返回每一个原始字节,这可能会适合你的使用场景:
rust
for b in "Зд".bytes() {
println!("{b}");
}
这些代码会打印出组成 String
的 4
个字节:
shell
208
151
208
180
总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。Rust
选择了以准确的方式处理 String
数据作为所有 Rust
程序的默认行为,这意味着程序员们必须更多的思考如何预先处理 UTF-8
数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发周期后期免于处理涉及非 ASCII
字符的错误。
好消息是标准库提供了很多围绕 String
和 &str
构建的功能,来帮助我们正确处理这些复杂场景。请务必查看这些使用方法的文档,例如 contains
来搜索一个字符串,和 replace
将字符串的一部分替换为另一个字符串。
好了,本文最繁杂的部分已经结束了,接下来将会非常轻松的进行。
4. Hash Map
哈希 map(hash map)
。HashMap<K, V>
类型储存了一个键类型 K
对应一个值类型 V
的映射。它通过一个 哈希函数(hashing function
)来实现映射,决定如何将键和值放入内存中。
在很多变成语言都支持 map
这个数据结构,因此也不算陌生。
4.1. 新建一个 Hash Map
使用 new
创建一个空的 HashMap
,并使用 insert 增加元素。这里我们记录两支队伍的分数,分别是蓝队和黄队。蓝队开始有 10 分而黄队开始有 50 分:
rust
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
注意必须首先 use
标准库中集合部分的 HashMap
。像 vector
一样,哈希 map
将它们的数据储存在堆上,这个 HashMap
的键类型是 String
而值类型是 i32
。
4.2. 获取 map 中值
通过 get
方法根据 关键字 可以获取对应的 值:
rust
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);
这里,score
是与蓝队分数相关的值,应为 10
。get
方法返回 Option<&V>
,如果某个键在哈希 map
中没有对应的值,get
会返回 None
。
程序中通过调用 copied
方法来获取一个 Option<i32>
而不是 Option<&i32>
,接着调用 unwrap_or
在 scores
中没有该键所对应的项时将其设置为零。
可以使用与 vector
类似的方式来遍历哈希 map
中的每一个键值对,也就是 for
循环:
rust
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}");
}
这会以任意顺序打印出每一个键值对:
rust
Yellow: 50
Blue: 10
4.4. 哈希 map 和所有权
对于像 i32
这样的实现了 Copy trait
的类型,其值可以拷贝进哈希 map
。对于像 String
这样拥有所有权的值,其值将被移动而哈希 map
会成为这些值的所有者:
rust
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
,这些值本身将不会被移动进哈希 map
。但是这些引用指向的值必须至少在哈希 map
有效时也是有效的。
4.5. 覆盖关键字的值
当插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。
rust
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
则被覆盖了。
4.6. 当关键字不存在时才插入键值对
我们经常会检查某个特定的键是否已经存在于哈希 map
中并进行如下操作:如果哈希 map
中键已经存在则不做任何操作。如果不存在则连同值一块插入。
rust
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}
。第一个 entry
调用会插入黄队的键和值 50
,因为黄队并没有一个值。第二个 entry
调用不会改变哈希 map
因为蓝队已经有了值 10
。
4.7. 根据旧值更新一个值
另一个常见的哈希 map
的应用场景是找到一个键对应的值并根据旧的值更新它。计数一些文本中每一个单词分别出现了多少次:
rust
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}
。你可能会看到相同的键值对以不同的顺序打印:回忆一下"获取 map
中的值"部分中提到遍历哈希 map
会以任意顺序进行。
其中 split_whitespace
方法返回一个由空格分隔 text
值子 slice
的迭代器。or_insert
方法返回这个键的值的一个可变引用(&mut V
)。这里我们将这个可变引用储存在 count
变量中,所以为了赋值必须首先使用星号(*
)解引用 count
。这个可变引用在 for
循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。
下一篇《Rust语言基础17》
觉得这篇文章对你有帮助的话,就留下一个赞吧v*
请尊重作者,转载还请注明出处!感谢配合~
作者\]: Imagine Miracle \[版权\]: 本作品采用知识共享[署名-非商业性-相同方式共享 4.0 国际许可协议](http://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。 \[本文链接\]: https://blog.csdn.net/qq_36393978/article/details/146397162