第十二章 模块化与 crate:把代码码成一座能住人的楼

你有没有收拾过一间堆满杂物的房间?所有东西都摊在地上,找个螺丝刀要翻半天,改个东西怕碰坏旁边的。代码写到几千行还全塞在一个文件里,就是这副德行。Rust 怎么帮你把这间乱房,改成有卧室、有厨房、有储藏间的公寓?

很多新手一上来被 mod、use、pub 这几个关键字搞晕,觉得”不就是分个文件嘛,至于这么绕?” 我当年也觉得绕。后来带团队做项目,看到有人把三万行的逻辑平铺在一个 main.rs 里,改一处崩一片,才明白:模块化不是”分文件”这么肤浅,它是给代码划地盘、定边界、立规矩。这一章我们把它讲透——从模块树,到 crate/package/workspace 这三层壳,再到 Cargo 这个”项目大管家”的心智。

一、模块树:代码不是平铺的地板,是分层的大树

Rust 里最基础的组织单位叫模块(module),用 mod 关键字声明。一个模块可以装着函数、结构体、也可以是别的模块。模块套模块,就长成一棵模块树——根是你这个 crate 的入口(库 crate 是 lib.rs,二进制 crate 是 main.rs)。

打个比方。你住的小区是一栋楼,楼里分楼层,楼层分住户,住户里分房间,房间里分柜子。模块树就是这棵”楼—层—户—房—柜”的树。你要拿柜子里的一双鞋,得顺着”几栋几层几户哪个柜”一路指下去,这就是路径。Rust 里引用一个东西,就是沿着模块树写路径。

mod kitchen {          // 厨房模块
    pub mod fridge {   // 冰箱子模块,对外可见
        pub fn store(food: &str) {
            println!("把 {} 放进冰箱", food);
        }
    }
}
 
// 通过路径调用
crate::kitchen::fridge::store("牛奶");

注意这里出现了 pub。模块默认是”关着门的”——里面的东西外面看不见。你得一扇扇门地开(pub),外面才能进。这不是麻烦,是默认私有这条铁律带来的安全感:你没主动公开的,别人就不可能依赖,你日后想改就改,不怕牵一发动全身。

二、use:给长路径起个别名,少写点字

路径写全了很长,就像每次找人都得报”3 栋 2 层 1 户”。Rust 用 use 让你把常用路径”搬”到当前作用域,之后直接用短名。

use crate::kitchen::fridge; // 把路径引进来
fridge::store("鸡蛋");      // 直接用,不用写全
 
// 也可以直接引到函数
use crate::kitchen::fridge::store;
store("蔬菜");

这里有个很容易踩的坑,墨叔点一下:use 只是把名字借到你这儿方便用,并不改变那个东西的”私有/公有”属性。 如果 store 本身没标 pub,你 use 它也 use 不进来——门都没开,你拿钥匙也进不去。所以可见性是第一道闸,use 是第二道便利梯,顺序不能乱。

如果你写过 Java,import 和 Rust 的 use 表面像,但心智要重装一点:Java 的 import 更多是”省打字”,类本身的可见性由 public/private 决定且包内默认可见;Rust 的模块是完全封闭的树,没有”同包默认可见”这种宽松地带,所有跨模块的可见都得靠显式 pub 一层层开。Rust 把”谁能看谁”这件事看得更死,换来的是重构时你可以放心地动私有代码。

三、可见性 pub:一扇扇开还是一下子开

pub 还有几个精细的”半开门”写法,值得知道,因为它们体现了 Rust 对”封装”的讲究:

  • pub:对任何地方可见。
  • pub(crate):只对当前 crate 内部可见,外部用你这个库的人看不到。像小区里的健身器材,住户能用,外人进不来。
  • pub(super):只对父模块可见。
  • pub(in path):只对指定路径内的模块可见。
mod db {
    pub(crate) struct Pool { /* 连接池内部细节 */ }
    // 外部库使用者拿不到 Pool,但 crate 内随便用
}

这套精细可见性,本质是给你一套”分层次暴露 API”的工具:对外承诺稳定的只标 pub,内部协作的用 pub(crate) 关起门来用。你以后写库给别人用时,会感激这个设计——它能把”用户能依赖的接口”和”你自己随便改的内部实现”清楚隔开。

四、crate / package / workspace:三层壳别搞混

很多人把这三个词混为一谈。墨叔给你一根线拎清楚,这是本章的硬骨头。

crate(箱):Rust 编译器一次编译的最小单元,就是一个”库”或”可执行程序”。一个库 crate 编译出 .rlib,一个二进制 crate 编译出可执行文件。一个 crate 对应一棵模块树。

package(包):由 Cargo 管理的、包含一个或几个 crate 的”发布单元”。一个 package 里至少有一个 crate,最多一个库 crate 加任意多个二进制 crate。Cargo.toml 就是一个 package 的身份证。

workspace(工作区):一组共享同一个 Cargo.lock 和输出目录的 package 的集合。适合把一个大项目拆成多个相互依赖的小 package,比如一个 core 库、一个 cli 工具、一个 server 服务,三个 package 放在一个 workspace 里一起管。

# Cargo.toml 声明一个 package
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
 
[dependencies]
serde = "1.0"   # 依赖一个外部 crate
# 工作区根目录的 Cargo.toml
[workspace]
members = [
    "core",     # 子 package:核心库
    "cli",      # 子 package:命令行
    "server",   # 子 package:服务端
]

记忆口诀:workspace 装 package,package 装 crate,crate 里面才是模块树。 从大到小是 工作区 → 包 → 箱 → 模块。你搞清了这组包含关系,就不会再把 mod 和 crate 混着用了。

五、Cargo:项目的”大管家”而非”构建工具”

说到 Cargo,很多人直觉以为它就是”Rust 的 Make / Maven”。太小看它了。Cargo 在 Rust 生态里的角色,是从摇篮到坟墓的项目管家:管依赖、管构建、管测试、管文档、管发布。

  • 依赖:你在 Cargo.toml 写一行 serde = "1.0",Cargo 自动去 crates.io 下载、解析版本、解决冲突,还帮你锁版本(Cargo.lock)。你不用手动去扒源码、配 include 路径——那种 C/C++ 时代的痛苦,Rust 从根上免了。
  • 构建:cargo build 编译,cargo build --release 开优化编译。它自动处理增量编译、缓存,你不用写 Makefile。
  • 测试:cargo test 一键跑所有单元测试和集成测试。Rust 把测试当一等公民,写测试就是写带 #[test] 的函数,不用引第三方框架。
  • 文档与发布:cargo doc 生成文档,cargo publish 推到 crates.io 让别人用。
cargo new my_lib --lib   # 新建一个库 package
cargo add serde          # 加依赖(自动写进 Cargo.toml)
cargo build              # 编译
cargo test               # 跑测试

墨叔说句实在话:Cargo 的”约定优于配置”极大地降低了协作成本。你从别人项目 git clone 下来,一句 cargo build 就能跑起来,不用问”你用的什么编译器版本、依赖放哪了”。这种”到我机器上也能跑”的确定性,是工程效率的真金白银。

六、Cargo 的 features:按需开关的”编译期开关”

顺带提一个 Cargo 很妙的能力:features(特性)。同一个 crate,你可以声明若干可选功能,使用者按需在 Cargo.toml 里打开。这背后是条件编译——开哪个 feature,就编译哪段代码。

# 库的 Cargo.toml
[features]
default = ["std"]      # 默认开启 std
std = []               # 标准库支持
serialize = ["serde"]  # 序列化支持,连带引入 serde
#[cfg(feature = "serialize")]
use serde::Serialize; // 只有开了 serialize 才编译这行

这套机制的价值在哪?它让你一个 crate 服务多种场景:嵌入式环境关掉 std 用 no_std 版本,普通应用开着全套功能。你不用为了不同场景维护多个代码分支或发布多个包。墨叔觉得这是”把代码组织成可复用单元”这件事在依赖层面的延伸——复用不只是”这段代码被多调用”,更是”同一份代码能按使用者的约束裁剪出不同形态”。

七、把代码组织成可复用单元:思路比语法重要

讲完机制,墨叔想跟你聊点更上层的:为什么要把代码码成模块、码成 crate? 不是因为语法要求,是因为”可复用”和”可维护”是软件活过三年的命脉。

核心思路就三句话:

第一,按”职责”切,不按”文件大小”切。厨房就该放厨具,卧室就该放床。别把一个处理网络、又处理数据库、又处理日志的巨无霸函数塞一起。每个模块只回答”我是谁、我负责什么”。

第二,对外暴露最小的面。你的模块像一家餐厅的后厨:顾客(调用方)只需要看菜单点菜(公开的 API),不需要冲进厨房看你怎么切洋葱。把内部实现用 pub(crate) 关起来,你改起内部来才不会被外部依赖绑住手脚。

第三,把稳定的核心做成库 crate,把入口做成二进制 crate。一个项目里,core 这种放核心逻辑、对外是干净 API 的,做成库;cli/server 这种”怎么启动、怎么接输入”的,做成二进制去依赖那个库。这样同一套核心,既能给命令行用,也能给服务端用,复用就成立了。

// lib.rs:只暴露干净的门面
pub mod engine;   // 内部实现细节在 engine 模块里
pub fn run() { /* 对外稳定接口 */ }

你品这个节奏:模块管”门里门外”,pub 管”开哪扇门”,crate/package/workspace 管”多大一个院子”,Cargo 管”院子怎么建、怎么交付”。四层各司其职,代码才从一团乱麻,变成一座你能住、能改、能扩建的楼。

【思考题】

  1. Rust 的模块默认完全私有,跨模块可见必须一层层 pub 显式打开;而有些语言”同包/同目录默认可见”。请你推一下:Rust 这种”宁可麻烦也要显式开门”的严格,在”你以后要重构一个被人依赖的库”时,能帮你避免什么具体的坑?反过来,它的代价是什么?

  2. crate / package / workspace 三层你刚看完。现在假如你要做一个项目:一个核心算法库,既给命令行工具用,又给 Web 服务端用,还想让这两个东西共享同一套依赖版本。你应该怎么用这三层壳去组织?画出(文字描述即可)它们之间的包含关系。

  3. 墨叔说 use 只是”把名字借来方便用”,不改变可见性。那请你想一个场景:我 use 了一个路径,但编译器报错说”它私有、引不进来”。这说明 use 和 pub 这两道闸,哪道在前哪道在后?从”门都没开,钥匙没用”这个比喻讲通。

【参考答案】

  1. Rust 的显式 pub 在重构时能帮你避免”不小心破坏了别人依赖的内部实现”这种坑。因为所有对外可见的点都是你主动标出来的,默认一切皆私有——所以你能放心地大改那些没标 pub 的模块,确信外面没人依赖它们(编译器会拦住任何越界的引用)。代价是:写代码时你要多敲几个 pub,路径也可能写得长;对只想快速验证想法的人来说,这种”每扇门都得亲手开”确实显得啰嗦。但换个角度,这点啰嗦换来了”改内部实现零心理负担”,对一个要活多年、多人协作的项目,这笔买卖太划算了。

  2. 用三层壳这么组织:最外层一个 workspace(共享 Cargo.lock 和依赖版本),里面放三个 package——core(库 crate,装核心算法,对外只暴露干净 API)、cli(二进制 crate,依赖 core,处理命令行输入)、server(二进制 crate,同样依赖 core,处理 Web 请求)。包含关系是:workspace ⊃ { core 包, cli 包, server 包 },每个 package 里又含 crate,crate 内部才是模块树。这样 cli 和 server 共享同一个 core 的代码和同一套锁定的依赖版本,复用和一致性都成立了。这正是 workspace 存在的意义:把”多个相关 package”当一个整体管。

  3. 报错说明可见性(pub)这第一道闸在前,use 这第二道便利梯在后。比喻是:模块像一间上锁的房间,pub 是”把门打开允许外人进”,use 是”拿到一把钥匙方便自己进出”。如果门根本没开(没标 pub,房间默认锁着),那你手里有再多的 use 钥匙也进不去——因为根本没有可被允许的入口。所以编译器的逻辑顺序是:先检查”这个东西对外可见吗(有没有 pub)“,不可见就直接拒绝,根本轮不到 use 发挥作用。记住:可见性是前提,use 只是省打字的语法糖,糖再甜也撬不开没开的门。

小结

模块树给代码划地盘,pub 一扇扇开可见性的门,use 只是把长路径借来省打字;crate 是编译单元、package 是发布单元、workspace 是多个 package 的集合,三层壳从大到小各管一摊;Cargo 这个大管家把依赖、构建、测试、发布一肩挑。把代码按职责切、最小面暴露、核心做库入口做二进制,你码出来的就不是乱房,是能住能改能扩建的楼。