常用集合类型
Rust 标准库中包含许多集合数据类型方便使用. 这些集合都是在堆上分配内存的, 这些集合类型如下:
vector
: 用于存储可变数量的值string
: 表示字符的集合hash map
: 通过哈希实现键定位的字典
Vector 的用法
vector 是顺序表的实现, 值在内存中的地址是连续的.
使用如下语法创建 vector:
let v: Vec<i32> = Vec::new();
// 或使用 Rust 的宏创建:
let v = vec![1, 2, 3];
更新 Vector:
let mut v = vec![1, 2, 3];
v.push(5);
v.push(6);
v.push(7);
读取 Vector 指定下标的值有如下两种方式:
let v = vec![1, 2, 3];
let third = &v[1]; // &i32
let third_get = v.get(1); // Option<&i32>
根据当前情况自由选择使用 get 或直接下标访问, 使用下标的好处是清晰易懂, 但越界会有 panic, 使用 get 的话更安全, 越界的话会返回 None
.
使用 Vector 时候的 ownership 和 borrow 规则和普通类型一致, 比如下面的代码会出现编译错误:
let mut v = vec![1, 2, 3];
let third = &v[2]; // 元素的不可变引用
v.push(4); // Vec 的可变引用 `&mut self`, 同时存在会编译出错
println!("third {}", third);
原因是: vector 是顺序表的实现, 在堆内存中分配元素空间, 当 vector 是可变时, 添加元素或删除元素可能导致容量变化的数组复制扩容/压缩操作, 此时如果允许 v 的元素不可变引用和 v 的可变引用(调用 push
时传入的 &mut self
), 则在扩容时 third
引用的内存实际是被释放了的, 造成访问已被释放内存的问题. 因此在这里不能有元素的不可变引用和 Vector 的可变引用同时存在.
Vec
类型的实现详见官方文档
遍历 Vector 的方法如下:
for i in &v {
println!("{}", i);
}
可以在遍历过程中同时修改 Vec 每个元素(并非修改 Vec 本身, 只是对每个元素修改):
for i in &mut v {
*i += 50;
}
由于没有在遍历过程中对 vec 进行增删等修改, 因此元素修改本身是内存安全的. 在 for 循环遍历的过程中, for 循环可以保证不会同时修改整个 vector(修改的话就会编译出错).
可以在 Vector 中放入任意类型, 比如 enum 的例子如下:
enum MyEnum {
Int(i32),
Text(String),
Float(f64),
}
let v = vec![
MyEnum::Int(3),
MyEnum::Text(String::from("hello")),
MyEnum::Float(3.33),
];
Rust 需要在编译时获知将要放入到 Vector 中的数据类型, 这样才可以确定在每个元素在堆中分配的大小, 并且需要是相同类型的元素.
比如放入 String 的话实际是 String 的指针, 指针指向实际放 String 的堆内存区域.
Vec 的重要性质: 当 Vec 被 Drop 时会 Drop 它的所有元素(意味着所有元素的 Owner 是这个 Vec, 类似 struct 的情况, 所有成员的 Owner 都是那个 struct 对象). Borrow checker 可以保证 vector 的任意元素只有在 vec 存在时才可用.
String: UTF-8 编码的字符集合
Rust 中 String 实际实现为字节集合, 在其上提供若干对字节处理为字符的方法.
那什么是 String 呢? 在 Rust Core 中只有一种 String 类型, 即字符串切片 str
和 切片引用 &str
. 而 String
类型是由 Rust 标准库提供的, 而非由 Rust 语言本身提供. String 是一种可扩展, 可变, 且在堆内存分配的 UTF-8 编码字符串类型. String
类型和字符串切片类型都是 UTF-8 编码的.
创建字符串
let mut s = String::new(); // s 是一个空字符串.
在任意实现了 Display
的类型上, 都可以调用 to_string
方法生成字符串:
let data = "initial contents";
let s = data.to_string();
// the method also works on a literal directly:
let s = "initial contents".to_string();
还可以通过 from
方法创建字符串:
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 mut s = String::from("foo");
// 通过 push 追加
s.push_str("bar");
// 通过 push_str 追加字符串切片到字符串. 且追加切片时, 不会进行 move 转移所有权.
// 因为切片所在的内存区域并非堆上, 而是在文本段中.
let s2 = "bar";
s1.push_str(s2);
// 追加字符到字符串:
let mut s = String::from("lo");
s.push('l');
字符串拼接操作符 +
的工作原理
可以通过 +
操作符拼接字符串:
// 使用 `+` 操作符拼接字符串
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意这里 s1 被 move 了, 之后就无法再使用 s1 了.
在最后一行中这样写的原因是 +
操作符实际就是在调用 add
方法, 它的方法签名是这样的:
fn add(self, s: &str) -> String {
//...
}
看方法签名可以发现, 这个方法会获取 self 的所有权, 第二个参数是 &str
, 因此才有上面的代码写法.
为什么 &str
参数使用的时候可以传入一个 &String
呢?
答案是 Rust 编译器会自动进行强制解引用(deref coercion), 它会将 &String
转换为 &String[..]
, 即获取这个字符串的整个切片, 这样就可以对应方法参数类型 &str
了.
因为 add
方法中第二个参数是引用(并没有 move), 因此在拼接后, 第二个参数仍然可用. 在 add
方法的实现中, 使用非常高效的方式去处理第二个参数, 而非单纯复制.
如果要拼接多个参数, 更好的办法是使用 format!
宏:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
字符串中的字符访问
由于 String 也是一种集合, 但如果通过下标访问会直接报错:
let s1 = String::from("hello");
let h = s1[0];
上面代 码会有编译错误!
为什么呢?
原因是 String 的内部表示, 它是一个 Vec<u8>
的集合, 即字节集合. 但 UTF-8 字符并非一个字节表示一个字符, 而是可变长度的(比如 emoji 的一个字符会更长). 因此如果允许下标访问的话会造成取出一个字符的部分.
Rust 为了避免取单个 UTF-8 字节而可能出现错误, 因此会在此种情况下直接编译报错.
实际在看 UTF-8 字符串时有三种视角:
- 字节数组
- scalar value: 标量值
- grapheme cluster: 字素簇
比如印地语的 नमस्ते
字符串, 对应的三种视角:
- 字节数组:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
- 标量集合(即 Rust 中的 char 集合):
['न', 'म', 'स', '्', 'त', 'े']
- 字素簇:
["न", "म", "स्", "ते"]
实际只有字素簇才是真正"人类理解"的这个字符串的组成部分. 而我们编程时肯定想要获取的是能够真正表示字符串中的"每个可读字符", 且由于顺序表总是想让它的下标访问是 O(1) 的, 但不同字符串中可读字符的长度不总是为 1 字节, 因此 Rust 不允许下标访问字符串.
正因为这个原因, 字符串的切片时也需要格外小心, 只有确认字符串中所有字符都是 ASCII 兼容的字符时, 切片才是安全的, 否则程序会出现 panic 让程序崩溃.
在 Rust 中提供了下面的字符串遍历方式:
// 字符视角
for c in "Зд".chars() {
println!("{}", c);
}
// 输出如下:
// З
// д
// 字节视角
for b in "Зд".bytes() {
println!("{}", b);
}
// 输出如下:
// 208
// 151
// 208
// 180
而目前 Rust 没有直接获取字素簇的内置方法, 只有使用外部库去获取这样的功能, 因为字素簇的获取非常复杂, Rust 标准库不会提供这样的功能.
Hash 实现的字典: HashMap
Rust 提 供了 HashMap<K, V>
用于存放键值对集合, 其中 K 会通过哈希定址进行存放.
下面仅介绍 HashMap 的主要 API:
// 创建和插入
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() // 元素是 i32, copy 到栈上不会有太大开销.
.unwrap_or(0);
// 枚举器遍历字典:
for (key, value) in &scores {
println!("{}: {}", key, value);
}
// 更改某个键值: 直接对同一个键写值即可.
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);
// 仅在不存在键的情况下写值: 第二次不会改变值了.
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Yellow")).or_insert(10);
// 根据老的值更新新值: or_insert 会返回原值的引用, 解引用后更新值即可.
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
HashMap 的 Ownership 规则和 Vec 的类似:
- 针对 Copy 的数据类型, 直接 Copy 到字典中
- 针对堆内存分配的数据类型, 会 move 到字典中
当然也可以在字典中存放堆内存分配数据的引用, 但需要保证该值的生命期至少和字典一致, 否则会编译出错.
HashMap 默认使用 SipHash 哈希算法, 虽然它不是最快的哈希算法, 但可以有效避免在哈希表上的 DoS 攻击, 因此 Rust 默认选择它. 如果需要更快的哈希算法, 可以先 benchmark 一下目前代码评估, 并切换到更快的哈希算法, 办法是提供不同的 hasher
, hasher 实现了 BuildHasher
即可.