内容概览
-
为什么 Rust 模块系统容易让人困惑
很多人会下意识把"模块"和"文件"画等号,但 Rust 里这两件事不是一回事。
-
先理解 crate:Cargo 认识的是入口文件,不是所有
.rs文件
src/main.rs和src/lib.rs是默认入口,其他文件需要通过mod接入模块树。 -
mod是声明模块,use是把名字引入作用域
mod决定代码是否进入 crate;use只是让路径更短。 -
模块可以写在同一个文件里,也可以拆到不同文件里
文件只是模块代码的存放方式,不是模块本身。
-
math.rs和math/mod.rs的关系两种写法在模块路径上等价,只是文件布局不同。
-
子模块、私有模块与 re-export
用私有子模块组织实现,用
pub use暴露真正想给外部使用的 API。 -
crate::、super::、self::怎么理解可以把它们类比成路径里的根目录、上级目录和当前目录。
-
兄弟模块之间如何访问
兄弟模块不能"直接互相看见",通常要通过父模块路径访问。
-
prelude 模式:大型 crate 如何整理常用 API
不一定把所有东西都暴露到根目录,可以通过
prelude精心导出常用符号。 -
结语:Rust 模块系统的核心心智模型
先想模块树,再想文件树;先理解可见性,再决定目录结构。
本文是对Rust modules vs files的整理与翻译
Rust 模块和文件不是一回事:一次讲清 mod、use、pub use
Rust 初学者经常会遇到一个看起来很小、但实际很烦的问题:
我明明新建了一个 math.rs 文件,为什么 main.rs 里不能直接用?
比如项目结构是这样:
text
src/
main.rs
math.rs
math.rs 里写:
rust
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
然后 main.rs 里写:
rust
fn main() {
let result = math::add(1, 2);
println!("{result}");
}
很多人会以为这应该能跑。毕竟在不少语言里,一个文件就是一个模块,文件存在了,编译器就会知道它。
但 Rust 不这样。
Rust 的模块系统最容易误解的地方就在这里:模块不是文件,文件也不会自动变成模块。
文件只是代码的存放位置。真正决定模块结构的是 mod。
一、先从 crate 说起
在 Rust 里,一个 crate 可以粗略理解成一个项目或一个编译单元。它通常有一个 Cargo.toml,里面声明包名、版本、edition、依赖等信息。
一个二进制 crate 默认入口是:
text
src/main.rs
一个库 crate 默认入口是:
text
src/lib.rs
也就是说,Cargo 默认会认识 main.rs 或 lib.rs。
但它不会自动扫描 src 下面所有 .rs 文件,然后全部编译进去。
这点非常关键。
如果你随手新建一个文件:
text
src/math.rs
但没有任何地方通过 mod math; 引用它,那么这个文件就不属于当前 crate 的模块树。编译器甚至不会解析它。
所以:
text
文件存在 != 模块存在
Rust 要求你明确告诉编译器:这个模块是 crate 的一部分。
二、mod 和 use 到底分别干什么
这是 Rust 模块系统里最重要的一组区别。
mod:声明一个模块
rust
mod math;
这句话的意思是:
请在当前模块下面声明一个名为 math 的子模块。它的源码通常在下面两个位置之一:
text
math.rs
math/mod.rs
如果是在 src/main.rs 里写 mod math;,编译器会去找:
text
src/math.rs
或者:
text
src/math/mod.rs
use:把路径引入当前作用域
rust
use math::add;
这句话的意思是:
把 math::add 这个名字引入当前作用域,这样后面可以直接写 add()。
它只是简化路径,不会让编译器加载新文件。
换句话说:
rust
use math::add;
不能替代:
rust
mod math;
这两个东西经常一起出现,但职责完全不同。
可以这样记:
text
mod 决定"有没有这个模块"
use 决定"怎么更方便地叫它"
三、模块不一定要放在单独文件里
Rust 的模块可以直接写在当前文件中。
比如 src/main.rs:
rust
mod math {
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
}
fn main() {
let result = math::add(1, 2);
println!("1 + 2 = {result}");
}
这里 math 是一个模块,但它没有单独文件。
所以模块首先是语言层面的命名空间,不是文件系统概念。
它可以用来:
- 组织相关函数、类型和常量
- 控制可见性
- 避免命名冲突
- 对外隐藏内部实现
文件只是承载模块代码的一种方式。
四、把模块拆到单独文件
现在我们想把 math 模块拆出来。
项目结构:
text
src/
main.rs
math.rs
src/math.rs:
rust
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
src/main.rs 必须写:
rust
mod math;
fn main() {
let result = math::add(1, 2);
println!("1 + 2 = {result}");
}
注意 math.rs 里面不需要再包一层:
rust
mod math {
// ...
}
这是很多新手会犯的错误。
因为 mod math; 已经告诉编译器:math.rs 里的内容属于 math 模块。
所以正确心智模型是:
rust
mod math;
大概等价于告诉编译器:
rust
mod math {
// 这里放 src/math.rs 的内容
}
只是实际项目中不要用 include! 这样手动拼文件,正常写 mod math; 就好。
五、math.rs 和 math/mod.rs 是等价布局
如果模块比较小,可以这样:
text
src/
main.rs
math.rs
如果模块变复杂,可以这样:
text
src/
main.rs
math/
mod.rs
此时 src/main.rs 仍然是:
rust
mod math;
fn main() {
let result = math::add(1, 2);
println!("{result}");
}
src/math/mod.rs:
rust
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
这两种布局对模块路径来说是等价的:
text
src/math.rs
src/math/mod.rs
都表示:
text
crate::math
可以简单理解:
text
math.rs 适合小模块
math/mod.rs 适合模块下还要继续拆子模块
补充一点:现代 Rust 也常见另一种目录布局:
text
src/
math.rs
math/
add.rs
sub.rs
也就是说,math.rs 可以作为 math 模块的入口,同时 math/ 目录放它的子模块。很多新项目更偏好这种方式,因为可以减少大量 mod.rs 文件。
六、子模块怎么拆
假设我们想让 math 下面有两个子模块:
text
src/
main.rs
math/
mod.rs
add.rs
sub.rs
src/main.rs:
rust
mod math;
use math::{add, sub};
fn main() {
let result = add(1, 2);
println!("1 + 2 = {result}");
}
src/math/add.rs:
rust
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
src/math/sub.rs:
rust
pub fn sub(x: i32, y: i32) -> i32 {
x - y
}
现在关键在 src/math/mod.rs。
如果只写:
rust
mod add;
mod sub;
这表示 math 模块知道了两个子模块,但这两个子模块默认是私有的。
外部不能直接写:
rust
use math::{add, sub};
因为 math::add 此时指的是一个私有模块,不是一个公开函数。
那是不是改成这样就行?
rust
pub mod add;
pub mod sub;
这确实能让外部访问模块,但路径会变成:
rust
math::add::add(1, 2)
math::sub::sub(1, 2)
这通常不是我们想要的 API。
我们真正想暴露的是函数,而不是内部拆分出来的模块。
更好的写法是:
rust
mod add;
mod sub;
pub use add::add;
pub use sub::sub;
这样:
add模块是私有实现细节sub模块是私有实现细节math::add是公开函数math::sub是公开函数
从调用方看,API 很干净:
rust
use math::{add, sub};
fn main() {
println!("{}", add(1, 2));
println!("{}", sub(3, 1));
}
这就是 pub use 的价值:重导出 API,同时隐藏内部文件结构。
七、pub mod 和 pub use 的区别
这两个写法都能让外部访问东西,但暴露的层级不一样。
pub mod
rust
pub mod add;
表示把整个 add 模块公开出去。
调用方可能需要写:
rust
math::add::add(1, 2)
这等于把你的目录结构暴露成公共 API。
pub use
rust
mod add;
pub use add::add;
表示模块仍然私有,但把里面的 add 函数重新导出。
调用方写:
rust
math::add(1, 2)
这更像是在设计 API,而不是把文件系统直接摊给用户看。
实际项目里,pub use 很常见。它可以让内部实现自由拆文件,而不影响外部调用路径。
八、crate::、super::、self:: 怎么理解
Rust 的模块路径里常见三个前缀:
rust
crate::
super::
self::
可以把它们类比成文件路径:
text
crate:: 类似根目录 /
super:: 类似上级目录 ../
self:: 类似当前目录 ./
假设结构是:
text
crate
└── math
├── add
└── sub
在 math/add.rs 里,想访问 math 模块里的常量:
src/math/mod.rs:
rust
mod add;
mod sub;
const DEBUG: bool = true;
src/math/add.rs:
rust
pub fn add(x: i32, y: i32) -> i32 {
if super::DEBUG {
println!("add({x}, {y})");
}
x + y
}
这里 super::DEBUG 表示访问父模块 math 里的 DEBUG。
即使 DEBUG 不是 pub,子模块也可以访问父模块中的私有项。Rust 的可见性不是简单地等同于文件路径,而是基于模块树。
如果你在任意地方想从 crate 根部开始写路径,可以用:
rust
crate::math::add
这比相对路径更稳定,适合跨层级引用。
九、兄弟模块不能直接互相访问
假设:
text
math
├── add
└── sub
你在 sub.rs 里不能直接写:
rust
add::add(x, -y)
因为 add 和 sub 是兄弟模块,不共享彼此的作用域。
正确方式是通过父模块:
rust
pub fn sub(x: i32, y: i32) -> i32 {
super::add::add(x, -y)
}
如果父模块做了 re-export:
src/math/mod.rs:
rust
mod add;
mod sub;
pub use add::add;
pub use sub::sub;
那么 sub.rs 里也可以写:
rust
pub fn sub(x: i32, y: i32) -> i32 {
super::add(x, -y)
}
这条规则很重要:兄弟模块之间不是天然互通的,要通过共同的父模块路径访问。
十、use 不影响编译范围
很多人会问:
如果我写:
rust
use math::add;
是不是只会编译 add,不会编译 sub?
不是。
use 只影响名字是否进入当前作用域,不影响编译器解析哪些文件。
真正决定文件是否进入 crate 的是 mod 关系。
比如:
rust
mod add;
mod sub;
pub use add::add;
pub use sub::sub;
只要 mod sub; 存在,编译器就会解析、类型检查、借用检查 sub.rs。
即使你当前没有 use sub,它仍然是 crate 的一部分。
如果某个函数完全没人用,编译器可能会警告:
text
warning: function is never used
最终二进制是否包含这段代码,则由优化和 dead code elimination 处理。
但"是否进入编译器检查范围"由 mod 决定,不由 use 决定。
十一、为什么会有 unused import 和 dead code 警告
如果你写:
rust
use math::{add, sub};
但只用了 add:
rust
fn main() {
let result = add(1, 2);
println!("{result}");
}
编译器会警告 sub 是 unused import。
如果你连 sub 都没有对外导出,或者当前 crate 内没有使用它,编译器还可能警告 dead code。
这两个警告不是挑刺,而是在提醒你:
- 作用域里引入了没用的名字
- crate 里有暂时没有实际用途的代码
一般不要急着用:
rust
#[allow(unused)]
去压警告。
更好的做法通常是:
- 真的不用就删掉
- 未来再需要时从版本控制里找回来
- 如果是库对外 API,就从
lib.rs或模块入口明确导出
十二、prelude 模式:不要把所有东西都塞到根目录
随着 crate 变大,模块层级会越来越复杂。
如果你把所有常用类型和 trait 都直接 re-export 到 crate 根目录,根命名空间会变得很拥挤。
很多 Rust crate 会提供一个 prelude 模块:
rust
use some_crate::prelude::*;
prelude 通常不是"所有东西",而是一组精心挑选的常用符号。
它的好处是:
- 用户可以快速导入常用 API
- crate 根目录不会太乱
- 可以避免一些名字冲突
- 对外 API 更有层次
比如你自己写库时,也可以这样组织:
rust
pub mod client;
pub mod error;
pub mod request;
pub mod prelude {
pub use crate::client::Client;
pub use crate::error::Error;
pub use crate::request::Request;
}
使用者可以选择:
rust
use my_crate::client::Client;
也可以选择:
rust
use my_crate::prelude::*;
这是一种 API 设计习惯,不是语法魔法。
十三、一个实用的模块组织模板
对于一个稍微有点规模的 Rust 项目,可以参考这样的结构:
text
src/
lib.rs
client.rs
error.rs
config.rs
transport/
mod.rs
http.rs
websocket.rs
src/lib.rs:
rust
pub mod client;
pub mod config;
pub mod error;
mod transport;
pub use client::Client;
pub use config::Config;
pub use error::Error;
src/transport/mod.rs:
rust
mod http;
mod websocket;
pub(crate) use http::HttpTransport;
pub(crate) use websocket::WebSocketTransport;
这里有几个设计点:
rust
pub mod client;
表示 client 是公开模块,用户可以访问 my_crate::client::...。
rust
mod transport;
表示 transport 是内部实现,不对外暴露。
rust
pub use client::Client;
表示把常用类型提升到 crate 根部,让用户可以写:
rust
use my_crate::Client;
rust
pub(crate) use ...
表示只在当前 crate 内公开,不暴露给外部依赖。
这类组织方式比"文件怎么摆就怎么暴露"更可控。
十四、常见误区总结
误区一:新建文件就能用
不行。必须有 mod 把它接入模块树。
rust
mod math;
误区二:use 会加载文件
不会。use 只把已有路径引入作用域。
误区三:pub fn 就一定能从外部访问
不一定。
函数所在模块也必须是可访问的,或者函数被更上层 pub use 重新导出。
误区四:子模块文件里还要再写一层 mod xxx {}
通常不需要。
如果 main.rs 写了 mod math;,那么 math.rs 的内容已经在 math 模块里。
误区五:兄弟模块可以直接访问彼此
不能。要通过 super:: 或 crate:: 路径访问。
误区六:pub mod 是最好的公开方式
不一定。
pub mod 会公开模块结构。很多时候用私有 mod 加 pub use,API 更干净。
结语:先画模块树,再摆文件
Rust 模块系统一开始看起来麻烦,是因为它没有把"文件系统"和"命名空间"强行绑定在一起。
这其实是好事。
你可以按照实现需要拆文件,也可以按照 API 设计重新导出符号。
内部文件结构可以很细,外部 API 可以很简洁。
理解 Rust 模块系统,最重要的是记住这几句话:
text
crate 是编译单元。
main.rs / lib.rs 是默认入口。
mod 把模块接入模块树。
use 只是缩短路径。
pub 控制可见性。
pub use 用来重新设计对外 API。
文件结构不等于 API 结构。
当你不再问"这个文件为什么不能用",而是开始问"这个模块有没有被接入模块树""这个路径在当前作用域里是否可见",Rust 的模块系统就会清晰很多。
最终,你会发现 Rust 的模块系统并不是在为难你。
它只是要求你明确区分两件事:
代码放在哪里,以及代码以什么名字暴露出去。