软件设计

【模块化 #1】什么是模块化?拆分代码的真正原因(内聚性・耦合度总结)

项目越大,修改一个文件就越让人害怕,因为很难判断影响会扩散到哪里。

4 分钟阅读
【模块化 #1】什么是模块化?拆分代码的真正原因(内聚性・耦合度总结) 封面图

项目越大,修改一个文件就越让人害怕,因为很难判断影响会扩散到哪里。

构建变慢,代码评审范围变大。新成员也会苦恼该从哪里开始阅读。

模块化是解决这一问题最古老的方法。简单来说,就是把大块代码拆分成可以独立理解和替换的单元。

本文将梳理模块化究竟拆分什么、用内聚性和耦合度判断好模块的标准,以及何时应该拆分。

先来看核心总结。

  1. 模块化就是把“一起变化的东西”归在一起,建立边界
  2. 好模块的标准是高内聚、低耦合
  3. 拆分的首要目的是降低变更成本,而不是提升构建速度
  4. 在边界不清晰时拆分,反而只会增加复杂度

模块化究竟拆分什么?

拆分文件夹和拆分模块是两回事。

文件夹只是整理文件。任何文件夹中的代码都可以自由使用其他文件夹中的代码。

模块会形成具有强制力的边界。在模块外,只能使用该模块公开的接口。

模块化的本质不是整理文件,而是让编译器强制区分“外部需要知道的内容”和“不需要知道的内容”。

有了这个边界,就能实现两点。

  • 独立理解:无需了解内部,只看接口就能使用模块
  • 独立替换:接口不变时,即使整体替换内部实现,外部也不受影响

这就是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. 你会以什么标准拆分模块?

以变更原因为标准。同一原因下会一起变化的代码放在一个模块中,因不同原因变化的代码则分开。能回答“这个模块隐藏了什么设计决策”,才是正确的边界。


下一篇将讨论拆分模块后必然遇到的问题:模块之间的依赖方向和循环依赖。

比拆分更难的是拆分后的关系设计。带着对内聚性和耦合度的理解阅读,下一篇会容易得多。

延伸阅读