Skip to content

SOLID 原则 ​

S4-002建议学进阶约 60 分钟JDK 21企业开发进阶 · 软件工程与设计模式

1. 本节定位 ​

《软件工程与开发流程》讲了软件工程要"驯服复杂度";本节给出最实用的五条设计规则:

text
SOLID 不是审美偏好,它直接决定「改需求时要动几处代码」  ✓

★ 本节每个原则都配一个"违反版 / 遵守版",并把差别量化成"改动点数量" ✓

2. 学习目标 ​

  • 能说出五个原则各自回答的问题(一句话版本)★★★;
  • 能判断一段代码违反了哪个原则,并知道怎么改 ★★★;
  • 知道 LSP 的经典反例(正方形不是长方形)及它的实质 ★★★;
  • 知道 DIP 的第一收益是"可测试" ★★★;
  • 知道 SOLID 的代价与边界(不要过度设计)★★★。

3. 前置知识检查 ​

  • 会写类与接口吗?(《抽象类与接口》)✓
  • 知道单元测试为什么需要"测试替身"吗?(T1-003)✓

4. 为什么需要这个知识 ​

先看一个没有 SOLID 的真实代价 ✗:

text
某项目的支付逻辑写在一个 if-else 里(支持 3 种渠道)。
半年来加了 8 种渠道 → 这个方法变成 300 行、嵌套 5 层 ✗
某次为了加"分账",改动了公共分支 → 影响了另外 7 种渠道 → 线上故障 ✗✗

★ 本节的实测会把"改动成本"量化 ✓:

text
    加一种支付方式:
        违反 OCP 版 → 改 1 个已有方法 ✗(且要重跑所有老功能的测试)
        遵守 OCP 版 → 加 1 个新类 ✓✓(老代码零改动)

★ 关键差别在"是否动到老代码" ✓✓:

改老代码 = 引入回归风险 = 所有老测试都要重跑 ✗

加新代码 = 隔离风险 = 老测试不用动 ✓✓

5. 核心概念 ​

5.1 SRP 单一职责:一个类只该有一个变化的理由 ★★★ ​

✗ 违反版:OrderService 同时干四件事 ✓:

它负责谁会让它变化
计算价格定价规则变化
存数据库表结构变化
发短信短信通道更换
生成 PDF报表格式调整

★ 后果:四个不同团队的需求都会改同一个文件 ✗✗ —— 合并冲突、测试互相影响、一处改动波及全部 ✓

✓ 遵守版:拆成 PricingCalculator / OrderRepository / SmsNotifier / PdfGenerator ✓

★ 判断方法(一句话) ✓✓:

"描述这个类时,你需要用「并且」吗?" ✓

"它负责下单并且发短信" → 该拆 ✓✓

★ 但要避免过度拆 ✗:

一个 30 行、内聚性很强的类就让它待着 ✓

拆成 5 个类、每个 6 行,只会让调用链变长 ✗

5.2 OCP 开闭原则:加新类型不改老代码 ★★★ ​

✗ 违反版(实测代码里的 BadPaymentService):

java
String pay(String type, BigDecimal amount) {
    if ("alipay".equals(type)) { ... }
    else if ("wechat".equals(type)) { ... }
    else if ("bank".equals(type)) { ... }
    throw new IllegalArgumentException("不支持的支付方式:" + type);
}

✓ 遵守版:注册式工厂 + 实现类 ✓

java
PaymentService service = new PaymentService();
service.register(new AlipayChannel());
service.register(new WechatChannel());
service.register(new UnionPayChannel());     // ★ 加新渠道:不改任何已有代码

★ 实测的对比 ✓:

要做的改动违反 OCP遵守 OCP
加一种支付方式改 1 个已有方法 ✗加 1 个新类 ✓
老功能的回归测试要重跑(动过代码) ✗不用跑 ✓✓
多人同时加渠道改同一个文件(冲突) ✗各加各的(无冲突) ✓✓

★★ 第三行是"团队协作层面的收益",常被忽略但极其真实 ✓✓:

两个人同时加渠道,一个改文件 A、一个加文件 B —— 零冲突 ✓

而在 if-else 版里,两人改同一个方法 → 必然冲突 ✗

★ OCP 的正确理解 ✓✓:

它不是"永远不许改代码" ✗

而是"把可预期的变化设计成扩展点" ✓✓

判断"可预期"的方法:这个 if-else 半年内加过几次分支?加过就说明它在变 ✓

5.3 LSP 里氏替换:子类不能破坏父类契约 ★★★ ​

经典反例(本节实测) ✓:

java
class Rectangle {
    void setWidth(int w)  { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    @Override void setWidth(int w)  { this.width = w; this.height = w; }   // ✗ 悄悄改了 height
    @Override void setHeight(int h) { this.width = h; this.height = h; }
}

★ 实测结果 ✓✓:

text
用 Rectangle 的契约(设置宽高后算面积):
      Rectangle(5,4).area() = 20 ✓(符合预期)

      Square 当 Rectangle 用 → area() = 16
      ✗✗ 期望 20,实际 16 —— 契约被破坏了

★ 问题出在哪 ✓✓:

Rectangle 的契约是"宽高互不影响" ✓

Square 加强了前置条件(改一个必须改另一个) ✗✗

所以它不能替换 Rectangle ✓

★ 正确建模 ✓:不要让 Square 继承 Rectangle,而是各自实现 Shape 接口 ✓

★ 工程上更隐蔽的 LSP 违反 ✓:

违反形式例子
子类抛父类没声明的异常 ✗父类说"不抛",子类抛了 IllegalStateException
把查询做成了副作用 ✗父类 getX() 是纯查询,子类里顺手改状态
返回 null 而父类承诺非 null ✗调用方到处 NPE
子类收窄了参数范围 ✗父类接受任意正整数,子类只接受偶数

★ 自查方法 ✓✓:"任何用父类的地方,换成子类后行为还对吗?" ✓

5.4 ISP 接口隔离:不强迫实现方依赖用不到的方法 ★★ ​

✗ 违反版:

java
interface Device {
    void print(); void scan(); void fax(); void staple(); void copy();
}

★ 后果 ✓:

后果说明
简单打印机被迫实现 4 个用不到的方法 ✗只能抛 UnsupportedOperationException
改接口会波及所有实现类 ✗哪怕它不用改的那个方法

✓ 遵守版:按能力拆小接口 ✓(Printer / Scanner / Fax)✓

★ 判断信号 ✓✓:

"实现类里有方法只能抛 UnsupportedOperationException" → 接口太胖 ✗

★ 但别走另一个极端 ✗:

"每个方法一个接口"也是灾难 ——调用方要注入五个接口 ✗

平衡点:按"谁会一起变化"分组 ✓✓

5.5 DIP 依赖倒置:依赖抽象而不是实现 ★★★ ​

✗ 违反版:

java
class OrderService {
    private MySQLOrderRepository repo = new MySQLOrderRepository();   // ✗ 写死具体实现
}

✓ 遵守版:

java
class OrderService {
    private final OrderRepository repo;                    // ← 接口
    OrderService(OrderRepository repo) { this.repo = repo; }   // ← 注入
}

★ 三层收益(按重要性排序) ✓✓:

#收益说明
①可测试 ★★★单元测试注入内存实现,不需要数据库 ✓✓
②可替换 ★★换数据库/换第三方只改装配处 ✓
③可并行开发 ★★接口定好后,上下游各自实现 ✓

★ 第一层收益最实在 ✓✓:

本项目 T1 系列的全部单元测试都靠它 —— 没有 DIP,测试就只能连真实数据库:慢、脆弱、跑不起来 ✗✗

而有了 DIP,测试可以在毫秒级跑完,还能随便造边界数据 ✓✓

★ DIP 的边界 ✓:

不是"每个类都要有接口" ✗

只有"会有多个实现"或"需要被替身替换"的依赖才需要接口 ✓✓

给一个永远不会变的工具类抽接口,只是噪音 ✗

5.6 一句话总结五个原则 ★★★ ​

缩写全称它回答的问题
SRP ★★★单一职责这个类需要同时被几个原因改动?
OCP ★★★开闭原则加新类型要改几处老代码?
LSP ★★★里氏替换换成子类后行为还对吗?
ISP ★★接口隔离实现类有被迫抛异常的方法吗?
DIP ★★★依赖倒置业务类认识具体实现吗?

★ 最后一条重要的话 ✓✓:

SOLID 是"降低改动成本"的手段,不是目的 ⚠️

它的收益是长期的(三个月后改需求时体现); 它的代价是当下的(更多类、更多接口、更多注入)✗

所以正确的态度是:在"确实会变"的地方应用它,其余地方保持简单 ✓✓

6. 递进示例 ​

6.1 最小示例:把 if-else 变成多态 ​

java
// ✗ 每加一种类型都改这里
if (type == A) { ... } else if (type == B) { ... }

// ✓ 每种类型一个实现,新增不改老代码
interface Handler { void handle(); }
Map<String, Handler> handlers = ...;    // 注册进来
handlers.get(type).handle();

6.2 业务示例:DIP 让单元测试跑起来 ​

java
// 生产:注入 MySQL 实现
new OrderService(new MySQLOrderRepository(dataSource));

// 测试:注入内存实现(毫秒级、不连库、能造任意边界数据)
new OrderService(new InMemoryOrderRepository());

6.3 反例:为了"符合 SOLID"而过度设计 ​

java
// ✗ 一个只有一行逻辑的格式化工具,也抽出接口 + 实现 + 工厂
interface FormatterFactory { Formatter create(); }

★ 判据:"这里未来真的会有第二种实现吗?" ——答案不确定就先不抽 ✓✓

7. 完整可运行代码与实测 ​

代码:labs/design-patterns/src/main/java/cn/kb/learn/dp/SolidLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl design-patterns compile
java "-Dstdout.encoding=UTF-8" -cp design-patterns/target/classes cn.kb.learn.dp.SolidLesson

实测输出(LSP 与 OCP 的关键部分):

text
  用 Rectangle 的契约(设置宽高后算面积):
      Rectangle(5,4).area() = 20 ✓(符合预期:5*4=20)
      Square 当 Rectangle 用 → area() = 16
      ✗✗(期望 20,实际 16 —— 契约被破坏了)

  加一种支付方式:
      违反 OCP → 改 1 个已有方法 ✗
      遵守 OCP → 加 1 个新类 ✓

★ 两条可落地的结论 ✓:

#结论依据
①"子类能替换父类"是继承的前提,不是自动成立的正方形替换长方形后行为变了 ✗
②把"会变的 if-else"换成"注册 + 多态"老代码零改动,且多人并行无冲突 ✓

8. Debug 观察点 ​

  1. 找出项目里最长的 if-else:数一数半年内加过几个分支 → 那就是该重构的地方 ★;
  2. 统计一个类的 import 数量:超过 20 个可能违反 SRP ⚠️;
  3. 找一个"改一次要重跑所有测试"的模块:它缺少扩展点 ✗;
  4. 看有没有实现类抛 UnsupportedOperationException:接口太胖 ✓;
  5. 看单元测试是否必须连数据库:说明 DIP 没做好 ✗。

9. 常见错误 ​

#错误说明
1把 SOLID 当教条,到处抽接口 ✗✗过度设计的代价同样真实 ✓
2为了"复用"让子类继承父类,却破坏了契约 ✗✗LSP(5.3)✗
3以为 OCP 是"永远不许改代码" ✗是"把可预期的变化做成扩展点" ✓
4一个接口塞进所有方法 ✗ISP(5.4)✗
5业务类里 new 具体实现 ✗✗DIP,且导致无法单元测试 ✓
6拆得过细导致调用链冗长 ✗SRP 过度 ✗
7只看"当前需求",不给任何扩展点 ✗半年后改动成本爆炸 ✓

10. 动手实验 ​

目标:亲手把一段"违反 OCP"的代码改成"遵守 OCP"。

步骤:

  1. 跑 SolidLesson,确认 LSP 的 20 vs 16、OCP 的改动点对比 ✓;
  2. 找一个项目里的 if-else 类型分发,改成注册表 + 接口 ★★;
  3. 写出"加新类型"的改动清单(改了哪几个文件、多少行)✓;
  4. 把某个硬编码依赖换成注入,并补一个不连数据库的单元测试 ★★★;
  5. 尝试故意违反 LSP(子类改变父类语义),观察调用方出现什么诡异行为 ✓。

验收方式:能说出"你改的那个 if-else,加新类型从改几处变成了改几处"。

扩展任务:设计一个"可扩展的优惠券系统":

  • 券类型可扩展(满减、折扣、赠品)✓;
  • 叠加规则可配置 ✓;
  • 用 OCP 保证"加新券种不改老代码" ✓;
  • 说明:哪些地方故意不抽接口(避免过度设计)★★

11. 练习题 ​

  1. 判断:SOLID 原则应该无条件、全量应用。(对 / 错)
  2. 填空:SRP 回答"这个类要被几个原因改动";OCP 回答"";DIP 回答""。
  3. 简答:为什么"正方形不应该继承长方形"?
  4. (代码阅读)
java
class UserService {
    public void register(String name, String email) {
        // 1. 校验邮箱格式
        // 2. 插入数据库
        // 3. 发送欢迎邮件
        // 4. 写审计日志
    }
}

这个类违反了哪个原则?怎么改? 5. (设计)把上题的 UserService 重构:给出拆分后的类与它们之间的依赖方向,并说明"为什么依赖方向是这样"。

参考答案(要点)
  1. 错。只在"确实会变"的地方应用 ✓ ——过度设计的代价同样真实 ✓★
  2. 加新类型要改几处老代码;业务类是否依赖具体实现 ✓
  3. 因为 Square 加强了前置条件(宽高必须相等),破坏了 Rectangle 的契约 ✗ —— 换成子类后行为变了,就不能替换 ✓★
  4. 违反 SRP(一个类四个变化理由)✗ —— 改法:拆出 EmailValidator、UserRepository、WelcomeMailSender、AuditLogger ✓
  5. 要点:UserService 依赖四个接口,由外部注入实现 ✓ —— 依赖方向从"具体实现"倒转为"抽象",这样 UserService 可以独立测试 ✓✓

12. 掌握标准 ​

  • [ ] 能说出五个原则各自回答的问题 ★★★;
  • [ ] 能判断一段代码违反哪个原则,并给出改法 ★★★;
  • [ ] 能解释"正方形不是长方形"的实质(加强前置条件) ★★★;
  • [ ] 知道 DIP 的第一收益是"可测试" ★★★;
  • [ ] 知道 ISP 的判断信号(被迫抛异常) ★★;
  • [ ] 知道 SOLID 的代价,不做过度设计 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《设计模式:创建型》:对象"怎么造"的三种套路,以及单例的六种写法与两个攻击面。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引