rust-lang-zh_CN/src/Ch08_Common_Collections.md
2023-04-08 22:33:35 +08:00

46 KiB
Raw Blame History

一些常用的集合

Common Collections

Rust 标准库中包含了几种名为 集合collections 的有用数据结构。大多数其他数据类型,都表示某个特定值,而集合则可包含多个值。与内建的数组和元组类型不同,这些集合所指向的数据,是存储在堆上的,这就意味着在编译时不需要知道数据的数量,进而在程序运行时,这些数据数量可增加或减少。每种集合都有不同能力与开销,针对应用程序当下情况,而选择恰当的一种集合,则是随着时间推移,要发展的一项编程技能。本章中,将讨论在 Rust 程序中,经常被用到的三种集合:

  • 矢量 允许存储并列的数个值;
  • 字符串 是一些字符的集合。早先曾提到过 String 类型,而本章就要深入讨论到他;
  • 哈希映射hash map 允许将某个值与特定键进行关联。他是一种更为通用数据结构、名为 映射map 的一个特定实现。

要了解由标准库所提供的其他类别集合,请参阅 文档

这里将讨论怎样创建与更新矢量、字符串与哈希映射,同时会讨论他们因何而变得特殊。

使用矢量类型,对值清单进行存储

Storing Lists of Values with Vectors

这里要看的第一个集合,便是 Vec<T>,也叫做 矢量vector 类型。矢量类型允许将多个值,存储在单个的、将全部这些值挨个放入内存的数据结构中。矢量类型仅能存储同一类型的这些值。在有着某个数据项目清单,比如某个文件中的那些文本行,或购物车中那些货品价格时,那么矢量类型就是有用的。

创建一个新的矢量值

要创建出一个新的空矢量值,就要调用 Vec::new() 函数,如下清单 8-1 所示:

    let v: Vec<i32> = Vec::new();

清单 8-1创建一个新的、用于保持一些类型 i32 值的空矢量

请注意这里添加了个类型注解。由于这里没有往这个矢量插入任何值,因此 Rust 是不清楚这里要存储何种类别元素的。这是个重点。矢量值是使用泛型实现的;在后面第 10 章中,就会讲到怎样在自己的类型中使用泛型。而此刻,就要明白由标准库提供的这个 Vec<T> 可以保存任何类型。在创建保存特定类型的矢量时,可在尖括号里头指定那个类型。在清单 8-1 中,就告诉了 Rustv 中的那个 Vec<T> 将保存 i32 类型的元素。

而更为常见的则是,会创建带有初始值的 Vec<T>,同时 Rust 就会推断出要存储的值类型那么就很少会进行这样的类型注解。Rust 贴心地提供了 vec! 这个宏,这个宏就会创建出一个新的、保存给到他的那些值的矢量来。下面清单 8-2 就创建了一个新的、保存了值 123Vec<i32>。之所以那个整数类型为 i32,是由于 i32 正是默认的整数类型,如同第 3 章的 "数据类型" 中所讨论的那样。

let v = vec! [1, 2, 3];

清单 8-2创建一个新的包含了值的矢量

由于这里已经给定了一些初始化 i32 的值,因此 Rust 就可以推断出 v 的类型为 Vec<i32>,而那个类型注解就不是必要的了。接下来,就要看看怎样修改矢量。

更新矢量

要创建出一个矢量,并随后将一些元素添加给他,就可以使用 push 方法,如下清单 8-3 中所示。

    let mut v = Vec::new();

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

清单 8-3使用 push 方法来把一些值添加到某个矢量

正如第 3 章中所讨论的,这里与任何变量一样,在想要能修改矢量的值时,就要使用 mut 关键字,将其构造为可变。这里在矢量内部的数字,全部都是 i32 类型,而 Rust 就会从这些数据,推断出这个类型,因此这里不需要 Vec<i32> 类型注解。

丢弃某个矢量,就会丢弃他的元素

Dropping a Vector Drops Its Elements

与其他任何 struct 一样,矢量在超出作用域时,就会被释放掉,如下清单 8-4 所示。

    {
        let v = vec! [1, 2, 3, 4];

        // 对 v 执行一些操作
    } // 这里 v 就超出了作用域,而被释放掉

清单 8-4对矢量及其元素在何处被丢弃进行展示

在这个矢量被丢弃时,那么他所有内容也会被丢弃,即他保存的那些整数将被清理掉。这初一看似乎直接明了,然而在开始触及到一些到该矢量元素的引用时,事情就会变得复杂。接下来就要解决这个问题!

读取矢量的元素

引用存储在矢量中某个值的方式有两种:经由索引,或使用 get 方法。在接下来的示例中,为讲得更清楚的原因,已经对从这些方法返回的值进行了注释。

下面清单 8-5 给出了访问矢量某个值的两种方式,即索引语法与 get 方法。

    let v = vec! [1, 2, 3, 4];

    let third: &i32 = &v[2];
    println! ("第三个元素为 {}", third);

    match v.get(2) {
        Some(third) => println! ("第三个元素为 {}", third),
        None => println! ("没有第三个元素。"),
    }

清单 8-5使用索引语法或 get 方法访问矢量中的某个元素

请留意这里的两个细节。首先,由于矢量是以从零开始的数字进行索引,因此这里使用了索引值 2 来获取那第三个元素。其次,这里是通过同时使用 &[],获取第三个元素的,这就给到一个引用变量,而使用带有作为参数传递索引的 get 方法,给到的却是个 Option<&T> 值。

Rust 提供这两种引用某个元素方式的原因在于,有了这两种方式,就可以在尝试使用某个超出了既有元素范围的索引值时,对程序此时的表现加以选择。比如,下面就来看看在有着一个五个元素的矢量,而随后尝试以两种技巧,来访问位于索引 100 处元素时,会发生什么事情,如下清单 8-6 中所示。

    let v = vec! [1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);

清单 8-6尝试访问包含五个元素矢量中索引 100 处的元素

在运行此代码时,由于第一种 [] 方式引用了不存在的元素,因此将导致程序死机。在有着某个对超出矢量末端的元素进行访问的尝试,而打算将程序崩溃掉时,那么用这种方式是最佳的。

而在传递给 get 方法的索引,位于矢量外部时,他就会返回不会程序死机的 None 值。在寻常情况下,就会时不时出现对超出矢量范围元素的访问时,就应使用这种方式。这时代码就将有如同第 6 章中所讨论的,处理 Some(&element)None 值的逻辑。比如,那个索引可以是来自某人输入的数字。在他们不小心输入了一个过大的数字时,程序就会得到一个 None 值,这时就可以告诉用户在当前矢量中有多少个项目,并给到他们又一次输入有效值的机会。比起由于输入错误而将程序崩溃掉,这将是更加用户友好!

当程序有了有效引用时Rust 的借用检查器,就会强制执行所有权与借用规则检查(在第 4 章讲到过),来确保该引用及全部其他的、到这个矢量内容的引用是有效的。请回顾那条表明了不能在同一作用域中,有着多个可变与不可变引用的规则。那条规则就适用于下面清单 8-7清单中有着一个到矢量首个元素的可变引用并尝试将一个元素添加到示例末尾。若同时在那个函数中尝试引用那个元素那么该程序就不会工作

    let mut v = vec! [1, 2, 3, 4, 5];

    let first: &i32 = &v[0];

    v.push(6);

    println! ("首个元素为:{}", first);

清单 8-7在保留到矢量某个条目的引用同时尝试将一个元素添加到该矢量

对此代码进行编译,将引发下面这个错误:

$ cargo run                     ✔ 
   Compiling vec_demo v0.1.0 (/home/peng/rust-lang/projects/vec_demo)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first: &i32 = &v[0];
  |                        - immutable borrow occurs here
5 | 
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 | 
8 |     println! ("首个元素为:{}", first);
  |                                 ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `vec_demo` due to previous error

清单 8-7 中的代码看起来似乎可以工作为何到矢量首个元素的引用会牵连到该矢量末尾的变化呢这个报错是由于矢量工作原理由于矢量是将他的那些值挨个放在内存中的那么将新元素添加到矢量末尾而在该矢量当前存储处没有足够场所来挨个放置全部这些元素时这时就会要求分配新内存并将那些旧有元素拷贝到新内存空间。在那种情况下到首个元素的引用就会指向已解分配内存deallocated memory。而正是这些 Rust 的借用规则,防止程序已这样的情形而告终。

请注意:更多有关 Vec<T> 类型的实现细节,请参考 Rust 专论The Rustonomicon

对矢量中那些值的迭代

要依次访问矢量中的各个元素,就要迭代全部元素,而非使用那些索引值,一次访问一个了。下面清单 8-8 展示了怎样使用 for 循环,来获取到一个 i32 矢量值中各个元素的不可变引用,并将这些元素打印出来。

    let v = vec! [100, 32, 57];

    for i in &v {
        println! ("{}", i);
    }

清单 8-8通过使用 for 循环对各个元素进行迭代,而打印出矢量中的每个元素

也可以为了对全部元素进行修改,而对可变示例中的各个元素,进行可变引用的迭代。下面清单 8-9 中的 for 循环,将把 50 添加到各个元素。

    let mut v = vec! [100, 32, 57];

    for i in &mut v {
        *i += 50;
    }

清单 8-9对实例中各个元素进行可变引用的迭代

要修改可变引用所指向的值,就必须使用 * 解引用操作符the * dereference operator在能够使用 += 运算符之前,获取到 i 中的那个值。在后面第 15 章的 “以解引用操作符,顺着指针找到值” 小节,就会讲到这个解引用操作符。

使用枚举存储多种类型

矢量只能存储同一类型的值。这就会不方便;显然是有需要存储不同类型条目清单的使用场景。幸运的是,枚举的那些变种,就是定义在同一枚举类型之下的,那么在需要某种表示那些不同类型元素的类型时,就可以定义并使用一个枚举!

好比说这里要从电子表格的某行,其中该行的一些列包含了整数,另一些列包含了浮点数,而其他列则包含了字符串,而要从这行获取到这些数据。那么就可以定义这样一个枚举,他的那些变种将保存这些不同值类型,而全部这些变种,就会被看着是同一类型:即为该枚举。随后就可以创建一个矢量,来保存那个枚举,进而最终保存了这些不同类型。下面清单 8-10 中就对此进行了演示。

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

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

清单 8-10定义一个 enum 来在矢量中存储不同类型的值

Rust 需要在编译时了解那个矢量中会有些什么类型,这样他就清楚存储该矢量的每个元素,所需要的内存堆上内存准确数量。同时必须显式声明该矢量中允许哪些类型。若 Rust 允许矢量保存任意类型,那么就会存在一个或多个类型,将引发在该矢量的元素上执行操作错误的可能。运用枚举加上 match 表达式,就意味着 Rust 将在编译时,确保所有可能情形都被处理,如同第 6 章中讨论的那样。

但若不清楚运行时程序会在矢量中收到的详尽类型集合那么这个枚举技巧就不会有用。相反这个时候就可以使用特质对象a trait object而在后面的第 17 章就会讲到特质对象。

既然这里已经讨论了使用矢量的一些最常用方式,那就一定要看看 API 文档,了解那些定义在 Vec<T> 上,由标准库所定义的全部有用方法。比如,除了 push 之外,pop 方法会移除并返回矢量的最后一个元素。下面就移步到下一个集合类型:String 吧!

使用 String 存储 UTF-8 编码的文本

在第 4 章中,就曾谈到过字符串,而现在则要深入审视他们。萌新 Rust 公民,通常会由于以下三个搅在一起的原因,而被字符串给卡住:作为 Rust 暴露各种可能错误的选择;相比于许多程序员意识到的复杂度,字符串是一种更具复杂度的数据结构;还有就是 UTF-8。在从别的语言转到 Rust 时,这些因素就会有以看起来有难度的方式,纠缠在一起。

由于字符串是作为字节集合,加上一些在将这些字节解析为文本时,提供有用功能的方法,这样来实现的,因此这里就在集合的语境中,来讨论字符串了。在本小节,将谈及在 String 类型上的那些所有集合都有的操作,比如创建、更新与读取等等。这里也会讨论 String 与其他集合的不同之处,即通过对比人类与机器解读 String 类型数据的不同之处,来搞清楚索引进到某个 String 有何等复杂。

何为 String

这里首先就要定义一下,字符串string 这个名词指的是什么。Rust 在其核心语言中,只有一种字符串类型,那就是字符串切片类型 str,该类型通常是以其被借用的形式 &str 而出现。在第 4 章中,就讲到过 字符串切片string slices,他们是到一些存储在各处的、以 UTF-8 编码的字符串数据的引用。

String 类型,则是由 Rust 标准库所提供而非编码进核心语言的一种可增长、可变、所有权被持有的、UTF-8 编码的字符串类型the String type, which is provided by Rust's standard library rather than coded into the core lanuage, is a growable, mutable, owned, UTF-8 encoded string type。在 Rust 公民提到 Rust 中的 “字符串” 时,他们可能指的既是 String,也可可能是字符串切片的 &str 类型,而不仅仅是这些类型其中之一。虽然这个小节很大部分讲的是 String,但在 Rust 标准库中,两种类型都有重度使用,且 String 与字符串切片,都是 UTF-8 编码的。

Rust 标准库还包含了一些其他字符串类型,比如 OsStringOsStrCStringCStr 等等。一些库代码箱则可提供到甚至更多的用于存储字符串数据的选项。发现这些名称都是以 StringStr 结尾的了吧?他们指向的都是是有所有权的与借用的变种,就跟先前所见到的 Stringstr 类型一样。比如,这些字符串类型就可存储不同编码或在内存中以不同方式表示的文本。本章中不会讨论这些其他字符串类型;请参阅 API 文档,了解更多有关如何使用他们,以及何时哪个是恰当的字符串类型的更多知识。

创建一个新的 String

Vec<T> 的许多同样操作,对 String 也是可用的,这里就以创建一个新字符串的 new 函数开始,如下清单 8-11 中所示。

    let mut s = String::new();

清单 8-11创建一个新的空 String

这行代码就创建了一个新的、名为 s 的空字符串,随后就可以将数据加载进这个空字符串了。通常,这里会有一些初始数据作为字符串的开头。为此,就要使用 to_string 方法,这个方法在所有实现了 Display 特质the Display trait正如字符串字面值这样的类型上都是可用的。下面清单 8-12 给出了两个示例。

    let data = "初始内容";

    let s = data.to_string();

    // 该方法同样直接工作于字面值之上
    let s = "初始内容".to_string();

清单 8-12使用 to_string 方法自字符串字面值创建出一个 String

此代码传教了一个包含 初始内容 的字符串。

这里也可以使用函数 String::from 来从字符串字面值创建 String。下面清单 8-13 众多的代码,与使用 to_string 的清单 8-12 中的代码等价。

    let s = String::from("初始内容");

清单 8-13使用 String::from 函数,从字符串字面值创建一个 String

由于字符串有相当多的用途,因此就可以使用字符串的众多不同的通用 API而赋予到很多选择。这些字符串通用 API 中的一些,可能看起来是重复的,但他们全都有他们的用处!就在这个示例中,String::fromto_string 两个函数完成的是同样的事情,那么选择哪个,就关乎代码风格与可读性了。

请记住字符串都是 UTF-8 编码的,因此就可以将任何编码恰当的数据,包含在字符串中,如下清单 8-14 中所示。

    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 hello = String::from("👋");

清单 8-14以不同语言在字符串中存储问候语

全部这些都是有效的 String 值。

更新 String

在将更多数据压入到其中时,String 可以增长大小,且就跟 Vec<T> 的内容一样,内容可以改变。此外,还可以方便地使用 + 运算符或 format! 宏,来连接一些 String 值。

使用 push_strpush,往 String 追加数据

通过使用 push_str 方法来追加一个字符串切片,就可以增大 String,如下清单 8-15 中所示的那样。

    let mut s = String::from("foo");
    s.push_str("bar");
    println! ("{}", s);

清单 8-15使用 push_str 方法将一个字符串切片追加到某个 String

在这两行之后,s 就会包含 foobar。由于这里并非真的想要取得那个参数的所有权,因此这个 push_str 方法取的是个字符串切片。而比如在下面 8-16 中的代码里,就打算在将 s2 的内容追加到 s1 后,能够对 s2 进行使用。

    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println! ("s2 为 {}", s2);

清单 8-16在将一个字符串切片的内容追加到某个 String 后再对其加以使用

若这个 push_str 方法取得了 s2 的所有权,那么这里就无法在最后一行打印其值。然而,这段代码正如预期那样运作了!

相比 push_str 方法,这个 push 方法则会取单个字符作为参数,并将其添加到 String。下面清单 8-17 就使用这个 push 方法,将字母 "l" 添加到了一个 String

    let mut s = String::from("lo");
    s.push('l');

清单 8-17使用 push 方法,将一个字符添加到某个 String

作为上面代码的结果,s 将包含 lol

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

通常,会想要将两个既有字符串合在一起。完成此操作的一种方式,就是使用 + 运算符,如下清单 8-18 中所示。

    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2;  // 请注意这里的 s1 已被迁移,而不再能被使用了

清单 8-18运用 + 运算符来将两个 String 值结合为一个新的 String

这个字符串 s3 将包含 Hello, world!s1 在该字符串加法之后不再有效的原因,以及这里使用到 s2 引用的原因,与这里使用 + 运算符时,被调用的那个方法的签名有关。这个 + 运算符使用了 add 方法,而该方法的签名使用了 add 方法,add 方法的签名,看起来与下面的类似:

fn add(self, s: &str) -> String

在标准库中,就会看到使用泛型定义的 add 函数。这里已将泛型的那些参数,用具体类型进行了替换,即在以 String 类型值调用是所发生的。在第 10 章就会讨论泛型。这个函数签名,提供了了解那个 + 运算符棘手之处所需的线索。

首先,这里的 s2 有个 &,表示这里这里正将第二个字符串的 引用,添加到第一个字符串。这是由于 add 函数中的那个 s 参数的原因:这里只能将一个 &str 添加到某个 String;这里是无法将两个 String 相加在一起的。不过稍等一下 -- &s2 的类型是 &String,而非在 add 函数的第二个参数中所指明的 &str。那为何清单 8-18 会编译呢?

这里之所以能在到 add 的调用中使用 &s2 的原因,在于编译器可将那个 &String 参数,强制转换&str 类型。在调用 add 方法时Rust 使用了 解引用强制转换deref coercion 特性,在这里该特性就将 &s2 转换为了 &s2[..]。在第 15 章就将深入讨论这个解引用强制转换。由于 add 方法并未占据那个 s 参数的所有权,因此 s2 在此运算之后,仍将有效。

其次,这里可以看到,在该方法签名中,由于 self 没有 &,那么 add 就取得了 self 的所有权。这就意味着清单 8-18 中的 s1 将被迁移到那个 add 调用中,并在那之后便不再有效。这样看来,尽管 let s3 = s1 + &s2; 这个语句看起来将同时拷贝这两个字符串,并创建一个新的字符串,不过此语句实际上是要取得 s1 的所有权,追加 s2 内容的一份拷贝,进而随后返回该运算结果的所有权。也就是说,看起来这行语句构造了很多拷贝,但并没有;这样的实现比拷贝更为高效。

在需要连接多个字符串时,这个 + 运算符的行为就变得笨拙了:

    let s1 = String::from("tic");
    let s2 = String::from("toc");
    let s3 = String::from("toe");

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

此处 s 将为 tic-toc-toe。由于有些全部的 +" 字符,因此就难于看清发生了什么。对于较复杂的字符串合并,可这个 format! 宏:

    let s1 = String::from("tic");
    let s2 = String::from("toc");
    let s3 = String::from("toe");

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

这段代码同样把 s 设置为了 tic-toc-toe。这个 format! 宏与 println! 宏的运作类似,而与将输出打印到屏幕不同,他会将结果内容,以一个 String 加以返回。使用 format! 这个版本的代码,读起来容易得多,且由于 format! 宏所生成的代码,使用的是引用,那么这个调用就不会占据任何一个其参数的所有权。

索引到 String 内部

再许多其他编程语言中,经由通过索引而引用字符串中的一些单独字符,都是有效且常见的操作。不过在 Rust 中,当尝试使用索引来访问某个 String 的一些部分时,就会收到错误。请考虑下面清单 8-19 中的无效代码。

    let s1 = String::from("hello");
    let h = s1[0];

清单 8-19尝试在某个字符串上使用索引语法

该代码将引发下面的错误:

$ cargo run
   Compiling string_demo v0.1.0 (/home/peng/rust-lang/projects/string_demo)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `string_demo` due to previous error

这个报错和提示讲清了缘由Rust 的字符串不支持索引。但为什么不支持呢?要回到这个问题,就要探讨一下 Rust 是怎样在内存中存储字符串的。

内部表示

String 是对 Vec<u8> 的一种封装a String is a wrapper over a Vec<v8>)。下面来看看清单 8-14 中,那里的一些以 UTF-8 良好编码的示例字符串。首先是这个:

let hello = String::from("Hola");

在此示例中,len 将为 4,这表示这个存储着字符串 “Hola” 的矢量长度为 4 个字节。这些字母在以 UTF-8 编码时,每个占用 1 个字节。而接下来的这行,就会惊讶到你了。(请注意这个字符串是以大写西里尔字母 Ze 开头,而非阿拉伯数字 3。)

    let hello = String::from("Здравствуйте");

在被问及这个字符串有多长时,你可能会讲是 12。事实上Rust 的答案是 24:由于那个字符串中的每个 Unicode 标量值,都有占用 2 字节的存储,故那就是以 UTF-8 编码 Здравствуйте 所用占用的字节数。由于这个缘故,到该字符串的那些字节的所以,就不会总是对应到某个有效的 Unicode 标量值了。为对此加以演示,请设想下面这段无效的 Rust 代码:

    let hello = String::from("Здравствуйте");
    let answer = &hello[0];

这里当然清楚 answer 将不会是那第一个字母 З。在以 UTF-8 编码时,З 的第一个字节是 208,同时第二个字节为 151,因此看起来 answer 事实上应该是 208,但 208 本身并不是个有效的字符。在用户要求该字符串的首个字母时,返回 208 就不会是他们所想要;然而,那却是 Rust 在字节索引 0 处有的唯一数据了。即使字符串只包含拉丁字母,用户也通常不想要那个返回的字节值:即便 &"hello"[0] 是返回了字节值的有效代码,他也会返回 104,而非 h

那么答案就是为避免返回一个不期望的值以及避免引发一些可能无法立即发现的程序错误Rust 就根本不会编译此代码,并阻止了在开发过程早期阶段的这些误解。

字节、标量值与字素簇我的天Bytes and Scalar Values and Grapheme Clusters! Oh My!

有关 UTF-8 的另一点,即为从 Rust 视角看待字符串,事实上有三种相关方式:视为字节、标量值与字素簇(而字素簇则是与我们称之为 文字/letters 的东西的最接近的事物了)。

在看到以梵文字书写的印地语词汇 "नमस्ते" 时,这个词汇是以看起来像下面这样的一些 u8 类型值的矢量存储的:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

这是 18 个字节,也是计算机最终存储该数据的方式。而在将他们视为 Unicode 的标量值,即 Rust 的 char 类型时,则这些字节看起来是这样的:

['न', 'म', 'स', '्', 'त', 'े']

这里就有了六个 char 值了但其中第四与第六个并不是文字letters他们是自身并无意义的变音符号。最后在将这些 Unicode 标量值视为字素簇时,就得到了人类所称呼的、四个构成了那个印地词语的四个文字:

["न", "म", "स्", "ते"]

Rust 提供了解析计算机存储的原始字符串数据的数种不同方式,因此各个程序就可以选择他所需的解析方式,这与该数据为何种人类语言无关。

Rust 不允许索引进入 String 来获取某个字符的终极原因,即是索引操作,被认为总是消耗固定的时间(即 O(1))。但由于 Rust 将不得不从开头遍历到索引位置,来确定那里有多少个有效字符,由此对于在 String 上执行索引操作,所消耗的时间是无法确保持续一致的。

对字符串进行切片操作

Slicing Strings

由于字符串索引操作的返回值类型不明朗:可能是字节值、字符、字素簇,或者字符串切片,因此索引到字符串中去,通常是个糟糕的主意。而在确实需要使用索引来创建字符串切片时,那么 Rust 就要求提供更具体的索引。

与使用带有单个数字的 [] 相比,可使用带有范围的 [],来创建包含一些特定字节的字符串切片:

    let hello = String::from("Здравствуйте");

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

这里的 s 将是个包含该字符串头 4 个字节的 &str。早先曾提到过,每个的这些字符都是 2 字节,那么这就意味着 s 将为 Зд

而在尝试使用类似 &hello[0..1] 这样的操作来对某个字符的那些字节的一部分进行切片时Rust 就会在运行时,以与在矢量中访问无效索引时同样的方式终止运行:

thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`'...

由于使用范围来创建出字符串切片这样的操作,可能将程序崩溃掉,因此进行这样操作时应小心谨慎。

对字符串进行迭代的一些方法

在字符串的各个片段上进行操作的最好方式,就是显示地指明是要字符还是字节。对于单独的 Unicode 标量值,就使用 chars 方法。在 नमस्ते 上调用 chars,就会分理出并返回六个类型 char 的值来,进而就可以对结果进行迭代,而访问到各个元素:

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

该代码将打印下面的东西:







此外,bytes 方法返回的则是各个原始字节,对与特定领域,这方法可能正好:

    for b in s.bytes() {
        println!("{}", b);
    }

该代码将打印出构成这个 String 的 18 个字节来:

224
164
168
224
164
174
224
164
184
224
165
141
224
164
164
224
165
135

不过要确保记住,有效的 Unicode 标量值可能有多余 1 个字节组成。

而从字符串获取字素簇则是复杂的,因此这项功能并未由标准库提供。在需要该功能时,在 crates.io 上有一些可用的代码箱。

字符串并不简单

总的来说字符串是复杂的。不同编程语言在以何种程度将这种复杂度呈现给编程者上做出了不同的选择。Rust 选择了将正确处理 String 数据,作为所有 Rust 程序的默认行为,这就意味着 Rust 程序员就必须在处理 UTF-9 数据时,要提前投入更多思考。这种权衡暴露了相较于其他编程语言,更多的字符串复杂度,但这防止了在软件开发生命周期后期,将涉及到的非 ASCII 字符的错误处理。

接下来就要切换到些许不那么复杂的事情:哈希图!

在哈希图中存储关联的键与值

最后一个常用集合,就是 哈希图hash map 了。类型 HashMap<K, V>,存储了使用确定如何将这些类型为 K 的键,与类型为 V 的值放置于内存中的 散列函数a hashing function,而建立的键与值映射关系。许多编程语言都支持这种数据结构,不过他们通常使用了别的名称,比如哈希、图、对象、哈希表、字典,或者关系数组,这里仅举几例。

在打算不使用如同矢量中那样的索引,而是通过使用可为任意类型的键,来查找数据时,哈希图就是有用的了。比如在某个游戏中,就可在各个键为战队名字,值为战队得分的哈希图中,保持对这些战队得分的追踪。在给到战队名字后,就可获取到他的得分。

本小节将审视哈希图集合数据结构的基本 API不过有数不尽的哈希图好处都是隐藏在由标准库所定义、HashMap<K, V> 上的那些函数里。与往常一样,请查看标准库文档,来了解更多信息。

创新一个新的哈希图

创建空哈希图的一种方式,即为使用 new 方法,与使用 insert 方法进行元素添加。在下面清单 8-20 中,就要对两个名字分别为 蓝队Blue黄队Yellow 的战队得分,进行追踪。蓝队以 10 分开始,而黄队以 50 分开始。

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("蓝队"), 10);
    scores.insert(String::from("红队"), 50);

清单 8-20创建一个新的哈希图并插入一些键与值

请注意这里需要首先 use 这个来自标准库集合部分的 HashMap。三个常用集合中,这个是最少用到的,因此他就没有包含在那些 Rust 程序前奏the prelude自动带入的特性里。哈希图受标准库的支持也较少比如标准库中就没有内建的构造哈希图的宏。

与矢量一样,哈希图是将他们的数据存储在内存堆上的。示例中的这个 HashMap 键的类型为 String,值的类型为 i32。与矢量类似哈希图都是同质的homogeneous所有键都必须有着同样类型且所有值也必须有着同样类型。

另一种构造哈希图的方式,即为通过使用迭代器,与元组矢量上的 collect 方法,而元组矢量中各个元组,则是由一个键与其值组成。在 第 13 章的 “使用迭代器处理一系列的条目” 小节,就会深入到迭代器的有关细节及其相关方法。collect 方法会将数据收集到包括 HashMap 在内数种集合类型中。比如,在将战队名字与初始得分放在两个单独矢量中时,那么就可以使用 zip 方法,来创建一个元组的迭代器,其中 Blue 就会与 10 结对,并以此类推。随后就可以使用 collect 方法类将那个元组迭代器,转换到一个哈希图了,如下清单 8-21 中所示。

    use std::collections::HashMap;

    let teams = vec! [String::from("蓝队"), String::from("红队")];
    let initial_scores = vec! [10, 50];

    let mut scores: HashMap<_, _> = teams
        .into_iter()
        .zip(initial_scores.into_iter())
        .collect();

清单 8-21从战队清单与得分清单创建一个哈希图

由于有可能 collect 进到许多不同数据结构,而除非有指明,那么 Rust 就不清楚所想要的是何种数据结构,因此这里的类型注解 HashMap<_, _> 是需要的。不过对于键与值类型的泛型参数,这里使用了下划线(_),而 Rust 可基于那两个矢量中数据的类型,而推断出该哈希图的类型。在上面清单 8-21 中,键的类型将是 String,而值类型将为 i32,就跟清单 8-20 中的一样。

哈希图与所有权

对于实现了 Copy 特质the Copy trait 的那些类型,比如 i32,那么他们的值就被拷贝到哈希图里。而对于像是 String 这样的被持有值,他们的所有值就会被迁移,进而哈希图会成为这些值的所有者,如同在下面清单 8-22 中所演示的那样。

    use std::collections::HashMap;

    let field_name = String::from("喜好颜色");
    let field_value = String::from("蓝色");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);

    println! ("{}, {}", field_name, field_value);
    // 到这里 field_name 与 field_value 就无效了,请尝试对
    // 他们进行使用,并看看会收到什么样的编译器错误!

清单 8-25对一旦被插入到哈希图键与值就被哈希图持有的展示

$ cargo run                                                                ✔ 
   Compiling hashmap_demo v0.1.0 (/home/peng/rust-lang/hashmap_demo)
error[E0382]: borrow of moved value: `field_name`
  --> src/main.rs:10:25
   |
4  |     let field_name = String::from("喜好颜色");
   |         ---------- move occurs because `field_name` has type `String`, which does not implement the `Copy` trait
...
8  |     map.insert(field_name, field_value);
   |                ---------- value moved here
9  | 
10 |     println! ("{}, {}", field_name, field_value);
   |                         ^^^^^^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0382]: borrow of moved value: `field_value`
  --> src/main.rs:10:37
   |
5  |     let field_value = String::from("蓝色");
   |         ----------- move occurs because `field_value` has type `String`, which does not implement the `Copy` trait
...
8  |     map.insert(field_name, field_value);
   |                            ----------- value moved here
9  | 
10 |     println! ("{}, {}", field_name, field_value);
   |                                     ^^^^^^^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hashmap_demo` due to 2 previous errors

在对 insert 调用而导致 field_namefield_value 被迁移到那个哈希图中之后,这里就无法使用这两个变量了。

而在将到值的引用插入进哈希图时,这些值就不会被迁移进哈希图。对于这些引用所指向的值,则只要哈希图尚有效,那么他们便一直有效。在后面第 10 章的 “以声明周期对引用有效性进行验证” 小节中,将进一步讲到这些问题。

访问哈希图中的值

通过将某个值的键提供给 get 方法,就可以从哈希图中获取到该值来,如下清单 8-23 中所示。

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("蓝队"), 10);
    scores.insert(String::from("红队"), 50);

    let team_name = String::from("蓝队");
    let score = scores.get(&team_name);

清单 8-23对存储在哈希图中的蓝队得分进行访问

这里,score 将具有与蓝队关联的取值,同时结果将为 Some(&10)。由于 get 方法返回的是 Option<&V> 类型,因此该结构是封装在 Some 中的;当在哈希图中没有那个键的值时,get 就会返回 None。程序就需要以在第 6 章中所讲到的那些方式之一,对这个返回的 Option 加以处理。

可以与对矢量进行迭代的类似方式,即使用 for 循环,对哈希图中的各个键/值对加以迭代:

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("蓝队"), 10);
    scores.insert(String::from("红队"), 50);

    for (key, value) in &scores {
        println! ("{}, {}", key, value);
    }

此代码将以任意顺序,打印出各个键值对:

蓝队, 10
红队, 50

更新哈希图

虽然键值对数目是可增长的,但每个键在某个时刻,只能有一个与其关联的值。在要修改哈希图中的数据时,就必须定下来怎样处理某个键已经指定了值的情形。可以将原有值替换为新值,而完全忽略原有值。可以保留原有值而忽视掉新值,而在键 尚未 有值时,仅将新值进行添加。或者可以将原有值与新值结合在一起。那就来看看怎样处理这些各种情况!

重写某个值

在将一个键与一个值插入到哈希图,并在随后再插入同样键与一个不同值,那么与那个键关联的值就会被替换掉。尽管下面清单 8-24 中的代码调用了 insert 两次,由于这里两次都是插入 “蓝队” 的值,因此那个哈希图将只包含一个键/值对。

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

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

清单 8-24以特定键对存储的某个值进行替换

此代码打印 {"蓝队": 25}。原先的值 10 已被重写。

在键无值时而仅插入值

检查某个特定键是否有值,并在其没有值时,为其插入一个值,这样的情况是常见的。为此哈希图有个特别的、名为 entry 的 API他会取要检查的键作为参数。entry 方法的返回值,是个叫做 Entry 的枚举,表示一个可能存在或可能不存在的值。那么下面就假设这里想要检查黄队的键有无与其关联的值。在其没有关联值时,就想要插入值 50,对于蓝队也同样操作。运用这个 entry API代码看起来就如同下面清单 8-25 一样。

    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("蓝队"), 10);

    scores.entry(String::from("黄队")).or_insert(50);
    scores.entry(String::from("蓝队")).or_insert(50);

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

清单 8-25使用 entry 方法仅在键尚无值时插入值

Entry 类型上的 or_insert 方法,被定义为在键存在时,返回相应 Entry 键的值的可变应用,而若键不存在,那么就会将其参数作为该键的新值插入,并返回到该新值的可变引用。此技巧相比于咱们自己来编写该逻辑,要清楚得多,此外,在以借用规则检查器进行检查时,进行得也更好。

运行清单 8-25 中的代码,将打印出 {"黄队": 50, "蓝队": 10}。其中首次到 entry 的调用,由于黄队尚无值,因此就会插入黄队的键与值 50。而第二个到 entry 的调用,因为蓝队已经有了值 10,因此就不会修改这个哈希图。

基于原有值而对某个值进行更新

哈希图的另一个常见使用情形,即是查找某个键的值,并随后根据原有值对其更新。举例来说,下面清单 8-26 给出了对在一些文字中各个词出现次数进行计数的代码。这里使用了一个以词汇作为键的哈希图,并对值进行增加来追踪已见到那个词了多少次。而在首次见到某个词时,就会首先插入值 0

    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);

清单 8-26使用存储词汇与计数的哈希图对词汇出现次数进行计数

此代码将打印 {"wonderful": 1, "world": 2, "hello": 1}。这个 split_withespace 方法会对 text 中的那个值的、以空格分隔的子切片进行迭代。而那个 or_insert 方法,返回的时到指定键的值的可变引用(&mut V)。这里是将那个可变引用存储在变量 count 中的,因此为了对那个值进行赋值,这里就必须首先使用星号(*)对 count 解引用。在那个 for 循环结尾,该可变引用就超出了作用域,因此所有这些修改都是安全的,同时为借用规则所允许。

散列函数Hashing Functions

默认情况下,HashMap 使用了一个可提供抵抗哈希表有关的拒绝服务攻击的、名为 SipHash 的散列函数(参见:https://en.wikipedia.org/wiki/SipHash)。这并非可用的最快散列算法,不过这种为了更好安全性,而在性能上的舍弃,是值得的。在对自己代码进行推敲,而发现这个默认散列函数对于自己目的太慢时,是可以通过指定别的哈希器,切换到另一函数的。 哈希器a hasher 是一种实现了 BuildHasher 特质the BuildHasher trait的类型。在第 10 章中就会谈到特质及其如何实现。不必从头实现自己的哈希器;crates.io 就有由其他 Rust 使用者共享的、提供对许多常用散列算法进行实现的哈希器的库。

本章小结

矢量、字符串与哈希图,在程序中需要存储、访问与修改数据时,就会提供大量必要功能。下面就是一些现在应有能力解决的练习:

  • 给定一个整数清单,请使用矢量,并返回这些数的中位数(即在这些数排序后,位于中间位置的值)与众数(最常出现的那个值;此时哈希图将有帮助);
  • 将字符串(英语)转换为拉丁语式的结尾。每个词汇的第一个常量,会被迁移到该词汇的末尾,同时会加上 “ay”那么 “first” 就变成了 “irst-fay” 了。以元音开头的词汇,则会将 “hay” 添加到词汇末尾(比如 “apple” 就成了 “apple-hay”。请牢记有关 UTF-8 编码的那些细节!
  • 运用哈希图与矢量,创建一个实现程序用户把员工名字添加到某公司里的某个部门的文本接口。比如,“添加 Sally 到工程部” 或 “添加 Amir 到销售部”。随后让用户获取到某个部门全体人员清单,或以部门字母排序的公司全体人员名单。

标准库 API 文档对矢量、字符串及哈希图有着的、对这些练习将有帮助的方法都有说明!

接下来就要进入到一些其中某些操作可能失败的程序,那么现在就是讨论错误处理的最佳时机。下一章就要来完成对错误处理的讨论了!