项目越大,修改一个文件就越让人害怕,因为很难判断影响会扩散到哪里。
构建变慢,代码评审范围变大。新成员也会苦恼该从哪里开始阅读。
模块化是解决这一问题最古老的方法。简单来说,就是把大块代码拆分成可以独立理解和替换的单元。
本文将梳理模块化究竟拆分什么、用内聚性和耦合度判断好模块的标准,以及何时应该拆分。
先来看核心总结。
- 模块化就是把“一起变化的东西”归在一起,建立边界
- 好模块的标准是高内聚、低耦合
- 拆分的首要目的是降低变更成本,而不是提升构建速度
- 在边界不清晰时拆分,反而只会增加复杂度
模块化究竟拆分什么?
拆分文件夹和拆分模块是两回事。
文件夹只是整理文件。任何文件夹中的代码都可以自由使用其他文件夹中的代码。
模块会形成具有强制力的边界。在模块外,只能使用该模块公开的接口。
模块化的本质不是整理文件,而是让编译器强制区分“外部需要知道的内容”和“不需要知道的内容”。
有了这个边界,就能实现两点。
- 独立理解:无需了解内部,只看接口就能使用模块
- 独立替换:接口不变时,即使整体替换内部实现,外部也不受影响
这就是David Parnas在1972年论文中提出的信息隐藏概念。拆分模块的标准不是功能,而是“想隐藏的设计决策”。
好模块的标准:内聚性与耦合度
如果拆分模块后反而更不方便,通常是这两个指标失衡了。
内聚性表示模块内部代码的关联程度,越高越好。
耦合度表示模块之间的依赖程度,越低越好。
有一个简单的判断方法。
| 问题 | 信号 |
|---|---|
| 修改一个功能时,是否要同时修改多个模块? | 耦合度高 |
| 模块中是否混入了互不相关的代码? | 内聚性低 |
| 删除一个模块后,是否只会消失对应功能? | 拆分合理 |
核心标准是变更。一起变化的代码放在同一模块,因不同原因变化的代码放在不同模块。
这个原则很熟悉吧?SOLID的单一职责原则(SRP)把“变更原因”扩展到了模块层级。
模块化有什么好处?
变更成本会首先降低。
有了边界,修改的影响范围就能限制在模块内。只要模块的公开接口没有变化,评审者就能判断外部是安全的。
团队分工也会更容易。按模块分配负责人,可以减少工作区域重叠造成的冲突。
测试也会更轻量。可以单独测试一个模块,无需启动整个应用即可验证。
构建速度也会提升,因为只需重新编译发生变化的模块。不过这只是结果,不是目的。边界设计糟糕时,仍然可能每次都重新构建全部内容。
在Swift中,边界通过访问控制体现。
// 模块外只公开协议
public protocol PriceFormatter {
func format(_ amount: Int) -> String
}
// 将实现隐藏在模块内部
final class KoreanPriceFormatter: PriceFormatter {
func format(_ amount: Int) -> String { "\(amount)圆" }
}
外部只需要知道PriceFormatter。理想情况下,即使更换内部实现,也无需重新编译模块外的代码。
什么时候拆分,什么时候应该忍住?
模块化并非没有成本。建立边界会带来接口设计、版本管理和项目配置成本。
| 情况 | 判断 |
|---|---|
| 修改一个功能时,多个团队或领域的代码都会一起变化 | 该拆分了 |
| 构建缓慢,经常打断开发流程 | 可以拆分,但先设计变更边界 |
| 领域边界仍经常变化的早期产品 | 先忍住,等边界稳定后再拆 |
| 一个人开发的小型应用 | 文件夹整理和访问控制就够了 |
边界错误的模块化比不模块化更糟。如果两个模块总是一起变化,说明边界错了,应当合并。
面试中会这样问
Q. 请解释内聚性和耦合度,并说明两者的关系。
内聚性是模块内部元素的关联性,耦合度是模块之间的依赖程度。良好设计追求高内聚和低耦合。将相关代码集中起来后,与模块外部的交互自然减少,因此二者是互补关系。
Q. 你会以什么标准拆分模块?
以变更原因为标准。同一原因下会一起变化的代码放在一个模块中,因不同原因变化的代码则分开。能回答“这个模块隐藏了什么设计决策”,才是正确的边界。
下一篇将讨论拆分模块后必然遇到的问题:模块之间的依赖方向和循环依赖。
比拆分更难的是拆分后的关系设计。带着对内聚性和耦合度的理解阅读,下一篇会容易得多。

