最后一次学习Rust:模块、可见性与工作区

探索Rust如何合理高效地组织代码。
实践中对这些概念理解不到位,想补充一下顺便耍一耍mattpocock/skills/teach。测试发现GLM5.2最好用,GPT语言能力差,抓不住重点;DS幻觉重。
古法手敲文章理清思路。

模块

首先澄清Rust中模块这一模糊的术语概念,拆分为下面三个词汇:

  • package: 指代一个Rust项目,即项目最顶层的Cargo.toml所在的位置; 用于解释如何组织项目,可以包含最多1个lib crate以及多个bin crate
  • crate: Rust中的代码编译单元,将代码路径组织为模块树,可以是库或者二进制
  • module: crate内部的命名空间(用于组织结构体,函数,函数,常量),使用关键字mod描述,module != 文件,一个module可以有多个代码文件

    package >= crate >= module

模块树的根一般位于 src/lib.rssrc/main.rs,也可以在Cargo.toml中声明:

1
2
3
[[bin]]
name = client
path = src/bin/client.rs # 必须包含fn main

Rust使用mod关键字声明Module,例如,在crate root中声明Module example有三种方法:

  1. 内联声明,在crate root中使用 mod example{} 声明
  2. crate root中使用 mod example;声明,有两种实现方式
    1. src/example.rs 实现模块
    2. src/example/mod.rs 实现模块
      模块是自上而下声明的,文件存在不等于被编译器看到,必须要在上级模块中声明,声明的模块具有父子关系,但没有更深的层级关系,不能声明孙模块。
      一个简单的示例如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      net/
      ├── 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 crate
      二级模块的声明都在lib.rs
      1
      2
      3
      4
      // lib.rs
      mod config;
      mod protocol;
      mod transport;
      protocol.rs声明:
      1
      2
      mod codex;
      mod message;
      transport/mod.rs声明:
      1
      2
      mod tcp;
      mod udp;
      rust官方推荐使用第一种声明模式,即protocol对应的模式,一句话就可以描述:模块体位于同名文件,子模块位于同名目录;同时代码具有更好的描述能力和唯一命名,grep时不会出现一堆mod.rs
      同一个package下多个crate是对等的;

路径与引用

模块的路径分为两种:

  1. 绝对路径,使用crate 进行寻址
  2. 相对路径,从当前路径开始寻址,或从父模块开始寻址(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 preludeRust 2018 默认相对解析:先查找当前作用域中的 foo,否则查找外部 crate引用外部 crate,或当前作用域内的项serde::Deserializehelper::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
    7
    pub 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
    7
    pub 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
    3
    pub 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
2
3
4
5
6
mod a {
use std::collections::HashMap; // 只在 a 模块内有效
mod b {
// 这里不能直接用 HashMap
}
}

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
    2
    pub use engine::*;                         // 重导出所有engine模块下的pub项
    pub use io::Reader as InputReader; // 外部暴露为InputReader

Facade模式

facade(门面)模式是指,在root crate公开暴露公共API,隐藏目录结构,从而将内部组织外部契约解耦和,给予更大的重构空间。
具体而言就是pub use对外API,内部代码隐藏在mod内部

1
2
3
4
5
6
7
8
9
src/
├── lib.rs ← 门面:只摆公共表面
├── engine/
│ ├── mod.rs pub(crate) 内部
│ ├── parser.rs 私有
│ └── optimizer.rs 私有
└── io/
├── reader.rs
└── writer.rs

外部用户只知道my_crate::{Engine,Reader,Writer}存在

1
2
3
4
5
6
7
// src/lib.rs —— Facade
mod engine; // 内部,默认私有,外部看不到
mod io;

// 唯一对外公开的扁平入口:
pub use engine::Engine;
pub use io::{Reader, Writer};

prelude

preluderust编程中约定的公共模块,pub use最常用的代码,方便用户一次性全局导入;除了标准库的prelude之外,不会被自动导入,因此只是省事罢了。

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/lib.rs
mod engine;
mod io;
mod config;

pub use engine::Engine;
pub use io::{Reader, Writer};

pub mod prelude { // 约定:模块名就叫 prelude
pub use crate::Engine;
pub use crate::{Reader, Writer};
pub use crate::config::Config;
}

下游用户使用my_crate::prelude::*;

1
use my_crate::prelude::*;    // Engine / Reader / Writer / Config 全到位

藏住公共代码

部分派生宏、Trait等必须是公开的(内部跨模块使用),但是不希望对外暴露,不是提供给用户的API,因此需要使用#[doc(hidden)]sealed Trait
#[doc(hidden)]作用是在不改变可见性的前提下,让rust docLSP看不到它。
sealed Trait是指让pub trait有一个外部无法实现的私有super trait。作用是为了穷举类型+锁死扩展点

1
2
3
4
5
6
7
8
9
// src/lib.rs
mod private {
pub trait Sealed {} // Sealed 是 pub,但所在模块 private 是私有的
}

// 你的公共 trait,要求实现者先实现 Sealed
pub trait Shape: private::Sealed {
fn area(&self) -> f64;
}

工作区

工作区是多个package的集合,Cargo默认布局是单package + 多crate, 但是随着代码膨胀,由于编译时间长,系统边界难以划分,需要复用部分crate等因素,需要进行代码拆分。
拆分后整个系统的组织方式为一个工作区 + 多个成员(package), 具有如下优势:

  1. 优化编译时间: Cargo并行编译多个package, 按package粒度缓存代码;
  2. 独立复用/发布: 子组件发布到crate.io
  3. 硬封装边界:每个package只能使用其他package的公共API

工作区的意义就在于统一管理项目的版本、依赖、CI;

共享项意义
target/所有成员的编译产物统一存放,避免重复编译同一份依赖。
Cargo.lock整个 workspace 锁定统一的依赖版本,避免不同成员使用不同版本。
Root Cargo.toml作为 workspace 清单,用于声明 members,并定义可继承的依赖项和 package 元数据。

工作区具有两种形态:

  • package + 多个内部库: 最常用,适合一个主程序+多个内部拆分逻辑+多个内部工具。CLIWEB都可以使用这种模式,如ripgrep
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    ripgrep/
    │ 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,每个独立对外发布,有明确边界,如tokio
    1
    2
    3
    4
    5
    6
    tokio/
    ├ tokio/ ← 主 crate
    ├ tokio-macros/
    ├ tokio-util/
    ├ tokio-stream/
    └ tokio-test/

使用方式是在Root Cargo.toml声明工作区成员,公共依赖,公共信息;在各个独立package中复用工作区配置或者独立配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# —— 根 Cargo.toml(虚拟 manifest)——
[workspace]
resolver = "2" # 显示设置为2或者3
members = ["crates/*"]
default-members = ["crates/app"]

[workspace.package]
version = "0.2.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/org/repo"

[workspace.dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
my-core = { path = "crates/core", version = "0.2.0" }

[workspace.lints.clippy]
all = "warn"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# —— crates/app/Cargo.toml(成员)——
[package]
name = "app" # 每个package有唯一名字
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
my-core = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }

[lints]
workspace = true

使用工作区有如下注意:

  • 共享信息必须放在workspace这一节,package的信息独立
  • package不会显示继承工作区的依赖,必须手动指明;
  • 工作区中通过路径指定的子package只有在指明了版本后才会独立发布(见my-core)

参考资料

  1. Cargo Workspaces - The Rust Programming Language
  2. Large Rust Workspaces
  3. Two ways of interpreting visibility in Rust | Kobzol’s blog
  4. Item 22: Minimize visibility - Effective Rust
  5. About - Rust API Guidelines
  6. GLM 5.2 + mattpocock/skills/teach