Rust 语言特性:常用集合

本文是篇纯笔记,集合的东西各个语言大同小异,学习时候基本是“一遍过”,遇到有需求不知道该用什么 API 的时候去查 API 文档。
1. Vector
Vector 是 Rust 标准库中的一个集合类型,本质是动态数组:分配的是连续内存空间,长度可动态增长。详情参见 [ 官方文档 ]。本文梳理一下 Vector 的基本操作和使用注意事项。
1.1 创建一个空的 Vector
let v1: Vec<i32> = Vec::new();
1.2 创建一个带初值的 Vector
let v2 = vec![1, 2, 3];
代码中的 vec! 是一个宏,它展开会类似于这样的代码:
let mut temp = Vec::new();
temp.push(1);
temp.push(2);
temp.push(3);
let v = temp;
这里之所以使用宏而不是用一个函数去实现一个 vector 的初始化是因为:这里要求填入的初值数量是不确定的,使用函数不太好实现,改为宏来实现会更优雅一些。这里要特别留意的是:[1, 2, 3] 不是数组字面量,而是 vec! 宏自定义出来的语法形式。
1.3 从尾部压入元素
和很多语言一样,Vector 天生会被拿来当 Stack 使用,因为它本身可以非常便捷地实现:尾部压入(push)和 尾部弹出(pop) 操作。
let mut v3 = vec![1, 2, 3];
v3.push(4); // 现在是 [1, 2, 3, 4]
1.4 从尾部弹出元素
let mut v4 = vec![1, 2, 3];
let three = v4.pop(); // 返回 Some(3)
1.5 销毁一个 Vector
Vector 是一个非 Copy 类型,它会在离开作用域时被自动析构。
1.6 访问 vector 中一个元素
有两种方法可以直接获得 vector 的一个元素:使用索引或者 get 方法:
let v5 = vec![4, 5, 6];
let five = v5[1]; // 返回 5
let six = v5.get(2); // 返回 Some(6)
这里要注意一个细节:使用索引直接返回值,而使用 get 方法返回的则是 Some 或 None。这个区别在别的一些语言里也有,简单说就是:当我们试图通过一个不存在的索引值去访问元素时,是直接抛出异常还是返回空值?索引形式和 get 方法对应的就是这两种应对形式。
1.7 当有不可变引用指向 Vector 中的元素时,禁止对 vector 进行更新
当我们以引用的方式获得一个 vector 中的元素有时,会牵涉到《深入理解 Rust 所有权》中提及的限制规则二:如果一个值已经有了不可变引用,就不能再声明可变引用,除非声明可变引用前所有不可变引用已不再使用,看下面的例子:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // 一个不可变引用(局部引用也是引用)
v.push(6); // 此处报错:cannot borrow `v` as mutable because it is also borrowed as immutable
println!("The first element is: {}", first);
first 是对 vector 的一个不可变引用(虽然只引用了其中一个元素),那第二个可变应用在哪里呢?是 push 方法的隐式参数 &mut self,这个方法要改写 vector 内部结构,必须要获得一个可变引用。
1.8 遍历 vector
遍历 vector 可以使用 for 语句,同时根据遍历要做的事情再确定遍历时是要获得不可变引用还是可变引用。下面是以只读方式遍历 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;
}
1.9 利用枚举存储多种类型
Vector 只能储存同类型的值,不过我们在《Rust 语言特性:枚举》中提到到:枚举成员可以自由定义任何结构,但它们都是同一种枚举类型,利用枚举的这种特点,我们就可以在 Vector 中储存不同类型的值。下面的例子生动地体现了枚举的灵活性:
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
2. HashMap
Rust 的 HashMap 跟其他语言中的实现基本类似,使用场景和方法也是一样的。详情参见 [ 官方文档 ]
2.1 创建一个 HashMap
use std::collections::HashMap;
// 创建一个空的 HaspMap
let mut scores = HashMap::new();
// 插入一个 KV 对
scores.insert(String::from("Blue"), 10);
很遗憾的是:HashMap 不像 Vector 那样有 vec![...] 这种内置的构建宏支持,不过,在 1.56 版本以后,HashMap 提供了一个 from 方法,搭配元祖可以非常简洁地用初始键值对构建一个 HashMap:
use std::collections::HashMap;
let map = HashMap::from([
("apple", 3),
("banana", 5),
("orange", 2),
]);
println!("{:?}", map);
2.2 访问 HashMap 中的元素
访问 HashMap 中的元素使用 get 方法,它返回一个 Option<V>,我们在《Rust 语言特性:枚举》一文中提到过,这里是展示 Option 的一个绝佳应用场景:
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); // 返回一个 Option<&i32>
由于 map 中不一定有给定的 key 对应的 value,所以 get 方法返回一个 option 是最佳选择,后续的代码可以使用模式匹配自行处理有值和无值时的逻辑。
2.3 遍历 HashMap
使用 for 语句同样可以遍历 HashMap,下面是一个示例。这里要解释一下 for 中的 (key, value),这是一个元组。我们知道:在 Rust 中对集合类型(Vec、HashMap 等)用 & 遍历,实际上是调用了 IntoIterator trait,而 HashMap 的 IntoIterator 实现返回的就是引用元组。你能感受到,返回元组使用起来很方便。
for (key, value) in &scores {
println!("{}: {}", key, value);
}
2.4 更新 HashMap
2.4.1 覆盖已有值
当我们向一个已存在的 key 写入 value 时,新值会覆盖掉原有的值:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25); // Blue 对应的值现在是 25 了,10 已被覆盖
2.4.2 只在键没有对应值时插入
这是很常见的一个需求,为此,HashMap 提供了一个 entry 方法,它返回一个 Entry,这是一个枚举,和 Option 很像,它代表一个可能存在与可能不存在的值,这个 Entry 有一个 or_insert 方法,这个方法的逻辑是:在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。
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); // 输出:{"Blue": 10, "Yellow": 50}
2.4.3 根据旧值更新一个值
另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。以下代码会将一句英文切分成若干个单词,每一个单词将作为 map 的 key,单词出现的此数将作为 value。下面代码最需要解释的是第 8 行的 or_insert,前面我们可能只注意它在 key 不存在的时候把参数作为新值插入,但可能忽略了 or_insert 返回的是关于 value 的一个可变应用,所以,第 8 行返回的 count 是存储在 map 里的值的引用,对它解引用加 1 后,就是直接在源值上改写新值,是作用到 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);
上述代码中的 *count += 1 可能多少会令一部分人感到“诧异”,因为这里的 value 是一个整型,而 or_insert 返回的是一个整型的引用,这在很多语言里是很少见的。Rust直接返回一个可修改引用是为了统一 HashMap 的访问模型,也就是无论是任何类型都要让 or_insert 行为保持一致。
3. String / &str
严格来说,在 Rust 语言里“字符串”类型应该指的是 str,因为这是语言的内置类型,可以证明这一点的是:当我们书写一个字符串常量 "abc" 时,它的类型就是 str,只不过它总是以引用形式出现,即:&str,而 String 则属于是标准库中的类型了。通常来说,在 Rust 中我们提到“字符串”的时候,一般是指 String 或 &str。
3.1 新建字符串
使用 new 方法创建一个空字符串:
let mut s = String::new();
直接书写一个字符串字面量然后通过 to_string 方法转换成一个 String:
let s = "initial contents".to_string();
使用 from 方法创建一个有值的字符串:
let s = String::from("initial contents");
3.2 更新字符串
String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。
3.2.1 使用 push_str 和 push 附加字符串
let mut s = String::from("foo");
s.push_str("bar"); // s 变成 "foobar"
注意:push_str 并不要求获得参数的所有权,它的行为其实是深度复制了字符串内容,这样,参数字符串还可以继续独立使用。
3.2.1 使用 + 运算符拼接字符串
除了 Sring 的 API,还可以使用 + 运算符拼接字符串,但要注意 + 两侧的数据类型, + 左侧是 String,它是要移交所有权给结果值的, + 右侧是 &str,只能一个字符串切片:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2;
+ 运算符之所以这样规定是因为它对应的其实是一个 add 函数,函数签名决定了 + 两侧的数据类型:
fn add(self, s: &str) -> String {...} // 非实际代码,是泛型的具体版
虽然 add 方法的第二个参数声明的是 &str 类型,但是 s2 依然是可以被接受的,因为这中间发生了解引用强制转换(deref coercion),就是把 &s2 变成了 &s2[..]。再有就是要注意 add 函数的 self 参数,没有作用 &,调用它之后要发生所有权的转移!
3.2.2 使用 format! 宏拼接字符串
当需要拼接多个字符串时,使用 + 显得不是很优雅,此时可以考虑使用 format! 宏:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
3.3 通过索引访问字符串
在很多语言里,通过索引访问字符串中的一个字符是一常规操作,但是在 Rust 里我们不能这么做:
let s1 = String::from("hello");
let h = s1[0]; // 报错!Rust 的字符串不支持索引
为什么 Rust 的字符串不支持索引呢?因为在 Rust 中:字符串是 UTF-8 编码,而 UTF-8 的“字符”长度是不固定的(变长编码)。因为 UTF-8 的设计目标之一是:既兼容 ASCII,又尽量节省空间,同时还能表示整个 Unicode,这个决定了UTF-8是变长编码。因此,当我们使用一个索引时,如果是按字节(或双字节)定位,很有可能落在一个字符的中间字节上,这就完全错了,但是 Rust 基于它的某种哲学(不好评价),认为不应该自动检查索引是否落在 UTF-8 字符边界上,所以它就禁止了字符串的索引操作。但是:使用 slice 却是可以的:
let s = "Здравствуйте";
let part = &s[0..4];
slice 会检查 UTF-8 字符边界。好吧,感觉真没必要。
更多推荐



所有评论(0)