SOLID 面对象设计的五大基本原则
SOLID 原则的价值
原则 | 核心价值 | 解决的问题 |
---|---|---|
SRP | 职责分离,提高内聚性 | 代码臃肿、牵一发而动全身 |
OCP | 通过扩展而非修改实现变化 | 频繁修改现有代码导致的风险 |
LSP | 确保子类行为的一致性 | 继承滥用导致的系统不稳定 |
ISP | 定制化接口,避免依赖冗余 | 接口过大导致的实现负担 |
DIP | 解耦高层与低层模块 | 模块间强依赖导致的可维护性差 |
SOLID原则是一组面向对象编程和设计的五个基本原则,由罗伯特·C·马丁在21世纪早期引入。这五个原则的首字母缩写为SOLID,它们分别是:单一职责原则(Single Responsibility Principle)、开闭原则(Open-Closed Principle)、里氏替换原则(Liskov Substitution Principle)、接口隔离原则(Interface Segregation Principle)和依赖反转原则(Dependency Inversion Principle)。
单一职责原则 (SRP)
单一职责原则 (SRP) 英文全称为 Single Responsibility Principle,是最简单,但也是最难用好的原则之一。它的定义也很简单:对于一个类而言,应该仅有一个引起它变化的原因。其中变化的原因就表示了这个类的职责,它可能是某个特定领域的功能,可能是某个需求的解决方案。
单一职责原则用于控制类的粒度大小,减少类中不相关功能的代码耦合,使得类更加的健壮;另外,单一职责原则也适用于模块之间解耦,对于模块的功能划分有很大的指导意义。
这个原则表达的是不要让一个类承担过多的责任,一旦有了多个职责,那么它就越容易因为某个职责而被更改,这样的状态是不稳定的,不经意的修改很有可能影响到这个类的其他功能。因此,我们需要将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,不同类之间的变化互不影响。
面对违背单一职责原则的程序代码,我们可以利用外观模式,代理模式,桥接模式,适配器模式,命令模式 等对已有设计进行重构,实现多职责的分离。
我们在设计一个类的时候,可以先从粗粒度的类开始设计,等到业务发展到一定规模,我们发现这个粗粒度的类方法和属性太多,且经常修改的时候,我们就可以对这个类进行重构了,将这个类拆分成粒度更细的类,这就是所谓的持续重构
开闭原则(OCP)
开闭原则 (OCP) 英文全称为 Open-Closed Principle,基本定义是软件中的对象(类,模块,函数等)应该对于扩展是开放的,但是对于修改是封闭的。这里的对扩展开放表示这添加新的代码,就可以让程序行为扩展来满足需求的变化;对修改封闭表示在扩展程序行为时不要修改已有的代码,进而避免影响原有的功能。
要实现不改代码的情况下,仍要去改变系统行为的关键就是抽象和多态,通过接口或者抽象类定义系统的抽象层,再通过具体类来进行扩展。这样一来,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,达到开闭原则的要求
同样,举个例子来更深刻地理解开闭原则:有一个用于图表显示的 Display 类,它能绘制各种类型的图表,比如饼状图,柱状图等;而需要绘制特定图表时,都强依赖了对应类型的图表,Display 类的内部实现如下:
public void display(String type) {if (type.equals("pie")) { PieChart chart = new PieChart(); chart.display(); } else if (type.equals("bar")) { BarChart chart = new BarChart(); chart.display(); }
}
基于上述的代码,如果需要新增一个图表,比如折线图 LineChart ,就要修改 Display 类的 display() 方法,增加新增的判断逻辑,很显然这样的做法违反开闭原则。而让类的实现符合开闭原则的方式就是引入抽象图表类 AbstractChart,作为其他图表的基类,让 Display 依赖这个抽象图表类 AbstractChart,然后通过 Display 决定使用哪种具体的图表类,实现代码变成了这样:
private Abstractchart chart;public void display() {chart.display();
}
现在我们新增一个 LineChart 对象只需要继承Abstractchart 实现其方法,无须修改之前的 BarChart 的代码。
这个重构后的设计符合开闭原则,因为我们通过扩展子类来实现新的功能,而不需要修改父类的代码和其他子类的代码。这样做的好处是,已有的代码保持不变,不会引入新的错误,同时也增加了系统的可扩展性和可维护性,但会增加一定的复杂性。
总结起来,开闭原则鼓励我们在设计软件时,采用抽象、封装和多态等方式,使得系统能够以最小的修改来适应变化。这种设计思想能够提高代码的可复用性、可扩展性和可维护性,是良好的软件设计实践之一。
里式替换原则 (LSP)
里式替换原则 (LSP) 英文全称为 Liskov Substitution Principle,基本定义为:在不影响程序正确性的基础上,所有使用基类的地方都能使用其子类的对象来替换。这里提到的基类和子类说的就是具有继承关系的两类对象,当我们传递一个子类型对象时,需要保证程序不会改变任何原基类的行为和状态,程序能正常运作。 一句话概括为: 能够使用父类的地方,一定可以使用其子类,并且预期结果是一致的。
让我们举一个简单的例子比方说,我们有一个动物超类,它有一个叫做 "咆哮 "的方法。现在我们有了我们的子类,例如狗和熊扩展了超类Animal。现在我们知道狗不会吼叫,但熊会,所以我们必须抛出一个狗不能吼叫的异常。所以子类(狗)实际上不能使用超类的这个咆哮方法,那么我们就违反了这个原则。
所以,我们需要确保我们的子类能够实现超类所有暴露的方法并且预期结果是一致的,这样才不会违反原则。
另一方面,里式替换原则也是对开闭原则的补充,不仅适用于继承关系,还适用于实现关系的设计,常提到的 IS-A (父子继承关系) 是针对行为方式来说的,如果两个类的行为方式是不相容,那么就不应该使用继承,更好的方式是提取公共部分的方法来代替继承
里氏替换主要解决的问题:
- 里氏替换解决了继承中重写父类造成的可复用性变差的问题
- 是动作正确性的保证,即类的扩展不会给已有系统引入新的错误,降低了代码出错的可能性
- 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
接口隔离原则 (ISP)
接接口隔离原则(Interface Segregation Principle)的定义是:类间的依赖关系应该建立在最小的接口上。简单地说:接口的内容一定要尽可能地小,能有多小就多小。
举个例子来说,我们经常会给别人提供服务,而服务调用方可能有很多个。很多时候我们会提供一个统一的接口给不同的调用方,但有些时候调用方 A 只使用 1、2、3 这三个方法,其他方法根本不用。调用方 B 只使用 4、5 两个方法,其他都不用。接口隔离原则的意思是,你应该把 1、2、3 抽离出来作为一个接口,4、5 抽离出来作为一个接口,这样接口之间就隔离开来了。
那么为什么要这么做呢?我想这是为了隔离变化吧! 想想看,如果我们把 1、2、3、4、5 放在一起,那么当我们修改了 A 调用方才用到 的 1 方法,此时虽然 B 调用方根本没用到 1 方法,但是调用方 B 也会有发生问题的风险。而如果我们把 1、2、3 和 4、5 隔离成两个接口了,我修改 1 方法,绝对不会影响到 4、5 方法。
除了改动导致的变化风险之外,其实还会有其他问题,例如:调用方 A 抱怨,为什么我只用 1、2、3 方法,你还要写上 4、5 方法,增加我的理解成本。调用方 B 同样会有这样的困惑。
依赖倒置原则(DIP)
依赖倒置原则 (DIP) 英文全称 Dependency Inversion Principle, DIP),基本定义是:
- 高层模块不应该依赖低层模块,应该共同依赖抽象;
- 抽象不应该依赖细节,细节应该依赖抽象。
这里的抽象就是接口和抽象类,而细节就是实现接口或继承抽象类而产生的类。
如何理解“高层模块不应该依赖低层模块,应该共同依赖抽象”呢?如果高层模块依赖于低层模块,那么低层模块的改动很有可能影响到高层模块,从而导致高层模块被迫改动,这样一来让高层模块的重用变得非常困难。
而最佳的做法就如上图一样,在高层模块构建一个稳定的抽象层,并且只依赖这个抽象层;而由底层模块完成抽象层的实现细节。这样一来,高层类都通过该抽象接口使用下一层,移除了高层对底层实现细节的依赖。
依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。同时依赖倒置原则也是框架设计的核心原则,善于创建可重用的框架和富有扩展性的代码。
我们来看看一个非常简单的寄信
案例帮你理解依赖倒置,寄信这个业务,主要存在两种角色:寄信人和邮递员。 最开始寄信人强依赖于邮递员,寄信需要送到邮递员家。这种模式缺点比较明显,邮递员换了很麻烦。直到后面增加了邮筒,寄信人不再直接依赖邮递员,而是依赖一个站着不会动的邮筒。
上面的邮筒,可以让邮递员再怎么变化,都不会影响到寄信人。这种将寄信人直接依赖邮递员,改为寄信人和邮递员互不依赖,两者都依赖于邮筒的过程,正是“依赖倒置”
下面来看看系统中模块的设计使用依赖倒置的场景:
比如 Tomcat 容器的 Servlet 规范实现,Spring Ioc 容器实现就是第一层境界的产品, 我们熟悉的 RPC 模式就是,第二层境界的产品 。 那么我们是不是可以总结为第一层境界是在系统内部模块和模块的协作,第二层境界是跨系统协作。