【Rust学习之旅】动态数组 Vector 、String、 HashMap(八)

1,533 阅读10分钟

前面的学习中,我们已经学习了基础类型,枚举类型,与结构体,本期我们来讲一下,String类型,动态数组 Vector 与HashMap。

动态数组 Vector

前面我们提到过数组类型,并且说了他是定并且类型必须一致,这样的数组明显不能满足我们的开发需求。

vector 允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值。vector 只能储存相同类型的值。rust提供了新的数组类型动态数组 Vector

使用用 Vec::new 函数,我们可以轻松创建一个Vector。

注意这里我们增加了一个类型注解。因为没有向这个 vector 中插入任何值,rust编译器不知道他是什么类型的,但是不用担心,rust可以根据上下文推断。后续使用就明白了。

fn main() {
    let v: Vec<i32> = Vec::new();
}

另一种创建的方式,使用vec! 宏,这个宏会根据我们提供的值来创建一个新的 vector。

fn main() {
    let v = vec![1, 2, 3];
}

这里提供了 i32 类型的初始值,Rust 可以推断出 v 的类型是 Vec<i32>,因此类型注解就不是必须的。

更新 vector

更新一个vector 和JavaScript也一样提供了push方法,别忘了添加mut

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

读取 vector 的元素

rust提供了两种方法引用 vector 中储存的值:通过索引或使用 get 方法。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}

这两种方式主要的区别就是:

  • 索引有可能会发生索引溢出,会发生panic
  • get方式不会,它不会 panic 而是返回 None

panic 代表出现发生错误,我们可以手动调用panic宏,类似于typescript中 throw Error

遍历 vector 中的元素

我们可以使用前面提到for in来遍历vector,

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}

我们也可以获取一个可变引用,改变其中的值

注意这里我们用到了*,这代表解引用刚好遇&相反

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

使用枚举来储存多种类型

刚才我们说到Vector也只可以储存同一种类型的值,但是我们可以配合枚举类型,达到储存多种类型值的目的。

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}

Rust 在编译时就必须准确的知道 vector 中类型的原因在于它需要知道储存每个元素到底需要多少内存。

如果Rust 允许 vector 存放任意类型,那么当对 vector 元素执行操作时一个或多个类型的值就有可能会造成错误。这对于安全要求高的语言来说是不可以接受的。

使用枚举外加 match 意味着 Rust 能在编译时就保证总是会处理所有可能的情况,

当然在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,后面会讲到。

标准库中 Vec 定义的很多其他实用方法的 API 文档。例如:pop,len 等

丢弃 vector 时也会丢弃其所有元素

类似于任何其他的 struct,vector 在其离开作用域时会被释放,

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

当 vector 被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。借用检查器确保了任何 vector 中内容的引用仅在 vector 本身有效时才可用。

String

字符串在rust中对于JavaScript重度使用者来说,确实有些反人类,不方便了,可能这是rust对于安全性考量,做的一些妥协吧。

字符串是比很多程序员所想象的要更为复杂的数据结构,

什么是字符串?

Rust 的核心语言中只有一种字符串类型:字符串 slice str,它通常以被借用的形式出现,&str

新建字符串

很多 Vec 可用的操作在 String 中同样可用,事实上 String 被实现为一个带有一些额外保证、限制和功能的字节 vector 的封装。这也是我们为什么先讲vector的原因.

使用String::from 我们可以轻松的创建一个字符串,并且下面的字符串都是合法的,rust字符串支持utf-8.

fn main() {
    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");
}

使用 to_string 方法,它能用于任何实现了 Display trait 的类型,比如字符串字面值。

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // 该方法也可直接用于字符串字面值:
    let s = "initial contents".to_string();
}

更新字符串

String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。

使用 push_str 和 push 附加字符串

可以通过 push_str 方法来附加字符串 slice,从而使 String 变长,注意这里所有权的转移。

  • push_str拼接一个字符串字面量&str,
  • push 拼接单个char
fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

使用 + 运算符或 format! 宏拼接字符串

通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符,

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
}

这里需要注意的是,+会得到左边字符串的所有权,右边是引用,因为这里相当于内部实现了add方法 fn add(self, s: &str) -> String {...}

在标准库中你会发现,add 的定义使用了泛型和关联类型。在这里我们替换为了具体类型,这也正是当使用 String 值调用这个方法会发生的。

如果想要多个字符串,+ 的行为就想一个憨憨一样了

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

rust提供了 format! 宏

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

是不是感觉rust,遇到处理困难的语法,就会使用,面向宏编程

索引字符串

在typescript中,我们可以通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现一个错误。

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

程序会发生错误,Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?

内部表现

刚才我们说了String 是一个 Vec<u8> 的封装。并且支持utf-8,所以一个我们所理解的字符实际上在内部实现上并不是占一个字节。索引访问到的值可能是一个毫无意义的值。

最后一个 Rust 不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间(O(1))。但是对于 String 不可能保证这样的性能,因为 Rust 必须从开头到索引位置遍历来确定有多少有效的字符。

字符串 slice

索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引创建字符串 slice 时,Rust 会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 [] 和单个值的索引,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

这里,s 会是一个 &str,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s 将会是 “Зд”。

如果获取 &hello[0..1] 会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样

你应该小心谨慎地使用这个操作,因为这么做可能会使你的程序崩溃。

遍历字符串的方法

操作字符串每一部分的最好的方法是明确表示需要字符还是字节。

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
for b in "Зд".bytes() {
    println!("{b}");
}
}
  • chars 方法会将其分开并返回两个 char 类型的值,接着就可以遍历其结果来访问每一个元素了:

  • 另外 bytes 方法返回每一个原始字节,这可能会适合你的使用场景

从字符串中获取如同天城文这样的字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。

字形簇就是我们视觉上的一个字符

字符串并不简单

字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。Rust 选择了以准确的方式处理 String 数据作为所有 Rust 程序的默认行为。

现在你知道JavaScript为我们负重前行了吧😂

rust标准库还是提供了很多围绕 String 和 &str 构建的功能,来帮助我们正确处理这些复杂场景。例如 contains 来搜索一个字符串,和 replace 将字符串的一部分替换为另一个字符串。并不是完全不考虑开发人员。

HashMap

最后我们讲解一下HashMap,HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。

新建一个哈希 map

可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素。注意必须首先 use 标准库中集合部分的 HashMap。在这三个常用集合中,HashMap 是最不常用的,所以并没有被 prelude 自动引用。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

像 vector 一样,哈希 map 将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。

访问哈希 map 中的值

可以通过 get 方法并提供对应的键来从哈希 map 中获取值,

fn main() {
    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().unwrap_or(0);
}

哈希 map 和所有权

对于像 i32 这样的实现了 Copy trait 的类型,其值可以拷贝进哈希 map。对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者,

fn main() {
    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);
    // 这里 field_name 和 field_value 不再有效,
    // 尝试使用它们看看会出现什么编译错误!
}

当 insert 调用将 field_name 和 field_value 移动到哈希 map 中后,将不能使用这两个绑定。

如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。

更新哈希 map

当我们想要改变哈希 map 中的数据时,必须决定如何处理一个键已经有值了的情况。可以选择完全无视旧值并用新值代替旧值。可以选择保留旧值而忽略新值,并只在键 没有 对应值时增加新值。或者可以结合新旧两值。

覆盖一个值

连续插入相同key的值会覆盖前一个

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{:?}", scores);
}

只在键没有对应值时插入键值对

Entry 的 or_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。

fn main() {
    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);
}

根据旧值更新一个值

另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。

fn main() {
    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);
}

split_whitespace 方法返回一个由空格分隔 text 值子 slice 的迭代器。or_insert 方法返回这个键的值的一个可变引用(&mut V)。这里我们将这个可变引用储存在 count 变量中,所以为了赋值必须首先使用星号(*)解引用 count。这个可变引用在 for 循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。

你只需要记住这个玩意和我们JavaScript中的Map特性差不多,区别

  • rust hashMap 是同构的,就是仅允许,key的类型相同,value的类型也要相同
  • 提供的方法不一样,JavaScript提供了getsetclear等,rust提供了getinsert等。

哈希函数

HashMap 默认使用一种叫做 SipHash 的哈希函数,它可以抵御涉及哈希表(hash table)1 的拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。hasher 是一个实现了 BuildHasher trait 的类型。第十章会讨论 trait 和如何实现它们。你并不需要从头开始实现你自己的 hasher;crates.io 有其他人分享的实现了许多常用哈希算法的 hasher 的库。

结语

这一期我们又学习三种复杂类型,至此rust的基础已经差不多快完了,下一期我们讲到错误处理,刚才我们也提过panic!宏。

rust学习之旅至此才刚刚开始。😂

坐稳准备发车了。🤔