当前位置:   article > 正文

Rust 基础(三)_option

option

六、枚举和模式匹配

在本章中,我们将研究enumeration,也称为enum
枚举允许通过枚举可能的变体来定义类型。
首先,将定义和使用枚举来展示枚举将意义(meaning )与数据(data)一起编码。接下来,将探索一个特别有用的枚举,称为Option,它表示一个值可以是有值也可以是无值。然后,将了解match 表达式中的模式匹配( pattern matching)如何使为枚举的不同值运行不同的代码变得容易。最后,将介绍if let构造如何成为在代码中处理枚举的另一个方便而简洁的习惯用法。

6.1 Defining an Enum

枚举提供了一种表示值是一组可能值中的一个的方法。

任何IP地址都可以是版本4或版本6的地址,但不能同时是两者。IP地址的这个属性使得枚举数据结构非常合适,因为枚举值只能是它的所有值中的一个。版本4和版本6的地址基本上仍然是IP地址,因此当代码处理适用于任何类型的IP地址的情况时,应该将它们视为同一类型。

可以通过定义一个IpAddrKind枚举并列出IP地址可能的类型V4V6来在代码中表达这个概念。

enum IpAddrKind {
    V4,
    V6,
}
  • 1
  • 2
  • 3
  • 4

IpAddrKind现在是一个自定义数据类型,可以在代码的其他地方使用它。

6.1.1 Enum Values

可以像这样分别创建IpAddrKind的两个变体(variants )的实例:

    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;
  • 1
  • 2

注意,枚举的变体位于其标识符命名名称空间之下,使用双冒号将两者分隔开。这很有用,因为现在IpAddrKind::V4IpAddrKind::V6的值都具有相同的类型:IpAddrKind

fn route(ip_kind: IpAddrKind) {}
  • 1

可以用任何一种变体调用这个函数称:

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
  • 1
  • 2

目前没有方法存储实际的IP地址数据;只知道它是什么类型的。用的结构体来解决这个问题。

    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

现在已经使用了一个结构来将kindaddress捆绑在一起,因此现在变体与该值相关联。
然而,仅仅使用enum的概念是更简洁的:而不是一个结构体内部的enum,我们可以直接将数据放入每个enum变体。新定义enum: V4V6变体都有相关的String 值:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

直接将数据附加到enum的每一种变体中,因此不需要额外的结构。这里**也更容易看到enums如何工作的细节:定义的每个enum变量的名称也成为构造enum实例的函数。
也就是说,IpAddr:V4()是一个函数调用,它有一个String参数的,并返回IpAddr类型的实例。我们自动得到定义enum的构造函数。

使用enum还有另一个优点:每个变体都可以有不同类型和数量的相关数据。版本4的IP地址将会有四个数字,它们的值在0到255之间。如果想将V4地址存储为4个u8值,但仍然以一个字符串值表达V6地址,我们就不能使用结构体。Enums 很容易处理这件事:

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

标准库有一个可以使用的定义IpAddr:

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

该代码说明了可以在enum变体中放置任何类型的数据:例如,字符串、数字类型或结构体。甚至可以包括另一个enum !此外,标准的库类型通常并不比你想到的复杂得多。

注意,尽管标准库包含了IpAddr的定义,但仍然可以创建并使用自己的定义,因为没有将标准库的定义引入到我们的范围中。在第7章中将更多地讨论引入类型。

另一个例子:这一个有各种各样的类型嵌入在它的变体中。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这个enum有四个不同类型的变体:

  • Quit没有与它相关的数据。
  • Move 有像结构体一样的命名字段。
  • Write 包括一个字符串。
  • ChangeColor包含三个i32值。

类似于定义了不同类型的struct,但不使用struct关键字,所有的变体都是在Message类型下组合在一起的。下面的结构体可以持有前面enum变体所持有的相同数据:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

但是,如果使用不同的结构,每个结构都有自己的类型,那么就不能很容易地定义一个函数来使用这些信息,而Message 枚举,这些是一种类型。

enumsstructs之间有一个相似之处:就像能够使用impl来定义结构体的方法一样,也可以在enums上定义方法。

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

让来看看标准库中的另一个enum,它是非常常见和有用的:Option

6.1.2 The Option Enum and Its Advantages Over Null Values

Option是标准库定义的另一个enumOption类型编码了一个非常常见的场景,在这个场景中,一个值可以是什么,或者什么都不是

例如,如果您求包含item的列表的第一个,将得到一个值。如果请求一个空列表的第一个,那么你什么也得不到。在类型系统方面表达这个概念意味着编译器可以检查您是否处理了您应该处理的所有情况;这个功能可以防止在其他编程语言中非常常见的bug。

Rust 没有许多其他语言的nullnull值意味着没有值。在带有null的语言中,变量通常可以在两个状态中的一个状态:nullnot-null

null的问题是,如果试着用null值作为非空值,会得到某种错误。因为这个nullnot-null属性是普遍存在的,所以很容易产生这种错误。

然而,null试图表达的概念仍然是有用的:null是当前一个无效或因某种原因缺少的值。

Rust 没有null,但它确实有一个enum,它可以编码一个值是存在,还是缺少。这个enumOption<T>,它由标准库定义:

enum Option<T> {
    None,
    Some(T),
}
  • 1
  • 2
  • 3
  • 4

这个Option<T> enum是非常有用的,它是预包含的;不需要明确地将它引入。它的变体甚至也预包含了:使用Some None时,可以没有前缀 Option::。但,Option<T>仍然是一个普通的enum,Some(T) None仍然是Option<T>的变体。
<T>语法是一个泛型类型参数(generic type parameter),我们将在第10章中详细介绍泛型(generics )。

目前,需要知道的是,<T>意味着Optionenum的Some 变体可以保存任何类型的数据,而每一个用于T位置的具体类型都使整体Option<T>类型为不同类型。

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

some_number的类型是Option<i32 >some_char 的类型是Option<char>,这是两种不同的类型。Rust可以推断出这些类型,因为在某些变体中指定了一个值。对于absent_number,Rust要求我们注释全局选项类型:,只看一个无值,编译器不能推断对应的一些变体的类型。在这里,告诉Rust,absent_number的类型是Option<i32 >

当有一个Some 值时,知道一个值是存在的,它的值在Some 里。当有一个None 值时,在某种意义上,它意味着与null相同的东西:我们没有一个有效的值。那么为什么Option<T>null好呢?

简而言之,因为Option<T>T(T可以是任何类型)是不同的类型,编译器不会让我们使用一个Option<T>值,作为一个有效的值。例如,此代码不会编译,因为它试图将i8Option<i8>相加:

    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
  • 1
  • 2
  • 3
  • 4

在这里插入图片描述
实际上,这个错误消息意味着Rust不理解如何添加i8Option<i8>,因为它们是不同的类型。当我们有一个类似Rust中i8类型的值时,编译器将确保我们总是有一个有效值。我们可以放心地继续进行,而不必在使用该值之前检查是否为空。只有当我们有一个Option<i8>(或我们正在处理的任何类型的值)时,我们才需要担心可能没有值,编译器将确保我们在使用该值之前处理这种情况。

换句话说,必须先将Option<T>转换为T,然后才能对其执行T操作。一般来说,这有助于抓住null最常见的问题之一:假设某些东西不是空的,而实际上是空的。

消除错误地假设一个非空值的风险可以帮助您对代码更加自信。为了得到可能为空的值,必须通过使该值的类型显式地转换成Option<T>。然后,当使用该值时,需要在值为空时显式地处理该情况。任何不是Option<T>的类型的值,可以安全地假设值不为空。这是一个深思熟虑的设计决定,以限制空的渗透,增加Rust的安全性。
那么,当有一个类型为Option<T>的值时,如何将T值从一个Some 变量中提取出来? Option<T>类型有很多方法 its documentation

一般来说,为了使用一个Option<T>值,需要拥有处理每个变体的代码。

match表达式是一种控制流构造,它在使用enums时就会实现这一点:它将运行不同的代码,这取决于它所拥有的enum的哪个变体,并且该代码可以使用匹配值内的数据。

6.2 The match Control Flow Construct

Rust有一个非常强大的控制流构造,称为match,它允许将一个值与一系列模式进行比较,然后根据哪些模式匹配执行代码模式可以由字面值、变量名、通配符和许多其他的东西组成;第18章涵盖了所有不同的模式。match的力量来自于模式的表达性,以及编译器确认所有可能的情况都被处理的事实。

值在匹配通过的每个模式,在第一个匹配到的模式中,值落到关联的代码块中。

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

value_in_cents 函数中分解match 首先,列出match 关键字,然后是一个表达式,在这个例子中是值coin。这似乎与使用的if表达式非常相似,但有一个很大的不同:if表达式需要返回一个布尔值,但是在这里,它可以返回任何类型

接下来是match 的 arm。arm有两个部分:一个模式和一些代码。这里的第一个arm有一个模式是值Coin::Penny, 然后是=>操作符,将模式和代码分开。这个例子中的代码就是值1。每个arm都用逗号隔开。

match 表达式执行时,它将结果值与每个arm的模式进行比较。如果一个模式与值相匹配,则执行与该模式相关的代码。如果这种模式与值不匹配,则执行将继续到下一个arm。

与每个arm关联的代码是一个表达式,匹配arm的表达式的结果值是返回到整个match 表达式的值

如果匹配的arm代码很短,通常不会使用大括号,如果想在匹配臂中运行多个代码行,则必须使用大括号,然后在arm后面的逗号是可选的

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

6.2.1 绑定到值的模式

arm 的另一个有用特性是:可以绑定匹配模式的值部分这就是我们如何从enum变体中提取值的方法。

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

在本代码的match 表达式中,添加一个变量state,到匹配变体Coin::Quarter中。当Coin::Quarter 匹配时,变量state将与QuarterUsState的值挂钩。然后就可以在代码中使用state,

6.2.2 Matching with Option<T>

工作方式是一样的

    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

在许多情况下,match enum绑定到一起是有用的。将看到大量的这种模式的Rust代码:匹配枚举,将变量绑定到其中的数据,然后基于它执行代码。一开始有点棘手,但一旦你习惯了,你就会希望所有的语言都拥有。它一直是用户的最爱。

6.2.3 匹配是详尽的(Matches Are Exhaustive)

还需要讨论match的另一个方面: arm的模式必须涵盖所有可能性。考虑一下这个版本的plus_one函数,它有一个bug,无法编译:

    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5

没有处理None情况,
在这里插入图片描述
Rust知道我们没有涵盖所有可能的情况,甚至知道我们忘记了哪个模式!Rust中的匹配是穷尽的:为了使代码有效,我们必须穷尽每一种可能性。特别是在Option<T>的情况下,当Rust防止我们忘记显式处理None情况时,它保护我们不假设我们有一个可能为null的值,从而使前面讨论的十亿美元的错误不可能发生。

6.2.4 Catch-all模式和_占位符

使用枚举,还可以对一些特定值采取特殊操作,但对所有其他值采取一个默认操作。

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

对于前两个arm,模式是文字值3和7。对于覆盖所有其他可能值的最后一个arm,模式是命名为other的一个变量。为other arm 运行的代码通过将该变量传递给move_player函数来使用它。

尽管没有列出u8可能具有的所有值,但这段代码仍然可以编译,因为最后一个模式将匹配没有特别列出的所有值。这种 catch-all模式满足了匹配必须是详尽无遗的要求。注意,必须把 catch-all放在最后,因为模式是按顺序计算的。如果我们把 catch-allarm放得更早,其他arm就永远跑不动了,所以如果在 catch-allarm之后加arm,Rust就会警告我们!

Rust还有一个模式,当想要一个 catch-all的模式,但不想使用 catch-all模式中的值时,我们可以使用:_是一个特殊的模式,它匹配任何值,但不绑定到该值。这告诉Rust我们不打算使用该值,因此Rust不会警告有未使用的变量。

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

最后,将再一次改变游戏规则,所以如果你摇出3或7以外的点数,那么在你的回合中不会发生其他事情。我们可以通过使用unit 值作为_ arm的代码来表示:

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

现在,将转到if let语法,它在match表达式有点冗长的情况下非常有用。

6.3 使用if let的简洁控制流

if let语法允许将iflet组合成一种不那么详细的方式来处理:匹配一个模式的值,而忽略其余的值。考虑以下程序,它匹配config_max变量中的Option<u8>值,但只希望在该值为Some变量时执行代码。

    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {}", max),
        _ => (),
    }
  • 1
  • 2
  • 3
  • 4
  • 5

可以用if let用一种更简短的方式来写它。

    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {}", max);
    }
  • 1
  • 2
  • 3
  • 4

if let语法用=将模式和表达式隔开。它与match 的工作方式一样,表达式给了match ,模式是match 的第一个arm。在这种例子中,模式是Some(max),max绑定到其中的值。然后可以在if let的body中使用max,就像在相应的匹配arm中使用max一样。如果该值与模式不匹配,那么if let 中的代码不运行。

使用 if let 意味着编写更少代码,更少的缩进和更少的样板代码。然而,这样会失去 match 强制要求的穷尽性检查matchif let 之间的选择依赖特定的环境以及增加简洁度和失去穷尽性检查的权衡取舍。

换句话说,可以考虑 if let作为match语法糖,当值与一个模式匹配时运行代码,然后忽略所有其他值。

We can include an else with an if let.

    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {:?}!", state),
        _ => count += 1,
    }
  • 1
  • 2
  • 3
  • 4
  • 5

we could use an if let and else expression like this:

    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {:?}!", state);
    } else {
        count += 1;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

如果程序使用match逻辑太过冗长,那就记住 if let

七、用包(package)、Crate和模块(Module)管理增长的项目

到目前为止,编写的程序已经在一个文件中的一个模块中了。随着项目的发展,应该将代码拆分为多个模块,然后将其分成多个文件一个包(package)可以包含多个二进制crate(binary crates),可选的一个库crate(library crate当一个包增长时,可以将部分提取到单独的crate中,从而成为外部依赖项。这一章涵盖了所有这些技术。对于非常大的项目,包括一系列共同发展的相关包,Cargo提供工作区,“Cargo Workspaces

一个相关的概念是作用域(scope):代码所写的嵌套上下文有一系列名称,它被定义为“作用域”。“当阅读、编写和编译代码时,程序员和编译器需要知道特定名称指的是某个特定地方的变量、函数、结构、enum、模块、常量或其他项,以及该项的意思。您可以创建作用域并更改作用域内或作用域外的名称。不能在相同的范围内拥有两个相同名称的项;工具可以解决名称冲突。

Rust有许多特性,允许管理代码的组织,包括哪些细节被公开,哪些细节是私有的,以及程序中的每个范围内的名称。这些功能,有时统称为模块系统(module system),包括:

  • Packages :A Cargo feature that lets you build, test, and share crates
    一个Cargo特性,允许您构建、测试和共享crate
  • Crates: A tree of modules that produces a library or executable
    生成库或可执行文件的模块树(crate可以包含一个或多个模块)
  • Modules and use :Let you control the organization, scope, and privacy of paths
    允许您控制路径的组织、作用域和私密性
  • Paths: A way of naming an item, such as a struct, function, or module
    命名项(如结构体、函数或模块)的路径

在本章中,将介绍所有这些特性,讨论它们是如何交互的,并解释如何使用它们来管理scope。

7.1 Packages and Crates

crate 是Rust编译器一次考虑的最小数量的代码。即使运行rustc而不是Cargo,并传递一个源代码文件,编译器认为该文件为一个cratecrate可以包含模块,模块可以在其他文件中定义,这些文件被编译成crate。

crate可以有两种形式:二进制crate或库crate。二进制crate是可以编译到可以运行的可执行文件的程序,如命令行程序或服务器。每个二进制crate都必须有一个名为main的函数定义当可执行运行时发生的事情。到目前为止,所创造的所有的crate都是二进制crate。

库crate没有main函数,也不能编译成可执行文件。相反,它们定义了希望与多个项目共享的功能。例如,在第2章中使用的rand crate提供了生成随机数的功能。大多数时候,Rustaceans说“crate”,他们指的是库crate,他们使用“crate”与一般编程概念中的“库”互换。

crate root是一个源文件, Rust 编译器以它为起始点,并构成 crate 的根模块。(我们将在“定义模块来控制作用域和私密”一节中深入解释模块)

一个包是一个或多个crate ,它提供了一组功能。一个包包含一个Cargo.toml 文件,说明如何去构建这些 crate。Cargo工具实际上是一个包,其中包含二进制crate,用来使用命令行工具的来构建代码。Cargo包也包含二进制crate所依赖的一个库crate,其他项目可以依赖Cargo库crate来使用Cargo命令行工具使用的相同逻辑。

一个包可以包含尽可能多的库crate,但最多只有一个二进制crate。

Cargo 遵循的一个约定src∕main.rs 就是一个与包同名的二进制 crate 的 crate 根。同样的,Cargo 知道如果包目录中包含 src∕lib.rs,则包带有与其同名的库 crate,且 src∕lib.rs 是 crate 根。crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者二进制crate。通过将文件放在 src∕bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的二进制 crate。

7.2 定义模块来控制作用域和可见性

将讨论模块和其它一些关于模块系统的部分,如允许你命名项的 路径(paths);用来将路径引入作用域的 use 关键字;以及使项变为公有的 pub 关键字。还将讨论 as 关键字、外部包和glob 运算符。

将从一系列的规则开始,在未来组织代码的时候,这些规则可被用作简单的参考。

7.2.1 模块速查表

这里提供一个简单的参考,用来解释模块、路径、use关键词和pub关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。

Start from the crate root

当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而
言是 src∕lib.rs,对于一个二进制 crate 而言是 src∕main.rs)中寻找需要被编译的代码。

Declaring modules

在 crate 根文件中,可以声明一个新模块;比如,用mod garden声明了一个叫做garden的模块。编译器会在下列路径中寻找模块代码:

  • Inline, 在大括号中,当mod garden后方不是一个分号而是一个大括号
  • In the file src/garden.rs
  • In the file src/garden/mod.rs
Declaring submodules

在除了 crate 根节点以外的其他文件中,可以定义子模块。比如,可能在src∕garden.rs 中定义了mod vegetables。编译器会在以父模块命名的目录中寻找子模块代码:

  • Inline, 在大括号中,当mod vegetables后方不是一个分号而是一个大括号
  • In the file src∕garden∕vegetables.rs
  • In the file src/garden/vegetables/mod.rs
Paths to code in modules:

一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的Asparagus类型可以在crate:: garden::vegetables::Asparagus被找到。

Private vs public

默认,一个模块里的代码对其父模块私有。为了使一个模块公用,应当在声明时使用pub mod替代mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub

The use keyword

在一个作用域内,use关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用crate:: garden::vegetables::Asparagus的作用域, 可以通过 use crate::garden::vegetables::Asparagus;建一个快捷方式,然后你就可以在作用域中只写Asparagus来使用该类型。

这里创建一个名为backyard的二进制 crate 来说明这些规则。该 crate 的路径同样命名为backyard,该路径包含了这些文件和目录:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

The crate root file in this case is src/main.rs, and it contains:

Filename: src/main.rs

use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {:?}!", plant);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

The pub mod garden; line tells the compiler to include the code it finds in src/garden.rs, which is:

Filename: src/garden.rs

pub mod vegetables;
  • 1

Here, pub mod vegetables; means the code in src/garden/vegetables.rs is included too. That code is:

#[derive(Debug)]
pub struct Asparagus {}
  • 1
  • 2

7.2.2 在模块中对相关代码进行分组

模块可以将一个 crate 中的代码进行分组,以提高可读性与重用性。模块还可以控制项的 可见性,因为模块中的代码默认是私有的。项也是可以被外部代码使用的(public)

在餐饮业,餐馆中会有一些地方被称之为 前台(front of house),还有另外一些地方被称之为 后台(back of house)。前台是招待顾客的地方,在这里,店主可以为顾客安排座位,服务员接受顾客下单和付款,调酒师会制作饮品。后台则是由厨师工作的厨房,洗碗工的工作地点,以及经理做行政工作的地方组成。

可以将函数放置到嵌套的模块中,来使我们的 crate 结构与实际的餐厅结构相同。通过执行cargo new −−lib restaurant,来创建一个新的名为 restaurant 的库。代码放入 src∕lib.rs 中,来定义一些模块和函数。

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

定义了一个模块,是以 mod 关键字为起始,然后指定模块的名字(本例中叫做 front_of_house),并且用花括号包围模块的主体。在模块内,还可以定义其他的模块,就像本例中的 hostingserving模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。

在前面我们提到了,src∕main.rssrc∕ lib .rs 叫做 crate 根。之所以这样叫它们是因为这两个文件的内容组成了模块结构的根下的一个名为 crate 的模块,该结构被称为 模块树(module tree)。

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的 子(child),模块 B 则是模块 A 的 父(parent)。注意,整个模块树都植根于
名为 crate 的隐式模块下。

7.3 Paths for Referring to an Item in the Module Tree

来看一下 Rust 如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • An absolute path 从 crate 根开始,以 crate 名字或者字面值 crate 开头。
  • A relative path 从当前模块开始,以 selfsuper 或当前模块的标识符开头。

Filename: src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

我们将展示两种方法,从crate根中定义的新函数eat_at_restaurant中调用add_to_waitlist函数。

第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist函数与 eat_at_restaurant 被定义在同一 crate 中,这意味着可以使用 crate 关键字为起始的绝对路径。

第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。

选择使用相对路径还是绝对路径,还是要取决于你的项目。取决于你是更倾向于将项的定义代码与使用该项的代码分开来移动,还是一起移动。

举一个例子,如果我们要将 front_of_house 模块和 eat_at_restaurant 函数一起移动到一个名为 customer_experience 的模块中,我们需要更新add_to_waitlist 的绝对路径,但是相对路径还是可用的
然而,如果我们要将 eat_at_restaurant 函数单独移到一个名为 dining 的模块中,还是可以使用原本的绝对路径来调用 add_to_waitlist,但是相对路径必须要更新。我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们被定义其中的上下文。

Rust 选择以这种方式来实现模块系统功能,因此默认隐藏内部实现细节。这样一来,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。你还可以通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

7.3.1 使用pub关键字公开路径

Filename: src/lib.rs

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

使模块公有并不使其内容也是公有的。模块上的pub关键字只允许其父模块引用它。
改成:

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

现在代码可以编译通过了!

在绝对路径,我们从 crate,也就是 crate 根开始。然后 crate 根中定义了 front_of_house 模块。front_of_house 模块不是公有的,不过因为 eat_at_restaurant 函数与 front_of_house 定义于同一模块中(即,eat_at_restaurant 和 front_of_house 是兄弟),我们可以从 eat_at_restaurant 中引用front_of_house。接下来是使用 pub 标记的 hosting 模块。我们可以访问 hosting 的父模块,所以可以访问 hosting。最后,add_to_waitlist 函数被标记为 pub ,我们可以访问其父模块,所以这个函数调用是有效的!

在相对路径,其逻辑与绝对路径相同,除了第一步:不同于从 crate 根开始,路径从 front_of_house 开始。front_of_house 模块与 eat_at_restaurant 定义于同一模块,所以从 eat_at_restaurant 中开始定义的该模块相对路径是有效的。接下来因为 hostingadd_to_waitlist 被标记为 pub,路径其余的部分也是有效的,因此函数调用也是有效的!
The Rust API Guidelines

模块树应该在src/lib.rs中定义。然后,任何公共项都可以通过使用包的名称开始路径来在二进制crate中使用。二进制crate成为库crate的用户,就像一个完全外部的crate将使用库的crate:它只能使用公共API。这有助于您设计一个良好的API;不仅是作者,也是一个客户!

7.3.2 使用 super 起始的相对路径

我们还可以使用 super 开头来构建从父模块开始的相对路径。这么做类似于文件系统中以.. 开头的语法。
使用super允许引用所知道的在父模块中的一个项目,当模块与父模块密切相关时,但是父可能会在某一天在模块树的其他地方移动时,它可以使模块树重新安排更容易。

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house的 父模块,也就是本例中的 crate 根。在这里,我们可以找到 deliver_order。成功!我们认为 back_of_house模块和 deliver_order函数之间可能具有某种关联关系,并且,如果我们要重新组织这个 crate 的模块树,需要一起移动它们。因此,我们使用 super,这样一来,如果这些代码被移动到了其他模块,我们只需要更新很少的代码。

7.3.3 Making Structs and Enums Public

还可以使用 pub 来设计公有的结构体和枚举,不过有一些额外的细节需要注意。如果在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。可以根据情况决定每个字段是否公有。
在示例中,我们定义了一个公有结构体 back_of_house:Breakfast
其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal
    // meal.seasonal_fruit = String::from("blueberries");
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。

还请注意一点,因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例 (这里我们命名为 summer)。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段seasonal_fruit 的值。
与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

还有一种使用 pub 的场景还没有涉及到,那就是最后要讲的模块功能:use 关键字。我们将先单独介绍 use,然后展示如何结合使用 pubuse

7.4 使用use关键字将路径引入作用域

到目前为止,似乎我们编写的用于调用函数的路径都很冗长且重复,并不方便。幸运的是,有一种方法可以简化这个过程。我们可以使用 use 关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。
在示例中,我们将 crate:: front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,
而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在作用域中增加 use路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting模块被定义于crate根一样。通过use引入作用域的路径也会检查可见性。

还可以使用 use相对路径来将一个项引入作用域
与上例 中一样的行为。

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use self::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

注意,use只会为use发生的特定范围创建快捷方式。

7.4.1 创建惯用的 use 路径

你可能会比较疑惑,为什么我们是指定 use crate::front_of_house::hosting ,然后在eat_at_restaurant 中调用 hosting::add_to_waitlist ,而不是通过指定一直到 add_to_waitlist 函数的use 路径来得到相同的结果,

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

虽然示都完成了相同的任务,但前面示例 是使用 use 将函数引入作用域的习惯用法。要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。下例展示了将HashMap 结构体引入二进制 crate 作用域的习惯用法。

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这种习惯用法背后没有什么硬性要求:它只是一种惯例,人们已经习惯了以这种方式阅读和编写 Rust代码。

这个习惯用法有一个例外,那就是想使用 use 语句将两个具有相同名称的项带入作用域,因为 Rust不允许这样做。示例 7-15 展示了如何将两个具有相同名称但不同父模块的 Result 类型引入作用域,以及如何引用它们。

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
}

fn function2() -> io::Result<()> {
    // --snip--
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

如你所见,使用父模块可以区分这两个 Result 类型

7.4.2 使用as关键字提供新名称

使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名。

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
}

fn function2() -> IoResult<()> {
    // --snip--
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

这两种 都是惯用的,如何选择都取决于你!

7.4.3 使用 pub use 重导出名称

使用 use 关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pubuse 合起来使用。这种技术被称为 ” 重导出(re−exporting)”:
我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

通过 pub use重导出,外部代码现在可以通过新路径restaurant::hosting::add_to_waitlist 来调用add_to_waitlist 函数。如果没有指定 pub use,外部代码需在其作用域中调用 restaurant::front_of_house::hosting::add_to_waitlist()

当你代码的内部结构与调用你代码的程序员所想象的结构不同时,重导出会很有用。例如,在这个餐馆的比喻中,经营餐馆的人会想到” 前台” 和” 后台”。但顾客在光顾一家餐馆时,可能不会以这些术语来考虑餐馆的各个部分。使用 pub use,我们可以使用一种结构编写代码,却将不同的结构形式暴露出来。这样做使我们的库井井有条,也使开发这个库的程序员和调用这个库的程序员都更加方便。

7.4.4 使用外部包

使用一个外部包rand,来生成随机数。为了在项目中使用 rand,在 Cargo.toml 中加入了如下行:

rand = "0.8.3"
  • 1

Cargo.toml 中加入 rand 依赖告诉了 Cargo 要从 crates.io 下载 rand 和其依赖,并使其可在项目代码中使用。

接着,为了将 rand 定义引入项目包的作用域,我们加入了 use 行,它以 rand 包名开头并列出了需要引入作用域的项。

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..=100);
}
  • 1
  • 2
  • 3
  • 4
  • 5

crates.io 上有很多 Rust 社区成员发布的包,将其引入你自己的项目都需要一道相同的步骤

注意标准库(std)对于你的包来说也是外部 crate。因为标准库随 Rust 语言一同分发,无需修改Cargo.toml 来引入 std,不过需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们,比如我们使用的 HashMap

use std::collections::HashMap;
  • 1

7.4.5 使用嵌套路径来消除大量的 use 行

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。

// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--
  • 1
  • 2
  • 3
  • 4

相反,我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分,

// --snip--
use std::{cmp::Ordering, io};
// --snip--
  • 1
  • 2
  • 3

可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。

use std::io;
use std::io::Write;
  • 1
  • 2

两个路径的相同部分是 std:: io,这正是第一个路径。为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self

use std::io::{self, Write};
  • 1

7.4.6 Glob 运算符

如果希望将一个路径下 所有公有项引入作用域,可以指定路径后跟*(glob 运算符):

use std::collections::*;
  • 1

这个 use 语句将 std:: collections 中定义的所有公有项引入当前作用域。
使用 glob 运算符时请多加小心!Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域;
How to Write Tests
glob 运算符有时也用于 prelude 模式

7.5 将模块分离到不同的文件中

到目前为止,本章所有的例子都在一个文件中定义多个模块。当模块变得更大时,你可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。

首先, 将 front_of_house 模块移动到属于它自己的文件 src/front_of_house.rs
首先,我们将将front_of_house模块提取到它自己的文件。删除大括号内代码,只留下mod front_of_house;声明。在这个例子中,crate 根文件是 src/lib.rs,这也同样适用于以 src/main.rs 为 crate 根文件的二进制 crate 项。

Filename: src/lib.rs

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

接下来,将括号中的代码放入一个名为src/front_of_house.rs的新文件中。编译器知道要查看这个文件,因为它在crate root的模块声明中出现,名称是front_of_house

Filename: src/front_of_house.rs

pub mod hosting {
    pub fn add_to_waitlist() {}
}
  • 1
  • 2
  • 3

注意,只需要在模块树中使用一个mod声明加载一个文件。一旦编译器知道该文件是项目的一部分(并且知道代码驻留的模块树的位置,因为您已经把mod语句放在了哪里),项目的其他文件应该使用路径引用加载的文件的代码。如“在模块树”部分中提到的“路径”。换句话说,mod并不是你在其他编程语言中看到的“包括”操作。
继续重构我们例子,将 hosting 模块也提取到其自己的文件中

//  src/front_of_house.rs
pub mod hosting;
  • 1
  • 2
// src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}
  • 1
  • 2

八、常见集合

Rust 标准库中包含一系列被称为 集合(collections)的非常有用的数据结构。大部分其他数据类型都代表一个特定的值,不过集合可以包含多个值。不同于内建的数组和元组类型,这些集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就已知,并且还可以随着程序的运行增长或缩小。每种集合都有着不同功能和成本,而根据当前情况选择合适的集合,这是一项应当逐渐掌握的技能。在这一章里,我们将详细的了解三个在 Rust 程序中被广泛使用的集合:

  • vector 允许我们一个挨着一个地储存一系列数量可变的值
  • string 是字符的集合。之前见过 String 类型,不过在本章我们将深入了解。
  • hash map 允许我们将值与一个特定的键(key)相关联。这是一个叫做 map 的更通用的数据结构的特定实现。

要了解标准库提供的其他类型的集合,请参见

8.1 使用 Vector 储存列表

要看的第一个集合类型是Vec<T>,也称为向量(vector)。向量允许在一个数据结构中存储多个值,该数据结构将所有值放在内存中相邻向量只能存储相同类型的值。当您有一个项目列表时,例如文件中的文本行或购物车中项目的价格,它们非常有用。

8.1.1 创建一个新的向量

要创建一个新的空向量,我们调用Vec::new函数:

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

注意,在这里添加了一个类型注释。因为没有向这个向量插入任何值,Rust不知道我们打算存储什么样的元素。这是很重要的一点。

标准库提供的Vec<T>类型可以保存任何类型。当创建用于存放特定类型的向量时,可以在尖括号内指定该类型。

通常情况下,创建带有初始值的Vec<T>, Rust将推断想要存储的值的类型,因此很少需要执行这种类型注释。Rust方便地提供vec!宏,它将创建一个新的向量来保存给它的值。

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

创建了一个新的Vec<i32>,其中包含值1、2和3。整数类型是i32,因为这是默认的整数类型。

8.1.2 更新Vector

要创建一个向量,然后向其中添加元素,可以使用push方法:

    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

不需要类型注释Vec<i32>

8.1.3 读取向量的元素

有两种方法引用存储在vector中的值:通过索引或使用get方法。

    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."),
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

注意这里的一些细节。使用索引值2来得到第三个元素,因为向量的索引是从0开始的。使用&[]提供了索引值处元素的引用。当使用get方法和传递的索引作为参数时,我们得到一个Option<&T>,我们可以用它来匹配。

Rust提供这两种引用元素的方法的原因是,当试图使用现有元素范围之外的索引值时,可以选择程序的行为方式。

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

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
  • 1
  • 2
  • 3
  • 4

当运行这段代码时,第一个[]方法将导致程序混乱,因为它引用了一个不存在的元素。当希望程序在试图访问超出vector末尾的元素时崩溃时,最好使用此方法。

get方法被传递到向量外部的索引时,它返回None而不会惊慌。如果在正常情况下偶尔会访问超出vector范围的元素,则可以使用此方法。然后代码将有逻辑来处理Some(&element)None

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

    let first = &v[0];

    v.push(6);

    println!("The first element is: {}", first);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

其中保存对vector中第一个元素的不可变引用,并试图在末尾添加一个元素。如果稍后在函数中也试图引用该元素,则此程序将无法工作:

在这里插入图片描述
代码看起来应该可以工作:为什么对第一个元素的引用要关心向量末尾的更改?这个错误是由于向量的工作方式:因为向量将值放在内存中相邻的位置,如果没有足够的空间将所有的元素放在向量当前存储的位置上,那么在向量的末尾添加一个新元素可能需要分配新的内存并将旧的元素复制到新空间中。在这种情况下,对第一个元素的引用将指向已释放的内存。借款规则防止项目陷入这种情况。

有关Vec类型的实现细节的更多信息

8.2 使用字符串储存 UTF-8 编码的文本

新的Rustaceans通常被困在字符串中,这有三个原因:Rust倾向于暴露可能的错误,字符串是比很多程序员所想象的要更为复杂的数据结构,以及UTF-8。当你来自其他编程语言时,这些因素会以一种看起来很难的方式结合在一起。

在集合的上下文中讨论字符串,因为字符串是作为一个字节集合实现的,加上一些方法,当这些字节被解释为文本时,提供有用的功能。在本节中,将讨论每个集合类型都有的字符串的操作,比如创建、更新和读取。也会讨论 String 与其他集合不一样的地方,例如索引 String 是很复杂的,由于人和计算机理解 String 数据方式的不同。

8.2.1 What Is a String?

首先定义术语字符串。Rust 的核心语言中只有一种字符串类型:字符串 slice str,它通常以被借用的形式出现,&str。第四章讲到了 字符串 slices:它们是一些对储存在别处的 UTF-8 编码字符串数据的引用。举例来说,由于字符串字面值被储存在程序的二进制输出中,因此字符串字面值也是字符串 slices。

String 类型,由Rust的标准库提供,而没有编码到核心语言,是一个可增长的、可变的、有所有权的UTF-8编码的字符串类型。当Rustaceans提到“字符串”时,它们可能指的是String 或字符串切片&str类型,而不仅仅是这些类型中的一种。虽然这一节主要是关于String的,但这两种类型都在Rust标准库中使用,String 和字符串切片都是UTF-8编码的。

8.2.2 Creating a New String

使用Vec<T>的许多相同操作也可以用于String,因为字符串实际上是作为一个字节vector 的一个包装器实现的,有一些额外的保证、限制和功能。
new函数创建String

let mut s = String::new();
  • 1

这一行创建了一个名为s的新的空字符串,可以将数据加载到s中。通常,会有一些初始数据,想要创建String。为此,我们使用to_string方法,在任何实现了Displaytrait类型中都有,字符串字面量也实现了。

    let data = "initial contents";

    let s = data.to_string();

    // the method also works on a literal directly:
    let s = "initial contents".to_string();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

还可以使用函数String::from从字符串字面量创建String。相当于使用to_string

let s = String::from("initial contents");
  • 1

请记住,字符串是UTF-8编码的,所以我们可以包括任何适当编码的数据,

    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");
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

所有这些都是有效的String值。

8.2.3 Updating a String

一个String可以在大小上增长,它的内容可以改变,如果把更多的数据push 进去,就像Vec<T>的内容一样。此外,可以方便地使用+操作符或format! 宏连接String值。

Appending to a String with push_str and push

可以使用push_str方法来添加一个字符串片

    let mut s = String::from("foo");
    s.push_str("bar");
  • 1
  • 2

push_str方法需要一个字符串切片,因为我们不一定要占用参数的所有权。如下代码中,我们希望在将内容附加到s1后使用s2

    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {}", s2);
  • 1
  • 2
  • 3
  • 4

push方法将单个字符作为参数,并将其添加到字符串中。

    let mut s = String::from("lo");
    s.push('l');
  • 1
  • 2
Concatenation with the + Operator or the format! Macro

通常,希望将两个现有字符串组合起来。一种方法是使用+运算符,

    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
  • 1
  • 2
  • 3

字符串s3将包含Hello,world !。在加法之后,s1不再有效,而使用s2的引用的原因与使用+运算符时调用add的方法的签名有关。

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

在标准库中,将看到add定义使用了泛型和相关类型。在这里,我们用具体的类型代替了,当我们用字符串值调用这个方法时,会发生什么。这个签名给了我们需要了解+运算符的技巧。

首先,s2有一个&,这意味着在向第一个字符串添加第二个字符串的引用。这是因为add函数中的s参数:我们只能在字符串中添加一个 &str;我们不能添加两个String 值。但是&s2的类型是&String,而不是&str, 为什么可以?

可以使用&s2的原因是编译器可以将&String参数强转(coerce )到一个&str上。当调用add方法时,Rust 使用了deref coercion,这里是&s2转成&s2[..]。我们将在第15章更深入地讨论deref 强转。因为add不占用s2参数的所有权,所以s2在操作后仍然是有效的字符串。

第二,我们可以看到在签名中add self的所有权,因为self没有&。这意味着s1将被转移到add 调用中,之后将不再有效。因此,尽管让s3 = s1 + &s2,看起来它将复制两个字符串并创建一个新的字符串,这个语句实际上需要s1的所有权,附加s2的内容的副本,然后返回结果的所有权。换句话说,它看起来就像复制了很多副本,但没有;实现比复制更有效。

如果需要连接多个字符串,+运算符的行为变得笨拙:

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

    let s = s1 + "-" + &s2 + "-" + &s3;
  • 1
  • 2
  • 3
  • 4
  • 5

在这一点上,s将是tic-tac-toe。有了所有的+"字符,很难看到发生了什么。”对于复杂的字符串组合,我们可以使用format!宏:

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

    let s = format!("{}-{}-{}", s1, s2, s3);
  • 1
  • 2
  • 3
  • 4
  • 5

这段代码也将s设置为tic-tac-toeformat!宏观工作像println!但是,它不是将输出打印到屏幕上,而是返回一个包含内容的String 。使用format!的代码更容易阅读,而且format!生成的代码使用引用,这样这个调用就不会占用它的任何参数的所有权

8.2.4 索引字符串(Indexing into Strings)

Rust 不支持索引语法访问字符串。

内部表示

String 是一个Vec<u8>的包装器。来看看正确编码的UTF-8示例字符串。首先,这个:

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

在这种情况下,len将是4,这意味着 vector 存储字符串“Hola”的长是4字节。在UTF-8中编码的每一个字母都有1个字节。然而,下面的路线可能会让你感到惊讶。(注意,这个字符串以大写字母Ze开头,而不是阿拉伯语3号。)

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

当被问及字符串是多长时,你可能会说12。事实上,Rust的答案是24:这就是在UTF-8中编码“Здравствуйте” 的字节数,因为在这个字符串中,每个Unicode标量值都需要2个字节的存储。**因此,将索引输入字符串的字节并不总是与有效的Unicode标量值相关联。**为了证明,考虑这个无效的Rust代码:

let hello = "Здравствуйте";
let answer = &hello[0];
  • 1
  • 2

你已经知道answer 不会是第一个字母З。当在UTF-8中编码时,第一个字节是208,第二个字节是151,所以答案应该是208,但208不是一个有效的字符。如果用户要求这条字符串的第一个字母,那么返回208可能不是用户想要的,但在字节索引0中,这是唯一的标准,。用户通常不希望返回的字节值,即使字符串只包含拉丁字母:如果&hello[0]是返回字节值的有效代码,它将返回104,而不是h。

然后,答案是,避免返回一个意想不到的值,并导致可能不会立即被发现的bug,Rust并没有完全编译这段代码,并在开发过程中早期防止误解。

字节和标量值和Grapheme Clusters

关于UTF-8的另一个观点是,实际上有三个相关的方法可以从Rust的透视图中查看字符串:作为字节标量值grapheme clusters(最接近字母)。

如果我们看在Devanagari脚本中印地语单词 “नमस्ते”,它被存储为u8值的向量,它看起来是这样的:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
  • 1
  • 2

这是18个字节,是计算机最终存储这些数据的方式。如果把它们看作Unicode标量值,这就是Rust的char类型,这些字节看起来是这样的:

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

这里有六个char值,但第四和第六不是字母:它们是不同的,它们本身没有意义。最后,如果我们把它们看成是grapheme集群,我们就会得到一个人所说的四个字母,它构成了印地语词:

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

最终的原因是Rust不允许索引一个字符串,以获得一个字符,即索引操作将总是需要常量时间(O(1))。但在String不能保证性能,因为Rust必须从开始到索引遍历,以确定有多少有效字符。

8.2.5 字符串 slice

索引到字符串通常是一个坏想法,因为不清楚路径索引操作的返回类型应该是:一个字节值、一个字符、一个grapheme cluster或一个字符串片。如果您真的需要使用索引来创建字符串切片,因此,Rust要求更具体。

不要用一个数字来索引使用[],你可以用一个范围使用[]来创建一个包含特定字节的字符串切片:

let hello = "Здравствуйте";

let s = &hello[0..4];
  • 1
  • 2
  • 3

在这里,s将是一个包含字符串的前4字节的一个&str。早些时候,我们提到,每个字符都是2个字节,这意味着s将会是Зд

应该使用范围以谨慎地创建字符串片,因为这样做会导致程序崩溃。

8.2.6 迭代字符串的方法

在字符串上操作的最好方法是明确你是否想要字符或字节。对于单个Unicode标量值,使用chars方法

for c in "Зд".chars() {
    println!("{}", c);
}
  • 1
  • 2
  • 3

bytes方法返回每个原始字节:

for b in "Зд".bytes() {
    println!("{}", b);
}
  • 1
  • 2
  • 3

但要记住,有效的Unicode标量值可能由超过1个字节组成。用Devanagari脚本从字符串中获取grapheme clusters是复杂的,因此这个功能不是由标准库提供的。可以到crates.io 查找。

8.2.6 Strings Are Not So Simple

综上所述,字符串是复杂的。不同的编程语言对如何向程序员呈现这种复杂性做出了不同的选择。为了正确处理String数据,Rust 已经选择了对所有Rust程序的默认行为,这意味着程序员必须提前考虑处理UTF-8数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发生命周期后期免于处理涉及非 ASCII 字符的错误。
好消息是,标准库提供了大量的功能,这些功能是通过String&str类型构建的,可以正确地处理这些复杂的情况。

请务必检查文档中包含的有用方法,例如contains在字符串中搜索,replace以另一个字符串的字符串替换部分。

8.3 使用 Hash Map 储存键值对

HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个 哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表、字典或者关联数组。

当想要查找数据时,可以使用索引,正如您可以使用向量,但使用可以使用的键来查找数据,这是很有用的。例如,在游戏中,可以在一个hash map中记录每个团队的分数,其中每个键都是一个团队的名字,而值是每个团队的分数。给定一个团队名称,您可以检索其分数。

本节中,讨论hash map的基本API,但更多的goodies隐藏在标准库的HashMap<K, V>中定义的函数中。与往常一样,检查标准库文档以获取更多信息。

8.3.1 Creating a New Hash Map

创建 空hash map的一种方法是使用new ,添加的元素用insert

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

注意,首先use标准库的集合部分的HashMap。在三个常见集合中,这个是最不常用的,所以它不包括在prelude中自动引入范围的特性。hash map来自标准库的支持也少;例如,没有内置的宏来构造它们。

就像向量一样,hash map将数据存储在堆上。这个HashMap有键类型String 和值类型i32。与向量一样,hash map是同质的:所有的键都必须有相同的类型,而所有的值都必须有相同的类型。

8.3.2 Accessing Values in a Hash Map

可以使用get方法通过提供它的的key来获得hash map的值。

    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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

get方法返回一个Option<&V>;如果在hash map中没有那个键的值,就返回None。这个程序通过调用copied 得到Option<i32>,而不是Option<&i32>,来处理Option 。如果scores 没有key的条目,那么就通过unwrap_or 把分数设置为0。

可以用一个类似的方式遍历每个键/值对,就像用向量做的那样,用一个for循环:

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

    for (key, value) in &scores {
        println!("{}: {}", key, value);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

8.3.3 Hash Maps and Ownership

对于实现Copy 特性的类型,如i32,将值复制到hash map中。对于像String这样拥有的值,值将被move,hash map将成为这些值的所有者,

    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 and field_value are invalid at this point, try using them and
    // see what compiler error you get!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

不能使用变量field_namefield_value,因为通过调用insert,它们已经被移动到hash map中。

如果我们将引用插入到哈希图中,那么这些值就不会被转移到散列映射中。只要哈希映射有效,引用点必须至少有效。“Validating References with Lifetimes”

8.3.4 Updating a Hash Map

虽然键值对的数量是可增长的,但每个唯一的密钥,只能只有一个值。

当想要在hash map中更改数据时,必须决定如何处理一个key已经分配了一个值的情况。可以用新的价值代替旧价值,完全忽略旧值。您可以保留旧值并忽略新值,只有在键没有值时才添加新值。或者你可以把旧的价值和新价值结合起来。

覆盖一个值 insert

如果将一个键和一个值插入到hash map 中,然后用不同值插入相同的键,那么与该键相关的值将被替换。

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

    println!("{:?}", scores);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

This code will print {"Blue": 25}.

Adding a Key and Value Only If a Key Isn’t Present

通常,检查一个有值的特定的key是否已经存在于的hash map中,然后采取以下行动:如果在哈希映射中确实存在关键,那么现有的值应该保留。如果键不存在,则插入它。

hash map有一个特殊的API,它叫做entry ,它取你想要检查的键作为参数。entry 方法的返回值是一个被称为Entry 的enum,它表示一个可能存在或可能不存在的值。

    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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

Entry or_insert方法被定义为如果该键存在,返回对应的Entry键的值一个可变引用;如果不存在,将参数插入作为此键的新值,并返回一个可变引用到新值。这种技术比编写逻辑更干净,而且,此外,与借阅检查器更友好。

基于旧值更新值

的另一个常见的用例是查找一个键的值,然后根据旧值更新它
以下显示了一些代码,它计算了在一些文本中每个word出现的次数。使用hash map 用word作为键,并增加值以跟踪我们看到这个词的次数。如果这是第一次看到一个单词,我们将首先插入值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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

This code will print {"world": 2, "hello": 1, "wonderful": 1}.

可能会看到相同的键/值对以不同的顺序打印:从“Accessing Values in a Hash Map” 的部分中,迭代在一个散列映射中以任意的顺序发生。

split_whitespace方法将返回一个子切片迭代器,由空格分隔text中的值。or_insert方法将指定密钥的值的可变引用(&mut V)返回。在这里,我们将可变引用存储在count变量中,因此为了赋值,我们必须首先使用(*)来“dereference count”。在for循环结束时,可变引用不在范围内,因此所有这些更改都是安全的,并遵循借用规则。

8.3.4 Hashing Functions

默认情况下,HashMap使用一个哈希函数,该函数称为SipHash,可以为拒绝服务(DoS)攻击提供阻力。这不是最快的哈希算法,但是为了更好的安全性而得到的平衡是值得的。如果对您的代码进行了配置,并发现默认的散列函数对您的目的来说太慢,可以通过指定不同的hasher切换到另一个函数。hasher是一个实现BuildHasher 特征的类型。我们将讨论特征,以及如何在第10章中实现它们。你不一定要从头开始执行你自己的工作;crates.io 拥有其他被其他Rust用户共享的库,它们提供了执行许多常见的哈希算法的hashers。

九、错误处理

错误是软件生活中的一个事实,所以在处理错误的情况下,Rust有许多特性。在许多情况下,Rust要求您承认错误的可能性,并在代码编译之前采取一些行动。这个要求使您的程序更加健壮,确保您在部署代码到生产之前会发现错误并适当地处理它们!

Rust将错误分为两大类:可恢复的和不可恢复的错误。对于可恢复的错误,例如未找到文件(file not found)错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是bug的症状,比如试图访问超出数组末尾的位置,因此我们希望立即停止程序。

大多数语言不区分这两种错误,并以同样的方式处理,使用诸如异常(exceptions)的机制。Rust没有exceptions。Rust没有异常。相反,它有类型为Result<T, E>来处理可恢复的错误和panic!宏用来当程序遇到不可恢复的错误时停止执行。本章涵盖首先讨论panic!,然后讨论返回Result<T, E>值。此外,我们将探讨在决定是尝试从错误中恢复还是停止执行时的注意事项。

9.1 panic! 处理不可恢复的错误!

在实践中引起恐慌(panic )的方法有两种:通过采取一种行动,使我们的代码陷入恐慌(比如访问一个数组的末端),或者通过显式地调用panic!。默认情况下,这些恐慌将打印一个失败消息,unwind,清理堆栈,退出。通过一个环境变量,当恐慌发生时,你也可以看到调用堆栈,从而更容易追踪恐慌的来源。

在响应Panic时展开堆栈或中止

默认情况下,当恐慌发生时,程序就会启动unwinding,这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序。
那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,直接 终止panic ,通过在 Cargo.toml[profile ] 部分增加 panic = 'abort' ,可以由展开切换为终止。例如,如果你想要在 release 模式中 panic 时直接终止:

[profile.release]
panic = 'abort'
  • 1
  • 2

Let’s try calling panic! in a simple program:

fn main() {
    panic!("crash and burn");
}
  • 1
  • 2
  • 3

在这种情况下,显示的是我们代码的一部分,如果我们走到那里,我们就会看到panic!宏调用。在其他情况下,panic!调用可能在代码调用的代码中,而错误消息所报告的文件名和行号将是其他人的代码,在那里panic!宏被调用,而不是我们的代码的行最终导致了panic!呼叫。我们可以用调用panic!的函数的backtrace自于弄清楚我们的代码的哪部分导致了问题。

Using a panic! Backtrace
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
  • 1
  • 2
  • 3
  • 4
  • 5

在这种情况下,Rust会引起恐慌。使用[]应该返回一个元素,但如果你通过一个无效的索引,没有任何元素,Rust 可以返回到这里,这是正确的。

在C中,试图超越数据结构的结束是未定义的行为。你可能会得到与数据结构中的那个元素对应的内存中的任何东西,即使内存不属于这个结构。这被称为缓冲区覆盖,如果攻击者能够以这样的方式操纵索引,就可以导致安全漏洞,以读取数据结构后不允许存储的数据。

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这个错误点在我们main.rs的第4行。在这里尝试访问索引99。下一个提示线告诉我们,我们可以设置RUST_BACKTRACE环境变量以得到错误的backtrace 。回溯是所有被调用的函数的列表。回溯在Rust的工作和在其他语言中一样:阅读backtrace的关键是从顶部开始阅读,直到看到你写的文件。这就是问题的根源所在。上面的行是你代码所调用的;下面的行是调用你代码的代码。这些前和之后的行可能包括Rust core,标准的库,或者你正在使用的crate。让我们尝试通过将RUST_BACKTRACE环境变量设置为0之外的任何值来获得回溯。

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
   1: core::panicking::panic_fmt
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
   2: core::panicking::panic_bounds_check
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
   5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
   6: panic::main
             at ./src/main.rs:4
   7: core::ops::function::FnOnce::call_once
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

为了获得这些信息的回溯,必须启用debug symbols。cargo build或者 cargo run 在使用没有--release的情况下,debug symbols就会启用。

9.2 Recoverable Errors with Result

大多数错误并不严重到需要程序完全停止。有时,当一个函数失败时,它是由于一个原因,你可以很容易地解释和响应。例如,如果您试图打开一个文件,而操作失败,因为文件不存在,您可能希望创建该文件,而不是终止流程。

Resultenum定义有两个变体,OkErr,如下:

enum Result<T, E> {
    Ok(T),
    Err(E),
}
  • 1
  • 2
  • 3
  • 4

现在需要知道的是,T表示Ok变体中成功情况下将返回的值的类型,E表示Err变体中失败情况下将返回的错误的类型。因为Result具有这些泛型类型形参,所以在许多不同的情况下,在希望返回的成功值和错误值可能不同,可以使用Result类型及其上定义的函数。

调用一个返回Result值的函数,因为该函数可能会失败。

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
  • 1
  • 2
  • 3
  • 4
  • 5

File::open的返回类型是Result<T, E>File::open的实现用成功值的类型std::fs::File填充了泛型参数T,它是一个文件句柄。错误值中使用的E类型为std::io::Error。这个返回类型意味着对File::open的调用可能会成功,并返回一个我们可以读取或写入的文件句柄。函数调用也可能失败:例如,文件可能不存在,或者我们可能没有访问该文件的权限。File::open函数需要有一种方法来告诉我们它是成功还是失败,同时给我们文件句柄或错误信息。此信息正是Result枚举所传达的内容。

我们需要根据File::open返回的值采取不同的操作。
9-4:

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

注意,与Option枚举一样,Result枚举及其变体也被prelude引入了作用域,因此我们不需要在匹配 arm 中的OkErr变体之前指定Result::

当结果为Ok时,这段代码将从Ok变量中返回内部file 值,然后将该文件句柄值赋给变量greeting_file。之后,可以使用文件句柄进行读或写。

match的另一个部分处理从File::open获得Err值的情况。在本例中,我们选择调用panic!宏。如果在当前目录中没有名为hello.txt的文件,并且我们运行这段代码,我们将看到panic!宏:

在这里插入图片描述

9.2.1 Matching on Different Errors

上例的代码会出现恐慌!无论File::open为什么失败。但是,我们希望针对不同的失败原因采取不同的操作:如果File::open因为文件不存在而失败,我们希望创建文件并将句柄返回到新文件。如果File::open因为任何其他原因失败——例如,因为我们没有打开文件的权限——我们仍然希望代码惊慌,为此,我们添加了一个内部匹配表达式:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

File::openErr变量中返回的值的类型是io::Error,这是标准库提供的结构体。这个结构体有一个方法类型,我们可以调用它来获取io::ErrorKind值。枚举io::ErrorKind由标准库提供,它有表示io操作可能导致的不同类型错误的变体。我们想要使用的变体是ErrorKind::NotFound,它表示我们试图打开的文件还不存在。因此,我们对greeting_file_result进行匹配,但也对error.kind()进行内部匹配。

我们想在内部匹配中检查的条件是error.kind()返回的值是否为ErrorKind enum的NotFound变体。如果是,我们尝试用file::create创建文件。但是,因为File::create也可能失败,所以我们需要在内部匹配表达式中添加第二个分支。当文件无法创建时,将打印不同的错误消息。外部匹配的第二arm保持不变,因此除了缺少文件错误外,程序还会对任何错误进行恐慌。

使用match with Result<T, E>的替代方法

match表达式非常有用,但也非常原始。在第13章中,您将学习闭包,它与Result<T, E>中定义的许多方法一起使用。当在代码中处理Result<T, E>值时,这些方法比使用match更简洁。
例如,下面是编写上例中所示逻辑的另一种方法,这次使用闭包和unwrap_or_else方法:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

尽管这段代码具有与上例相同的行为,但它不包含任何match表达式,读起来更清晰。在阅读了第13章之后,再回到这个例子,并在标准库文档中查找unwrap_or_else方法。在处理错误时,有更多这样的方法可以清理巨大的嵌套match表达式。

9.2.2 Shortcuts for Panic on Error: unwrap and expect

使用match可以很好地工作,但它可能有点啰嗦,并不能总是很好地传达意图。Result<T, E>类型上定义了许多帮助方法,用于执行各种更特定的任务。unwrap方法是一个实现的快捷方法,就像我们在9-4实现的匹配表达式一样。如果Result值是Ok变体,则展开将返回Ok中的值。如果ResultErr变体,则展开将调用panic!宏观的。

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}
  • 1
  • 2
  • 3
  • 4
  • 5

同样,expect方法也让我们选择了panic!错误消息。使用expect而不是unwrap并提供良好的错误消息可以传达您的意图,并使跟踪恐慌的来源更容易。expect的语法如下所示:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在这里插入图片描述
在产品质量的代码中,大多数Rustaceans选择expect而不是unwrap

9.2.3 传播错误 (Propagating Errors)

当函数的实现调用可能失败的东西时,您可以将错误返回给调用代码,而不是在函数本身中处理错误,以便它可以决定做什么。这被称为传播错误,并为调用代码提供了更多的控制,其中可能有比在你代码的上下文中更多的信息或逻辑来指示应该如何处理错误。

例如,9-6显示了一个从文件读取用户名的函数。如果文件不存在或无法读取,此函数将向调用该函数的代码返回这些错误。
9-6:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

这个函数可以用更短的方式编写,但我们将从大量手动操作开始,以探索错误处理;最后,我们将展示捷径。让我们先看看函数的返回类型:Result<String, io::Error>。这意味着该函数返回一个类型为Result<T, E>的值,其中泛型参数T已用具体类型String填充,泛型类型E已用具体类型io::Error填充。

如果此函数成功且没有任何问题,则调用此函数的代码将收到一个Ok值,其中包含一个String—此函数从文件中读取的用户名。如果这个函数遇到任何问题,调用代码将收到一个Err值,该值包含io::Error的实例,其中包含关于问题的更多信息。我们选择io::Error作为这个函数的返回类型,因为这恰好是我们在这个函数体中调用的两个方法File::open函数和read_to_string 可能失败的操作所返回的错误值的类型。

函数体首先调用File::open函数。然后,我们用类似于9-4中的匹配来处理Result值。如果File::open成功,模式变量file 中的文件句柄变成可变变量username_file中的值,函数继续。在Err的情况下,不要调用panic!,我们使用return关键字提前返回,并将来自File::open的错误值(现在位于模式变量e中)作为该函数的错误值传递回调用代码。

因此,如果我们在username_file中有一个文件句柄,该函数然后在变量username中创建一个新的String,并在username_file中的文件句柄上调用read_to_string方法,将文件的内容读入username。read_to_string方法也返回Result,因为它可能失败,即使File::open成功了。所以我们需要另一个匹配来处理Result如果read_to_string成功,那么我们的函数成功了,我们从文件中返回用户名现在在username中,包在Ok中。如果read_to_string失败,我们将以处理File::open返回值的匹配中返回错误值的相同方式返回错误值。但是,我们不需要显式地说return,因为这是函数中的最后一个表达式。

因此,如果我们在username_file中有一个文件句柄,该函数然后在变量username中创建一个新的String,并在username_file中的文件句柄上调用read_to_string方法,将文件的内容读入usernameread_to_string方法也返回Result,因为它可能失败,即使File::open成功了。所以我们需要另一个匹配来处理Result如果read_to_string成功,那么我们的函数成功了,我们从文件中返回用户名现在在username中,包在Ok中。如果read_to_string失败,我们将以处理File::open返回值的匹配中返回错误值的相同方式返回错误值。但是,我们不需要显式地说return,因为这是函数中的最后一个表达式。

传播错误的快捷方式: ? 操作符

9-7显示了read_username_from_file的实现,其功能与9-6中相同,但该实现使用?操作符。

//  9-7
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

?放置在Result值之后,其工作方式与我们在9-6中定义的处理Result值的match达式几乎相同。如果Result的值是Ok, Ok里面的值将从这个表达式返回,程序将继续。如果值是Err,则Err将从整个函数返回,就像我们使用了return关键字一样,因此错误值将传播到调用代码。

清单9-6中的match表达式与?操作符所做的工作不尽相同: 有?操作符对它们调用的错误值,将通过定义在标准库Fromtrait 中的from函数,该函数用于将值从一种类型转换为另一种类型。当?操作符调用from函数,接收到的错误类型将转换为当前函数的返回类型中定义的错误类型。当函数返回一个错误类型来表示函数可能失败的所有方式时,这是很有用的,即使部分可能因为许多不同的原因而失败。

例如,我们可以更改清单9-7中的read_username_from_file函数,以返回我们定义的名为OurError的自定义错误类型。如果我们还定义impl From<io::Error> for OurError,从io::Error构造一个OurError的实例,那么read_username_from_file函数体中的?操作符调用将调用和转换错误类型,而不需要向函数中添加任何更多的代码。
在清单9-7的上下文中,?在File::open调用的最后,将返回Ok中的值给变量username_file。如果发生错误,?operator将在整个函数的早期返回,并将任何Err值赋给调用代码。同样的事情也适用于?在read_to_string调用的末尾。

?Operator消除了大量的样板代码,使该函数的实现更简单。我们甚至可以通过在?后面链式方法调用来进一步缩短这段代码,如清单9-8所示。
9-8

use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

清单9-9显示了一种使用fs::read_to_string使其更短的方法。
9-9

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

将文件读入字符串是一种相当常见的操作,因此标准库提供了方便的fs::read_to_string函数,该函数打开文件,创建一个新的String,读取文件内容,将内容放入String中,然后返回该String。当然,使用fs::read_to_string并没有给我们机会解释所有的错误处理,所以我们先用较长的方法来解释。

哪里可以使用?操作符

? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。因为 ? 运算符被定义为从函数中提早返回一个值,这与示例 9-6 中的 match 表达式有着完全相同的工作方式。示例 9-6 中 match 作用于一个Result 值,提早返回的分支返回了一个 Err(e) 值。函数的返回值必须是 Result 才能与这个 return 相兼容。

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
  • 1
  • 2
  • 3
  • 4
  • 5

在这里插入图片描述
这个错误指出我们只允许使用?返回ResultOption或实现FromResidual的其他类型的函数中的操作符。

要修复错误,您有两个选择。一种选择是更改函数的返回类型,使其与正在使用?只要你没有任何限制就可以操作。另一种技术是使用match Result<T, E>方法中的一个来以任何合适的方式处理Result<T, E>

错误信息还提到?也可以使用Option<T>值。和使用一样?结果,您只能使用?返回Option的函数中的Option。?运算符在Option<T>时的行为与在Result<T, E>类似:如果值为None,在该点上函数将提前返回None。如果值为Some,则Some中的值为表达式的结果值,函数继续执行。清单9-11给出了一个函数示例,该函数查找给定字符串中第一行的最后一个字符

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}
  • 1
  • 2
  • 3

注意,可以使用?操作符在返回Result的函数中的Result上,也可以使用?操作符在返回Option的函数中的Option上,但不能混合和匹配。?operator不会自动将Result转换为Option,反之亦然;在这些情况下,可以使用Result上的ok方法或Option上的ok_or方法来显式地进行转换。

到目前为止,我们使用的所有main函数都返回()main函数是特殊的,因为它是可执行程序的入口和出口点,并且它的返回类型对程序的行为有一些限制。
幸运的是,main也可以返回 Result<(), E>

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Box<dyn Error>类型是一个trait对象

现在,您可以将Box<dyn Error>解读为“任何类型的错误”。使用?允许错误类型为Box<dyn error >main函数的Result值,因为它允许提前返回任何Err值。尽管main函数体只返回std::io::Error类型的错误,但通过指定Box<dyn Error>,即使在main函数体中添加更多返回其他错误的代码,该签名也将继续正确。

main函数返回Result<(), E>时,如果main返回Ok(()),则可执行程序将以0值退出,如果main返回Err值则以非零值退出。用C编写的可执行程序在退出时返回整数:退出成功的程序返回整数0,出错的程序返回非0的整数。Rust还从可执行程序返回整数,以与此约定兼容。

main函数可以返回实现std::process: terminatetrait的任何类型,该特征包含一个返回ExitCode的函数报告。有关为您自己的类型实现terminate特性的更多信息,请参考标准库文档。

9.3 To panic! or Not to panic!

那么,该如何决定何时应该 panic! 以及何时应该返回 Result 呢?如果代码 panic,就没有恢复的可能。你可以选择对任何错误场景都调用 panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。选择返回 Result 值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为 Err 是不可恢复的,所以他们也可能会调用 panic! 并将可恢复的错误变成了不可恢复的错误。因此返回 Result 是定义可能会失败的函数的一个好的默认选择。

在一些类似示例、原型代码(prototype code)和测试中,panic 比返回 Result 更为合适,不过他们并不常见。让我们讨论一下为何在示例、代码原型和测试中,以及那些人们认为不会失败而编译器不这么看的情况下,panic 是合适的。本节最后会总结一些在库代码中如何决定是否要 panic 的通用指导原则。

9.3.1 示例、原型代码和测试

当您编写示例来说明某些概念时,也包括健壮的错误处理代码会使示例不那么清晰。在示例中,可以理解对unwrap这样的方法的调用可能会引起恐慌,这意味着您希望应用程序处理错误的方式是占位符,根据代码的其余部分所做的操作,这些方法可能会有所不同。

类似地,在我们准备好决定如何处理错误之前,unwrapexpect方法在原型设计时非常方便。当我们准备好让程序更加健壮时,它们会在代码中留下清晰的标记。

如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为panic! 会将测试标记为失败,此时调用unwrap expect 是恰当的。

本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号