一:单一职责原则
单一职责原则告诉我们:一个类不能做太多的东西。在软件系统中,一个类(一个模块、或者一个方法)承担的职责越多,那么其被复用的可能性就会越低。一个很典型的例子就是万能类。其实可以说一句大实话:任何一个常规的MVC项目,在极端的情况下,可以用一个类(甚至一个方法)完成所有的功能。但是这样做就会严重耦合,甚至牵一发动全身。一个类承(一个模块、或者一个方法)担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
在一个项目系统代码编写的时候,由于历史原因和人为的不规范,导致项目没有分层,一个Service类的伪代码是这样的:
public class Service {
public UserDTO findUser(String name){
Connection connection = getConnection();
PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM t_user WHERE name = ?");
preparedStatement.setObject(1, name);
User user = //处理结果
UserDTO dto = new UserDTO();
//entity值拷贝到dto
return dto;
}
}
这里出现一个问题,Service做了太多东西,包括数据库连接的管理,Sql的执行这些业务层不应该接触到的逻辑,更可怕的是,例如到时候如果数据库换成了Oracle,这个方法将会大改。因此,拆分出新的DataBaseUtils类用于专门管理数据库资源,Dao类用于专门执行查询和查询结果封装,改造后Service类的伪代码如下:
public class Service {
private Dao dao;
public UserDTO findUser(String name){
User user = dao.findUserByName(name);
UserDTO dto = new UserDTO();
//entity值拷贝到dto
return dto;
}
}
public class Dao{
public User findUserByName(String name){
Connection connection = DataBaseUtils.getConnnection();
PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM t_user WHERE name = ?");
preparedStatement.setObject(1, name);
User user = //处理结果
return user;
}
}
现在,如果有查询封装的变动只需要修改Dao类,数据库相关变动只需要修改DataBaseUtils类,每个类的职责分明。这个时候,如果我们要把底层的存储结构缓成Redis或者MongoDB怎么办,这样显然要重建整个Dao类,这种情况下,需要进行接口隔离,下面分析接口隔离原则。
二:接口隔离原则
接口隔离原则(Interface Segregation Principle,ISP)的定义是客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。简单来说就是建立单一的接口,不要建立臃肿庞大的接口。也就是接口尽量细化,同时接口中的方法尽量少。
如何看待接口隔离原则和单一职责原则?
单一职责原则注重的是类和接口的职责单一,这里职责是从业务逻辑上划分的,但是在接口隔离原则要求当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。也就是说,我们在设计接口的时候有可能满足单一职责原则但是不满足接口隔离原则。
接口隔离原则的规范:
1: 使用接口隔离原则前首先需要满足单一职责原则。 2:接口需要高内聚,也就是提高接口、类、模块的处理能力,少对外发布public的方法。 3:定制服务,就是单独为一个个体提供优良的服务,简单来说就是拆分接口,对特定接口进行定制。
package com.yrl.design_principles;
public class Segreegation1 {
public static void main(String[] args) {
A a = new A();
a.depend1(new B());
a.depend2(new B());
a.depend3(new B());
D d = new D();
d.depend1(new C());
d.depend4(new C());
d.depend5(new C());
}
}
interface Interface1{
void operstion1();
}
interface Interface2{
void operstion2();
void operstion3();
}
interface Interface3{
void operstion4();
void operstion5();
}
class B implements Interface1,Interface2{
@Override
public void operstion1() {
System.out.println("B>>>>>operstion1");
}
@Override
public void operstion2() {
System.out.println("B>>>>>operstion2");
}
@Override
public void operstion3() {
System.out.println("B>>>>>operstion3");
}
}
class C implements Interface1,Interface3{
@Override
public void operstion1() {
System.out.println("C>>>>>operstion1");
}
@Override
public void operstion4() {
System.out.println("C>>>>>operstion4");
}
@Override
public void operstion5() {
System.out.println("C>>>>>operstion5");
}
}
class A{
public void depend1(Interface1 i){
i.operstion1();
}
public void depend2(Interface2 i){
i.operstion2();
}
public void depend3(Interface2 i){
i.operstion3();
}
}
class D{
public void depend1(Interface1 i){
i.operstion1();
}
public void depend4(Interface3 i){
i.operstion4();
}
public void depend5(Interface3 i){
i.operstion5();
}
}
三:依赖倒转原则
依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。
依赖倒转原则的注意事项:
1:高层模块不应该依赖低层模块,高层模块和低层模块都应该依赖于抽象。 2:抽象不应该依赖于具体,具体应该依赖于抽象。
public interface Driver {
void drive();
void setCar(Car car);
}
public interface Car {
void run();
}
public class DefaultDriver implements Driver {
private Car car;
@Override
public void drive() {
car.run();
}
@Override
public void setCar(Car car) {
this.car = car;
}
}
public class Bmw implements Car {
@Override
public void run() {
System.out.println("Bmw runs...");
}
}
public class Benz implements Car {
@Override
public void run() {
System.out.println("Benz runs...");
}
}
public class App {
public static void main(String[] args) throws Exception {
Driver driver = new DefaultDriver();
Car car = new Benz();
driver.setCar(car);
driver.drive();
car = new Bmw();
driver.setCar(car);
driver.drive();
}
}
从Java角度看待依赖倒转原则的本质就是:面向接口(抽象)编程。
- 每个具体的类都应该有其接口或者基类,或者两者都具备。
- 类中的引用对象应该是接口或者基类。
- 任何具体类都不应该派生出子类。
- 尽量不要覆写基类中的方法。
- 继承遵循里氏代换原则使用。
四:里氏替换原则
里氏代换原则(Liskov Substitution Principle,LSP)的定义是:所有引用基类的地方必须能透明地使用其子类的对象,也可以简单理解为任何基类可以出现的地方,子类一定可以出现。
里氏代换原则的意义:
只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对"开-闭"原则的补充。实现"开-闭"原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。当然,如果反过来,软件单位使用的是一个子类对象的话,那么它不一定能够使用基类对象。举个很简单的例子说明这个问题:如果一个方法接收Map类型参数,那么它一定可以接收Map的子类参数例如HashMap、LinkedHashMap、ConcurrentHashMap类型的参数;但是返过来,如果另一个方法只接收HashMap类型的参数,那么它一定不能接收所有Map类型的参数,否则它可以接收LinkedHashMap、ConcurrentHashMap类型的参数。
子类为什么可以替换基类的位置?
其实原因很简单,只要存在继承关系,基类的所有非私有属性或者方法,子类都可以通过继承获得(白箱复用),反过来不成立,因为子类很有可能扩充自身的非私有属性或者方法,这个时候不能用基类获取子类新增的这些属性或者方法。
里氏代换原则是实现开闭原则的基础,它告诉我们在设计程序的时候进可能使用基类进行对象的定义和引用,在运行时再决定基类的具体子类型。
public abstract class Animal {
protected abstract void breathe();
}
public class Bird extends Animal {
@Override
public void breathe() {
System.out.println("Bird breathes...");
}
}
public class Pig extends Animal {
@Override
public void breathe() {
System.out.println("Pig breathes...");
}
}
public class App {
public static void main(String[] args) throws Exception {
Animal bird = new Bird();
bird.breathe();
Animal pig = new Pig();
pig.breathe();
}
}
五:开闭原则
开闭原则(Open Closed Principle,OCP)的定义是:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。模块应尽量在不修改原(是"原",指原来的代码)代码的情况下进行扩展。
开闭原则的意义:
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
如何实现对扩展开放,对修改关闭?
要实现对扩展开放,对修改关闭,即遵循开闭原则,需要对系统进行抽象化设计,抽象可以基于抽象类或者接口。一般来说需要做到几点:
- 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法,也就是扩展必须添加具体实现而不是改变具体的方法。
- 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类,这样就能尽量保证抽象层是稳定的。
- 一般抽象模块设计完成(例如接口的方法已经敲定),不允许修改接口或者抽象方法的定义。
下面通过一个例子遵循开闭原则进行设计,场景是这样:某系统的后台需要监测业务数据展示图表,如柱状图、折线图等,在未来需要支持图表的着色操作。在开始设计的时候,代码可能是这样的:
public class BarChart {
public void draw(){
System.out.println("Draw bar chart...");
}
}
public class LineChart {
public void draw(){
System.out.println("Draw line chart...");
}
}
public class App {
public void drawChart(String type){
if (type.equalsIgnoreCase("line")){
new LineChart().draw();
}else if (type.equalsIgnoreCase("bar")){
new BarChart().draw();
}
}
}
这样做在初期是能满足业务需要的,开发效率也十分高,但是当后面需要新增一个饼状图的时候,既要添加一个饼状图的类,原来的客户端App类的drawChart
方法也要新增一个if分支,这样做就是修改了原有客户端类库的方法,是十分不合理的。如果这个时候,在图中加入一个颜色属性,复杂性也大大提高。基于此,需要引入一个抽象Chart类AbstractChart,App类在画图的时候总是把相关的操作委托到具体的AbstractChart的派生类实例,这样的话App类的代码就不用修改:
abstract class AbstractChart {
public abstract void draw();
}
class BarChart extends AbstractChart{
@Override
public void draw() {
System.out.println("Draw bar chart...");
}
}
class LineChart extends AbstractChart {
@Override
public void draw() {
System.out.println("Draw line chart...");
}
}
class App {
public void drawChart(AbstractChart chart){
chart.draw();
}
}
六:迪米特法则
迪米特法则(Law of Demeter,LOD),有时候也叫做最少知识原则(Least Knowledge Principle,LKP),它的定义是:一个软件实体应当尽可能少地与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。迪米特法则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。迪米特法则不希望类之间建立直接的联系。如果真的有需要建立联系,也希望能通过它的友元类(中间类或者跳转类)来转达。
迪米特法则的规则:
- Only talk to your immediate friends(只与直接的朋友通讯),一个对象的"朋友"包括他本身(this)、它持有的成员对象、入参对象、它所创建的对象。
- 尽量少发布public的变量和方法,一旦公开的属性和方法越多,修改的时候影响的范围越大。
- "是自己的就是自己的",如果一个方法放在本类中,既不产生新的类间依赖,也不造成负面的影响,那么次方法就应该放在本类中。
迪米特法则的意义:
迪米特法则的核心观念就是类间解耦,也就降低类之间的耦合,只有类处于弱耦合状态,类的复用率才会提高。所谓降低类间耦合,实际上就是尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。但是这样会引发一个问题,有可能产生大量的中间类或者跳转类,导致系统的复杂性提高,可维护性降低。如果一味追求极度解耦,那么最终有可能变成面向字节码编程甚至是面向二进制的0和1编程。
举个很简单的例子,体育老师要知道班里面女生的人数,他委托体育课代表点清女生的人数:
public class Girl {
}
public class GroupLeader {
private final List<Girl> girls;
public GroupLeader(List<Girl> girls) {
this.girls = girls;
}
public void countGirls() {
System.out.println("The sum of girls is " + girls.size());
}
}
public class Teacher {
public void command(GroupLeader leader){
leader.countGirls();
}
}
public class App {
public static void main(String[] args) throws Exception {
Teacher teacher = new Teacher();
GroupLeader groupLeader = new GroupLeader(Arrays.asList(new Girl(), new Girl()));
teacher.command(groupLeader);
}
}
这个例子中,体育课代表就是中间类,体育课代表对于体育老师来说就是"直接的朋友",如果去掉体育课代表这个中间类,体育老师必须亲自清点女生的人数(实际上就数人数这个功能,体育老师是不必要获取所有女生的对象列表),这样做会违反迪米特法则。
七:合成/聚合复用原则
就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的,该原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分:新的对象通过向这些对象的委派达到复用已有功能的目的。
聚合(Aggregate)的概念:
聚合表示一种弱的"拥有"关系,一般表现为松散的整体和部分的关系,其实,所谓整体和部分也可以是完全不相关的。例如A对象持有B对象,B对象并不是A对象的一部分,也就是B对象的生命周期是B对象自身管理,和A对象不相关。
合成(Composite)的概念:
合成表示一种强的"拥有"关系,一般表现为严格的整体和部分的关系,部分和整体的生命周期是一样的。
为什么要用合成/聚合来替代继承达到复用的目的?
继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,基类的内部细节通常对子类来说是可见的,这种复用也称为"白箱复用"。这里有一个明显的问题是:派生类继承自基类,如果基类的实现发生改变,将会影响到所有派生类的实现;如果从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活。
由于合成或聚合关系可以将已有的对象,一般叫成员对象,纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为"黑箱"复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成/聚合复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
如果有阅读过《Effective Java 2nd》的同学就知道,此书也建议慎用继承。一般情况下,只有明确知道派生类和基类满IS A
的时候才选用继承,当满足HAS A
或者不能判断的情况下应该选用合成/聚合。
下面举个很极端的例子说明一下如果在非IS A
的情况下使用继承会出现什么问题:
先定义一个抽象手,手有一个摇摆的方法,然后定义左右手继承抽象手,实现摇摆方法:
public abstract class AbstractHand {
protected abstract void swing();
}
public class LeftHand extends AbstractHand {
@Override
public void swing() {
System.out.println("Left hand swings...");
}
}
public class RightHand extends AbstractHand {
@Override
public void swing() {
System.out.println("Right hand swings...");
}
}
现在看起来没有任何问题,实现也十分正确,现在出现了人(Person)这个类,具备摇左右手的功能,如果不考虑IS A
的关系,很有可能有人会这样做:
public abstract class AbstractSwingHand extends AbstractHand{
@Override
protected void swing() {
System.out.println(" hand swings...");
}
}
public class Person extends AbstractSwingHand {
public void swingLeftHand(){
System.out.print("Left ");
super.swing();
}
public void swingRightHand(){
System.out.print("Right ");
super.swing();
}
}
上面Person的实现让人觉得百思不得其解,但是往往这会出现在真实的环境中,因为Hand不是Person,所以Person继承Hand一定会出现曲线实现等奇葩逻辑。Hand和Person是严格的部分和整体的关系,或者说Person和Hand是HAS A
的关系,如果使用合成,逻辑将会十分清晰:
public class Person {
private AbstractHand leftHand;
private AbstractHand rightHand;
public Person() {
leftHand = new LeftHand();
rightHand = new RightHand();
}
public void swingLeftHand(){
leftHand.swing();
}
public void swingRightHand(){
rightHand.swing();
}
}