模块与crate

区分Cargo package、crate与模块,并用路径、可见性和重导出构造稳定的Rust公共API。

难度 进阶 时长 标准深度约12分钟
版本 Rust 1.98
what

Cargo package是Cargo.toml描述的交付单元,crate是一次编译的单元,模块则在crate内组织名称与可见性。

trap

文件夹不会自动成为模块,use也不会加载文件;把pub加到目标项上,仍不保证外部代码有可达路径。

fix

从crate root声明模块树,只开放所需路径,并用pub use在稳定边界重导出公共API。

是什么,为什么存在

Rust用三个不同层次组织代码。 Cargo package 是带有Cargo.toml的项目交付单元; crate 是编译器一次处理的库或可执行程序; 模块(module) 则是一个crate内部的命名与可见性单元。日常说的“项目”“包”和“库”容易把这三层混在一起,读编译错误时必须使用准确名称。

一个package至少包含一个crate。它最多有一个library crate,却可以有多个binary crate;常规布局中,src/lib.rs是library crate的入口,src/main.rs和src/bin/下的目标是binary crate的入口。每个入口文件都是相应crate的 crate root ,同时形成模块树的根模块。

crate内的模块把相关项放进层级命名空间,并决定哪些实现细节能从哪些位置访问。路径指出目标项在模块树中的位置,use只把已有路径绑定到当前作用域,pub及其受限形式则控制可见范围。模块因此同时解决名称冲突、代码导航和封装边界,而不只是拆分文件。

当一个文件开始混合多个职责,或者库使用者不得依赖内部目录时,就需要模块边界。crate边界更重:它形成独立编译目标,也形成库与可执行程序之间的接口边界。工作空间、依赖解析和发布属于Cargo层,不能用模块规则替代。

工作原理

三层结构

下面这张图展示一个同时包含库和可执行程序的package。两个crate分别拥有根模块;图中的catalog与pricing只属于library crate的模块树。

package名和crate名通常接近,但它们不是同一种标识符。Cargo package可以叫store-domain,Rust源码中的默认library crate路径则写作store_domain。crate::更不是package名,它从当前crate的根模块开始解析。

模块声明与文件

mod catalog { ... }在当前位置声明并定义内联模块。mod catalog;声明相同的逻辑模块,但让编译器从外部文件读取其内容。在src/lib.rs中写mod catalog;时,常规候选是src/catalog.rs或src/catalog/mod.rs,两者不能同时存在。

子模块的文件位置由逻辑父模块决定。若src/catalog.rs包含mod pricing;,编译器会查找src/catalog/pricing.rs或src/catalog/pricing/mod.rs。移动模块定义到文件不会改变crate::catalog::pricing这样的逻辑路径;文件系统只是模块树的一种存放方式。

文件不会因为位于src/下就自动参与编译。某个可达模块中的mod声明才把它接入crate的模块树。相反,use crate::catalog::Product;不声明模块,也不控制要编译哪些文件;它只让Product这个短名称在当前作用域可用。

绝对路径与相对路径

以crate::开头的路径从当前crate根模块解析,适合表达跨越多个同级模块的稳定位置。self::从当前模块开始,super::从父模块开始;不带这些前缀的路径先按当前作用域中的名称解析。外部crate通常直接以crate名开头,例如serde::Serialize。

在一个模块内部,super::很适合访问紧邻父模块的协作项;跨越较远边界时,crate::往往更清楚。路径选择不是可见性的替代品:路径解析到某个项后,编译器仍会检查调用位置是否有权访问路径上的每一层。

use支持嵌套导入、self和as。类型通常直接导入,容易冲突的函数或同名类型则保留父模块名,或者用反映领域含义的别名。use默认只影响它所在的作用域;子模块不会自动继承父模块写下的导入。

可见性沿路径生效

普通项默认是私有的,只能由定义它的模块及其后代访问。pub扩大项的可见范围,但从某个调用点访问它时,路径上的祖先模块也必须可访问。公开结构体的字段仍分别控制可见性;公开枚举的变体则默认公开。

受限可见性把接口停在更窄的边界。pub(crate)对当前crate可见,pub(super)对父模块范围可见,pub(in crate::some_module)对指定祖先模块范围可见。在Rust 2018及之后的edition中,pub(in ...)的路径必须从crate、self或super开始。

重导出(re-export) 使用pub use为已有公共项创建另一条公共路径。它既能把深层实现路径提升到crate root,也能让内部模块本身保持私有。重导出改变调用方能使用的API路径,但不会复制定义,也不会让原路径自动变得公开。

示例

内联模块、绝对路径与相对路径

第一个示例把运费逻辑放在shipping模块中。根模块只能调用公开的quote;子模块tracking则可以用super::调用父模块的私有辅助函数。

module_paths.rs
mod shipping {
    const BASE_CENTS: u32 = 250;

    fn zone(code: &str) -> &str {
        if code.starts_with("EU-") { "EU" } else { "OTHER" }
    }

    pub fn quote(code: &str, weight_kg: u32) -> u32 {
        BASE_CENTS + weight_kg * 80 + tracking::zone_fee(code)
    }

    mod tracking {
        pub(super) fn zone_fee(code: &str) -> u32 {
            if super::zone(code) == "EU" { 0 } else { 500 }
        }
    }
}

fn main() {
    let cents = crate::shipping::quote("EU-FR", 3);
    println!("shipping: {cents} cents");
}
shipping: 490 cents

zone对shipping及其后代私有,所以tracking可以访问它。zone_fee标成pub(super)后,父模块shipping可以访问它,但crate root不能直接调用它。这里的两个限制共同留下一个有意设计的公开入口。

导入同名项

billing与shipping都提供total,直接把两个函数导入同一作用域会发生名称冲突。一个保留模块前缀,另一个使用领域别名,可以让调用点继续表达来源。

import_aliases.rs
mod billing {
    pub fn total(subtotal: u32) -> u32 {
        subtotal + subtotal / 5
    }
}

mod shipping {
    pub fn total(weight_kg: u32) -> u32 {
        250 + weight_kg * 80
    }
}

mod report {
    use crate::billing::total as invoice_total;
    use crate::shipping;

    pub fn print() {
        let goods = invoice_total(1_000);
        let delivery = shipping::total(3);

        println!("invoice: {goods}");
        println!("shipping: {delivery}");
    }
}

fn main() {
    report::print();
}
invoice: 1200
shipping: 490

别名invoice_total描述了业务含义,不只是为了绕过编译器。保留shipping::total则符合“函数通过父模块调用”的常见风格。两种选择可以并存,关键是调用点能看出使用的是哪个total。

用重导出构造公共API

下面的store模块模拟library crate的根模块。catalog保持私有,外部代码只通过根模块重导出的Product和quote使用功能。

public_api.rs
mod store {
    mod catalog {
        pub struct Product {
            name: String,
            cents: u32,
        }

        impl Product {
            pub fn new(name: &str, cents: u32) -> Self {
                Self { name: name.into(), cents }
            }

            pub fn label(&self) -> &str {
                &self.name
            }
        }

        pub fn quote(product: &Product, count: u32) -> u32 {
            product.cents * count
        }
    }

    pub use catalog::{Product, quote};
}

fn main() {
    let keyboard = store::Product::new("Keyboard", 4_500);
    let cents = store::quote(&keyboard, 2);

    println!("{}: {cents} cents", keyboard.label());
}
Keyboard: 9000 cents

Product是公开类型,但name和cents字段仍然私有,调用方必须经过构造函数与方法。pub use提供store::Product,因此内部以后可以移动catalog,只要保留这条公共路径和行为,调用方就不必随之修改。

跨同级模块使用crate内接口

最后一个示例让checkout调用同级的inventory。库存查询不是crate的外部API,因此使用pub(crate);checkout::can_ship才是根模块选择公开的入口。

crate_visibility.rs
mod inventory {
    pub(crate) fn available(sku: &str) -> u32 {
        match sku {
            "KB-01" => 8,
            "MS-02" => 0,
            _ => 0,
        }
    }
}

mod checkout {
    pub fn can_ship(sku: &str, requested: u32) -> bool {
        crate::inventory::available(sku) >= requested
    }
}

fn main() {
    println!("KB-01 x3: {}", checkout::can_ship("KB-01", 3));
    println!("MS-02 x1: {}", checkout::can_ship("MS-02", 1));
}
KB-01 x3: true
MS-02 x1: false

pub(crate)不是“以后可能会公开”的占位符,而是一项可检查的边界声明。它允许crate内的同级协作,同时阻止依赖此library crate的代码绑定到库存模块的内部查询方式。

陷阱

修复方法: 先从Cargo.toml与常规目标文件确认package中有哪些crate,再从每个crate root沿mod声明画出模块树。讨论依赖与发布时说package,讨论编译目标时说crate,讨论内部命名空间时说模块。

修复方法: 在逻辑父模块中只写一次mod orders;,并选择orders.rs或orders/mod.rs其中一种入口。子模块声明放在父模块的定义中,而不是一律堆到lib.rs。

修复方法: 决定调用方应长期依赖的路径,再公开必要祖先或在稳定边界pub use目标项。用外部风格的集成测试验证公共路径,不要只在定义模块的后代中测试。

修复方法: 从binary crate按library crate名导入,例如package store-domain的默认库通常写作use store_domain::Product;。共享逻辑放入library crate,让每个二进制成为它的普通调用方。

修复方法: 导入实际使用的类型,函数则保留父模块前缀或使用明确别名。若提供prelude,要把它视为经过设计的公共API,并审查每次新增重导出。

深入 文件树不是模块树

文件树不是模块树

常规Cargo布局让两棵树看起来相似,但编译器依据声明构造模块树。假设library crate有以下逻辑结构:

crate
└── catalog
    ├── Product
    └── pricing
        └── quote

一种对应文件布局是src/lib.rs声明mod catalog;,src/catalog.rs再声明mod pricing;,实现位于src/catalog/pricing.rs。也可以把catalog的入口放在src/catalog/mod.rs,但不能与src/catalog.rs并存。入口文件的选择不改变crate::catalog::pricing::quote这条逻辑路径。

#[path = "..."]可以覆盖外部模块的默认文件位置,但它会切断读者对常规映射的预期。只有生成代码、平台分支或遗留布局确实需要时才使用,并把该属性留在逻辑父模块附近。普通业务模块采用常规命名更容易被编辑器、构建工具和人类定位。

edition不会自动迁移目录

现代Rust允许catalog.rs搭配catalog/pricing.rs,不再要求所有带子模块的入口都叫mod.rs。旧布局仍然有效,所以升级edition不要求机械改名。真正的约束是同一位置不能同时用两种入口表示同一个模块。

公共API是可达路径集合

可见性检查针对使用位置和完整路径。深层项的pub表示它可以在祖先允许的范围内继续传播,不代表所有外部代码已经能命名它。公开API因此不是源文件中所有pub的简单列表,而是外部调用方实际可达的路径集合。

pub use internal::Product;可以让私有internal模块中的公开Product通过crate root可达。调用方绑定的是新路径,定义仍只存在一份。门面模式利用这一点把目录重构与公共API分离,但删除或改名既有重导出仍可能是破坏性变更。

公开类型的签名也会把其他类型带到接口边界。如果公共函数返回只能通过私有路径描述的类型,编译器的私有接口检查会报告问题,或者调用方会得到难以使用的API。审查公共函数时,要沿参数、返回值、trait bound与关联类型继续检查可达性。

结构体与枚举的边界不同

pub struct Product不会自动公开字段,这允许构造函数维持不变量。若生成代码把所有字段都标成pub,调用方就能绕过验证并依赖表示细节。与之不同,pub enum Status的变体默认公开,因为调用方通常需要构造或匹配这些变体。

package内的crate边界

src/lib.rs与src/main.rs同时存在时,Cargo会构建两个crate。library crate可以公开共享领域逻辑;binary crate通过库的crate名使用它,就像同package之外的调用方一样。二进制自己的crate::始终指向main.rs形成的根模块。

src/bin/import.rs、examples/demo.rs和tests/api.rs也分别形成编译目标。尤其是tests/下的每个集成测试都作为独立crate编译,所以它只能使用library crate的公共API。这个边界使集成测试很适合验证重导出路径是否真的对外可用。

crate边界还决定pub(crate)的范围。某个项对library crate内部公开,并不意味着同package的binary或集成测试可以访问它。若调用方确实需要该能力,就设计稳定的pub接口;若只为测试内部实现,优先在相应模块的后代测试模块中访问私有项。

Cargo目标形成独立根

常见package可以用下面的文件布局同时表达一个库、两个可执行程序和一个集成测试。每个目标都有自己的crate root,而catalog.rs只是library crate模块树中的一个文件。

store-domain/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── catalog.rs
│   └── bin/
│       └── importer.rs
└── tests/
    └── public_api.rs

src/main.rs生成默认二进制,src/bin/importer.rs生成另一个二进制。它们都可以依赖src/lib.rs生成的库,但彼此的私有模块不会合并。若main.rs与importer.rs都需要某段逻辑,应把它放进library crate的合适模块并公开最小接口,而不是复制实现。

集成测试tests/public_api.rs也从外部使用库,这与写在src/lib.rs内的#[cfg(test)] mod tests不同。后者是library crate模块树的后代,能够访问祖先模块的私有项;前者形成独立crate,只能看到公开可达路径。选择测试位置时应明确要验证内部实现还是外部契约。

导入绑定与API路径

use声明创建的是当前作用域中的名称绑定。默认的use是私有绑定,方便当前模块及其后代引用目标;pub use则让该绑定按声明的可见范围供其他模块使用。两者都不改变目标项原来所在的位置,也不会创建第二份类型。

同一个项可以通过多条路径访问。若crate root写下pub use catalog::Product;,库内部仍可使用crate::catalog::Product,外部调用方则使用store_domain::Product。这两个名称指向相同类型,所以不会产生转换或运行时包装。

嵌套导入只是减少重复前缀。例如use std::io::{self, Read, Write};同时绑定模块名io、trait Read和trait Write。花括号结构不表示模块父子关系发生变化;它只是一组use声明的紧凑写法。

导入的作用域同样值得审查。在父模块写下use crate::catalog::Product;,不会让子模块无条件获得裸名称Product。子模块可以自行导入,或者在确实需要依赖父级绑定时显式使用super::Product;自行导入通常更能显示直接依赖。

别名属于调用方词汇

as只改变本作用域中的绑定名,不会重命名定义或改变其他模块看到的API。遇到两个Result、两个Error或两个Config时,应按职责取名,例如FormatResult与IoResult,而不是使用Result1这类无信息别名。

公开重导出也可以使用as,这会成为外部可见的正式名称。此时别名不再只是局部便利,而是API契约的一部分。重命名前要搜索下游使用点,必要时保留旧重导出并给出迁移期。

边界检查与可维护性

Rust的可见性是编译期名称访问规则,不是安全边界。只要公共API提供了读取或修改能力,模块私有性不能阻止有权运行该进程的代码观察结果。它的价值在于约束源代码依赖,让不变量和可替换实现有明确的维护位置。

最窄可见性也不是把每个函数都藏到最深层。若多个同级模块稳定共享一项crate内能力,pub(crate)比重复包装或复制代码更准确;若只有父模块协调子模块,pub(super)可以表达更小范围。范围应跟随架构关系,而不是根据当前编译错误逐个放宽。

公共门面需要同时维护名称和语义。保留pub use catalog::Product能让内部文件移动对调用方透明,但若构造函数、字段可见性或trait实现发生不兼容变化,路径不变也不能避免破坏。模块设计减少耦合,却不会自动替你管理所有API兼容性。

使用cargo check --all-targets能覆盖默认库、二进制、example、测试与其他已声明目标的编译关系。只运行cargo run可能遗漏没有参与该次运行的目标,恰好让错误的crate路径或私有API使用继续潜伏。模块重构后应检查全部目标,并运行依赖公共路径的集成测试。

声明顺序不是执行顺序

模块和函数定义属于item,其名称通常可以在同一模块中先使用、后定义。把mod reports;移到main上方不会让它“更早加载”,放到下方也不会延迟加载。编译器在构建crate时解析整棵item树。

因此,修复未解析路径应检查模块是否声明在正确的逻辑父级,以及目标是否在当前可见范围内,而不是反复调整item顺序。macro_rules!有自己的文本作用域规则,不能据此推断普通模块也按脚本顺序加载。

#[cfg(...)] mod platform;会让模块声明本身受条件控制。生成代码若只在一个平台或feature组合上检查,就可能漏掉另一份模块文件中的错误;涉及条件模块时,应对项目支持的配置分别执行检查。

条件模块的导入和重导出通常也要使用一致的#[cfg],否则关闭该配置时,路径会指向根本不存在的项。把条件放在边界声明附近,能让模块树的每种形态更容易审查。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

前置内容 Cargo 即将上线
下一篇 Cargo workspaces 即将上线 Testing 即将上线 Derive macros 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?