常用的集合 Collections

除了基本数据类型或固定大小的数组或元组存储特定的值,Rust 标准库还提供不少非常有用的集合数据结构来存放动态数据

1)动态数组 Vector

Vec 存放多个相同类型的值:

#![allow(unused)]
fn main() {
// `Vec` is a regular struct,为了后续可以修改 vector, 上面加上了可变声明 mut
// 换种写法亦可:let v = vec![]; , 不要被宏 !写法吓到
// 如可以预估数组长度,可以在初始化时指定 Vec::with_capacity(5);
let mut v: Vec<i32> = Vec::new();	
v.push(0);			// 更新 vector
v.extend([1, 2]);		// 拼接 vector

for i in &mut v { *i += 1; } 	// 可以循环遍历, *i 解引用获取值

let len = v.len();		// 获取 vector 长度
let last: i32 = v.pop()		// 这里获取了可变引用,如果不理解,学习了所有权章节后再回顾
		.unwrap();		// 并对 pop 方法返回 Option 枚举直接 unwrap
let one: i32 = v[0];		// 读取 vector 中的元素, 等同于 v.get(0),不过要注意返回 Result
println!("Length of this vector is {len}. First is:{one}. Last is {last}");
}

巧妙利用枚举来存放不同数据类型的值:

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum TableCell {	// 定义一个枚举类型表示电子表格的单元格数据
	Int(i32),		// 可能是数字
	Float(f64),		// 可能是浮点值
	Text(String),	// 可能是字符串
}

let row = vec![TableCell::Int(3), TableCell::Float(3.14), TableCell::Text("Rust".to_string())];

for (index, cell) in row.iter().enumerate() { 
	let pos = index + 1;
	println!("Cell {pos} is {:?} .", cell); 
}
}

初始化 vec 的更多方式:

fn main() {
    let v = vec![0; 3];   // 默认值为 0,初始长度为 3
    let v_from = Vec::from([0, 0, 0]);
    assert_eq!(v, v_from);
}

动态数据都在内存空间存放在堆(Heap)上,在内存中彼此相邻排列。

动态数组意味着我们增加元素时,如果容量不足就会导致 vector 扩容(目前的策略是重新申请一块 2 倍大小的内存,再将所有元素拷贝到新的内存位置,同时更新指针数据),显然,当频繁扩容或者当元素数量较多且需要扩容时,大量的内存拷贝会降低程序的性能。

可以考虑在初始化时就指定一个实际的预估容量,尽量减少可能的内存拷贝:

fn main() {
    let mut v = Vec::with_capacity(10);
    v.extend([1, 2, 3]);    // 附加数据到 v
    println!("Vector 长度是: {}, 容量是: {}", v.len(), v.capacity());

    v.reserve(100);        // 调整 v 的容量,至少要有 100 的容量
    println!("Vector(reserve) 长度是: {}, 容量是: {}", v.len(), v.capacity());

    v.shrink_to_fit();     // 释放剩余的容量,一般情况下,不会主动去释放容量
    println!("Vector(shrink_to_fit) 长度是: {}, 容量是: {}", v.len(), v.capacity());
}

Vector 常见的一些方法示例:

#![allow(unused)]
fn main() {
let mut v =  vec![1, 2];
assert!(!v.is_empty());         // 检查 v 是否为空

v.insert(2, 3);                 // 在指定索引插入数据,索引值不能大于 v 的长度, v: [1, 2, 3] 
assert_eq!(v.remove(1), 2);     // 移除指定位置的元素并返回, v: [1, 3]
assert_eq!(v.pop(), Some(3));   // 删除并返回 v 尾部的元素,v: [1]
assert_eq!(v.pop(), Some(1));   // v: []
assert_eq!(v.pop(), None);      // 记得 pop 方法返回的是 Option 枚举值
v.clear();                      // 清空 v, v: []

let mut v1 = [11, 22].to_vec(); // append 操作会导致 v1 清空数据,增加可变声明
v.append(&mut v1);              // 将 v1 中的所有元素附加到 v 中, v1: []
v.truncate(1);                  // 截断到指定长度,多余的元素被删除, v: [11]
v.retain(|x| *x > 10);          // 保留满足条件的元素,即删除不满足条件的元素

let mut v = vec![11, 22, 33, 44, 55];
// 删除指定范围的元素,同时获取被删除元素的迭代器, v: [11, 55], m: [22, 33, 44]
let mut m: Vec<_> = v.drain(1..=3).collect();    

let v2 = m.split_off(1);        // 指定索引处切分成两个 vec, m: [22], v2: [33, 44]
}

当然也可以像数组切片的方式获取 vec 的部分元素:

fn main() {
    let v = vec![11, 22, 33, 44, 55];
    let slice = &v[1..=3];
    assert_eq!(slice, &[22, 33, 44]);
}

更多技术细节,阅读 Vector 的标准库文档

数组排序是重要且常用的知识,了解一下:

#![allow(unused)]
fn main() {
let mut vec = vec![1, 5, 10, 2, 15];    
vec.sort();    
print!("{:?}", vec);

// 如果 vector 内的数据类型无法直接比较,也可使用 sort_by 和闭包(后面会学到)来实现
// 比如我们计划把存放个人数据的 vector 按照年龄倒序排列:
// peoples.sort_by(|a, b| b.age.cmp(&a.age))	// 示例代码,无法运行, age:u32
}

非稳定排序 sort_unstable 和 sort_unstable_by 的说明,所谓的非稳定并不是指排序算法本身不稳定,而是指在排序过程中对相等元素的处理方式。在稳定 排序算法里,对相等的元素,不会对其进行重新排序。而在非稳定的算法里则不保证这点。

总体而言,非稳定排序的算法的速度会优于稳定排序算法,同时,稳定排序还会额外分配原数组一半的空间。

延伸阅读

2)字符串 str

字符串在 Rust 中反而是一个难点,要多花点心思理解透彻,人类语言本身亦很复杂。

在语言级别,只有字符串切片类型 str (切片类型本质上是一个胖指针),&str 表示为 str 类型的引用。

Rust 标准库提供的字符串类型:String、OsString、OsStr、CsString、CsStr 等。

字符串字面量(比如 "THE RUST LANGUAGE.",双引号包围),被编译器做了特殊处理,硬编码写入程序二进制文件中,并不是直接存到内存的堆(Heap)或者栈(Stack),程序加载时,放在单独的地方(内存静态数据区的全局字面量区)。

str 类型无法被修改,String 则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串。

fn print_type_name<T>(_val: &T) {
    println!("{}", std::any::type_name::<T>());
}

fn main() {
	let lang = "THE RUST LANGUAGE."; // lang: &str,存放在栈上
	println!("{lang}");

	// "Let's Rusty!" 在程序加载期间被放入字面量内存区 Literals
	// String::from 从字面量内存区将其拷贝到内存堆(Heap)上, 
	// 然后将堆内存中该数据的地址保存到栈(Stack)内变量 hello 中
	let hello = String::from("Let's Rusty!"); // 等同 .to_string() 即 &str => String
	// &hello: String => &[String], &hello.as_str(): String => &str
	print_type_name(&hello);  // alloc::string::String
}

字符串 String 的底层的数据存储格式实际上是[u8]:一个字节数组,而不同语言的字符长度不一,通过索引的方式获取某个字符是不可预期的。

操作字符串:

#![allow(unused)]
fn main() {
let mut s = String::from("Hello ");

s.push_str("rust");			// 附加 &str	, 亦可以: s + "rust!";
s.push('!');	
s.insert(5, ',');				
s.insert_str(6, " i like");		// 插入到指定索引 &str
let mut s1 = s.replace("rust", "RUST");	// 替换 
s1.replace_range(7..8, "r");		// 替换指定范围,直接操作原字符串

println!("{s1}");

// 其他方法:remove、truncate、clear

// 遍历字符操作: 你好 👋
for c in "السلام عليكم".chars() { print!("{c} "); } // ا ل س ل ا م   ع ل ي ك م
for b in "Здравствуйте".bytes() { print!("{b} "); }
// 181 151 208 180 209 128 208 176 208 178 209 129 209 130 208 178 209 131 208 185 209 130 208 

}

关于字符串转义、Unicode 字符、UTF-8 的知识:

  • https://crates.io/crates/utf8_slice
  • https://doc.rust-lang.org/std/string/struct.String.html#utf-8

3)哈希 HashMap

实际业务中有很多键值(key-value)对应的数据,比如游戏排行榜:每个玩家和对应的分数,我们可以用 HashMap<k, v> 来存储, 它通过一个 哈希函数(hashing function) 来实现映射,决定如何将键和值放入内存中。

哈希 map 将它们的数据储存在堆上,所有的键必须是相同类型,值也必须都是相同类型。

#![allow(unused)]
fn main() {
// 不常用,没有被 prelude 自动引入:https://doc.rust-lang.org/std/prelude/index.html
use std::collections::HashMap;	

let mut scores = HashMap::new();
let a = "Player A";	// 键类型为字符串切片引用:&str 
let b = "Player B";	
scores.insert(a, 10); 
scores.insert(b, 50);

scores.insert(a, 20);	// 如果键已经存在,会覆盖
scores.entry(b).or_insert(60);	// 只在键没有对应一个值时插入, 所以这里不会更新数据

// 任意顺序打印出每一个键值对
for (key, value) in &scores { println!("{key}: {value}"); }
}

常见的代码示例:统计单词频次

#![allow(unused)]
fn main() {
let mut map = std::collections::HashMap::new();
for word in "hello rust hello world wonderful world, rust".split_whitespace() {
	// or_insert 方法返回这个键的值的一个可变引用(&mut V), 存放在 count 变量
	let count = map.entry(word).or_insert(0); 
	*count += 1;	// * 解引用,重新赋值,更新单词的统计数
}
println!("{:?}", map);
}

程序需要储存、访问和修改数据时,来回顾学习,更多集合数据类型,可以在后续需要时学习。