面向对象设计的SOLID原则
SOLID简介
SRP | The Single Responsibility Principle | 单一责任原则 |
OCP | The Open Closed Principle | 开放封闭原则 |
LSP | The Liskov Substitution Principle | 里氏替换原则 |
ISP | The Interface Segregation Principle | 接口分离原则 |
DIP | The Dependency Inversion Principle | 依赖倒置原则 |
定义
单一责任原则
在面向对象编程领域中,单一功能原则(Single responsibility principle)规定每个类都应该有一个单一的功能,并且该功能应该由这个类完全封装起来。所有它的(这个类的)服务都应该严密的和该功能平行(功能平行,意味着没有依赖)。
当需要修改某个类的时候原因有且只有一个(THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE)。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。
这个术语由罗伯特·C·马丁(Robert Cecil Martin)在他的《敏捷软件开发,原则,模式和实践》一书中的一篇名为〈面向对象设计原则〉的文章中给出。马丁表述该原则是基于的《结构化分析和系统规格》一书中的内聚原则(Cohesion)上。
马丁把功能(职责)定义为:“改变的原因”,并且总结出一个类或者模块应该有且只有一个改变的原因。一个具体的例子就是,想象有一个用于编辑和打印报表的模块。这样的一个模块存在两个改变的原因。第一,报表的内容可以改变(编辑)。第二,报表的格式可以改变(打印)。这两方面会的改变因为完全不同的起因而发生:一个是本质的修改,一个是表面的修改。单一功能原则认为这两方面的问题事实上是两个分离的功能,因此他们应该分离在不同的类或者模块里。把有不同的改变原因的事物耦合在一起的设计是糟糕的。
保持一个类专注于单一功能点上的一个重要的原因是,它会使得类更加的健壮。继续上面的例子,如果有一个对于报表编辑流程的修改,那么将存在极大的危险性,打印功能的代码会因此不工作,假使这两个功能存在于同一个类中的话。
例如:在Spring中,ApplicationContext
拥有事件发布的功能,但是事件发布功能并不是ApplicationContext
的主要职责所在,framework提供了一个单独的事件发布者ApplicationEventPublisher
来提供事件发布功能,而ApplicationEventPublisher
仅仅提供了时间发布的功能,不包含任意其他的职责。
package org.springframework.context;
/**
* Interface that encapsulates event publication functionality.
* Serves as super-interface for ApplicationContext.
*
* @author Juergen Hoeller
* @since 1.1.1
* @see ApplicationContext
* @see ApplicationEventPublisherAware
* @see org.springframework.context.ApplicationEvent
* @see org.springframework.context.event.EventPublicationInterceptor
*/
public interface ApplicationEventPublisher {
/**
* Notify all listeners registered with this application of an application
* event. Events may be framework events (such as RequestHandledEvent)
* or application-specific events.
* @param event the event to publish
* @see org.springframework.web.context.support.RequestHandledEvent
*/
void publishEvent(ApplicationEvent event);
}
开放封闭原则
在面向对象编程领域中,开放封闭原则规定软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的,这意味着一个实体是允许在不改变它的源代码的前提下变更它的行为。该特性在产品化的环境中是特别有价值的,在这种环境中,改变源代码需要代码审查,单元测试以及诸如此类的用以确保产品使用质量的过程。遵循这种原则的代码在扩展时并不发生改变,因此无需上述的过程。
开闭原则的命名被应用在两种方式上。这两种方式都使用了继承来解决明显的困境,但是它们的目的,技术以及结果是不同的。
-
梅耶开闭原则
勃兰特·梅耶一般被认为是最早提出开闭原则这一术语的人,在他1988年发行的《面向对象软件构造》中给出。这一想法认为一旦完成,一个类的实现只应该因错误而修改,新的或者改变的特性应该通过新建不同的类实现。新建的类可以通过继承的方式来重用原类的代码。衍生的子类可以或不可以拥有和原类相同的接口。
梅耶的定义提倡实现继承。具体实现可以通过继承方式来重用,但是接口规格不必如此。已存在的实现对于修改是封闭的,但是新的实现不必实现原有的接口。
例如:在JDK中存在
java.util.Hashtable
和java.util.Properties
两个类,前者使用实现了一个使用哈希表将键映射到相应的值的Map,而后者使用哈希表表示了一个持久的属性集。由于属性集只应用于String之上,如果使用java.util.Hashtable
类标识,将更改了java.util.Hashtable
原有的某些特性,但属性集还必须拥有java.util.Hashtable
的一部分属性,因此java.util.Properties
类继承java.util.Hashtable
,保留了其部分功能,同时又针对属性集定义了一部分特性。这样java.util.Hashtable
原有的功能没有被修改,同时又扩展了一部分功能。 -
多态开闭原则
在20世纪90年代,开闭原则被广泛的重新定义用于抽象化接口的使用,在这中间实现可以被改变,多种实现可以被创建,并且多态化的替换不同的实现。
相比梅耶的使用方式,多态开闭原则的定义倡导对抽象基类的继承。接口规约可以通过继承来重用,但是实现不必重用。已存在的接口对于修改是封闭的,并且新的实现必须,至少,实现那个接口。
例如:在JDK中
java.util.List
定义了一个有序序列。用户可以对列表中每个元素的插入位置进行精确地控制。提供了java.util.AbstractList
类提供 List 接口的骨干实现,以最大限度地减少实现“随机访问”数据存储(如数组)支持的该接口所需的工作。要实现不可修改的列表,编程人员只需扩展此类,并提供get(int)
和size()
方法的实现。要实现可修改的列表,编程人员另外重写set(int, E)
方法。如果列表为可变大小,则编程人员另外重写add(int, E)
和remove(int)
方法即可。这样针对有序列表便有了多种具体的实现,而这些实现中有一部分功能是一致的。
里氏替换原则
继承必须确保超类所拥有的性质在子类中仍然成立。也就是说当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系
例如:生物学的分类体系中把企鹅归属为鸟类。我们模仿这个体系,设计出这样的类和关系。 类“鸟”中有个方法fly,企鹅自然也继承了这个方法,可是企鹅不能飞,于是,我们在企鹅的类中覆盖了fly方法,告诉方法的调用者:企鹅是不会飞的。这完全符合常理。但是,这违反了LSP,企鹅是鸟的子类,可是企鹅却不能飞!
再从Java语言的特性上举例此问题。
Java语言规定继承并且覆盖超类方法的时候,子类中的方法的可见性必须等于或者大于超类中的方法的可见性,子类中的方法所抛出的受检异常只能是超类中对应方法所抛出的受检异常的子类。
如下代码:
package com.warningrc.test.solid;
import java.io.EOFException;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
public class Super {
// 这里是超类中使用protected修饰,返回类型是Collection并抛出IOException异常的方法
protected <T> Collection<T> function() throws IOException {
return Collections.emptySet();
}
}
class Son extends Super {
@Override
// 这里是子类中使用public修饰,返回类型是List并抛出EOFException异常的方法
public <T> List<T> function() throws EOFException {
return Collections.emptyList();
}
}
在上述代码中,public
的可见性大于protected
,List
和EOFException
分别是Collection
和IOException
的子类
接口分离原则
接口分离原则指在设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。即,一个类要给多个客户使用,那么可以为每个客户创建一个接口,然后这个类实现所有的接口;而不要只创建一个接口,其中包含所有客户类需要的方法,然后这个类实现这个接口。
不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口总要好。
图一中设计就是没有使用接口分离原则,如果客户端A需要改变所使用的Service
接口中的方法,那么不但要改动Service
接口和ServiceImp
类,还需要修改客户端B和客户端C。也就是说,对客户端A的修改会影响客户端B和客户端C,因此应对其进行修改。
图二采用的是接口分离原则,对每个客户类都有一个专用的接口,这个接口中只声明了与这个客户类相关的方法,而ServiceImp
类实现了所有的接口。如果客户端A要改变它所使用的接口中的方法,只需改动ServiceA
接口和ServiceImpl
类即可,客户端B和客户端C类不受影响。
在Spring中,一个通过类路径下的XML文件启动实例的类org.springframework.context.support.ClassPathXmlApplicationContext
提供了如下功能:
- IoC容器的核心功能,提供访问IoC容器的基本操作。
- 分层的BeanFactory容器结构(父级BeanFactory)。
- 提供对Spring Bean的遍历操作。
- 提供国际化信息资源处理操作。
上述四个功能完全可以定义在一个接口或抽象类中,然后交给客户使用,但是有些客户上述功能中的一种,其他三种功能他们从不关心。最好的方案就是将上述四种功能分别定义在四个接口中。
- 接口
org.springframework.beans.factory.BeanFactory
提供IoC容器的核心功能,提供访问IoC容器的基本操作。 - 接口
org.springframework.beans.factory.HierarchicalBeanFactory
提供分层的BeanFactory容器结构。 - 接口
org.springframework.beans.factory.ListableBeanFactory
提供对Spring Bean的遍历操作。 - 接口
org.springframework.context.MessageSource
提供国际化信息资源处理操作。
ClassPathXmlApplicationContext
实现了上述四个接口,这样当客户有某一种需求时,将ClassPathXmlApplicationContext
对象向上转型为指定功能接口,然后提供给客户使用,而客户不会看到对象提供的其他功能。如果对上述某一个功能做改动时,只需要修改相应的接口和ClassPathXmlApplicationContext
类中指定的功能即可。
通过上述的设计,ClassPathXmlApplicationContext
功能结构也能够一目了然。
依赖倒置原则
在面向对象编程领域中,依赖倒置原则(Dependency inversion principle)指代了一种特定的解耦(传统的依赖关系建立在高层次上,而具体的策略设置则应用在低层次的模块上)形式。在这种形势下,为了使得高层次的模块不依赖于低层次的模块的实现细节的目的,依赖模块被颠倒了(例如:反转)。该原则规定:
- 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
- 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。
该原则颠倒了一部分人对于面向对象设计的认识方式,比如高层次和低层次对象都应该应该依赖于相同的抽象接口。
在传统的应用架构中,低层次的组件设计用于被高层次的组件使用,这一点提供了逐步的构建一个复杂系统的可能。在这种结构下,高层次的组件直接依赖于低层次的组件去实现一些任务。这种对于低层次组件的依赖限制了重用高层次组件的可行性。
依赖反转原则的目的在把高层次组件从低层次组件中解耦出来,这样使得重用不同的低层组件实现变得可能。把高层组件和低层组件划分到不同的包/库(在这些包/库中拥有定义了高层组件所必须的行为和服务的接口,并且存在高层组件的包)中的方式促进了这种解耦。由低层组件对于高层组件接口的具体实现要求低层组件包的编译是依赖于高层组件的,因此颠倒了传统的依赖关系。众多的设计模式,比如插件,服务定位器或者依赖反转,则被用来在运行时把指定的低层组件实现提供给高层组件。
应用依赖反转原则同样被认为是应用了适配器模式,例如:高层的类定义了它自己的适配器接口(高层类所依赖的抽象接都)。被适配的对象同样依赖于适配器接口的抽象(这是当然的,因为它实现了这个接口),同时它的实现则可以使用它自身所在低层模块的代码。通过这种方式,高层组件则不依赖于低层组件,因为它(高层组件)仅间接的通过调用配置器接口多态方法的方式使用了低层组件通过适配器接口,在这些多态方法则是由被适配对象以及它的低层模块所实现的。
假如有如下场景:张三家里拥有一辆奔驰骑车,他拥有C级驾照,可以开动他的奔驰骑车,我们将代码设计如下:
司机类:
package com.warningrc.test.solid;
/**
* 汽车司机.
*
*/
public class Driver {
private String name;
public Driver(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void drive(Benz benz) {
System.out.println(name + "开车");
benz.run();
}
}
奔驰汽车类:
package com.warningrc.test.solid;
/**
* 奔驰汽车.
*
*/
public class Benz {
public void run() {
System.out.println("奔驰启动了");
}
}
客户端启动类:
package com.warningrc.test.solid;
public class Main {
public static void main(String[] args) {
Driver zhangSan = new Driver("张三");
Benz benz = new Benz();
zhangSan.drive(benz);
}
}
通过上述设计,张三便可以开动他的奔驰汽车了。但是某一天,张三一激动又买了一辆法拉利,我们拿上述的程序,张三无法开动他的新车了。现在一个拥有C级驾照的人却开动不了自己的新车,张三可定会急的哇哇叫。想要让他开动他的法拉利,还需要做如下设计。
package com.warningrc.test.solid;
/**
* 法拉利汽车类.
*
*/
public class Ferrari {
public void run() {
System.out.println("法拉利启动了");
}
}
package com.warningrc.test.solid;
/**
* 汽车司机.
*
*/
public class Driver {
private String name;
public Driver(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void drive(Benz benz) {
System.out.println(name + "开奔驰车");
benz.run();
}
public void drive(Ferrari ferrari) {
System.out.println(name + "开法拉利车");
ferrari.run();
}
}
package com.warningrc.test.solid;
public class Main {
public static void main(String[] args) {
Driver zhangSan = new Driver("张三");
Benz benz = new Benz();
//张三开奔驰
zhangSan.drive(benz);
Ferrari ferrari = new Ferrari();
//张三开法拉利
zhangSan.drive(ferrari);
}
}
这样,张三的确可以开动他的新车了。但是如果土豪张三又买了其他车怎么办呢?难道再从新设计我们的程序么?返回来看我们设计的程序,设计出现了问题:司机类和汽车类之间是一个紧耦合的关系,其导致的结果就是系统的可维护性大大降低,可读性降低,两个相似的类需要两个文件。还有稳定性,什么是稳定性?固化的、健壮的才是稳定的,这里只是增加了一个车类就需要修改司机类,这不是稳定性,这是易变性。(在上述的梅耶开闭原则
中也强调,一个类的实现只应该因错误而修改。)被依赖者的变更竟然让依赖者来承担修改的成本,这样的依赖关系谁肯承担!
“减少并行开发引起的风险”,什么是并行开发的风险?并行开发最大的风险就是风险扩散,本来只是一段程序的错误或异常,逐步波及一个功能,一个模块,甚至到最后毁坏了整个项目,为什么并行开发就有这个风险呢?一个团队,20人开发,各人负责不同的功能模块,甲负责汽车类的建造,乙负责司机类的建造,在甲没有完成的情况下,乙是不能完全地编写代码的,缺少汽车类,编译器根本就不会让你通过!在缺少Benz类的情况下,Driver类能编译吗?更不要说是单元测试了!在这种不使用依赖倒置原则的环境中,所有的开发工作都是“单线程”的,甲做完,乙再做,然后是丙继续…,在现在的大中型项目需要一个团队来完成,一个项目是一个团队的协作结果,要协作就要并行开发,要并行开发就要解决模块之间的项目依赖关系,那然后呢?依赖倒置原则就隆重出场了!
我们重新设计上面的程序:
package com.warningrc.test.solid;
/**
* 汽车接口
*
*/
public interface Car {
/**
* 汽车都可跑
*/
void run();
}
package com.warningrc.test.solid;
/**
* 汽车司机.
*
*/
public class Driver {
private String name;
public Driver(String name) {
this.name = name;
}
public String getName() {
return name;
}
/**
* 司机的职责是开车。
*
*/
public void drive(Car car) {
System.out.println(name + "开车");
car.run();
}
}
package com.warningrc.test.solid;
/**
* 奔驰汽车.
*
*/
public class Benz implements Car {
public void run() {
System.out.println("奔驰启动了");
}
}
package com.warningrc.test.solid;
/**
* 法拉利汽车类.
*
*/
public class Ferrari implements Car {
public void run() {
System.out.println("法拉利启动了");
}
}
package com.warningrc.test.solid;
public class Main {
public static void main(String[] args) {
Driver zhangSan = new Driver("张三");
Car benz = new Benz();
zhangSan.drive(benz);
Car ferrari = new Ferrari();
zhangSan.drive(ferrari);
}
}
这样,无论张三有多土豪,买再多的车,我们的程序都可以满足需求。在增加了更多的汽车类时,我们的Driver类都不需要修改,这样便将变更的风险降低到了最低。
依赖倒转原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合。在开发项目的时候需要遵循一下几个原则:
- 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备。
-
变量的显示类型尽量是接口或者是抽象类。
很多资料说变量的类型一定要是接口或者是抽象类,这个有点绝对化了,比如一个工具类,xxxUtils一般是不需要接口或是抽象类的。还有,如果你要使用类的clone方法,就必须使用实现类,这个是JDK提供一个规范。
-
任何类都不应该从具体类派生。
如果一个项目处于开发状态,确实不应该有从具体类派生出的子类的情况,但这也不是绝对的,因为人都是会犯错误的,有时设计缺陷是在所难免的,因此只要不超过两层的继承都是可以忍受的。特别是做项目维护的同志,基本上可以不考虑这个规则,为什么?维护工作基本上都是做扩展开发,修复行为,通过一个继承关系,覆写一个方法就可以修正一个很大的Bug,何必再要去继承最高的基类呢?
-
尽量不要覆写基类的方法。
如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
- 结合里氏替换原则使用。