面向对象设计的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);

}

开放封闭原则

在面向对象编程领域中,开放封闭原则规定软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的,这意味着一个实体是允许在不改变它的源代码的前提下变更它的行为。该特性在产品化的环境中是特别有价值的,在这种环境中,改变源代码需要代码审查,单元测试以及诸如此类的用以确保产品使用质量的过程。遵循这种原则的代码在扩展时并不发生改变,因此无需上述的过程。

开闭原则的命名被应用在两种方式上。这两种方式都使用了继承来解决明显的困境,但是它们的目的,技术以及结果是不同的。

里氏替换原则

继承必须确保超类所拥有的性质在子类中仍然成立。也就是说当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有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的可见性大于protectedListEOFException分别是CollectionIOException的子类

接口分离原则

接口分离原则指在设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。即,一个类要给多个客户使用,那么可以为每个客户创建一个接口,然后这个类实现所有的接口;而不要只创建一个接口,其中包含所有客户类需要的方法,然后这个类实现这个接口。

不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口总要好。

图一中设计就是没有使用接口分离原则,如果客户端A需要改变所使用的Service接口中的方法,那么不但要改动Service接口和ServiceImp类,还需要修改客户端B和客户端C。也就是说,对客户端A的修改会影响客户端B和客户端C,因此应对其进行修改。

图二采用的是接口分离原则,对每个客户类都有一个专用的接口,这个接口中只声明了与这个客户类相关的方法,而ServiceImp类实现了所有的接口。如果客户端A要改变它所使用的接口中的方法,只需改动ServiceA接口和ServiceImpl类即可,客户端B和客户端C类不受影响。

在Spring中,一个通过类路径下的XML文件启动实例的类org.springframework.context.support.ClassPathXmlApplicationContext提供了如下功能:

  1. IoC容器的核心功能,提供访问IoC容器的基本操作。
  2. 分层的BeanFactory容器结构(父级BeanFactory)。
  3. 提供对Spring Bean的遍历操作。
  4. 提供国际化信息资源处理操作。

上述四个功能完全可以定义在一个接口或抽象类中,然后交给客户使用,但是有些客户上述功能中的一种,其他三种功能他们从不关心。最好的方案就是将上述四种功能分别定义在四个接口中。

  1. 接口org.springframework.beans.factory.BeanFactory提供IoC容器的核心功能,提供访问IoC容器的基本操作。
  2. 接口org.springframework.beans.factory.HierarchicalBeanFactory提供分层的BeanFactory容器结构。
  3. 接口org.springframework.beans.factory.ListableBeanFactory提供对Spring Bean的遍历操作。
  4. 接口org.springframework.context.MessageSource提供国际化信息资源处理操作。

ClassPathXmlApplicationContext实现了上述四个接口,这样当客户有某一种需求时,将ClassPathXmlApplicationContext对象向上转型为指定功能接口,然后提供给客户使用,而客户不会看到对象提供的其他功能。如果对上述某一个功能做改动时,只需要修改相应的接口和ClassPathXmlApplicationContext类中指定的功能即可。

通过上述的设计,ClassPathXmlApplicationContext功能结构也能够一目了然。

依赖倒置原则

在面向对象编程领域中,依赖倒置原则(Dependency inversion principle)指代了一种特定的解耦(传统的依赖关系建立在高层次上,而具体的策略设置则应用在低层次的模块上)形式。在这种形势下,为了使得高层次的模块不依赖于低层次的模块的实现细节的目的,依赖模块被颠倒了(例如:反转)。该原则规定:

  1. 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
  2. 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。

该原则颠倒了一部分人对于面向对象设计的认识方式,比如高层次和低层次对象都应该应该依赖于相同的抽象接口。

在传统的应用架构中,低层次的组件设计用于被高层次的组件使用,这一点提供了逐步的构建一个复杂系统的可能。在这种结构下,高层次的组件直接依赖于低层次的组件去实现一些任务。这种对于低层次组件的依赖限制了重用高层次组件的可行性。

依赖反转原则的目的在把高层次组件从低层次组件中解耦出来,这样使得重用不同的低层组件实现变得可能。把高层组件和低层组件划分到不同的包/库(在这些包/库中拥有定义了高层组件所必须的行为和服务的接口,并且存在高层组件的包)中的方式促进了这种解耦。由低层组件对于高层组件接口的具体实现要求低层组件包的编译是依赖于高层组件的,因此颠倒了传统的依赖关系。众多的设计模式,比如插件,服务定位器或者依赖反转,则被用来在运行时把指定的低层组件实现提供给高层组件。

应用依赖反转原则同样被认为是应用了适配器模式,例如:高层的类定义了它自己的适配器接口(高层类所依赖的抽象接都)。被适配的对象同样依赖于适配器接口的抽象(这是当然的,因为它实现了这个接口),同时它的实现则可以使用它自身所在低层模块的代码。通过这种方式,高层组件则不依赖于低层组件,因为它(高层组件)仅间接的通过调用配置器接口多态方法的方式使用了低层组件通过适配器接口,在这些多态方法则是由被适配对象以及它的低层模块所实现的。

假如有如下场景:张三家里拥有一辆奔驰骑车,他拥有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类都不需要修改,这样便将变更的风险降低到了最低。

依赖倒转原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合。在开发项目的时候需要遵循一下几个原则: