SOLID 面向对象设计原则
SOLID 用五项原则管理职责、扩展和依赖。本文负责原则含义、行为契约与代码判断。
1. 面试核心
先记住这几条,再读正文- 单一职责。 SRP 按变化原因划分职责,同一类可包含多个相关方法。卡 · 单一职责原则
- 开闭原则。 OCP 通过稳定扩展点隔离变化,修改仍可发生在扩展点之外。
- 里氏替换。 LSP 要求子类型遵守父类型的行为契约。卡 · 里氏替换原则
- 接口隔离。 ISP 按客户端需要拆分接口,避免依赖无关能力。
- 依赖倒置与注入。 DIP 让业务依赖抽象,DI 负责把具体依赖传入对象。卡 · 依赖倒置原则
2. 职责与变化
代码维护的主要成本来自变化的传播。多个职责混在一起时,一处修改会影响无关调用者。SOLID 用于判断这些依赖与边界。
抽象也有成本。类和接口增加后,阅读调用链会更费力。应围绕已经存在的变化点选择边界。
3. SOLID 概念
| 原则 | 英文名称 | 判断对象 |
|---|---|---|
| 单一职责 | Single Responsibility Principle | 导致模块变化的职责来源 |
| 开闭原则 | Open/Closed Principle | 稳定部分与扩展点 |
| 里氏替换 | Liskov Substitution Principle | 子类型的行为契约 |
| 接口隔离 | Interface Segregation Principle | 客户端实际使用的能力 |
| 依赖倒置 | Dependency Inversion Principle | 源码依赖的方向 |
4. SOLID 原则
4.1 单一职责原则
SRP 将同一变化原因影响的内容组织在一起。例如薪资计算与报表格式由不同需求驱动。分离后,报表改版不会改变薪资规则。
一个类有多个方法,仍可围绕同一职责。实体维护自身不变量也属于合理职责。User 并非只能保存字段,校验也无需一律外移。
字段合法性可由值对象或实体维护。注册流程的唯一性检查则依赖仓库,适合放在应用服务中。
4.2 开闭原则
OCP 用稳定接口隔离可预见的扩展。例如绘图器通过 Shape 调用不同图形:
interface Shape {
void draw();
}
final class Circle implements Shape {
public void draw() {
System.out.println("circle");
}
}
final class Renderer {
void render(Shape shape) {
shape.draw();
}
}
新增图形只需实现 Shape,Renderer 的调用逻辑保持稳定。注册实现、装配对象和修复缺陷仍可修改代码。
扩展方向应来自业务变化。为了未知需求引入插件系统,会增加当前维护成本。
4.3 里氏替换原则
LSP 要求调用者依赖的行为在替换后继续成立。子类型应保留父类型不变量,并遵守历史约束。例如父类型承诺创建后不变,子类型也应保持不变。它还应满足以下条件:
- 前置条件保持或放宽,避免拒绝父类型允许的输入。
- 后置条件保持或加强,确保父类型承诺的结果成立。
例如账户接口承诺余额足够时允许提款。子类额外要求“仅工作日提款”,就加强了前置条件。参数类型相同,仍会违反行为契约。
父类型若承诺成功返回后已经持久化,子类型就需遵守。只把记录放入易失内存会削弱后置条件。
继承关系通过编译检查,只说明类型兼容。契约测试还需检查结果、异常与状态变化。
4.4 接口隔离原则
ISP 按调用方需要划分能力。只读取数据的调用方可依赖只读接口。写入能力由另一个接口表达。
动物能力也可以拆成独立接口:
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
final class Fish implements Swimmable {
public void swim() {
System.out.println("swim");
}
}
Fish 只实现游泳能力,无需提供空的飞行方法。新增功能可以增加接口,也可以扩展已有稳定职责。是否拆分取决于客户端依赖关系。
4.5 依赖倒置原则
DIP 让高层业务和低层实现共同依赖抽象。抽象描述业务需要,避免直接暴露数据库细节。
interface UserRepository {
void save(String userId);
}
final class UserService {
private final UserRepository repository;
UserService(UserRepository repository) {
this.repository = repository;
}
void register(String userId) {
repository.save(userId);
}
}
UserService 的源码只依赖 UserRepository。数据库适配器实现该接口,装配入口传入具体对象。
构造器注入属于 DI,即依赖注入。若构造器直接接收具体 MySQL 类,仍有 DI。该写法却没有隔离高层对存储细节的依赖。
5. 使用边界
SOLID 用于控制变化传播,接口数量无需作为目标。稳定的小模块可以保留直接实现。
行为差异无法满足父类型契约时,可改用组合。调用者依赖所需能力,组件通过委托完成协作。
评审时应使用具体变更检验设计。例如新增存储实现时,业务规则是否需要改动。新增读取客户端时,是否被迫依赖写方法。
6 · 面试题卡片
本页题目与参考回答
6.1 单一职责是否要求一个类只做一个动作?
考察重点:变化原因、实体不变量与职责边界。
相关内容:单一职责原则。
参考回答
SRP 按变化原因划分职责,允许多个相关动作。
薪资规则与报表格式由不同需求驱动,适合分离。
实体可维护自身不变量。是否提取校验类,要看校验依赖与职责来源。方法数量无法直接决定职责数量。
6.2 子类方法签名相同,为什么仍会违反里氏替换原则?
考察重点:前置条件、后置条件与不变量。
相关内容:里氏替换原则。
参考回答
方法签名只约束类型,LSP 还约束行为。
子类若收紧合法输入,或削弱成功结果,就会破坏契约。
提款子类额外限定工作日,会拒绝父类型允许的调用。持久化子类只写内存,则削弱成功承诺。契约测试应检查这些行为。
6.3 依赖倒置与依赖注入有什么区别?
考察重点:源码依赖方向与对象装配。
相关内容:依赖倒置原则。
参考回答
DIP 管理源码依赖方向,DI 管理对象依赖的传入方式。
业务依赖仓库接口,适配器实现接口,体现 DIP。
构造器接收具体数据库类也属于 DI。它仍让业务源码依赖具体存储。两者可以配合使用,但解决的问题不同。