跳转至

0x07 Managing Growing Projects with Packages, Crates and Modules*

目前写的程序都是在一个文件一个模块中。随着项目的增长,可以通过将代码分解为多个模块和多个文件来组织代码。一个包可以包含多个二进制 crate 和一个可选的库 crate。随着包的增长,可以把包中的部分代码提取出来,做成独立的 crate,作为外部依赖。本章将会涵盖这些概念。对于一个由一系列相互关联的包组合而成的大型项目,Cargo 提供了“工作空间”功能,将在第十四章讲解。

除了对功能进行分组外,封装实现细节可以使你更高级地重用代码:其他代码通过公共接口调用,而不需要知道内部实现。写代码时可以定义哪些部分是其他代码可以使用的公共部分,哪些部分是仅自己有权修改实现细节的私有部分。

这就要引入作用域(scope)的概念,即代码所在的嵌套上下文 。阅读、编写和编译代码时,程序员和编译器需要知道特定位置的特定名称是否引用了变量、函数、结构体、枚举、模块、常量或其他有意义的项。可以创建作用域,并指定哪些名称在作用域内或外。同一作用域内不能拥有两个相同名称的项,可以使用一些工具来解决命名冲突。

Rust 有很多功能来管理组织代码,称为模块系统(the module system),包括:

  • 包(Packages):Cargo 的功能,允许构建、测试和分享 crate。
  • Crates:生成库和二进制文件的模块树。
  • 模块(Modules)和 use:允许控制组织、作用域和路径私有性。
  • 路径(path):命名结构体、函数或模块的方式。

本章将涵盖上述所有概念,讨论它们如何交互,并说明如何使用它们来管理作用域。

Packages and Crates*

首先介绍包和 crate。crate 是一个可执行二进制文件或库。所谓 crate root 是一个源文件,Rust 编译器以它为起点来构建 crate 根模块(在下一节会详细解释)。包(package)是提供一系列功能的一个或多个 crate。一个包中有一个 Cargo.toml 文件来描述如何构建其中的 crate。

关于包的内容有几条规则。一个包最多只能包含一个库 crate,可以包含任意多个二进制文件 crate,包中至少要包含一个 crate。

前面已经提到过,用 cargo new 创建新项目,也就是创建包。新建项目 my-project

创建包的流程已经很熟悉了,Cargo 会创建一个 Cargo.toml 文件,以及 src/main.rs 文件。Cargo 默认把 src/main.rs 作为与包同名的二进制 crate 的 crate 根。同样,如果 Cargo 发现包目录包含 src/lib.rs,则包带有与其同名的库 crate,且 src/lib.rs 就是 crate 根。crate 跟文件将由 Cargo 传递给 rustc 来实际构建库或二进制项目。

现在有了一个包含 src/main.rs 的包,也就是它现在只含有 my-project 的二进制 crate。如果可以通过把文件放到 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会编译成一个单独的二进制 crate。

一个 crate 会将一个作用域内的相关功能分组到一起,使得该功能可以很方便地在多个项目之间共享。比如第二章使用的 rand crate 提供了生成随机数的功能。通过将 rand crate 加入到项目的作用域中,就可以在项目中使用功能。

将一个 crate 的功能保持在其自身的作用域中,可以知晓一些特定的功能是在自己的 crate 定义的还是在 rand crate 中定义的,这可以防止潜在的冲突。

Defining Modules to Control Scope and Privacy*

模块让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。模块还可以控制项的私有性,即是否可以被外部代码使用(public or private)。

使用 cargo new --lib 创建库项目。

定义模块,是以 mod 关键字起始,然后指定模块名,用大括号包裹模块主体。模块支持嵌套,在模块中可以定义结构体、枚举、常量、trait 或者函数等。

通过使用模块,可以将相关的定义组合到一起。模块之间的关系可以形成模块树结构。

Paths for Referring to an Item in the Module Tree*

Rust 使用路径的方式在模块在模块树中找到一个项的位置,类似文件系统使用路径。和文件系统类似,有绝对路径(从 crate 根开始,以 crate 名或字面值 crate 开头)和相对路径(从当前模块名或 self/super 开始)。绝对路径和相对路径都由 :: 分割。

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

这段代码给出了使用绝对和相对路径调用函数的示例。但这段代码会编译报错。

error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^ private module

hosting 模块是私有的,虽然拥有正确路径,但 Rust 不允许访问私有片段。

模块定义了 Rust 的私有边界,不允许外部代码了解、调用和依赖被封装的实现细节。Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块的项不能使用子模块的私有项,但子模块中的项可以使用它们父模块中的项。

可以通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

使用 pub 关键字暴露路径*

前面例子报错 hosting 模块是私有的,如果想在父模块中的函数中调用子模块的函数,就需要把 hosting 模块标记为公共项。

但是只在 hosting 模块处加 pub 还是会报错,这回轮到内部的 add_to_waitlist 函数了,报错提示它仍然是私有的,也就是说模块公有并不会使其内容也是公有。所以还需要把函数标记为公有,这样才终于通过编译。

所以要想通过路径访问模块树中的项,必须保证每一步都可访问,要么是位于同一模块或父模块,要么必须标记为公有。

还可以使用 super 开头来构建从父模块开始的相对路径,类似于文件系统中的 ..。当子模块中的函数需要访问父模块或祖先模块中的内容时就需要用到 super,而且可以和 ../.. 类似,可以嵌套。

创建公有的结构体和枚举*

如果在结构体定义前使用 pub,结构体变成公有的,但是结构体的字段仍然是私有的。也就是可以决定每个字段是否公有。

但是如果把枚举设为公有,它的所有成员都将变为公有。因为枚举通过列举所有可能成员来定义类型必须可以访问所有的成员才能正常使用。

Bringing Path into Scope with the Use Keyword*

使用路径的方式很麻烦,使用 use 关键字可以将路径一次性引入作用域,再调用该路径中的项,就如同它们是本地项一样。

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();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

只要使用绝对路径把 hosting 模块引入作用域,就可以直接指定 hosting::add_to_waitlist 来调用函数。use 相当于文件系统创建符号链接,通过 use 引入的路径也要检查私有性。use 后也可使用相对路径。

实际上我们直接可以把 use 引入的路径写到具体函数,但是这样就无法清晰地表明函数是否在本地定义。所以一般需要调用函数时一般习惯是引入函数的父模块。

而如果使用 use 引入结构体、枚举或其他项时,一般习惯是指定完整路径。但是如果 use 语句会将两个相同名称的项引入作用域,则不能都指定完整路径,否则同一作用域中会有两个相同名称的项,编译报错。在这种情况下,还可以使用 as 指定一个新的本地使用的别名,类似 python。

使用 pub use 重导出名称*

使用 use 关键字导入到作用域时,新作用域中可用的名称是私有的。使用 pub use 实现重导出(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();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

通过使用 pub use,外部代码可以使用 hosting::add_to_waitlist 来调用函数,而如果没指定 pub use,外部代码就无法调用 add_to_waitlist 函数。

当代码的内部结构与调用代码的程序员的思考领域不同时,重导出会很有用。

使用外部包*

第二章使用了外部包 rand 来生成随机数。使用外部包首先要在 Cargo.toml 中添加依赖性,指定依赖名和版本。然后将外部包引入项目的作用域。标准库其实也是外部包,只不过不用让 Cargo 下载,直接用 use 引入即可使用。

这里还没讲怎么使用本地包,rust - How to use a local unpublished crate? - Stack Overflow

嵌套路径来消除大量的引入行*

如果要引入很多定义在相同包或相同模块的项,需要写很多行可以将路径相同的部分合并到一起。

use std::cmp::Ordering;
use std::io;
// 合并
use std::{cmp::Ordering, io};

还可以使用 self,即 std::io 可以写成 std::io::{self}

如果想将一个路径下所有的公有项引入作用域,可以用路径后跟 *

use std::collections::*;

使用 * 就会导致难以判断作用域中有什么名称和它们是在何处定义的。

Separating Modules into Different Files*

当模块变得很大时,可能会想将它移动到多个文件中。

mod front_of_house;

pub use crate::front_of_house::hosting;

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

在 crate 根文件中声明 front_of_house 模块,其内容位于 src/front_of_house.rs

// src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}

在模块名之后使用分号,而不是代码块,将告知 Rust 在与模块同名的文件中加载模块的内容。这样就可以将所有的模块都提取到自己的文件中。

模块书依然相同,通过路径的函数调用也保持有效,use 不会有任何影响。使用 mod 关键字声明模块,Rust 就会在模块同名文件中查找模块。

似乎还可以把模块移动到 src/front_of_house/mod.rs 中,Rust 也会找到。

Rust 2018 还支持同时存在 src/front_of_house.rssrc/front_of_house/ ,这样我们可以把子模块放到文件夹中。

总结*

Rust 提供将包分成多个 crate,将 crate 分成模块,以及通过指定绝对或相对路径从一个模块引用另一个模块中定义的项的方式。模块定义的代码默认是私有的,可以通过 pub 定义为公有。使用 use 语句将路径引入作用域。

下面将介绍一些标准库提供的集合数据类型。


最后更新: May 20, 2021