Rust 学习笔记:管理项目中的代码

Rust 学习笔记:管理项目中的代码

在编写大型程序时,组织代码将变得越来越重要。对相关功能进行分组,并将具有不同特性的代码分开。

到目前为止,我们编写的程序都在一个模块中的一个文件中。随着项目的增长,需要通过将代码拆分为多个模块和多个文件来组织代码。一个包可以包含多个二进制 crate,也可以包含一个库 crate。随着包的增长,可以将各个部分提取到单独的箱中,从而成为外部依赖项。

封装:一旦实现了一个操作,其他代码就可以通过其公共接口调用代码,而不必知道实现是如何工作的。编写代码的方式定义了哪些部分对其他代码是公开的,哪些部分是私有的。

一个相关的概念是作用域:编写代码的嵌套上下文具有一组定义为“在作用域中”的名称。在读取、编写和编译代码时,程序员和编译器需要知道特定位置的特定名称是否指的是变量、函数、结构体、枚举、模块、常量或其他项,以及该项的含义。可以创建作用域并更改作用域内或作用域外的名称。同一个作用域中不能有两个名称相同的项,但可以使用工具来解决名称冲突。

Rust 有许多管理代码组织的特性,包括哪些细节是公开的,哪些细节是私有的,以及程序中每个作用域中的名称。这些功能有时统称为模块系统,包括:

  • Packages:一个 Cargo 特性,构建、测试和共享
  • Crates:生成库或可执行文件的模块树
  • Modules(模块):控制 Paths 的组织、范围和私密性
  • Paths: 一种命名项目的方式,如结构体、函数或模块

Package 和 Crate

crate 是 Rust 编译器一次考虑的最小代码量。即使你运行 rustc 而不是 cargo,并且传递一个源代码文件,编译器也会认为该文件是一个 crate。crate 可以包含模块,这些模块可以在使用 crate 编译的其他文件中定义。

crate 有两种形式:二进制 crate 或库 crate。

二进制 crate 是可以编译为可运行的可执行文件的程序,例如命令行程序或服务器。每个程序都必须有一个名为 main 的函数,用于定义可执行程序运行时发生的情况。到目前为止我们创建的所有箱子都是二进制的。

库 crate 没有 main 函数,也不能编译成可执行文件。相反,它们定义了旨在与多个项目共享的功能。例如,我们之前使用的 rand crate 提供了生成随机数的功能。

crate 根文件是 Rust 编译器启动的源文件,它构成了 crate 的根模块。

package 是提供一组功能的一个或多个 crate 的集合。一个 package 包含一个 Cargo.toml,这个文件描述如何构建这些 crate。Cargo 实际上是一个 package,其中包含用于构建代码的命令行工具的二进制 crate。Cargo package 还包含一个二进制 crate 所依赖的库 crate。其他项目可以依赖 Cargo 库 crate 来使用 Cargo 命令行工具使用的相同逻辑。一个 package 可以包含任意多个二进制 crate,但最多只能包含一个库 crate。一个 package 必须包含至少一个 crate,无论是库 crate 还是二进制 crate。

让我们来看看创建 package 时会发生什么。首先我们输入命令 cargo new my-project

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

在项目目录中,有一个 Cargo.toml 文件,这就是一个 package。

还有一个包含 main.rs 的 src 目录。Cargo 遵循 src/main.rs 是与包同名的二进制 crate 的根目录。同样,Cargo 知道如果包目录包含 src/lib.rs 中,package 包含与包同名的库 crate,并且 src/lib.rs 是它的根。Cargo 将 crate 根文件传递给 rustc 以构建库或二进制文件。

这里,我们有一个只包含 src/main.rs 的 package,这意味着它只包含一个名为 my-project 的二进制 crate。

如果一个 package 包含 src/main.rs 和 src/lib.rs。在这里,它有两个 crate:一个二进制文件和一个库,它们的名字都和包的名字一样。通过在 src/bin 目录下放置文件,一个包可以有多个二进制 crate:每个文件将是一个单独的二进制 crate。

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

在本节中,我们将讨论模块和模块系统的其他部分,即路径(Path),它允许命名项;use 关键字将路径引入作用域;pub 关键字使项目公开。我们还将讨论 as 关键字、外部 package 和 glob 操作符。

模块的工作方式

在详细介绍模块和路径之前,我们先简要介绍模块、路径、use 关键字和 pub 关键字在编译器中的工作方式,以及大多数开发人员如何组织他们的代码。

当编译一个 crate 时,编译器首先查找 crate 根文件(通常是 src/lib.rs 作为库 crate 或 src/main.rs 作为二进制 crate),用于编译代码。

在 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 的一部分,只要隐私规则允许,就可以使用代码路径从同一 crate 中的任何其他地方引用该模块中的代码。例如,garden vegetables 模块中的 Asparagus 类型可以通过 crate::garden::vegetables::Asparagus 找到。

默认情况下,模块内的代码对其父模块是私有的。要使一个模块为公共,请使用 pub mod 声明它。要使公共模块中的项也为公共,请在声明它们之前使用 pub。

在作用域中,use 关键字创建项的快捷方式,以减少长路径的重复。在任何可以引用 crate::garden::vegetables::Asparagus 的作用域中,你可以通过 use crate::garden::vegetables::Asparagus; 创建一个快捷方式。从那时起,你只需要编写 Asparagus 就可以在作用域中使用该类型。

在这里,我们创建了一个名为 backyard 的二进制箱子来说明这些规则。crate 的目录也被命名为backyard,包含这些文件和目录:

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 找其包含的代码,其代码为:

// src/garden.rs
pub mod vegetables;

pub mod vegetables; 再告诉编译器去 src/garden/vegetables.rs 找其包含的代码,其代码为:

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

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

模块让我们在一个 crate 中组织代码,以提高可读性和重用性。模块还允许我们控制项的私密性,因为模块中的代码在默认情况下是私有的。私有项是外部无法使用的内部实现细节。我们可以选择将模块和其中的项设为公共,这样就可以让外部代码使用和依赖它们。

例如,让我们编写一个提供餐厅功能的库 crate。运行 cargo new restaurant --lib 创建一个名为 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() {}
    }
}

我们使用 mod 关键字后跟模块名(在本例中为 front_of_house)来定义模块。在模块内部(花括号内),我们可以放置其他模块,就像本例中托管和服务模块一样。模块还可以保存其他项的定义,比如结构体、枚举、常量、trait 和函数。

通过使用模块,我们可以将相关的定义分组在一起,并说明它们相关的原因。使用此代码的程序员可以根据组导航代码,而不必通读所有定义,从而更容易找到与组相关的定义。向这段代码添加新功能的程序员应该知道将代码放在哪里,以保持程序的组织性。

前面我们提到 src/main.rs 和 src/lib.rs 被称为 crate 根文件。命名的原因是这两个文件的内容在 crate 的模块结构(称为模块树)的根位置形成了一个名为 crate 的模块。

restaurant 库的模块树如下所示:

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

这个树显示了一些模块如何嵌套在其他模块中,整个模块树都植根于名为 crate 的隐式模块。

如果模块 A 包含在模块 B 中,我们说模块 A 是模块 B 的子模块,模块 B 是模块 A 的父模块。同一层级的模块叫做兄弟模块。

模块树如同计算机上文件系统的目录树。就像目录中的文件一样,我们需要一种找到模块的方法。

包含二进制文件和库的 package 的最佳实践

我们提到过一个 package 可以同时包含 src/main.rs(二进制 crate 根文件)以及 src/lib.rs 两个 crate。

默认情况下,两个 crate 都有包名。通常,具有这种既包含库 crate 又包含二进制 crate 的 package 在二进制 crate 中会有足够的代码来启动调用库 crate 中的代码的可执行文件。这使得其他项目可以从包提供的大部分功能中受益,因为库 crate 的代码可以共享。

模块树应该在 src/lib.rs 中定义。然后,任何公共项目都可以通过以包名开始的路径在二进制 crate 中使用。二进制 crate 成为库 crate 的用户,就像一个完全外部的 crate 使用库 crate 一样:它只能使用公共 API。

这有助于你设计一个好的 API。因为你不仅是作者,还是客户!

在模块树中引用项目的路径

要调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • 绝对路径是从 crate 根目录开始的完整路径。对于来自外部 crate 的代码,绝对路径以该 crate 的名称开头;而对于来自当前 crate 的代码,它以 crate:: 开头。

  • 相对路径从当前模块开始,使用当前模块中的 self、super 或标识符。

绝对路径和相对路径都使用 :: 标识符做分隔。

假设我们想调用 add_to_waitlist 函数,要先找到它的路径,于是有两种方式调用:

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

eat_at_restaurant 函数是库 crate 的公共 API 的一部分,所以我们用 pub 关键字标记它。

虽然上面的代码中绝对路径和相对路径的写法没有问题,但不能通过编译。报错:error[E0603]: module `hosting` is private。

原因是模块 host 是私有的,Rust 不允许访问模块的私有部分。在 Rust 中,默认情况下,所有项(函数、方法、结构、枚举、模块和常量)对父模块都是私有的。父模块中的项不能使用子模块中的私有项,但子模块中的项可以使用其祖先模块中的项。这是因为子模块封装并隐藏了它们的实现细节,但是子模块可以看到它们被定义的上下文。

使用 pub 关键字公开路径

Rust 提供 pub 关键字,可以将一个项设为 public,将子模块代码的内部部分公开给外部祖先模块。

将模块设为公共并不会将其内容设为公共。模块上的 pub 关键字只允许其祖先模块中的代码引用它,而不能访问其内部代码。我们需要更进一步,选择将模块中的一个或多个项也设置为公共项。

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

可以简化:

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

以 super 开头的相对路径

通过在路径的开头使用 super,我们可以构建从父模块开始的相对路径,而不是从当前模块或 crate 根模块开始。

fn deliver_order() {}

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

    fn cook_order() {}
}

fix_incorrect_order 函数位于 back_of_house 模块中,因此我们可以使用 super 转到 back_of_house 的父模块,在本例中是 crate,即根模块。从那里,我们寻找 deliver_order 并找到它。成功!

使用 super,如果将来这些代码被移动到不同的模块,我们将有更少的地方更新代码。

使结构和枚举成为 public

结构体和枚举默认是 private 的,我们也可以使用 pub 将结构体和枚举指定为 public。

如果在结构体定义之前使用 pub,则将结构体设为 public,但结构体的字段仍然是 private。我们可以根据具体情况将每个字段公开或不公开。

我们定义一个公共的 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() {
    // Order a breakfast in the summer with Rye toast
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal
    // meal.seasonal_fruit = String::from("blueberries");
}

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

相反,如果将枚举设为 public,则它的所有变体都是 public。我们只需要在 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;
}

在每种情况下都必须用 pub 来注释所有枚举变量是很烦人的,所以枚举变量的默认值是 public。

还有一种涉及pub的情况我们还没有讨论,那是我们模块系统的最后一个特性:use关键字。我们将首先介绍use本身,然后我们将展示如何将pub和use结合起来。

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

必须写出调用函数的路径会让人觉得不方便且重复。幸运的是,有一种方法可以简化这个过程:我们可以用 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();
}

通过在 crate 根目录中添加 use crate::front_of_house::hosting, hosting 现在是该作用域内的有效名称,就像在 crate 根目录中定义了 hosting 模块一样。

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

将 eat_at_restaurant 函数移动到名为 customer 的新子模块中,该子模块的作用域与 use 语句不同,因此函数体无法编译。

use 的常见使用方法

一般使用 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();
}

另一方面,在使用 struct、enum 和其他项时,习惯上指定完整路径。下面演示了将标准库的 HashMap 结构体引入二进制 crate 作用域的惯用方法。

use std::collections::HashMap;

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

这种习惯用法背后没有什么强有力的理由,它只是一种已经出现的约定,人们已经习惯了以这种方式阅读和编写 Rust 代码。

这种习惯用法的例外情况是,如果我们使用 use 语句将两个同名的项带入作用域,因为 Rust 不允许这样做。下面演示了如何将两个名称相同但父模块不同的 Result 类型引入作用域,以及如何引用它们。

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

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

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

使用父模块可以区分两种 Result 类型。如果我们指定 use std::fmt::Result 和 use std::io::Result,我们将在同一作用域中拥有两个 Result 类型,Rust 在使用 Result 时将不知道我们指的是哪一个。

使用 as 关键字提供新名称

对于将两个具有相同名称的类型带入相同作用域的问题,还有另一种解决方案:在路径之后,我们可以为类型指定 as 和一个新的本地名称或别名。

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

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

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

在第二个 use 语句中,我们为 std::io::Result 类型选择了新名称 IoResult,它不会与 std::io::Result 冲突。

使用 pub use 重新导出名称

当使用 use 关键字将名称引入作用域时,在新作用域中可用的名称是 private。为了使调用我们代码的代码能够引用该名称,就好像它是在该代码的作用域中定义的一样,我们可以组合 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();
}

在此更改之前,外部代码必须通过使用路径 restaurant::front_of_house::hosting::add_to_waitlist() 来调用 add_to_waitlist 函数,使用 pub use 之后,可以用 restaurant::hosting::add_to_waitlist() 代替。

使用外部 package

在之前的猜谜游戏中,我们在 Cargo.toml 的依赖部分添加了这一行:

rand = "0.8.5"

然后,为了将 rand 定义引入包的作用域中,我们添加了以 crate 名称 rand 开头的 use 行,并列出了我们想要引入作用域中的项。

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..=100);
}

注意,标准 std 库也是一个外部的 crate。因为标准库是随 Rust 语言一起提供的,所以我们不需要在 Cargo.toml 中显式引入。但是我们确实需要用 use 将其中的项引入作用域。例如,使用 HashMap,我们将使用这一行:

use std::collections::HashMap;

这是一个以 std 开头的绝对路径,std 是标准库的名称。

使用嵌套路径简化代码

如果我们在同一个 crate 或同一个模块中定义了多个项目,那么将每个项目单独列在一行中可能会占用文件中的大量垂直空间。

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

我们可以使用嵌套路径将相同的项放在一行中。通过指定路径的公共部分,后面跟着 ::,然后用 {} 括起路径中不同部分的列表来实现这一点:

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

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

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

这两个路径的共同部分是 std::io,为了将这两个路径合并为一个 use 语句,我们可以在嵌套路径中使用 self:

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

Glob 操作符

如果要将路径中定义的所有公共项都纳入作用域,可以指定该路径,后跟 * 操作符:

use std::collections::*;

这个 use 语句将 std::collections 中定义的所有公共项带入当前作用域。

在测试时,经常使用 * 操作符将所有要测试的东西都放入 tests 模块中。

将模块分离到不同的文件中

当模块变大时,我们将把模块提取到文件中,而不是在 crate 根文件中定义所有模块。

首先,我们将 front_of_house 模块提取到它自己的文件中。删除 front_of_house 模块花括号内的代码,只留下 mod front_of_house; 声明。

src/lib.rs:

mod front_of_house;

pub use crate::front_of_house::hosting;

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

类似地,我们将 hosting 模块提取到它自己的文件中。

src/front_of_house.rs:

pub mod hosting;

src/front_of_house/hosting.rs:

pub fn add_to_waitlist() {}

这个过程有点不同,因为 hosting 是 front_of_house 的子模块,而不是根模块。我们将把用于托管的文件放在一个新目录中,该目录将以模块树中它的祖先命名,在本例中为 src/front_of_house。

mod 关键字声明了模块,Rust 会在与模块同名的文件中查找进入该模块的代码。

备用文件路径

到目前为止,我们已经介绍了 Rust 编译器使用的最惯用的文件路径,但是 Rust 还支持一种更老的文件路径。对于在 crate 根目录中声明的名为 front_of_house 的模块,编译器将在以下目录中查找该模块的代码:

  • src/front_of_house.rs(声明)
  • src/front_of_house/mod.rs(实现)

对于一个名为 hosting 的模块,它是 front_of_house 的子模块,编译器将在下面查找该模块的代码:

  • src/front_of_house/hosting.rs(声明)
  • src/front_of_house/hosting/mod. rs(实现)

Rust 允许在同一项目中的不同模块中混合使用这两种风格,但如果对同一个模块同时使用这两种风格,就会得到编译器错误。

使用名为 mod.rs 文件的风格的主要缺点是,大型项目最终可能会有许多 mod.rs,会让人搞混。

总结

Rust 允许将一个 package 拆分为多个 crate,并将一个 crate 拆分为多个模块,这样就可以从一个模块中引用另一个模块中定义的项。

引入模块可以通过指定绝对或相对路径来实现。也可以使用 use 语句将这些路径引入作用域中,这样就可以使用较短的路径来多次使用该作用域中的项。

模块代码在默认情况下是私有的,但是可以通过添加 pub 关键字使定义成为公共的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值