Rust 使用包、Crate 和模块管理不断增长的项目

发布时间 2024-01-11 15:10:15作者: 二次元攻城狮


本文在原文有删减,原文参考使用包、Crate 和模块管理不断增长的项目

Rust 有许多功能可以管理代码的组织,包括:

  • 包(Packages):Cargo 的一个功能,它允许你构建、测试和分享 crate。
  • Crates:一个模块的树形结构,它形成了库或二进制项目。
  • 模块(Modules)和 use:允许你控制作用域和路径的私有性。
  • 路径(path):一个命名例如结构体、函数或模块等项的方式。

包和 Crate

crate 是 Rust 在编译时最小的代码单位,crate 有两种形式:二进制项和库。

二进制项 可以被编译为可执行程序,比如一个命令行程序或者一个服务器,必须有一个 main 函数来定义当程序被执行的时候所需要做的事情。

没有 main 函数,也不会编译为可执行程序,提供一些诸如函数之类的东西给其他项目使用。

crate root 是一个源文件,Rust 编译器以它为起始点,并构成 crate 的根模块。

包(package)是提供一系列功能的一个或者多个 crate,一个包会包含一个 Cargo.toml 文件阐述如何去构建这些 crate。

包中可以包含至多一个库 crate(library crate),包中可以包含任意多个二进制 crate(binary 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

注:ls 命令为 Linux 平台的指令,Windows 下可用 dir。

Cargo 遵循的一个约定:

  • src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。
  • 如果包目录中包含 src/lib.rs ,则包带有与其同名的库 crate,且 src/lib.rs 是 crate 根。
  • crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者二进制项目。

一个只包含 src/main.rs 的包,意味着它只含有一个名为 my-project 的二进制 crate。如果一个包同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。

通过将文件放在 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的二进制 crate。

定义模块来控制作用域与私有性

下面介绍模块、路径、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 来引用 garden vegetables 模块下的 Asparagus 类型。

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

  • use 关键字: 在一个作用域内,可以用 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

这个例子中的 crate 根文件是src/main.rs,内容如下:

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

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

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

文件 src/garden.rs 代码如下:

//在src/garden/vegetables.rs中的代码也应该被包括
pub mod vegetables;

文件 src/garden/vegetables.rs 代码如下:

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

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

模块可以将一个 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() {}
    }
}

上面示例中的模块树的结构如下:

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

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

整个模块树都植根于名为 crate 的隐式模块下,模块树的结构类似于电脑上文件系统的目录树。

引用模块项目的路径

来看一下 Rust 如何在模块树中找到一个项的位置,调用一个函数需要知道它的路径,路径有两种形式:

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

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

使用绝对路径和相对路径来调用 add_to_waitlist 函数:

//无法通过编译:hosting 模块是私有的
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

// front_of_house 模块在模块树中与 eat_at_restaurant 定义在同一层级
pub fn eat_at_restaurant() {
    // 绝对路径
    crate::front_of_house::hosting::add_to_waitlist();

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

一般更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

在 Rust 中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的,父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用它们父模块中的项

使用 pub 关键字暴露路径

为 mod hosting 和 fn add_to_waitlist 添加 pub 关键字使它们可以在 eat_at_restaurant 函数中被调用:

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

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

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

如果你计划共享你的库 crate 以便其它项目可以使用你的代码,公有 API 将是决定 crate 用户如何与你代码交互的契约。关于管理公有 API 的修改以便被人更容易依赖你的库的考量,可以参考 The Rust API Guidelines

二进制和库 crate 包的最佳实践

我们提到过包可以同时包含一个 src/main.rs 二进制 crate 根和一个 src/lib.rs 库 crate 根,并且这两个 crate 默认以包名来命名。通常,这种包含二进制 crate 和库 crate 的模式的包,在二进制 crate 中只有足够的代码来启动一个可执行文件,可执行文件调用库 crate 的代码。又因为库 crate 可以共享,这使得其它项目从包提供的大部分功能中受益

模块树应该定义在 src/lib.rs 中,这样通过以包名开头的路径,公有项就可以在二进制 crate 中使用。二进制 crate 就完全变成了同其它 外部 crate 一样的库 crate 的用户:它只能使用公有 API。这有助于你设计一个好的 API;你不仅仅是作者,也是用户!

super 开始的相对路径

可以通过在路径的开头使用 super 从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始,这类似以 .. 语法开始一个文件系统路径。
使用以 super 开头的相对路径从父目录开始调用函数:

fn deliver_order() {}

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

    fn cook_order() {}
}

当认为 back_of_house 模块和 deliver_order 函数之间可能具有某种关联关系,并且重新组织 crate 的模块树时需要一起移动,我们就可以使用 super。

创建公有的结构体和枚举

可以使用 pub 来设计公有的结构体和枚举,如果在一个结构体定义的前面使用了 pub 结构体会变成公有的,但是这个结构体的字段仍然是私有的。带有公有和私有字段的结构体:

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 具有私有字段,必须提供一个公共的关联函数来构造 Breakfast 的实例 ,否则将无法在 eat_at_restaurant 中创建 Breakfast 实例。

与之相反,如果将枚举设为公有,则它的所有成员都将变为公有。在 enum 关键字前面加上 pub 设计公有枚举,使其所有成员公有:

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

枚举成员不是公有的会显得用处不大,因此枚举成员默认就是公有的。结构体通常使用时不必将它们的字段公有化,因此结构体遵循常规内容默认全部是私有的。

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

不得不编写路径来调用函数显得不便且重复,可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。

通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 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();
}

use 只能创建 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() {
        //编译器错误:短路径不在适用于 customer 模块中
        hosting::add_to_waitlist(); 
    }
}

如果想修复这个编译错误,可以将 use 移动到 customer 模块内,或者在子模块 customer 内通过 super::hosting 引用父模块中的这个短路径。

创建惯用的 use 路径.

使用 use 引入函数

使用 use 将 add_to_waitlist 函数引入作用域并不符合习惯:

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 是在哪里被定义的
    add_to_waitlist();
}

要想使用 use 将函数的父模块引入作用域,必须在调用函数时指定父模块以表明函数不是在本地定义的,同时使完整路径的重复度最小化。

使用 use 引入结构体、枚举和其他项

将 HashMap 结构体引入作用域的习惯用法:

use std::collections::HashMap;

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

使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径,这是一种惯例。

使用 use 的例外用法

如果想使用 use 语句将两个具有相同名称的项带入作用域则需要指定父模块,将两个具有相同名称但不同父模块的 Result 类型引入作用域:

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

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

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

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

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

use std::fmt::Result;
//选择 IoResult 作为 std::io::Result 的新名称
use std::io::Result as IoResult;

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

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

使用 pub use 重导出名称

使用 use 关键字将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时也能够正常使用这个名称,那可以将 pub 和 use 合起来使用,这种技术被称为 “重导出(re-exporting)”。

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

使用外部包

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

rand = "0.8.5"

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

std 标准库也是外部 crate,只是无需修改 Cargo.toml 来引入 std,但需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们:

use std::collections::HashMap;

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

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。有两行 use 语句都从 std 引入项到作用域:

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

可以使用嵌套路径将相同的项在一行中引入作用域,指定嵌套的路径在一行中将多个带有相同前缀的项引入作用域:

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

可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。通过两行 use 语句引入两个路径,其中一个是另一个的子路径:

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

为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self:

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

通过 glob 运算符将所有的公有定义引入作用域

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

//将 std::collections 中定义的所有公有项引入当前作用
use std::collections::*;

使用 glob 运算符时需要小心,Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域。

将模块拆分成多个文件

当模块变得更大时可能会将它们的定义移动到单独的文件中,从而使代码更容易阅读。

声明 front_of_house 模块,其内容将位于 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();
}

在 src/front_of_house.rs 中定义 front_of_house 模块:

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

mod 不同于其他编程语言中看到的 "include" 操作,它更依赖于代码文件目录。

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

pub mod hosting;

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

pub fn add_to_waitlist() {}

编译器所遵循的哪些文件对应哪些模块的代码的规则,意味着目录和文件更接近于模块树

另一种文件路径

前面介绍了 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 文件,当在编辑器中同时打开它们时对程序员很不友好。