SOLID 面向对象设计原则
SOLID 是五项面向对象设计原则,分别讨论职责、扩展、类型替换、接口和依赖方向。它们帮助我们判断:一个需求变化后,哪些代码应该跟着改,哪些代码应该保持稳定。
理解这些原则,不能只记住缩写。要能用具体变更检验设计,例如新增一种图形时绘图器是否需要改,替换存储实现时业务规则是否受到影响。
1. 面试核心
先记住这几条,再读正文- 单一职责按变化原因划分职责,同一个类可以包含多个服务于同一职责的方法。卡 · 单一职责原则
- 开闭原则通过稳定的扩展点隔离变化,不要求所有代码永远不能修改。
- 里氏替换要求子类型遵守父类型的行为契约,方法签名相同还不够。卡 · 里氏替换原则
- 接口隔离按调用方实际需要的能力拆分接口,避免它依赖用不到的方法。
- 依赖倒置让业务依赖抽象而非具体实现;依赖注入则负责把对象所需的依赖传进来。卡 · 依赖倒置原则
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();
}
}
Renderer.render 始终调用 shape.draw()。新增一种图形时,只要实现 Shape,绘图器的这段逻辑就不用跟着改。
这里保持稳定的是绘图调用,注册实现、装配对象和修复缺陷仍然可能修改代码。扩展点应来自实际业务变化;为了尚未出现的需求提前建立插件系统,只会增加当前的维护成本。
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 只实现 Swimmable,因此只需要提供 swim(),不用写一个空的 fly() 来凑齐接口。
接口是否拆分,要看调用方的依赖关系。新增功能可以增加接口,也可以在已有职责内扩展;没有必要把每个方法都拆成一个接口。
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.register 只通过 UserRepository.save 保存用户,源码中不需要知道数据库的具体类型。数据库适配器实现 UserRepository,再由装配入口把具体对象传给 UserService。
依赖注入(DI)是从外部把对象需要的依赖传进去,上面的构造器就是一种注入方式。它与依赖倒置并不等同:构造器直接接收具体的 MySQL 类也属于注入,但业务源码仍然依赖具体存储实现,没有通过抽象隔离这项变化。
5. 使用边界
SOLID 的价值要通过具体变更来检验。新增存储实现时,业务规则是否需要修改?新增只读调用方时,它是否被迫依赖写方法?这些问题比接口数量更能说明设计是否合理。
稳定的小模块可以保留直接实现。如果某个子类无法遵守父类型的行为契约,可以改用组合:调用方依赖所需能力,对象再把部分工作交给另一个组件,避免建立不成立的替换关系。
6 · 面试题卡片
本页题目与参考回答
6.1 单一职责是否要求一个类只做一个动作?
考察重点:变化原因、实体不变量与职责边界。
相关内容:单一职责原则。
参考回答
不要求。单一职责按变化原因划分,一个类可以包含多个服务于同一职责的方法。
薪资规则和报表格式由不同需求驱动,适合分开;实体维护自身状态与校验规则,则可以属于同一职责。是否提取校验类,要看它依赖什么、受什么需求影响,不能只数方法数量。
6.2 子类方法签名相同,为什么仍会违反里氏替换原则?
考察重点:前置条件、后置条件与不变量。
相关内容:里氏替换原则。
参考回答
方法签名相同只保证类型上的兼容,子类还可能违背父类型承诺的行为。
父类型允许余额足够时提款,子类却额外限制只能在工作日提款,就收紧了前置条件。父类型承诺成功后已经持久化,子类却只写内存,就削弱了后置条件。替换后调用方原来的判断不再成立,因此违反里氏替换原则。
6.3 依赖倒置与依赖注入有什么区别?
考察重点:源码依赖方向与对象装配。
相关内容:依赖倒置原则。
参考回答
依赖倒置规定源码应该依赖什么,依赖注入解决对象所需的依赖怎样传进来。
业务依赖仓库接口、数据库适配器实现这个接口,体现依赖倒置。构造器从外部接收仓库对象,是依赖注入。
构造器即使直接接收具体数据库类,也仍然属于注入,但业务源码依旧依赖具体存储实现。因此,有依赖注入不等于做到了依赖倒置。
7. 参考资料
- Robert C. Martin:The Single Responsibility Principle
- Robert C. Martin:SOLID Relevance
- Liskov、Wing:A Behavioral Notion of Subtyping
- Martin Fowler:Inversion of Control Containers and Dependency Injection
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明