Miller 定律:人类工作记忆一次只能处理约 7 个单位的信息。但程序可以增长到百万行。

矛盾的唯一解法是:让你能在不理解整体的情况下理解局部。模块是这个机制的形式化。它的核心功能不是”组织代码”,而是制度化的主动无知——刻意选择不去了解某些细节,以便能够专注于其他的事。

一、模块是什么?(本体论)

模块是命名的无知单元——它允许你在不知道实现细节的情况下使用它。这不是缺陷,是核心功能。

David Parnas 在 1972 年的奠基论文中提出了一个至今仍被低估的洞察:模块化不是”把相关代码放在一起”,而是”把你可能想要改变的决策隐藏起来”

两种划分方式的差异:

按功能步骤划分(常见但错误):
  module_read_input
  module_process_data
  module_write_output
 
按设计决策划分(Parnas 的意思):
  module_storage_format     ← 隐藏"数据如何存储"的决策
  module_algorithm          ← 隐藏"如何处理"的决策
  module_io_protocol        ← 隐藏"如何读写"的决策

第一种划分,每个步骤都暴露给其他模块,改任何一步都可能牵连所有人。第二种划分,改存储格式只需改 module_storage_format,其他模块感知不到。

这个区别决定了模块系统的质量:按步骤划分容易,按设计决策划分才有真正的信息隐藏效果,才能让局部修改不传播到全局。

二、如何划分边界?(设计论)

接口是承诺,实现是自由

信息隐藏的核心逻辑:

公开接口  →  对使用者的承诺,不能随意改变
私有实现  →  只属于自己的自由,可以随时重写

把实现设为私有,就获得了重构的自由——你可以把整个内部算法推倒重来,只要公开接口不变,所有使用者不受影响。这是可维护性的根基,也是为什么 private 不是限制,而是保护。

循环依赖:设计错误的形式证明

如果模块 A 依赖 B,同时 B 依赖 A,说明这两个模块在概念上是同一个东西,只是假装成两个。

依赖结构是真实概念结构的镜像。循环依赖不只是技术问题,而是边界划错了的证明——需要的不是技术 workaround,而是重新思考边界。

语言循环依赖行为检测时机真正的解法
Python可能运行成功,但容易出问题运行时提取公共概念到第三个模块
TypeScriptESM 会警告,但可能编译通过编译时(警告)依赖注入、接口抽象
Rust编译错误,强制解决编译时(错误)提取公共模块,重新划分边界

Rust 强制报错不是苛刻,是诚实——它拒绝让你用技术手段掩盖概念错误。

命名空间是概念地图

模块的组织方式应该反映问题域的结构。两种常见组织方式:

按技术层次:按领域概念:
  service/              user/
    user.ts               service.ts
    order.ts              model.ts
  model/                  repository.ts
    user.ts             order/
    order.ts              service.ts
  repository/             model.ts
    user.ts
    order.ts

哪种模块树更贴近你的领域,哪种就更稳定——技术层次会变(从 MVC 到 DDD),但领域概念相对稳定。模块树和领域模型的偏差,是”代码坏味道”最深层的来源。

三、谁能越过边界?(权限论)

可见性控制是”主动无知”的执行机制——它决定了谁有权利知道什么。

三种哲学立场,和 01 类型系统 的身份认定三种立场(鸭子/结构/标称)高度对应:

策略哲学机制代表语言
约定信任共识——大家都知道规则,自觉遵守_ 前缀 + __all__,语言不强制Python
编译时检查工具验证——编译器在构建时核实private 关键字,运行时可绕过TypeScript、Java
编译器强制形式证明——不满足约束,代码不存在pub 体系,无法绕过Rust

核心洞察:可见性控制不是安全机制,是认知管理机制private 不是”秘密”,是”你不需要知道这个来使用我”。目标是缩小使用者需要理解的认知面积——公开接口越小,使用者的心智负担越低。

Python:社区共识

Python 没有 public / private 关键字。可见性靠命名约定实现:

约定含义实际效果
name公开 API可自由导入和使用
_name内部实现(约定)from module import * 不导入,但手动导入可以
__name名称修饰类内部变为 _ClassName__name,防止子类意外覆盖
__all__模块公开接口列表控制 * 导入的范围

Python 的立场是:信任比强制更重要。约定比规则更灵活,大多数情况下足够用——但在大型团队中,Linter 规则(pylint、flake8)成为事实上的”软编译器”,承担部分验证职责。

TypeScript:编译时验证

TypeScript 的 public / private / protected编译时检查——编译后完全消失,运行时无法阻止访问”私有”成员((obj as any)._field 可以绕过)。

JavaScript 的 # 私有字段是真正的运行时私有:

class Counter {
  #count = 0;           // 运行时强制私有,无法从外部访问
  private label = '';   // 编译时检查,运行时可绕过
}

两者的区别反映了”编译时验证”和”运行时强制”的哲学差异。TypeScript 选择编译时,是因为目标是开发者体验,不是运行时安全。

Rust:细粒度的承诺范围

Rust 的可见性不只是 pub / 私有二分,而是对承诺范围的精确控制:

修饰符承诺范围
无(默认)仅当前模块及其子模块可见
pub任何地方可见
pub(crate)当前 crate 内可见
pub(super)父模块可见
pub(in path)指定路径内可见

pub(crate) 的存在尤其有意思:这个函数对当前 crate 内部是公开的,但对外部消费者是私有的。这是对”内部实现 vs 公开 API”的精确区分,在 TypeScript 中需要靠文档约定,在 Rust 中编译器强制。

四、边界如何组合?(组合论)

ESM vs CJS:时态哲学的分歧

JavaScript 的模块系统是两种时态哲学共存的结果——和 07 编译与执行 的 AOT vs 解释执行是同一种分歧的模块版本:

维度ESM(静态)CJS(动态)
加载时机编译时确定依赖图运行时执行 require()
时态哲学先知——提前决定所有依赖经验主义——运行时按需加载
Tree-shaking支持(静态结构可分析)不支持
条件加载需要动态 import()原生支持
顶层 await支持不支持

ESM 是现代标准,但 CJS 在 Node.js 生态中十五年积累的历史包袱至今共存。两者混用时的 "type": "module".mjs / .cjs 扩展名,是”历史时态冲突”留下的工程代价。

Tree-shaking 的本质:因为 ESM 的依赖图在编译时完全确定,打包工具可以静态分析哪些 export 从未被 import,直接删除——这是模块系统的静态性换来的性能红利。CJS 的动态 require() 让这个分析不可能。

Rust:显式模块树

Rust 的模块树不是从文件系统发现的,而是在代码中显式声明的:

// main.rs
mod models;           // 声明 models 模块存在
pub mod services;     // 声明 services 模块对外可见

这看起来是额外负担,实质是”显式优于隐式”的工程哲学:编译器不需要扫描文件系统,模块树在编译开始前完全确定,有利于增量编译。Python 和 JavaScript 文件即模块的设计更方便,但”文件存在但从未被导入”的死模块问题无法被编译器发现。

孤儿规则:所有权哲学在模块层的投影

Rust 的孤儿规则:为类型 T 实现 trait Tr 时,TTr 中至少有一个必须在当前 crate 中定义。

这防止了边界入侵——第三方 crate 不能声称对别人类型的行为权。如果允许任意 crate 为任意类型实现任意 trait,两个 crate 可能对同一类型提供冲突的实现,编译器无法决定用哪个。

孤儿规则是 06 内存管理 所有权概念在模块层面的投影:每个行为的定义权,必须有明确的归属。

五、多语对照

可见性哲学光谱,从约定到证明:

约定(信任)验证(工具)证明(编译器)
    │                    │                    │
Python `_`          TypeScript           Rust `pub`
Go 首字母大写         Java 四级修饰         体系
                    JavaScript `#`

Go 的可见性策略:首字母大写 = 公开,首字母小写 = 私有。是 Python 约定的变体,但规则更简单、更一致——不需要 _ 前缀约定,整个生态统一遵守。Java 的四级修饰(public / protected / package-private / private)是编译时验证,比 TypeScript 更细粒度但不如 Rust 的 pub(crate) 精确。

维度PythonTypeScript / JSRust
模块组织文件即模块,目录即包ESM + CJS 双模型显式 mod 声明
可见性机制_ 约定 + __all__private(编译时)/ #(运行时)pub 体系(编译器强制)
循环依赖允许(不推荐)允许(警告)编译错误
导入时机运行时动态编译时静态(ESM)编译时静态
孤儿规则
Tree-shaking不适用支持(ESM)支持
单独编译不支持支持(.d.ts支持
大型项目适用性中(约定弱)良好(类型边界)优秀(编译器边界)

三语的模块系统差异,本质上是对”认知复杂度应该由谁管理”的不同答案:Python 相信程序员的自律,TypeScript 相信工具的验证,Rust 相信编译器的证明。和 03 包管理与工具链 一起,构成了边界管理的两个尺度——模块管理项目内的认知边界,包管理项目间的信任边界。


延伸阅读

理论

  • Parnas《On the Criteria To Be Used in Decomposing Systems into Modules》(1972) — 模块化设计奠基,至今仍是最好的模块设计指南
  • Miller《The Magical Number Seven, Plus or Minus Two》(1956) — 工作记忆容量的认知科学基础

实践


关联 meta 维度

  • 03 包管理与工具链 — 模块是项目内的认知边界,包是项目间的信任边界;两者是同一哲学在不同尺度的体现
  • 01 类型系统 — 类型定义值的契约,模块定义接口的契约;可见性的三种哲学(约定/验证/证明)与类型身份认定(鸭子/结构/标称)完全对应
  • 04 错误处理 — 错误边界往往也是模块边界:公开 API 应暴露类型化的错误,内部错误不应泄漏到模块外
  • 06 内存管理 — Rust 孤儿规则是所有权哲学在模块层的投影:每个行为定义权必须有明确归属
  • 07 编译与执行 — ESM vs CJS 是 AOT vs 解释执行的模块版本;显式模块树有利于增量编译