软件分析与设计笔记 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 一个具体的坏设计

看一个具体的坏设计。假设工单类型的差异(收费 / 免费)全写在工单类里:

java
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("保修单必须有凭证");
        }
        // ... 关单逻辑
    }
}

它有三个问题:

  1. 内聚低。 fee 只对维修单有意义、warrantyNo 只对保修单有意义,却塞在同一个类里;
  2. 耦合高。 每加一种工单类型,都要回来改这个类。改这个类的人可能同时在改别的地方;
  3. 判断分散。 amount() 里有一个 if,close() 里又有一个 —— 而它们是同一个知识(「保修单是免费的、且必须有凭证」)的两处表达。

第 3 条最隐蔽:同一个业务事实被写在了两个方法里。哪天真要改(比如保修单也要收上门费),你得记得改两处。而编译器不会提醒你。

2.2 用多态重设计

用多态重设计之后:

java
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() 一处✅ 集中
新同事要多久看懂?重设计后:看类名就知道有哪两种工单✅

本节自测

  1. 高内聚、低耦合,各自解决的是「改动集中」还是「改动不外溢」?
  2. 在那一节的坏设计里,fee 为什么该移到子类里?
  3. 什么情况下该用抽象类而不是接口?什么情况下该用接口?
  4. 为什么「看起来相似」不足以成为合并代码的理由?举出本例中的一个反例。
  5. 「加一个新类型要改几个文件」为什么能作为设计好坏的判断标准?
  6. 多态为什么不是「永远更好」?判断该不该为某个维度做多态的依据是什么?

参考答案

  1. 高内聚 → 改动集中;低耦合 → 改动不外溢(§1.3)。
  2. 因为 fee 只对维修单有意义。留在父类里意味着内聚低(所有工单都带着一个自己用不上的字段),而且父类会因为「维修单要改费率」而变(耦合高)(§2.1、§2.2)。
  3. 共享同一部分实现(且有共通逻辑)→ 抽象类;只约定行为、或跨领域、或需要多种能力 → 接口。本例 WorkOrder.close() 有共通关单逻辑,所以抽象类;假设要做「可导出 PDF」,各单据实现不同且是横切能力,所以接口(§3.1)。
  4. 因为变化的时机不同,合并反而制造耦合。本例反例:「校验工单状态」在派单里要求 CREATED、在申请配件里要求 IN_PROGRESS —— 看起来是同一个「校验」,其实是两个规则,合并后一遇新需求就要加参数(§4.2)。
  5. 因为它直接度量了「改动的影响范围」:改得越少,说明职责切得越清、信息没有被复制。理想是只加文件、不改文件(§5)。
  6. 因为多态换来的是「易扩展」,代价是更多类、更绕的读图路径。判断依据:这个维度过去半年变过几次?变过两次以上值得设计,一次没变过就别动(§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接口只约定行为、不含实现的契约

© 2026 HuanYu Blog

记录技术,也记录思考