包装箱和模块

crates-and-modules.md


commit 573222ed32fff4e0cb27be940a97344f339ab98b

当一个项目变大以后,良好的软件工程实践是把它分为一堆较小的部分,再把它们装配到一起。定义良好的接口也非常重要,以使有些功能是私有的,而有些是公有的。Rust 有一个模块系统来帮助我们处理这些工作。

基础术语:包装箱和模块

Rust 有两个不同的术语与模块系统有关:包装箱crate)和模块module)。包装箱是其它语言中library)或package)的同义词。因此,“Cargo”则是 Rust 包管理工具的名字:你通过 Cargo 把你的包装箱交付给别人。包装箱可以根据项目的不同,生成可执行文件或库文件。

每个包装箱有一个隐含的根模块root module)包含了该包装箱的代码。你可以在根模块下定义一个子模块树。模块让你可以在包装箱内部为代码分区。

作为一个例子,让我们来创建一个短语phrases)包装箱,它会给我们一些不同语言的短语。为了简单起见,仅有“你好”和“再见”这两种短语,并使用英语和日语作为这些短语的语言。我们采用如下模块布局:

  1. +-----------+
  2. +---| greetings |
  3. +---------+ | +-----------+
  4. +---| english |---+
  5. | +---------+ | +-----------+
  6. | +---| farewells |
  7. +---------+ | +-----------+
  8. | phrases |---+
  9. +---------+ | +-----------+
  10. | +---| greetings |
  11. | +----------+ | +-----------+
  12. +---| japanese |--+
  13. +----------+ | +-----------+
  14. +---| farewells |
  15. +-----------+

在这个例子中,phrases是我们包装箱的名字。剩下所有的都是模块。你可以看到它们组成了一个树,以包装箱为(即phrases树的根)分叉出来。

现在我们想要在代码中定义这些模块。首先,用 Cargo 创建一个新包装箱:

  1. $ cargo new phrases
  2. $ cd phrases

如果你还记得,这会生成一个简单的项目:

  1. $ tree .
  2. .
  3. ├── Cargo.toml
  4. └── src
  5. └── lib.rs
  6. 1 directory, 2 files

src/lib.rs是我们包装箱的根,与上面图表中的phrases对应。

定义模块

我们用mod关键字来定义我们的每一个模块。让我们把src/lib.rs写成这样:

  1. mod english {
  2. mod greetings {
  3. }
  4. mod farewells {
  5. }
  6. }
  7. mod japanese {
  8. mod greetings {
  9. }
  10. mod farewells {
  11. }
  12. }

mod关键字之后是模块的名字。模块的命名采用Rust其它标识符的命名惯例:lower_snake_case。在大括号中({})是模块的内容。

mod中,你可以定义子mod。我们可以用双冒号(::)标记访问子模块。我们的4个嵌套模块是english::greetingsenglish::farewellsjapanese::greetingsjapanese::farewells。因为子模块位于父模块的命名空间中,所以这些不会冲突:english::greetingsjapanese::greetings是不同的,即便它们的名字都是greetings

因为这个包装箱的根文件叫做lib.rs,且没有一个main()函数。Cargo会把这个包装箱构建为一个库:

  1. $ cargo build
  2. Compiling phrases v0.0.1 (file:///home/you/projects/phrases)
  3. $ ls target/debug
  4. build deps examples libphrases-a7448e02a0468eaa.rlib native

libphrases-<hash>.rlib是构建好的包装箱。在我们了解如何使用这个包装箱之前,先让我们把它拆分为多个文件。

多文件包装箱

如果每个包装箱只能有一个文件,这些文件将会变得非常庞大。把包装箱分散到多个文件也非常简单,Rust支持两种方法。

除了这样定义一个模块外:

  1. mod english {
  2. // Contents of our module go here.
  3. }

我们还可以这样定义:

  1. mod english;

如果我们这么做的话,Rust会期望能找到一个包含我们模块内容的english.rs文件,或者包含我们模块内容的english/mod.rs文件:

注意在这些文件中,你不需要重新定义这些模块:它们已经由最开始的mod定义。

使用这两个技巧,我们可以将我们的包装箱拆分为两个目录和七个文件:

  1. $ tree .
  2. .
  3. ├── Cargo.lock
  4. ├── Cargo.toml
  5. ├── src
  6. ├── english
  7. ├── farewells.rs
  8. ├── greetings.rs
  9. └── mod.rs
  10. ├── japanese
  11. ├── farewells.rs
  12. ├── greetings.rs
  13. └── mod.rs
  14. └── lib.rs
  15. └── target
  16. └── debug
  17. ├── build
  18. ├── deps
  19. ├── examples
  20. ├── libphrases-a7448e02a0468eaa.rlib
  21. └── native

src/lib.rs是我们包装箱的根,它看起来像这样:

  1. mod english;
  2. mod japanese;

这两个定义告诉 Rust 去寻找

  • src/english.rssrc/english/mod.rs
  • src/japanese.rssrc/japanese/mod.rs

具体根据你的偏好。在我们的例子中,因为我们的模块含有子模块,所以我们选择第二种方式。src/english/mod.rssrc/japanese/mod.rs都看起来像这样:

  1. mod greetings;
  2. mod farewells;

再一次,这些定义告诉 Rust 去寻找

  • src/english/greetings.rssrc/english/greetings/mod.rs
  • src/english/farewells.rssrc/english/farewells/mod.rs
  • src/japanese/greetings.rssrc/japanese/greetings/mod.rs
  • src/japanese/farewells.rssrc/japanese/farewells/mod.rs

因为这些子模块没有自己的子模块,我们选择src/english/greetings.rssrc/japanese/farewells.rs

现在src/english/greetings.rssrc/japanese/farewells.rs都是空的。让我们添加一些函数。

src/english/greetings.rs添加如下:

  1. fn hello() -> String {
  2. "Hello!".to_string()
  3. }

src/english/farewells.rs添加如下:

  1. fn goodbye() -> String {
  2. "Goodbye.".to_string()
  3. }

src/japanese/greetings.rs添加如下:

  1. fn hello() -> String {
  2. "こんにちは".to_string()
  3. }

当然,你可以从本文复制粘贴这些内容,或者写点别的东西。事实上你写进去“konnichiwa”对我们学习模块系统并不重要。

src/japanese/farewells.rs添加如下:

  1. fn goodbye() -> String {
  2. "さようなら".to_string()
  3. }

(这是“Sayōnara”,如果你很好奇的话。)

现在我们在包装箱中添加了一些函数,让我们尝试在别的包装箱中使用它。

导入外部的包装箱

我们有了一个库包装箱。让我们创建一个可执行的包装箱来导入和使用我们的库。

创建一个src/main.rs文件然后写入如下:(现在它还不能编译)

  1. extern crate phrases;
  2. fn main() {
  3. println!("Hello in English: {}", phrases::english::greetings::hello());
  4. println!("Goodbye in English: {}", phrases::english::farewells::goodbye());
  5. println!("Hello in Japanese: {}", phrases::japanese::greetings::hello());
  6. println!("Goodbye in Japanese: {}", phrases::japanese::farewells::goodbye());
  7. }

extern crate声明告诉Rust我们需要编译和链接phrases包装箱。然后我们就可以在这里使用phrases的模块了。就像我们之前提到的,你可以用双冒号引用子模块和之中的函数。

(注意:当导入像“like-this”名字中包含连字符的 crate时,这样的名字并不是一个有效的 Rust 标识符,它可以通过将连字符变为下划线来转换,所以你应该写成extern crate like_this;

另外,Cargo假设src/main.rs是二进制包装箱的根,而不是库包装箱的。现在我们的包中有两个包装箱:src/lib.rssrc/main.rs。这种模式在可执行包装箱中非常常见:大部分功能都在库包装箱中,而可执行包装箱使用这个库。这样,其它程序可以只使用我们的库,另外这也是各司其职的良好分离。

现在它还不能很好的工作。我们会得到 4 个错误,它们看起来像:

  1. $ cargo build
  2. Compiling phrases v0.0.1 (file:///home/you/projects/phrases)
  3. src/main.rs:4:38: 4:72 error: function `hello` is private
  4. src/main.rs:4 println!("Hello in English: {}", phrases::english::greetings::hello());
  5. ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  6. note: in expansion of format_args!
  7. <std macros>:2:25: 2:58 note: expansion site
  8. <std macros>:1:1: 2:62 note: in expansion of print!
  9. <std macros>:3:1: 3:54 note: expansion site
  10. <std macros>:1:1: 3:58 note: in expansion of println!
  11. phrases/src/main.rs:4:5: 4:76 note: expansion site

Rust 默认一切都是私有的。让我们深入了解一下这个。

导出公用接口

Rust 允许你严格的控制你的接口哪部分是公有的,所以它们默认都是私有的。你需要使用pub关键字,来公开它。让我们先关注english模块,所以让我们像这样减少src/main.rs的内容:

  1. extern crate phrases;
  2. fn main() {
  3. println!("Hello in English: {}", phrases::english::greetings::hello());
  4. println!("Goodbye in English: {}", phrases::english::farewells::goodbye());
  5. }

在我们的src/lib.rs,让我们给english模块声明添加一个pub

  1. pub mod english;
  2. mod japanese;

然后在我们的src/english/mod.rs中,加上两个pub

  1. pub mod greetings;
  2. pub mod farewells;

在我们的src/english/greetings.rs中,让我们在fn声明中加上pub

  1. pub fn hello() -> String {
  2. "Hello!".to_string()
  3. }

然后在src/english/farewells.rs中:

  1. pub fn goodbye() -> String {
  2. "Goodbye.".to_string()
  3. }

这样,我们的包装箱就可以编译了,虽然会有警告说我们没有使用japanese的方法:

  1. $ cargo run
  2. Compiling phrases v0.0.1 (file:///home/you/projects/phrases)
  3. src/japanese/greetings.rs:1:1: 3:2 warning: function is never used: `hello`, #[warn(dead_code)] on by default
  4. src/japanese/greetings.rs:1 fn hello() -> String {
  5. src/japanese/greetings.rs:2 "こんにちは".to_string()
  6. src/japanese/greetings.rs:3 }
  7. src/japanese/farewells.rs:1:1: 3:2 warning: function is never used: `goodbye`, #[warn(dead_code)] on by default
  8. src/japanese/farewells.rs:1 fn goodbye() -> String {
  9. src/japanese/farewells.rs:2 "さようなら".to_string()
  10. src/japanese/farewells.rs:3 }
  11. Running `target/debug/phrases`
  12. Hello in English: Hello!
  13. Goodbye in English: Goodbye.

现在我们的函数是公有的了,我们可以使用它们。好的!然而,phrases::english::greetings::hello()非常长并且重复。Rust 有另一个关键字用来导入名字到当前空间中,这样我们就可以用更短的名字来引用它们。让我们聊聊use

use导入模块

Rust有一个use关键字,它允许我们导入名字到我们本地的作用域中。让我们把src/main.rs改成这样:

  1. extern crate phrases;
  2. use phrases::english::greetings;
  3. use phrases::english::farewells;
  4. fn main() {
  5. println!("Hello in English: {}", greetings::hello());
  6. println!("Goodbye in English: {}", farewells::goodbye());
  7. }

这两行use导入了两个模块到我们本地作用域中,这样我们就可以用一个短得多的名字来引用函数。作为一个传统,当导入函数时,导入模块而不是直接导入函数被认为是一个最佳实践。也就是说,你可以这么做:

  1. extern crate phrases;
  2. use phrases::english::greetings::hello;
  3. use phrases::english::farewells::goodbye;
  4. fn main() {
  5. println!("Hello in English: {}", hello());
  6. println!("Goodbye in English: {}", goodbye());
  7. }

不过这并不理想。这意味着更加容易导致命名冲突。在我们的小程序中,这没什么大不了的,不过随着我们的程序增长,它将会成为一个问题。如果我们有命名冲突,Rust会给我们一个编译错误。举例来说,如果我们将japanese的函数设为公有,然后这样尝试:

  1. extern crate phrases;
  2. use phrases::english::greetings::hello;
  3. use phrases::japanese::greetings::hello;
  4. fn main() {
  5. println!("Hello in English: {}", hello());
  6. println!("Hello in Japanese: {}", hello());
  7. }

Rust会给我们一个编译时错误:

  1. Compiling phrases v0.0.1 (file:///home/you/projects/phrases)
  2. src/main.rs:4:5: 4:40 error: a value named `hello` has already been imported in this module [E0252]
  3. src/main.rs:4 use phrases::japanese::greetings::hello;
  4. ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  5. error: aborting due to previous error
  6. Could not compile `phrases`.

如果你从同样的模块中导入多个名字,我们不必写多遍。Rust有一个简便的语法:

  1. use phrases::english::greetings;
  2. use phrases::english::farewells;

我们可以使用这个简写:

  1. use phrases::english::{greetings, farewells};

使用pub use重导出

你不仅可以用use来简化标识符。你也可以在包装箱内用它重导出函数到另一个模块中。这意味着你可以展示一个外部接口可能并不直接映射到内部代码结构。

让我们看个例子。修改src/main.rs让它看起来像这样:

  1. extern crate phrases;
  2. use phrases::english::{greetings,farewells};
  3. use phrases::japanese;
  4. fn main() {
  5. println!("Hello in English: {}", greetings::hello());
  6. println!("Goodbye in English: {}", farewells::goodbye());
  7. println!("Hello in Japanese: {}", japanese::hello());
  8. println!("Goodbye in Japanese: {}", japanese::goodbye());
  9. }

然后修改src/lib.rs公开japanese模块:

  1. pub mod english;
  2. pub mod japanese;

接下来,把这两个函数声明为公有,先是src/japanese/greetings.rs

  1. pub fn hello() -> String {
  2. "こんにちは".to_string()
  3. }

然后是src/japanese/farewells.rs

  1. pub fn goodbye() -> String {
  2. "さようなら".to_string()
  3. }

最后,修改你的src/japanese/mod.rs为这样:

  1. pub use self::greetings::hello;
  2. pub use self::farewells::goodbye;
  3. mod greetings;
  4. mod farewells;

pub use声明将这些函数导入到了我们模块结构空间中。因为我们在japanese模块内使用了pub use,我们现在有了phrases::japanese::hello()phrases::japanese::goodbye()函数,即使它们的代码在phrases::japanese::greetings::hello()phrases::japanese::farewells::goodbye()函数中。内部结构并不反映外部接口。

这里我们对每个我们想导入到japanese空间的函数使用了pub use。我们也可以使用通配符来导入greetings的一切到当前空间中:pub use self::greetings::*

那么self怎么办呢?好吧,默认,use声明是绝对路径,从你的包装箱根目录开始。self则使路径相对于你在结构中的当前位置。有一个更特殊的use形式:你可以使用use super::来到达你树中当前位置的上一级。一些同学喜欢把self看作.而把super看作..,它们在许多shell表示为当前目录和父目录。

除了use之外,路径是相对的:foo::bar()引用一个相对我们位置的foo中的函数。如果它带有::前缀,它引用了一个不同的foo,一个从你包装箱根开始的绝对路径。

另外,注意pub use出现在mod定义之前。Rust要求use位于最开始。

构建然后运行:

  1. $ cargo run
  2. Compiling phrases v0.0.1 (file:///home/you/projects/phrases)
  3. Running `target/debug/phrases`
  4. Hello in English: Hello!
  5. Goodbye in English: Goodbye.
  6. Hello in Japanese: こんにちは
  7. Goodbye in Japanese: さようなら

复杂的导入

Rust 提供了多种高级选项来让你的extern crateuse语句变得简洁方便。这是一个例子:

  1. extern crate phrases as sayings;
  2. use sayings::japanese::greetings as ja_greetings;
  3. use sayings::japanese::farewells::*;
  4. use sayings::english::{self, greetings as en_greetings, farewells as en_farewells};
  5. fn main() {
  6. println!("Hello in English; {}", en_greetings::hello());
  7. println!("And in Japanese: {}", ja_greetings::hello());
  8. println!("Goodbye in English: {}", english::farewells::goodbye());
  9. println!("Again: {}", en_farewells::goodbye());
  10. println!("And in Japanese: {}", goodbye());
  11. }

这里发生了什么?

首先,extern crateuse都允许重命名导入的项。所以 crate 仍然叫“phrases”,不过这里我们以“sayings”来引用它。类似的,第一个use语句从 crate 中导入japanese::greetings,不过作为ja_greetings而不是简单的greetings。这可以帮助我们消除来自不同包中相似名字的项的歧义。

第二个use语句用了一个星号来引入sayings::japanese::farewells模块中的所有公有符号。如你所见之后我们可以不用模块标识来引用日语的goodbye函数。这类全局引用要保守使用。需要注意的是它只引入公有符号,哪怕在相同模块的代码中引入。

第三个use语句需要更多的解释。它使用了“大括号扩展(brace expansion)”来将三条use语句压缩成了一条(这类语法对曾经写过 Linux shell 脚本的人应该很熟悉)。语句的非压缩形式应该是:

  1. use sayings::english;
  2. use sayings::english::greetings as en_greetings;
  3. use sayings::english::farewells as en_farewells;

如你所见,大括号压缩了位于同一位置的多个项的use语句,而且在这里self指向这个位置。注意:大括号不能与星号嵌套或混合。