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

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。它仍让业务源码依赖具体存储。两者可以配合使用,但解决的问题不同。

7. 参考资料​