rust入坑小记-06-集合类型
10 集合
集合(collections)是rust中非常有用的数据结构,下面会逐个介绍三种集合。
10.1 String
字符串切片
切片在前面介绍数组时有简单提到,它是一类引用,没有所有权。
1 | let s = String::from("hello world"); |
s是String类型,hello 没有引用整个 String s,而是引用了 s 的一部分内容,通过 [0..5] 的方式来指定。这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,这是一个[)右半开区间。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引 - 开始索引 的方式计算得来的。
..除了用于解构,在这里是生成连续序列的意思,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:
1 | let slice = &s[0..2]; |
同样的,如果你的切片想要包含 String 的最后一个字节,则可以这样使用:
1 | let s = String::from("hello"); |
你也可以截取完整的 String 切片:
1 | let s = String::from("hello"); |
但通常,字符串切片是非常危险的操作,具体原因请继续往下看。
字符串的字面值是切片
对于:
1 | let s = "Hello, world!"; |
s 的类型是 &str,因此你也可以这样声明:
1 | let s: &str = "Hello, world!"; |
这也是为什么字符串字面量是不可变的,因为 &str 是一个不可变引用。
字符串
字符串是由字符组成的连续集合。rust中的字符是Unicode类型,因此每个字符占据4个字节内存空间,但是在字符串中不一样,字符串是UTF-8编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间
String是常用的数据类型,它是在标准库中定义的。rust在语言级别只有一种数据类型str,它通常以引用的形式&str出现,也就是字符串切片,也叫做字符串的字面值。
str 类型硬编码进可执行文件,也无法被修改,但是String则是一个可增长、可改变且具有所有权的UTF-8编码字符串。当用户提到字符串时,往往指的就是String类型和&str字符串切片类型,这两个类型都是UTF-8编码。
创建字符串与转换类型
1 | let mut s = String::new(); |
通过new可以新建一个空字符串。但更多时候,我们需要&str与String相互转换。
首先是从 &str 类型生成 String 类型:
1 | let s = String::from("initial contents"); |
这两种生成方式得到的效果是一样的,String::from 和 .to_string 最终做了完全相同的工作,所以如何选择就是代码风格与可读性的问题了。
将 String 类型转为 &str 类型也非常简单,取引用即可:
1 | fn main() { |
字符串的编码
字符串是UTF-8编码,所以可以包含任何可以正确编码的数据:
1 | let hello = String::from("السلام عليكم"); |
所有这些都是有效的 String 值。
字符串索引
在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现错误:
1 | fn main() { |
错误提示告诉我们,rust的字符串不支持索引。这是因为字符串在内存中的存储决定的。
内部表现
字符串的底层的数据存储格式实际上是一个u8类型的字节数组。前面举了一些UTF-8的字符串例子,比如:
1 | fn main() { |
在这里,len的值是4,因为Hola中的每个字母在UTF-8编码中仅占用1个字节,但是对于:
1 | fn main() { |
len 的值是12,因为一个中文字符需要3个字节存储。在这种情况下,假如对s1取索引&s1[0],并不会返回中,而是这3个字节中的第1个字节,这显然不是用户想要的结果。为了避免返回意外的值并造成不能立刻发现的bug,rust根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
这引起了关于UTF-8的另外一个问题:从rust的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字母。
比如这个用梵文书写的印度语单词 “नमस्ते”,它底层的字节数组是如下形式:
1 | [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, |
这里有 18 个字节,也就是计算机最终会储存的数据。
如果从Unicode标量值的角度理解它们,也就像rust的char类型那样,这些字节看起来像这样:
1 | ['न', 'म', 'स', '्', 'त', 'े'] |
这里有六个 char,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字母的角度理解:
1 | ["न", "म", "स्", "ते"] |
rust提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。
最后一个rust不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间O(1)。但是对于String不可能保证这样的性能,因为rust必须从开头到索引位置遍历来确定有多少有效的字符。
字符串切片可能造成的问题
前面提到,字符串切片是非常危险的操作,这正是由于字符串索引返回的东西不明确。因此,如果你真的希望使用索引创建字符串切片时,需要你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 [] 和单个值的索引,可以使用 [] 和一个range来创建含特定字节的字符串切片:
1 | let hello = "中国人"; |
这里,s 会是一个 &str,它包含字符串的前3个字节,也就是中。但是,如果你取错了索引:
1 | let s = &hello[0..2]; |
那么就会在运行时painc。你应该小心谨慎地使用这个操作,因为这么做可能会使你的程序崩溃。
字符串操作
String是动态可增加的,其内容也可以改变,下面介绍字符串的修改,添加,删除等常用方法。
追加
可以通过 push_str 方法来附加字符串切片,从而使 String 变长:
1 | fn main() { |
s2并没有在push_str后失效,这是因为我们使用的是字符串字面值,并不需要获取参数的所有权。
还可以使用push,push 方法被定义为获取一个单独的字符作为参数,并附加到 String 中:
1 | fn main() { |
这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则被添加的字符串必须是可变的,需要用mut。
拼接字符串
通常你会希望将两个已知的字符串合并在一起。一种办法是使用 + 运算符。
1 | fn main() { |
这里有一些细节需要注意,首先,我们将两个字符串拼接的时候,需要使用+,左边是s1,而右边则获取了引用&s2。并且,如果你在最后打印s1,会发现它不再有效,这是由于+运算背后调用了标准库的add方法,函数签名看起来像这样:
1 | fn add(self, s: &str) -> String {} |
根据前几章的内容可以得知,我们使用第二个字符串的引用与第一个字符串相加。这是因为 add 函数的 s 参数:只能将 &str 和 String 相加,不能将两个 String 值相加。不过正如 add 的第二个参数所指定的,&s2 的类型是 &String 而不是 &str。那么为什么还能通过编译呢?之所以能够在 add 调用中使用 &s2 是因为 &String 可以被强转(coerced)成 &str。当add函数被调用时,rust使用了一个被称为Deref 强制转换(deref coercion)的技术,你可以将其理解为它把 &s2 变成了 &s2[..]。
其次,可以发现签名中 add 获取了 self 的所有权,因为self没有使用 &。这意味着s1 的所有权将被移动到 add 调用中,之后就不再有效。所以虽然 let s3 = s1 + &s2; 看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。
另外也可以使用+=:
1 | fn main() { |
如果需要多个字符串拼接,+就不太方便了,此时可以使用format!宏:
1 | fn main() { |
format! 与 println! 的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String。这个版本就好理解的多,宏 format! 生成的代码使用引用所以不会获取任何参数的所有权。
插入
使用 insert() 方法插入单个字符 char,也可以使用 insert_str() 方法插入字符串字面值。这两个方法需要传入两个参数,一个是待插入的值,另一个是插入的位置索引,索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由mut关键字修饰。
1 | fn main() { |
替换
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace() 方法。与替换有关的方法有三个。
replace。用于
String和&str类型。replace()方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。1
2
3
4
5fn main() {
let string_replace = String::from("I like rust. Learning rust is my favorite!");
let new_string_replace = string_replace.replace("rust", "RUST");
println!("{}",new_string_replace);
}replacen该方法可适用于
String和&str类型。replacen()方法接收三个参数,前两个参数与replace()方法一样,第三个参数则表示替换的个数。同样返回一个新的字符串,而不是操作原来的字符串。1
2
3
4
5fn main() {
let string_replace = String::from("I like rust. Learning rust is my favorite!");
let new_string_replace = string_replace.replacen("rust", "RUST",1);
println!("{}",new_string_replace);
}replace_range该方法仅适用于
String类型。replace_range接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。因此被操作字符串需要使用mut关键字修饰。1
2
3
4
5fn main() {
let mut string_replace_range = String::from("I like rust!");
string_replace_range.replace_range(7..8, "R");
println!("{}",string_replace_range);
}将索引范围是
[7,8)区间内的字符替换为字符串R
删除
与字符串删除相关的方法有 4 个,他们分别是 pop(),remove(),truncate(),clear()。这四个方法仅适用于 String 类型,并且直接在原地操作,需要使用mut修饰。
pop该方法删除并返回字符串的最后一个字符,返回值是一个
Option,如果字符串为空,则返回None。1
2
3
4
5
6
7
8fn main() {
let mut s = String::from("I like rust!");
let t = s.pop();
if let Some(x) = t {
println!("{}", x);
}
println!("{}", s);
}remove该方法删除并返回字符串中指定位置的字符。接收一个参数即要删除的索引位置,如果索引越界则会
panic:1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
let t = s.remove(3);
println!("{}", t);
println!("{}", s);
}truncate该方法删除字符串中从指定位置开始到结尾的全部字符,无返回值,索引越界则会
panic:1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
s.truncate(5);
println!("{}", s);
}clear该方法清空字符串,相当于
truncate(0):1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
s.clear();
println!("{}", s);
}
更多方法,请参阅官方文档。
转义字符串
我们可以通过转义的方式\输出ASCII和Unicode字符。
1 | fn main() { |
当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:
1 | fn main() { |
操作UTF-8字符串
如果你想要以Unicode字符的方式遍历字符串,最好的办法是使用 chars 方法,例如:
1 | for c in "中国人".chars() { |
以字节形式遍历,使用bytes:
1 | for b in "中国人".bytes() { |
想要准确的从 UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 holla中国人नमस्ते 这种变长的字符串中取出某一个子串,使用标准库是做不到的。 需要在 crates.io 上搜索 utf8 来寻找想要的功能:考虑使用utf8_slice
总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。rust选择了较为复杂、更加准确的方式。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发周期后期免于处理涉及非 ASCII字符的错误。
10.2 动态数组 Vector
动态数组Vec<T>也被称为vector,是长度可增长的数组,下面介绍它的基本使用。
创建动态数组
创建新的空vector,可以调用 Vec::new 函数:
1 | let v: Vec<i32> = Vec::new(); |
这里显式增加了一个类型标注Vec<i32>,编译器可以得知v中的元素是i32类型。如果去掉标注,由于动态数组是通过泛型实现的,rust并不知道我们想要储存什么类型的元素。
当然,rust可以通过类型推断来确定动态数组的类型:
1 | let mut v = Vec::new(); |
首先需要使用mut将v变成可变的,然后使用push方法添加一个元素,此时通过类型推断,编译器可以得知v 中的元素类型是 i32,因此推导出 v 的类型是 Vec<i32>。
通常,我们会用初始值来创建一个 Vec<T> ,rust会推断出储存值的类型,使用宏 vec! 来创建数组,这个宏会根据我们提供的值来创建一个新的 vector:
1 | let v = vec![1, 2, 3]; |
我们提供了 i32 类型的初始值,rust可以推断出 v 的类型是 Vec<i32>,因此类型注解就不是必须的。
修改动态数组
向数组尾部添加元素,可以使用 push 方法:
1 | let mut v = Vec::new(); |
插入元素insert:
1 | fn main() { |
删除并返回数组中最后一个元素:
1 | fn main() { |
更多方法,可以参阅官方文档。
读取数组的元素
有两种方法引用 vector 中储存的值:通过索引或使用 get 方法:
1 | fn main() { |
使用 & 和 [] 会得到一个索引位置元素的引用。当使用索引作为参数调用 get 方法时,会得到一个可以用于 match 的 Option<&T>枚举类型。
之所以提供两种读取方法,是因为如果索引越界,程序的效果不同,如果是直接取索引:
1 | fn main() { |
rust直接会panic,而使用get:
1 | fn main() { |
它不会panic而是返回 None,当偶尔出现超过范围的访问属于正常情况的时候可以考虑使用它。接着你的代码可以有处理 Some(&element) 或 None 的逻辑,就像模式匹配里讲的那样。
一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保vector内容的这个引用和任何其他引用保持有效。回忆一下不能在相同作用域中同时存在可变和不可变引用的规则:
1 | fn main() { |
当我们获取了数组的第一个元素的不可变引用并尝试在末尾增加一个元素的时候,如果尝试在函数的后面引用这个元素是行不通的。为什么第一个元素的引用会关心数组结尾的变化?不能这么做的原因是由于动态数组的工作方式:在动态数组的结尾增加新元素时,在没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历数组元素
如果想要依次访问数组中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问。
1 | fn main() { |
当然,也可以遍历可变引用,以在循环中改变原始值:
1 | fn main() { |
在其他语言中,有一种不安全的行为,比如,在python的for循环中,不断地向被迭代的对象添加元素:
1 | arr = [1, 2, 3, 4, 5] |
就会导致无限循环下去的bug。所以,在这些语言中,应当避免在循环体中对被循环的数组进行插入、删除等操作,在C/C++这样的编译型语言中,编译器也不会给出提醒报错,使得这样的错误有时难以被发现。
而rust则根本解决了这个问题。根据借用检查器的规则,无论可变还是不可变地遍历一个动态数组都是安全的。如果尝试在 for 循环体内插入或删除项,都会得到编译错误。这是因为在for 循环中已经获取了动态数组引用,阻止了同时对动态数组整体的修改,比如
1 | fn main() { |
即使改成可变引用:
1 | fn main() { |
也会由于“同一个变量的可变引用在同一作用域内最多只能有一个”这样的规则而报错。
动态数组的drop
类似于结构体,动态数组在其离开作用域时会被释放:
1 | { |
当动态数组被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的元素将被清理。借用检查器确保了任何数组中内容的引用仅在数组本身有效时才可用。比如:
1 | fn main() { |
使用动态数组和枚举来存储不同类型的数据
有时候,我们需要在数组中存放不同类型的数据,就像弱类型(js)或动态类型(python)的语言一样。rust中,需要麻烦一些,通过枚举来实现这种需求。
1 | enum Number { |
这里的arr中存储的都是枚举类型Number,在枚举成员中会存放这些不同类型的值。rust在编译时就必须准确的知道数组中类型的原因在于,它需要知道储存每个元素到底需要多少内存。但如果在编译时不能确定有多少类型的数据,则无法使用枚举,可以通过特征对象来实现这个需求。留到后面进行讨论,详见特征对象。
10.3 哈希表 hashmap
最后介绍的常用集合类型是哈希(hashmap),HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表、dict或者关联数组等。例如,在一个游戏中,你可以将每个团队的分数记录到hashmap中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。
创建hashmap
通过new方法创建:
1 | use std::collections::HashMap; |
在这三个常用集合中,HashMap 是最不常用的,所以并没有被prelude自动引用,需要通过use引入HashMap,然后创建一个空的scores,并插入两个键值对。和String一样,哈希表将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于动态数组,哈希表是同质的:所有的键必须是相同类型,值也必须都是相同类型。
访问hashmap
可以通过 get 方法并提供对应的键来从hashmap中获取值:
1 | use std::collections::HashMap; |
这里,score 是与蓝队分数相关的值,应为 10。get 方法返回 Option<&V>,如果某个键在哈希 map 中没有对应的值,get 会返回 None。程序中通过调用 copied 方法来获取一个 Option<i32> 而不是 Option<&i32>,接着调用 unwrap_or 在 score 中没有该键所对应的项时将其设置为零。
遍历hashmap
可以使用for循环遍历hashmap的所有值:
1 | use std::collections::HashMap; |
所有权转移
hashmap的所有权规则与其它类型没有区别,对于实现了Copy特征的类型,该类型会被复制进hashmap;对于没实现 Copy 特征的类型,其值将被移动而hashmap会成为这些值的所有者。
1 | fn main() { |
如果将值的引用插入hashmap,这些值的所有权不会被转移。但是这些引用指向的值必须至少在hashmap有效时也是有效的,看下面的例子:
1 | fn main() { |
在{}作用域内向hashmap中插入了一对元素,其中,key传入了引用&field_name,field_name的所有权没有被移动,但是在{}作用域结束后,field_name被自动drop后失效,hashmap的仍然保留着的这个引用所指向的值是未知的,无法通过编译。这里涉及到生命周期的概念,后面会进行介绍(见生命周期)。
更新hashmap
当我们想要改变hashmap中的数据时,必须决定如何处理一个键已经有值了的情况。有以下几种处理方法:
直接覆盖原先的。
如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。
1
2
3
4
5
6fn main() {
let mut map = HashMap::new();
map.insert(String::from("aaa"), 3);
map.insert(String::from("aaa"), 4);
println!("{:?}", map);
}最终hashmap的值为:
{"aaa": 4},最开始的3被覆盖了。只在键没有对应值时插入键值对。
有时可能会需要这样的操作:当插入时,hashmap中不存在这个key,则插入;hashmap存在这个key则不作任何操作。
为此hashmap提供了一个
entry方法,它获取我们想要检查的键作为参数,它的返回值是一个枚举Entry。这个枚举代表了可能存在也可能不存在的值。Entry的or_insert方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。1
2
3
4
5
6fn main() {
let mut map = HashMap::new();
map.insert(String::from("aaa"), 3);
map.entry(String::from("aaa")).or_insert(5);
println!("{:?}", map);
}使用
or_insert比编写自己的逻辑要简明,另外也与借用检查器结合得更好。根据旧值更新一个值。
另一个常见的应用场景是找到一个键对应的值并根据旧的值更新它。
1
2
3
4
5
6
7
8fn main() {
let mut map = HashMap::new();
map.insert(String::from("aaa"), 3);
let value = map.entry(String::from("aaa")).or_insert(8);
*value += 1;
println!("{:?}", map);
}or_insert返回了&mut value引用,因此可以通过该可变引用直接修改map中对应的值,使用引用时,需要解引用*。