11 package&crate

11.1 rustc编译单个文件

对于简单使用,可以通过rustc编译代码,假设当前文件为main.rs,内容为:

1
2
3
fn main() {
println!("Hello, world!");
}

进行编译:

1
rustc main.rs

在 Linux、macOS 或 Windows 的 PowerShell 上,在 shell 中输入 ls 命令可以看见这个可执行文件:

1
2
$ ls
main main.rs

在 Linux 和 macOS,你会看到两个文件。在 Windows PowerShell 中,你会看到同使用 CMD 相同的三个文件。在 Windows 的 CMD 上,则输入如下内容:

1
2
> dir
main.exe main.pdb main.rs

这展示了扩展名为.rs的源文件、可执行文件(在 Windows 下是main.exe,其它平台是main),以及当使用 CMD 时会有一个包含调试信息、扩展名为.pdb的文件。从这里开始运行mainmain.exe文件,如下:

1
$ ./main # Windows 是 .\main.exe

在终端上就会打印出 Hello, world!

仅仅使用 rustc 编译简单程序是没问题的,不过随着项目的增长,你可能需要管理你项目的方方面面,并让代码易于分享。因此,使用rust提供的cargo可以更好地管理项目。

11.2 模块系统

这里有一些名词需要区分,首先是crate,它是rust在编译时最小的代码单位。如果用rustc去编译一个文件,这个文件就被认为是一个cratecrate可以包含多个modulemodule可以定义在其它文件中,然后和crate一起编译。

crate有两种形式:二进制项(binary crate)和库(library crate)。二进制项可以被编译为可执行程序,比如一个命令行程序或者一个服务器。它们必须有一个 main 函数来定义当程序被执行的时候所需要做的事情。库并没有 main 函数,它们也不会编译为可执行程序,它们提供一些诸如函数之类的东西,使其他项目也能使用这些东西。

package是提供一系列功能的一个或者多个crate,可以理解为整个项目,它包含有独立的 Cargo.toml 文件,并且包含至多一个库类型的crate(library crate),可以包含任意多个二进制crate,而且必须至少包含一个crate(不管是二进制的还是库的)

使用cargo new [packagename]可以创建一个项目:

1
2
3
4
5
6
7
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

运行了这条命令后,Cargo会给我们的包创建一个Cargo.toml文件,Cargo遵循的一个约定是src/main.rs就是一个与package同名的二进制cratecrate根。同样的,Cargo知道如果包目录中包含src/lib.rs,则package带有与其同名的库 crate,且src/lib.rscrate根。crate根文件将由Cargo传递给rustc来实际构建库或者二进制项目。默认情况下,创建的都是二进制的crate,想要创建lib类型的项目可以使用参数--lib

1
$ cargo new my-lib --lib

如果一个包同时含有src/main.rssrc/lib.rs,则它有两个crate,一个二进制的和一个库的,且名字都与包相同。通过将文件放在src/bin目录下,一个包可以拥有多个二进制crate:每个src/bin下的文件都会被编译成一个独立的二进制crate

使用cargo new创建的项目中,packagecrate的名字是相同的,因此有时可能容易混淆。

11.3 典型的package结构

一个真实项目中典型的 Package,会包含多个二进制包,这些包文件被放在 src/bin 目录下,每一个文件都是独立的二进制包,同时也会包含一个库包src/lib.rs,该包只能存在一个 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ ├── lib.rs
│ └── bin
│ └── main1.rs
│ └── main2.rs
├── tests
│ └── some_integration_tests.rs
├── benches
│ └── simple_bench.rs
└── examples
└── simple_example.rs
  • 唯一库包:src/lib.rs
  • 默认二进制包:src/main.rs,编译后生成的可执行文件与 Package 同名
  • 其余二进制包:src/bin/main1.rssrc/bin/main2.rs,它们会分别生成一个文件同名的二进制可执行文件
  • 集成测试文件:tests 目录下
  • 基准性能测试 benchmark 文件:benches 目录下
  • 项目示例:examples 目录下

大多数真实的rust项目与上述结构类似。

理解了包的概念,下面一节我们将讨论模块和其它一些关于模块系统的部分。

11.4 module

模块module是构成crate的基本单元,它让我们可以将一个crate中的代码进行分组,以提高可读性与重用性。因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的 私有性。私有项是不可为外部使用的内在详细实现。我们也可以将模块和它其中的项标记为公开的,这样,外部代码就可以使用并依赖与它们。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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() {}
}
}

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

通过使用模块,我们可以将相关的定义分组到一起,并指出他们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。

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

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

这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting 嵌套在 front_of_house 中)。这个树还展示了一些模块是互为兄弟(siblings)的,这意味着它们定义在同一模块中(hostingserving 被一起定义在 front_of_house 中)。继续沿用家庭关系的比喻,如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的子(child),模块 B 则是模块 A 的父(parent)。注意,整个模块树都植根于名为 crate 的隐式模块下。

这有点像操作系统中用来管理文件的文件树,你可以使用模块来组织你的代码。并且,就像目录中的文件,我们需要一种方法来找到模块。

11.5 引用模块项目的路径

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

路径有两种形式:

  • 绝对路径(absolute path)是以crate根(root)开头的全路径;对于外部crate的代码,是以crate名开头的绝对路径,对于对于当前crate的代码,则以字面值 crate 开头。
  • 相对路径(relative path)从当前模块开始,以 selfsuper 或当前模块的标识符开头。

绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。

假设希望调用 add_to_waitlist 函数,在crate根定义了一个新函数 eat_at_restaurant,其中使用两种方法方式调用。在src/lib.rs

1
2
3
4
5
6
7
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

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

crate 后面,我们持续地嵌入模块,直到我们找到 add_to_waitlist。你可以想象出一个相同结构的文件系统,我们通过指定路径 /front_of_house/hosting/add_to_waitlist 来执行 add_to_waitlist 程序。我们使用 cratecrate根开始就类似于在 shell 中使用 / 从文件系统根开始。

第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist。以模块名开头意味着该路径是相对路径。

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

下面尝试使用cargo build编译这个项目,会发现无法通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  --> src\lib.rs:34:28
|
34 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src\lib.rs:17:5
|
17 | mod hosting {
| ^^^^^^^^^^^

error[E0603]: module `hosting` is private
--> src\lib.rs:37:21
|
37 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src\lib.rs:17:5
|
17 | mod hosting {
| ^^^^^^^^^^^

错误信息提示 hosting 模块是私有的。换句话说,我们填写了正确的路径,但rust不允许使用,因为它不能访问私有片段。在rust中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的。如果希望创建一个私有函数或结构体,你可以将其放入一个模块。

父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的事务对餐厅顾客来说是不可知的,但办公室经理可以洞悉其经营的餐厅并在其中做任何事情。

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

11.6 使用pub暴露路径

之前的例子中,hosting 模块是私有的,我们想让父模块中的 eat_at_restaurant 函数可以访问子模块中的 add_to_waitlist 函数,因此我们使用 pub 关键字来标记 hosting 模块:

1
2
3
4
5
6
7
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}

fn seat_at_table() {}
}
}

不过这还不够,在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting 的内容(contents)仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它,而不允许访问内部代码。因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。

私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。因此,还需要把函数标记为公有:

1
2
3
4
5
6
7
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}

pub fn seat_at_table() {}
}
}

现在代码就可以编译通过了。

11.7 使用super起始的相对路径

我们还可以使用 super 而不是当前模块或者crate根来开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。使用 super 允许我们引用已知的父模块中的项,当模块与父模块关联的很紧密的时候,如果某天可能需要父模块要移动到模块树的其它位置,这使得重新组织模块树变得更容易。

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

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

fn cook_order() {}
}

考虑上述代码,它模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。back_of_house 模块中的定义的 fix_incorrect_order 函数通过指定的 super 起始的 serve_order 路径,来调用父模块中的 deliver_order 函数。

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

11.8 控制结构体和枚举的公有

我们还可以使用 pub 来设计公有的结构体和枚举,不过关于在结构体和枚举上使用 pub 还有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。

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
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() {
// 在夏天订购一个黑麦土司作为早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 改变主意更换想要面包的类型
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);

// 如果取消下一行的注释代码不能编译;
// 不允许查看或修改早餐附带的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}

我们定义了一个公有结构体 back_of_house:Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。

因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试去除那一行修改 seasonal_fruit 字段值的代码的注释,看看会发生什么错误。

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

与此不同的是,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub

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

因为我们创建了名为 Appetizer 的公有枚举,所以我们可以在 eat_at_restaurant 中使用 SoupSalad 成员。

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

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

11.9 使用use

无论是绝对路径还是相对路径,当每次想要调用某个函数时,都必须写明它的上面层级,这无疑是非常麻烦的。有一种方法可以简化这个过程。就类似于C++的using关键字一样,我们可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。

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

我们使用usecrate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。同其它路径一样通过 use 引入作用域的路径也会检查私有性。

注意 use 只能创建 use 所在的特定作用域内的短路径。下面的代码将 eat_at_restaurant 函数移动到了一个叫 customer 的子模块,这又是一个不同于 use 语句的作用域,所以函数体不能编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

mod customer {
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}

为了修复这个问题,可以将 use 移动到 customer 模块内,或者在子模块 customer 内通过 super::hosting 引用父模块中的这个短路径。

11.10 use使用惯例

看下面两种引用:

1
2
use crate::front_of_house::hosting;
use crate::front_of_house::hosting::add_to_waitlist;

这两种引用都可达到使用add_to_waitlist的效果。不过,前者是更加通用的做法,要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化;而后者则不清楚 add_to_waitlist 是在哪里被定义的。

如果想使用 use 语句将两个具有相同名称的项带入作用域,则需要引用父模块来区分它们:

1
2
3
4
5
6
7
8
9
10
use std::fmt;
use std::io;

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

fn function2() -> io::Result<()> {
// --snip--
}

如果我们是指定 use std::fmt::Resultuse std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,rust则不知道我们要用的是哪个。

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

1
2
3
4
5
6
7
8
9
10
use std::fmt::Result;
use std::io::Result as IoResult;

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

fn function2() -> IoResult<()> {
// --snip--
}

在第二个 use 语句中,我们选择 IoResult 作为 std::io::Result 的新名称,它与从 std::fmt 引入作用域的 Result 并不冲突。

11.11 重导出

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

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

在这个修改之前,外部代码需要使用路径 restaurant::front_of_house::hosting::add_to_waitlist() 来调用 add_to_waitlist 函数。现在这个 pub use 从根模块重导出了 hosting 模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist

11.12 嵌套路径来消除大量的use

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

1
2
use std::cmp::Ordering;
use std::io;

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

1
use std::{cmp::Ordering, io};

在较大的程序中,使用嵌套路径从相同包或模块中引入很多项,可以显著减少所需的独立 use 语句的数量。我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用,比如:

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

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

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

这一行便将 std::iostd::io::Write 同时引入作用域。

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

1
use std::collections::*;

这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。

使用glob运算符时请多加小心,这会使得我们难以推导作用域中有什么名称和它们是在何处定义的。

11.13 受限可见性

对于某些情况,可能希望对于某些特定的模块可见,但是对于其他模块又不可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub mod a {
pub const I: i32 = 3;

fn semisecret(x: i32) -> i32 {
use self::b::c::J;
x + J
}

pub fn bar(z: i32) -> i32 {
semisecret(I) * z
}
pub fn foo(y: i32) -> i32 {
semisecret(I) + y
}

mod b {
mod c {
const J: i32 = 4;
}
}
}

我们只希望a 导出 Ibarfoo,但是上述代码会报错,J位于子模块中,对于父模块是不可见的。如果不改变代码的形式,同时满足要求,就需要限制可见性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub mod a {
pub const I: i32 = 3;

fn semisecret(x: i32) -> i32 {
use self::b::c::J;
x + J
}

pub fn bar(z: i32) -> i32 {
semisecret(I) * z
}
pub fn foo(y: i32) -> i32 {
semisecret(I) + y
}

mod b {
pub(in crate::a) mod c {
pub(in crate::a) const J: i32 = 4;
}
}
}

b中,使用pub(in crate::a) 的方式,我们指定了模块 c 和常量 J 是公有的,但它们的可见范围都只在a模块中,a 之外的模块完全访问不到它们。

这就是受限可见性,通常有如下几种使用方法:

  • pub 意味着可见性无任何限制
  • pub(crate) 表示在当前包可见
  • pub(self) 在当前模块可见
  • pub(super) 在父模块可见
  • pub(in <path>) 表示在某个路径代表的模块中可见,其中 path 必须是父模块或者祖先模块

它们在实际项目中会非常有用。

11.14 将模块拆分成多个文件

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

我们使用在重导出中的代码作为开始。

文件名:src/lib.rs

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

我们会将模块提取到各自的文件中,而不是将所有模块都定义到crate根文件中。在这里,crate根文件是src/lib.rs,不过这个过程也适用于crate根文件是src/main.rs的二进制crate

首先将 front_of_house 模块提取到其自己的文件中。删除 front_of_house 模块的大括号中的代码,只留下 mod front_of_house; 声明。

文件名:src/lib.rs

1
2
3
4
5
6
7
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

接下来将之前大括号内的代码放入一个名叫src/front_of_house.rs的新文件中。因为编译器找到了crate根中名叫 front_of_house 的模块声明,它就知道去搜寻这个文件。

文件名:src/front_of_house.rs

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

注意你只需在模块树中的某处使用一次 mod 声明就可以加载这个文件。一旦编译器知道了这个文件是项目的一部分(并且通过 mod 语句的位置知道了代码在模块树中的位置),项目中的其它文件应该使用其所声明的位置的路径来引用那个文件的代码。换句话说,mod 并不是某些编程语言中看到的“include“操作。

接下来我们同样将 hosting 模块提取到自己的文件中。这个过程会有所不同,因为 hostingfront_of_house 的子模块而不是根模块。我们将 hosting 的文件放在与模块树中它的父级模块同名的目录中,在这里是src/front_of_house/

为了移动 hosting,修改src/front_of_house.rs使之仅包含 hosting 模块的声明。

文件名:src/front_of_house.rs

1
pub mod hosting;

接着我们创建一个src/front_of_house目录和一个包含 hosting 模块定义的hosting.rs文件。

文件名:src/front_of_house/hosting.rs

1
pub fn add_to_waitlist() {}

如果将hosting.rs放在src目录,编译器会认为 hosting 模块中的hosting.rs的代码声明于crate根,而不是声明为 front_of_house 的子模块。编译器所遵循的哪些文件对应哪些模块的代码的规则,意味着目录和文件更接近于模块树。

现在我们将各个模块的代码移动到独立文件了,同时模块树依旧相同。eat_at_restaurant 中的函数调用也无需修改继续保持有效,即便其定义存在于不同的文件中。这个技巧让你可以在模块代码增长时,将它们移动到新文件中。

注意,src/lib.rs中的 pub use crate::front_of_house::hosting 语句是没有改变的,在文件作为crate的一部分而编译时,use 不会有任何影响。mod 关键字声明了模块,rust会在与模块同名的文件中查找模块的代码。

11.15 代码组织总结

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

  • crate根节点开始:当编译一个crate, 编译器首先在crate根文件(通常,对于一个库crate而言是src/lib.rs,对于一个二进制crate而言是src/main.rs中寻找需要被编译的代码。
  • 声明模块:在crate根文件中,你可以声明一个新模块;比如,你用mod garden声明了一个叫做garden的模块。编译器会在下列路径中寻找模块代码:
    • 内联,在大括号中,当mod garden后方不是一个分号而是一个大括号
    • 在文件src/garden.rs
    • 在文件src/garden/mod.rs(旧版风格)
  • 声明子模块:在除了crate根节点以外的其他文件中,你可以定义子模块。比如,你可能在src/garden.rs中定义了mod vegetables;。编译器会在以父模块命名的目录中寻找子模块代码:
    • 内联,在大括号中,当mod vegetables后方不是一个分号而是一个大括号
    • 在文件src/garden/vegetables.rs
    • 在文件src/garden/vegetables/mod.rs(旧版风格)
  • 模块中的代码路径:一旦一个模块是你crate的一部分,你可以在隐私规则允许的前提下,从同一个crate内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的Asparagus类型可以在crate::garden::vegetables::Asparagus被找到。
  • 私有 vs 公用:一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用pub mod替代mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub
  • use 关键字:在一个作用域内,use关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用crate::garden::vegetables::Asparagus的作用域,你可以通过 use crate::garden::vegetables::Asparagus;创建一个快捷方式,然后你就可以在作用域中只写Asparagus来使用该类型。

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

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

这个例子中的crate根文件是src/main.rs,该文件包括了:

文件名:src/main.rs

1
2
3
4
5
6
7
8
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
let plant = Asparagus {};
println!("I'm growing {:?}!", plant);
}

pub mod garden;告诉编译器应该包含在src/garden.rs文件中发现的代码:

文件名:src/garden.rs

1
pub mod vegetables;

在此处, pub mod vegetables;意味着在src/garden/vegetables.rs中的代码也应该被包括。这些代码是:

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

12 错误处理

rust中的错误主要分为两类:

  • 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
  • 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的

12.1 使用panic!处理不可恢复的错误

想要触发panic一般有两种方式,一种是通过执行会造成panic的代码(比如越界访问某数组),第二种是panic!宏,主动触发一个不可恢复的错误。通常情况下这些panic会打印出一个错误信息,展开并清理栈数据,然后退出。

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

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

让我们在一个简单的程序中调用 panic!

1
2
3
4
5
6
7
8
fn main() {
panic!("crash and burn");
}

/* 错误如下
thread 'main' panicked at 'crash and burn', src\main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
*/

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic! 宏的调用。在其他情况下,panic! 可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic! 宏调用,而不是我们代码中最终导致 panic! 的那一行。

比如:

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

v[99];
}
/* 错误如下
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
*/

越界访问数组毫无疑问会panic,错误信息里的说明(note)行提醒我们可以设置 RUST_BACKTRACE 环境变量来得到一个 backtracebacktrace是一个执行到目前位置所有被调用的函数的列表。rust的backtrace跟其他语言中的一样:阅读backtrace的关键是从头开始读直到发现你编写的文件。这就是问题的发源地,这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心rust代码,标准库代码或用到的crate代码。

下面就来设置RUST_BACKTRACE=1 并运行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ 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: std::panicking::begin_panic_handler
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\std\src\panicking.rs:575
1: core::panicking::panic_fmt
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\core\src\panicking.rs:64
2: core::panicking::panic_bounds_check
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\core\src\panicking.rs:147
3: core::slice::index::impl$2::index<i32>
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\slice\index.rs:260
4: core::slice::index::impl$0::index
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\slice\index.rs:18
5: alloc::vec::impl$15::index<i32,usize,alloc::alloc::Global>
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\alloc\src\vec\mod.rs:2727
6: myproject::main
at .\src\main.rs:4
7: core::ops::function::FnOnce::call_once<void (*)(),tuple$<> >
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\ops\function.rs:507
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

这里有大量的输出。你实际看到的输出可能因不同的操作系统和rust版本而有所不同。为了获取带有这些信息的backtrace,必须启用debug标识。当不使用 --release 参数运行cargo buildcargo rundebug标识会默认启用,就像这里默认启用了一样。

这里,编译器还会提示你如果想获取更详细的输出,可以使用RUST_BACKTRACE=full

12.2 使用Result处理可恢复的错误

大部分错误并没有严重到需要程序完全停止执行。回忆一下在枚举泛型中介绍过的Result,它定义有OkErr

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

让我们调用一个返回 Result 的函数的例子:

1
2
3
4
5
use std::fs::File;

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

File::open 的返回值是 Result<T, E>。泛型参数 T 会被 File::open 的实现放入成功返回值的类型 std::fs::File,这是一个文件句柄。错误返回值使用的 E 的类型是 std::io::Error。这些返回类型意味着 File::open 调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。File::open 函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。这些信息正好是 Result 枚举所代表的。

File::open 成功时,greeting_file_result 变量将会是一个包含文件句柄的 Ok 实例。当失败时,greeting_file_result 变量将会是一个包含了更多关于发生了何种错误的信息的 Err 实例。

下面就用模式匹配中介绍的方法进行解构:

1
2
3
4
5
6
7
fn main() {
let greeting_file_result = File::open("hello.txt");
let file = match greeting_file_result {
Ok(f) => f,
Err(e) => panic!("Problem opening the file: {:?}", e),
};
}

Option 枚举一样,Result 枚举和其成员也被导入到了prelude中,所以就不需要在 match 分支中的 OkErr 之前指定 Result::了。

这个模式匹配的意思是,当结果是 Ok 时,返回 Ok 成员中的 file 值,然后将这个文件句柄赋值给变量 filematch 之后,我们可以利用这个文件句柄来进行读写;match 的另一个分支处理从 File::open 得到 Err 值的情况。在这种情况下,我们选择调用 panic! 宏。如果当前目录没有一个叫做hello.txt的文件,当运行这段代码时会看到如下来自 panic! 宏的输出:

1
2
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:7:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

匹配不同的错误

当然,你还可以细分错误的类型,如果 File::open 因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望 panic!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);
}
},
};
}

File::open 返回的 Err 成员中的值类型 io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind 值的 kind 方法可供调用。io::ErrorKind 是一个标准库提供的枚举,它的成员对应 io 操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound,它代表尝试打开的文件并不存在。这样,match 就匹配完 greeting_file_result 了,不过对于 error.kind() 还有一个内层 match

我们希望在内层 match 中检查的条件是 error.kind() 的返回值是否为 ErrorKindNotFound 成员。如果是,则尝试通过 File::create 创建文件。然而因为 File::create 也可能会失败,还需要增加一个内层 match 语句。当文件不能被打开,会打印出一个不同的错误信息。外层 match 的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序panic

这里有很多 matchmatch 确实很强大,不过也非常的繁琐。在后面,我们会介绍闭包,这可以用于很多 Result<T, E> 上定义的方法。在处理代码中的 Result<T, E> 值时这些方法可能会更加简洁。比如逻辑相同但是使用闭包和 unwrap_or_else 方法的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}
});
}

这段代码有着相同的行为,但并没有包含任何 match 表达式且更容易阅读。

unwarpexcept

match 能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E> 类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap,它的实现就类似于上一小节的 match 语句。如果 Result 值是成员 Okunwrap 会返回 Ok 中的值。如果 Result 是成员 Errunwrap 会为我们调用 panic!,比如:

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}

如果调用这段代码时不存在hello.txt文件,我们将会看到一个 unwrap 调用 panic! 时提供的错误信息:

1
2
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:4:49
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

还有另一个类似于 unwrap 的方法它还允许我们选择 panic! 的错误信息:expect。使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。

1
2
3
4
5
6
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}

expectunwrap 的使用方式一样:返回文件句柄或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。它看起来像这样:

1
2
thread 'main' panicked at 'hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:5:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

在生产级别的代码中选择 expect 而不是 unwrap 并提供更多关于为何操作期望是一直成功的上下文。如此如果该假设真的被证明是错的,你也有更多的信息来用于调试。

传播错误

当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。换句话说,在获取到这个错误后并不立即进行处理,而是将其传递到上一层调用者的手里。这称为传播(propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

下面展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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),
}
}

首先注意函数的返回值:Result<String, io::Error>。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 的具体类型是 String,而 E 的具体类型是 io::Error

如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 StringOk 值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err 值,它储存了一个包含更多这个问题相关信息的 io::Error 实例。这里选择 io::Error 作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open 函数和 read_to_string 方法。

函数体以调用 File::open 函数开始。接着使用 match 处理返回值 Result,如果 File::open 成功了,模式变量 file 中的文件句柄就变成了可变变量 username_file 中的值,接着函数继续执行。在 Err 的情况下,我们没有调用 panic!,而是使用 return 关键字提前结束整个函数,并将来自 File::open 的错误值(现在在模式变量 e 中)作为函数的错误值传回给调用者。

所以 username_file 中有了一个文件句柄,函数接着在变量 username 中创建了一个新 String 并调用文件句柄 username_fileread_to_string 方法来将文件的内容读取到 username 中。read_to_string 方法也返回一个 Result 因为它也可能会失败:哪怕是 File::open 已经成功了。所以我们需要另一个 match 来处理这个 Result:如果 read_to_string 成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Okusername 中。如果read_to_string 失败了,则像之前处理 File::open 的返回值的 match 那样返回错误值。不过并不需要显式的调用 return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的 Ok 值,或者一个包含 io::ErrorErr 值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err 值,他们可能会选择 panic! 并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

上述代码在实际的使用中很常用,因此rust提供了? 问号运算符来使其更加简便。

使用?传播错误

使用?来简化上一小节的代码:

1
2
3
4
5
6
7
8
9
use std::fs::File;
use std::io::{self, 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)
}

Result 值之后的 ? 被定义为上一小节中处理 Result 值的 match 表达式相同的工作方式:如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。如果值是 ErrErr 中的值将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

上一小节中的match 表达式与 ? 运算符所做的有一点不同:? 运算符所使用的错误值被传递给了 from 函数,它定义于标准库的 From 特征中,其用来将错误从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。例如,我们可以将read_username_from_file函数修改为返回一个自定义的 OurError 错误类型。如果我们也定义了 impl From<io::Error> for OurError 来从 io::Error 构造一个 OurError 实例,那么 read_username_from_file 函数体中的 ? 运算符调用会调用 from 并转换错误而无需在函数中增加任何额外的代码。

File::open 调用结尾的 ? 会将 Ok 中的值返回给变量 username_file。如果发生了错误,? 运算符会使整个函数提前返回并将任何 Err 值返回给调用代码。同理也适用于 read_to_string 调用结尾的 ?

? 运算符消除了大量样板代码并使得函数的实现更简单。我们甚至可以在 ? 之后直接使用链式方法调用来进一步缩短代码:

1
2
3
4
5
6
7
8
9
10
use std::fs::File;
use std::io::{self, 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)
}

我们对 File::open("hello.txt")? 的结果直接链式调用了 read_to_string,而不再创建变量 username_file。仍然需要 read_to_string 调用结尾的 ?,而且当 File::openread_to_string 都成功没有失败时返回包含用户名 usernameOk 值。

当然,还有一个更简单的版本:

1
2
3
4
5
6
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}

将文件读取到一个字符串是相当常见的操作,所以rust提供了名为 fs::read_to_string 的函数,它会打开文件、新建一个 String、读取文件的内容,并将内容放入 String,接着返回它。

使用?的位置

? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。因为 ? 运算符被定义为从函数中提早返回一个值,让我们看看在返回值不兼容的 main 函数中使用 ? 运算符会得到什么错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")?;
}
/* 错误如下
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
*/

这段代码打开一个文件,这可能会失败。? 运算符作用于 File::open 返回的 Result 值,不过 main 函数的返回类型是 () 而不是 Result。这个错误指出只能在返回 Result 或者其它实现了 FromResidual 的类型的函数中使用 ? 运算符。

为了修复这个错误,有两个选择。一个是如果没有限制的话将函数的返回值改为 Result<T, E>。另一个是使用 matchResult<T, E> 的方法中合适的一个来处理 Result<T, E>

错误信息也提到 ? 也可用于 Option<T> 值。如同对 Result 使用 ? 一样,只能在返回 Option 的函数中对 Option 使用 ?。在 Option<T> 上调用 ? 运算符的行为与 Result<T, E> 类似:如果值是 None,此时 None 会从函数中提前返回。如果值是 SomeSome 中的值作为表达式的返回值同时函数继续。比如:

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

上面的例子是一个从给定文本中返回第一行最后一个字符的函数。

这个函数返回 Option<char> 因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取 text 字符串切片作为参数并调用其 lines 方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器 next 来获取迭代器中第一个值。如果 text 是空字符串,next 调用会返回 None,此时我们可以使用 ? 来停止并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,next 会返回一个包含 text 中第一行的字符串sliceSome 值。

? 会提取这个字符串切片,然后可以在字符串切片上调用 chars 来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用 last 来返回迭代器的最后一项。这是一个 Option,因为有可能第一行是一个空字符串,例如 text 以一个空行开头而后面的行有文本,像是 "\nhi"。不过,如果第一行有最后一个字符,它会返回在一个 Some 成员中。? 运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在 Option 上使用 ? 运算符,则不得不使用更多的方法调用或者 match 表达式来实现这些逻辑。

注意你可以在返回 Result 的函数中对 Result 使用 ? 运算符,可以在返回 Option 的函数中对 Option 使用 ? 运算符,但是不可以混合搭配。? 运算符不会自动将 Result 转化为 Option,反之亦然;在这些情况下,可以使用类似 Resultok 方法或者 Optionok_or 方法来显式转换。

幸运的是 main 函数也可以返回 Result<(), E>,下面的代码修改了 main 的返回值为 Result<(), Box<dyn Error>> 并在结尾增加了一个 Ok(()) 作为返回值。可以通过编译:

1
2
3
4
5
6
7
8
use std::error::Error;
use std::fs::File;

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

Ok(())
}

Box<dyn Error> 类型是一个特征对象,通道式涉及到智能指针的相关内容,我们会在后续介绍。目前可以将 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 值则会以非零值退出;成功退出的程序会返回整数 0,运行错误的程序会返回非 0 的整数。rust也会从二进制程序中返回与这个惯例相兼容的整数。

main 函数也可以返回任何实现了 std::process::Termination 特征的类型,它包含了一个返回 ExitCodereport 函数。请查阅标准库文档了解更多为自定义类型实现 Termination 特征的细节。

12.3 何时使用panic!

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

下面总结了一个通用指导原则:

在当有可能会导致有害状态的情况下建议使用 panic! —— 在这里,有害状态是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 —— 外加如下几种情况:

  • 有害状态是非预期的行为,与偶尔会发生的行为相对,比如用户输入了错误格式的数据。
  • 在此之后代码的运行依赖于不处于这种有害状态,而不是在每一步都检查是否有问题。
  • 没有可行的手段来将有害状态信息编码进所使用的类型中的情况。

如果别人调用你的代码并传递了一个没有意义的值,尽最大可能返回一个错误,如此库的用户就可以决定在这种情况下该如何处理。然而在继续执行代码是不安全或有害的情况下,最好的选择可能是调用 panic! 并警告库的用户他们的代码中有 bug,这样他们就会在开发时进行修复。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么 panic! 往往是合适的。

然而当错误预期会出现时,返回 Result 仍要比调用 panic! 更为合适。这样的例子包括解析器接收到格式错误的数据,或者 HTTP 请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回 Result 来表明失败预期是可能的,这样将有害状态向上传播,调用者就可以决定该如何处理这个问题。使用 panic! 来处理这些情况就不是最好的选择。

当你的代码在进行一个使用无效值进行调用时可能将用户置于风险中的操作时,代码应该首先验证值是有效的,并在其无效时 panic!。这主要是出于安全的原因:尝试操作无效数据会暴露代码漏洞,这就是标准库在尝试越界访问数组时会 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全隐患。函数通常都遵循契约(contracts):他们的行为只有在输入满足特定条件时才能得到保证。当违反契约时panic是有道理的,因为这通常代表调用方的bug,而且这也不是那种你希望所调用的代码必须处理的错误。事实上所调用的代码也没有合理的方式来恢复,而是需要调用方的程序员修复其代码。函数的契约,尤其是当违反它会造成panic的契约,应该在函数的 API 文档中得到解释。

虽然在所有函数中都拥有许多错误检查是冗长而烦人的。幸运的是,可以利用rust的类型系统(以及编译器的类型检查)为你进行很多检查。如果函数有一个特定类型的参数,可以在知晓编译器已经确保其拥有一个有效值的前提下进行你的代码逻辑。例如,如果你使用了一个并不是 Option 的类型,则程序期望它是有值的并且不是空值。你的代码无需处理 SomeNone 这两种情况,它只会有一种情况就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像 u32 这样的无符号整型,也会确保它永远不为负。

总之rust的错误处理功能被设计为帮助你编写更加健壮的代码。panic! 宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。rust类型系统的 Result 枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result 来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!Result 将会使你的代码在面对不可避免的错误时显得更加可靠。