菜鸡前端的Rust学习笔记(八)—常用集合

611 阅读14分钟

写在前面

Rust标准库包含着一种集合的数据结构,称之为集合。集合和其他数据结构不一样的地方在于,集合可以包含多种不同的数据解构于一身。与数组和元组不同,集合是存在堆中的,因此在运行时他不需要知道数据的大小,在程序跑起来的时候可能增加或者缩减对应的大小。

因此,对于开发者来说在开发过程中选择合适的集合,是一种技巧。本章中将讨论三种rust中常用的集合:

  • 向量:允许存储变量在相邻的位置
  • 字符串集合:本章将对String类型进入深刻的探讨
  • hashmap:允许使用特殊的键来关联值,更通用的数据解构称为map

8.1 通过向量来存储列表值

第一种集合类型称为向量,用Vec<T>来表示,向量是一种可以存储多个值的单一数据结构,在内存中多个值是彼此贴近存储的。向量只能存储相同数据类型的值,当你有多个相同类型的列表需要存储,可以使用他。

8.1.1 创建一个新的向量

我们通过Vec::new来创建一个向量实例。因为向量只能接收一种数据类型的结构,所以通过泛型来设置这个向量可以接收数据的类型为i32。在更常用的代码中,一旦你插入了相关的值,Rust可以自动推断出你想存储值的类型,所以并不需要去进行类型的声明。相较于Vec<T>通过vec!可以创建一个带有初始值的向量,会更加的方便,其写法如下:

fn main() {
    let mut v: Vec<i32> = Vec::new();
    let mut v2 = vec![1, 2, 3];
}

注意点

Q1: Vec和Array的区别

A1: Vec可以通过push直接扩展,其扩展的原理是重新分配一个新的内存空间,然后将老的空间复制到新的内存空间上,而Array是连续的空间,所以要提前声明长度,防止越界。

8.1.2 更新向量值

通过push的方式来为向量增加元素。

fn main() {
    let mut v: Vec<i32> = Vec::new();
    v.push(3);
​
    let mut v2 = vec![1, 2, 3];
    // 如果这里
    v2.push(4);
}

注意点

需要类型相同,如果我这个时候插入一个字符串类型,会如下错误报错

expected integer, found struct std::string::String

8.1.3 删除向量元素

删除整个向量:向量也遵守所有者原则,当其在所有者区外的时候,就会直接GC回收整个向量,并且其中的内容也会被回收了。

8.1.4 读取向量元素

有两种方式可以读取向量的值:1. 通过索引 2. 利用提供的get方法。

因为我们已经声明了向量的类型,所以得到值的类型是十分清楚的。

fn main() {
    let mut v: Vec<i32> = Vec::new();
    v.push(3);
​
    let mut v2 = vec![1, 2, 3];
    v2.push(4);
​
    let thrid: &i32 = &v2[2];
    println!("{}", thrid);
​
    let fourth = v2.get(2);
​
    match fourth {
        Some(value) => println!("value -> {}", value),
        None => println!("no value")
    }
}

注意点

  1. 索引的方式取值是从0开始的

  2. 使用索引方式拿到的是借用的值,如果需要使用引用,需要加上&

  3. 利用get方法拿到的值是Option<&T>,所以需要使用matchif let语法进行操作

  4. 对于不存在的引用,使用索引的方式获取对应的值可能会导致程序的崩溃,因为这个时候引用了一个不存在的值,但是如果通过get方法,这个时候会返回一个None

  5. 如果一个值已经被借用了,这个时候再向向量添加元素这个时候会报错

    cannot borrow v2 as mutable because it is also borrowed as immutable.

    fn main() {
        let mut v2 = vec![1, 2, 3];
        let thrid = &v2[2];
    ​
        v2.push(4);  // 这里会报错
    }
    ​
    // 解决办法
    fn main() {
        let mut v2 = vec![1, 2, 3];
    ​
        // 剞劂办法是
        {
            let thrid = &v2[2];
            println!("{}", thrid);
        }
    ​
        v2.push(4);
    }
    

    解决办法:将push语句前置或者使用所有者,将对应的引用先释放,其原因是,这种场景下需要扩展向量容积,但是因为v2[2]已经被借用,不能移动其空间在内存中的位置,所以这个时候维持原状,push方法不能增加元素了。

8.1.5 向量中值的迭代特性

使用for...in语法可以通过迭代器的方式遍历相应的可迭代类型。

fn main () {
    // 对v2的引用进行遍历
    for item in &v2 {
        // 这里可以直接使用for循环
        // 对v2进行遍历操作
        println!("value => {}", item);
    }
}

如果想对对应遍历的值进行修改,可以使用*运算符放在对应的变量之前,即可对引用类型进行修改

fn main() {
    let mut v2 = vec![1, 2, 3];
​
    for i in &mut v2 {
        *i = *i * 100 / 2;
    }
​
    for item in &v2 {
        println!("value => {}", item);
        // value => 50
        // value => 100
        // value => 150
    }
}

注意点

  • 如果需要对对应的变量进行修改,类似javascript中的map的用法,需要遵循以下几点

    • 使用mut在引用变量之前
    • 使用*在对应的变量之前,这是一种指针的用法, 在后续章节会讲到
    • 对对应的变量进行操作,需要使用*i在等式左右两边

8.1.6 使用枚举类型来存储多类型

如果向量只用来存储一种类型的值,在使用上会不太方便。幸运的是,枚举的衍生类型可以让我们定义多种不同的类型,之后所有枚举的衍生类都会被认为是一个数据类型,所以当我们需要在向量中存储不同类型的时候,我们可以定义枚举类。

我们可以通过定义枚举类的衍生类型,来区分不同的数据类型,这样所有存储在这个向量中的类型都会被认为是我们定义的这个枚举类。

pub mod EnumTypesTest {
    #[derive(Debug)]
    pub enum SpreadSheetCell {
        Int(i32),
        Float(f64),
        Text(String)
    }
    
​
    pub fn useMultiTypes () {
        // 这个向量中所有的类型都是SpreadSheetCell
        let row = vec![
            SpreadSheetCell::Int(3),
            SpreadSheetCell::Float(3.14),
            SpreadSheetCell::Text(String::from("string"))
        ];
​
        for item in &row {
            println!("types -> {:?}", item);
        }
    }
}

Rust在编译时需要知道对应存储元素的类型,以至于知道实际在运行时需要在堆中分配多少内存。第二个优势是rust可以推断出这个向量中存储元素的类型。

8.1.7 向量的其他API用法

具体的api可以参考相关的api文档:Struct Vec

  1. 切片

    fn read_slice(slice: &[usize]) {
        // ...
    }
    ​
    let v = vec![0, 1];
    read_slice(&v);
    
  2. 分配容量

    // 和alloc一样分配10个存储空间
    let mut vec = Vec::with_capacity(10);
    ​
    // len获取已存储数据的长度
    assert_eq!(vec.len(), 0);
    // capacity获取分配空间的长度
    assert_eq!(vec.capacity(), 10);
    ​
    // These are all done without reallocating...
    for i in 0..10 {
        vec.push(i);
    }
    assert_eq!(vec.len(), 10);
    assert_eq!(vec.capacity(), 10);
    
  3. 元素的操作

    // push
    let mut vec = vec![1, 2];
    vec.push(3);
    assert_eq!(vec, [1, 2, 3]);
    ​
    // pop弹出元素
    let mut vec = vec![1, 2, 3];
    assert_eq!(vec.pop(), Some(3));
    assert_eq!(vec, [1, 2]);
    ​
    // append合并两个向量
    // 将第一个向量和第二个向量合并,并且清空第二个i向量
    let mut vec = vec![1, 2, 3];
    let mut vec2 = vec![4, 5, 6];
    vec.append(&mut vec2);
    assert_eq!(vec, [1, 2, 3, 4, 5, 6]);
    assert_eq!(vec2, []);
    ​
    // 清空数组
    // drain和splice有点像,原向量保存切片之前的内容,丢掉切片之后的内容
    let mut v = vec![1, 2, 3];
    let u: Vec<_> = v.drain(1..).collect();
    assert_eq!(v, &[1]);
    assert_eq!(u, &[2, 3]);
    ​
    // A full range clears the vector
    v.drain(..);
    assert_eq!(v, &[]);
    ​
    //clear直接清空对应的向量
    let mut v = vec![1, 2, 3];
    ​
    v.clear();
    ​
    assert!(v.is_empty());
    

8.2 通过String存储UTF-8文本

本小节将更深度使用String类型,rust使用者因为以下三个原因导致对string使用困难:

  • Rust倾向于去抛出可能的错误
  • String类型可能比很多coder想象的更复杂
  • UTF-8

所以当你从其他语言转过来开发rust,发现会变得更加困难。本节将讨论String的创建、更新和读写

8.2.1 什么是String

在rust中只有一种string类型,其类型为str,通常在借用的时候写作&str借用对应的引用。

String类通过rust的标准库提供而不是初始数据结构,是可扩展的,而可变的UTF-8的string类型。

我们通常使用的是String&str切片,在Rust标准库中还有很多其他的String类型比如OsStringOsStrCString等。

8.2.2 String的创建

String的创建目前有三种方式:

  • 使用new方法
  • 使用from方法
  • 使用str自带的to_string方法
pub mod StringDemo {
    pub fn StringCreate() {
        let mut s = String::new();
​
        let data = "new string";
​
        let s1 = data.to_string();
​
        let s2 = String::from(data);
​
        println!("s1 -> {}, s2 -> {}", s1, s2)
    }
}

8.2.3 String的更新

String作为一种集合类型也有和向量Vec类似的拼接操作:

  • 使用push方法
  • 使用+:这种方法可以是str的引用,也可以是直接str
  • 使用format!方法:类似时殷弘模板字符串将多个String进行拼接
pub mod StringDemo {
   pub fn string_update() {
        let mut s = String::from("hello");
        let s2 = String::from(" world\n");
​
        s += &s2;
​
        s.push_str(" are you ok?");
​
        s += " pidan";
​
        println!("s -> {}", s)
       
        let s4 = "tic";
        let s5 = "tac";
        let s6 = "toe";
​
        let concat = format!("{}-{}-{}", s4, s5, s6);
​
        println!("{}", concat);
   }
}

8.2.4 字符串索引

在很多变成语言中,通过索引来获取字符串中的字符看似是一个常规操作,但是在rust中这么做会报错(rust的String不能通过索引获取对用的字符值)。

不能这么操作的理由是

  • 目前UTF-8中有的字符占用一个字节,而比如中文或者俄文其实际占用了两个字节,所以如果我们直接通过索引其实是拿不到我们想要的字符的,因为都是通过二进制存储的。
  • 和电脑系统存储的字符类型有关,在不同字符集和标量下表现不同
  • 和Rust String的性能有关,Rust的索引获取元素的复杂度并不是O(1)因为并不知道这个集合有多大。

8.2.5 切片类型

使用索引是无法获取到对应的字符的,但是我们可以是用range切片的方式来获取相应的字符

pub fn slice_string() {
    let mut s = String::from("Hello world");
    let s2 = &s[1..2];
​
    println!("s2 -> {}", s2); // e
    println!("s2 -> {}", &s[0..1]); // e
​
    let firstChar = s.get(0..1);
​
    if let Some(pat) = firstChar {
        println!("char -> {}", pat);
    }
}

8.2.6 字符串迭代遍历

通过charsbytes可以对字符和字节进行遍历,结合for..in的方法

for c in "नमस्ते".chars() {
    println!("{}", c);
}
​
for b in "नमस्ते".bytes() {
    println!("{}", b);
}

8.2.7 字符串并不简单

总的来说String是非常复杂的。在Rust中选择在Rust的程序中正确处理String数据的默认行为,比如考虑直接处理UFT-8的数据。这种权衡导致相较于其他语言增加了String的复杂性。但是带来的好处是,当你在编程的时候,你不用去处理以下ACSII字符的异常问题。

8.3 键值对关联存储Hash Map

最后一种通用的集合类型称为HashMap。HashMap的定义为HashMap<K, V>用来存储键值的类型K和值的类型V

通过hash算法来决定存储在内存中的键值是什么样子。许多编程语言支持这种类型的数据解构,可能有不同的名字来描述这种结构比如hash,map,对象,hash 表、字典等。

当我们想要通过索引来查询数据的时候,hash map是之分有用的,本节中我们将介绍一些基础的API用法,但是更多的用法在HashMap<K, V>的标准库中可以查询。

8.3.1 创建一个hashmap

HashMap的使用步骤:

  • 引入hashmap的包:std::collections::HashMap,hashmap不像Vec一样是默认导入的需要额外引入
  • 使用new方法创建HashMap实例
  • 利用insert方法插入对应的值
use std::collections::HashMap;
​
pub mod hash_learner {
    pub fn create_hash_map {
        let mut scores: HashMap<String, i32> = HashMap::new();
​
        scores.insert(String::from("tom"), 60);
        scores.insert(String::from("jerry"), 80);
        
        for key in scores.into_iter() {
            let k = key.0;
            let v = key.1;
​
            println!("k -> {}, v -> {}", k, v);
            // k -> jerry, v -> 80
            // k -> tom, v -> 60
        }
    }
}

注意点

  1. HashMap是存储在堆中的
  2. HashMap的所有键必须有相同的类型
  3. HashMap的所有值必须有相同的类型

利用向量和元组的collect方法来构建hash map

    pub fn build_with_collect() {
        use std::collections::HashMap;
​
        let students = vec![String::from("Tom"), String::from("Jerry")];
        let initial_scores = vec![90, 80];
​
        let score_map: HashMap<_, _> = students
            .into_iter()
            .zip(initial_scores.into_iter())
            .collect();
​
        for key in score_map.into_iter() {
            let k = key.0;
            let v = key.1;
​
            println!("k -> {}, v -> {}", k, v);
        }
    }

这里主要用到了迭代器的特性,具体的注意点如下:

  • 这里的HashMap<_, _>是必须的,因为collect可能会产生多种数据结构,rust并不知道对应的值是什么数据结构,所以必须认为强制告诉编译器
  • zip是迭代器的一种方法,将两边的对应索引的值匹配起来。
  • collect方法收集数据通过数字的集合类型,包括HashMap。

8.3.2 Hashmaps和所有者

当一个值被HashMap收录之后,他的所有者就变成了HashMap,所以这个时候就不用再借用对应的值,如果借用了对应的值,这个时候编译器会报错,看下面的这个例子:

pub fn ownership () {
    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);
​
    // let item = field_name;
    // 这一行代码会报错,因为这个时候field_name已经被放入了hashmap,所以无法再进行出借了。
   
}

解决办法

  • 出借给hashMap的时候使用引用的方式进行出借即可

        pub fn ownership () {
            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);
    ​
            let item = field_name; // 当使用&field_name出借的时候,这里就不会报错
        }
    

8.4.3 查询hashmap中的值

上面的尝试中,使用了.into_iter方法,进行遍历,这里将系统的介绍几个获取hashMap中值的方法:

  • 使用get方法获取单个值,这里获取的是一个Option<&K>
  • 直接通过for...in进行遍历,这里获取的就是对应的值
pub fn read() {
    use std::collections::HashMap;
​
    let mut scores = HashMap::new();
    let tom = String::from("tom");
    let jerry = String::from("jerry");
​
    scores.insert(&tom, 60);
    scores.insert(&jerry, 80);
​
    let tom_score = scores.get(&tom);
    let jerry_score = scores.get(&jerry);
​
    println!("tom -> {:?}, jerry -> {:?}", tom_score, jerry_score);
​
    for (key, value) in &scores {
        println!("key -> {}, value -> {}", key, value);
    }
}

8.4.4 更新Hash Map

尽管键值对是可以不断扩展的,但是每一个键只能关联一个值,当你更新已存在的键的时候,你需要决定怎样去处理这种场景,是放弃老的值,保留新的,还是放弃新的保存老的,还是新老联合一下。只有当key不存在的时候你存才会新创建一条记录。

覆写老的值

当我们多次给同样的key插入(insert)不同的值,最后保留的值是最后一次插入的值,这个时候老的值就会被覆写

pub fn update() {
    use std::collections::HashMap;
​
    let key = String::from("Tom");
    let mut scores = HashMap::new();
​
    scores.insert(&key, 50);
    scores.insert(&key, 60);
​
    println!("overwrite -> {:#?}", scores);
}

当键不存在场景才插入

通常场景,我们当key不存在的时候才插入对应的值。因此Hash Map提供了一个特殊的api,entryentry这个API使用后返回的类型为Entry,它用于告诉使用者这个值是否已经存在,之后调用or_insert方法来进行插入,这个or_insert方法是Entry定义的。

pub fn update() {
    use std::collections::HashMap;
​
    let jerry = String::from("jerry");
    let timi = String::from("Timi");
    let mut scores = HashMap::new();
​
​
    scores.insert(&jerry, 60);
    scores.entry(&jerry).or_insert(50);
    scores.entry(&timi).or_insert(80);
​
    println!("overwrite -> {:#?}", scores);
    // jerry 60, timi 90
​
}

基于已有内容更改

还有一种场景类似js中的map,对原来的hashmap内容进行修改

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是个&mut i32的类型,这个时候可以使用*运算符来改变引用的值。

Hash 函数

默认的hash生成算法是SipHash方法,其可以防止DoS攻击。这种算法不是最快的但是最好的权衡了安全性和性能。

总结

向量、字符串和哈希表,提供了程序中大量必要的功能,当你存储、接入和修改数据的时候。这里也有一些你需要知道的经验之谈:

  • 在大量整数列表的情况下,使用向量和返回中位数(排序取中间数)和列表的众数(经常出现的数,这种用了hash表也可能有用)。
  • 将string转变为颠倒的字符。需要时刻关注UTF-的编码规范,在字符串排序的时候。
  • 使用向量和哈希表的时候创建一个接口允许用户增加用户名在公司中。

标准的库的API的使用还需要查看官方文档的文档,接下来一节将讨论错误处理机制。