核心命题
模块的合并,应当基于"身份的一致性",而非"形态的相似性"。
- 同一个 IP → 同一个模块
- 同一个业务规则 → 同一个函数
- 同一个数据实体 → 同一个 schema
- 两个东西只是碰巧长得像 → 分开,保持独立
违反这个原则的代价:已量产、已稳定的模块,因为另一个模块的需求变更而被牵连,被迫进行不必要的回归测试和风险评估。
一、问题场景
两个东西——比如两套代码、两个业务模块、两个微服务——它们在结构上高度相似,甚至在很多地方完全一样。直觉会告诉你"应该合并成一个抽象层"。
但实际上它们不是同一个东西。它们有不同的变更原因、不同的维护者、不同的生命周期。把它们强制合并在一起,短期减少了代码行数,长期增加了耦合风险。
这个问题在以下场景反复出现:
| 领域 | 例子 |
|---|---|
| 嵌入式 BSP | F4 系列 USART 驱动 vs H7 系列 USART 驱动——寄存器相似但不是同一个 IP |
| 业务系统 | 两个产品的审批流程"看起来一样",但合规要求和变更节奏不同 |
| 微服务 | 两个不同团队维护的用户模块,API 签名相似但内部逻辑独立演进 |
| 数据层 | 两个表结构相似,但一个服务订单、一个服务退货单,未来字段会分化 |
二、理论来源
这个问题被软件工程界多次独立发现并讨论,形成了一套跨领域共识。
2.1 Parnas(1972):按变更原因划分模块
David Parnas 在《On the Criteria To Be Used in Decomposing Systems into Modules》中提出:
模块不应该按执行流程来划分(流程图怎么走,模块就怎么分),而应该按可能独立变更的设计决策来划分。
两个 USART 驱动的"流程图"一样——都是初始化、发数据、中断处理——按流程图分就会合并。但它们的设计决策不同——两个芯片的 IP 是两个独立决策,未来会各自演进、各自出勘误表、各自被替代。
Parnas 的判断标准:如果模块 A 因为决策 X 变更,模块 B 不受影响,那么 X 就应该被封装在 A 内部。 F4 的勘误表变更不应该影响 H7,所以 F4 的 USART 驱动和 H7 的 USART 驱动应该是两个独立模块。
2.2 单一职责原则(SRP):一个模块只有一个变更原因
Robert C. Martin 在 2003 年形式化了 SRP,其核心定义并不是"一个类只做一件事",而是:
一个模块应该只有一个变更原因。
Martin 进一步引入了"变更责任人"的概念——“一个模块应该只对一个角色(actor)负责”。F4 驱动对 F4 产品团队负责,H7 驱动对 H7 产品团队负责。如果它们在同一个模块里,那么任何一个团队的变更都会牵连另一个团队的代码,违背了 SRP。
判断方法:找出谁想让这个模块变更。如果有超过一个来源,它就有超过一个职责。
2.3 Conway 定律(1967):系统结构镜像组织边界
Melvin Conway:
设计系统的组织,其产生的设计等同于这些组织的沟通结构。
当 F4 和 H7 由不同团队维护时,共享的驱动代码会变成两个团队都必须沟通才能改动的东西。每次变更都需要跨团队协调、联合评审、共同回归测试。沟通成本被固化为代码依赖。
Conway 定律的正向推论同样重要:如果你希望系统模块化,组织边界就应该和代码边界对齐。 芯片系列独立 → 团队独立 → 代码独立。这是反推——按组织边界设计代码边界,而不是反过来。
2.4 Rule of Three(1999):三次才值得抽象
Martin Fowler 在《重构》中提出:
第一次,写一个特定的解决方法。 第二次,拷贝上一次的代码。 第三次,才着手抽象,写出通用的解决方法。
为什么不是第一次?因为你只有一个样本,不知道哪些是"本质的共性",哪些是"碰巧的相似"。为什么不是第二次?因为两个样本仍然不足以可靠判断——你不知道第三个变体会打破你刚抽出来的抽象。
F4 和 H7 只有两个样本。未来的 GD32E2 系列会有什么不同,没人知道。此时的抽象就是过早的。
2.5 Sandi Metz(2014):重复比错误抽象更便宜
Sandi Metz 在 2014 年 RailsConf 上明确提出了这个结论:
“Duplication is far cheaper than the wrong abstraction.”
她的论证:
- 错误的抽象一旦形成,后续所有改动都需要"理解全部使用者才能改动局部"——维护者被迫理解全局
- 修复错误抽象的唯一方法是重新引入重复——让两套代码分开,让差异告诉你什么是正确的抽象,而不是提前假设它们是一样的
- 代码量少(LOC)不等于可维护性好。简洁的代码可能比稍微重复的代码更难改
2.6 Bounded Context(DDD,2003):同一个词在不同上下文里含义不同
Eric Evans 在《领域驱动设计》中提出了 Bounded Context 概念:
同一个术语,在不同的上下文中可能代表完全不同的概念。强行统一为一个模型,会导致含义被稀释,最终谁也说不清这个模型代表什么。
“USART 驱动"在 F4 上下文和 H7 上下文中看起来是同一个术语,但背后是两个不同的硬件 IP。强行合并为一个"通用 USART 驱动”,就是强行统一了两个 Bounded Context 中的不同概念。
三、判断框架
当面对"两个东西要不要合并"的决策时:
3.1 正确的标准:身份一致性
问:它们是同一个东西吗?
├── 是同一个 IP 的同一个实现? → 合并
├── 是同一个业务规则的同一个定义? → 合并
├── 是同一个数据实体的同一个 schema? → 合并
└── 不是上面的情况,只是看起来像? → 分开
3.2 实用的判别法:勘误表测试
如果一个的勘误表(或 bug 列表、变更记录)不能覆盖另一个,它们就不是同一个东西。
- F4 的勘误表有一条"USART DMA 在特定条件下丢失最后一个字节"→ H7 的勘误表里没有 → 它们不是一个东西
- 业务模块 A 的合规变更需要增加字段 → 业务模块 B 不需要 → 它们不是一个东西
3.3 实用的判别法:变更原因测试
列出未来可能导致这个模块变更的原因。如果两个东西的变更原因列表有本质差异,它们应该分开。
- F4 驱动变更原因:F4 芯片勘误表、F4 固件库更新、F4 停产
- H7 驱动变更原因:H7 芯片勘误表、H7 固件库更新、H7 停产
- 差异显著 → 分开
四、代价分析
4.1 合并但应该是独立的代价
| 代价 | 说明 |
|---|---|
| 牵连风险 | A 的 bug 修复导致 B 回归 |
| 认知负担 | 维护者必须理解所有使用场景才能改动任何一个 |
| 组织耦合 | 不同团队被迫共同评审和测试对方的改动 |
| 抽象脆弱 | 未来第三个变体出现时,现有抽象大概率被打破 |
| 已量产风险 | 已发布产品的代码因为另一个产品的需求变更而被修改 |
4.2 分开但本可以合并的代价
| 代价 | 说明 |
|---|---|
| 代码量稍多 | 通用逻辑被复制了几十行 |
| bug 可能需要在多处修复 | 如果真的是同一个 bug 逻辑 |
对比结论:分开但可能多修一次 bug 的代价 < 合并造成的测试范围牵连和已量产风险的代价。
五、应用指南
5.1 什么时候复制优于抽象
- 样本不足三个:不要基于两个样本抽抽象,等第三个出现再说
- 变更原因不同:不同的人/角色驱动变更,不应共享
- 生命周期不同:一个已量产一个在开发,已量产的不应该因为新项目的抽象需求被改动
- 不确定:宁可先复制,等"真正的共性"自己浮现
5.2 什么时候抽象合理
- 同一个身份:同一个 IP、同一个业务规则、同一个实体
- 变更同步:A 的每一个变更,B 必然也需要——因为它们是同一个东西
- 纯工具:数据结构和算法,不绑定任何特定上下文
5.3 组织层面的操作
- 模块边界 = 团队边界。谁拥有变更权,谁就拥有那个模块
- 跨团队的共享,需要明确归属——不能有两个团队都觉得自己有权改
- 如果组织边界还没形成(“一个人管全系列”),可以暂时宽松,但一旦裂变,代码也要跟着裂变
六、相关文献
| 来源 | 核心贡献 |
|---|---|
| Parnas, 1972 — On the Criteria To Be Used in Decomposing Systems into Modules | 按变更决策划分模块,不是按流程图 |
| Conway, 1967 — How Do Committees Invent? | 系统结构镜像组织沟通结构 |
| Fowler, 1999 — Refactoring | Rule of Three:第三次出现才抽象 |
| Martin, 2003 — Agile Software Development | SRP:一个模块只有一个变更原因 |
| Evans, 2003 — Domain-Driven Design | Bounded Context:同一术语在不同上下文中含义不同 |
| Metz, 2014 — RailsConf Keynote | 重复比错误抽象更便宜 |
| Hunt & Thomas, 1999 — The Pragmatic Programmer | DRY 的正确含义:消除知识重复,而非代码相似 |