面向对象设计原则
TL;DR
- SOLID 是指导面向对象设计的工具方法,开闭原则是其精神领袖
- 职责按「引起变更的原因」划分;依赖应落在抽象上,接口按使用者隔离
- 子类可扩展父类,但不能破坏父类契约;类间只与必要的朋友交流
面向对象设计里常说的几条原则,核心是 SOLID,再加上迪米特法则。它们不是教条,而是在「可扩展」和「少踩坑」之间找平衡的一套约束。下面按原则整理定义、含义与典型案例。
单一职责原则(Single Responsibility Principle, SRP)
职责 = 引起变更的原因。
不要存在多余一个引起变化的原因。
适用范围:类、接口、方法。
案例一(《敏捷软件开发——原则、模式与实践》)
Modem 包含了两个职责:通信管理、连接管理。
Bad:
interface Modem {
public void dial(String pno);
public void hangup();
public void send(char c);
public void recv();
}
Good:
interface DataChannel {
public void send(char c);
public void recv();
}
interface Connection {
public void dial(String pno);
public void hangup();
}
案例二(《设计模式之禅》)
Bad:
public interface IUserInfo {
void setUserID();
String getUserID();
void setPassword();
String getPassword();
void setUserName();
String getUserName();
boolean changePassword();
boolean deleteUser();
void mapUser();
boolean addOrg(int orgID);
boolean addRole(int roleID);
}
Good:
public interface IUserBo {
void setUserID();
String getUserID();
void setPassword();
String getPassword();
void setUserName();
String getUserName();
boolean changePassword();
}
public interface IUserBz {
boolean deleteUser();
void mapUser();
boolean addOrg(int orgID);
boolean addRole(int roleID);
}
public interface IUserInfo extends IUserBo, IUserBz {
}

案例三(《Spring5 核心原理》)
Bad:
public class UserInfo {
private String userName;
private String address;
private void modifyUserInfo(String userName, String address) {
this.userName = userName;
this.address = address;
}
}
Good:
public class UserInfo {
private String userName;
private String address;
private void modifyUserName(String userName) {
this.userName = userName;
}
private void modifyUserAddress(String address) {
this.address = address;
}
}
里氏替换原则(Liskov Substitution Principle, LSP)
子类可以扩展父类的功能,但是不能改变父类原有的功能。
建立这个原则的目的
「继承」这一语言特性是优劣并存的,建立里氏替换的约束是为了使优点大于缺点:

- 约束继承泛滥,是开闭原则的一种体现
里氏替换的 4 层含义
- 子类必须完全实现父类的方法
- 子类可以有自己的个性
- 覆盖或实现父类的方法时输入参数可以被放大
- 覆写或实现父类的方法时输出结果可以被缩小
案例一:玩具枪
ToyGun 是假的枪,是不能实现 shoot 方法的,因此这种抽象方式有问题:

注意: 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生「畸变」,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
可以在 AbstractToy 中声明将声音、形状都委托给 AbstractGun 处理——仿真枪嘛,形状和声音都要和真实的枪一样——然后两个基类下的子类自由延展,互不影响。

案例二:子类可以有自己的个性
子类 AUG 可以有自己独特的方法:zoomOut & shoot

案例三:输入参数放大
public class Father {
public Collection doSomething(Map map) {
System.out.println("父类执行");
return map.values();
}
}
public class Son extends Father {
public Collection doSomething(HashMap map) {
System.out.println("子类执行");
return map.values();
}
}
public class TestLiskov {
@Test
void test() {
// Father f = new Father();
Son f = new Son();
// 子类替换父类执行方法会执行子类的逻辑
f.doSomething(new HashMap());
}
}
子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱。因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会「歪曲」了父类的意图,引起一堆意想不到的业务逻辑混乱。所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。
正确的做法是扩大子类的输入参数类型的范围。子类代替父类传递到调用者中,子类的方法永远都不会被执行,这样才能真正符合「子类的实例 is a 父类实例」,父类的代码才能真正复用。
父类方法的输入参数是 HashMap 类型,子类的输入参数是 Map 类型:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类执行");
return map.values();
}
}
public class Son extends Father {
public Collection doSomething(Map map) {
System.out.println("子类执行");
return map.values();
}
}
public class TestLiskov {
@Test
void test() {
// Father f = new Father();
Son f = new Son();
// 不管是哪一个,实际调用的都是父类实现的方法
f.doSomething(new HashMap());
}
}
另外:如果父类的一个方法的返回值是一个类型 T,子类的相同方法(重载或覆写)的返回值为 S,那么里氏替换原则就要求 S 必须小于等于 T,也就是说,要么 S 和 T 是同一个类型,要么 S 是 T 的子类。为什么呢?分两种情况:如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的返回值 S 小于等于 T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。
案例四:正方形不是长方形
public class Rectangle {
protected int height;
protected int width;
public Rectangle(int height, int width) {
this.height = height;
this.width = width;
}
public Rectangle() {}
public void setHeight(int height) {
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public int area() {
return height * width;
}
}
public class Square extends Rectangle {
public Square() {}
public void setHeight(int height) {
this.height = height;
this.width = height;
}
public void setWidth(int width) {
this.width = width;
this.height = width;
}
}

在这个例子中,子类对父类的方法进行重写,改变了一个行为:width、height 不能单独变化,所以我们并不能说 Square Is A Rectangle。OOD 中的 IS-A 关系是针对对象的行为方式而言的,是可以进行假设的,哪怕在现实生活中我们定义:正方形是一种长方形。
依赖倒置原则(Dependence Inversion Principle, DIP)
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象
- 抽象不应该依赖细节
- 细节应该依赖抽象
抽象:就是指接口或抽象类,两者都是不能直接被实例化的。
细节:就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字 new 产生一个对象。
模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;接口或抽象类不依赖于实现类;实现类依赖接口或抽象类。更加精简的定义就是「面向接口编程」——OOD(Object-Oriented Design,面向对象设计)的精髓之一。
采用 DIP 的优点
反证法:如果不使用依赖倒置原则就会加重类间的耦合性,降低系统的稳定性,增加并行开发引起的风险,降低代码的可读性和可维护性。
例子一:
public class Benz {
public void run() {
System.out.println("奔驰汽车开始...");
}
}
public class Driver {
public void drive(Benz benz) {
benz.run();
}
}
public class BMW {
public void run() {
System.out.println("宝马汽车开始...");
}
}
上面的代码 Driver 与 Benz 直接依赖,出现新的车的时候逻辑就出现问题了,driver 只能开 Benz 不能开 BMW。
引入依赖倒置原则后:
public interface IDriver {
void drive(ICar car);
}
public class Driver implements IDriver {
@Override
public void drive(ICar car) {
car.run();
}
}
public interface ICar {
void run();
}
public class Benz implements ICar {
public void run() {
System.out.println("奔驰汽车开始...");
}
}
public class BMW implements ICar {
public void run() {
System.out.println("宝马汽车开始...");
}
}

注意:在 Java 中,只要定义变量就必然要有类型,一个变量可以有两种类型:表面类型和实际类型。表面类型是在定义的时候赋予的类型,实际类型是对象的类型,如 zhangSan 的表面类型是 IDriver,实际类型是 Driver。
抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的是保证所有的细节不脱离契约的范畴,确保约束双方按照既定的契约(抽象)共同发展。只要抽象这根基线在,细节就脱离不了这个圈圈,始终让你的对象做到「言必信,行必果」。
依赖注入的三种方式
- 构造函数:一个司机一辆车,司机只能开自己的车
public class Driver implements IDriver {
private final ICar car;
public Driver(ICar car) {
this.car = car;
}
@Override
public void drive() {
this.car.run();
}
}

- Setter 方法注入:司机可以换车
public class Driver implements IDriver {
private ICar car;
@Override
public void setCar(ICar car) {
this.car = car;
}
@Override
public void drive() {
this.car.run();
}
}

- 接口注入
public class Driver implements IDriver {
@Override
public void drive(ICar car) {
car.run();
}
}

使用依赖倒置的最佳实践
依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合:
- 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
- 变量的表面类型尽量是接口或者是抽象类
- 任何类都不应该从具体类派生
- 尽量不要覆写基类的方法
- 如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
- 结合里氏替换原则使用
- 根据里氏替换原则,父类出现的地方子类就能出现,再结合依赖倒置,我们可以得出这样一个通俗的规则:接口负责定义 public 属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化
到底什么是「倒置」
到底什么是「倒置」呢?我们先说「正置」是什么意思。依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式——我要开奔驰车就依赖奔驰车,我要使用笔记本电脑就直接依赖笔记本电脑。而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,「倒置」就是从这里产生的。
接口隔离原则(Interface Segregation Principle, ISP)
- 一个类对另一个类的依赖应该建立在最小的接口上
- 建立单一接口,不要建立庞大的臃肿的接口
- 尽量细化接口,接口中的方法尽量少(适度)
- 分离接口的使用者(Client)就是接口分离
- 我们在考虑引起软件发生变更的作用力时,通常是在考虑接口的变化会怎样影响它们的使用者。因此迫使接口发生改变的,正是它们的使用者。
- 不应该强迫 client 依赖它们不用的方法
接口隔离原则与单一职责原则的区别
接口隔离原则与单一职责的审视角度是不相同的。单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分;而接口隔离原则要求接口的方法尽量少。例如一个接口的职责可能包含 10 个方法,这 10 个方法都放在一个接口中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约束「不使用的方法不要访问」——按照单一职责原则是允许的,按照接口隔离原则是不允许的,因为它要求「尽量使用多个专门的接口」。专门的接口指什么?就是指提供给每个模块的都应该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。
功能过多的接口:

合理的接口拆分:

最佳实践
- 一个接口只服务于一个子模块或业务逻辑
- 接口隔离原则和其他设计原则一样,都需要花费较多的时间和精力来进行设计和筹划,但是它带来了设计的灵活性,让你可以在业务人员提出「无理」要求时轻松应付
- 贯彻使用接口隔离原则最好的方法就是一个接口一个方法,保证绝对符合接口隔离原则(有可能不符合单一职责原则),但你会采用吗?不会,除非你是疯子。那怎么才能正确地使用接口隔离原则呢?答案是根据经验和常识决定接口的粒度大小。接口粒度太小,导致接口数量剧增,开发人员呛死在接口的海洋里;接口粒度太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。怎么准确地实践接口隔离原则?与业务语言相结合,实践、经验和领悟
迪米特法则(Law of Demeter, LoD)
也称为最少知识原则(Least Knowledge Principle, LKP)。
一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多 public 方法,我就调用这么多,其他的我一概不关心。
4 层含义
- 只和朋友交流
- 朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类
- 朋友间也是有距离的
- 一个类公开的 public 属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少 public 方法和属性,是否可以修改为 private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected 等访问权限,是否可以加上 final 关键字等
- 是自己的就是自己的
- 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中
- 谨慎使用 Serializable
最佳实践
迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。读者在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。
场景:老师让班长数学生人数。老师发号施令的过程中只需要跟班长之间发生,不需要跟学生产生关系,所以下面的代码不符合迪米特法则,其产生的复杂性也是显而易见的。
public class Teacher {
public void command(Monitor monitor) {
List students = new ArrayList();
for (int i = 0; i < 20; i++) {
students.add(new Student());
}
monitor.countStudents(students);
}
}
public class Monitor {
public int countStudents(List students) {
return students.size();
}
}
public class Student {
public Student() {}
}

public class Teacher {
public void command(Monitor monitor) {
monitor.countStudents();
}
}
public class Monitor {
private List<Student> students;
public Monitor(List<Student> students) {
this.students = students;
}
public int countStudents() {
return students.size();
}
}
public class Student {
public Student() {}
}

开闭原则(Open Close Principle, OCP)
软件实体应该对扩展开放,对修改关闭:其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。
注意:开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改。低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
我们可以把变化归纳为以下三种类型:
- 逻辑变化
- 子模块变化
- 可见视图变化
案例:通过扩展拥抱需求变化
public interface IBook {
String getName();
int getPrice();
String getAuthor();
}
public class NovelBook implements IBook {
private String name;
private int price;
private String author;
public NovelBook(String name, int price, String author) {
this.name = name;
this.price = price;
this.author = author;
}
@Override
public String getName() {
return name;
}
@Override
public int getPrice() {
return price;
}
@Override
public String getAuthor() {
return author;
}
}
需求变化:当书籍总价超过 40 元,打 9 折;否则打 8 折。
public class OffNovelBook extends NovelBook {
public OffNovelBook(String name, int price, String author) {
super(name, price, author);
}
@Override
public int getPrice() {
int selfPrice = super.getPrice();
int offPrice = 0;
if (selfPrice > 4000) {
offPrice = selfPrice * 90 / 100;
} else {
offPrice = selfPrice * 80 / 100;
}
return offPrice;
}
}
开闭原则是最基础的一个原则,也就是说前五个原则就是指导设计的工具和方法,而开闭原则才是其精神领袖。
开闭原则的重要性
- 开闭原则对测试的影响
- 一个方法的测试方法一般不少于 3 种,为什么呢?首先是正常的业务逻辑要保证测试到,其次是边界条件要测试到,然后是异常要测试到,比较重要的方法的测试方法甚至有十多种,而且单元测试是对类的测试,类中的方法耦合是允许的。在这样的条件下,如果再想着通过修改一个方法或多个方法代码来完成变化,基本上就是痴人说梦,该类的所有测试方法都要重构,想象一下你在一堆你并不熟悉的代码中进行重构时的感觉吧
- 开闭原则可以提高复用性
- 在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大
- 开闭原则可以提高可维护性
- 一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展。维护人员最乐意做的事情就是扩展一个类,而不是修改一个类——甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情
- 面向对象开发的要求
如何使用开闭原则
- 抽象约束
- 元数据(metadata)控制模块行为
- 制定项目章程
- 在一个团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置
- 封装变化
- 对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中
欢迎交流
如果你觉得文章有帮助,欢迎加我微信或关注公众号,获取更多内容

