Rust 常见集合
本文只是学习过程中的副产品,在原文的基础上有删减,原文参考常见集合。
Rust 标准库中包含一系列被称为 集合(collections)的非常有用的数据结构,这里介绍三个在 Rust 程序中被广泛使用的集合:
- vector :允许一个挨着一个地储存一系列数量可变的值
- 字符串(string):字符的集合,之前的 String 类型。
- 哈希 map(hash map):允许将值与一个特定的键(key)相关联。
使用 Vector 储存列表
类型 Vec<T> 也被称为 vector,和C++中的vector类似,可以参考Vec 文档。
新建 vector
Vec::new 函数(无初值)
创建一个新的空 vector,可以调用 Vec::new 函数:
// i32 :类型注解
let v: Vec<i32> = Vec::new();
vec! 宏(有初值)
为了方便 Rust 提供了 vec! 宏,这个宏会根据提供的值来创建一个新的 vector:
//提供了 i32 类型的初始值,不需要注解
let v = vec![1, 2, 3];
更新 vector
使用 push 方法向 vector 增加值
//必须使用 mut 关键字使其可变
let mut v = Vec::new();
//整数字面值默认是 i32 类型的
v.push(5);
v.push(6);
v.push(7);
v.push(8);
读取 vector 的元素
有两种方法引用 vector 中储存的值:通过索引或使用 get 方法:
let v = vec![1, 2, 3, 4, 5];
//通过索引:使用 & 和 [] 会得到一个索引位置元素的引用
let third: &i32 = &v[2];
println!("The third element is {third}");
//使用 get 方法:调用 get 方法得到一个可以用于 match 的 Option<&T>
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
Rust 提供了两种引用元素的方法的原因是当尝试使用现有元素范围之外的索引值时可以选择让程序如何运行:
let v = vec![1, 2, 3, 4, 5];
//当引用一个不存在的元素时 Rust 会造成 panic
let does_not_exist = &v[100];
//当 get 方法被传递了一个数组外的索引时返回 None
let does_not_exist = v.get(100);
注意可变和不可变引用
一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保 vector 内容的这个引用和任何其他引用保持有效。
不能在相同作用域中同时存在可变和不可变引用,以下代码无法通过编译:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
原因是由于 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 中的值
*i += 50;
}
如果尝试在 for 循环体内插入或删除项,for 循环中获取的 vector 引用阻止了同时对 vector 整体的修改。
使用枚举来储存多种类型
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),
];
如果在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,则可以使用 trait 对象。
丢弃 vector 时也会丢弃其所有元素
类似于任何其他的 struct,vector 在其离开作用域时会被释放:
{
let v = vec![1, 2, 3, 4];
// do stuff with v
}// <- v goes out of scope and is freed here
借用检查器确保了任何 vector 中内容的引用仅在 vector 本身有效时才可用。
使用字符串储存 UTF-8 编码的文本
什么是字符串?
Rust 的核心语言中只有一种字符串类型:字符串 slice str,它通常以被借用的形式出现 &str。由于字符串字面值被储存在程序的二进制输出中,因此字符串字面值也是字符串 slices。
字符串(String)类型由 Rust 标准库提供,而不是编入核心语言,它是一种可增长、可变、可拥有、UTF-8 编码的字符串类型。
这两种类型在 Rust 的标准库中都有大量使用,而且 String 和 字符串 slices 都是 UTF-8 编码的。
新建字符串
新建一个空的 String:
let mut s = String::new();
使用 to_string 方法从字符串字面值创建 String:
let data = "initial contents";
let s = data.to_string();
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
使用 String::from 函数从字符串字面值创建 String:
let s = String::from("initial contents");
注: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 的大小可以增加,其内容也可以改变,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。
使用 push_str 和 push 附加字符串
使用 push_str 方法向 String 附加字符串 slice:
let mut s = String::from("foo");
s.push_str("bar");
//s 将会包含 foobar
将字符串 slice 的内容附加到 String 后使用它:
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
使用 push 将一个字符加入 String 值中:
let mut s = String::from("lo");
s.push('l');
//,s 将会包含 “lol”
使用 + 运算符或 format! 宏拼接字符串
使用 + 运算符将两个 String 值合并到一个新的 String 值中:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
//字符串 s3 将会包含 Hello, world!
+ 运算符使用了 add 函数,函数签名如下:
//标准库中 add 的定义使用了泛型和关联类型,这是当使用 String 值调用这个方法会发生的
fn add(self, s: &str) -> String {
- add 调用中使用 &s2 是因为 &String 可以被 强转(coerced)成 &str( Deref 强制转换(deref coercion)技术)。
- 签名中 add 获取了 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;
对于更为复杂的字符串链接,可以使用 format! 宏:
//s 的内容会是 “tic-tac-toe”
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
//级联多个字符串,+ 太笨重
//let s = s1 + "-" + &s2 + "-" + &s3;
let s = format!("{s1}-{s2}-{s3}");
format! 与 println! 的工作原理相同,不过它是返回一个带有结果内容的 String,宏 format! 生成的代码使用引用所以不会获取任何参数的所有权。
索引字符串
在 Rust 中,如果尝试使用索引语法访问 String 的一部分会出现一个错误:
let s1 = String::from("hello");
let h = s1[0];
Rust 的字符串不支持索引,原因可以从 Rust 是如何在内存中储存字符串的说起。
内部表现
String 是一个 Vec<u8> 的封装。
先看一个简单的示例:
let hello = String::from("Hola");
let len=hello.len(); //len = 4
len 的值是 4 意味着储存字符串 “Hola” 的 Vec 的长度是四个字节:这里每一个字母的 UTF-8 编码都占用一个字节。
再看一个复杂的示例:
//首字母是西里尔字母的 Ze 而不是数字 3
let hello = String::from("Здравствуйте");
let len=hello.len(); //len = 24
len 的值是 24,基本的西里尔字母每个字符的 UTF-8 编码通常占用2个字节。
一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值,以下 Rust 代码无效:
let hello = "Здравствуйте";
//answer 实际上应该是 20
//Rust 在字节索引 0 位置所能提供的唯一数据
let answer = &hello[0];
当使用 UTF-8 编码时,(西里尔字母的 Ze)З 的第一个字节是 208,第二个是 151,所以 answer 实际上应该是 208。
为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
字节、标量值和字形簇
从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。
对于用梵文书写的印度语单词 “नमस्ते”:
- 储存在 vector 中的 u8 值看起来像这样,有 18 个字节:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
- 从 Unicode 标量值的角度理解它们,也就像 Rust 的 char 类型那样,有六个 char:
//第四个和第六个都不是字母,它们是发音符号本身并没有任何意义
['न', 'म', 'स', '्', 'त', 'े']
- 以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:
["न", "म", "स्", "ते"]
Rust 不允许使用索引获取 String 字符的原因是:Rust 必须从开头到索引位置遍历来确定有多少有效的字符。
字符串 slice
字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。
为了更明确索引并表明需要一个字符串 slice,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:
let hello = "Здравствуйте";
//s 会是一个 &str,包含字符串的头四个字节
//这些字母都是两个字节长的,结果是 “Зд”
let s = &hello[0..4];
如果获取 &hello[0..1] ,Rust 在运行时会 panic,小心谨慎地使用这个操作。
遍历字符串的方法
操作字符串每一部分的最好的方法是明确表示需要字符还是字节,对于单独的 Unicode 标量值使用 chars 方法。
对 “Зд” 调用 chars 方法会将其分开并返回两个 char 类型的值:
for c in "Зд".chars() {
println!("{c}");
}
打印出如下内容:
З
д
调用 bytes 方法返回每一个原始字节:
for b in "Зд".bytes() {
println!("{b}");
}
打印出组成 String 的 4 个字节:
208
151
208
180
不过有效的 Unicode 标量值可能会由不止一个字节组成。
字符串并不简单
Rust 选择了以准确的方式处理 String 数据作为所有 Rust 程序的默认行为,程序员们必须更多的思考如何预先处理 UTF-8 数据。
标准库提供了很多围绕 String 和 &str 构建的功能来处理复杂场景,比如:
- contains 来搜索一个字符串
- replace 将字符串的一部分替换为另一个字符串。
String 类型是由标准库提供的,没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。一般谈到 Rust 的 “字符串”时通常指的是 String 或字符串 slice &str 类型,而不特指其中某一个,String 和字符串 slices 都是 UTF-8 编码的。
使用 Hash Map 储存键值对
HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射,它通过一个 哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。
新建一个哈希 map
可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素:
use std::collections::HashMap;
let mut scores = HashMap::new();
//键类型是 String ,值类型是 i32
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
注:必须首先 use 标准库中集合部分的 HashMap,HashMap 并没有被 prelude 自动引用。
像 vector 一样,哈希 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");
//get 方法返回 Option<&V>,如果某个键在哈希 map 中没有对应的值,get 会返回 None
let score = scores.get(&team_name)
.copied() //调用 copied 方法来获取一个 Option<i32>
.unwrap_or(0); //调用 unwrap_or 在 scores 中没有该键所对应的项时将其设置为零
使用与 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 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 不再有效,
// 尝试使用它们看看会出现什么编译错误!
如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map,但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。
更新哈希 map
覆盖一个值
插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);
//会打印出 {"Blue": 25}
println!("{:?}", scores);
只在键没有对应值时插入键值对
哈希 map 有一个特有的 API 叫做 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);
//会打印出 {"Yellow": 50, "Blue": 10}
println!("{:?}", scores);
Entry 的 or_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。
根据旧值更新一个值
找到一个键对应的值并根据旧的值更新它,如通过哈希 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;
}
//会打印出 {"world": 2, "hello": 1, "wonderful": 1}
println!("{:?}", map);
- split_whitespace 方法返回一个由空格分隔 text 值子 slice 的迭代器。
- or_insert 方法返回这个键的值的一个可变引用(&mut V)。
- 可变引用储存在 count 变量中,为了赋值必须首先使用星号(*)解引用 count。
- 可变引用在 for 循环的结尾离开作用域,所有这些改变都是安全的并符合借用规则。
哈希函数
HashMap 默认使用一种叫做 SipHash 的哈希函数,可以指定一个不同的 hasher 来切换为其它函数,hasher 是一个实现了 BuildHasher trait 的类型。