2026最新Rust入门教程[07]

0 阅读26分钟

Rust Book 编译版 07 · 包、crate 与模块:Rust 的代码组织与可见性

编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。

开始之前

前面六章的程序都挤在一个文件里,项目初始阶段没问题,但项目一大,就得把相关功能聚在一起、把无关代码拆开,让它便于维护——手段就是分模块、分文件,再往上是分 crate、分包。Rust 这套东西概念多得吓人:包(package)、crate、模块(module)、路径(path)、use、pub……但在我看来,核心只有两件事:路径和可见性。

这一整套特性合称模块系统(module system):包是 Cargo 构建、测试与分享 crate 的单位;crate 是一棵模块树,产出库或可执行程序;模块与 use 控制组织结构、作用域与私有性;路径为项(item)命名。顺带交代两件本章不展开的事:封装好的操作通过公共接口(public interface)复用、实现细节藏成私有;至于把一组相互关联的包组织在一起的 Cargo 工作空间(workspace),第 14 章再见。

package(包)= Cargo.toml + 一到多个 crate
├── src/main.rs ── 二进制 crate 的根(有 main,编译成可执行文件)
├── src/lib.rs  ── 库 crate 的根(无 main,供别人复用;一个包至多一个)
└── crate 内部是一棵模块树:
    crate(隐式根模块)
    └── 模块 / 子模块 / 项(函数、结构体、枚举、常量……)
        

前置知识:第 6 章「枚举与模式匹配」。

你会学到:?

  • 分清包与 crate:一个包如何同时装下二进制 crate 和库 crate
  • 用 mod 在 crate 内搭出模块树,用绝对/相对路径(crate、self、super)引用任意项
  • 掌握 Rust「默认私有」的可见性规则,知道 pub 该加在哪一层——模块、函数、结构体字段还是枚举
  • 用 use、as、pub use、嵌套路径与 glob 写出惯用的引用代码
  • 把模块拆分到独立文件,分清 foo.rs 与 foo/mod.rs 两种文件布局

包与 crate

crate 是 Rust 编译器一次视作一个整体的最小代码单元——哪怕不跑 cargo、直接 rustc 一个源文件(第 1 章干过),这个文件也算一个 crate。crate 里可以包含模块,模块可以分布在其他文件里,随 crate 一起编译。

crate 有两种形态:

  • 二进制 crate:编译成可执行文件来运行,必须有 main 函数作为入口。此前我们创建的项目全是它。
  • 库 crate:没有 main,也不产出可执行文件,定义的是给别人复用的功能,比如第 2 章用过的 rand。平时说「crate」,多数时候指的就是库 crate。

每个 crate 都有一个起点,叫 crate 根(crate root):编译器从这个源文件出发编译整个 crate,模块树也以它为根。

比 crate 大一号的概念是包(package):一到多个 crate 的集合,外加一份 Cargo.toml,说明这些 crate 怎么构建。Cargo 自己就是个活例子。你天天敲的 cargo 命令,是这个包里的二进制 crate;但真正干活的逻辑——解析依赖、编排构建——并没有写在它里面,而是写在同一个包的库 crate 里,cargo 命令只是个调用库的薄壳。这么拆的直接收益:其他项目也能依赖这个库 crate,在自己的代码里直接调用这些逻辑(比如做依赖分析),不必起一个子进程去跑命令行、再费劲解析输出。

一个合法的包要遵守三条规则:

  • 库 crate 至多一个;
  • 二进制 crate 要多少有多少;
  • 但至少得有一个 crate。

看 cargo new 实际发生了什么:

cargo new my-project
     Created binary (application) `my-project` package
ls my-project
Cargo.toml
src
ls my-project/src
main.rs

目录里有 Cargo.toml——这就有了包;src 里是 main.rs。打开 Cargo.toml 会发现它压根没提 src/main.rs:Cargo 靠惯例定位 crate 根——src/main.rs 是与包同名的二进制 crate 的 crate 根;若包里有 src/lib.rs,则包还包含一个与包同名的库 crate,src/lib.rs 是它的 crate 根。Cargo 把 crate 根文件交给 rustc 去构建库或二进制。这个包只有 src/main.rs,所以只含一个名为 my-project 的二进制 crate;main.rs 与 lib.rs 并存就是两个 crate;再往 src/bin 目录里放文件,每个文件都是一个独立的二进制 crate。

编译注:「包 ≠ crate」是本章最容易混的一对概念:在 crates.io 上,别人 cargo add 你时依赖的是你的库 crate,而你的包可以同时带着任意多个二进制 crate,它们不会被外界依赖。所以开源命令行工具有个常见套路——一个包同时带 lib 和 bin:bin 只做参数解析和启动,逻辑全部写进 lib。本章后面「同时包含二进制与库的包的最佳实践」会正面讲这个模式。

用模块控制作用域与私有性

这一节把模块系统的其余角色依次介绍:路径(path)为项命名;use 把路径引入作用域;pub 把项设为公有。顺带讲 as 关键字、外部包和 glob 运算符。

模块速查表

深入细节之前,先给一份速查:模块、路径、use、pub 在编译器里如何运作,以及多数开发者怎么组织文件。后文逐条演示,忘了就翻回来。

  • 从 crate 根开始:编译 crate 时,编译器首先在 crate 根文件里找要编译的代码(库 crate 通常是 src/lib.rs,二进制 crate 是 src/main.rs)。
  • 声明模块:在 crate 根文件里声明新模块,比如 mod 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 的其他任何地方都能通过路径引用模块里的代码。例如 garden 的 vegetables 模块里的 Asparagus 类型,路径就是 crate::garden::vegetables::Asparagus。
  • 私有与公有:模块里的代码默认对父模块私有。要让模块公有,用 pub mod 取代 mod 声明;要让公有模块里的项也公有,在声明前加上 pub。
  • use 关键字:在某个作用域内为项创建快捷方式,减少长路径的重复。在能引用 crate::garden::vegetables::Asparagus 的作用域里,写 use crate::garden::vegetables::Asparagus;,此后用 Asparagus 即可。

下面创建一个名为 backyard 的二进制 crate 演示这套规则,目录结构如下:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

crate 根文件是 src/main.rs,内容如下:

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

pub mod garden;

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

pub mod garden; 告诉编译器把 src/garden.rs 里的代码纳入进来,该文件内容是:

pub mod vegetables;

pub mod vegetables; 又意味着 src/garden/vegetables.rs 也会被纳入,那段代码是:

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

接下来深入这些规则的细节。

用模块把相关代码分组

模块(module)让我们在 crate 内部组织代码:把相关的定义聚在一起,给这组相关性一个名字,可读性与复用性都随之提升。模块同时是私有性(privacy)的开关——模块内的代码默认私有,是对外不可见的实现细节;想暴露,就显式设为公有。

原书的例子是一家餐厅:顾客看得到的前厅(front of house)负责引座、点单、收银;藏在后面的后厨(back of house)负责烹饪与运营。照这个结构搭一个库 crate:只写函数签名、函数体留空,注意力全放在组织方式上。运行 cargo new restaurant --lib,把示例 7-1 输入 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() {}
    }
}

示例 7-1:一个 front_of_house 模块,其中包含另外几个模块,模块里又包含函数

mod 后跟模块名、模块体放花括号,就是一个模块定义;模块里可以再嵌模块(hosting、serving),也可以放结构体、枚举、常量、trait 等任何项。读代码的人按分组导航,不必通读全部定义;加新功能的人也知道代码该放在哪儿。

src/main.rs 和 src/lib.rs 之所以叫 crate 根,是因为它们(任一个)的内容构成 crate 模块结构根处一个名为 crate 的隐式模块,这个结构就是模块树(module tree)。示例 7-2 画出示例 7-1 的模块树:

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

示例 7-2:示例 7-1 中代码的模块树

树上,hosting 嵌套在 front_of_house 里;hosting 和 serving 定义在同一个模块里,互为兄弟(sibling)。A 包含在 B 里,A 是 B 的子模块、B 是 A 的父模块;整棵树的根是隐式的 crate 模块。这棵树像极了文件系统的目录树——模块好比目录,项好比文件,这个类比后文一直用得上。

引用模块树中项的路径

在模块树里指明「项在哪」,用路径,就像文件系统里用路径找文件;要调用函数,先得知道它的路径。路径两种形式:

  • 绝对路径(absolute path)从 crate 根开始:外部 crate 的代码以 crate 名开头,当前 crate 的代码以字面量 crate 开头。
  • 相对路径(relative path)从当前模块出发,用 self、super 或当前模块中的标识符。

两种路径都由一个或多个标识符组成,之间用双冒号(::)分隔。

回到示例 7-1:想在 crate 根新定义的函数 eat_at_restaurant 里调用 add_to_waitlist,路径怎么写?示例 7-3 给出两种写法。这些路径本身是对的,但有个问题会让示例暂时编译不过,稍后揭晓。eat_at_restaurant 属于库的公共 API,所以标了 pub——下节细说。

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

pub fn eat_at_restaurant() {
    // 绝对路径
    crate::front_of_house::hosting::add_to_waitlist();

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

示例 7-3:使用绝对路径和相对路径调用 add_to_waitlist 函数

第一次调用用绝对路径:add_to_waitlist 与 eat_at_restaurant 同属一个 crate,于是从 crate 出发,逐级写下沿途模块直到目标。第二次调用用相对路径,从当前模块里的 front_of_house 出发,相当于 front_of_house/hosting/add_to_waitlist。

选哪种,取决于你预期「定义」和「调用」将来会不会一起搬:把 front_of_house 和 eat_at_restaurant 一起挪进新模块,绝对路径要更新、相对路径不用;只挪 eat_at_restaurant,则相反。总体上更推荐绝对路径,因为两处代码各自独立移动的概率更大。

编译示例 7-3 试试,为什么编译不过?错误如示例 7-4:

cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
 2 |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

示例 7-4:构建示例 7-3 中代码时产生的编译器错误

错误说 hosting 模块是私有的:路径没写错,但 Rust 不放行,因为我们无权访问私有部分。在 Rust 中,所有项(函数、方法、结构体、枚举、模块和常量)默认对父模块私有;想让某个项保持私有,就把它放进模块里。

准确规则是单向的:父模块中的项用不了子模块里的私有项,子模块中的项却可以用其祖先模块里的项——子模块包裹并隐藏实现细节,但看得见自己被定义时的上下文。延续餐厅的比喻:后厨里发生的事对顾客保密,管理人员却看得见并操办一切。默认私有是有意设计:你清楚哪些内部代码可以放心改而不破坏外部代码;要开放,用 pub 显式暴露即可。

用 pub 关键字暴露路径

给 hosting 模块加上 pub,如示例 7-5:

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

// -- snip --

示例 7-5:把 hosting 模块声明为 pub,以便在 eat_at_restaurant 中使用它

遗憾的是仍然编译不过,错误如示例 7-6:

cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

示例 7-6:构建示例 7-5 中代码时产生的编译器错误

怎么回事?pub 加在模块上,只让「模块这个名字」可被祖先模块引用,并不让它的内容也公有。模块是容器:公开容器和公开内容是两码事,光标 pub mod 意义不大,还得有选择地把模块内的项也设为公有。示例 7-6 里报私有的已经换成了 add_to_waitlist 函数——私有性规则不仅适用于模块,也适用于结构体、枚举、函数和方法。

给函数定义也加上 pub,如示例 7-7:

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

// -- snip --

示例 7-7:给 mod hosting 和 fn add_to_waitlist 都加上 pub 关键字后,就能在 eat_at_restaurant 中调用该函数了。

现在能编译了。按私有性规则走一遍两条路径。绝对路径从 crate(模块树的根)出发:front_of_house 定义在 crate 根,虽非公有,但与 eat_at_restaurant 同模块(兄弟),可引用;hosting 标了 pub 且其父可访问,可引用;add_to_waitlist 标了 pub 且其父可访问——调用成立。相对路径同理,只是第一步换成从同模块的 front_of_house 出发,余下逐级都是 pub,走得通。

若要把库 crate 分享出去,公共 API 就是你与使用者之间的契约,管理其变更是门学问,超出本书范围——感兴趣的读 Rust API 指南。

同时包含二进制与库的包的最佳实践

一个包同时含 src/main.rs 和 src/lib.rs(两个 crate 默认都与包同名)时,惯用法是:二进制 crate 只放启动可执行程序的代码,启动后调用库 crate 里的逻辑——这样其他项目能充分复用库 crate 的功能。模块树定义在 src/lib.rs,二进制 crate 用包名开头的路径访问公有项,就像一个完全外部的 crate 一样只用公共 API。这有助于设计出好的 API:你不只是作者,同时也是客户。第 12 章的命令行项目会实战这种组织方式。

用 super 开头的相对路径

super 开头的路径从父模块(而非当前模块或 crate 根)出发,相当于文件系统路径以 .. 开头。示例 7-8 模拟厨师改正一道点错的菜并亲自端给顾客:back_of_house 里的 fix_incorrect_order 用 super::deliver_order() 调用父模块的函数。

fn deliver_order() {}

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

    fn cook_order() {}
}

示例 7-8:使用以 super 开头的相对路径调用函数

fix_incorrect_order 位于 back_of_house,super 跳到它的父模块(这里就是 crate 根),在那里找到 deliver_order。用 super 的理由:back_of_house 与 deliver_order 的关系大概率不变,将来重组模块树也会一起移动;从父模块出发引用,代码挪动时需要更新的路径更少。

将结构体和枚举设为公有

pub 也能用在结构体和枚举上,但各有一个关键细节。结构体公有 ≠ 字段公有:示例 7-9 的 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() {
    // 在夏天点一份黑麦(Rye)面包的早餐。
    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");
}

示例 7-9:一个字段部分公有、部分私有的结构体

toast 公有,所以在 eat_at_restaurant 里能用点号读写;seasonal_fruit 私有,取消那行注释试试,会得到编译错误。另一个推论:有私有字段的结构体,外部无法用字面量构造实例——所以 Breakfast 必须提供公有关联函数 summer 来构造自己,否则 eat_at_restaurant 里根本建不出实例(私有字段没处赋值)。

枚举恰好相反:枚举一旦公有,所有变体全部公有,只需在 enum 前加 pub,如示例 7-10:

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

示例 7-10:把枚举设为公有,其所有变体也随之公有。

于是 Soup 和 Salad 在 eat_at_restaurant 里都能用。道理很简单:变体不公有的枚举基本没用,逐个加 pub 纯属折磨人,所以枚举变体默认跟随公有;而字段全私有的结构体依然有用,所以结构体字段遵循通用规则——默认私有,标注 pub 才公开。

还剩模块系统最后一个涉及 pub 的特性:use 关键字。先讲它本身,再讲它和 pub 的组合。

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

每次调用都写全 front_of_house::hosting::add_to_waitlist,繁琐又重复。use 关键字一次性把路径引入作用域,之后用短名即可。示例 7-11 把 crate::front_of_house::hosting 模块引入 crate 根:

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

示例 7-11:用 use 将模块引入作用域

在作用域中添加 use 和路径,类似在文件系统里创建符号链接:use crate::front_of_house::hosting; 之后,hosting 在该作用域里就是合法名字,仿佛本来定义在 crate 根里。注意 use 引入的路径同样过私有性检查,与其他路径没有区别。

快捷方式只在 use 所在的那个作用域内有效。示例 7-12 把 eat_at_restaurant 挪进新子模块 customer,它和 use 语句不在同一作用域,于是编译不过:

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

示例 7-12:use 语句只在它所处的作用域中生效。

编译器错误表明快捷方式在 customer 模块里失效了:

cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: cannot find module or crate `hosting` in this scope
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of unresolved module or unlinked crate `hosting`
   |
   = help: if you wanted to use a crate named `hosting`, use `cargo add hosting` to add it to your `Cargo.toml`
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

注意还有条警告,说这条 use 在它的作用域中已无人使用。修法有二:把 use 也挪进 customer 模块,或在子模块里用 super::hosting 引用父作用域里的快捷方式。

创建惯用的 use 路径

你可能会问:为什么示例 7-11 只 use 到 hosting 模块,调用时写 hosting::add_to_waitlist,而不像示例 7-13 那样把路径一路写到函数?

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

示例 7-13:用 use 把 add_to_waitlist 函数引入作用域——这种写法不合惯例

效果一样,但 7-11 才是惯用写法:函数引到父模块为止,调用时必须写 hosting::add_to_waitlist——父模块名标明了「这不是本地定义的函数」,又把长路径的重复降到最低;7-13 则完全看不出 add_to_waitlist 从哪来。反过来,引结构体、枚举等其他项时,惯用写法是全路径引到项本身,示例 7-14 是标准库 HashMap 的惯用引入方式:

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

示例 7-14:以惯用方式将 HashMap 引入作用域

这条惯例没有过硬的理由,纯是约定俗成——大家习惯了这样读写 Rust 代码。它唯一的例外场景:两个同名项要进同一个作用域,而 Rust 不允许两条 use 引入同一个名字。示例 7-15 的解法是只引各自的父模块:

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

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

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

示例 7-15:把两个同名类型引入同一作用域,必须借助于它们的父模块

有 fmt:: 和 io:: 前缀,两个 Result 就分得开;若写成 use std::fmt::Result 加 use std::io::Result,用到 Result 时 Rust 就不知道指哪个了。

用 as 关键字提供新名字

同名冲突还有另一种解法:路径后面用 as 指定一个本地新名字,即别名(alias)。示例 7-16 给其中一个 Result 改名:

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

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

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

示例 7-16:用 as 关键字在把类型引入作用域时为其改名。

第二条 use 给 std::io::Result 起名 IoResult,与同时引入的 std::fmt::Result 不冲突。示例 7-15 和 7-16 都算惯用,选哪种随你。

用 pub use 重导出名字

use 引入的名字对导入它的作用域是私有的。若想让作用域之外的代码也能像「这名字就定义在这儿」一样用它,把 pub 和 use 组合起来——这项技术叫重导出(re-exporting):既把项引入作用域,又让别人能从你这里再引入它。示例 7-17 在 7-11 的基础上把根模块的 use 改成 pub use:

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

示例 7-17:用 pub use 让任何代码都能在新作用域中使用某个名字。

改动之前,外部代码得写 restaurant::front_of_house::hosting::add_to_waitlist(),还得把 front_of_house 标为 pub;现在有了这条 pub use,restaurant::hosting::add_to_waitlist() 就够。当代码的内部结构与使用者看问题的方式不一致时,重导出最有用:经营者分前厅后厨,顾客并不用这些概念理解餐厅——pub use 让你按一种结构写代码、按另一种结构对外,两边眼前都井井有条。第 14 章还会再看一个 pub use 的例子,以及它对 crate 文档的影响。

使用外部包

第 2 章的猜数字游戏用过外部包 rand:先在 Cargo.toml 里声明依赖:

rand = "0.10.1"

这一行告诉 Cargo 从 crates.io 下载 rand 及其所有依赖,供项目使用。再用 use 把它的项引入作用域——回顾第 2 章,我们引入的是 rand::prelude 模块,并调用 rand::rng 函数:

use rand::prelude::*;

fn main() {
    let secret_number = rand::rng().random_range(1..=100);
}

crates.io 上任何包都是同样两步:Cargo.toml 里列出依赖,use 引入其项。注意标准库 std 对我们的包来说同样是外部 crate,只是它随语言一起发行、不用写进 Cargo.toml,但 use 一步不能省。比如引入 HashMap:

use std::collections::HashMap;

这是一条以 std(标准库 crate 名)开头的绝对路径。

使用嵌套路径清理 use 列表

从同一 crate 或模块引入多个项,一行一条 use 会占掉不少纵向空间——猜数字游戏(示例 2-4)里就有这两条:

// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

嵌套路径能把它们合成一行:先写公共部分,接双冒号,再用花括号列出各自不同的部分,如示例 7-18:

// --snip--
use std::{cmp::Ordering, io};
// --snip--

示例 7-18:指定嵌套路径,把多个具有相同前缀的项引入作用域

大程序里用嵌套路径批量引入,能大幅减少独立 use 语句的数量。嵌套可以用在路径任何层级,合并「一条是另一条子路径」的 use 尤其好用——示例 7-19 的两条,一条引 std::io,一条引 std::io::Write:

use std::io;
use std::io::Write;

示例 7-19:两条 use 语句,其中一条是另一条的子路径

公共部分 std::io 恰好就是第一条完整路径,合并时用 self 指代它,如示例 7-20:

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

示例 7-20:把示例 7-19 中的路径合并成一条 use 语句

这一行同时把 std::io 和 std::io::Write 引入了作用域。

用 glob 运算符导入项

想把某条路径下的所有公有项全部引入作用域,在路径后加 * glob 运算符:

use std::collections::*;

慎用:glob 让你难以分辨作用域里有哪些名字、某个名字定义在哪;依赖一旦新增与你作用域内同名的定义,升级时就可能撞出编译错误。常见用途有二:测试里把所有被测内容倒进 tests 模块(第 11 章讲),以及 prelude 模式的一部分(见标准库文档)。

将模块拆分到不同文件

本章示例都把多个模块写在一个文件里,这只是演示习惯;模块变大后,把定义挪进单独文件更好浏览。从示例 7-17 出发,把各模块抽成独立文件——crate 根是 src/lib.rs,src/main.rs 的二进制 crate 流程相同。

第一步,把 front_of_house 抽出去:花括号里的代码删掉,只留 mod front_of_house; 声明,src/lib.rs 变成示例 7-21。注意:在创建示例 7-22 的 src/front_of_house.rs 之前,这段代码编译不过。

mod front_of_house;

pub use crate::front_of_house::hosting;

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

示例 7-21:声明 front_of_house 模块,其主体将放在 src/front_of_house.rs 中

接着,原花括号里的代码放进新文件 src/front_of_house.rs,如示例 7-22。编译器知道来这个文件找,是因为它在 crate 根遇到了 front_of_house 这个模块声明。

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

示例 7-22:src/front_of_house.rs 中 front_of_house 模块内的定义

要点:用 mod 声明在模块树中挂载一个文件,只需要一次。编译器由此知道该文件属于项目、以及它在模块树中的位置,其他代码一律通过指向声明处的路径来引用——mod 不是其他语言里那种「include」操作。

第二步,把 hosting 也抽出去。流程稍有不同:hosting 是 front_of_house 的子模块,文件要放进以它在模块树中的祖先命名的新目录 src/front_of_house。先把 src/front_of_house.rs 改成只剩声明:

pub mod hosting;

再创建 src/front_of_house 目录,在其中放 hosting.rs:

pub fn add_to_waitlist() {}

如果把 hosting.rs 直接放在 src 下,编译器会认为它是 crate 根声明的 hosting 模块,而不是 front_of_house 的子模块。「哪个文件对应哪个模块」的规则,让目录结构与模块树高度对应。

备选文件路径

上文用的是最惯用的文件路径,Rust 还支持一种更老的风格。对 crate 根中声明的 front_of_house 模块,编译器在这些位置查找:

  • src/front_of_house.rs(本文讲的方式)
  • src/front_of_house/mod.rs(较老的风格,仍在支持的路径)

对 front_of_house 的子模块 hosting,编译器在这些位置查找:

  • src/front_of_house/hosting.rs(本文讲的方式)
  • src/front_of_house/hosting/mod.rs(较老的风格,仍在支持的路径)

对同一个模块同时使用两种风格会编译报错;同一项目里对不同模块混用是允许的,但读你项目的人会犯迷糊。mod.rs 风格的主要缺点是项目里会冒出一大堆同名 mod.rs,编辑器里同时打开时满屏标签,眼花缭乱。

编译注: 两种风格对应两个时代:2018 edition 之前,带子模块的模块只能写在 foo/mod.rs 里;2018 起 foo.rs + 同名目录的组合才可用。新项目(包括官方教程用的也是这种)基本都选 foo.rs 风格——文件名即模块名,一眼可查;维护 2018 之前的老代码库时,再回头认得 mod.rs 就行。

至此每个模块各占一个文件,模块树没有变化:eat_at_restaurant 里的调用一字未改照常工作,尽管定义已经分散在不同文件里。模块变大时,就可以这样把它挪进新文件。另外注意 src/lib.rs 里的 pub use crate::front_of_house::hosting 同样没变——use 不影响哪些文件参与编译,那是 mod 的职责:声明模块,Rust 到与模块对应的文件里找代码。

小结与下一步

  • 包是 Cargo 的构建单位(一个 Cargo.toml),装一到多个 crate:src/main.rs 和 src/bin/ 下每个文件是二进制 crate,src/lib.rs 是至多一个的库 crate
  • crate 内部是一棵模块树,根是隐式的 crate 模块;跨模块引用靠绝对路径(crate::)或相对路径(self、super)
  • 可见性默认私有且单向:子模块看得见祖先的项,祖先看不见子模块的私有项;公开要逐级 pub——结构体字段逐个加,枚举变体随枚举整体公开
  • use 把长路径绑成短名(函数引到父模块、类型引到全路径),as 解同名冲突,pub use 重导出门面,嵌套路径与 glob 负责清理
  • 模块可拆进独立文件,foo.rs 与 foo/mod.rs 两种风格同一模块只能选一种;mod 声明一次即挂载,不是 include

下一篇:第 8 章「常用集合」——Vec、String、HashMap,标准库给的三件日用武器。