探索Rust如何合理高效地组织代码。
实践中对这些概念理解不到位,想补充一下顺便耍一耍mattpocock/skills/teach。测试发现GLM5.2最好用,GPT语言能力差,抓不住重点;DS幻觉重。
古法手敲文章理清思路。
模块
首先澄清Rust中模块这一模糊的术语概念,拆分为下面三个词汇:
package: 指代一个Rust项目,即项目最顶层的Cargo.toml所在的位置; 用于解释如何组织项目,可以包含最多1个lib crate以及多个bin cratecrate:Rust中的代码编译单元,将代码路径组织为模块树,可以是库或者二进制module:crate内部的命名空间(用于组织结构体,函数,函数,常量),使用关键字mod描述,module!= 文件,一个module可以有多个代码文件package >= crate >= module
模块树的根一般位于 src/lib.rs或src/main.rs,也可以在Cargo.toml中声明:
1 | [[bin]] |
Rust使用mod关键字声明Module,例如,在crate root中声明Module example有三种方法:
- 内联声明,在
crate root中使用mod example{}声明 - 在
crate root中使用mod example;声明,有两种实现方式- 在
src/example.rs实现模块 - 在
src/example/mod.rs实现模块
模块是自上而下声明的,文件存在不等于被编译器看到,必须要在上级模块中声明,声明的模块具有父子关系,但没有更深的层级关系,不能声明孙模块。
一个简单的示例如下:二级模块的声明都在1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20net/
├── Cargo.toml
└── src/
├── lib.rs
│
├── config.rs # 单文件模块:net::config
│
├── protocol.rs # 模块入口:net::protocol
├── protocol/ # protocol 的子模块目录
│ ├── codec.rs # net::protocol::codec
│ └── message.rs # net::protocol::message
│
├── transport/ # 使用 mod.rs 的目录模块
│ ├── mod.rs # 模块入口:net::transport
│ ├── tcp.rs # net::transport::tcp
│ └── udp.rs # net::transport::udp
│
└── bin/
├── server.rs # server binary crate
└── client.rs # client binary cratelib.rs中1
2
3
4// lib.rs
mod config;
mod protocol;
mod transport;protocol.rs声明:1
2mod codex;
mod message;transport/mod.rs声明:1
2mod tcp;
mod udp;rust官方推荐使用第一种声明模式,即protocol对应的模式,一句话就可以描述:模块体位于同名文件,子模块位于同名目录;同时代码具有更好的描述能力和唯一命名,grep时不会出现一堆mod.rs。
同一个package下多个crate是对等的;
- 在
路径与引用
模块的路径分为两种:
- 绝对路径,使用
crate进行寻址 - 相对路径,从当前路径开始寻址,或从父模块开始寻址(
super)
如果同一模块内部紧耦合,一般使用super::寻址,否则直接用绝对路径寻址crate::; 引用的是模块而非文件。
| 模式 | 锚点(相对谁) | 解析规则 | 典型用途 | 示例 |
|---|---|---|---|---|
crate::path | 当前 crate 的根 | 绝对路径 | 推荐默认,无歧义,移动文件后不易失效 | crate::net::Connection |
self::path | 当前模块 | 显式相对,退 0 层 | 较少使用,用于强调“当前模块” | self::helper() |
super::path | 父模块 | 退 1 层 | 模块子树内部互相引用 | super::session::Session |
super::super::path | 祖先模块,可叠加 super:: | 退多层 | 较少使用,层数过多会降低可读性 | super::super::top_fn |
裸路径 foo::bar | 当前作用域或 extern prelude | Rust 2018 默认相对解析:先查找当前作用域中的 foo,否则查找外部 crate | 引用外部 crate,或当前作用域内的项 | serde::Deserialize、helper::run |
::foo::bar,前导双冒号 | crate 根或 extern prelude | 强制从根或外部 crate 开始解析 | 消除歧义,例如局部名称遮蔽了 crate 名称 | ::serde::Serialize |
$crate::path | 定义该宏的 crate | 仅在宏内部有效 | 使宏展开后的代码指向宏定义所在的 crate | $crate::Config |
Self::path / Self | 当前 impl 或 trait 的实现类型 | 类型上下文,不属于模块路径 | 引用当前实现类型自身 | Self::new()、Self |
可见性
讨论代码的访问权限与导出方式
Rust默认代码(函数,Trait, 常量)访问权限私有,仅本模块和子模块可以使用;公开权限使用pub关键字声明,可以细化开放的程度
| 写法 | 等价于 | 谁可见 | 典型用途 |
|---|---|---|---|
不写 / pub(self) | pub(in self) | 仅当前模块及其后代 | 默认私有 |
pub(super) | pub(in super) | 父模块及其所有后代 | 只供上层模块使用 |
pub(in path) | — | path 指定的祖先模块及其后代 | 供某个模块子树使用 |
pub(crate) | pub(in crate) | 整个当前 crate | 最常用的 crate 内部可见性 |
pub | 可理解为开放到 crate 根之外 | 所有 crate,包括外部 crate | 构建公共 API 表面 |
| 有三种情况需要单独说明: |
- 公开结构体的字段默认私有,需要显式指明访问权限(使用
pub/pub(crate)/pub(super))1
2
3
4
5
6
7pub struct Point { // struct 本身对外公开
x: f64, // 但字段 x、y 默认私有!
y: f64,
}
// 外部 crate 写 Point { x: 1.0, y: 2.0 } ❌ 编译失败
// 外部 crate 写 p.x ❌ 编译失败 - 枚举类型的所有变体基础枚举类的访问权限
1
2
3
4
5
6
7pub enum Message { // enum 公开
Quit, // 自动公开
Move { x: i32, y: i32 }, // 公开,但字段x/y采用struct字段私有规则;
Write(String), // 公开
}
// 外部:Message::Move { x, y } ✅
// 外部: Message::Move {x : 1, y : 1 } ❌ 编译失败,x/y 得是 pub 才能写字面量 trait内声明的函数,访问权限与trait一致1
2
3pub trait Sink {
fn write(&mut self, data: &[u8]); // 不能写 pub,也不需要
}
导出
- 使用
use关键字导入模块内的符号,如use std::sync::Arc - 使用
use mod_example::*;导入模块中的所有可见符号; - 使用
pub use mod_example;使本模块导入的关键字对上层可见 - 使用
use … as …模式为符号声明别名, 例如use std::io::Result as IoResult
use关键字不改变类型/常量等的可见性,而且use只在本模块创建别名; rust中每个模块都是独立的命名空间,刻意保持模块之间的隔离
1 | mod a { |
pub use被称为重导出,同样不改变可见性,但是提供额外的访问路径,并且可以被其他模块和外部模块使用。
典型用法一:压平命名空间
1
2
3
4
5
6
7// 内部深处
// src/engine/tcp/conn.rs
pub struct Connection { /* ... */ }
// src/lib.rs —— crate 根
pub mod engine; // 注意:engine 子树本身也 pub,否则够不着
pub use engine::tcp::conn::Connection; // 压平!用户写
my_crate::Connection,而不是又臭又长的my_crate::engine::tcp::conn::Connection。 内部重构(改名、移目录)时,只要这行pub use还指向正确的项,用户代码一行不用改。典型用法二:重导出公共依赖
如果公共API暴露依赖crate类型,按 API Guidelines #176,重导出可以避免下游手动添加依赖 :1
2
3// 你的 crate 依赖了 `serde_json`,且公共函数返回 serde_json::Value
pub use serde_json; // 下游用 my_crate::serde_json::Value 即可
pub fn parse_config(raw: &str) -> serde_json::Value { /* ... */ }规则 C-REEXPORT:凡是出现在你公共签名里的外部类型,最好
pub use重导出它的来源 crate。否则下游为了能”命名”那个类型,被迫额外加一个依赖、还要确保版本一致。典型用法三:全局导出与改名
1
2pub use engine::*; // 重导出所有engine模块下的pub项
pub use io::Reader as InputReader; // 外部暴露为InputReader
Facade模式
facade(门面)模式是指,在root crate公开暴露公共API,隐藏目录结构,从而将内部组织和外部契约解耦和,给予更大的重构空间。
具体而言就是pub use对外API,内部代码隐藏在mod内部
1 | src/ |
外部用户只知道my_crate::{Engine,Reader,Writer}存在
1 | // src/lib.rs —— Facade |
prelude
prelude是rust编程中约定的公共模块,pub use最常用的代码,方便用户一次性全局导入;除了标准库的prelude之外,不会被自动导入,因此只是省事罢了。
1 | // src/lib.rs |
下游用户使用my_crate::prelude::*;
1 | use my_crate::prelude::*; // Engine / Reader / Writer / Config 全到位 |
藏住公共代码
部分派生宏、Trait等必须是公开的(内部跨模块使用),但是不希望对外暴露,不是提供给用户的API,因此需要使用#[doc(hidden)]和sealed Trait#[doc(hidden)]作用是在不改变可见性的前提下,让rust doc和LSP看不到它。sealed Trait是指让pub trait有一个外部无法实现的私有super trait。作用是为了穷举类型+锁死扩展点
1 | // src/lib.rs |
工作区
工作区是多个package的集合,Cargo默认布局是单package + 多crate, 但是随着代码膨胀,由于编译时间长,系统边界难以划分,需要复用部分crate等因素,需要进行代码拆分。
拆分后整个系统的组织方式为一个工作区 + 多个成员(package), 具有如下优势:
- 优化编译时间:
Cargo并行编译多个package, 按package粒度缓存代码; - 独立复用/发布: 子组件发布到
crate.io - 硬封装边界:每个
package只能使用其他package的公共API
工作区的意义就在于统一管理项目的版本、依赖、CI;
| 共享项 | 意义 |
|---|---|
target/ | 所有成员的编译产物统一存放,避免重复编译同一份依赖。 |
Cargo.lock | 整个 workspace 锁定统一的依赖版本,避免不同成员使用不同版本。 |
Root Cargo.toml | 作为 workspace 清单,用于声明 members,并定义可继承的依赖项和 package 元数据。 |
工作区具有两种形态:
- 主
package+ 多个内部库: 最常用,适合一个主程序+多个内部拆分逻辑+多个内部工具。CLI到WEB都可以使用这种模式,如ripgrep1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17ripgrep/
│ Cargo.toml ← workspace
│
├ ripgrep/ ← 主 crate(最终的 rg 可执行程序)
│ └ src/
│
├ grep-searcher/ ← 内部库 crate(搜索引擎)
│ └ src/
│
├ grep-regex/ ← 内部库 crate(正则处理)
│ └ src/
│
├ grep-cli/ ← 内部库 crate(命令行参数解析)
│ └ src/
│
└ ignore/ ← 内部库 crate(.gitignore 规则解析)
└ src/ - 多个平级的
package(虚拟Manifest): 一个生态下有多个package,每个独立对外发布,有明确边界,如tokio1
2
3
4
5
6tokio/
├ tokio/ ← 主 crate
├ tokio-macros/
├ tokio-util/
├ tokio-stream/
└ tokio-test/
使用方式是在Root Cargo.toml声明工作区成员,公共依赖,公共信息;在各个独立package中复用工作区配置或者独立配置。
1 | # —— 根 Cargo.toml(虚拟 manifest)—— |
1 | # —— crates/app/Cargo.toml(成员)—— |
使用工作区有如下注意:
- 共享信息必须放在
workspace这一节,package的信息独立 package不会显示继承工作区的依赖,必须手动指明;- 工作区中通过路径指定的子
package只有在指明了版本后才会独立发布(见my-core)