一、用向量存储值列表
我们将看到的第一个集合类型是Vec<T>
,也称为向量。向量允许您在单个数据结构中存储多个值,该数据结构将所有值并排放置在内存中。向量只能存储相同类型的值。
创建一个新的向量
要创建一个新的空向量,我们可以调用该Vec::new
函数let v: Vec<i32> = Vec::new();
我们在此处添加了类型注释。因为我们没有在这个向量中插入任何值,Rust 不知道我们打算存储什么样的元素。这是很重要的一点。向量是使用泛型实现的。
在更现实的代码中,Rust 通常可以在插入值后推断出您想要存储的值的类型,因此您很少需要进行这种类型注释。创建Vec<T>
具有初始值的a 更为常见,vec!
为了方便起见,Rust 提供了宏。宏将创建一个新的向量来保存你给它的值。let v = vec![1, 2, 3];
因为我们提供了 i32 类型的初始值,Rust 可以推断出 v 的类型是 Vec<i>
,因此类型注解就不是必须的。
更新向量
要创建一个向量然后向其中添加元素,我们可以使用该push方法。let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
与任何变量一样,如果我们希望能够改变它的值,我们需要使用mut
关键字使其可变。
删除一个向量会删除它的元素
与任何其他一样struct,当超出范围时,vector 会被释放{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
当向量被删除时,它的所有内容也被删除,这意味着它持有的那些整数将被清除。这似乎是一个简单的观点,但当您开始引入对向量元素的引用时,可能会变得更加复杂。
读取向量元素
既然知道如何创建、更新和销毁向量,那么了解如何读取它们的内容是一个很好的下一步。有两种方法可以引用存储在向量中的值。let v = vec![1, 2, 3, 4, 5];
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."),
}
请注意这里的两个细节。首先,我们使用的索引值2来获取第三个元素:向量按数字索引,从零开始。其次,获取第三个元素的两种方法是使用&
和 []
,它为我们提供了一个引用,或者使用get将索引作为参数传递的方法,这给了我们一个Option<&T>
;.
Rust 有两种引用元素的方法,因此当您尝试使用向量没有元素的索引值时,您可以选择程序的行为方式。let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
当我们运行这段代码时,第一个[]
方法会导致程序崩溃,因为它引用了一个不存在的元素。当您希望程序在尝试访问超过向量末尾的元素时崩溃时,最好使用此方法。
当该get方法被传递一个向量外部的索引时,它会返回 None 而不会出现恐慌。如果在正常情况下偶尔会访问超出向量范围的元素,您将使用此方法。然后,您的代码将具有处理Some(&element)
或 逻辑None。
当程序有一个有效的引用时,借用检查器会强制执行所有权和借用规则以确保这个引用和对向量内容的任何其他引用保持有效。即在同一范围内不能有可变引用和不可变引用。其中我们持有对向量中第一个元素的不可变引用,并尝试在末尾添加一个元素,如果我们还尝试在函数稍后引用该元素,这将不起作用:let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {}", first);
编译此代码将导致此错误: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` due to previous error
此错误是由向量的工作方式造成的:如果没有足够的空间将所有元素放在每个元素旁边,则在向量末尾添加新元素可能需要分配新内存并将旧元素复制到新空间向量当前所在的其他位置。在这种情况下,对第一个元素的引用将指向已释放的内存。借用规则可防止程序在这种情况下结束。
迭代向量中的值
如果我们想依次访问向量中的每个元素,我们可以遍历所有元素,而不是使用索引一次访问一个。fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}
}
我们还可以迭代可变向量中每个元素的可变引用,以便对所有元素进行更改。let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
要更改可变引用所引用的值,我们必须先使用解引用运算符*
获取其中的值,i然后才能使用该+=运算符。
使用枚举存储多种类型
我们说过向量只能存储相同类型的值。这可能很不方便;肯定有需要存储不同类型项目列表的用例。幸运的是,枚举的变体定义在相同的枚举类型下,所以当我们需要在向量中存储不同类型的元素时,我们可以定义和使用枚举!
我们可以定义一个枚举,它的变体将保存不同的值类型,然后所有的枚举变体将被视为相同的类型:枚举的类型。然后我们可以创建一个包含该枚举的向量,因此最终包含不同的类型。fn main() {
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
}
Rust 需要知道在编译时向量中的类型是什么,以便它确切知道堆上需要多少内存来存储每个元素。第二个优点是我们可以明确说明该向量中允许的类型。如果 Rust 允许向量包含任何类型,那么一种或多种类型可能会导致对向量元素执行的操作出错。
当编写程序时,如果您不知道程序在运行时获取的用于存储在向量中的详尽类型集,则枚举技术将不起作用。相反,您可以使用 trait 对象。
二、用字符串存储 UTF-8 编码的文本
之前讨论了字符串,但现在我们将更深入地研究它们。字符串是通常会被困住的领域,这是由于三方面内容的结合:
- Rust 倾向于确保暴露出可能的错误
- 字符串是比很多程序员所想象的要更为复杂的数据结构
- UTF-8。
什么是字符串?
我们将首先定义术语string的含义。Rust 在核心语言中只有一种字符串类型str
,即通常以借用形式出现的字符串切片&str
。我们讨论了字符串切片,它是对存储在其他地方的一些 UTF-8 编码字符串数据的引用。String 类型
由 Rust 的标准库提供而不是编码到核心语言中,是一种可增长、可变、拥有的 UTF-8 编码字符串类型。在 Rust 中提到“字符串”时,他们通常指的是 String
和 字符串切片&str
类型,而不仅仅是这些类型中的一种。
Rust 标准库中还包含一系列其他字符串类型,比如 OsString
、 OsStr
、 CString
和 CStr
。它们通常也提供有所有权和可借用的变体,就比如说。这些字符串类型在储存的编码或内存表现形式上可能有所不同。
创建新字符串
很多 Vec 可用的操作在 String 中同样可用,从以 new 函数创建字符串开始let mut s = String::new();
这新建了一个叫做 s 的空的字符串,接着我们可以向其中装载数据。
通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string
方法,它能用于任何实现了 Display trait
的类型,字符串字面值就可以。fn main() {
let data = "initial contents";
let s = data.to_string();
// the method also works on a literal directly:
let s = "initial contents".to_string();
}
也可以使用 String::from 函数来从字符串字面值创建 String 。let s = String::from("initial contents");
因为字符串应用广泛,这里有很多不同的用于字符串的通用 API 可供选择。它们有些可能显得有些多余,不过都有其用武之地!在这个例子中, String::from
和 .to_string
最终做了完全相同的工作,所以如何选择就是风格问题了。
记住字符串是 UTF-8 编码的,所以可以包含任何可以正确编码的数据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");
所有这些都是有效值String。
更新字符串
String 大小可以增长,其内容也可以更改,就可以改变Vec<T>
的内容一样,如果向其中推送更多数据。此外,可以方便地使用+运算符
或format!宏
来连接String值。
使用push_str和附加到字符串push
我们可以String通过使用push_str
方法来增加一个字符串切片,fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
执行这两行代码之后 s 将会包含 foobar 。该push_str方法需要一个字符串切片,因为我们不一定要获得参数的所有权。fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);
}
如果 push_str 方法获取了 s2 的所有权,就不能在最后一行打印出其值了。好在代码如我们期望那样工作!
push
方法被定义为获取一个单独的字符作为参数,并附加到使用 push
方法将字母 l 加入 String 的代码。let mut s = String::from("lo");
s.push('l');
由于此代码,s将包含lol.
使用 + 运算符或 format! 宏连接字符串
通常我们希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1已移到此处,不能再使用
执行完这些代码之后字符串 s3 将会包含 Hello, world!
。 s1 在相加后不再有效的原因,和使用 s2 的引用的原因与使用 + 运算符时调用的方法签名有关,这个函数签名看起来像这样:fn add(self, s: &str) -> String {
这并不是标准库中实际的签名;标准库中的 add 使用泛型定义。这里我们看到的 add 的签名使用具体类型代替了泛型,这也正是当使用 String 值调用这个方法会发生的。
s2 使用了 &
,意味着我们使用第二个字符串的 引用
与第一个字符串相加。这是因为 add 函数的 s 参数:只能将 &str
和 String 相加,不能将两个 String 值相加。不过等一下——正如 add 的第二个参数所指定的, &s2
的类型是 &String
而不是 &str
。
之所以能够在 add 调用中使用 &s2 是因为 &String
可以被强转(coerced)成&str
——当 add 函数被调用时,Rust 使用了一个被称为解引用强制多态(deref coercion)的技术,可以将其理解为它把 &s2
变成了 &s2[..]
。
其次,可以发现签名中 add 获取了 self 的所有权,因为 self 没有使用 &
。这意味着上面例子中的 s1 的所有权将被移动到 add 调用中,之后就不再有效。所以虽然 let s3 = s1 + &s2;
看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。
如果想要级联多个字符串, + 的行为就显得笨重了:let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
这时 s 的内容会是“tic-tac-toe”。在有这么多 + 和 “ 字符的情况下,很难理解具体发生了什么。对于更为复杂的字符串链接,可以使用 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内容。使用的代码版本format!
更易于阅读,并且format!
宏生成的代码使用引用,因此该调用不会获得其任何参数的所有权。
索引字符串
在许多其他编程语言中,通过索引引用字符串中的单个字符来访问它们是一种有效且常见的操作。然而在 Rust 中,如果尝试使用索引语法访问 String 的一部分,会出现一个错误。let s1 = String::from("hello");
let h = s1[0];
此代码将导致以下错误:cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
内部代表
String 是一个 Vec<u8>
的封装。让我们看看之前一些正确编码的字符串的例子。let hello = String::from("Hola");
在这里, len
的值是 4,这意味着储存字符串 “Hola” 的 Vec 的长度是四个字节:这里每一个字母的 UTF-8 编码都占用一个字节。let len = String::from("Здравствуйте").len();
当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。
考虑如下无效的代码let hello = "Здравствуйте";
let answer = &hello[0];
answer 的值应该是什么呢?它应该是第一个字符 З 吗?当使用 UTF-8 编码时, З 的第一个字节 208 ,第二个是 151 ,所以 answer 实际上应该是 208 ,不过 208 自身并不是一个有效的字母。返回 208 可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引 0 位置所能提供的唯一数据。
返回字节值可能不是人们希望看到的,即便是只有拉丁字母时: &”hello”[0] 会返回 104 而不是 h 。为了避免返回意想不到值并造成不能立刻发现的bug。Rust 选择不编译这些代码并及早杜绝了误会的发生。
字节、标量值和字形簇!天呐!
关于 UTF-8 的另一点是,从 Rust 的角度来看,实际上有三种相关的方式来看待字符串:字节、标量值和字素簇(最接近我们所说的字母)。
比如这个用梵文书写的印度语单词 “नमस्ते”,最终它储存在 Vec 中的 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)
对字符串进行索引通常是一个坏主意,因为不清楚字符串索引操作的返回类型应该是什么:字节值、字符、字素簇或字符串切片。因此,如果您真的需要使用索引来创建字符串切片,Rust 会要求您更加具体。为了更具体地建立索引并指示您想要一个字符串切片,相比使用 []
和单个值的索引,可以使用 []
和一个 range
来创建含特定字节的字符串 slice:let hello = "Здравствуйте";
let s = &hello[0..4];
这里,s
会是一个 &str
,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s 将会是 “Зд”。
如果获取 &hello[0..1]
会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样:cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
遍历字符串的方法
幸运的是,这里还有其他获取字符串元素的方式。
如果我们需要操作单独的 Unicode 标量值,最好的选择是使用 chars
方法。对 “नमस्ते” 调用 chars
方法会将其分开并返回六个 char
类型的值,接着就可以遍历其结果来访问每一个元素了:for c in "नमस्ते".chars() {
println!("{}", c);
}
此代码将打印以下内容:न
म
स
्
त
े
bytes 方法返回每一个原始字节,这可能会适合你的使用场景:for b in "नमस्ते".bytes() {
println!("{}", b);
}
此代码将打印构成此的 18 个字节String:224
164
// --snip--
165
135
但一定要记住,有效的 Unicode 标量值可能由 1 个以上的字节组成。
从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。
三、在哈希映射中存储具有关联值的键
最后介绍的常用集合类型是哈希 map(hash map)。 HashMap<K, V>
类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。
新建一个哈希 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
自动引用。标准库中对 HashMap
的支持也相对较少,例如,并没有内建的构建宏。
像 vector
一样,哈希 map 将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32 。同样类似于 vector,哈希 map 是同质的: 所有的键必须是相同类型,值也必须都是相同类型。
另一个构建哈希 map 的方法是使用一个元组的 vector 的 collect
方法,其中每个元组包含一个键值对。 collect 方法可以将数据收集进一系列的集合类型,包括 HashMap 。使用该zip
方法创建一个元组迭代器
,使用该collect
方法将元组迭代器转换为哈希映射,如下所示use std::collections::HashMap;
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let mut scores: HashMap<_, _> =
teams.into_iter().zip(initial_scores.into_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,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 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);
因为 get 返回 Option<V>
,所以结果被装进 Some
。如果某个键在哈希 map 中没有对应的值, get 会返回 None 。这时就要用到之前提到的方法之一来处理 Option 。
可以使用与 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
方法在键对应的值存在时就返回这个值的 Entry
,如果不存在则将参数作为新值插入并返回修改过的 Entry
。
根据旧值更新值
另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。我们使用以单词为键的哈希映射并增加值以跟踪我们看到该单词的次数。如果这是我们第一次看到一个词,我们将首先插入值 0。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 。这个可变引用在 for
循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。
散列函数
HashMap 默认使用一种密码学安全的哈希函数,它可以抵抗拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher
来切换为其它函数。hasher
是一个实现了 BuildHasher trait
的类型。crates.io
有其他人分享的实现了许多常用哈希算法的 hasher
的库。