08 使用包、单元包及模块管理项目

08 使用包、单元包及模块管理项目

------正文内容展示,开始汲取新知识啦------

前言

编写较为复杂的项目时,合理地对代码进行组织和管理很重要。只有按照不同的特性组织分割相关功能的代码,才能快速定位到实现指定功能的代码片段。一个包(package)可以存放多个二进制单元包和可选的库单元包。还有外部依赖的独立单元包。除了对功能进行分组,对实现细节的封装可以更高效的复用代码。

Rust 提供了一系列的功能来帮助我们管理代码,包括决定哪些细节是暴露的、哪些细节是私有的,以及不同作用域下存在哪些标识符名称。这些功能有时被称为“模块系统”(the module system),包括:

  • 包(Packages): 一个用于构建、测试并分享单元包的 Cargo 功能。
  • 单元包(Crate) :一个用于生成库或可执行二进制文件的树形模块结构。
  • 模块(Modules)和 use关键字: 被用于控制文件结构、作用域和路径的私有性。
  • 路径(path):一种用于命名条目的方法,这些条目包括:结构体、函数和模块等

包和单元包

单元包(crate)是 Rust 在编译时最小的代码单位。如果你用rustc而不是cargo来编译一个文件,编译器还是会将那个文件当作一个单元包crate。crate可以包含模块,模块可以定义在其他文件,然后和crate一起编译

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

库 并没有 main 函数,它们也不会编译为可执行程序,它们提供一些诸如函数之类的东西,使其他项目也能使用这些东西。比如 rand crate 就提供了生成随机数的东西。大多数时候Rustaceans 说的 crate 指的都是库,这和其他编程语言中 library 概念是一样的。

我们将Rust编译时使用的入口文件称为crate的根节点,同时也是crate的根模块。

包(package)则由一个或多个提供相关功能的crate集合而成,它所附带的配置文件 Cargo.toml描述了包内单元包的构建信息,一个包内至多包含一个库crate,可以包含任意多个二进制crate,但必须至少包含一个crate(无论是库的还是二进制的)。

$ 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.toml 的内容,会发现并没有提到 src/main.rs,因为 Cargo 遵循的一个约定:src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。同样的,Cargo 知道如果包目录中包含 src/lib.rs,则包带有与其同名的库 crate,且 src/lib.rs 是 crate 根。 Cargo 会在构建库和二进制程序时将这些单元包的根节点文件作为参数传递给 rustc 。

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

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

  • 声明模块:在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来使用该类型。

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

模块允许我们对crate内代码按照可读性与重用性进行分组。同时,模块还可以允许我们控制代码条目的私有性,即代码条目可以对外暴露,也可以仅是内部调用,不对外暴露实现细节。

以餐馆中结构为例,餐馆中有前台和后台。前台是招待顾客的地方,在这里,店主可以为顾客安排座位,服务员接受顾客下单和付款,调酒师会制作饮品。后台则是由厨师工作的厨房,洗碗工的工作地点,以及经理做行政工作的地方组成。通过执行cargo new --lib restaurant,来创建一个新的名为 restaurant 的库。用代码项目结构表示餐馆的结构:

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),并且用花括号包围模块的主体。在模块内,还可以定义其他的模块,就像本例中的 hosting 和 serving 模块。模块还可以包含其他条目的定义,比如结构体、枚举、常量、特性、或者函数。

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

src/main.rs 和 src/lib.rs 叫做 crate 的根节点。因为这两个文件的内容各自组成了一个叫 crate 的模块,并位于单元模块的根部,该结构被称为模块树(module tree)。

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

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

用于在模块树中指明条目的路径

Rust 利用路径在模块树中找到一个条目的位置,如同在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。

路径分为两种形式:

  • 绝对路径,从 crate 根节点开始,以 crate 名或者字面值 crate 开头。
  • 相对路径,从当前模块开始,以 self 、super 或当前模块的标识符开头。

绝对路径和相对路径都由至少一个标识符组成,标识符之间用::分隔。

还是用餐馆结构的代码,我们在 crate 根定义了一个新函数 eat_at_restaurant,并在其中展示调用 add_to_waitlist 函数的两种方法。eat_at_restaurant 函数是我们 crate 库的一个公共 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();
}

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

第二种方式,我们在 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,但是相对路径必须要更新。一般使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

上面的代码构建会失败,出现编译错误:

error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^ 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();
   |                     ^^^^^^^ 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` due to 2 previous errors

错误信息指出:hosting模块是私有的,外部不能访问私有片段。

模块不仅仅被用于组织代码,同时还定义了Rust中的私有性边界:这条界线不允许外部代码了解、调用和依赖被封装的实现细节。所以,如果你希望创建一个私有函数或结构体,你可以将其放入模块。(Rust没有类似于private的关键字定义私有性,模块实现类似功能)。

Rust 中默认所有代码条目(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块中的项不能使用子模块中的私有条目,但是子模块中的条目可以使用他们父模块中的条目。这是因为子模块虽然封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的工作细节对餐厅顾客来说是不可知的,但办公室经理可以观察并使用自己餐厅内的所有东西。

当我们希望默认隐藏某部分实现细节,并且明确知道修改哪部分内部实现不会破坏外部代码。

使用 pub 关键字对外暴露路径

如果想要使用私有条目,需要在模块和条目前面加上pub关键字。

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

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

在绝对路径,我们从 crate,也就是 crate 根节点开始。然后 crate 根中定义了 front_of_house 模块。front_of_house模块不是公有的,不过因为eat_at_restaurant函数与 front_of_house 定义于同一模块中(即eat_at_restaurantfront_of_house是兄弟模块),我们可以从eat_at_restaurant中引用 front_of_house。接下来是使用pub标记的hosting模块。我们可以访问hosting的父模块,所以可以访问 hosting。最后,add_to_waitlist 函数被标记为 pub ,我们可以访问其父模块,所以这个函数调用可以被正常调用。

在相对路径逻辑与绝对路径相同,除了第一步:不同于从 crate 根开始,路径从front_of_house开始。front_of_house模块与 eat_at_restaurant 定义于同一模块,所以从 eat_at_restaurant 中开始定义的该模块相对路径是有效的。接下来因为 hosting 和 add_to_waitlist 被标记为 pub,路径其余的部分也是有效的,因此函数调用也是有效的

使用 super 起始的相对路径

super开头从父模块开始构建路径,类似于文件系统中使..语法构建路径。

下面的代码模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。fix_incorrect_order函数通过指定的 super 起始的 serve_order 路径,来调用 serve_order 函数:

fn serve_order() {}

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

    fn cook_order() {}
}

fix_incorrect_order函数在 back_of_house 模块中,所以我们可以使用super进入back_of_house父模块,也就是本例中的 crate 根。在这里,我们可以找到 serve_order。

创建公有的结构体和枚举

结构体和枚举都可以用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 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用.来随意的读写 toast 字段。我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。

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

  • 当我们将枚举声明为公有时,则其内部成员都会变为公有。
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,因为被声明为pub,所以在eat_at_restaurant函数中,可以使用它的变体成员。

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

基于路径来调用函数的方法是有些冗长和重复的。此时我们可以使用 use 关键字将路径引入作用域,这样就可以如同本地条目一样被调用。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
//将路径引入作用域
//绝对路径
use crate::front_of_house::hosting;
//相对路径
use (self::)front_of_house::hosting;

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

创建 use 路径时的常用模式

在上面的例子中,路径没有指定到 add_to_waitlist ,而是到 hosting,要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。如果指定到add_to_waitlist 就不清楚 add_to_waitlist 是在哪里被定义的了。

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

use std::collections::HashMap;

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

需要注意的是,使用 use 语句将两个具有相同名称的项带入作用域,必须带上父模块,否则Rust 则不知道我们要用的是哪个,如:

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

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

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

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

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

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

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

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

使用 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 来调用 add_to_waitlist 函数。如果没有指定 pub use,eat_at_restaurant 函数可以在其作用域中调用 hosting::add_to_waitlist,但外部代码则不允许使用这个新路径。

使用外部包

在 Cargo.toml 文件内[dependencies]下引入依赖包

[dependencies]
rand = "0.8.3"

为了将 w外部包定义引入项目包的作用域,我们加入一行 use 起始的包名,它以包名开头并列出了需要引入作用域的项。

【注意】标准库(std)实际上也是外部 crate。因为标准库随 Rust 语言一同分发,无需修改 Cargo.toml 来引入 std,不过需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们,比如我们使用的 HashMap:

use std::collections::HashMap;//这是一个以标准库 crate 名 std 开头的绝对路径。

使用嵌套路径消除大量的use行

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分:

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

我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。

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

//为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self
use std::io::{self, Write};

使用通配符 * 将所有公有条目定义引入作用域

如果希望将一个路径下所有公有条目引入作用域,可以指定路径后跟通配符*

use std::collections::*;

使用这个通配符需要小心,这个特性会让我们难以确定作用域存在哪些条目,以及使用位置。

将模块拆分为多个文件

当模块变得更大时,需要将它们的定义移动到单独的文件中,从而使代码更容易阅读。将 front_of_house 模块移动到属于它自己的文件 src/front_of_house.rs 中,通过改变 crate 根文件,使其包含所示的代码。crate 根文件是 src/lib.rs,这也同样适用于以 src/main.rs 为 crate 根文件的二进制 crate 项。

//声明 front_of_house 模块,其内容将位于 src/front_of_house.rs
mod front_of_house;

//在 mod front_of_house 后使用分号,而非代码块,
//这将告诉Rust在另一个与模块同名的文件中加载模块的内容。
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 mod hosting {
    pub fn add_to_waitlist() {}
}

继续重构代码,将 hosting 模块也提取到其自己的文件中,仅对 src/front_of_house.rs 包含 hosting 模块的声明进行修改:

pub mod hosting;

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

pub fn add_to_waitlist() {}

模块树依然会保持相同,eat_at_restaurant 中的函数调用也无需修改继续保持有效,即便其定义存在于不同的文件中。

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

总结

Rust 提供了将包分成多个 crate,将 crate 分成模块,以及通过指定绝对或相对路径从一个模块引用另一个模块中定义的项的方式。可以通过使用 use 语句将路径引入作用域,这样在多次使用时可以使用更短的路径。模块定义的代码默认是私有的,不过可以选择增加 pub 关键字使其定义变为公有。

温馨提示:本文最后更新于2024-05-04 11:00:16,某些文章具有时效性,若有错误或已失效,请在下方留言或QQ联系站长
------正文内容展示,开始汲取新知识啦------

感谢您的访问,Ctrl+D收藏本站吧。

© 版权声明
THE END
喜欢就支持一下吧
点赞5赞赏 分享
评论 共2条

请登录后发表评论