跳到主要内容
约 6 分钟3 张卡

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. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明