SOLID 原则笔记(以 DIP 为主线)

SOLID 原则笔记(以 DIP 为主线)
Lay依赖反转(Dependency Inversion Principle,DIP)
核心思想
高层模块不应该依赖底层模块,二者应该依赖抽象。
高层模块需要什么,抽象就定义成什么,底层模块必须无条件向高层模块低头。抽象通常定义在消费者层(高层)。
相关原则
单一职责原则(Single Responsibility Principle,SRP)
- 一个软件模块应该有且仅有一个被修改的理由。
- 一个软件模块应该且对一个角色或利益相关者负责。
开闭原则(Open/Closed Principle,OCP)
设计良好的软件系统应能方便地扩展新功能,这种扩展理想情况下应无需修改现有的旧代码。为了实现 OCP,设计者会利用 SRP 和 DIP:
- SRP 将代码按变更原因隔离,使得新功能可以独立成新组件;
- DIP 通过接口反转依赖,让高层策略不依赖低层细节。
二者结合,使得系统在添加新功能时,只需新增一个实现接口的低层组件,无需触碰封装了核心业务逻辑的高层旧代码,从而实现了 OCP。
举例说明
高层模块(业务核心包)
1 | // ===== 包/模块: 核心业务 ===== |
说明:
报表导出器和报表服务物理上放在同一个包(模块)里,高层自己定义所需的抽象,不依赖任何外部具体实现。
低层实现模块(放在其他包)
1 | // ===== 包/模块: 导出实现 ===== |
依赖方向: 低层的
导出实现包依赖高层的核心业务包(因为要实现报表导出器),高层对低层完全无感知。
如何体现 SRP
HTML导出器的修改只因为 HTML 格式变动。PDF导出器的修改只因为 PDF 要求变动。报表服务的修改只因为数据收集或流程控制变动。
三种变化原因被隔离在不同的类中,互不干扰。
如何体现 DIP
依赖方向(箭头表示”依赖”):
1 | 报表服务 ──依赖──▶ 报表导出器(抽象) ◀──实现── HTML导出器 |
报表服务(高层)只依赖报表导出器接口,从不引用任何具体导出类。- 具体导出类
HTML导出器和PDF导出器依赖于高层定义的抽象,通过实现接口”倒置”了传统的依赖方向。
里氏替换原则(Liskov Substitution Principle,LSP)
1. 核心定义
子类型必须能够替换其基类型,且不改变程序的逻辑正确性和预期行为。
2. LSP 的本质:遵守契约
调用方应当针对抽象编写逻辑,而无需关心具体实现的差异。LSP 强调的不仅仅是”编译能通过”(那是结构问题),更是”契约设计“(是行为问题):子类在重写方法时,不能提出比基类更苛刻的条件,也不能给出比基类更差的结果(例如抛出未声明的异常,或返回高层非预期的数据类型)。
3. DIP 与 LSP 的关系
- DIP(依赖倒置) 提供的是替换的”骨架”:高层不依赖低层,双方依赖抽象(保证了结构上可以拔插)。
- LSP(里氏替换) 保证的是替换的”灵魂”:确保子类插进去后,高层逻辑绝对不会崩溃,也不需要做特殊判断。
结论: 实现了 DIP 只是满足了 LSP 的结构前提;如果在子类里乱写逻辑打破了预期,依然会违背 LSP。
总而言之,高层代码不能动——也就是 OCP 要实现的终极目标之一:对修改关闭。
4. 结合”报表导出器”的例子解释
【背景】 高层模块 报表服务 依赖了抽象接口 报表导出器 的 .导出(数据) 方法,预期行为是:”不管传入什么合法数据,都会返回一份导出的文件内容”。
【正向例子:满足 LSP】 我们新增了 HTML导出器 和 PDF导出器。它们完全遵守了接口的契约,接收数据,老老实实地返回 HTML 或 PDF 的文件流。高层业务代码(调用方)完全不用修改,这就是完美的里氏替换。
【反向例子:满足 DIP 但违背 LSP】 后来业务要求增加一个 Excel导出器。在结构上它实现了 报表导出器 接口(满足了 DIP),但在具体实现里加了”私货”:
1 | class Excel导出器 实现 报表导出器 { |
【违背 LSP 带来的恶果】 当高层 报表服务 无意间用到这个 Excel导出器 且数据量大于 10000 时,程序直接崩溃了,因为高层根本没准备好捕获这个异常。
为了让系统不崩溃,高层模块被迫改成这样:
1 | class 报表服务 { |
接口隔离原则(Interface Segregation Principle,ISP)
任何调用方都不应该依赖它不使用的元素。
“臃肿”接口的问题
在缺乏 ISP 的典型设计中,可能会定义一个包含太多方法的大接口:
1 | // 臃肿接口:包含太多方法 |
如果不同的调用方只需要其中部分方法:
Robot只需要Work(),不需要Eat()和Sleep()Human需要全部三个
在 Go 中,即使 Robot 只调用 Work(),如果它依赖的某个类型实现了整个 Worker 接口,那么:
- 源代码依赖: 对
Eat()或Sleep()方法的任何签名修改,都会影响所有引用Worker接口的代码。 - 编译影响: Go 是静态编译语言,任何接口方法的变化都会导致所有依赖该接口的包需要重新编译。
- 测试复杂化: 为测试 Robot 而模拟(mock)
Worker接口时,必须实现它不需要的Eat()和Sleep()方法。
ISP 解决方案:接口隔离
将大接口拆分为多个小而专注的接口:
1 | // 隔离后的专用接口 |
现在:
Robot只依赖Worker接口- 修改
Eater或Sleeper接口不会影响Robot或任何只使用Worker的代码





