软件分析与设计笔记 24:设计原则:内聚、耦合与多态
好设计怎么衡量:内聚与耦合分别在说什么、一次从「差设计」到「多态重设计」的完整对照、抽象类与接口的选择依据,以及复用为什么会从优势变成风险。
本文是 《软件分析与设计笔记》 系列的第 24 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。22 篇产出了分析模型,这一篇讲怎么判断它好不好 —— 以及不好时怎么改。
0 同一句话,两种改法
需求方说:「我们要新增一类工单 —— 保修单,免费但需要保修凭证。」
这句话在不同设计下的代价完全不同:
- 设计 A:在所有判断工单类型的地方各加一个
if; - 设计 B:改三个文件,其中两个是别人负责的;改完还要回归测试五个功能。
这两者之间的差别,就是这一篇要讲的设计原则(design principles)。
1 衡量设计的两个词
1.1 内聚(cohesion)
内聚(cohesion) 说的是一个模块内部各元素的相关程度。
- 内聚高:这个类只做一件事,它的所有方法都在服务同一份数据;
- 内聚低:一个类里塞了几件不相关的事,改 A 的时候要担心弄坏 B。
本例里有个现成的低内聚例子:如果 WorkOrder 类里既有「工单状态流转」的方法,又有「计算配件金额」的方法,还有「生成客户通知文本」的方法 —— 那它就是低内聚的。因为通知文本的格式和工单状态没有任何关系,前者会因为「客户想改措辞」而变,后者会因为「业务加了状态」而变。
1.2 耦合(coupling)
耦合(coupling) 说的是模块之间的依赖程度。
- 松耦合(loosely coupled system):一方改动,另一方不用跟着改;
- 紧耦合:改一处要顺着调用链改一路。
本例里一个具体的紧耦合:如果客服端的派单页面直接读 stock_item.on_hand_qty(跳过控制器),那么一旦库存表的字段改名,页面也要改。更糟的是,页面会顺手在那里写「库存不足就标红」这种判断 —— 于是业务规则又多了一份。
1.3 好设计的标准
好设计(good design)的标准由此推出来:高内聚、低耦合。这个说法很常见,但它的实际含义是具体的 —— 高内聚让「改动集中」,低耦合让「改动不外溢」。
顺带一提,可维护性(ease of maintenance)和可扩展性(extensibility)不是另外两个维度,它们是好内聚耦合的结果。
2 一次完整的重设计
2.1 一个具体的坏设计
看一个具体的坏设计。假设工单类型的差异(收费 / 免费)全写在工单类里:
class WorkOrder {
private String type; // "REPAIR" / "WARRANTY"
private double fee; // 只有维修单用得上
private String warrantyNo; // 只有保修单用得上
double amount() {
if (type.equals("WARRANTY")) return 0.0;
return fee;
}
void close() {
if (type.equals("WARRANTY") && warrantyNo == null) {
throw new IllegalStateException("保修单必须有凭证");
}
// ... 关单逻辑
}
}
它有三个问题:
- 内聚低。
fee只对维修单有意义、warrantyNo只对保修单有意义,却塞在同一个类里; - 耦合高。 每加一种工单类型,都要回来改这个类。改这个类的人可能同时在改别的地方;
- 判断分散。
amount()里有一个if,close()里又有一个 —— 而它们是同一个知识(「保修单是免费的、且必须有凭证」)的两处表达。
第 3 条最隐蔽:同一个业务事实被写在了两个方法里。哪天真要改(比如保修单也要收上门费),你得记得改两处。而编译器不会提醒你。
2.2 用多态重设计
用多态重设计之后:
abstract class WorkOrder {
abstract double amount();
void close() { /* 共通关单逻辑 */ }
}
class RepairOrder extends WorkOrder {
private double fee;
double amount() { return fee; }
}
class WarrantyOrder extends WorkOrder {
private String warrantyNo;
double amount() { return 0.0; }
@Override
void close() {
if (warrantyNo == null) throw new IllegalStateException("保修单必须有凭证");
super.close();
}
}
变化发生了:
- 类型专有的数据(
fee、warrantyNo)移到了真正用它的子类里 → 内聚提高; - 调用方只写
order.amount(),不需要知道有哪几种工单 → 耦合降低; - 新增一种工单 = 新增一个类,已有的代码一行都不用改。
这个「通过继承与抽象方法让对象自己回答」的做法,就叫面向多态的设计(design for polymorphism)。
2.3 但这次重设计有个前提
这次重设计成立的前提是:「工单类型」这个维度真的会继续扩张。如果业务上就是维修单和保修单两种,永远不变,那原来那个 if 版本其实没什么问题 —— 它更短、更容易读。
这一点值得强调,因为它是设计原则里最容易被滥用的一条:多态不是「更好」,它只是「更容易扩展」。如果不需要扩展,那它为扩展付出的代价(更多类、更绕的读图路径)就是纯亏损。
判断依据:这个维度过去半年变过几次?变过两次以上,值得为它设计;一次没变过,先别动。
3 抽象类与接口怎么选
3.1 四种情形对应用什么
重设计之后马上会遇到下一个问题:父类该是抽象类(abstract class / abstract superclass),还是接口(interface)?
| 情形 | 用什么 | 本例 |
|---|---|---|
| 多个类要共享同一部分实现(不只是接口) | 抽象类 | WorkOrder.close() 有共通的关单逻辑 → 抽象类 |
| 只约定行为,不共享任何实现 | 接口 | 假设要做「可导出 PDF」能力,各单据实现不同 → 接口 |
| 这些类分属不同领域,只是恰好都需要这个能力 | 接口 | 「可导出」与「工单」是不同领域 → 接口 |
| 一个类需要同时具备多种能力 | 多个接口 | 工单既要能导出、又要能被审批 → 两个接口 |
3.2 几条补充判断
几条补充的判断:
- 抽象父类和具体父类(concrete superclass) 的差别在于能不能被直接实例化。如果父类本身没有实际意义,就该是抽象类;
- 反过来,如果父类确实有实例(「普通维修单」就是一种真实的工单),那用具体类(concrete class) 作父类也没问题 —— 不要为了「看起来更抽象」而强行抽象;
- 单继承语言里,「同时具备多种能力」只能靠多接口实现,这也是接口在这种语言里格外重要的原因。
本例里第二条其实值得认真考虑:RepairOrder 是真实存在的类型,所以 WorkOrder 完全可以做成具体类(提供默认的 amount() 返回 0),让 RepairOrder 覆盖它。这样就没有抽象类了。
「要不要抽象」的判据是:有没有一个「什么都不是」的中间状态。如果 WorkOrder 单独拿出来没有意义(一张既不是维修也不是保修的工单不存在),就抽象;如果有意义(比如「普通工单」),就用具体类。
4 复用:机会与陷阱
4.1 值得先找一遍的复用机会
设计原则里还有一条是关于可复用性(reusability) 的:主动寻找可以复用的组件,而不是每次重写。
值得先找一遍的复用机会(reuse opportunity) 通常是横切性的东西:
- 数据访问(笔记 23 的数据代理);
- 日志与审计;
- 参数校验与权限检查;
- 统一的错误处理。
本例里最值得复用的一处,是 15 篇那条降级策略:「供应商接口超时 3 秒即降级为待确认」。这条逻辑如果不复用,会在「申请配件」「查询库存」「补货建议」三个地方各写一遍,然后三处超时阈值迟早不一致。
4.2 复用并不总是好的
但复用有个反直觉的地方:它并不总是好的。如果两段代码只是看起来相似,而变化的时机不同,把它们强行合并成一份,反而会制造耦合 —— 将来其中一方要改,另一方会被迫跟着改,甚至要加参数来区分「你现在是哪种情况」。
本例里有个现成的反例:「校验工单状态」在「派单」和「申请配件」里看起来一样(都是「必须是进行中」),但其实不同:
| 派单 | 申请配件 | |
|---|---|---|
| 允许的状态 | CREATED(还没派) | IN_PROGRESS(已在做) |
| 变化原因 | 派单流程变了才改 | 配件流程变了才改 |
它们看起来一样,其实是两个不同的规则。如果强行合并成一个 validateStatus(),第一次遇到「派单前允许改派」的需求时,这个方法就要加参数 —— 然后就再也不是「共用逻辑」了。
判断标准是那句老话:它们会一起变化吗?会一起变的,该合并;只是碰巧长得像的,别合并。
5 三条能立刻用的自查
- 加一个新类型,要改几个文件? 改得越少越好。理想情况是只加文件、不改文件;
- 改一个字段名或一条规则,要改几个地方? 超过两处,说明这个信息被复制了;
- 这段代码,新同事多久能看懂它为什么这么写? 如果需要翻三个历史提交才能理解,说明结构本身在传达错误的信息。
三条都能在本例上验一次:
| 自查 | 现状 | 判断 |
|---|---|---|
| 加「保修单」要改几个文件? | 重设计前:WorkOrder + 两处判断 + 可能的界面 | ❌ 至少三处 |
| 重设计后:新增一个类 | ✅ 只加不改 | |
| 改「保修单必须有凭证」要改几处? | 重设计前:close() 里一处(但 amount() 里还有隐含的) | ⚠️ 容易漏 |
重设计后:WarrantyOrder.close() 一处 | ✅ 集中 | |
| 新同事要多久看懂? | 重设计后:看类名就知道有哪两种工单 | ✅ |
本节自测
- 高内聚、低耦合,各自解决的是「改动集中」还是「改动不外溢」?
- 在那一节的坏设计里,
fee为什么该移到子类里? - 什么情况下该用抽象类而不是接口?什么情况下该用接口?
- 为什么「看起来相似」不足以成为合并代码的理由?举出本例中的一个反例。
- 「加一个新类型要改几个文件」为什么能作为设计好坏的判断标准?
- 多态为什么不是「永远更好」?判断该不该为某个维度做多态的依据是什么?
参考答案
- 高内聚 → 改动集中;低耦合 → 改动不外溢(§1.3)。
- 因为
fee只对维修单有意义。留在父类里意味着内聚低(所有工单都带着一个自己用不上的字段),而且父类会因为「维修单要改费率」而变(耦合高)(§2.1、§2.2)。 - 共享同一部分实现(且有共通逻辑)→ 抽象类;只约定行为、或跨领域、或需要多种能力 → 接口。本例
WorkOrder.close()有共通关单逻辑,所以抽象类;假设要做「可导出 PDF」,各单据实现不同且是横切能力,所以接口(§3.1)。 - 因为变化的时机不同,合并反而制造耦合。本例反例:「校验工单状态」在派单里要求
CREATED、在申请配件里要求IN_PROGRESS—— 看起来是同一个「校验」,其实是两个规则,合并后一遇新需求就要加参数(§4.2)。 - 因为它直接度量了「改动的影响范围」:改得越少,说明职责切得越清、信息没有被复制。理想是只加文件、不改文件(§5)。
- 因为多态换来的是「易扩展」,代价是更多类、更绕的读图路径。判断依据:这个维度过去半年变过几次?变过两次以上值得设计,一次没变过就别动(§2.3)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| design principles | 设计原则 | 判断设计好坏的一组通用标准 |
| cohesion | 内聚 | 一个模块内部元素的相关程度 |
| coupling | 耦合 | 模块之间互相依赖的程度 |
| loosely coupled system | 松耦合系统 | 模块之间改动互不影响较多的系统 |
| reusability | 可复用性 | 代码或组件被多处使用的能力 |
| reuse opportunity | 复用机会 | 值得被抽出来共用的组件 |
| good design | 好设计 | 高内聚、低耦合、易于改动与理解的设计 |
| ease of maintenance | 易维护 | 系统被理解和修改的难易程度 |
| extensibility | 可扩展性 | 增加新功能时不必大改既有结构的能力 |
| polymorphism | 多态 | 同一调用在不同对象上产生不同行为 |
| design for polymorphism | 面向多态的设计 | 用抽象与多态代替类型判断来组织代码 |
| concrete class | 具体类 | 可以直接实例化的类 |
| concrete superclass | 具体父类 | 本身有实例、又被继承的父类 |
| abstract class | 抽象类 | 不能被直接实例化、用于被继承的类 |
| abstract superclass | 抽象父类 | 作为继承起点的抽象类 |
| interface | 接口 | 只约定行为、不含实现的契约 |